From 88c73e212c4510ce41ad8c6557fa7d5c8f72d199 Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
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 <noreply@anthropic.com>
---
 .../dashboard/bookmarks/BookmarkOptions.tsx        | 267 ++++++++++++---------
 1 file changed, 149 insertions(+), 118 deletions(-)

(limited to 'apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx')

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>
     </>
-- 
cgit v1.2.3-70-g09d2

