aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard/lists
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-17 01:12:41 +0000
committerGitHub <noreply@github.com>2025-11-17 01:12:41 +0000
commit88c73e212c4510ce41ad8c6557fa7d5c8f72d199 (patch)
tree11f47349b8c34de1bf541febd9ba48cc44aa305a /apps/web/components/dashboard/lists
parentcc8fee0d28d87299ee9a3ad11dcb4ae5a7b86c15 (diff)
downloadkarakeep-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/lists')
-rw-r--r--apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx14
-rw-r--r--apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx83
-rw-r--r--apps/web/components/dashboard/lists/ListHeader.tsx30
-rw-r--r--apps/web/components/dashboard/lists/ListOptions.tsx161
-rw-r--r--apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx345
5 files changed, 573 insertions, 60 deletions
diff --git a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx
index 90d4cb3f..2f8fca6a 100644
--- a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx
+++ b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx
@@ -4,10 +4,7 @@ import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { api } from "@/lib/trpc";
import { keepPreviousData } from "@tanstack/react-query";
-import {
- augmentBookmarkListsWithInitialData,
- useBookmarkLists,
-} from "@karakeep/shared-react/hooks/lists";
+import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
import { ZBookmarkList } from "@karakeep/shared/types/lists";
import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils";
@@ -83,20 +80,15 @@ export function CollapsibleBookmarkLists({
className,
isOpenFunc,
}: {
- initialData?: ZBookmarkList[];
+ initialData: ZBookmarkList[];
render: RenderFunc;
isOpenFunc?: IsOpenFunc;
className?: string;
}) {
let { data } = useBookmarkLists(undefined, {
- initialData: initialData ? { lists: initialData } : undefined,
+ initialData: { lists: initialData },
});
- // TODO: This seems to be a bug in react query
- if (initialData) {
- data = augmentBookmarkListsWithInitialData(data, initialData);
- }
-
if (!data) {
return <FullPageSpinner />;
}
diff --git a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx
new file mode 100644
index 00000000..62dbbcef
--- /dev/null
+++ b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx
@@ -0,0 +1,83 @@
+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 { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+
+import type { ZBookmarkList } from "@karakeep/shared/types/lists";
+
+export default function LeaveListConfirmationDialog({
+ list,
+ children,
+ open,
+ setOpen,
+}: {
+ list: ZBookmarkList;
+ children?: React.ReactNode;
+ open: boolean;
+ setOpen: (v: boolean) => void;
+}) {
+ const { t } = useTranslation();
+ const currentPath = usePathname();
+ const router = useRouter();
+ const utils = api.useUtils();
+
+ 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"),
+ });
+ },
+ });
+
+ return (
+ <ActionConfirmingDialog
+ open={open}
+ setOpen={setOpen}
+ title={t("lists.leave_list.title")}
+ description={
+ <div className="space-y-3">
+ <p className="text-balance">
+ {t("lists.leave_list.confirm_message", {
+ icon: list.icon,
+ name: list.name,
+ })}
+ </p>
+ <p className="text-balance text-sm text-muted-foreground">
+ {t("lists.leave_list.warning")}
+ </p>
+ </div>
+ }
+ actionButton={() => (
+ <ActionButton
+ type="button"
+ variant="destructive"
+ loading={isPending}
+ onClick={() => leaveList({ listId: list.id })}
+ >
+ {t("lists.leave_list.action")}
+ </ActionButton>
+ )}
+ >
+ {children}
+ </ActionConfirmingDialog>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx
index 4e318dad..8e014e2a 100644
--- a/apps/web/components/dashboard/lists/ListHeader.tsx
+++ b/apps/web/components/dashboard/lists/ListHeader.tsx
@@ -3,8 +3,14 @@
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { useTranslation } from "@/lib/i18n/client";
-import { MoreHorizontal, SearchIcon } from "lucide-react";
+import { MoreHorizontal, SearchIcon, Users } from "lucide-react";
import { api } from "@karakeep/shared-react/trpc";
import { parseSearchQuery } from "@karakeep/shared/searchQueryParser";
@@ -48,12 +54,24 @@ export default function ListHeader({
<div className="flex items-center gap-2">
<span className="text-2xl">
{list.icon} {list.name}
- {list.description && (
- <span className="mx-2 text-lg text-gray-400">
- {`(${list.description})`}
- </span>
- )}
</span>
+ {list.hasCollaborators && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Users className="size-5 text-primary" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{t("lists.shared")}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ {list.description && (
+ <span className="text-lg text-gray-400">
+ {`(${list.description})`}
+ </span>
+ )}
</div>
<div className="flex items-center">
{parsedQuery && (
diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx
index 7e020374..b80ac680 100644
--- a/apps/web/components/dashboard/lists/ListOptions.tsx
+++ b/apps/web/components/dashboard/lists/ListOptions.tsx
@@ -8,6 +8,7 @@ import {
import { useShowArchived } from "@/components/utils/useShowArchived";
import { useTranslation } from "@/lib/i18n/client";
import {
+ DoorOpen,
FolderInput,
Pencil,
Plus,
@@ -15,12 +16,15 @@ import {
Square,
SquareCheck,
Trash2,
+ Users,
} from "lucide-react";
import { ZBookmarkList } from "@karakeep/shared/types/lists";
import { EditListModal } from "../lists/EditListModal";
import DeleteListConfirmationDialog from "./DeleteListConfirmationDialog";
+import LeaveListConfirmationDialog from "./LeaveListConfirmationDialog";
+import { ManageCollaboratorsModal } from "./ManageCollaboratorsModal";
import { MergeListModal } from "./MergeListModal";
import { ShareListModal } from "./ShareListModal";
@@ -39,10 +43,102 @@ export function ListOptions({
const { showArchived, onClickShowArchived } = useShowArchived();
const [deleteListDialogOpen, setDeleteListDialogOpen] = useState(false);
+ const [leaveListDialogOpen, setLeaveListDialogOpen] = useState(false);
const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
const [mergeListModalOpen, setMergeListModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
+ const [collaboratorsModalOpen, setCollaboratorsModalOpen] = useState(false);
+
+ // Only owners can manage the list (edit, delete, manage collaborators, etc.)
+ const isOwner = list.userRole === "owner";
+ // Collaborators (non-owners) can leave the list
+ const isCollaborator =
+ list.userRole === "editor" || list.userRole === "viewer";
+
+ // Define action items array
+ const actionItems = [
+ {
+ id: "edit",
+ title: t("actions.edit"),
+ icon: <Pencil className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setEditModalOpen(true),
+ },
+ {
+ id: "share",
+ title: t("lists.share_list"),
+ icon: <Share className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setShareModalOpen(true),
+ },
+ {
+ id: "manage-collaborators",
+ title: isOwner
+ ? t("lists.collaborators.manage")
+ : t("lists.collaborators.view"),
+ icon: <Users className="size-4" />,
+ visible: true, // Always visible for all roles
+ disabled: false,
+ onClick: () => setCollaboratorsModalOpen(true),
+ },
+ {
+ id: "new-nested-list",
+ title: t("lists.new_nested_list"),
+ icon: <Plus className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setNewNestedListModalOpen(true),
+ },
+ {
+ id: "merge-list",
+ title: t("lists.merge_list"),
+ icon: <FolderInput className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setMergeListModalOpen(true),
+ },
+ {
+ id: "toggle-archived",
+ title: t("actions.toggle_show_archived"),
+ icon: showArchived ? (
+ <SquareCheck className="size-4" />
+ ) : (
+ <Square className="size-4" />
+ ),
+ visible: true,
+ disabled: false,
+ onClick: onClickShowArchived,
+ },
+ {
+ id: "leave-list",
+ title: t("lists.leave_list.action"),
+ icon: <DoorOpen className="size-4" />,
+ visible: isCollaborator,
+ disabled: false,
+ className: "flex gap-2 text-destructive",
+ onClick: () => setLeaveListDialogOpen(true),
+ },
+ {
+ id: "delete",
+ title: t("actions.delete"),
+ icon: <Trash2 className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ className: "flex gap-2 text-destructive",
+ onClick: () => setDeleteListDialogOpen(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 (
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
@@ -51,6 +147,12 @@ export function ListOptions({
setOpen={setShareModalOpen}
list={list}
/>
+ <ManageCollaboratorsModal
+ open={collaboratorsModalOpen}
+ setOpen={setCollaboratorsModalOpen}
+ list={list}
+ readOnly={!isOwner}
+ />
<EditListModal
open={newNestedListModalOpen}
setOpen={setNewNestedListModalOpen}
@@ -73,51 +175,24 @@ export function ListOptions({
open={deleteListDialogOpen}
setOpen={setDeleteListDialogOpen}
/>
+ <LeaveListConfirmationDialog
+ list={list}
+ open={leaveListDialogOpen}
+ setOpen={setLeaveListDialogOpen}
+ />
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setEditModalOpen(true)}
- >
- <Pencil className="size-4" />
- <span>{t("actions.edit")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setShareModalOpen(true)}
- >
- <Share className="size-4" />
- <span>{t("lists.share_list")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setNewNestedListModalOpen(true)}
- >
- <Plus className="size-4" />
- <span>{t("lists.new_nested_list")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setMergeListModalOpen(true)}
- >
- <FolderInput className="size-4" />
- <span>{t("lists.merge_list")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem className="flex gap-2" onClick={onClickShowArchived}>
- {showArchived ? (
- <SquareCheck className="size-4" />
- ) : (
- <Square className="size-4" />
- )}
- <span>{t("actions.toggle_show_archived")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setDeleteListDialogOpen(true)}
- >
- <Trash2 className="size-4" />
- <span>{t("actions.delete")}</span>
- </DropdownMenuItem>
+ {visibleItems.map((item) => (
+ <DropdownMenuItem
+ key={item.id}
+ className={item.className ?? "flex gap-2"}
+ disabled={item.disabled}
+ onClick={item.onClick}
+ >
+ {item.icon}
+ <span>{item.title}</span>
+ </DropdownMenuItem>
+ ))}
</DropdownMenuContent>
</DropdownMenu>
);
diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx
new file mode 100644
index 00000000..8e0a0602
--- /dev/null
+++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx
@@ -0,0 +1,345 @@
+"use client";
+
+import { useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { toast } from "@/components/ui/use-toast";
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+import { Loader2, Trash2, UserPlus, Users } from "lucide-react";
+
+import { ZBookmarkList } from "@karakeep/shared/types/lists";
+
+export function ManageCollaboratorsModal({
+ open: userOpen,
+ setOpen: userSetOpen,
+ list,
+ children,
+ readOnly = false,
+}: {
+ open?: boolean;
+ setOpen?: (v: boolean) => void;
+ list: ZBookmarkList;
+ children?: React.ReactNode;
+ readOnly?: boolean;
+}) {
+ if (
+ (userOpen !== undefined && !userSetOpen) ||
+ (userOpen === undefined && userSetOpen)
+ ) {
+ throw new Error("You must provide both open and setOpen or neither");
+ }
+ const [customOpen, customSetOpen] = useState(false);
+ const [open, setOpen] = [
+ userOpen ?? customOpen,
+ userSetOpen ?? customSetOpen,
+ ];
+
+ const [newCollaboratorEmail, setNewCollaboratorEmail] = useState("");
+ const [newCollaboratorRole, setNewCollaboratorRole] = useState<
+ "viewer" | "editor"
+ >("viewer");
+
+ const { t } = useTranslation();
+ const utils = api.useUtils();
+
+ 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 }),
+ ]);
+
+ // Fetch collaborators
+ const { data: collaboratorsData, isLoading } =
+ api.lists.getCollaborators.useQuery({ listId: list.id }, { enabled: open });
+
+ // Mutations
+ const addCollaborator = api.lists.addCollaborator.useMutation({
+ onSuccess: async () => {
+ toast({
+ description: t("lists.collaborators.added_successfully"),
+ });
+ 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 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 handleAddCollaborator = () => {
+ if (!newCollaboratorEmail.trim()) {
+ toast({
+ variant: "destructive",
+ description: t("lists.collaborators.please_enter_email"),
+ });
+ return;
+ }
+
+ addCollaborator.mutate({
+ listId: list.id,
+ email: newCollaboratorEmail,
+ role: newCollaboratorRole,
+ });
+ };
+
+ return (
+ <Dialog
+ open={open}
+ onOpenChange={(s) => {
+ setOpen(s);
+ }}
+ >
+ {children && <DialogTrigger asChild>{children}</DialogTrigger>}
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="h-5 w-5" />
+ {readOnly
+ ? t("lists.collaborators.collaborators")
+ : t("lists.collaborators.manage")}
+ <Badge className="bg-green-600 text-white hover:bg-green-600/80">
+ Beta
+ </Badge>
+ </DialogTitle>
+ <DialogDescription>
+ {readOnly
+ ? t("lists.collaborators.people_with_access")
+ : t("lists.collaborators.add_or_remove")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* Add Collaborator Section */}
+ {!readOnly && (
+ <div className="space-y-3">
+ <Label>{t("lists.collaborators.add")}</Label>
+ <div className="flex gap-2">
+ <div className="flex-1">
+ <Input
+ type="email"
+ placeholder={t("lists.collaborators.enter_email")}
+ value={newCollaboratorEmail}
+ onChange={(e) => setNewCollaboratorEmail(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleAddCollaborator();
+ }
+ }}
+ />
+ </div>
+ <Select
+ value={newCollaboratorRole}
+ onValueChange={(value) =>
+ setNewCollaboratorRole(value as "viewer" | "editor")
+ }
+ >
+ <SelectTrigger className="w-32">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">
+ {t("lists.collaborators.viewer")}
+ </SelectItem>
+ <SelectItem value="editor">
+ {t("lists.collaborators.editor")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ <Button
+ onClick={handleAddCollaborator}
+ disabled={addCollaborator.isPending}
+ >
+ {addCollaborator.isPending ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <UserPlus className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ <strong>{t("lists.collaborators.viewer")}:</strong>{" "}
+ {t("lists.collaborators.viewer_description")}
+ <br />
+ <strong>{t("lists.collaborators.editor")}:</strong>{" "}
+ {t("lists.collaborators.editor_description")}
+ </p>
+ </div>
+ )}
+
+ {/* Current Collaborators */}
+ <div className="space-y-3">
+ <Label>
+ {readOnly
+ ? t("lists.collaborators.collaborators")
+ : t("lists.collaborators.current")}
+ </Label>
+ {isLoading ? (
+ <div className="flex justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ ) : collaboratorsData ? (
+ <div className="space-y-2">
+ {/* Show owner first */}
+ {collaboratorsData.owner && (
+ <div
+ 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>
+ <div className="text-sm text-muted-foreground">
+ {collaboratorsData.owner.email}
+ </div>
+ </div>
+ <div className="text-sm capitalize text-muted-foreground">
+ {t("lists.collaborators.owner")}
+ </div>
+ </div>
+ )}
+ {/* Show collaborators */}
+ {collaboratorsData.collaborators.length > 0 ? (
+ collaboratorsData.collaborators.map((collaborator) => (
+ <div
+ key={collaborator.id}
+ className="flex items-center justify-between rounded-lg border p-3"
+ >
+ <div className="flex-1">
+ <div className="font-medium">
+ {collaborator.user.name}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {collaborator.user.email}
+ </div>
+ </div>
+ {readOnly ? (
+ <div className="text-sm capitalize text-muted-foreground">
+ {collaborator.role}
+ </div>
+ ) : (
+ <div className="flex items-center gap-2">
+ <Select
+ value={collaborator.role}
+ onValueChange={(value) =>
+ updateCollaboratorRole.mutate({
+ listId: list.id,
+ userId: collaborator.userId,
+ role: value as "viewer" | "editor",
+ })
+ }
+ >
+ <SelectTrigger className="w-28">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">
+ {t("lists.collaborators.viewer")}
+ </SelectItem>
+ <SelectItem value="editor">
+ {t("lists.collaborators.editor")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() =>
+ removeCollaborator.mutate({
+ listId: list.id,
+ userId: collaborator.userId,
+ })
+ }
+ disabled={removeCollaborator.isPending}
+ >
+ <Trash2 className="h-4 w-4 text-destructive" />
+ </Button>
+ </div>
+ )}
+ </div>
+ ))
+ ) : !collaboratorsData.owner ? (
+ <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
+ {readOnly
+ ? t("lists.collaborators.no_collaborators_readonly")
+ : t("lists.collaborators.no_collaborators")}
+ </div>
+ ) : null}
+ </div>
+ ) : (
+ <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
+ {readOnly
+ ? t("lists.collaborators.no_collaborators_readonly")
+ : t("lists.collaborators.no_collaborators")}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ {t("actions.close")}
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}