aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorlexafaxine <40200356+lexafaxine@users.noreply.github.com>2025-02-10 00:11:17 +0900
committerGitHub <noreply@github.com>2025-02-09 15:11:17 +0000
commitf9c2557f98baab00245efb9f97461d7b9b7c186a (patch)
tree0ce814795c58d2b7dac14178d880cef4f0cfef67 /apps
parentd6456ebb6adb05eb8b5705bf44dc5ad77948c634 (diff)
downloadkarakeep-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.tsx68
-rw-r--r--apps/web/components/dashboard/tags/BulkTagAction.tsx143
-rw-r--r--apps/web/components/dashboard/tags/MultiTagSelector.tsx61
-rw-r--r--apps/web/lib/bulkTagActions.ts56
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;