From 88c73e212c4510ce41ad8c6557fa7d5c8f72d199 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Mon, 17 Nov 2025 01:12:41 +0000 Subject: feat: Add collaborative lists (#2146) * feat: Add collaborative lists backend implementation This commit implements the core backend functionality for collaborative lists, allowing multiple users to share and interact with bookmark lists. Database changes: - Add listCollaborators table to track users with access to lists and their roles (viewer/editor) - Add addedBy field to bookmarksInLists to track who added bookmarks - Add relations for collaborative list functionality Access control updates: - Update List model to support role-based access (owner/editor/viewer) - Add methods to check and enforce permissions for list operations - Update Bookmark model to allow access through collaborative lists - Modify bookmark queries to include bookmarks from collaborative lists List collaboration features: - Add/remove/update collaborators - Get list of collaborators - Get lists shared with current user - Only manual lists can have collaborators tRPC procedures: - addCollaborator: Add a user as a collaborator to a list - removeCollaborator: Remove a collaborator from a list - updateCollaboratorRole: Change a collaborator's role - getCollaborators: Get all collaborators for a list - getSharedWithMe: Get all lists shared with the current user - cloneBookmark: Clone a bookmark to the current user's collection Implementation notes: - Editors can add/remove bookmarks from the list (must own the bookmark) - Viewers can only view bookmarks in the list - Only the list owner can manage collaborators and list metadata - Smart lists cannot have collaborators (only manual lists) - Users cannot edit bookmarks they don't own, even in shared lists * feat: Add collaborative lists frontend UI This commit implements the frontend user interface for collaborative lists, allowing users to view shared bookmarks and manage list collaborators. New pages: - /dashboard/shared: Shows bookmarks from lists shared with the user - Displays bookmarks from all collaborative lists - Uses SharedBookmarks component - Shows empty state when no lists are shared Navigation: - Added "Shared with you" link to sidebar with Users icon - Positioned after "Home" in main navigation - Available in both desktop and mobile sidebar Collaborator management: - ManageCollaboratorsModal component for managing list collaborators - Add collaborators by user ID with viewer/editor role - View current collaborators with their roles - Update collaborator roles inline - Remove collaborators - Shows empty state when no collaborators - Integrated into ListOptions dropdown menu - Accessible via "Manage Collaborators" menu item Components created: - SharedBookmarks.tsx: Server component fetching shared lists/bookmarks - ManageCollaboratorsModal.tsx: Client component with tRPC mutations - /dashboard/shared/page.tsx: Route for shared bookmarks page UI features: - Role selector for viewer/editor permissions - Real-time collaborator list updates - Toast notifications for success/error states - Loading states for async operations - Responsive design matching existing UI patterns Implementation notes: - Uses existing tRPC endpoints (getSharedWithMe, getCollaborators, etc.) - Follows established modal patterns from ShareListModal - Integrates seamlessly with existing list UI - Currently uses user ID for adding collaborators (email lookup TBD) * fix typecheck * add collaborator by email * add shared list in the sidebar * fix perm issue * hide UI components from non list owners * list leaving * fix shared bookmarks showing up in homepage * fix getBookmark access check * e2e tests * hide user specific fields from shared lists * simplify bookmark perm checks * disable editable fields in bookmark preview * hide lists if they don't have options * fix list ownership * fix highlights * move tests to trpc * fix alignment of leave list * make tag lists unclickable * allow editors to remove from list * add a badge for shared lists * remove bookmarks of user when they're removed from a list * fix tests * show owner in the manage collab modal * fix hasCollab * drop shared with you * i18n * beta badge * correctly invalidate caches on collab change * reduce unnecessary changes * Add ratelimits * stop manually removing bookmarks on remove * some fixes * fixes * remove unused function * improve tests --------- Co-authored-by: Claude --- apps/web/app/dashboard/lists/[listId]/page.tsx | 5 +- apps/web/app/reader/[bookmarkId]/page.tsx | 14 +- .../bookmarks/BookmarkLayoutAdaptingCard.tsx | 6 +- .../dashboard/bookmarks/BookmarkOptions.tsx | 267 +++++++++------- .../dashboard/bookmarks/BookmarkTagsEditor.tsx | 9 +- .../dashboard/bookmarks/SummarizeBookmarkArea.tsx | 64 ++-- .../web/components/dashboard/bookmarks/TagList.tsx | 36 ++- .../components/dashboard/bookmarks/TagsEditor.tsx | 4 +- .../dashboard/highlights/AllHighlights.tsx | 2 +- .../dashboard/highlights/HighlightCard.tsx | 22 +- .../dashboard/lists/CollapsibleBookmarkLists.tsx | 14 +- .../lists/LeaveListConfirmationDialog.tsx | 83 +++++ apps/web/components/dashboard/lists/ListHeader.tsx | 30 +- .../web/components/dashboard/lists/ListOptions.tsx | 161 +++++++--- .../dashboard/lists/ManageCollaboratorsModal.tsx | 345 +++++++++++++++++++++ .../components/dashboard/preview/AttachmentBox.tsx | 66 ++-- .../dashboard/preview/BookmarkHtmlHighlighter.tsx | 6 + .../dashboard/preview/BookmarkPreview.tsx | 17 +- .../components/dashboard/preview/HighlightsBox.tsx | 14 +- .../dashboard/preview/LinkContentSection.tsx | 4 + .../components/dashboard/preview/NoteEditor.tsx | 10 +- .../components/dashboard/preview/ReaderView.tsx | 3 + apps/web/lib/i18n/locales/en/translation.json | 32 ++ 23 files changed, 944 insertions(+), 270 deletions(-) create mode 100644 apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx create mode 100644 apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx (limited to 'apps/web') diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx index 3f9c3416..2b2cc9bb 100644 --- a/apps/web/app/dashboard/lists/[listId]/page.tsx +++ b/apps/web/app/dashboard/lists/[listId]/page.tsx @@ -50,6 +50,9 @@ export default async function ListPage(props: { ? searchParams.includeArchived === "true" : userSettings.archiveDisplayBehaviour === "show"; + // Only show editor card if user is owner or editor (not viewer) + const canEdit = list.userRole === "owner" || list.userRole === "editor"; + return ( } /> diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx index 7c2b0c9e..e32811a9 100644 --- a/apps/web/app/reader/[bookmarkId]/page.tsx +++ b/apps/web/app/reader/[bookmarkId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { Suspense, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import HighlightCard from "@/components/dashboard/highlights/HighlightCard"; import ReaderView from "@/components/dashboard/preview/ReaderView"; import { Button } from "@/components/ui/button"; @@ -29,16 +29,14 @@ import { Type, X, } from "lucide-react"; +import { useSession } from "next-auth/react"; import { api } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils"; -export default function ReaderViewPage({ - params, -}: { - params: { bookmarkId: string }; -}) { +export default function ReaderViewPage() { + const params = useParams<{ bookmarkId: string }>(); const bookmarkId = params.bookmarkId; const { data: highlights } = api.highlights.getForBookmark.useQuery({ bookmarkId, @@ -47,12 +45,14 @@ export default function ReaderViewPage({ bookmarkId, }); + const { data: session } = useSession(); const router = useRouter(); const [fontSize, setFontSize] = useState([18]); const [lineHeight, setLineHeight] = useState([1.6]); const [fontFamily, setFontFamily] = useState("serif"); const [showHighlights, setShowHighlights] = useState(false); const [showSettings, setShowSettings] = useState(false); + const isOwner = session?.user?.id === bookmark?.userId; const fontFamilies = { serif: "ui-serif, Georgia, Cambria, serif", @@ -245,6 +245,7 @@ export default function ReaderViewPage({ lineHeight: lineHeight[0], }} bookmarkId={bookmarkId} + readOnly={!isOwner} /> @@ -299,6 +300,7 @@ export default function ReaderViewPage({ key={highlight.id} highlight={highlight} clickable={true} + readOnly={!isOwner} /> ))} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index 98babb22..e8520b1a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -13,6 +13,7 @@ import { } 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 { useTheme } from "next-themes"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; @@ -64,12 +65,15 @@ function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark); const [isSelected, setIsSelected] = useState(false); const { theme } = useTheme(); + const { data: session } = useSession(); useEffect(() => { setIsSelected(selectedBookmarks.some((item) => item.id === bookmark.id)); }, [selectedBookmarks]); - if (!isBulkEditEnabled) return null; + // Don't show selector for non-owned bookmarks or when bulk edit is disabled + const isOwner = session?.user?.id === bookmark.userId; + if (!isBulkEditEnabled || !isOwner) return null; const getIconColor = () => { if (theme === "dark") { diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 4725c77f..66de6156 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -22,6 +22,7 @@ import { SquarePen, Trash2, } from "lucide-react"; +import { useSession } from "next-auth/react"; import type { ZBookmark, @@ -46,9 +47,13 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); const { toast } = useToast(); const linkId = bookmark.id; + const { data: session } = useSession(); const demoMode = !!useClientConfig().demoMode; + // Check if the current user owns this bookmark + const isOwner = session?.user?.id === bookmark.userId; + const [isClipboardAvailable, setIsClipboardAvailable] = useState(false); useEffect(() => { @@ -114,6 +119,142 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { onError, }); + // Define action items array + const actionItems = [ + { + id: "edit", + title: t("actions.edit"), + icon: , + visible: isOwner, + disabled: false, + onClick: () => setEditBookmarkDialogOpen(true), + }, + { + id: "open-editor", + title: t("actions.open_editor"), + icon: , + visible: isOwner && bookmark.content.type === BookmarkTypes.TEXT, + disabled: false, + onClick: () => setTextEditorOpen(true), + }, + { + id: "favorite", + title: bookmark.favourited + ? t("actions.unfavorite") + : t("actions.favorite"), + icon: ( + + ), + visible: isOwner, + disabled: demoMode, + onClick: () => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + favourited: !bookmark.favourited, + }), + }, + { + id: "archive", + title: bookmark.archived ? t("actions.unarchive") : t("actions.archive"), + icon: ( + + ), + visible: isOwner, + disabled: demoMode, + onClick: () => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + archived: !bookmark.archived, + }), + }, + { + id: "download-full-page", + title: t("actions.download_full_page_archive"), + icon: , + 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: , + visible: bookmark.content.type === BookmarkTypes.LINK, + disabled: !isClipboardAvailable, + onClick: () => { + navigator.clipboard.writeText( + (bookmark.content as ZBookmarkedLink).url, + ); + toast({ + description: t("toasts.bookmarks.clipboard_copied"), + }); + }, + }, + { + id: "manage-lists", + title: t("actions.manage_lists"), + icon: , + visible: isOwner, + disabled: false, + onClick: () => setManageListsModalOpen(true), + }, + { + id: "remove-from-list", + title: t("actions.remove_from_list"), + icon: , + visible: + (isOwner || + (withinListContext && + (withinListContext.userRole === "editor" || + withinListContext.userRole === "owner"))) && + !!listId && + !!withinListContext && + withinListContext.type === "manual", + disabled: demoMode, + onClick: () => + removeFromListMutator.mutate({ + listId: listId!, + bookmarkId: bookmark.id, + }), + }, + { + id: "refresh", + title: t("actions.refresh"), + icon: , + visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, + disabled: demoMode, + onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + }, + { + id: "delete", + title: t("actions.delete"), + icon: , + visible: isOwner, + disabled: demoMode, + className: "text-destructive", + onClick: () => setDeleteBookmarkDialogOpen(true), + }, + ]; + + // Filter visible items + const visibleItems = actionItems.filter((item) => item.visible); + + // If no items are visible, don't render the dropdown + if (visibleItems.length === 0) { + return null; + } + return ( <> {manageListsModal} @@ -142,127 +283,17 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { - setEditBookmarkDialogOpen(true)}> - - {t("actions.edit")} - - {bookmark.content.type === BookmarkTypes.TEXT && ( - setTextEditorOpen(true)}> - - {t("actions.open_editor")} - - )} - - updateBookmarkMutator.mutate({ - bookmarkId: linkId, - favourited: !bookmark.favourited, - }) - } - > - - - {bookmark.favourited - ? t("actions.unfavorite") - : t("actions.favorite")} - - - - updateBookmarkMutator.mutate({ - bookmarkId: linkId, - archived: !bookmark.archived, - }) - } - > - - - {bookmark.archived - ? t("actions.unarchive") - : t("actions.archive")} - - - - {bookmark.content.type === BookmarkTypes.LINK && ( - { - fullPageArchiveBookmarkMutator.mutate({ - bookmarkId: bookmark.id, - archiveFullPage: true, - }); - }} - > - - {t("actions.download_full_page_archive")} - - )} - - {bookmark.content.type === BookmarkTypes.LINK && ( + {visibleItems.map((item) => ( { - navigator.clipboard.writeText( - (bookmark.content as ZBookmarkedLink).url, - ); - toast({ - description: t("toasts.bookmarks.clipboard_copied"), - }); - }} + key={item.id} + disabled={item.disabled} + className={item.className} + onClick={item.onClick} > - - {t("actions.copy_link")} + {item.icon} + {item.title} - )} - - setManageListsModalOpen(true)}> - - {t("actions.manage_lists")} - - - {listId && - withinListContext && - withinListContext.type === "manual" && ( - - removeFromListMutator.mutate({ - listId, - bookmarkId: bookmark.id, - }) - } - > - - {t("actions.remove_from_list")} - - )} - - {bookmark.content.type === BookmarkTypes.LINK && ( - - crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) - } - > - - {t("actions.refresh")} - - )} - setDeleteBookmarkDialogOpen(true)} - > - - {t("actions.delete")} - + ))} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx index fa4f40de..22b5408e 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx @@ -5,7 +5,13 @@ import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks"; import { TagsEditor } from "./TagsEditor"; -export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) { +export function BookmarkTagsEditor({ + bookmark, + disabled, +}: { + bookmark: ZBookmark; + disabled?: boolean; +}) { const { mutate } = useUpdateBookmarkTags({ onSuccess: () => { toast({ @@ -24,6 +30,7 @@ export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) { return ( { mutate({ bookmarkId: bookmark.id, diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx index e75886e1..b2cf118e 100644 --- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx +++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx @@ -17,9 +17,11 @@ import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; function AISummary({ bookmarkId, summary, + readOnly = false, }: { bookmarkId: string; summary: string; + readOnly?: boolean; }) { const [isExpanded, setIsExpanded] = React.useState(false); const { mutate: resummarize, isPending: isResummarizing } = @@ -60,28 +62,34 @@ function AISummary({ {isExpanded && ( - } - className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400" - aria-label={isExpanded ? "Collapse" : "Expand"} - loading={isResummarizing} - onClick={() => resummarize({ bookmarkId })} - > - - - } - className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400" - aria-label={isExpanded ? "Collapse" : "Expand"} - loading={isUpdatingBookmark} - onClick={() => updateBookmark({ bookmarkId, summary: null })} - > - - + {!readOnly && ( + <> + } + className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400" + aria-label={isExpanded ? "Collapse" : "Expand"} + loading={isResummarizing} + onClick={() => resummarize({ bookmarkId })} + > + + + } + className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400" + aria-label={isExpanded ? "Collapse" : "Expand"} + loading={isUpdatingBookmark} + onClick={() => + updateBookmark({ bookmarkId, summary: null }) + } + > + + + + )} + +

+ {t("lists.collaborators.viewer")}:{" "} + {t("lists.collaborators.viewer_description")} +
+ {t("lists.collaborators.editor")}:{" "} + {t("lists.collaborators.editor_description")} +

+ + )} + + {/* Current Collaborators */} +
+ + {isLoading ? ( +
+ +
+ ) : collaboratorsData ? ( +
+ {/* Show owner first */} + {collaboratorsData.owner && ( +
+
+
+ {collaboratorsData.owner.name} +
+
+ {collaboratorsData.owner.email} +
+
+
+ {t("lists.collaborators.owner")} +
+
+ )} + {/* Show collaborators */} + {collaboratorsData.collaborators.length > 0 ? ( + collaboratorsData.collaborators.map((collaborator) => ( +
+
+
+ {collaborator.user.name} +
+
+ {collaborator.user.email} +
+
+ {readOnly ? ( +
+ {collaborator.role} +
+ ) : ( +
+ + +
+ )} +
+ )) + ) : !collaboratorsData.owner ? ( +
+ {readOnly + ? t("lists.collaborators.no_collaborators_readonly") + : t("lists.collaborators.no_collaborators")} +
+ ) : null} +
+ ) : ( +
+ {readOnly + ? t("lists.collaborators.no_collaborators_readonly") + : t("lists.collaborators.no_collaborators")} +
+ )} +
+ + + + + + + + + + ); +} diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx index e24cc646..73eea640 100644 --- a/apps/web/components/dashboard/preview/AttachmentBox.tsx +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -27,7 +27,13 @@ import { isAllowedToDetachAsset, } from "@karakeep/trpc/lib/attachments"; -export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { +export default function AttachmentBox({ + bookmark, + readOnly = false, +}: { + bookmark: ZBookmark; + readOnly?: boolean; +}) { const { t } = useTranslation(); const { mutate: attachAsset, isPending: isAttaching } = useAttachBookmarkAsset({ @@ -122,7 +128,8 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { > - {isAllowedToAttachAsset(asset.assetType) && + {!readOnly && + isAllowedToAttachAsset(asset.assetType) && asset.assetType !== "userUploaded" && ( )} - {isAllowedToDetachAsset(asset.assetType) && ( + {!readOnly && isAllowedToDetachAsset(asset.assetType) && ( ))} - {!bookmark.assets.some((asset) => asset.assetType == "bannerImage") && + {!readOnly && + !bookmark.assets.some((asset) => asset.assetType == "bannerImage") && bookmark.content.type != BookmarkTypes.ASSET && ( )} - - uploadAsset(file, { - onSuccess: (resp) => { - attachAsset({ - bookmarkId: bookmark.id, - asset: { - id: resp.assetId, - assetType: "userUploaded", - }, - }); - }, - }) - } - > - - Upload File - + {!readOnly && ( + + uploadAsset(file, { + onSuccess: (resp) => { + attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: resp.assetId, + assetType: "userUploaded", + }, + }); + }, + }) + } + > + + Upload File + + )} ); diff --git a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx index 19499d3e..e0f20ea2 100644 --- a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx +++ b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx @@ -95,6 +95,7 @@ interface HTMLHighlighterProps { style?: React.CSSProperties; className?: string; highlights?: Highlight[]; + readOnly?: boolean; onHighlight?: (highlight: Highlight) => void; onUpdateHighlight?: (highlight: Highlight) => void; onDeleteHighlight?: (highlight: Highlight) => void; @@ -105,6 +106,7 @@ function BookmarkHTMLHighlighter({ className, style, highlights = [], + readOnly = false, onHighlight, onUpdateHighlight, onDeleteHighlight, @@ -173,6 +175,10 @@ function BookmarkHTMLHighlighter({ }, [pendingHighlight, contentRef]); const handlePointerUp = (e: React.PointerEvent) => { + if (readOnly) { + return; + } + const selection = window.getSelection(); // Check if we clicked on an existing highlight diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index 4766bd32..7e6bf814 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -17,6 +17,7 @@ import useRelativeTime from "@/lib/hooks/relative-time"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { Building, CalendarDays, ExternalLink, User } from "lucide-react"; +import { useSession } from "next-auth/react"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { @@ -117,6 +118,7 @@ export default function BookmarkPreview({ }) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("content"); + const { data: session } = useSession(); const { data: bookmark } = api.bookmarks.getBookmark.useQuery( { @@ -138,6 +140,9 @@ export default function BookmarkPreview({ return ; } + // Check if the current user owns this bookmark + const isOwner = session?.user?.id === bookmark.userId; + let content; switch (bookmark.content.type) { case BookmarkTypes.LINK: { @@ -186,18 +191,18 @@ export default function BookmarkPreview({ - +

{t("common.tags")}

- +

{t("common.note")}

- +
- - - + + + {isOwner && } ); diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx index 4da22d04..41ab7d74 100644 --- a/apps/web/components/dashboard/preview/HighlightsBox.tsx +++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx @@ -11,7 +11,13 @@ import { ChevronsDownUp } from "lucide-react"; import HighlightCard from "../highlights/HighlightCard"; -export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) { +export default function HighlightsBox({ + bookmarkId, + readOnly, +}: { + bookmarkId: string; + readOnly: boolean; +}) { const { t } = useTranslation(); const { data: highlights, isPending: isLoading } = @@ -30,7 +36,11 @@ export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) { {highlights.highlights.map((highlight) => ( - + ))} diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index 53559aa0..64b62df6 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -25,6 +25,7 @@ import { ExpandIcon, Video, } from "lucide-react"; +import { useSession } from "next-auth/react"; import { useQueryState } from "nuqs"; import { ErrorBoundary } from "react-error-boundary"; @@ -111,6 +112,8 @@ export default function LinkContentSection({ const [section, setSection] = useQueryState("section", { defaultValue: defaultSection, }); + const { data: session } = useSession(); + const isOwner = session?.user?.id === bookmark.userId; if (bookmark.content.type != BookmarkTypes.LINK) { throw new Error("Invalid content type"); @@ -133,6 +136,7 @@ export default function LinkContentSection({ ); diff --git a/apps/web/components/dashboard/preview/NoteEditor.tsx b/apps/web/components/dashboard/preview/NoteEditor.tsx index 393628b5..538aff2e 100644 --- a/apps/web/components/dashboard/preview/NoteEditor.tsx +++ b/apps/web/components/dashboard/preview/NoteEditor.tsx @@ -5,7 +5,13 @@ import { useClientConfig } from "@/lib/clientConfig"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; -export function NoteEditor({ bookmark }: { bookmark: ZBookmark }) { +export function NoteEditor({ + bookmark, + disabled, +}: { + bookmark: ZBookmark; + disabled?: boolean; +}) { const demoMode = !!useClientConfig().demoMode; const updateBookmarkMutator = useUpdateBookmark({ @@ -26,7 +32,7 @@ export function NoteEditor({ bookmark }: { bookmark: ZBookmark }) {