diff options
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> |
