diff options
Diffstat (limited to 'apps/web/components/dashboard')
61 files changed, 2128 insertions, 587 deletions
diff --git a/apps/web/components/dashboard/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx index 817521ff..0e74b985 100644 --- a/apps/web/components/dashboard/BulkBookmarksAction.tsx +++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx @@ -7,7 +7,7 @@ import { ActionButtonWithTooltip, } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { useToast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import useBulkActionsStore from "@/lib/bulkActions"; import { useTranslation } from "@/lib/i18n/client"; import { @@ -16,6 +16,7 @@ import { Hash, Link, List, + ListMinus, Pencil, RotateCw, Trash2, @@ -27,6 +28,7 @@ import { useRecrawlBookmark, useUpdateBookmark, } from "@karakeep/shared-react/hooks/bookmarks"; +import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists"; import { limitConcurrency } from "@karakeep/shared/concurrency"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; @@ -38,7 +40,11 @@ const MAX_CONCURRENT_BULK_ACTIONS = 50; export default function BulkBookmarksAction() { const { t } = useTranslation(); - const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); + const { + selectedBookmarks, + isBulkEditEnabled, + listContext: withinListContext, + } = useBulkActionsStore(); const setIsBulkEditEnabled = useBulkActionsStore( (state) => state.setIsBulkEditEnabled, ); @@ -49,8 +55,9 @@ export default function BulkBookmarksAction() { const isEverythingSelected = useBulkActionsStore( (state) => state.isEverythingSelected, ); - const { toast } = useToast(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRemoveFromListDialogOpen, setIsRemoveFromListDialogOpen] = + useState(false); const [manageListsModal, setManageListsModalOpen] = useState(false); const [bulkTagModal, setBulkTagModalOpen] = useState(false); const pathname = usePathname(); @@ -93,6 +100,13 @@ export default function BulkBookmarksAction() { onError, }); + const removeBookmarkFromListMutator = useRemoveBookmarkFromList({ + onSuccess: () => { + setIsBulkEditEnabled(false); + }, + onError, + }); + interface UpdateBookmarkProps { favourited?: boolean; archived?: boolean; @@ -185,6 +199,31 @@ export default function BulkBookmarksAction() { setIsDeleteDialogOpen(false); }; + const removeBookmarksFromList = async () => { + if (!withinListContext) return; + + const results = await Promise.allSettled( + limitConcurrency( + selectedBookmarks.map( + (item) => () => + removeBookmarkFromListMutator.mutateAsync({ + bookmarkId: item.id, + listId: withinListContext.id, + }), + ), + MAX_CONCURRENT_BULK_ACTIONS, + ), + ); + + const successes = results.filter((r) => r.status === "fulfilled").length; + if (successes > 0) { + toast({ + description: `${successes} bookmarks have been removed from the list!`, + }); + } + setIsRemoveFromListDialogOpen(false); + }; + const alreadyFavourited = selectedBookmarks.length && selectedBookmarks.every((item) => item.favourited === true); @@ -204,6 +243,18 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { + name: t("actions.remove_from_list"), + icon: <ListMinus size={18} />, + action: () => setIsRemoveFromListDialogOpen(true), + isPending: removeBookmarkFromListMutator.isPending, + hidden: + !isBulkEditEnabled || + !withinListContext || + withinListContext.type !== "manual" || + (withinListContext.userRole !== "editor" && + withinListContext.userRole !== "owner"), + }, + { name: t("actions.add_to_list"), icon: <List size={18} />, action: () => setManageListsModalOpen(true), @@ -232,7 +283,7 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { - name: t("actions.download_full_page_archive"), + name: t("actions.preserve_offline_archive"), icon: <FileDown size={18} />, action: () => recrawlBookmarks(true), isPending: recrawlBookmarkMutator.isPending, @@ -299,6 +350,27 @@ export default function BulkBookmarksAction() { </ActionButton> )} /> + <ActionConfirmingDialog + open={isRemoveFromListDialogOpen} + setOpen={setIsRemoveFromListDialogOpen} + title={"Remove Bookmarks from List"} + description={ + <p> + Are you sure you want to remove {selectedBookmarks.length} bookmarks + from this list? + </p> + } + actionButton={() => ( + <ActionButton + type="button" + variant="destructive" + loading={removeBookmarkFromListMutator.isPending} + onClick={() => removeBookmarksFromList()} + > + {t("actions.remove")} + </ActionButton> + )} + /> <BulkManageListsModal bookmarkIds={selectedBookmarks.map((b) => b.id)} open={manageListsModal} diff --git a/apps/web/components/dashboard/ErrorFallback.tsx b/apps/web/components/dashboard/ErrorFallback.tsx new file mode 100644 index 00000000..7e4ce0d6 --- /dev/null +++ b/apps/web/components/dashboard/ErrorFallback.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, Home, RefreshCw } from "lucide-react"; + +export default function ErrorFallback() { + return ( + <div className="flex flex-1 items-center justify-center rounded-lg bg-slate-50 p-8 shadow-sm dark:bg-slate-700/50 dark:shadow-md"> + <div className="w-full max-w-md space-y-8 text-center"> + <div className="flex justify-center"> + <div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted"> + <AlertTriangle className="h-10 w-10 text-muted-foreground" /> + </div> + </div> + + <div className="space-y-4"> + <h1 className="text-balance text-2xl font-semibold text-foreground"> + Oops! Something went wrong + </h1> + <p className="text-pretty leading-relaxed text-muted-foreground"> + We're sorry, but an unexpected error occurred. Please try again + or contact support if the issue persists. + </p> + </div> + + <div className="space-y-3"> + <Button className="w-full" onClick={() => window.location.reload()}> + <RefreshCw className="mr-2 h-4 w-4" /> + Try Again + </Button> + + <Link href="/" className="block"> + <Button variant="outline" className="w-full"> + <Home className="mr-2 h-4 w-4" /> + Go Home + </Button> + </Link> + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx index 8d119467..c76da523 100644 --- a/apps/web/components/dashboard/UploadDropzone.tsx +++ b/apps/web/components/dashboard/UploadDropzone.tsx @@ -1,6 +1,8 @@ "use client"; import React, { useCallback, useState } from "react"; +import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useUpload from "@/lib/hooks/upload-file"; import { cn } from "@/lib/utils"; import { TRPCClientError } from "@trpc/client"; @@ -10,7 +12,6 @@ import { useCreateBookmarkWithPostHook } from "@karakeep/shared-react/hooks/book import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import LoadingSpinner from "../ui/spinner"; -import { toast } from "../ui/use-toast"; import BookmarkAlreadyExistsToast from "../utils/BookmarkAlreadyExistsToast"; export function useUploadAsset() { @@ -136,7 +137,12 @@ export default function UploadDropzone({ <DropZone noClick onDrop={onDrop} - onDragEnter={() => setDragging(true)} + onDragEnter={(e) => { + // Don't show overlay for internal bookmark card drags + if (!e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + setDragging(true); + } + }} onDragLeave={() => setDragging(false)} > {({ getRootProps, getInputProps }) => ( 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 } = diff --git a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx index 52a9ab0c..b1870644 100644 --- a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx +++ b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx @@ -11,6 +11,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; import { Table, @@ -20,14 +21,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import { distance } from "fastest-levenshtein"; import { Check, Combine, X } from "lucide-react"; import { useMergeTag } from "@karakeep/shared-react/hooks/tags"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface Suggestion { mergeIntoId: string; @@ -199,12 +200,15 @@ function SuggestionRow({ } export function TagDuplicationDetection() { + const api = useTRPC(); const [expanded, setExpanded] = useState(false); - let { data: allTags } = api.tags.list.useQuery( - {}, - { - refetchOnWindowFocus: false, - }, + let { data: allTags } = useQuery( + api.tags.list.queryOptions( + {}, + { + refetchOnWindowFocus: false, + }, + ), ); const { suggestions, updateMergeInto, setSuggestions, deleteSuggestion } = diff --git a/apps/web/components/dashboard/feeds/FeedSelector.tsx b/apps/web/components/dashboard/feeds/FeedSelector.tsx index db95a042..58fae503 100644 --- a/apps/web/components/dashboard/feeds/FeedSelector.tsx +++ b/apps/web/components/dashboard/feeds/FeedSelector.tsx @@ -7,8 +7,10 @@ import { SelectValue, } from "@/components/ui/select"; import LoadingSpinner from "@/components/ui/spinner"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export function FeedSelector({ value, @@ -21,9 +23,12 @@ export function FeedSelector({ onChange: (value: string) => void; placeholder?: string; }) { - const { data, isPending } = api.feeds.list.useQuery(undefined, { - select: (data) => data.feeds, - }); + const api = useTRPC(); + const { data, isPending } = useQuery( + api.feeds.list.queryOptions(undefined, { + select: (data) => data.feeds, + }), + ); if (isPending) { return <LoadingSpinner />; diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx index 7ccc0078..8a2b0165 100644 --- a/apps/web/components/dashboard/header/ProfileOptions.tsx +++ b/apps/web/components/dashboard/header/ProfileOptions.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import Link from "next/link"; import { redirect, useRouter } from "next/navigation"; import { useToggleTheme } from "@/components/theme-provider"; @@ -11,11 +12,24 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; +import { UserAvatar } from "@/components/ui/user-avatar"; +import { useSession } from "@/lib/auth/client"; import { useTranslation } from "@/lib/i18n/client"; -import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { + BookOpen, + LogOut, + Moon, + Paintbrush, + Puzzle, + Settings, + Shield, + Sun, + Twitter, +} from "lucide-react"; import { useTheme } from "next-themes"; +import { useWhoAmI } from "@karakeep/shared-react/hooks/users"; + import { AdminNoticeBadge } from "../../admin/AdminNotices"; function DarkModeToggle() { @@ -43,7 +57,12 @@ export default function SidebarProfileOptions() { const { t } = useTranslation(); const toggleTheme = useToggleTheme(); const { data: session } = useSession(); + const { data: whoami } = useWhoAmI(); const router = useRouter(); + + const avatarImage = whoami?.image ?? null; + const avatarUrl = useMemo(() => avatarImage ?? null, [avatarImage]); + if (!session) return redirect("/"); return ( @@ -53,13 +72,21 @@ export default function SidebarProfileOptions() { className="border-new-gray-200 aspect-square rounded-full border-4 bg-black p-0 text-white" variant="ghost" > - {session.user.name?.charAt(0) ?? "U"} + <UserAvatar + image={avatarUrl} + name={session.user.name} + className="h-full w-full rounded-full" + /> </Button> </DropdownMenuTrigger> <DropdownMenuContent className="mr-2 min-w-64 p-2"> <div className="flex gap-2"> - <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center rounded-full border-4 bg-black p-0 text-white"> - {session.user.name?.charAt(0) ?? "U"} + <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center overflow-hidden rounded-full border-4 bg-black p-0 text-white"> + <UserAvatar + image={avatarUrl} + name={session.user.name} + className="h-full w-full" + /> </div> <div className="flex flex-col"> <p>{session.user.name}</p> @@ -95,6 +122,25 @@ export default function SidebarProfileOptions() { <DarkModeToggle /> </DropdownMenuItem> <Separator className="my-2" /> + <DropdownMenuItem asChild> + <a href="https://karakeep.app/apps" target="_blank" rel="noreferrer"> + <Puzzle className="mr-2 size-4" /> + {t("options.apps_extensions")} + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href="https://docs.karakeep.app" target="_blank" rel="noreferrer"> + <BookOpen className="mr-2 size-4" /> + {t("options.documentation")} + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href="https://x.com/karakeep_app" target="_blank" rel="noreferrer"> + <Twitter className="mr-2 size-4" /> + {t("options.follow_us_on_x")} + </a> + </DropdownMenuItem> + <Separator className="my-2" /> <DropdownMenuItem onClick={() => router.push("/logout")}> <LogOut className="mr-2 size-4" /> <span>{t("actions.sign_out")}</span> diff --git a/apps/web/components/dashboard/highlights/AllHighlights.tsx b/apps/web/components/dashboard/highlights/AllHighlights.tsx index 928f4e05..c7e809ec 100644 --- a/apps/web/components/dashboard/highlights/AllHighlights.tsx +++ b/apps/web/components/dashboard/highlights/AllHighlights.tsx @@ -5,15 +5,14 @@ import Link from "next/link"; import { ActionButton } from "@/components/ui/action-button"; import { Input } from "@/components/ui/input"; import useRelativeTime from "@/lib/hooks/relative-time"; -import { api } from "@/lib/trpc"; import { Separator } from "@radix-ui/react-dropdown-menu"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { Dot, LinkIcon, Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useInView } from "react-intersection-observer"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZGetAllHighlightsResponse, ZHighlight, @@ -21,8 +20,6 @@ import { import HighlightCard from "./HighlightCard"; -dayjs.extend(relativeTime); - function Highlight({ highlight }: { highlight: ZHighlight }) { const { fromNow, localCreatedAt } = useRelativeTime(highlight.createdAt); const { t } = useTranslation(); @@ -49,6 +46,7 @@ export default function AllHighlights({ }: { highlights: ZGetAllHighlightsResponse; }) { + const api = useTRPC(); const { t } = useTranslation(); const [searchInput, setSearchInput] = useState(""); const debouncedSearch = useDebounce(searchInput, 300); @@ -56,28 +54,32 @@ export default function AllHighlights({ // Use search endpoint if searchQuery is provided, otherwise use getAll const useSearchQuery = debouncedSearch.trim().length > 0; - const getAllQuery = api.highlights.getAll.useInfiniteQuery( - {}, - { - enabled: !useSearchQuery, - initialData: !useSearchQuery - ? () => ({ - pages: [initialHighlights], - pageParams: [null], - }) - : undefined, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + const getAllQuery = useInfiniteQuery( + api.highlights.getAll.infiniteQueryOptions( + {}, + { + enabled: !useSearchQuery, + initialData: !useSearchQuery + ? () => ({ + pages: [initialHighlights], + pageParams: [null], + }) + : undefined, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); - const searchQueryResult = api.highlights.search.useInfiniteQuery( - { text: debouncedSearch }, - { - enabled: useSearchQuery, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + const searchQueryResult = useInfiniteQuery( + api.highlights.search.infiniteQueryOptions( + { text: debouncedSearch }, + { + enabled: useSearchQuery, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = diff --git a/apps/web/components/dashboard/highlights/HighlightCard.tsx b/apps/web/components/dashboard/highlights/HighlightCard.tsx index 51421e0f..e7e7c519 100644 --- a/apps/web/components/dashboard/highlights/HighlightCard.tsx +++ b/apps/web/components/dashboard/highlights/HighlightCard.tsx @@ -1,5 +1,5 @@ import { ActionButton } from "@/components/ui/action-button"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { Trash2 } from "lucide-react"; diff --git a/apps/web/components/dashboard/lists/AllListsView.tsx b/apps/web/components/dashboard/lists/AllListsView.tsx index 7a7c9504..52d65756 100644 --- a/apps/web/components/dashboard/lists/AllListsView.tsx +++ b/apps/web/components/dashboard/lists/AllListsView.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import Link from "next/link"; -import { EditListModal } from "@/components/dashboard/lists/EditListModal"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -10,7 +9,7 @@ import { CollapsibleTriggerChevron, } from "@/components/ui/collapsible"; import { useTranslation } from "@/lib/i18n/client"; -import { MoreHorizontal, Plus } from "lucide-react"; +import { MoreHorizontal } from "lucide-react"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { @@ -89,12 +88,6 @@ export default function AllListsView({ return ( <ul> - <EditListModal> - <Button className="mb-2 flex h-full w-full items-center"> - <Plus /> - <span>{t("lists.new_list")}</span> - </Button> - </EditListModal> <ListItem collapsible={false} name={t("lists.favourites")} diff --git a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx index 2bb5f41b..0070b827 100644 --- a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx +++ b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { api } from "@/lib/trpc"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; @@ -101,6 +101,7 @@ export function CollapsibleBookmarkLists({ filter?: (node: ZBookmarkListTreeNode) => boolean; indentOffset?: number; }) { + const api = useTRPC(); // If listsData is provided, use it directly. Otherwise, fetch it. let { data: fetchedData } = useBookmarkLists(undefined, { initialData: initialData ? { lists: initialData } : undefined, @@ -108,9 +109,11 @@ export function CollapsibleBookmarkLists({ }); const data = listsData || fetchedData; - const { data: listStats } = api.lists.stats.useQuery(undefined, { - placeholderData: keepPreviousData, - }); + const { data: listStats } = useQuery( + api.lists.stats.queryOptions(undefined, { + placeholderData: keepPreviousData, + }), + ); if (!data) { return <FullPageSpinner />; diff --git a/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx index 4996ddf1..6c091d7a 100644 --- a/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx +++ b/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx @@ -3,8 +3,8 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx index 5febf88c..21a61d65 100644 --- a/apps/web/components/dashboard/lists/EditListModal.tsx +++ b/apps/web/components/dashboard/lists/EditListModal.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -34,7 +36,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import data from "@emoji-mart/data"; import Picker from "@emoji-mart/react"; diff --git a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx index 62dbbcef..859f4c83 100644 --- a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx +++ b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx @@ -2,11 +2,12 @@ import React from "react"; 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 { api } from "@/lib/trpc"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function LeaveListConfirmationDialog({ list, @@ -19,34 +20,37 @@ export default function LeaveListConfirmationDialog({ open: boolean; setOpen: (v: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); const currentPath = usePathname(); const router = useRouter(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: leaveList, isPending } = api.lists.leaveList.useMutation({ - onSuccess: () => { - toast({ - description: t("lists.leave_list.success", { - icon: list.icon, - name: list.name, - }), - }); - setOpen(false); - // Invalidate the lists cache - utils.lists.list.invalidate(); - // If currently viewing this list, redirect to lists page - if (currentPath.includes(list.id)) { - router.push("/dashboard/lists"); - } - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("common.something_went_wrong"), - }); - }, - }); + const { mutate: leaveList, isPending } = useMutation( + api.lists.leaveList.mutationOptions({ + onSuccess: () => { + toast({ + description: t("lists.leave_list.success", { + icon: list.icon, + name: list.name, + }), + }); + setOpen(false); + // Invalidate the lists cache + queryClient.invalidateQueries(api.lists.list.pathFilter()); + // If currently viewing this list, redirect to lists page + if (currentPath.includes(list.id)) { + router.push("/dashboard/lists"); + } + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("common.something_went_wrong"), + }); + }, + }), + ); return ( <ActionConfirmingDialog diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx index 8e014e2a..4176a80e 100644 --- a/apps/web/components/dashboard/lists/ListHeader.tsx +++ b/apps/web/components/dashboard/lists/ListHeader.tsx @@ -6,13 +6,14 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; -import { MoreHorizontal, SearchIcon, Users } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { MoreHorizontal, SearchIcon } from "lucide-react"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; @@ -24,15 +25,30 @@ export default function ListHeader({ }: { initialData: ZBookmarkList; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); - const { data: list, error } = api.lists.get.useQuery( - { - listId: initialData.id, - }, - { - initialData, - }, + const { data: list, error } = useQuery( + api.lists.get.queryOptions( + { + listId: initialData.id, + }, + { + initialData, + }, + ), + ); + + const { data: collaboratorsData } = useQuery( + api.lists.getCollaborators.queryOptions( + { + listId: initialData.id, + }, + { + refetchOnWindowFocus: false, + enabled: list.hasCollaborators, + }, + ), ); const parsedQuery = useMemo(() => { @@ -55,22 +71,44 @@ export default function ListHeader({ <span className="text-2xl"> {list.icon} {list.name} </span> - {list.hasCollaborators && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Users className="size-5 text-primary" /> - </TooltipTrigger> - <TooltipContent> - <p>{t("lists.shared")}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> + {list.hasCollaborators && collaboratorsData && ( + <div className="group flex"> + {collaboratorsData.owner && ( + <Tooltip> + <TooltipTrigger> + <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1"> + <UserAvatar + name={collaboratorsData.owner.name} + image={collaboratorsData.owner.image} + className="size-5 shrink-0 rounded-full ring-2 ring-background" + /> + </div> + </TooltipTrigger> + <TooltipContent> + <p>{collaboratorsData.owner.name}</p> + </TooltipContent> + </Tooltip> + )} + {collaboratorsData.collaborators.map((collab) => ( + <Tooltip key={collab.userId}> + <TooltipTrigger> + <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1"> + <UserAvatar + name={collab.user.name} + image={collab.user.image} + className="size-5 shrink-0 rounded-full ring-2 ring-background" + /> + </div> + </TooltipTrigger> + <TooltipContent> + <p>{collab.user.name}</p> + </TooltipContent> + </Tooltip> + ))} + </div> )} {list.description && ( - <span className="text-lg text-gray-400"> - {`(${list.description})`} - </span> + <span className="text-lg text-gray-400">{`(${list.description})`}</span> )} </div> <div className="flex items-center"> diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx index 0a55c5fe..518e6440 100644 --- a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx +++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx @@ -22,11 +22,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2, UserPlus, Users } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; export function ManageCollaboratorsModal({ @@ -42,6 +44,7 @@ export function ManageCollaboratorsModal({ children?: React.ReactNode; readOnly?: boolean; }) { + const api = useTRPC(); if ( (userOpen !== undefined && !userSetOpen) || (userOpen === undefined && userSetOpen) @@ -60,82 +63,102 @@ export function ManageCollaboratorsModal({ >("viewer"); const { t } = useTranslation(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); const invalidateListCaches = () => Promise.all([ - utils.lists.getCollaborators.invalidate({ listId: list.id }), - utils.lists.get.invalidate({ listId: list.id }), - utils.lists.list.invalidate(), - utils.bookmarks.getBookmarks.invalidate({ listId: list.id }), + queryClient.invalidateQueries( + api.lists.getCollaborators.queryFilter({ listId: list.id }), + ), + queryClient.invalidateQueries( + api.lists.get.queryFilter({ listId: list.id }), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + queryClient.invalidateQueries( + api.bookmarks.getBookmarks.queryFilter({ listId: list.id }), + ), ]); // Fetch collaborators - const { data: collaboratorsData, isLoading } = - api.lists.getCollaborators.useQuery({ listId: list.id }, { enabled: open }); + const { data: collaboratorsData, isLoading } = useQuery( + api.lists.getCollaborators.queryOptions( + { listId: list.id }, + { enabled: open }, + ), + ); // Mutations - const addCollaborator = api.lists.addCollaborator.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.invitation_sent"), - }); - setNewCollaboratorEmail(""); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_add"), - }); - }, - }); + const addCollaborator = useMutation( + api.lists.addCollaborator.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.invitation_sent"), + }); + setNewCollaboratorEmail(""); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("lists.collaborators.failed_to_add"), + }); + }, + }), + ); - const removeCollaborator = api.lists.removeCollaborator.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.removed"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_remove"), - }); - }, - }); + const removeCollaborator = useMutation( + api.lists.removeCollaborator.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.removed"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_remove"), + }); + }, + }), + ); - const updateCollaboratorRole = api.lists.updateCollaboratorRole.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.role_updated"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: - error.message || t("lists.collaborators.failed_to_update_role"), - }); - }, - }); + const updateCollaboratorRole = useMutation( + api.lists.updateCollaboratorRole.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.role_updated"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_update_role"), + }); + }, + }), + ); - const revokeInvitation = api.lists.revokeInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.invitation_revoked"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_revoke"), - }); - }, - }); + const revokeInvitation = useMutation( + api.lists.revokeInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.invitation_revoked"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_revoke"), + }); + }, + }), + ); const handleAddCollaborator = () => { if (!newCollaboratorEmail.trim()) { @@ -256,15 +279,22 @@ export function ManageCollaboratorsModal({ key={`owner-${collaboratorsData.owner.id}`} className="flex items-center justify-between rounded-lg border p-3" > - <div className="flex-1"> - <div className="font-medium"> - {collaboratorsData.owner.name} - </div> - {collaboratorsData.owner.email && ( - <div className="text-sm text-muted-foreground"> - {collaboratorsData.owner.email} + <div className="flex flex-1 items-center gap-3"> + <UserAvatar + name={collaboratorsData.owner.name} + image={collaboratorsData.owner.image} + className="size-10 ring-1 ring-border" + /> + <div className="flex-1"> + <div className="font-medium"> + {collaboratorsData.owner.name} </div> - )} + {collaboratorsData.owner.email && ( + <div className="text-sm text-muted-foreground"> + {collaboratorsData.owner.email} + </div> + )} + </div> </div> <div className="text-sm capitalize text-muted-foreground"> {t("lists.collaborators.owner")} @@ -278,27 +308,34 @@ export function ManageCollaboratorsModal({ key={collaborator.id} className="flex items-center justify-between rounded-lg border p-3" > - <div className="flex-1"> - <div className="flex items-center gap-2"> - <div className="font-medium"> - {collaborator.user.name} + <div className="flex flex-1 items-center gap-3"> + <UserAvatar + name={collaborator.user.name} + image={collaborator.user.image} + className="size-10 ring-1 ring-border" + /> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <div className="font-medium"> + {collaborator.user.name} + </div> + {collaborator.status === "pending" && ( + <Badge variant="outline" className="text-xs"> + {t("lists.collaborators.pending")} + </Badge> + )} + {collaborator.status === "declined" && ( + <Badge variant="destructive" className="text-xs"> + {t("lists.collaborators.declined")} + </Badge> + )} </div> - {collaborator.status === "pending" && ( - <Badge variant="outline" className="text-xs"> - {t("lists.collaborators.pending")} - </Badge> - )} - {collaborator.status === "declined" && ( - <Badge variant="destructive" className="text-xs"> - {t("lists.collaborators.declined")} - </Badge> + {collaborator.user.email && ( + <div className="text-sm text-muted-foreground"> + {collaborator.user.email} + </div> )} </div> - {collaborator.user.email && ( - <div className="text-sm text-muted-foreground"> - {collaborator.user.email} - </div> - )} </div> {readOnly ? ( <div className="text-sm capitalize text-muted-foreground"> diff --git a/apps/web/components/dashboard/lists/MergeListModal.tsx b/apps/web/components/dashboard/lists/MergeListModal.tsx index 0b7d362a..b22cd1a2 100644 --- a/apps/web/components/dashboard/lists/MergeListModal.tsx +++ b/apps/web/components/dashboard/lists/MergeListModal.tsx @@ -19,8 +19,8 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { X } from "lucide-react"; diff --git a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx index c453a91f..7c13dbeb 100644 --- a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx +++ b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx @@ -8,11 +8,13 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, Mail, X } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + interface Invitation { id: string; role: string; @@ -27,41 +29,51 @@ interface Invitation { } function InvitationRow({ invitation }: { invitation: Invitation }) { + const api = useTRPC(); const { t } = useTranslation(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); - const acceptInvitation = api.lists.acceptInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.invitations.accepted"), - }); - await Promise.all([ - utils.lists.getPendingInvitations.invalidate(), - utils.lists.list.invalidate(), - ]); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.invitations.failed_to_accept"), - }); - }, - }); + const acceptInvitation = useMutation( + api.lists.acceptInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.invitations.accepted"), + }); + await Promise.all([ + queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + ]); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("lists.invitations.failed_to_accept"), + }); + }, + }), + ); - const declineInvitation = api.lists.declineInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.invitations.declined"), - }); - await utils.lists.getPendingInvitations.invalidate(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.invitations.failed_to_decline"), - }); - }, - }); + const declineInvitation = useMutation( + api.lists.declineInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.invitations.declined"), + }); + await queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.invitations.failed_to_decline"), + }); + }, + }), + ); return ( <div className="flex items-center justify-between rounded-lg border p-4"> @@ -126,10 +138,12 @@ function InvitationRow({ invitation }: { invitation: Invitation }) { } export function PendingInvitationsCard() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: invitations, isLoading } = - api.lists.getPendingInvitations.useQuery(); + const { data: invitations, isLoading } = useQuery( + api.lists.getPendingInvitations.queryOptions(), + ); if (isLoading) { return null; @@ -142,9 +156,13 @@ export function PendingInvitationsCard() { return ( <Card> <CardHeader> - <CardTitle className="flex items-center gap-2"> + <CardTitle className="flex items-center gap-2 font-normal"> <Mail className="h-5 w-5" /> - {t("lists.invitations.pending")} ({invitations.length}) + {t("lists.invitations.pending")} + + <span className="rounded bg-secondary p-1 text-sm text-secondary-foreground"> + {invitations.length} + </span> </CardTitle> <CardDescription>{t("lists.invitations.description")}</CardDescription> </CardHeader> diff --git a/apps/web/components/dashboard/lists/RssLink.tsx b/apps/web/components/dashboard/lists/RssLink.tsx index 1be48681..2ac53c93 100644 --- a/apps/web/components/dashboard/lists/RssLink.tsx +++ b/apps/web/components/dashboard/lists/RssLink.tsx @@ -7,29 +7,39 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, RotateCcw } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + export default function RssLink({ listId }: { listId: string }) { + const api = useTRPC(); const { t } = useTranslation(); const clientConfig = useClientConfig(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: regenRssToken, isPending: isRegenPending } = - api.lists.regenRssToken.useMutation({ + const { mutate: regenRssToken, isPending: isRegenPending } = useMutation( + api.lists.regenRssToken.mutationOptions({ onSuccess: () => { - apiUtils.lists.getRssToken.invalidate({ listId }); + queryClient.invalidateQueries( + api.lists.getRssToken.queryFilter({ listId }), + ); }, - }); - const { mutate: clearRssToken, isPending: isClearPending } = - api.lists.clearRssToken.useMutation({ + }), + ); + const { mutate: clearRssToken, isPending: isClearPending } = useMutation( + api.lists.clearRssToken.mutationOptions({ onSuccess: () => { - apiUtils.lists.getRssToken.invalidate({ listId }); + queryClient.invalidateQueries( + api.lists.getRssToken.queryFilter({ listId }), + ); }, - }); - const { data: rssToken, isLoading: isTokenLoading } = - api.lists.getRssToken.useQuery({ listId }); + }), + ); + const { data: rssToken, isLoading: isTokenLoading } = useQuery( + api.lists.getRssToken.queryOptions({ listId }), + ); const rssUrl = useMemo(() => { if (!rssToken || !rssToken.token) { diff --git a/apps/web/components/dashboard/preview/ActionBar.tsx b/apps/web/components/dashboard/preview/ActionBar.tsx index 6e4cd5a2..9603465e 100644 --- a/apps/web/components/dashboard/preview/ActionBar.tsx +++ b/apps/web/components/dashboard/preview/ActionBar.tsx @@ -1,12 +1,12 @@ import { useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { Pencil, Trash2 } from "lucide-react"; diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx index 73eea640..654f3211 100644 --- a/apps/web/components/dashboard/preview/AttachmentBox.tsx +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -8,7 +8,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import FilePickerButton from "@/components/ui/file-picker-button"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { ASSET_TYPE_TO_ICON } from "@/lib/attachments"; import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index 7e6bf814..719cdff8 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -13,12 +13,13 @@ import { TooltipPortal, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useSession } from "@/lib/auth/client"; import useRelativeTime from "@/lib/hooks/relative-time"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { Building, CalendarDays, ExternalLink, User } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { getBookmarkRefreshInterval, @@ -116,24 +117,27 @@ export default function BookmarkPreview({ bookmarkId: string; initialData?: ZBookmark; }) { + const api = useTRPC(); const { t } = useTranslation(); const [activeTab, setActiveTab] = useState<string>("content"); const { data: session } = useSession(); - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId, - }, - { - initialData, - refetchInterval: (query) => { - const data = query.state.data; - if (!data) { - return false; - } - return getBookmarkRefreshInterval(data); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId, }, - }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + return getBookmarkRefreshInterval(data); + }, + }, + ), ); if (!bookmark) { diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx index 41ab7d74..e8503fd9 100644 --- a/apps/web/components/dashboard/preview/HighlightsBox.tsx +++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx @@ -5,10 +5,12 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { Separator } from "@radix-ui/react-dropdown-menu"; +import { useQuery } from "@tanstack/react-query"; import { ChevronsDownUp } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import HighlightCard from "../highlights/HighlightCard"; export default function HighlightsBox({ @@ -18,10 +20,12 @@ export default function HighlightsBox({ bookmarkId: string; readOnly: boolean; }) { + const api = useTRPC(); const { t } = useTranslation(); - const { data: highlights, isPending: isLoading } = - api.highlights.getForBookmark.useQuery({ bookmarkId }); + const { data: highlights, isPending: isLoading } = useQuery( + api.highlights.getForBookmark.queryOptions({ bookmarkId }), + ); if (isLoading || !highlights || highlights?.highlights.length === 0) { return null; diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index 64b62df6..f4e344ac 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -16,16 +16,19 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useTranslation } from "@/lib/i18n/client"; +import { useSession } from "@/lib/auth/client"; +import { Trans, useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; import { AlertTriangle, Archive, BookOpen, Camera, ExpandIcon, + FileText, + Info, Video, } from "lucide-react"; -import { useSession } from "next-auth/react"; import { useQueryState } from "nuqs"; import { ErrorBoundary } from "react-error-boundary"; @@ -34,8 +37,10 @@ import { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; +import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers"; import { contentRendererRegistry } from "./content-renderers"; +import ReaderSettingsPopover from "./ReaderSettingsPopover"; import ReaderView from "./ReaderView"; function CustomRendererErrorFallback({ error }: { error: Error }) { @@ -100,12 +105,23 @@ function VideoSection({ link }: { link: ZBookmarkedLink }) { ); } +function PDFSection({ link }: { link: ZBookmarkedLink }) { + return ( + <iframe + title="PDF Viewer" + src={`/api/assets/${link.pdfAssetId}`} + className="relative h-full min-w-full" + /> + ); +} + export default function LinkContentSection({ bookmark, }: { bookmark: ZBookmark; }) { const { t } = useTranslation(); + const { settings } = useReaderSettings(); const availableRenderers = contentRendererRegistry.getRenderers(bookmark); const defaultSection = availableRenderers.length > 0 ? availableRenderers[0].id : "cached"; @@ -135,6 +151,11 @@ export default function LinkContentSection({ <ScrollArea className="h-full"> <ReaderView className="prose mx-auto dark:prose-invert" + style={{ + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${settings.fontSize}px`, + lineHeight: settings.lineHeight, + }} bookmarkId={bookmark.id} readOnly={!isOwner} /> @@ -144,6 +165,8 @@ export default function LinkContentSection({ content = <FullPageArchiveSection link={bookmark.content} />; } else if (section === "video") { content = <VideoSection link={bookmark.content} />; + } else if (section === "pdf") { + content = <PDFSection link={bookmark.content} />; } else { content = <ScreenshotSection link={bookmark.content} />; } @@ -188,6 +211,12 @@ export default function LinkContentSection({ {t("common.screenshot")} </div> </SelectItem> + <SelectItem value="pdf" disabled={!bookmark.content.pdfAssetId}> + <div className="flex items-center"> + <FileText className="mr-2 h-4 w-4" /> + {t("common.pdf")} + </div> + </SelectItem> <SelectItem value="archive" disabled={ @@ -213,16 +242,47 @@ export default function LinkContentSection({ </SelectContent> </Select> {section === "cached" && ( + <> + <ReaderSettingsPopover /> + <Tooltip> + <TooltipTrigger> + <Link + href={`/reader/${bookmark.id}`} + className={buttonVariants({ variant: "outline" })} + > + <ExpandIcon className="h-4 w-4" /> + </Link> + </TooltipTrigger> + <TooltipContent side="bottom">FullScreen</TooltipContent> + </Tooltip> + </> + )} + {section === "archive" && ( <Tooltip> - <TooltipTrigger> - <Link - href={`/reader/${bookmark.id}`} - className={buttonVariants({ variant: "outline" })} - > - <ExpandIcon className="h-4 w-4" /> - </Link> + <TooltipTrigger asChild> + <div className="flex h-10 items-center gap-1 rounded-md border border-blue-500/50 bg-blue-50 px-3 text-blue-700 dark:bg-blue-950 dark:text-blue-300"> + <Info className="h-4 w-4" /> + </div> </TooltipTrigger> - <TooltipContent side="bottom">FullScreen</TooltipContent> + <TooltipContent side="bottom" className="max-w-sm"> + <p className="text-sm"> + <Trans + i18nKey="preview.archive_info" + components={{ + 1: ( + <Link + prefetch={false} + href={`/api/assets/${bookmark.content.fullPageArchiveAssetId ?? bookmark.content.precrawledArchiveAssetId}`} + download + className="font-medium underline" + > + link + </Link> + ), + }} + /> + </p> + </TooltipContent> </Tooltip> )} </div> diff --git a/apps/web/components/dashboard/preview/NoteEditor.tsx b/apps/web/components/dashboard/preview/NoteEditor.tsx index 538aff2e..86807569 100644 --- a/apps/web/components/dashboard/preview/NoteEditor.tsx +++ b/apps/web/components/dashboard/preview/NoteEditor.tsx @@ -1,5 +1,5 @@ +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; diff --git a/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx new file mode 100644 index 00000000..f37b8263 --- /dev/null +++ b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { + Globe, + Laptop, + Minus, + Plus, + RotateCcw, + Settings, + Type, + X, +} from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +interface ReaderSettingsPopoverProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + variant?: "outline" | "ghost"; +} + +export default function ReaderSettingsPopover({ + open, + onOpenChange, + variant = "outline", +}: ReaderSettingsPopoverProps) { + const { t } = useTranslation(); + const { + settings, + serverSettings, + localOverrides, + sessionOverrides, + hasSessionChanges, + hasLocalOverrides, + isSaving, + updateSession, + clearSession, + saveToDevice, + clearLocalOverride, + saveToServer, + } = useReaderSettings(); + + // Helper to get the effective server value (server setting or default) + const getServerValue = <K extends keyof typeof serverSettings>(key: K) => { + return serverSettings[key] ?? READER_DEFAULTS[key]; + }; + + // Helper to check if a setting has a local override + const hasLocalOverride = (key: keyof typeof localOverrides) => { + return localOverrides[key] !== undefined; + }; + + // Build tooltip message for the settings button + const getSettingsTooltip = () => { + if (hasSessionChanges && hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_preview_and_local"); + } + if (hasSessionChanges) { + return t("settings.info.reader_settings.tooltip_preview"); + } + if (hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_local"); + } + return t("settings.info.reader_settings.tooltip_default"); + }; + + return ( + <Popover open={open} onOpenChange={onOpenChange}> + <Tooltip> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <Button variant={variant} size="icon" className="relative"> + <Settings className="h-4 w-4" /> + {(hasSessionChanges || hasLocalOverrides) && ( + <span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" /> + )} + </Button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent side="bottom"> + <p>{getSettingsTooltip()}</p> + </TooltipContent> + </Tooltip> + <PopoverContent + side="bottom" + align="center" + collisionPadding={32} + className="flex w-80 flex-col overflow-hidden p-0" + style={{ + maxHeight: "var(--radix-popover-content-available-height)", + }} + > + <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4"> + <div className="flex items-center justify-between pb-2"> + <div className="flex items-center gap-2"> + <Type className="h-4 w-4" /> + <h3 className="font-semibold"> + {t("settings.info.reader_settings.title")} + </h3> + </div> + {hasSessionChanges && ( + <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"> + {t("settings.info.reader_settings.preview")} + </span> + )} + </div> + + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_family")} + </label> + <div className="flex items-center gap-1"> + {sessionOverrides.fontFamily !== undefined && ( + <span className="text-xs text-muted-foreground"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + {hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontFamily")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: t( + `settings.info.reader_settings.${getServerValue("fontFamily")}` as const, + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <Select + value={settings.fontFamily} + onValueChange={(value) => + updateSession({ + fontFamily: value as "serif" | "sans" | "mono", + }) + } + > + <SelectTrigger + className={ + hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined + ? "border-primary/50" + : "" + } + > + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="serif"> + {t("settings.info.reader_settings.serif")} + </SelectItem> + <SelectItem value="sans"> + {t("settings.info.reader_settings.sans")} + </SelectItem> + <SelectItem value="mono"> + {t("settings.info.reader_settings.mono")} + </SelectItem> + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_size")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatFontSize(settings.fontSize)} + {sessionOverrides.fontSize !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontSize")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatFontSize( + getServerValue("fontSize"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.max( + READER_SETTING_CONSTRAINTS.fontSize.min, + settings.fontSize - + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.fontSize]} + onValueChange={([value]) => + updateSession({ fontSize: value }) + } + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + className={`flex-1 ${ + hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.min( + READER_SETTING_CONSTRAINTS.fontSize.max, + settings.fontSize + + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <Plus className="h-3 w-3" /> + </Button> + </div> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.line_height")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatLineHeight(settings.lineHeight)} + {sessionOverrides.lineHeight !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("lineHeight")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatLineHeight( + getServerValue("lineHeight"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.max( + READER_SETTING_CONSTRAINTS.lineHeight.min, + Math.round( + (settings.lineHeight - + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.lineHeight]} + onValueChange={([value]) => + updateSession({ lineHeight: value }) + } + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + className={`flex-1 ${ + hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.min( + READER_SETTING_CONSTRAINTS.lineHeight.max, + Math.round( + (settings.lineHeight + + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Plus className="h-3 w-3" /> + </Button> + </div> + </div> + + {hasSessionChanges && ( + <> + <Separator /> + + <div className="space-y-2"> + <Button + variant="outline" + size="sm" + className="w-full" + onClick={() => clearSession()} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.reset_preview")} + </Button> + + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToDevice()} + > + <Laptop className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_device")} + </Button> + <Button + variant="default" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToServer()} + > + <Globe className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_all_devices")} + </Button> + </div> + + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.save_hint")} + </p> + </div> + </> + )} + + {!hasSessionChanges && ( + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.adjust_hint")} + </p> + )} + </div> + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/web/components/dashboard/preview/ReaderView.tsx b/apps/web/components/dashboard/preview/ReaderView.tsx index f2f843ee..76070534 100644 --- a/apps/web/components/dashboard/preview/ReaderView.tsx +++ b/apps/web/components/dashboard/preview/ReaderView.tsx @@ -1,12 +1,15 @@ import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; +import { useTranslation } from "@/lib/i18n/client"; +import { useQuery } from "@tanstack/react-query"; +import { FileX } from "lucide-react"; import { useCreateHighlight, useDeleteHighlight, useUpdateHighlight, } from "@karakeep/shared-react/hooks/highlights"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import BookmarkHTMLHighlighter from "./BookmarkHtmlHighlighter"; @@ -22,11 +25,15 @@ export default function ReaderView({ style?: React.CSSProperties; readOnly: boolean; }) { - const { data: highlights } = api.highlights.getForBookmark.useQuery({ - bookmarkId, - }); - const { data: cachedContent, isPending: isCachedContentLoading } = - api.bookmarks.getBookmark.useQuery( + const { t } = useTranslation(); + const api = useTRPC(); + const { data: highlights } = useQuery( + api.highlights.getForBookmark.queryOptions({ + bookmarkId, + }), + ); + const { data: cachedContent, isPending: isCachedContentLoading } = useQuery( + api.bookmarks.getBookmark.queryOptions( { bookmarkId, includeContent: true, @@ -37,7 +44,8 @@ export default function ReaderView({ ? data.content.htmlContent : null, }, - ); + ), + ); const { mutate: createHighlight } = useCreateHighlight({ onSuccess: () => { @@ -86,7 +94,23 @@ export default function ReaderView({ content = <FullPageSpinner />; } else if (!cachedContent) { content = ( - <div className="text-destructive">Failed to fetch link content ...</div> + <div className="flex h-full w-full items-center justify-center p-4"> + <div className="max-w-sm space-y-4 text-center"> + <div className="flex justify-center"> + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> + <FileX className="h-8 w-8 text-muted-foreground" /> + </div> + </div> + <div className="space-y-2"> + <h3 className="text-lg font-medium text-foreground"> + {t("preview.fetch_error_title")} + </h3> + <p className="text-sm leading-relaxed text-muted-foreground"> + {t("preview.fetch_error_description")} + </p> + </div> + </div> + </div> ); } else { content = ( diff --git a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx index 8faca013..28bf690d 100644 --- a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx @@ -19,6 +19,7 @@ import { ChevronDown, ChevronRight, FileType, + Heading, Link, PlusCircle, Rss, @@ -28,7 +29,10 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { RuleEngineCondition } from "@karakeep/shared/types/rules"; +import type { + RuleEngineCondition, + RuleEngineEvent, +} from "@karakeep/shared/types/rules"; import { FeedSelector } from "../feeds/FeedSelector"; import { TagAutocomplete } from "../tags/TagAutocomplete"; @@ -36,6 +40,7 @@ import { TagAutocomplete } from "../tags/TagAutocomplete"; interface ConditionBuilderProps { value: RuleEngineCondition; onChange: (condition: RuleEngineCondition) => void; + eventType: RuleEngineEvent["type"]; level?: number; onRemove?: () => void; } @@ -43,6 +48,7 @@ interface ConditionBuilderProps { export function ConditionBuilder({ value, onChange, + eventType, level = 0, onRemove, }: ConditionBuilderProps) { @@ -54,6 +60,15 @@ export function ConditionBuilder({ case "urlContains": onChange({ type: "urlContains", str: "" }); break; + case "urlDoesNotContain": + onChange({ type: "urlDoesNotContain", str: "" }); + break; + case "titleContains": + onChange({ type: "titleContains", str: "" }); + break; + case "titleDoesNotContain": + onChange({ type: "titleDoesNotContain", str: "" }); + break; case "importedFromFeed": onChange({ type: "importedFromFeed", feedId: "" }); break; @@ -88,7 +103,11 @@ export function ConditionBuilder({ const renderConditionIcon = (type: RuleEngineCondition["type"]) => { switch (type) { case "urlContains": + case "urlDoesNotContain": return <Link className="h-4 w-4" />; + case "titleContains": + case "titleDoesNotContain": + return <Heading className="h-4 w-4" />; case "importedFromFeed": return <Rss className="h-4 w-4" />; case "bookmarkTypeIs": @@ -118,6 +137,42 @@ export function ConditionBuilder({ </div> ); + case "urlDoesNotContain": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="URL does not contain..." + className="w-full" + /> + </div> + ); + + case "titleContains": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="Title contains..." + className="w-full" + /> + </div> + ); + + case "titleDoesNotContain": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="Title does not contain..." + className="w-full" + /> + </div> + ); + case "importedFromFeed": return ( <div className="mt-2"> @@ -182,6 +237,7 @@ export function ConditionBuilder({ newConditions[index] = newCondition; onChange({ ...value, conditions: newConditions }); }} + eventType={eventType} level={level + 1} onRemove={() => { const newConditions = [...value.conditions]; @@ -217,6 +273,10 @@ export function ConditionBuilder({ } }; + // Title conditions are hidden for "bookmarkAdded" event because + // titles are not available at bookmark creation time (they're fetched during crawling) + const showTitleConditions = eventType !== "bookmarkAdded"; + const ConditionSelector = () => ( <Select value={value.type} onValueChange={handleTypeChange}> <SelectTrigger className="ml-2 h-8 border-none bg-transparent px-2"> @@ -235,6 +295,19 @@ export function ConditionBuilder({ <SelectItem value="urlContains"> {t("settings.rules.conditions_types.url_contains")} </SelectItem> + <SelectItem value="urlDoesNotContain"> + {t("settings.rules.conditions_types.url_does_not_contain")} + </SelectItem> + {showTitleConditions && ( + <SelectItem value="titleContains"> + {t("settings.rules.conditions_types.title_contains")} + </SelectItem> + )} + {showTitleConditions && ( + <SelectItem value="titleDoesNotContain"> + {t("settings.rules.conditions_types.title_does_not_contain")} + </SelectItem> + )} <SelectItem value="importedFromFeed"> {t("settings.rules.conditions_types.imported_from_feed")} </SelectItem> diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx index da10317a..e4859b4a 100644 --- a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx @@ -8,8 +8,8 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { Save, X } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -175,6 +175,7 @@ export function RuleEditor({ rule, onCancel }: RuleEditorProps) { <ConditionBuilder value={editedRule.condition} onChange={handleConditionChange} + eventType={editedRule.event.type} /> </div> diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx index 206a3550..32262b31 100644 --- a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx @@ -2,8 +2,8 @@ import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import { Edit, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index 15facb2d..4d3a690b 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -208,6 +208,17 @@ export default function QueryExplainerTooltip({ </TableCell> </TableRow> ); + case "source": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.is_not_from_source") + : t("search.is_from_source")} + </TableCell> + <TableCell>{matcher.source}</TableCell> + </TableRow> + ); default: { const _exhaustiveCheck: never = matcher; return null; diff --git a/apps/web/components/dashboard/search/useSearchAutocomplete.ts b/apps/web/components/dashboard/search/useSearchAutocomplete.ts index ba55d51f..c72f4fc5 100644 --- a/apps/web/components/dashboard/search/useSearchAutocomplete.ts +++ b/apps/web/components/dashboard/search/useSearchAutocomplete.ts @@ -2,8 +2,9 @@ import type translation from "@/lib/i18n/locales/en/translation.json"; import type { TFunction } from "i18next"; import type { LucideIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { + Globe, History, ListTree, RssIcon, @@ -14,6 +15,8 @@ import { import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; const MAX_DISPLAY_SUGGESTIONS = 5; @@ -97,10 +100,14 @@ const QUALIFIER_DEFINITIONS = [ value: "age:", descriptionKey: "search.created_within", }, + { + value: "source:", + descriptionKey: "search.is_from_source", + }, ] satisfies ReadonlyArray<QualifierDefinition>; export interface AutocompleteSuggestionItem { - type: "token" | "tag" | "list" | "feed"; + type: "token" | "tag" | "list" | "feed" | "source"; id: string; label: string; insertText: string; @@ -263,6 +270,7 @@ const useTagSuggestions = ( const { data: tagResults } = useTagAutocomplete({ nameContains: debouncedTagSearchTerm, select: (data) => data.tags, + enabled: parsed.activeToken.length > 0, }); const tagSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { @@ -292,6 +300,7 @@ const useTagSuggestions = ( const useFeedSuggestions = ( parsed: ParsedSearchState, ): AutocompleteSuggestionItem[] => { + const api = useTRPC(); const shouldSuggestFeeds = parsed.normalizedTokenWithoutMinus.startsWith("feed:"); const feedSearchTermRaw = shouldSuggestFeeds @@ -299,7 +308,11 @@ const useFeedSuggestions = ( : ""; const feedSearchTerm = stripSurroundingQuotes(feedSearchTermRaw); const normalizedFeedSearchTerm = feedSearchTerm.toLowerCase(); - const { data: feedResults } = api.feeds.list.useQuery(); + const { data: feedResults } = useQuery( + api.feeds.list.queryOptions(undefined, { + enabled: parsed.activeToken.length > 0, + }), + ); const feedSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { if (!shouldSuggestFeeds) { @@ -349,7 +362,9 @@ const useListSuggestions = ( : ""; const listSearchTerm = stripSurroundingQuotes(listSearchTermRaw); const normalizedListSearchTerm = listSearchTerm.toLowerCase(); - const { data: listResults } = useBookmarkLists(); + const { data: listResults } = useBookmarkLists(undefined, { + enabled: parsed.activeToken.length > 0, + }); const listSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { if (!shouldSuggestLists) { @@ -357,6 +372,7 @@ const useListSuggestions = ( } const lists = listResults?.data ?? []; + const seenListNames = new Set<string>(); return lists .filter((list) => { @@ -365,6 +381,15 @@ const useListSuggestions = ( } return list.name.toLowerCase().includes(normalizedListSearchTerm); }) + .filter((list) => { + const normalizedListName = list.name.trim().toLowerCase(); + if (seenListNames.has(normalizedListName)) { + return false; + } + + seenListNames.add(normalizedListName); + return true; + }) .slice(0, MAX_DISPLAY_SUGGESTIONS) .map((list) => { const formattedName = formatSearchValue(list.name); @@ -389,12 +414,53 @@ const useListSuggestions = ( return listSuggestions; }; +const SOURCE_VALUES = zBookmarkSourceSchema.options; + +const useSourceSuggestions = ( + parsed: ParsedSearchState, +): AutocompleteSuggestionItem[] => { + const shouldSuggestSources = + parsed.normalizedTokenWithoutMinus.startsWith("source:"); + const sourceSearchTerm = shouldSuggestSources + ? parsed.normalizedTokenWithoutMinus.slice("source:".length) + : ""; + + const sourceSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { + if (!shouldSuggestSources) { + return []; + } + + return SOURCE_VALUES.filter((source) => { + if (sourceSearchTerm.length === 0) { + return true; + } + return source.startsWith(sourceSearchTerm); + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map((source) => { + const insertText = `${parsed.isTokenNegative ? "-" : ""}source:${source}`; + return { + type: "source" as const, + id: `source-${source}`, + label: insertText, + insertText, + appendSpace: true, + description: undefined, + Icon: Globe, + } satisfies AutocompleteSuggestionItem; + }); + }, [shouldSuggestSources, sourceSearchTerm, parsed.isTokenNegative]); + + return sourceSuggestions; +}; + const useHistorySuggestions = ( value: string, history: string[], ): HistorySuggestionItem[] => { const historyItems = useMemo<HistorySuggestionItem[]>(() => { const trimmedValue = value.trim(); + const seenTerms = new Set<string>(); const results = trimmedValue.length === 0 ? history @@ -402,16 +468,27 @@ const useHistorySuggestions = ( item.toLowerCase().includes(trimmedValue.toLowerCase()), ); - return results.slice(0, MAX_DISPLAY_SUGGESTIONS).map( - (term) => - ({ - type: "history" as const, - id: `history-${term}`, - term, - label: term, - Icon: History, - }) satisfies HistorySuggestionItem, - ); + return results + .filter((term) => { + const normalizedTerm = term.trim().toLowerCase(); + if (seenTerms.has(normalizedTerm)) { + return false; + } + + seenTerms.add(normalizedTerm); + return true; + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map( + (term) => + ({ + type: "history" as const, + id: `history-${term}`, + term, + label: term, + Icon: History, + }) satisfies HistorySuggestionItem, + ); }, [history, value]); return historyItems; @@ -431,6 +508,7 @@ export const useSearchAutocomplete = ({ const tagSuggestions = useTagSuggestions(parsedState); const listSuggestions = useListSuggestions(parsedState); const feedSuggestions = useFeedSuggestions(parsedState); + const sourceSuggestions = useSourceSuggestions(parsedState); const historyItems = useHistorySuggestions(value, history); const { activeToken, getActiveToken } = parsedState; @@ -461,6 +539,14 @@ export const useSearchAutocomplete = ({ }); } + if (sourceSuggestions.length > 0) { + groups.push({ + id: "sources", + label: t("search.is_from_source"), + items: sourceSuggestions, + }); + } + // Only suggest qualifiers if no other suggestions are available if (groups.length === 0 && qualifierSuggestions.length > 0) { groups.push({ @@ -484,6 +570,7 @@ export const useSearchAutocomplete = ({ tagSuggestions, listSuggestions, feedSuggestions, + sourceSuggestions, historyItems, t, ]); diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx index 306bf4b4..d1099231 100644 --- a/apps/web/components/dashboard/sidebar/AllLists.tsx +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import SidebarItem from "@/components/shared/sidebar/SidebarItem"; @@ -10,6 +10,8 @@ import { CollapsibleContent, CollapsibleTriggerTriangle, } from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; import { MoreHorizontal, Plus } from "lucide-react"; @@ -17,6 +19,7 @@ import { MoreHorizontal, Plus } from "lucide-react"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { augmentBookmarkListsWithInitialData, + useAddBookmarkToList, useBookmarkLists, } from "@karakeep/shared-react/hooks/lists"; import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; @@ -26,6 +29,146 @@ import { EditListModal } from "../lists/EditListModal"; import { ListOptions } from "../lists/ListOptions"; import { InvitationNotificationBadge } from "./InvitationNotificationBadge"; +function useDropTarget(listId: string, listName: string) { + const { mutateAsync: addToList } = useAddBookmarkToList(); + const [dropHighlight, setDropHighlight] = useState(false); + const dragCounterRef = useRef(0); + const { t } = useTranslation(); + + const onDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }, []); + + const onDragEnter = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + dragCounterRef.current++; + setDropHighlight(true); + } + }, []); + + const onDragLeave = useCallback(() => { + dragCounterRef.current--; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setDropHighlight(false); + } + }, []); + + const onDrop = useCallback( + async (e: React.DragEvent) => { + dragCounterRef.current = 0; + setDropHighlight(false); + const bookmarkId = e.dataTransfer.getData(BOOKMARK_DRAG_MIME); + if (!bookmarkId) return; + e.preventDefault(); + try { + await addToList({ bookmarkId, listId }); + toast({ + description: t("lists.add_to_list_success", { + list: listName, + defaultValue: `Added to "${listName}"`, + }), + }); + } catch { + toast({ + description: t("common.something_went_wrong", { + defaultValue: "Something went wrong", + }), + variant: "destructive", + }); + } + }, + [addToList, listId, listName, t], + ); + + return { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop }; +} + +function DroppableListSidebarItem({ + node, + level, + open, + numBookmarks, + selectedListId, + setSelectedListId, +}: { + node: ZBookmarkListTreeNode; + level: number; + open: boolean; + numBookmarks?: number; + selectedListId: string | null; + setSelectedListId: (id: string | null) => void; +}) { + const canDrop = + node.item.type === "manual" && + (node.item.userRole === "owner" || node.item.userRole === "editor"); + const { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop } = + useDropTarget(node.item.id, node.item.name); + + return ( + <SidebarItem + collapseButton={ + node.children.length > 0 && ( + <CollapsibleTriggerTriangle + className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" + open={open} + /> + ) + } + logo={ + <span className="flex"> + <span className="text-lg"> {node.item.icon}</span> + </span> + } + name={node.item.name} + path={`/dashboard/lists/${node.item.id}`} + className="group px-0.5" + right={ + <ListOptions + onOpenChange={(isOpen) => { + if (isOpen) { + setSelectedListId(node.item.id); + } else { + setSelectedListId(null); + } + }} + list={node.item} + > + <Button size="none" variant="ghost" className="relative"> + <MoreHorizontal + className={cn( + "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", + selectedListId == node.item.id ? "opacity-100" : "opacity-0", + )} + /> + <span + className={cn( + "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", + selectedListId == node.item.id || numBookmarks === undefined + ? "opacity-0" + : "opacity-100", + )} + > + {numBookmarks} + </span> + </Button> + </ListOptions> + } + linkClassName="py-0.5" + style={{ marginLeft: `${level * 1}rem` }} + dropHighlight={canDrop && dropHighlight} + onDragOver={canDrop ? onDragOver : undefined} + onDragEnter={canDrop ? onDragEnter : undefined} + onDragLeave={canDrop ? onDragLeave : undefined} + onDrop={canDrop ? onDrop : undefined} + /> + ); +} + export default function AllLists({ initialData, }: { @@ -71,7 +214,7 @@ export default function AllLists({ }, [isViewingSharedList, sharedListsOpen]); return ( - <ul className="max-h-full gap-y-2 overflow-auto text-sm"> + <ul className="sidebar-scrollbar max-h-full gap-y-2 overflow-auto text-sm"> <li className="flex justify-between pb-3"> <p className="text-xs uppercase tracking-wider text-muted-foreground"> Lists @@ -107,59 +250,13 @@ export default function AllLists({ filter={(node) => node.item.userRole === "owner"} isOpenFunc={isNodeOpen} render={({ node, level, open, numBookmarks }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> @@ -187,59 +284,13 @@ export default function AllLists({ isOpenFunc={isNodeOpen} indentOffset={1} render={({ node, level, open, numBookmarks }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> diff --git a/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx b/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx index e4d7b39f..e3c65be9 100644 --- a/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx +++ b/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx @@ -1,13 +1,15 @@ "use client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export function InvitationNotificationBadge() { - const { data: pendingInvitations } = api.lists.getPendingInvitations.useQuery( - undefined, - { + const api = useTRPC(); + const { data: pendingInvitations } = useQuery( + api.lists.getPendingInvitations.queryOptions(undefined, { refetchInterval: 1000 * 60 * 5, - }, + }), ); const pendingInvitationsCount = pendingInvitations?.length ?? 0; diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx index c21f9aac..9708c37f 100644 --- a/apps/web/components/dashboard/tags/AllTagsView.tsx +++ b/apps/web/components/dashboard/tags/AllTagsView.tsx @@ -22,9 +22,9 @@ import { import InfoTooltip from "@/components/ui/info-tooltip"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; +import { toast } from "@/components/ui/sonner"; import Spinner from "@/components/ui/spinner"; 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 { ArrowDownAZ, ChevronDown, Combine, Search, Tag } from "lucide-react"; diff --git a/apps/web/components/dashboard/tags/BulkTagAction.tsx b/apps/web/components/dashboard/tags/BulkTagAction.tsx index fbd044e0..c8061a1f 100644 --- a/apps/web/components/dashboard/tags/BulkTagAction.tsx +++ b/apps/web/components/dashboard/tags/BulkTagAction.tsx @@ -4,8 +4,8 @@ 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 { toast } from "@/components/ui/sonner"; 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"; @@ -17,7 +17,6 @@ const MAX_CONCURRENT_BULK_ACTIONS = 50; export default function BulkTagAction() { const { t } = useTranslation(); - const { toast } = useToast(); const { selectedTagIds, diff --git a/apps/web/components/dashboard/tags/CreateTagModal.tsx b/apps/web/components/dashboard/tags/CreateTagModal.tsx index 3a4c4995..e5cf4a45 100644 --- a/apps/web/components/dashboard/tags/CreateTagModal.tsx +++ b/apps/web/components/dashboard/tags/CreateTagModal.tsx @@ -22,7 +22,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Plus } from "lucide-react"; diff --git a/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx index 0a589ee6..7df04e20 100644 --- a/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx +++ b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.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 { useDeleteTag } from "@karakeep/shared-react/hooks/tags"; diff --git a/apps/web/components/dashboard/tags/EditableTagName.tsx b/apps/web/components/dashboard/tags/EditableTagName.tsx index 7854be32..e6df5086 100644 --- a/apps/web/components/dashboard/tags/EditableTagName.tsx +++ b/apps/web/components/dashboard/tags/EditableTagName.tsx @@ -1,7 +1,7 @@ "use client"; import { usePathname, useRouter } from "next/navigation"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { useUpdateTag } from "@karakeep/shared-react/hooks/tags"; diff --git a/apps/web/components/dashboard/tags/MergeTagModal.tsx b/apps/web/components/dashboard/tags/MergeTagModal.tsx index 84dcd478..22b07c98 100644 --- a/apps/web/components/dashboard/tags/MergeTagModal.tsx +++ b/apps/web/components/dashboard/tags/MergeTagModal.tsx @@ -18,7 +18,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/tags/TagAutocomplete.tsx b/apps/web/components/dashboard/tags/TagAutocomplete.tsx index 8164dc81..656d4c5a 100644 --- a/apps/web/components/dashboard/tags/TagAutocomplete.tsx +++ b/apps/web/components/dashboard/tags/TagAutocomplete.tsx @@ -15,11 +15,12 @@ import { } from "@/components/ui/popover"; import LoadingSpinner from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import { Check, ChevronsUpDown, X } from "lucide-react"; import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface TagAutocompleteProps { tagId: string; @@ -32,6 +33,7 @@ export function TagAutocomplete({ onChange, className, }: TagAutocompleteProps) { + const api = useTRPC(); const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const searchQueryDebounced = useDebounce(searchQuery, 500); @@ -41,8 +43,8 @@ export function TagAutocomplete({ select: (data) => data.tags, }); - const { data: selectedTag, isLoading: isSelectedTagLoading } = - api.tags.get.useQuery( + const { data: selectedTag, isLoading: isSelectedTagLoading } = useQuery( + api.tags.get.queryOptions( { tagId, }, @@ -53,7 +55,8 @@ export function TagAutocomplete({ }), enabled: !!tagId, }, - ); + ), + ); const handleSelect = (currentValue: string) => { setOpen(false); diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx index 65a42e08..09310f9f 100644 --- a/apps/web/components/dashboard/tags/TagPill.tsx +++ b/apps/web/components/dashboard/tags/TagPill.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useDragAndDrop } from "@/lib/drag-and-drop"; import { X } from "lucide-react"; import Draggable from "react-draggable"; |
