aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-09-01 19:15:24 +0000
committerMohamedBassem <me@mbassem.com>2024-09-01 19:15:24 +0000
commit67729c131c92a2fab6d1422db34aa000c348af07 (patch)
treefe3064beb40f40d3a19c5ec200f7afc2b0e1ff9e /apps/web/components/dashboard
parent6d4d1a6e574a99d86c46636ebc8f14a55b07e1b5 (diff)
downloadkarakeep-67729c131c92a2fab6d1422db34aa000c348af07.tar.zst
feature(web): Manage tags in bulk actions
Diffstat (limited to 'apps/web/components/dashboard')
-rw-r--r--apps/web/components/dashboard/BulkBookmarksAction.tsx16
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx48
-rw-r--r--apps/web/components/dashboard/bookmarks/BulkTagModal.tsx126
-rw-r--r--apps/web/components/dashboard/bookmarks/TagModal.tsx4
-rw-r--r--apps/web/components/dashboard/bookmarks/TagsEditor.tsx60
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx4
6 files changed, 214 insertions, 44 deletions
diff --git a/apps/web/components/dashboard/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx
index 383b9a4e..2e6fc75b 100644
--- a/apps/web/components/dashboard/BulkBookmarksAction.tsx
+++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx
@@ -8,7 +8,7 @@ import {
import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
import { useToast } from "@/components/ui/use-toast";
import useBulkActionsStore from "@/lib/bulkActions";
-import { CheckCheck, List, Pencil, Trash2, X } from "lucide-react";
+import { CheckCheck, Hash, List, Pencil, Trash2, X } from "lucide-react";
import {
useDeleteBookmark,
@@ -16,6 +16,7 @@ import {
} from "@hoarder/shared-react/hooks/bookmarks";
import BulkManageListsModal from "./bookmarks/BulkManageListsModal";
+import BulkTagModal from "./bookmarks/BulkTagModal";
import { ArchivedActionIcon, FavouritedActionIcon } from "./bookmarks/icons";
export default function BulkBookmarksAction() {
@@ -26,6 +27,7 @@ export default function BulkBookmarksAction() {
const { toast } = useToast();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [manageListsModal, setManageListsModalOpen] = useState(false);
+ const [bulkTagModal, setBulkTagModalOpen] = useState(false);
useEffect(() => {
setIsBulkEditEnabled(false); // turn off toggle + clear selected bookmarks on mount
@@ -105,6 +107,13 @@ export default function BulkBookmarksAction() {
hidden: !isBulkEditEnabled,
},
{
+ name: "Edit Tags",
+ icon: <Hash size={18} />,
+ action: () => setBulkTagModalOpen(true),
+ isPending: false,
+ hidden: !isBulkEditEnabled,
+ },
+ {
name: alreadyFavourited ? "Unfavourite" : "Favourite",
icon: <FavouritedActionIcon favourited={!!alreadyFavourited} size={18} />,
action: () => updateBookmarks({ favourited: !alreadyFavourited }),
@@ -163,6 +172,11 @@ export default function BulkBookmarksAction() {
open={manageListsModal}
setOpen={setManageListsModalOpen}
/>
+ <BulkTagModal
+ bookmarkIds={selectedBookmarks.map((b) => b.id)}
+ open={bulkTagModal}
+ setOpen={setBulkTagModalOpen}
+ />
<div className="flex items-center">
{isBulkEditEnabled && (
<p className="flex items-center gap-2">
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx
new file mode 100644
index 00000000..d6d60d22
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx
@@ -0,0 +1,48 @@
+import { toast } from "@/components/ui/use-toast";
+
+import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
+import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";
+
+import { TagsEditor } from "./TagsEditor";
+
+export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) {
+ const { mutate } = useUpdateBookmarkTags({
+ onSuccess: () => {
+ toast({
+ description: "Tags has been updated!",
+ });
+ },
+ onError: () => {
+ toast({
+ variant: "destructive",
+ title: "Something went wrong",
+ description: "There was a problem with your request.",
+ });
+ },
+ });
+
+ return (
+ <TagsEditor
+ tags={bookmark.tags}
+ onAttach={({ tagName, tagId }) => {
+ mutate({
+ bookmarkId: bookmark.id,
+ attach: [
+ {
+ tagName,
+ tagId,
+ },
+ ],
+ detach: [],
+ });
+ }}
+ onDetach={({ tagId }) => {
+ mutate({
+ bookmarkId: bookmark.id,
+ attach: [],
+ detach: [{ tagId }],
+ });
+ }}
+ />
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx
new file mode 100644
index 00000000..3c8e75e7
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx
@@ -0,0 +1,126 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { toast } from "@/components/ui/use-toast";
+
+import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";
+import { api } from "@hoarder/shared-react/trpc";
+import { ZBookmark } from "@hoarder/shared/types/bookmarks";
+
+import { TagsEditor } from "./TagsEditor";
+
+export default function BulkTagModal({
+ bookmarkIds,
+ open,
+ setOpen,
+}: {
+ bookmarkIds: string[];
+ open: boolean;
+ setOpen: (open: boolean) => void;
+}) {
+ const results = api.useQueries((t) =>
+ bookmarkIds.map((id) => t.bookmarks.getBookmark({ bookmarkId: id })),
+ );
+
+ const bookmarks = results
+ .map((r) => r.data)
+ .filter((b): b is ZBookmark => !!b);
+
+ const { mutateAsync } = useUpdateBookmarkTags({
+ onError: (err) => {
+ if (err.data?.code == "BAD_REQUEST") {
+ if (err.data.zodError) {
+ toast({
+ variant: "destructive",
+ description: Object.values(err.data.zodError.fieldErrors)
+ .flat()
+ .join("\n"),
+ });
+ } else {
+ toast({
+ variant: "destructive",
+ description: err.message,
+ });
+ }
+ } else {
+ toast({
+ variant: "destructive",
+ title: "Something went wrong",
+ });
+ }
+ },
+ });
+
+ const onAttach = async (tag: { tagName: string; tagId?: string }) => {
+ const results = await Promise.allSettled(
+ bookmarkIds.map((id) =>
+ mutateAsync({
+ bookmarkId: id,
+ attach: [tag],
+ detach: [],
+ }),
+ ),
+ );
+ const successes = results.filter((r) => r.status == "fulfilled").length;
+ toast({
+ description: `Tag "${tag.tagName}" has been added to ${successes} bookmarks!`,
+ });
+ };
+
+ const onDetach = async ({
+ tagId,
+ tagName,
+ }: {
+ tagId: string;
+ tagName: string;
+ }) => {
+ const results = await Promise.allSettled(
+ bookmarkIds.map((id) =>
+ mutateAsync({
+ bookmarkId: id,
+ attach: [],
+ detach: [{ tagId }],
+ }),
+ ),
+ );
+ const successes = results.filter((r) => r.status == "fulfilled").length;
+ toast({
+ description: `Tag "${tagName}" has been removed from ${successes} bookmarks!`,
+ });
+ };
+
+ // Get all the tags that are attached to all the bookmarks
+ let tags = bookmarks
+ .flatMap((b) => b.tags)
+ .filter((tag) =>
+ bookmarks.every((b) => b.tags.some((t) => tag.id == t.id)),
+ );
+ // Filter duplicates
+ tags = tags.filter(
+ (tag, index, self) => index === self.findIndex((t) => t.id == tag.id),
+ );
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Edit Tags of {bookmarks.length} Bookmarks</DialogTitle>
+ </DialogHeader>
+ <TagsEditor tags={tags} onAttach={onAttach} onDetach={onDetach} />
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/TagModal.tsx b/apps/web/components/dashboard/bookmarks/TagModal.tsx
index 00cc40fc..c2f081be 100644
--- a/apps/web/components/dashboard/bookmarks/TagModal.tsx
+++ b/apps/web/components/dashboard/bookmarks/TagModal.tsx
@@ -11,7 +11,7 @@ import {
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
-import { TagsEditor } from "./TagsEditor";
+import { BookmarkTagsEditor } from "./BookmarkTagsEditor";
export default function TagModal({
bookmark,
@@ -28,7 +28,7 @@ export default function TagModal({
<DialogHeader>
<DialogTitle>Edit Tags</DialogTitle>
</DialogHeader>
- <TagsEditor bookmark={bookmark} />
+ <BookmarkTagsEditor bookmark={bookmark} />
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
index 06d89aff..d50acc60 100644
--- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
+++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
@@ -1,14 +1,14 @@
import type { ActionMeta } from "react-select";
-import { toast } from "@/components/ui/use-toast";
import { useClientConfig } from "@/lib/clientConfig";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import CreateableSelect from "react-select/creatable";
-import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
-import type { ZAttachedByEnum } from "@hoarder/shared/types/tags";
-import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";
+import type {
+ ZAttachedByEnum,
+ ZBookmarkTags,
+} from "@hoarder/shared/types/tags";
interface EditableTag {
attachedBy: ZAttachedByEnum;
@@ -16,24 +16,17 @@ interface EditableTag {
label: string;
}
-export function TagsEditor({ bookmark }: { bookmark: ZBookmark }) {
+export function TagsEditor({
+ tags,
+ onAttach,
+ onDetach,
+}: {
+ tags: ZBookmarkTags[];
+ onAttach: (tag: { tagName: string; tagId?: string }) => void;
+ onDetach: (tag: { tagName: string; tagId: string }) => void;
+}) {
const demoMode = !!useClientConfig().demoMode;
- const { mutate } = useUpdateBookmarkTags({
- onSuccess: () => {
- toast({
- description: "Tags has been updated!",
- });
- },
- onError: () => {
- toast({
- variant: "destructive",
- title: "Something went wrong",
- description: "There was a problem with your request.",
- });
- },
- });
-
const { data: existingTags, isLoading: isExistingTagsLoading } =
api.tags.list.useQuery();
@@ -47,33 +40,22 @@ export function TagsEditor({ bookmark }: { bookmark: ZBookmark }) {
case "pop-value":
case "remove-value": {
if (actionMeta.removedValue.value) {
- mutate({
- bookmarkId: bookmark.id,
- attach: [],
- detach: [{ tagId: actionMeta.removedValue.value }],
+ onDetach({
+ tagId: actionMeta.removedValue.value,
+ tagName: actionMeta.removedValue.label,
});
}
break;
}
case "create-option": {
- mutate({
- bookmarkId: bookmark.id,
- attach: [{ tagName: actionMeta.option.label }],
- detach: [],
- });
+ onAttach({ tagName: actionMeta.option.label });
break;
}
case "select-option": {
if (actionMeta.option) {
- mutate({
- bookmarkId: bookmark.id,
- attach: [
- {
- tagName: actionMeta.option.label,
- tagId: actionMeta.option?.value,
- },
- ],
- detach: [],
+ onAttach({
+ tagName: actionMeta.option.label,
+ tagId: actionMeta.option?.value,
});
}
break;
@@ -92,7 +74,7 @@ export function TagsEditor({ bookmark }: { bookmark: ZBookmark }) {
attachedBy: "human" as const,
})) ?? []
}
- value={bookmark.tags.map((t) => ({
+ value={tags.map((t) => ({
label: t.name,
value: t.id,
attachedBy: t.attachedBy,
diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
index 01e57e05..13d3c9d8 100644
--- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
-import { TagsEditor } from "@/components/dashboard/bookmarks/TagsEditor";
+import { BookmarkTagsEditor } from "@/components/dashboard/bookmarks/BookmarkTagsEditor";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
@@ -147,7 +147,7 @@ export default function BookmarkPreview({
<CreationTime createdAt={bookmark.createdAt} />
<div className="flex items-center gap-4">
<p className="text-sm text-gray-400">Tags</p>
- <TagsEditor bookmark={bookmark} />
+ <BookmarkTagsEditor bookmark={bookmark} />
</div>
<div className="flex gap-4">
<p className="pt-2 text-sm text-gray-400">Note</p>