aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components/dashboard')
-rw-r--r--apps/web/components/dashboard/BulkBookmarksAction.tsx80
-rw-r--r--apps/web/components/dashboard/ErrorFallback.tsx43
-rw-r--r--apps/web/components/dashboard/UploadDropzone.tsx10
-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
-rw-r--r--apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx18
-rw-r--r--apps/web/components/dashboard/feeds/FeedSelector.tsx13
-rw-r--r--apps/web/components/dashboard/header/ProfileOptions.tsx56
-rw-r--r--apps/web/components/dashboard/highlights/AllHighlights.tsx52
-rw-r--r--apps/web/components/dashboard/highlights/HighlightCard.tsx2
-rw-r--r--apps/web/components/dashboard/lists/AllListsView.tsx9
-rw-r--r--apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx13
-rw-r--r--apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx2
-rw-r--r--apps/web/components/dashboard/lists/EditListModal.tsx4
-rw-r--r--apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx56
-rw-r--r--apps/web/components/dashboard/lists/ListHeader.tsx86
-rw-r--r--apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx223
-rw-r--r--apps/web/components/dashboard/lists/MergeListModal.tsx2
-rw-r--r--apps/web/components/dashboard/lists/PendingInvitationsCard.tsx94
-rw-r--r--apps/web/components/dashboard/lists/RssLink.tsx34
-rw-r--r--apps/web/components/dashboard/preview/ActionBar.tsx2
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx2
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx34
-rw-r--r--apps/web/components/dashboard/preview/HighlightsBox.tsx10
-rw-r--r--apps/web/components/dashboard/preview/LinkContentSection.tsx80
-rw-r--r--apps/web/components/dashboard/preview/NoteEditor.tsx2
-rw-r--r--apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx457
-rw-r--r--apps/web/components/dashboard/preview/ReaderView.tsx42
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx75
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx3
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineRuleList.tsx2
-rw-r--r--apps/web/components/dashboard/search/QueryExplainerTooltip.tsx11
-rw-r--r--apps/web/components/dashboard/search/useSearchAutocomplete.ts115
-rw-r--r--apps/web/components/dashboard/sidebar/AllLists.tsx267
-rw-r--r--apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx12
-rw-r--r--apps/web/components/dashboard/tags/AllTagsView.tsx2
-rw-r--r--apps/web/components/dashboard/tags/BulkTagAction.tsx3
-rw-r--r--apps/web/components/dashboard/tags/CreateTagModal.tsx2
-rw-r--r--apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx2
-rw-r--r--apps/web/components/dashboard/tags/EditableTagName.tsx2
-rw-r--r--apps/web/components/dashboard/tags/MergeTagModal.tsx2
-rw-r--r--apps/web/components/dashboard/tags/TagAutocomplete.tsx11
-rw-r--r--apps/web/components/dashboard/tags/TagPill.tsx2
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&apos;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 &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 } =
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";