From ef27670f5c027be87d279b9b32553e980e55d888 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Wed, 24 Dec 2025 15:15:46 +0200 Subject: feat: show bookmark owner icon in shared lists (#2277) * feat: Add owner icon to bookmarks in shared lists Display a small icon showing the bookmark owner's name and email on hover when viewing bookmarks from other users in shared lists. The icon appears in the top-right corner of bookmark cards across all layout types (grid, list, compact). Changes: - Add user field to ZBookmark type to include owner name and email - Update bookmark queries to fetch user information via join - Create BookmarkOwnerIcon component with tooltip showing owner details - Integrate owner indicator into BookmarkLayoutAdaptingCard for all layouts - Only show icon for bookmarks not owned by current user * use icons in more places * remove tooltip providers * fix non list context --------- Co-authored-by: Claude --- .../bookmarks/BookmarkLayoutAdaptingCard.tsx | 46 ++++++++++++++- .../dashboard/bookmarks/BookmarkOwnerIcon.tsx | 31 ++++++++++ apps/web/components/dashboard/lists/ListHeader.tsx | 64 +++++++++++++++------ .../dashboard/lists/ManageCollaboratorsModal.tsx | 67 +++++++++++++--------- apps/web/components/ui/avatar.tsx | 2 +- 5 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx (limited to 'apps/web') diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index e8520b1a..2f02f095 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import useBulkActionsStore from "@/lib/bulkActions"; +import { api } from "@/lib/trpc"; import { bookmarkLayoutSwitch, useBookmarkDisplaySettings, @@ -17,12 +18,14 @@ import { useSession } from "next-auth/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 { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import { 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 +63,40 @@ function BottomRow({ ); } +function OwnerIndicator({ bookmark }: { bookmark: ZBookmark }) { + const listContext = useBookmarkListContext(); + const collaborators = api.lists.getCollaborators.useQuery( + { + 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 ( +
+ +
+ ); +} + function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark); @@ -133,11 +170,12 @@ function ListView({ return (
+
{image("list", cn("size-32 rounded-lg", imgFitClass))}
@@ -191,12 +229,13 @@ function GridView({ return (
+ {img &&
{img}
}
@@ -228,12 +267,13 @@ function CompactView({ bookmark, title, footer, className }: Props) { return (
+
{bookmark.content.type === BookmarkTypes.LINK && 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 ( + + + + + +

{ownerName}

+
+
+ ); +} diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx index 8e014e2a..ecbb6431 100644 --- a/apps/web/components/dashboard/lists/ListHeader.tsx +++ b/apps/web/components/dashboard/lists/ListHeader.tsx @@ -6,11 +6,11 @@ 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 { MoreHorizontal, SearchIcon } from "lucide-react"; import { api } from "@karakeep/shared-react/trpc"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; @@ -35,6 +35,16 @@ export default function ListHeader({ }, ); + const { data: collaboratorsData } = api.lists.getCollaborators.useQuery( + { + listId: initialData.id, + }, + { + refetchOnWindowFocus: false, + enabled: list.hasCollaborators, + }, + ); + const parsedQuery = useMemo(() => { if (!list.query) { return null; @@ -55,22 +65,44 @@ export default function ListHeader({ {list.icon} {list.name} - {list.hasCollaborators && ( - - - - - - -

{t("lists.shared")}

-
-
-
+ {list.hasCollaborators && collaboratorsData && ( +
+ {collaboratorsData.owner && ( + + +
+ +
+
+ +

{collaboratorsData.owner.name}

+
+
+ )} + {collaboratorsData.collaborators.map((collab) => ( + + +
+ +
+
+ +

{collab.user.name}

+
+
+ ))} +
)} {list.description && ( - - {`(${list.description})`} - + {`(${list.description})`} )}
diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx index 0a55c5fe..80dbcf65 100644 --- a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx +++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from "@/components/ui/select"; import { toast } from "@/components/ui/use-toast"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { Loader2, Trash2, UserPlus, Users } from "lucide-react"; @@ -256,15 +257,22 @@ export function ManageCollaboratorsModal({ key={`owner-${collaboratorsData.owner.id}`} className="flex items-center justify-between rounded-lg border p-3" > -
-
- {collaboratorsData.owner.name} -
- {collaboratorsData.owner.email && ( -
- {collaboratorsData.owner.email} +
+ +
+
+ {collaboratorsData.owner.name}
- )} + {collaboratorsData.owner.email && ( +
+ {collaboratorsData.owner.email} +
+ )} +
{t("lists.collaborators.owner")} @@ -278,27 +286,34 @@ export function ManageCollaboratorsModal({ key={collaborator.id} className="flex items-center justify-between rounded-lg border p-3" > -
-
-
- {collaborator.user.name} +
+ +
+
+
+ {collaborator.user.name} +
+ {collaborator.status === "pending" && ( + + {t("lists.collaborators.pending")} + + )} + {collaborator.status === "declined" && ( + + {t("lists.collaborators.declined")} + + )}
- {collaborator.status === "pending" && ( - - {t("lists.collaborators.pending")} - - )} - {collaborator.status === "declined" && ( - - {t("lists.collaborators.declined")} - + {collaborator.user.email && ( +
+ {collaborator.user.email} +
)}
- {collaborator.user.email && ( -
- {collaborator.user.email} -
- )}
{readOnly ? (
diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx index 7afec626..48ec676b 100644 --- a/apps/web/components/ui/avatar.tsx +++ b/apps/web/components/ui/avatar.tsx @@ -38,7 +38,7 @@ const AvatarFallback = React.forwardRef<