aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-12-24 15:15:46 +0200
committerGitHub <noreply@github.com>2025-12-24 13:15:46 +0000
commitef27670f5c027be87d279b9b32553e980e55d888 (patch)
treeaa30e49787c17499abbe7a12b7a3353d13dbec7d /apps/web/components
parentf7d3462790652c6e5ecd90ae0d699e05b0320a97 (diff)
downloadkarakeep-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')
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx46
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx31
-rw-r--r--apps/web/components/dashboard/lists/ListHeader.tsx64
-rw-r--r--apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx67
-rw-r--r--apps/web/components/ui/avatar.tsx2
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}