diff options
Diffstat (limited to 'apps/web/components/dashboard/bookmarks')
20 files changed, 630 insertions, 148 deletions
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx index 595a9e00..b120e0b1 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx @@ -1,5 +1,6 @@ -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { getBookmarkRefreshInterval } from "@karakeep/shared/utils/bookmarkUtils"; @@ -15,20 +16,23 @@ export default function BookmarkCard({ bookmark: ZBookmark; className?: string; }) { - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId: initialData.id, - }, - { - initialData, - refetchInterval: (query) => { - const data = query.state.data; - if (!data) { - return false; - } - return getBookmarkRefreshInterval(data); + const api = useTRPC(); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId: initialData.id, }, - }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + return getBookmarkRefreshInterval(data); + }, + }, + ), ); switch (bookmark.content.type) { diff --git a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx index a3e5d3b3..7c254336 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx @@ -1,8 +1,8 @@ -import dayjs from "dayjs"; +import { format, isAfter, subYears } from "date-fns"; export default function BookmarkFormattedCreatedAt(prop: { createdAt: Date }) { - const createdAt = dayjs(prop.createdAt); - const oneYearAgo = dayjs().subtract(1, "year"); - const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY"; - return createdAt.format(formatString); + const createdAt = prop.createdAt; + const oneYearAgo = subYears(new Date(), 1); + const formatString = isAfter(createdAt, oneYearAgo) ? "MMM d" : "MMM d, yyyy"; + return format(createdAt, formatString); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index e8520b1a..f164b275 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -2,9 +2,11 @@ import type { BookmarksLayoutTypes } from "@/lib/userLocalSettings/types"; import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Image from "next/image"; import Link from "next/link"; +import { useSession } from "@/lib/auth/client"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useBulkActionsStore from "@/lib/bulkActions"; import { bookmarkLayoutSwitch, @@ -12,17 +14,28 @@ import { useBookmarkLayout, } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; -import { Check, Image as ImageIcon, NotebookPen } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useQuery } from "@tanstack/react-query"; +import { + Check, + GripVertical, + Image as ImageIcon, + NotebookPen, +} from "lucide-react"; import { useTheme } from "next-themes"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; -import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils"; +import { + getBookmarkTitle, + isBookmarkStillTagging, +} from "@karakeep/shared/utils/bookmarkUtils"; import { switchCase } from "@karakeep/shared/utils/switch"; import BookmarkActionBar from "./BookmarkActionBar"; import BookmarkFormattedCreatedAt from "./BookmarkFormattedCreatedAt"; +import BookmarkOwnerIcon from "./BookmarkOwnerIcon"; import { NotePreview } from "./NotePreview"; import TagList from "./TagList"; @@ -60,6 +73,43 @@ function BottomRow({ ); } +function OwnerIndicator({ bookmark }: { bookmark: ZBookmark }) { + const api = useTRPC(); + const listContext = useBookmarkListContext(); + const collaborators = useQuery( + api.lists.getCollaborators.queryOptions( + { + listId: listContext?.id ?? "", + }, + { + refetchOnWindowFocus: false, + enabled: !!listContext?.hasCollaborators, + }, + ), + ); + + if (!listContext || listContext.userRole === "owner" || !collaborators.data) { + return null; + } + + let owner = undefined; + if (bookmark.userId === collaborators.data.owner?.id) { + owner = collaborators.data.owner; + } else { + owner = collaborators.data.collaborators.find( + (c) => c.userId === bookmark.userId, + )?.user; + } + + if (!owner) return null; + + return ( + <div className="absolute right-2 top-2 z-40 opacity-0 transition-opacity duration-200 group-hover:opacity-100"> + <BookmarkOwnerIcon ownerName={owner.name} ownerAvatar={owner.image} /> + </div> + ); +} + function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark); @@ -114,6 +164,65 @@ function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { ); } +function DragHandle({ + bookmark, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { isBulkEditEnabled } = useBulkActionsStore(); + const handleDragStart = useCallback( + (e: React.DragEvent) => { + e.stopPropagation(); + e.dataTransfer.setData(BOOKMARK_DRAG_MIME, bookmark.id); + e.dataTransfer.effectAllowed = "copy"; + + // Create a small pill element as the drag preview + const pill = document.createElement("div"); + const title = getBookmarkTitle(bookmark) ?? "Untitled"; + pill.textContent = + title.length > 40 ? title.substring(0, 40) + "\u2026" : title; + Object.assign(pill.style, { + position: "fixed", + left: "-9999px", + top: "-9999px", + padding: "6px 12px", + borderRadius: "8px", + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + fontSize: "13px", + fontFamily: "inherit", + color: "hsl(var(--foreground))", + maxWidth: "240px", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }); + document.body.appendChild(pill); + e.dataTransfer.setDragImage(pill, 0, 0); + requestAnimationFrame(() => pill.remove()); + }, + [bookmark], + ); + + if (isBulkEditEnabled) return null; + + return ( + <div + draggable + onDragStart={handleDragStart} + className={cn( + "absolute z-40 cursor-grab rounded bg-background/70 p-0.5 opacity-0 shadow-sm transition-opacity duration-200 group-hover:opacity-100", + className, + )} + > + <GripVertical className="size-4 text-muted-foreground" /> + </div> + ); +} + function ListView({ bookmark, image, @@ -133,11 +242,16 @@ function ListView({ return ( <div className={cn( - "relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2", + "group relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2", className, )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-1 top-1/2 -translate-y-1/2" + /> <div className="flex size-32 items-center justify-center overflow-hidden"> {image("list", cn("size-32 rounded-lg", imgFitClass))} </div> @@ -191,12 +305,14 @@ function GridView({ return ( <div className={cn( - "relative flex flex-col overflow-hidden rounded-lg", + "group relative flex flex-col overflow-hidden rounded-lg", className, fitHeight && layout != "grid" ? "max-h-96" : "h-96", )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle bookmark={bookmark} className="left-2 top-2" /> {img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>} <div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2"> <div className="grow-1 flex flex-col gap-2 overflow-hidden"> @@ -228,12 +344,17 @@ function CompactView({ bookmark, title, footer, className }: Props) { return ( <div className={cn( - "relative flex flex-col overflow-hidden rounded-lg", + "group relative flex flex-col overflow-hidden rounded-lg", className, "max-h-96", )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-0.5 top-1/2 -translate-y-1/2" + /> <div className="flex h-full justify-between gap-2 overflow-hidden p-2"> <div className="flex items-center gap-2"> {bookmark.content.type === BookmarkTypes.LINK && diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx index e7fea2c3..a1eab830 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx @@ -1,6 +1,6 @@ import MarkdownEditor from "@/components/ui/markdown/markdown-editor";
import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 66de6156..c161853d 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -1,18 +1,26 @@ "use client"; -import { useEffect, useState } from "react"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useToast } from "@/components/ui/use-toast"; +import { useSession } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; +import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; import { + Archive, + Download, FileDown, + FileText, + ImagePlus, Link, List, ListX, @@ -22,20 +30,25 @@ import { SquarePen, Trash2, } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { toast } from "sonner"; import type { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; import { - useRecrawlBookmark, - useUpdateBookmark, -} from "@karakeep/shared-react/hooks//bookmarks"; -import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks//lists"; + useAttachBookmarkAsset, + useReplaceBookmarkAsset, +} from "@karakeep/shared-react/hooks/assets"; import { useBookmarkGridContext } from "@karakeep/shared-react/hooks/bookmark-grid-context"; import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; +import { + useRecrawlBookmark, + useUpdateBookmark, +} from "@karakeep/shared-react/hooks/bookmarks"; +import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; import DeleteBookmarkConfirmationDialog from "./DeleteBookmarkConfirmationDialog"; @@ -43,9 +56,35 @@ import { EditBookmarkDialog } from "./EditBookmarkDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "./icons"; import { useManageListsModal } from "./ManageListsModal"; +interface ActionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + disabled: boolean; + className?: string; + onClick: () => void; +} + +interface SubsectionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + items: ActionItem[]; +} + +const getBannerSonnerId = (bookmarkId: string) => + `replace-banner-${bookmarkId}`; + +type ActionItemType = ActionItem | SubsectionItem; + +function isSubsectionItem(item: ActionItemType): item is SubsectionItem { + return "items" in item; +} + export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); - const { toast } = useToast(); const linkId = bookmark.id; const { data: session } = useSession(); @@ -73,54 +112,122 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const [isTextEditorOpen, setTextEditorOpen] = useState(false); const [isEditBookmarkDialogOpen, setEditBookmarkDialogOpen] = useState(false); + const bannerFileInputRef = useRef<HTMLInputElement>(null); + + const { mutate: uploadBannerAsset } = useUpload({ + onError: (e) => { + toast.error(e.error, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + + const { mutate: attachAsset, isPending: isAttaching } = + useAttachBookmarkAsset({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.update_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + }, + onError: (e) => { + toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + + const { mutate: replaceAsset, isPending: isReplacing } = + useReplaceBookmarkAsset({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.update_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + }, + onError: (e) => { + toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + const { listId } = useBookmarkGridContext() ?? {}; const withinListContext = useBookmarkListContext(); const onError = () => { - toast({ - variant: "destructive", - title: t("common.something_went_wrong"), - }); + toast.error(t("common.something_went_wrong")); }; const updateBookmarkMutator = useUpdateBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.updated"), - }); + toast.success(t("toasts.bookmarks.updated")); }, onError, }); const crawlBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.refetch"), - }); + toast.success(t("toasts.bookmarks.refetch")); }, onError, }); const fullPageArchiveBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.full_page_archive"), - }); + toast.success(t("toasts.bookmarks.full_page_archive")); + }, + onError, + }); + + const preservePdfMutator = useRecrawlBookmark({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.preserve_pdf")); }, onError, }); const removeFromListMutator = useRemoveBookmarkFromList({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.delete_from_list"), - }); + toast.success(t("toasts.bookmarks.delete_from_list")); }, onError, }); + const handleBannerFileChange = (event: ChangeEvent<HTMLInputElement>) => { + const files = event.target.files; + if (files && files.length > 0) { + const file = files[0]; + const existingBanner = bookmark.assets.find( + (asset) => asset.assetType === "bannerImage", + ); + + if (existingBanner) { + toast.loading(t("toasts.bookmarks.uploading_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + uploadBannerAsset(file, { + onSuccess: (resp) => { + replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: existingBanner.id, + newAssetId: resp.assetId, + }); + }, + }); + } else { + toast.loading(t("toasts.bookmarks.uploading_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + uploadBannerAsset(file, { + onSuccess: (resp) => { + attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: resp.assetId, + assetType: "bannerImage", + }, + }); + }, + }); + } + } + }; + // Define action items array - const actionItems = [ + const actionItems: ActionItemType[] = [ { id: "edit", title: t("actions.edit"), @@ -174,19 +281,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }), }, { - id: "download-full-page", - title: t("actions.download_full_page_archive"), - icon: <FileDown className="mr-2 size-4" />, - visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: false, - onClick: () => { - fullPageArchiveBookmarkMutator.mutate({ - bookmarkId: bookmark.id, - archiveFullPage: true, - }); - }, - }, - { id: "copy-link", title: t("actions.copy_link"), icon: <Link className="mr-2 size-4" />, @@ -196,9 +290,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { navigator.clipboard.writeText( (bookmark.content as ZBookmarkedLink).url, ); - toast({ - description: t("toasts.bookmarks.clipboard_copied"), - }); + toast.success(t("toasts.bookmarks.clipboard_copied")); }, }, { @@ -213,14 +305,15 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { id: "remove-from-list", title: t("actions.remove_from_list"), icon: <ListX className="mr-2 size-4" />, - visible: + visible: Boolean( (isOwner || (withinListContext && (withinListContext.userRole === "editor" || withinListContext.userRole === "owner"))) && - !!listId && - !!withinListContext && - withinListContext.type === "manual", + !!listId && + !!withinListContext && + withinListContext.type === "manual", + ), disabled: demoMode, onClick: () => removeFromListMutator.mutate({ @@ -229,12 +322,98 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }), }, { - id: "refresh", - title: t("actions.refresh"), - icon: <RotateCw className="mr-2 size-4" />, + id: "offline-copies", + title: t("actions.offline_copies"), + icon: <Archive className="mr-2 size-4" />, visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: demoMode, - onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + items: [ + { + id: "download-full-page", + title: t("actions.preserve_offline_archive"), + icon: <FileDown className="mr-2 size-4" />, + visible: true, + disabled: demoMode, + onClick: () => { + fullPageArchiveBookmarkMutator.mutate({ + bookmarkId: bookmark.id, + archiveFullPage: true, + }); + }, + }, + { + id: "preserve-pdf", + title: t("actions.preserve_as_pdf"), + icon: <FileText className="mr-2 size-4" />, + visible: true, + disabled: demoMode, + onClick: () => { + preservePdfMutator.mutate({ + bookmarkId: bookmark.id, + storePdf: true, + }); + }, + }, + { + id: "download-full-page-archive", + title: t("actions.download_full_page_archive_file"), + icon: <Download className="mr-2 size-4" />, + visible: + bookmark.content.type === BookmarkTypes.LINK && + !!( + bookmark.content.fullPageArchiveAssetId || + bookmark.content.precrawledArchiveAssetId + ), + disabled: false, + onClick: () => { + const link = bookmark.content as ZBookmarkedLink; + const archiveAssetId = + link.fullPageArchiveAssetId ?? link.precrawledArchiveAssetId; + if (archiveAssetId) { + window.open(getAssetUrl(archiveAssetId), "_blank"); + } + }, + }, + { + id: "download-pdf", + title: t("actions.download_pdf_file"), + icon: <Download className="mr-2 size-4" />, + visible: !!(bookmark.content as ZBookmarkedLink).pdfAssetId, + disabled: false, + onClick: () => { + const link = bookmark.content as ZBookmarkedLink; + if (link.pdfAssetId) { + window.open(getAssetUrl(link.pdfAssetId), "_blank"); + } + }, + }, + ], + }, + { + id: "more", + title: t("actions.more"), + icon: <MoreHorizontal className="mr-2 size-4" />, + visible: isOwner, + items: [ + { + id: "refresh", + title: t("actions.refresh"), + icon: <RotateCw className="mr-2 size-4" />, + visible: bookmark.content.type === BookmarkTypes.LINK, + disabled: demoMode, + onClick: () => + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + }, + { + id: "replace-banner", + title: bookmark.assets.find((a) => a.assetType === "bannerImage") + ? t("actions.replace_banner") + : t("actions.add_banner"), + icon: <ImagePlus className="mr-2 size-4" />, + visible: true, + disabled: demoMode || isAttaching || isReplacing, + onClick: () => bannerFileInputRef.current?.click(), + }, + ], }, { id: "delete", @@ -248,7 +427,12 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { ]; // Filter visible items - const visibleItems = actionItems.filter((item) => item.visible); + const visibleItems: ActionItemType[] = actionItems.filter((item) => { + if (isSubsectionItem(item)) { + return item.visible && item.items.some((subItem) => subItem.visible); + } + return item.visible; + }); // If no items are visible, don't render the dropdown if (visibleItems.length === 0) { @@ -283,19 +467,56 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> - {visibleItems.map((item) => ( - <DropdownMenuItem - key={item.id} - disabled={item.disabled} - className={item.className} - onClick={item.onClick} - > - {item.icon} - <span>{item.title}</span> - </DropdownMenuItem> - ))} + {visibleItems.map((item) => { + if (isSubsectionItem(item)) { + const visibleSubItems = item.items.filter( + (subItem) => subItem.visible, + ); + if (visibleSubItems.length === 0) { + return null; + } + return ( + <DropdownMenuSub key={item.id}> + <DropdownMenuSubTrigger> + {item.icon} + <span>{item.title}</span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent> + {visibleSubItems.map((subItem) => ( + <DropdownMenuItem + key={subItem.id} + disabled={subItem.disabled} + onClick={subItem.onClick} + > + {subItem.icon} + <span>{subItem.title}</span> + </DropdownMenuItem> + ))} + </DropdownMenuSubContent> + </DropdownMenuSub> + ); + } + return ( + <DropdownMenuItem + key={item.id} + disabled={item.disabled} + className={item.className} + onClick={item.onClick} + > + {item.icon} + <span>{item.title}</span> + </DropdownMenuItem> + ); + })} </DropdownMenuContent> </DropdownMenu> + <input + type="file" + ref={bannerFileInputRef} + onChange={handleBannerFileChange} + className="hidden" + accept=".jpg,.jpeg,.png,.webp" + /> </> ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx new file mode 100644 index 00000000..57770547 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx @@ -0,0 +1,31 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { UserAvatar } from "@/components/ui/user-avatar"; + +interface BookmarkOwnerIconProps { + ownerName: string; + ownerAvatar: string | null; +} + +export default function BookmarkOwnerIcon({ + ownerName, + ownerAvatar, +}: BookmarkOwnerIconProps) { + return ( + <Tooltip> + <TooltipTrigger> + <UserAvatar + name={ownerName} + image={ownerAvatar} + className="size-5 shrink-0 rounded-full ring-1 ring-border" + /> + </TooltipTrigger> + <TooltipContent className="font-sm"> + <p className="font-medium">{ownerName}</p> + </TooltipContent> + </Tooltip> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx index 22b5408e..09843bce 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx @@ -1,4 +1,4 @@ -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index f726c703..b3a1881a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -16,6 +16,7 @@ import Masonry from "react-masonry-css"; import resolveConfig from "tailwindcss/resolveConfig"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; import BookmarkCard from "./BookmarkCard"; import EditorCard from "./EditorCard"; @@ -64,6 +65,7 @@ export default function BookmarksGrid({ const gridColumns = useGridColumns(); const bulkActionsStore = useBulkActionsStore(); const inBookmarkGrid = useInBookmarkGridStore(); + const withinListContext = useBookmarkListContext(); const breakpointConfig = useMemo( () => getBreakpointConfig(gridColumns), [gridColumns], @@ -72,10 +74,13 @@ export default function BookmarksGrid({ useEffect(() => { bulkActionsStore.setVisibleBookmarks(bookmarks); + bulkActionsStore.setListContext(withinListContext); + return () => { bulkActionsStore.setVisibleBookmarks([]); + bulkActionsStore.setListContext(undefined); }; - }, [bookmarks]); + }, [bookmarks, withinListContext?.id]); useEffect(() => { inBookmarkGrid.setInBookmarkGrid(true); @@ -112,12 +117,20 @@ export default function BookmarksGrid({ <> {bookmarkLayoutSwitch(layout, { masonry: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), grid: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx index b592919b..9adc7b7a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx @@ -69,12 +69,20 @@ export default function BookmarksGridSkeleton({ return bookmarkLayoutSwitch(layout, { masonry: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), grid: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), diff --git a/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx b/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx index 23afa7d2..1d4f5814 100644 --- a/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx +++ b/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx @@ -15,7 +15,7 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx index 431f0fcd..c790a5fe 100644 --- a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx +++ b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx @@ -7,10 +7,11 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { useQueries } from "@tanstack/react-query"; import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { limitConcurrency } from "@karakeep/shared/concurrency"; import { ZBookmark } from "@karakeep/shared/types/bookmarks"; @@ -25,9 +26,12 @@ export default function BulkTagModal({ open: boolean; setOpen: (open: boolean) => void; }) { - const results = api.useQueries((t) => - bookmarkIds.map((id) => t.bookmarks.getBookmark({ bookmarkId: id })), - ); + const api = useTRPC(); + const results = useQueries({ + queries: bookmarkIds.map((id) => + api.bookmarks.getBookmark.queryOptions({ bookmarkId: id }), + ), + }); const bookmarks = results .map((r) => r.data) diff --git a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx index 7e680706..8e7a4d34 100644 --- a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx +++ b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx @@ -1,7 +1,7 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import { useDeleteBookmark } from "@karakeep/shared-react/hooks//bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx index 76208158..8b77365c 100644 --- a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx +++ b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx @@ -25,18 +25,19 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { useDialogFormReset } from "@/lib/hooks/useDialogFormReset"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark, @@ -60,10 +61,11 @@ export function EditBookmarkDialog({ open: boolean; setOpen: (v: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); - const { data: assetContent, isLoading: isAssetContentLoading } = - api.bookmarks.getBookmark.useQuery( + const { data: assetContent, isLoading: isAssetContentLoading } = useQuery( + api.bookmarks.getBookmark.queryOptions( { bookmarkId: bookmark.id, includeContent: true, @@ -73,11 +75,13 @@ export function EditBookmarkDialog({ select: (b) => b.content.type == BookmarkTypes.ASSET ? b.content.content : null, }, - ); + ), + ); const bookmarkToDefault = (bookmark: ZBookmark) => ({ bookmarkId: bookmark.id, summary: bookmark.summary, + note: bookmark.note === null ? undefined : bookmark.note, title: getBookmarkTitle(bookmark), createdAt: bookmark.createdAt ?? new Date(), // Link specific defaults (only if bookmark is a link) @@ -196,6 +200,26 @@ export function EditBookmarkDialog({ /> )} + { + <FormField + control={form.control} + name="note" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("common.note")}</FormLabel> + <FormControl> + <Textarea + placeholder="Bookmark notes" + {...field} + value={field.value ?? ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + } + {isLink && ( <FormField control={form.control} diff --git a/apps/web/components/dashboard/bookmarks/EditorCard.tsx b/apps/web/components/dashboard/bookmarks/EditorCard.tsx index fa752c5f..4636bcb9 100644 --- a/apps/web/components/dashboard/bookmarks/EditorCard.tsx +++ b/apps/web/components/dashboard/bookmarks/EditorCard.tsx @@ -5,8 +5,8 @@ import { Form, FormControl, FormItem } from "@/components/ui/form"; import { Kbd } from "@/components/ui/kbd"; import MultipleChoiceDialog from "@/components/ui/multiple-choice-dialog"; import { Separator } from "@/components/ui/separator"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import BookmarkAlreadyExistsToast from "@/components/utils/BookmarkAlreadyExistsToast"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; diff --git a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx index 7c3827ab..1fee0505 100644 --- a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx +++ b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx @@ -16,11 +16,11 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { Archive, X } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -30,6 +30,7 @@ import { useBookmarkLists, useRemoveBookmarkFromList, } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkListSelector } from "../lists/BookmarkListSelector"; import ArchiveBookmarkButton from "./action-buttons/ArchiveBookmarkButton"; @@ -43,6 +44,7 @@ export default function ManageListsModal({ open: boolean; setOpen: (open: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); const formSchema = z.object({ listId: z.string({ @@ -61,13 +63,14 @@ export default function ManageListsModal({ { enabled: open }, ); - const { data: alreadyInList, isPending: isAlreadyInListPending } = - api.lists.getListsOfBookmark.useQuery( + const { data: alreadyInList, isPending: isAlreadyInListPending } = useQuery( + api.lists.getListsOfBookmark.queryOptions( { bookmarkId, }, { enabled: open }, - ); + ), + ); const isLoading = isAllListsPending || isAlreadyInListPending; diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx index b2cf118e..5f107663 100644 --- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx +++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx @@ -1,8 +1,8 @@ import React from "react"; import { ActionButton } from "@/components/ui/action-button"; import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx index f1c319ea..88611c52 100644 --- a/apps/web/components/dashboard/bookmarks/TagList.tsx +++ b/apps/web/components/dashboard/bookmarks/TagList.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import { badgeVariants } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { useSession } from "@/lib/auth/client"; import { cn } from "@/lib/utils"; -import { useSession } from "next-auth/react"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx index bc06c647..ec4a9d8a 100644 --- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx @@ -13,25 +13,32 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; +import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Command as CommandPrimitive } from "cmdk"; import { Check, Loader2, Plus, Sparkles, X } from "lucide-react"; import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export function TagsEditor({ tags: _tags, onAttach, onDetach, disabled, + allowCreation = true, + placeholder, }: { tags: ZBookmarkTags[]; onAttach: (tag: { tagName: string; tagId?: string }) => void; onDetach: (tag: { tagName: string; tagId: string }) => void; disabled?: boolean; + allowCreation?: boolean; + placeholder?: string; }) { + const api = useTRPC(); + const { t } = useTranslation(); const demoMode = !!useClientConfig().demoMode; const isDisabled = demoMode || disabled; const inputRef = React.useRef<HTMLInputElement>(null); @@ -40,6 +47,7 @@ export function TagsEditor({ const [inputValue, setInputValue] = React.useState(""); const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_tags); const tempIdCounter = React.useRef(0); + const hasInitializedRef = React.useRef(_tags.length > 0); const generateTempId = React.useCallback(() => { tempIdCounter.current += 1; @@ -54,25 +62,42 @@ export function TagsEditor({ }, []); React.useEffect(() => { + // When allowCreation is false, only sync on initial load + // After that, rely on optimistic updates to avoid re-ordering + if (!allowCreation) { + if (!hasInitializedRef.current && _tags.length > 0) { + hasInitializedRef.current = true; + setOptimisticTags(_tags); + } + return; + } + + // For allowCreation mode, sync server state with optimistic state setOptimisticTags((prev) => { - let results = prev; + // Start with a copy to avoid mutating the previous state + const results = [...prev]; + let changed = false; + for (const tag of _tags) { const idx = results.findIndex((t) => t.name === tag.name); if (idx == -1) { results.push(tag); + changed = true; continue; } if (results[idx].id.startsWith("temp-")) { results[idx] = tag; + changed = true; continue; } } - return results; + + return changed ? results : prev; }); - }, [_tags]); + }, [_tags, allowCreation]); - const { data: filteredOptions, isLoading: isExistingTagsLoading } = - api.tags.list.useQuery( + const { data: filteredOptions, isLoading: isExistingTagsLoading } = useQuery( + api.tags.list.queryOptions( { nameContains: inputValue, limit: 50, @@ -91,7 +116,8 @@ export function TagsEditor({ placeholderData: keepPreviousData, gcTime: inputValue.length > 0 ? 60_000 : 3_600_000, }, - ); + ), + ); const selectedValues = optimisticTags.map((tag) => tag.id); @@ -122,7 +148,7 @@ export function TagsEditor({ (opt) => opt.name.toLowerCase() === trimmedInputValue.toLowerCase(), ); - if (!exactMatch) { + if (!exactMatch && allowCreation) { return [ { id: "create-new", @@ -136,7 +162,7 @@ export function TagsEditor({ } return baseOptions; - }, [filteredOptions, trimmedInputValue]); + }, [filteredOptions, trimmedInputValue, allowCreation]); const onChange = ( actionMeta: @@ -256,6 +282,24 @@ export function TagsEditor({ } }; + const inputPlaceholder = + placeholder ?? + (allowCreation + ? t("tags.search_or_create_placeholder", { + defaultValue: "Search or create tags...", + }) + : t("tags.search_placeholder", { + defaultValue: "Search tags...", + })); + const visiblePlaceholder = + optimisticTags.length === 0 ? inputPlaceholder : undefined; + const inputWidth = Math.max( + inputValue.length > 0 + ? inputValue.length + : Math.min(visiblePlaceholder?.length ?? 1, 24), + 1, + ); + return ( <div ref={containerRef} className="w-full"> <Popover open={open && !isDisabled} onOpenChange={handleOpenChange}> @@ -311,8 +355,9 @@ export function TagsEditor({ value={inputValue} onKeyDown={handleKeyDown} onValueChange={(v) => setInputValue(v)} + placeholder={visiblePlaceholder} className="bg-transparent outline-none placeholder:text-muted-foreground" - style={{ width: `${Math.max(inputValue.length, 1)}ch` }} + style={{ width: `${inputWidth}ch` }} disabled={isDisabled} /> {isExistingTagsLoading && ( @@ -329,7 +374,7 @@ export function TagsEditor({ <CommandList className="max-h-64"> {displayedOptions.length === 0 ? ( <CommandEmpty> - {trimmedInputValue ? ( + {trimmedInputValue && allowCreation ? ( <div className="flex items-center justify-between px-2 py-1.5"> <span>Create "{trimmedInputValue}"</span> <Button diff --git a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx index 968d0326..e9bee653 100644 --- a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx @@ -3,13 +3,14 @@ import { useEffect } from "react"; import UploadDropzone from "@/components/dashboard/UploadDropzone"; import { useSortOrderStore } from "@/lib/store/useSortOrderStore"; -import { api } from "@/lib/trpc"; +import { useInfiniteQuery } from "@tanstack/react-query"; import type { ZGetBookmarksRequest, ZGetBookmarksResponse, } from "@karakeep/shared/types/bookmarks"; import { BookmarkGridContextProvider } from "@karakeep/shared-react/hooks/bookmark-grid-context"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import BookmarksGrid from "./BookmarksGrid"; @@ -23,6 +24,7 @@ export default function UpdatableBookmarksGrid({ showEditorCard?: boolean; itemsPerPage?: number; }) { + const api = useTRPC(); let sortOrder = useSortOrderStore((state) => state.sortOrder); if (sortOrder === "relevance") { // Relevance is not supported in the `getBookmarks` endpoint. @@ -32,17 +34,19 @@ export default function UpdatableBookmarksGrid({ const finalQuery = { ...query, sortOrder, includeContent: false }; const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = - api.bookmarks.getBookmarks.useInfiniteQuery( - { ...finalQuery, useCursorV2: true }, - { - initialData: () => ({ - pages: [initialBookmarks], - pageParams: [query.cursor], - }), - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - refetchOnMount: true, - }, + useInfiniteQuery( + api.bookmarks.getBookmarks.infiniteQueryOptions( + { ...finalQuery, useCursorV2: true }, + { + initialData: () => ({ + pages: [initialBookmarks], + pageParams: [query.cursor ?? null], + }), + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + refetchOnMount: true, + }, + ), ); useEffect(() => { diff --git a/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx b/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx index d45cfc82..48d3c7ac 100644 --- a/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx +++ b/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx @@ -1,9 +1,10 @@ import React from "react"; import { ActionButton, ActionButtonProps } from "@/components/ui/action-button"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; +import { useQuery } from "@tanstack/react-query"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface ArchiveBookmarkButtonProps extends Omit<ActionButtonProps, "loading" | "disabled"> { @@ -15,13 +16,16 @@ const ArchiveBookmarkButton = React.forwardRef< HTMLButtonElement, ArchiveBookmarkButtonProps >(({ bookmarkId, onDone, ...props }, ref) => { - const { data } = api.bookmarks.getBookmark.useQuery( - { bookmarkId }, - { - select: (data) => ({ - archived: data.archived, - }), - }, + const api = useTRPC(); + const { data } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { bookmarkId }, + { + select: (data) => ({ + archived: data.archived, + }), + }, + ), ); const { mutate: updateBookmark, isPending: isArchivingBookmark } = |
