diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-12-24 15:15:46 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-24 13:15:46 +0000 |
| commit | ef27670f5c027be87d279b9b32553e980e55d888 (patch) | |
| tree | aa30e49787c17499abbe7a12b7a3353d13dbec7d /apps/web/components | |
| parent | f7d3462790652c6e5ecd90ae0d699e05b0320a97 (diff) | |
| download | karakeep-ef27670f5c027be87d279b9b32553e980e55d888.tar.zst | |
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>
Diffstat (limited to 'apps/web/components')
5 files changed, 164 insertions, 46 deletions
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 && 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> + ); +} 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"> 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"> 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} |
