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