rcgit

/ karakeep

Commit ef27670f

SHA ef27670f5c027be87d279b9b32553e980e55d888
Author Mohamed Bassem <me at mbassem dot com>
Author Date 2025-12-24 15:15 +0200
Committer GitHub <noreply at github dot com>
Commit Date 2025-12-24 13:15 +0000
Parent(s) f7d346279065 (diff)
Tree aa30e49787c1

patch snapshot

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 <noreply@anthropic.com>
File + - Graph
M apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +43 -3
A apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx +31 -0
M apps/web/components/dashboard/lists/ListHeader.tsx +48 -16
M apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx +41 -26
M apps/web/components/ui/avatar.tsx +1 -1
M packages/trpc/models/listInvitations.ts +1 -0
M packages/trpc/models/lists.ts +4 -0
M packages/trpc/routers/lists.ts +2 -0
8 file(s) changed, 171 insertions(+), 46 deletions(-)

apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx

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 (
+    <div className="absolute right-2 top-2 z-40 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
+      <BookmarkOwnerIcon ownerName={owner.name} ownerAvatar={owner.image} />
+    </div>
+  );
+}
+
 function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) {
   const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore();
   const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark);
@@ -133,11 +170,12 @@ function ListView({
   return (
     <div
       className={cn(
-        "relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2",
+        "group relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2",
         className,
       )}
     >
       <MultiBookmarkSelector bookmark={bookmark} />
+      <OwnerIndicator bookmark={bookmark} />
       <div className="flex size-32 items-center justify-center overflow-hidden">
         {image("list", cn("size-32 rounded-lg", imgFitClass))}
       </div>
@@ -191,12 +229,13 @@ function GridView({
   return (
     <div
       className={cn(
-        "relative flex flex-col overflow-hidden rounded-lg",
+        "group relative flex flex-col overflow-hidden rounded-lg",
         className,
         fitHeight && layout != "grid" ? "max-h-96" : "h-96",
       )}
     >
       <MultiBookmarkSelector bookmark={bookmark} />
+      <OwnerIndicator bookmark={bookmark} />
       {img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>}
       <div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2">
         <div className="grow-1 flex flex-col gap-2 overflow-hidden">
@@ -228,12 +267,13 @@ function CompactView({ bookmark, title, footer, className }: Props) {
   return (
     <div
       className={cn(
-        "relative flex flex-col overflow-hidden rounded-lg",
+        "group relative flex flex-col overflow-hidden rounded-lg",
         className,
         "max-h-96",
       )}
     >
       <MultiBookmarkSelector bookmark={bookmark} />
+      <OwnerIndicator bookmark={bookmark} />
       <div className="flex h-full justify-between gap-2 overflow-hidden p-2">
         <div className="flex items-center gap-2">
           {bookmark.content.type === BookmarkTypes.LINK &&

apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx

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 (
+    <Tooltip>
+      <TooltipTrigger>
+        <UserAvatar
+          name={ownerName}
+          image={ownerAvatar}
+          className="size-5 shrink-0 rounded-full ring-1 ring-border"
+        />
+      </TooltipTrigger>
+      <TooltipContent className="font-sm">
+        <p className="font-medium">{ownerName}</p>
+      </TooltipContent>
+    </Tooltip>
+  );
+}

apps/web/components/dashboard/lists/ListHeader.tsx

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({
         <span className="text-2xl">
           {list.icon} {list.name}
         </span>
-        {list.hasCollaborators && (
-          <TooltipProvider>
-            <Tooltip>
-              <TooltipTrigger asChild>
-                <Users className="size-5 text-primary" />
-              </TooltipTrigger>
-              <TooltipContent>
-                <p>{t("lists.shared")}</p>
-              </TooltipContent>
-            </Tooltip>
-          </TooltipProvider>
+        {list.hasCollaborators && collaboratorsData && (
+          <div className="group flex">
+            {collaboratorsData.owner && (
+              <Tooltip>
+                <TooltipTrigger>
+                  <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1">
+                    <UserAvatar
+                      name={collaboratorsData.owner.name}
+                      image={collaboratorsData.owner.image}
+                      className="size-5 shrink-0 rounded-full ring-2 ring-background"
+                    />
+                  </div>
+                </TooltipTrigger>
+                <TooltipContent>
+                  <p>{collaboratorsData.owner.name}</p>
+                </TooltipContent>
+              </Tooltip>
+            )}
+            {collaboratorsData.collaborators.map((collab) => (
+              <Tooltip key={collab.userId}>
+                <TooltipTrigger>
+                  <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1">
+                    <UserAvatar
+                      name={collab.user.name}
+                      image={collab.user.image}
+                      className="size-5 shrink-0 rounded-full ring-2 ring-background"
+                    />
+                  </div>
+                </TooltipTrigger>
+                <TooltipContent>
+                  <p>{collab.user.name}</p>
+                </TooltipContent>
+              </Tooltip>
+            ))}
+          </div>
         )}
         {list.description && (
-          <span className="text-lg text-gray-400">
-            {`(${list.description})`}
-          </span>
+          <span className="text-lg text-gray-400">{`(${list.description})`}</span>
         )}
       </div>
       <div className="flex items-center">

apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx

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"
                   >
-                    <div className="flex-1">
-                      <div className="font-medium">
-                        {collaboratorsData.owner.name}
-                      </div>
-                      {collaboratorsData.owner.email && (
-                        <div className="text-sm text-muted-foreground">
-                          {collaboratorsData.owner.email}
+                    <div className="flex flex-1 items-center gap-3">
+                      <UserAvatar
+                        name={collaboratorsData.owner.name}
+                        image={collaboratorsData.owner.image}
+                        className="size-10 ring-1 ring-border"
+                      />
+                      <div className="flex-1">
+                        <div className="font-medium">
+                          {collaboratorsData.owner.name}
                         </div>
-                      )}
+                        {collaboratorsData.owner.email && (
+                          <div className="text-sm text-muted-foreground">
+                            {collaboratorsData.owner.email}
+                          </div>
+                        )}
+                      </div>
                     </div>
                     <div className="text-sm capitalize text-muted-foreground">
                       {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"
                     >
-                      <div className="flex-1">
-                        <div className="flex items-center gap-2">
-                          <div className="font-medium">
-                            {collaborator.user.name}
+                      <div className="flex flex-1 items-center gap-3">
+                        <UserAvatar
+                          name={collaborator.user.name}
+                          image={collaborator.user.image}
+                          className="size-10 ring-1 ring-border"
+                        />
+                        <div className="flex-1">
+                          <div className="flex items-center gap-2">
+                            <div className="font-medium">
+                              {collaborator.user.name}
+                            </div>
+                            {collaborator.status === "pending" && (
+                              <Badge variant="outline" className="text-xs">
+                                {t("lists.collaborators.pending")}
+                              </Badge>
+                            )}
+                            {collaborator.status === "declined" && (
+                              <Badge variant="destructive" className="text-xs">
+                                {t("lists.collaborators.declined")}
+                              </Badge>
+                            )}
                           </div>
-                          {collaborator.status === "pending" && (
-                            <Badge variant="outline" className="text-xs">
-                              {t("lists.collaborators.pending")}
-                            </Badge>
-                          )}
-                          {collaborator.status === "declined" && (
-                            <Badge variant="destructive" className="text-xs">
-                              {t("lists.collaborators.declined")}
-                            </Badge>
+                          {collaborator.user.email && (
+                            <div className="text-sm text-muted-foreground">
+                              {collaborator.user.email}
+                            </div>
                           )}
                         </div>
-                        {collaborator.user.email && (
-                          <div className="text-sm text-muted-foreground">
-                            {collaborator.user.email}
-                          </div>
-                        )}
                       </div>
                       {readOnly ? (
                         <div className="text-sm capitalize text-muted-foreground">

apps/web/components/ui/avatar.tsx

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<
   <AvatarPrimitive.Fallback
     ref={ref}
     className={cn(
-      "flex h-full w-full items-center justify-center rounded-full bg-muted",
+      "flex h-full w-full items-center justify-center rounded-full bg-black text-white",
       className,
     )}
     {...props}

packages/trpc/models/listInvitations.ts

diff --git a/packages/trpc/models/listInvitations.ts b/packages/trpc/models/listInvitations.ts
index 6bdc8ffa..2e17fa2e 100644
--- a/packages/trpc/models/listInvitations.ts
+++ b/packages/trpc/models/listInvitations.ts
@@ -372,6 +372,7 @@ export class ListInvitation {
         // This protects user privacy until they accept
         name: "Pending User",
         email: invitation.user.email || "",
+        image: null,
       },
     }));
   }

packages/trpc/models/lists.ts

diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts
index 0968492a..29945b92 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -719,6 +719,7 @@ export abstract class List {
               id: true,
               name: true,
               email: true,
+              image: true,
             },
           },
         },
@@ -738,6 +739,7 @@ export abstract class List {
         id: true,
         name: true,
         email: true,
+        image: true,
       },
     });
 
@@ -754,6 +756,7 @@ export abstract class List {
           name: c.user.name,
           // Only show email to the owner for privacy
           email: isOwner ? c.user.email : null,
+          image: c.user.image,
         },
       };
     });
@@ -766,6 +769,7 @@ export abstract class List {
             name: owner.name,
             // Only show owner email to the owner for privacy
             email: isOwner ? owner.email : null,
+            image: owner.image,
           }
         : null,
     };

packages/trpc/routers/lists.ts

diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts
index 296679f3..bca3dc53 100644
--- a/packages/trpc/routers/lists.ts
+++ b/packages/trpc/routers/lists.ts
@@ -302,6 +302,7 @@ export const listsAppRouter = router({
               id: z.string(),
               name: z.string(),
               email: z.string().nullable(),
+              image: z.string().nullable(),
             }),
           }),
         ),
@@ -310,6 +311,7 @@ export const listsAppRouter = router({
             id: z.string(),
             name: z.string(),
             email: z.string().nullable(),
+            image: z.string().nullable(),
           })
           .nullable(),
       }),