diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-17 01:12:41 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-17 01:12:41 +0000 |
| commit | 88c73e212c4510ce41ad8c6557fa7d5c8f72d199 (patch) | |
| tree | 11f47349b8c34de1bf541febd9ba48cc44aa305a /apps/web/components/dashboard/bookmarks | |
| parent | cc8fee0d28d87299ee9a3ad11dcb4ae5a7b86c15 (diff) | |
| download | karakeep-88c73e212c4510ce41ad8c6557fa7d5c8f72d199.tar.zst | |
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 <noreply@anthropic.com>
Diffstat (limited to 'apps/web/components/dashboard/bookmarks')
6 files changed, 231 insertions, 155 deletions
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: <Pencil className="mr-2 size-4" />, + visible: isOwner, + disabled: false, + onClick: () => setEditBookmarkDialogOpen(true), + }, + { + id: "open-editor", + title: t("actions.open_editor"), + icon: <SquarePen className="mr-2 size-4" />, + visible: isOwner && bookmark.content.type === BookmarkTypes.TEXT, + disabled: false, + onClick: () => setTextEditorOpen(true), + }, + { + id: "favorite", + title: bookmark.favourited + ? t("actions.unfavorite") + : t("actions.favorite"), + icon: ( + <FavouritedActionIcon + className="mr-2 size-4" + favourited={bookmark.favourited} + /> + ), + visible: isOwner, + disabled: demoMode, + onClick: () => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + favourited: !bookmark.favourited, + }), + }, + { + id: "archive", + title: bookmark.archived ? t("actions.unarchive") : t("actions.archive"), + icon: ( + <ArchivedActionIcon + className="mr-2 size-4" + archived={bookmark.archived} + /> + ), + visible: isOwner, + disabled: demoMode, + onClick: () => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + archived: !bookmark.archived, + }), + }, + { + 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" />, + 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: <List className="mr-2 size-4" />, + visible: isOwner, + disabled: false, + onClick: () => setManageListsModalOpen(true), + }, + { + id: "remove-from-list", + title: t("actions.remove_from_list"), + icon: <ListX className="mr-2 size-4" />, + 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: <RotateCw className="mr-2 size-4" />, + visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, + disabled: demoMode, + onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + }, + { + id: "delete", + title: t("actions.delete"), + icon: <Trash2 className="mr-2 size-4" />, + 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 }) { </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> - <DropdownMenuItem onClick={() => setEditBookmarkDialogOpen(true)}> - <Pencil className="mr-2 size-4" /> - <span>{t("actions.edit")}</span> - </DropdownMenuItem> - {bookmark.content.type === BookmarkTypes.TEXT && ( - <DropdownMenuItem onClick={() => setTextEditorOpen(true)}> - <SquarePen className="mr-2 size-4" /> - <span>{t("actions.open_editor")}</span> - </DropdownMenuItem> - )} - <DropdownMenuItem - disabled={demoMode} - onClick={() => - updateBookmarkMutator.mutate({ - bookmarkId: linkId, - favourited: !bookmark.favourited, - }) - } - > - <FavouritedActionIcon - className="mr-2 size-4" - favourited={bookmark.favourited} - /> - <span> - {bookmark.favourited - ? t("actions.unfavorite") - : t("actions.favorite")} - </span> - </DropdownMenuItem> - <DropdownMenuItem - disabled={demoMode} - onClick={() => - updateBookmarkMutator.mutate({ - bookmarkId: linkId, - archived: !bookmark.archived, - }) - } - > - <ArchivedActionIcon - className="mr-2 size-4" - archived={bookmark.archived} - /> - <span> - {bookmark.archived - ? t("actions.unarchive") - : t("actions.archive")} - </span> - </DropdownMenuItem> - - {bookmark.content.type === BookmarkTypes.LINK && ( - <DropdownMenuItem - onClick={() => { - fullPageArchiveBookmarkMutator.mutate({ - bookmarkId: bookmark.id, - archiveFullPage: true, - }); - }} - > - <FileDown className="mr-2 size-4" /> - <span>{t("actions.download_full_page_archive")}</span> - </DropdownMenuItem> - )} - - {bookmark.content.type === BookmarkTypes.LINK && ( + {visibleItems.map((item) => ( <DropdownMenuItem - disabled={!isClipboardAvailable} - onClick={() => { - 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} > - <Link className="mr-2 size-4" /> - <span>{t("actions.copy_link")}</span> + {item.icon} + <span>{item.title}</span> </DropdownMenuItem> - )} - - <DropdownMenuItem onClick={() => setManageListsModalOpen(true)}> - <List className="mr-2 size-4" /> - <span>{t("actions.manage_lists")}</span> - </DropdownMenuItem> - - {listId && - withinListContext && - withinListContext.type === "manual" && ( - <DropdownMenuItem - disabled={demoMode} - onClick={() => - removeFromListMutator.mutate({ - listId, - bookmarkId: bookmark.id, - }) - } - > - <ListX className="mr-2 size-4" /> - <span>{t("actions.remove_from_list")}</span> - </DropdownMenuItem> - )} - - {bookmark.content.type === BookmarkTypes.LINK && ( - <DropdownMenuItem - disabled={demoMode} - onClick={() => - crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) - } - > - <RotateCw className="mr-2 size-4" /> - <span>{t("actions.refresh")}</span> - </DropdownMenuItem> - )} - <DropdownMenuItem - disabled={demoMode} - className="text-destructive" - onClick={() => setDeleteBookmarkDialogOpen(true)} - > - <Trash2 className="mr-2 size-4" /> - <span>{t("actions.delete")}</span> - </DropdownMenuItem> + ))} </DropdownMenuContent> </DropdownMenu> </> 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 ( <TagsEditor tags={bookmark.tags} + disabled={disabled} onAttach={({ tagName, tagId }) => { 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({ </MarkdownReadonly> {isExpanded && ( <span className="flex justify-end gap-2 pt-2"> - <ActionButton - variant="none" - size="none" - spinner={<LoadingSpinner className="size-4" />} - 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 })} - > - <RefreshCw size={16} /> - </ActionButton> - <ActionButton - size="none" - variant="none" - spinner={<LoadingSpinner className="size-4" />} - 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 })} - > - <Trash2 size={16} /> - </ActionButton> + {!readOnly && ( + <> + <ActionButton + variant="none" + size="none" + spinner={<LoadingSpinner className="size-4" />} + 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 })} + > + <RefreshCw size={16} /> + </ActionButton> + <ActionButton + size="none" + variant="none" + spinner={<LoadingSpinner className="size-4" />} + 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 }) + } + > + <Trash2 size={16} /> + </ActionButton> + </> + )} <button className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400" aria-label="Collapse" @@ -99,8 +107,10 @@ function AISummary({ export default function SummarizeBookmarkArea({ bookmark, + readOnly = false, }: { bookmark: ZBookmark; + readOnly?: boolean; }) { const { t } = useTranslation(); const { mutate, isPending } = useSummarizeBookmark({ @@ -118,8 +128,14 @@ export default function SummarizeBookmarkArea({ } if (bookmark.summary) { - return <AISummary bookmarkId={bookmark.id} summary={bookmark.summary} />; - } else if (!clientConfig.inference.isConfigured) { + return ( + <AISummary + bookmarkId={bookmark.id} + summary={bookmark.summary} + readOnly={readOnly} + /> + ); + } else if (!clientConfig.inference.isConfigured || readOnly) { return null; } else { return ( diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx index 593a269b..f1c319ea 100644 --- a/apps/web/components/dashboard/bookmarks/TagList.tsx +++ b/apps/web/components/dashboard/bookmarks/TagList.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { badgeVariants } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; +import { useSession } from "next-auth/react"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; @@ -14,6 +15,9 @@ export default function TagList({ loading?: boolean; className?: string; }) { + const { data: session } = useSession(); + const isOwner = session?.user?.id === bookmark.userId; + if (loading) { return ( <div className="flex w-full flex-col justify-end space-y-2 p-2"> @@ -26,16 +30,28 @@ export default function TagList({ <> {bookmark.tags.map((t) => ( <div key={t.id} className={className}> - <Link - key={t.id} - className={cn( - badgeVariants({ variant: "secondary" }), - "text-nowrap font-light text-gray-700 hover:bg-foreground hover:text-secondary dark:text-gray-400", - )} - href={`/dashboard/tags/${t.id}`} - > - {t.name} - </Link> + {isOwner ? ( + <Link + key={t.id} + className={cn( + badgeVariants({ variant: "secondary" }), + "text-nowrap font-light text-gray-700 hover:bg-foreground hover:text-secondary dark:text-gray-400", + )} + href={`/dashboard/tags/${t.id}`} + > + {t.name} + </Link> + ) : ( + <span + key={t.id} + className={cn( + badgeVariants({ variant: "secondary" }), + "text-nowrap font-light text-gray-700 dark:text-gray-400", + )} + > + {t.name} + </span> + )} </div> ))} </> diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx index 512fa990..bc06c647 100644 --- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx @@ -25,13 +25,15 @@ export function TagsEditor({ tags: _tags, onAttach, onDetach, + disabled, }: { tags: ZBookmarkTags[]; onAttach: (tag: { tagName: string; tagId?: string }) => void; onDetach: (tag: { tagName: string; tagId: string }) => void; + disabled?: boolean; }) { const demoMode = !!useClientConfig().demoMode; - const isDisabled = demoMode; + const isDisabled = demoMode || disabled; const inputRef = React.useRef<HTMLInputElement>(null); const containerRef = React.useRef<HTMLDivElement>(null); const [open, setOpen] = React.useState(false); |
