diff options
| author | lexafaxine <40200356+lexafaxine@users.noreply.github.com> | 2025-02-10 00:11:17 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-02-09 15:11:17 +0000 |
| commit | f9c2557f98baab00245efb9f97461d7b9b7c186a (patch) | |
| tree | 0ce814795c58d2b7dac14178d880cef4f0cfef67 /apps | |
| parent | d6456ebb6adb05eb8b5705bf44dc5ad77948c634 (diff) | |
| download | karakeep-f9c2557f98baab00245efb9f97461d7b9b7c186a.tar.zst | |
feat(web): bulk tag deletion. Fixes #872 (#900)
* feat(web): #872 bulk tag deletion
* revert lock file change
* change bulk tag action type def and fix small issue
* fix prettier
* minor fixes
---------
Co-authored-by: Mohamed Bassem <me@mbassem.com>
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/components/dashboard/tags/AllTagsView.tsx | 68 | ||||
| -rw-r--r-- | apps/web/components/dashboard/tags/BulkTagAction.tsx | 143 | ||||
| -rw-r--r-- | apps/web/components/dashboard/tags/MultiTagSelector.tsx | 61 | ||||
| -rw-r--r-- | apps/web/lib/bulkTagActions.ts | 56 |
4 files changed, 305 insertions, 23 deletions
diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx index 6b10d800..d8471cf5 100644 --- a/apps/web/components/dashboard/tags/AllTagsView.tsx +++ b/apps/web/components/dashboard/tags/AllTagsView.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useEffect } from "react"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; @@ -13,14 +13,17 @@ import InfoTooltip from "@/components/ui/info-tooltip"; import { Separator } from "@/components/ui/separator"; import { Toggle } from "@/components/ui/toggle"; import { toast } from "@/components/ui/use-toast"; +import useBulkTagActionsStore from "@/lib/bulkTagActions"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { ArrowDownAZ, Combine } from "lucide-react"; -import type { ZGetTagResponse } from "@hoarder/shared/types/tags"; +import type { ZGetTagResponse, ZTagBasic } from "@hoarder/shared/types/tags"; import { useDeleteUnusedTags } from "@hoarder/shared-react/hooks/tags"; +import BulkTagAction from "./BulkTagAction"; import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog"; +import { MultiTagSelector } from "./MultiTagSelector"; import { TagPill } from "./TagPill"; function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) { @@ -75,18 +78,15 @@ export default function AllTagsView({ initialData: ZGetTagResponse[]; }) { const { t } = useTranslation(); - interface Tag { - id: string; - name: string; - } - const [draggingEnabled, setDraggingEnabled] = React.useState(false); const [sortByName, setSortByName] = React.useState(false); const [isDialogOpen, setIsDialogOpen] = React.useState(false); - const [selectedTag, setSelectedTag] = React.useState<Tag | null>(null); + const [selectedTag, setSelectedTag] = React.useState<ZTagBasic | null>(null); + + const { setVisibleTagIds, isBulkEditEnabled } = useBulkTagActionsStore(); - const handleOpenDialog = (tag: Tag) => { + const handleOpenDialog = (tag: ZTagBasic) => { setSelectedTag(tag); setIsDialogOpen(true); }; @@ -102,6 +102,15 @@ export default function AllTagsView({ const { data } = api.tags.list.useQuery(undefined, { initialData: { tags: initialData }, }); + + useEffect(() => { + const visibleTagIds = data.tags.map((tag) => tag.id); + setVisibleTagIds(visibleTagIds); + return () => { + setVisibleTagIds([]); + }; + }, [data.tags]); + // Sort tags by usage desc const allTags = data.tags.sort(sortByName ? byNameSorter : byUsageSorter); @@ -115,21 +124,30 @@ export default function AllTagsView({ ); const emptyTags = allTags.filter((t) => t.numBookmarks === 0); - const tagsToPill = (tags: typeof allTags) => { + const tagsToPill = (tags: typeof allTags, bulkEditEnabled: boolean) => { let tagPill; if (tags.length) { tagPill = ( <div className="flex flex-wrap gap-3"> - {tags.map((t) => ( - <TagPill - key={t.id} - id={t.id} - name={t.name} - count={t.numBookmarks} - isDraggable={draggingEnabled} - onOpenDialog={handleOpenDialog} - /> - ))} + {tags.map((t) => + bulkEditEnabled ? ( + <MultiTagSelector + key={t.id} + id={t.id} + name={t.name} + count={t.numBookmarks} + /> + ) : ( + <TagPill + key={t.id} + id={t.id} + name={t.name} + count={t.numBookmarks} + isDraggable={draggingEnabled} + onOpenDialog={handleOpenDialog} + /> + ), + )} </div> ); } else { @@ -152,11 +170,13 @@ export default function AllTagsView({ /> )} <div className="flex justify-end gap-x-2"> + <BulkTagAction /> <Toggle variant="outline" aria-label="Toggle bold" pressed={draggingEnabled} onPressedChange={toggleDraggingEnabled} + disabled={isBulkEditEnabled} > <Combine className="mr-2 size-4" /> {t("tags.drag_and_drop_merging")} @@ -179,7 +199,7 @@ export default function AllTagsView({ <p>{t("tags.your_tags_info")}</p> </InfoTooltip> </span> - {tagsToPill(humanTags)} + {tagsToPill(humanTags, isBulkEditEnabled)} <Separator /> <span className="flex items-center gap-2"> <p className="text-lg">{t("tags.ai_tags")}</p> @@ -187,7 +207,7 @@ export default function AllTagsView({ <p>{t("tags.ai_tags_info")}</p> </InfoTooltip> </span> - {tagsToPill(aiTags)} + {tagsToPill(aiTags, isBulkEditEnabled)} <Separator /> <span className="flex items-center gap-2"> <p className="text-lg">{t("tags.unused_tags")}</p> @@ -208,7 +228,9 @@ export default function AllTagsView({ <DeleteAllUnusedTags numUnusedTags={emptyTags.length} /> )} </div> - <CollapsibleContent>{tagsToPill(emptyTags)}</CollapsibleContent> + <CollapsibleContent> + {tagsToPill(emptyTags, isBulkEditEnabled)} + </CollapsibleContent> </Collapsible> </> ); diff --git a/apps/web/components/dashboard/tags/BulkTagAction.tsx b/apps/web/components/dashboard/tags/BulkTagAction.tsx new file mode 100644 index 00000000..c559b9cf --- /dev/null +++ b/apps/web/components/dashboard/tags/BulkTagAction.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { ButtonWithTooltip } from "@/components/ui/button"; +import { Toggle } from "@/components/ui/toggle"; +import { useToast } from "@/components/ui/use-toast"; +import useBulkTagActionsStore from "@/lib/bulkTagActions"; +import { useTranslation } from "@/lib/i18n/client"; +import { CheckCheck, Pencil, Trash2, X } from "lucide-react"; + +import { useDeleteTag } from "@hoarder/shared-react/hooks/tags"; +import { limitConcurrency } from "@hoarder/shared/concurrency"; + +const MAX_CONCURRENT_BULK_ACTIONS = 50; + +export default function BulkTagAction() { + const { t } = useTranslation(); + const { toast } = useToast(); + + const { + selectedTagIds, + isBulkEditEnabled, + selectAll: selectAllTags, + unSelectAll: unSelectAllTags, + isEverythingSelected, + setIsBulkEditEnabled, + } = useBulkTagActionsStore(); + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + useEffect(() => { + return () => { + setIsBulkEditEnabled(false); + }; + }, []); + + const onError = () => { + toast({ + variant: "destructive", + title: t("common.something_went_wrong"), + description: "There was a problem with your request.", + }); + }; + + const deleteTagMutator = useDeleteTag({ + onSuccess: () => { + setIsBulkEditEnabled(false); + }, + onError, + }); + + const deleteSelectedTags = async () => { + await Promise.all( + limitConcurrency( + selectedTagIds.map( + (item) => () => deleteTagMutator.mutateAsync({ tagId: item }), + ), + MAX_CONCURRENT_BULK_ACTIONS, + ), + ); + toast({ + description: `${selectedTagIds.length} tags have been deleted!`, + }); + setIsDeleteDialogOpen(false); + }; + + const actionList = [ + { + name: isEverythingSelected() + ? t("actions.unselect_all") + : t("actions.select_all"), + icon: ( + <p className="flex items-center gap-2"> + ( <CheckCheck size={18} /> {selectedTagIds.length} ) + </p> + ), + action: () => + isEverythingSelected() ? unSelectAllTags() : selectAllTags(), + alwaysEnable: true, + }, + { + name: t("actions.delete"), + icon: <Trash2 size={18} color="red" />, + action: () => setIsDeleteDialogOpen(true), + }, + { + name: t("actions.close_bulk_edit"), + icon: <X size={18} />, + action: () => setIsBulkEditEnabled(false), + alwaysEnable: true, + }, + ]; + + return ( + <div> + <ActionConfirmingDialog + open={isDeleteDialogOpen} + setOpen={setIsDeleteDialogOpen} + title={"Delete Tags"} + description={<p>Are you sure you want to delete these tags?</p>} + actionButton={() => ( + <ActionButton + type="button" + variant="destructive" + loading={deleteTagMutator.isPending} + onClick={() => deleteSelectedTags()} + > + {t("actions.delete")} + </ActionButton> + )} + /> + + {!isBulkEditEnabled ? ( + <Toggle + variant="outline" + aria-label="Toggle bulk edit" + pressed={isBulkEditEnabled} + onPressedChange={setIsBulkEditEnabled} + > + <Pencil className="mr-2 size-4" /> + {t("actions.bulk_edit")} + </Toggle> + ) : ( + <div className="flex items-center"> + {actionList.map(({ name, icon, action, alwaysEnable }) => ( + <ButtonWithTooltip + tooltip={name} + disabled={!selectedTagIds.length && !alwaysEnable} + delayDuration={100} + variant="ghost" + key={name} + onClick={action} + > + {icon} + </ButtonWithTooltip> + ))} + </div> + )} + </div> + ); +} diff --git a/apps/web/components/dashboard/tags/MultiTagSelector.tsx b/apps/web/components/dashboard/tags/MultiTagSelector.tsx new file mode 100644 index 00000000..096c4566 --- /dev/null +++ b/apps/web/components/dashboard/tags/MultiTagSelector.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { Separator } from "@/components/ui/separator"; +import useBulkTagActionsStore from "@/lib/bulkTagActions"; +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; +import { useTheme } from "next-themes"; + +export function MultiTagSelector({ + id, + name, + count, +}: { + id: string; + name: string; + count: number; +}) { + const toggleTag = useBulkTagActionsStore((state) => state.toggleTag); + const { theme } = useTheme(); + const isSelected = useBulkTagActionsStore((state) => state.isTagSelected(id)); + + const getIconColor = () => { + if (theme === "dark") { + return isSelected ? "black" : "white"; + } + return isSelected ? "white" : "black"; + }; + + const getIconBackgroundColor = () => { + if (theme === "dark") { + return isSelected ? "bg-white" : "bg-white bg-opacity-10"; + } + return isSelected ? "bg-black" : "bg-white"; + }; + + const pill = ( + <div className="group relative flex"> + <button + className={cn( + "flex gap-2 rounded-md border border-border px-2 py-1", + isSelected + ? "bg-black bg-opacity-10" + : "bg-background text-foreground hover:bg-foreground hover:text-background", + )} + data-id={id} + onClick={() => toggleTag(id)} + > + {name} <Separator orientation="vertical" /> {count} + <div + className={cn( + "absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full border border-gray-600", + getIconBackgroundColor(), + )} + > + <Check size={12} color={getIconColor()} /> + </div> + </button> + </div> + ); + + return pill; +} diff --git a/apps/web/lib/bulkTagActions.ts b/apps/web/lib/bulkTagActions.ts new file mode 100644 index 00000000..aa49f4f1 --- /dev/null +++ b/apps/web/lib/bulkTagActions.ts @@ -0,0 +1,56 @@ +import { create } from "zustand"; + +interface TagState { + selectedTagIds: string[]; + visibleTagIds: string[]; + isBulkEditEnabled: boolean; + setIsBulkEditEnabled: (isEnabled: boolean) => void; + toggleTag: (tagId: string) => void; + setVisibleTagIds: (visibleTagIds: string[]) => void; + selectAll: () => void; + unSelectAll: () => void; + isEverythingSelected: () => boolean; + isTagSelected: (tagId: string) => boolean; +} + +const useBulkTagActionsStore = create<TagState>((set, get) => ({ + selectedTagIds: [], + visibleTagIds: [], + isBulkEditEnabled: false, + + toggleTag: (tagId: string) => { + const selectedTagIds = get().selectedTagIds; + set({ + selectedTagIds: selectedTagIds.includes(tagId) + ? selectedTagIds.filter((id) => id !== tagId) + : [...selectedTagIds, tagId], + }); + }, + + selectAll: () => { + set({ selectedTagIds: get().visibleTagIds }); + }, + unSelectAll: () => { + set({ selectedTagIds: [] }); + }, + + isEverythingSelected: () => { + return get().selectedTagIds.length === get().visibleTagIds.length; + }, + + setIsBulkEditEnabled: (isEnabled) => { + set({ + isBulkEditEnabled: isEnabled, + selectedTagIds: [], + }); + }, + + setVisibleTagIds: (visibleTagIds: string[]) => { + set({ visibleTagIds }); + }, + isTagSelected: (tagId: string) => { + return get().selectedTagIds.includes(tagId); + }, +})); + +export default useBulkTagActionsStore; |
