aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/dashboard/lists/[listId]/page.tsx5
-rw-r--r--apps/web/app/reader/[bookmarkId]/page.tsx14
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx6
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx267
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx9
-rw-r--r--apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx64
-rw-r--r--apps/web/components/dashboard/bookmarks/TagList.tsx36
-rw-r--r--apps/web/components/dashboard/bookmarks/TagsEditor.tsx4
-rw-r--r--apps/web/components/dashboard/highlights/AllHighlights.tsx2
-rw-r--r--apps/web/components/dashboard/highlights/HighlightCard.tsx22
-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
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx66
-rw-r--r--apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx6
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx17
-rw-r--r--apps/web/components/dashboard/preview/HighlightsBox.tsx14
-rw-r--r--apps/web/components/dashboard/preview/LinkContentSection.tsx4
-rw-r--r--apps/web/components/dashboard/preview/NoteEditor.tsx10
-rw-r--r--apps/web/components/dashboard/preview/ReaderView.tsx3
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json32
23 files changed, 944 insertions, 270 deletions
diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx
index 3f9c3416..2b2cc9bb 100644
--- a/apps/web/app/dashboard/lists/[listId]/page.tsx
+++ b/apps/web/app/dashboard/lists/[listId]/page.tsx
@@ -50,6 +50,9 @@ export default async function ListPage(props: {
? searchParams.includeArchived === "true"
: userSettings.archiveDisplayBehaviour === "show";
+ // Only show editor card if user is owner or editor (not viewer)
+ const canEdit = list.userRole === "owner" || list.userRole === "editor";
+
return (
<BookmarkListContextProvider list={list}>
<Bookmarks
@@ -58,7 +61,7 @@ export default async function ListPage(props: {
archived: !includeArchived ? false : undefined,
}}
showDivider={true}
- showEditorCard={list.type === "manual"}
+ showEditorCard={list.type === "manual" && canEdit}
header={<ListHeader initialData={list} />}
/>
</BookmarkListContextProvider>
diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx
index 7c2b0c9e..e32811a9 100644
--- a/apps/web/app/reader/[bookmarkId]/page.tsx
+++ b/apps/web/app/reader/[bookmarkId]/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { Suspense, useState } from "react";
-import { useRouter } from "next/navigation";
+import { useParams, useRouter } from "next/navigation";
import HighlightCard from "@/components/dashboard/highlights/HighlightCard";
import ReaderView from "@/components/dashboard/preview/ReaderView";
import { Button } from "@/components/ui/button";
@@ -29,16 +29,14 @@ import {
Type,
X,
} from "lucide-react";
+import { useSession } from "next-auth/react";
import { api } from "@karakeep/shared-react/trpc";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
-export default function ReaderViewPage({
- params,
-}: {
- params: { bookmarkId: string };
-}) {
+export default function ReaderViewPage() {
+ const params = useParams<{ bookmarkId: string }>();
const bookmarkId = params.bookmarkId;
const { data: highlights } = api.highlights.getForBookmark.useQuery({
bookmarkId,
@@ -47,12 +45,14 @@ export default function ReaderViewPage({
bookmarkId,
});
+ const { data: session } = useSession();
const router = useRouter();
const [fontSize, setFontSize] = useState([18]);
const [lineHeight, setLineHeight] = useState([1.6]);
const [fontFamily, setFontFamily] = useState("serif");
const [showHighlights, setShowHighlights] = useState(false);
const [showSettings, setShowSettings] = useState(false);
+ const isOwner = session?.user?.id === bookmark?.userId;
const fontFamilies = {
serif: "ui-serif, Georgia, Cambria, serif",
@@ -245,6 +245,7 @@ export default function ReaderViewPage({
lineHeight: lineHeight[0],
}}
bookmarkId={bookmarkId}
+ readOnly={!isOwner}
/>
</div>
</Suspense>
@@ -299,6 +300,7 @@ export default function ReaderViewPage({
key={highlight.id}
highlight={highlight}
clickable={true}
+ readOnly={!isOwner}
/>
))}
</div>
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
index 98babb22..e8520b1a 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
@@ -13,6 +13,7 @@ import {
} from "@/lib/userLocalSettings/bookmarksLayout";
import { cn } from "@/lib/utils";
import { Check, Image as ImageIcon, NotebookPen } from "lucide-react";
+import { useSession } from "next-auth/react";
import { useTheme } from "next-themes";
import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
@@ -64,12 +65,15 @@ function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) {
const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark);
const [isSelected, setIsSelected] = useState(false);
const { theme } = useTheme();
+ const { data: session } = useSession();
useEffect(() => {
setIsSelected(selectedBookmarks.some((item) => item.id === bookmark.id));
}, [selectedBookmarks]);
- if (!isBulkEditEnabled) return null;
+ // Don't show selector for non-owned bookmarks or when bulk edit is disabled
+ const isOwner = session?.user?.id === bookmark.userId;
+ if (!isBulkEditEnabled || !isOwner) return null;
const getIconColor = () => {
if (theme === "dark") {
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
index 4725c77f..66de6156 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
@@ -22,6 +22,7 @@ import {
SquarePen,
Trash2,
} from "lucide-react";
+import { useSession } from "next-auth/react";
import type {
ZBookmark,
@@ -46,9 +47,13 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
const { t } = useTranslation();
const { toast } = useToast();
const linkId = bookmark.id;
+ const { data: session } = useSession();
const demoMode = !!useClientConfig().demoMode;
+ // Check if the current user owns this bookmark
+ const isOwner = session?.user?.id === bookmark.userId;
+
const [isClipboardAvailable, setIsClipboardAvailable] = useState(false);
useEffect(() => {
@@ -114,6 +119,142 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
onError,
});
+ // Define action items array
+ const actionItems = [
+ {
+ id: "edit",
+ title: t("actions.edit"),
+ icon: <Pencil className="mr-2 size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setEditBookmarkDialogOpen(true),
+ },
+ {
+ id: "open-editor",
+ title: t("actions.open_editor"),
+ icon: <SquarePen className="mr-2 size-4" />,
+ visible: isOwner && bookmark.content.type === BookmarkTypes.TEXT,
+ disabled: false,
+ onClick: () => setTextEditorOpen(true),
+ },
+ {
+ id: "favorite",
+ title: bookmark.favourited
+ ? t("actions.unfavorite")
+ : t("actions.favorite"),
+ icon: (
+ <FavouritedActionIcon
+ className="mr-2 size-4"
+ favourited={bookmark.favourited}
+ />
+ ),
+ visible: isOwner,
+ disabled: demoMode,
+ onClick: () =>
+ updateBookmarkMutator.mutate({
+ bookmarkId: linkId,
+ favourited: !bookmark.favourited,
+ }),
+ },
+ {
+ id: "archive",
+ title: bookmark.archived ? t("actions.unarchive") : t("actions.archive"),
+ icon: (
+ <ArchivedActionIcon
+ className="mr-2 size-4"
+ archived={bookmark.archived}
+ />
+ ),
+ visible: isOwner,
+ disabled: demoMode,
+ onClick: () =>
+ updateBookmarkMutator.mutate({
+ bookmarkId: linkId,
+ archived: !bookmark.archived,
+ }),
+ },
+ {
+ id: "download-full-page",
+ title: t("actions.download_full_page_archive"),
+ icon: <FileDown className="mr-2 size-4" />,
+ visible: isOwner && bookmark.content.type === BookmarkTypes.LINK,
+ disabled: false,
+ onClick: () => {
+ fullPageArchiveBookmarkMutator.mutate({
+ bookmarkId: bookmark.id,
+ archiveFullPage: true,
+ });
+ },
+ },
+ {
+ id: "copy-link",
+ title: t("actions.copy_link"),
+ icon: <Link className="mr-2 size-4" />,
+ visible: bookmark.content.type === BookmarkTypes.LINK,
+ disabled: !isClipboardAvailable,
+ onClick: () => {
+ navigator.clipboard.writeText(
+ (bookmark.content as ZBookmarkedLink).url,
+ );
+ toast({
+ description: t("toasts.bookmarks.clipboard_copied"),
+ });
+ },
+ },
+ {
+ id: "manage-lists",
+ title: t("actions.manage_lists"),
+ icon: <List className="mr-2 size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setManageListsModalOpen(true),
+ },
+ {
+ id: "remove-from-list",
+ title: t("actions.remove_from_list"),
+ icon: <ListX className="mr-2 size-4" />,
+ visible:
+ (isOwner ||
+ (withinListContext &&
+ (withinListContext.userRole === "editor" ||
+ withinListContext.userRole === "owner"))) &&
+ !!listId &&
+ !!withinListContext &&
+ withinListContext.type === "manual",
+ disabled: demoMode,
+ onClick: () =>
+ removeFromListMutator.mutate({
+ listId: listId!,
+ bookmarkId: bookmark.id,
+ }),
+ },
+ {
+ id: "refresh",
+ title: t("actions.refresh"),
+ icon: <RotateCw className="mr-2 size-4" />,
+ visible: isOwner && bookmark.content.type === BookmarkTypes.LINK,
+ disabled: demoMode,
+ onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }),
+ },
+ {
+ id: "delete",
+ title: t("actions.delete"),
+ icon: <Trash2 className="mr-2 size-4" />,
+ visible: isOwner,
+ disabled: demoMode,
+ className: "text-destructive",
+ onClick: () => setDeleteBookmarkDialogOpen(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 (
<>
{manageListsModal}
@@ -142,127 +283,17 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-fit">
- <DropdownMenuItem onClick={() => setEditBookmarkDialogOpen(true)}>
- <Pencil className="mr-2 size-4" />
- <span>{t("actions.edit")}</span>
- </DropdownMenuItem>
- {bookmark.content.type === BookmarkTypes.TEXT && (
- <DropdownMenuItem onClick={() => setTextEditorOpen(true)}>
- <SquarePen className="mr-2 size-4" />
- <span>{t("actions.open_editor")}</span>
- </DropdownMenuItem>
- )}
- <DropdownMenuItem
- disabled={demoMode}
- onClick={() =>
- updateBookmarkMutator.mutate({
- bookmarkId: linkId,
- favourited: !bookmark.favourited,
- })
- }
- >
- <FavouritedActionIcon
- className="mr-2 size-4"
- favourited={bookmark.favourited}
- />
- <span>
- {bookmark.favourited
- ? t("actions.unfavorite")
- : t("actions.favorite")}
- </span>
- </DropdownMenuItem>
- <DropdownMenuItem
- disabled={demoMode}
- onClick={() =>
- updateBookmarkMutator.mutate({
- bookmarkId: linkId,
- archived: !bookmark.archived,
- })
- }
- >
- <ArchivedActionIcon
- className="mr-2 size-4"
- archived={bookmark.archived}
- />
- <span>
- {bookmark.archived
- ? t("actions.unarchive")
- : t("actions.archive")}
- </span>
- </DropdownMenuItem>
-
- {bookmark.content.type === BookmarkTypes.LINK && (
- <DropdownMenuItem
- onClick={() => {
- fullPageArchiveBookmarkMutator.mutate({
- bookmarkId: bookmark.id,
- archiveFullPage: true,
- });
- }}
- >
- <FileDown className="mr-2 size-4" />
- <span>{t("actions.download_full_page_archive")}</span>
- </DropdownMenuItem>
- )}
-
- {bookmark.content.type === BookmarkTypes.LINK && (
+ {visibleItems.map((item) => (
<DropdownMenuItem
- disabled={!isClipboardAvailable}
- onClick={() => {
- navigator.clipboard.writeText(
- (bookmark.content as ZBookmarkedLink).url,
- );
- toast({
- description: t("toasts.bookmarks.clipboard_copied"),
- });
- }}
+ key={item.id}
+ disabled={item.disabled}
+ className={item.className}
+ onClick={item.onClick}
>
- <Link className="mr-2 size-4" />
- <span>{t("actions.copy_link")}</span>
+ {item.icon}
+ <span>{item.title}</span>
</DropdownMenuItem>
- )}
-
- <DropdownMenuItem onClick={() => setManageListsModalOpen(true)}>
- <List className="mr-2 size-4" />
- <span>{t("actions.manage_lists")}</span>
- </DropdownMenuItem>
-
- {listId &&
- withinListContext &&
- withinListContext.type === "manual" && (
- <DropdownMenuItem
- disabled={demoMode}
- onClick={() =>
- removeFromListMutator.mutate({
- listId,
- bookmarkId: bookmark.id,
- })
- }
- >
- <ListX className="mr-2 size-4" />
- <span>{t("actions.remove_from_list")}</span>
- </DropdownMenuItem>
- )}
-
- {bookmark.content.type === BookmarkTypes.LINK && (
- <DropdownMenuItem
- disabled={demoMode}
- onClick={() =>
- crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id })
- }
- >
- <RotateCw className="mr-2 size-4" />
- <span>{t("actions.refresh")}</span>
- </DropdownMenuItem>
- )}
- <DropdownMenuItem
- disabled={demoMode}
- className="text-destructive"
- onClick={() => setDeleteBookmarkDialogOpen(true)}
- >
- <Trash2 className="mr-2 size-4" />
- <span>{t("actions.delete")}</span>
- </DropdownMenuItem>
+ ))}
</DropdownMenuContent>
</DropdownMenu>
</>
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx
index fa4f40de..22b5408e 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx
@@ -5,7 +5,13 @@ import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks";
import { TagsEditor } from "./TagsEditor";
-export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) {
+export function BookmarkTagsEditor({
+ bookmark,
+ disabled,
+}: {
+ bookmark: ZBookmark;
+ disabled?: boolean;
+}) {
const { mutate } = useUpdateBookmarkTags({
onSuccess: () => {
toast({
@@ -24,6 +30,7 @@ export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) {
return (
<TagsEditor
tags={bookmark.tags}
+ disabled={disabled}
onAttach={({ tagName, tagId }) => {
mutate({
bookmarkId: bookmark.id,
diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx
index e75886e1..b2cf118e 100644
--- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx
+++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx
@@ -17,9 +17,11 @@ import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
function AISummary({
bookmarkId,
summary,
+ readOnly = false,
}: {
bookmarkId: string;
summary: string;
+ readOnly?: boolean;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
const { mutate: resummarize, isPending: isResummarizing } =
@@ -60,28 +62,34 @@ function AISummary({
</MarkdownReadonly>
{isExpanded && (
<span className="flex justify-end gap-2 pt-2">
- <ActionButton
- variant="none"
- size="none"
- spinner={<LoadingSpinner className="size-4" />}
- className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
- aria-label={isExpanded ? "Collapse" : "Expand"}
- loading={isResummarizing}
- onClick={() => resummarize({ bookmarkId })}
- >
- <RefreshCw size={16} />
- </ActionButton>
- <ActionButton
- size="none"
- variant="none"
- spinner={<LoadingSpinner className="size-4" />}
- className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
- aria-label={isExpanded ? "Collapse" : "Expand"}
- loading={isUpdatingBookmark}
- onClick={() => updateBookmark({ bookmarkId, summary: null })}
- >
- <Trash2 size={16} />
- </ActionButton>
+ {!readOnly && (
+ <>
+ <ActionButton
+ variant="none"
+ size="none"
+ spinner={<LoadingSpinner className="size-4" />}
+ className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ loading={isResummarizing}
+ onClick={() => resummarize({ bookmarkId })}
+ >
+ <RefreshCw size={16} />
+ </ActionButton>
+ <ActionButton
+ size="none"
+ variant="none"
+ spinner={<LoadingSpinner className="size-4" />}
+ className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ loading={isUpdatingBookmark}
+ onClick={() =>
+ updateBookmark({ bookmarkId, summary: null })
+ }
+ >
+ <Trash2 size={16} />
+ </ActionButton>
+ </>
+ )}
<button
className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
aria-label="Collapse"
@@ -99,8 +107,10 @@ function AISummary({
export default function SummarizeBookmarkArea({
bookmark,
+ readOnly = false,
}: {
bookmark: ZBookmark;
+ readOnly?: boolean;
}) {
const { t } = useTranslation();
const { mutate, isPending } = useSummarizeBookmark({
@@ -118,8 +128,14 @@ export default function SummarizeBookmarkArea({
}
if (bookmark.summary) {
- return <AISummary bookmarkId={bookmark.id} summary={bookmark.summary} />;
- } else if (!clientConfig.inference.isConfigured) {
+ return (
+ <AISummary
+ bookmarkId={bookmark.id}
+ summary={bookmark.summary}
+ readOnly={readOnly}
+ />
+ );
+ } else if (!clientConfig.inference.isConfigured || readOnly) {
return null;
} else {
return (
diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx
index 593a269b..f1c319ea 100644
--- a/apps/web/components/dashboard/bookmarks/TagList.tsx
+++ b/apps/web/components/dashboard/bookmarks/TagList.tsx
@@ -2,6 +2,7 @@ import Link from "next/link";
import { badgeVariants } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
+import { useSession } from "next-auth/react";
import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
@@ -14,6 +15,9 @@ export default function TagList({
loading?: boolean;
className?: string;
}) {
+ const { data: session } = useSession();
+ const isOwner = session?.user?.id === bookmark.userId;
+
if (loading) {
return (
<div className="flex w-full flex-col justify-end space-y-2 p-2">
@@ -26,16 +30,28 @@ export default function TagList({
<>
{bookmark.tags.map((t) => (
<div key={t.id} className={className}>
- <Link
- key={t.id}
- className={cn(
- badgeVariants({ variant: "secondary" }),
- "text-nowrap font-light text-gray-700 hover:bg-foreground hover:text-secondary dark:text-gray-400",
- )}
- href={`/dashboard/tags/${t.id}`}
- >
- {t.name}
- </Link>
+ {isOwner ? (
+ <Link
+ key={t.id}
+ className={cn(
+ badgeVariants({ variant: "secondary" }),
+ "text-nowrap font-light text-gray-700 hover:bg-foreground hover:text-secondary dark:text-gray-400",
+ )}
+ href={`/dashboard/tags/${t.id}`}
+ >
+ {t.name}
+ </Link>
+ ) : (
+ <span
+ key={t.id}
+ className={cn(
+ badgeVariants({ variant: "secondary" }),
+ "text-nowrap font-light text-gray-700 dark:text-gray-400",
+ )}
+ >
+ {t.name}
+ </span>
+ )}
</div>
))}
</>
diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
index 512fa990..bc06c647 100644
--- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
+++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
@@ -25,13 +25,15 @@ export function TagsEditor({
tags: _tags,
onAttach,
onDetach,
+ disabled,
}: {
tags: ZBookmarkTags[];
onAttach: (tag: { tagName: string; tagId?: string }) => void;
onDetach: (tag: { tagName: string; tagId: string }) => void;
+ disabled?: boolean;
}) {
const demoMode = !!useClientConfig().demoMode;
- const isDisabled = demoMode;
+ const isDisabled = demoMode || disabled;
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [open, setOpen] = React.useState(false);
diff --git a/apps/web/components/dashboard/highlights/AllHighlights.tsx b/apps/web/components/dashboard/highlights/AllHighlights.tsx
index 9f39a471..23fa51d2 100644
--- a/apps/web/components/dashboard/highlights/AllHighlights.tsx
+++ b/apps/web/components/dashboard/highlights/AllHighlights.tsx
@@ -26,7 +26,7 @@ function Highlight({ highlight }: { highlight: ZHighlight }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
- <HighlightCard highlight={highlight} clickable={false} />
+ <HighlightCard highlight={highlight} clickable={false} readOnly={false} />
<span className="flex items-center gap-0.5 text-xs italic text-gray-400">
<span title={localCreatedAt}>{fromNow}</span>
<Dot />
diff --git a/apps/web/components/dashboard/highlights/HighlightCard.tsx b/apps/web/components/dashboard/highlights/HighlightCard.tsx
index 8bb24353..1bba0b47 100644
--- a/apps/web/components/dashboard/highlights/HighlightCard.tsx
+++ b/apps/web/components/dashboard/highlights/HighlightCard.tsx
@@ -12,10 +12,12 @@ export default function HighlightCard({
highlight,
clickable,
className,
+ readOnly,
}: {
highlight: ZHighlight;
clickable: boolean;
className?: string;
+ readOnly: boolean;
}) {
const { mutate: deleteHighlight, isPending: isDeleting } = useDeleteHighlight(
{
@@ -62,15 +64,17 @@ export default function HighlightCard({
<p>{highlight.text}</p>
</blockquote>
</Wrapper>
- <div className="flex gap-2">
- <ActionButton
- loading={isDeleting}
- variant="ghost"
- onClick={() => deleteHighlight({ highlightId: highlight.id })}
- >
- <Trash2 className="size-4 text-destructive" />
- </ActionButton>
- </div>
+ {!readOnly && (
+ <div className="flex gap-2">
+ <ActionButton
+ loading={isDeleting}
+ variant="ghost"
+ onClick={() => deleteHighlight({ highlightId: highlight.id })}
+ >
+ <Trash2 className="size-4 text-destructive" />
+ </ActionButton>
+ </div>
+ )}
</div>
);
}
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>
+ );
+}
diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx
index e24cc646..73eea640 100644
--- a/apps/web/components/dashboard/preview/AttachmentBox.tsx
+++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx
@@ -27,7 +27,13 @@ import {
isAllowedToDetachAsset,
} from "@karakeep/trpc/lib/attachments";
-export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
+export default function AttachmentBox({
+ bookmark,
+ readOnly = false,
+}: {
+ bookmark: ZBookmark;
+ readOnly?: boolean;
+}) {
const { t } = useTranslation();
const { mutate: attachAsset, isPending: isAttaching } =
useAttachBookmarkAsset({
@@ -122,7 +128,8 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
>
<Download className="size-4" />
</Link>
- {isAllowedToAttachAsset(asset.assetType) &&
+ {!readOnly &&
+ isAllowedToAttachAsset(asset.assetType) &&
asset.assetType !== "userUploaded" && (
<FilePickerButton
title="Replace"
@@ -147,7 +154,7 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
<Pencil className="size-4" />
</FilePickerButton>
)}
- {isAllowedToDetachAsset(asset.assetType) && (
+ {!readOnly && isAllowedToDetachAsset(asset.assetType) && (
<ActionConfirmingDialog
title="Delete Attachment?"
description={`Are you sure you want to delete the attachment of the bookmark?`}
@@ -175,7 +182,8 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
</div>
</div>
))}
- {!bookmark.assets.some((asset) => asset.assetType == "bannerImage") &&
+ {!readOnly &&
+ !bookmark.assets.some((asset) => asset.assetType == "bannerImage") &&
bookmark.content.type != BookmarkTypes.ASSET && (
<FilePickerButton
title="Attach a Banner"
@@ -203,30 +211,32 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
Attach a Banner
</FilePickerButton>
)}
- <FilePickerButton
- title="Upload File"
- loading={isAttaching}
- multiple={false}
- variant="ghost"
- size="none"
- className="flex w-full items-center justify-center gap-2"
- onFileSelect={(file) =>
- uploadAsset(file, {
- onSuccess: (resp) => {
- attachAsset({
- bookmarkId: bookmark.id,
- asset: {
- id: resp.assetId,
- assetType: "userUploaded",
- },
- });
- },
- })
- }
- >
- <Plus className="size-4" />
- Upload File
- </FilePickerButton>
+ {!readOnly && (
+ <FilePickerButton
+ title="Upload File"
+ loading={isAttaching}
+ multiple={false}
+ variant="ghost"
+ size="none"
+ className="flex w-full items-center justify-center gap-2"
+ onFileSelect={(file) =>
+ uploadAsset(file, {
+ onSuccess: (resp) => {
+ attachAsset({
+ bookmarkId: bookmark.id,
+ asset: {
+ id: resp.assetId,
+ assetType: "userUploaded",
+ },
+ });
+ },
+ })
+ }
+ >
+ <Plus className="size-4" />
+ Upload File
+ </FilePickerButton>
+ )}
</CollapsibleContent>
</Collapsible>
);
diff --git a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
index 19499d3e..e0f20ea2 100644
--- a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
@@ -95,6 +95,7 @@ interface HTMLHighlighterProps {
style?: React.CSSProperties;
className?: string;
highlights?: Highlight[];
+ readOnly?: boolean;
onHighlight?: (highlight: Highlight) => void;
onUpdateHighlight?: (highlight: Highlight) => void;
onDeleteHighlight?: (highlight: Highlight) => void;
@@ -105,6 +106,7 @@ function BookmarkHTMLHighlighter({
className,
style,
highlights = [],
+ readOnly = false,
onHighlight,
onUpdateHighlight,
onDeleteHighlight,
@@ -173,6 +175,10 @@ function BookmarkHTMLHighlighter({
}, [pendingHighlight, contentRef]);
const handlePointerUp = (e: React.PointerEvent) => {
+ if (readOnly) {
+ return;
+ }
+
const selection = window.getSelection();
// Check if we clicked on an existing highlight
diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
index 4766bd32..7e6bf814 100644
--- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
@@ -17,6 +17,7 @@ import useRelativeTime from "@/lib/hooks/relative-time";
import { useTranslation } from "@/lib/i18n/client";
import { api } from "@/lib/trpc";
import { Building, CalendarDays, ExternalLink, User } from "lucide-react";
+import { useSession } from "next-auth/react";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
import {
@@ -117,6 +118,7 @@ export default function BookmarkPreview({
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<string>("content");
+ const { data: session } = useSession();
const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
{
@@ -138,6 +140,9 @@ export default function BookmarkPreview({
return <FullPageSpinner />;
}
+ // Check if the current user owns this bookmark
+ const isOwner = session?.user?.id === bookmark.userId;
+
let content;
switch (bookmark.content.type) {
case BookmarkTypes.LINK: {
@@ -186,18 +191,18 @@ export default function BookmarkPreview({
</div>
<CreationTime createdAt={bookmark.createdAt} />
<BookmarkMetadata bookmark={bookmark} />
- <SummarizeBookmarkArea bookmark={bookmark} />
+ <SummarizeBookmarkArea bookmark={bookmark} readOnly={!isOwner} />
<div className="flex items-center gap-4">
<p className="text-sm text-gray-400">{t("common.tags")}</p>
- <BookmarkTagsEditor bookmark={bookmark} />
+ <BookmarkTagsEditor bookmark={bookmark} disabled={!isOwner} />
</div>
<div className="flex gap-4">
<p className="pt-2 text-sm text-gray-400">{t("common.note")}</p>
- <NoteEditor bookmark={bookmark} />
+ <NoteEditor bookmark={bookmark} disabled={!isOwner} />
</div>
- <AttachmentBox bookmark={bookmark} />
- <HighlightsBox bookmarkId={bookmark.id} />
- <ActionBar bookmark={bookmark} />
+ <AttachmentBox bookmark={bookmark} readOnly={!isOwner} />
+ <HighlightsBox bookmarkId={bookmark.id} readOnly={!isOwner} />
+ {isOwner && <ActionBar bookmark={bookmark} />}
</div>
);
diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx
index 4da22d04..41ab7d74 100644
--- a/apps/web/components/dashboard/preview/HighlightsBox.tsx
+++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx
@@ -11,7 +11,13 @@ import { ChevronsDownUp } from "lucide-react";
import HighlightCard from "../highlights/HighlightCard";
-export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) {
+export default function HighlightsBox({
+ bookmarkId,
+ readOnly,
+}: {
+ bookmarkId: string;
+ readOnly: boolean;
+}) {
const { t } = useTranslation();
const { data: highlights, isPending: isLoading } =
@@ -30,7 +36,11 @@ export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) {
<CollapsibleContent className="group flex flex-col py-3 text-sm">
{highlights.highlights.map((highlight) => (
<Fragment key={highlight.id}>
- <HighlightCard highlight={highlight} clickable />
+ <HighlightCard
+ highlight={highlight}
+ clickable
+ readOnly={readOnly}
+ />
<Separator className="m-2 h-0.5 bg-gray-200 last:hidden" />
</Fragment>
))}
diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx
index 53559aa0..64b62df6 100644
--- a/apps/web/components/dashboard/preview/LinkContentSection.tsx
+++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx
@@ -25,6 +25,7 @@ import {
ExpandIcon,
Video,
} from "lucide-react";
+import { useSession } from "next-auth/react";
import { useQueryState } from "nuqs";
import { ErrorBoundary } from "react-error-boundary";
@@ -111,6 +112,8 @@ export default function LinkContentSection({
const [section, setSection] = useQueryState("section", {
defaultValue: defaultSection,
});
+ const { data: session } = useSession();
+ const isOwner = session?.user?.id === bookmark.userId;
if (bookmark.content.type != BookmarkTypes.LINK) {
throw new Error("Invalid content type");
@@ -133,6 +136,7 @@ export default function LinkContentSection({
<ReaderView
className="prose mx-auto dark:prose-invert"
bookmarkId={bookmark.id}
+ readOnly={!isOwner}
/>
</ScrollArea>
);
diff --git a/apps/web/components/dashboard/preview/NoteEditor.tsx b/apps/web/components/dashboard/preview/NoteEditor.tsx
index 393628b5..538aff2e 100644
--- a/apps/web/components/dashboard/preview/NoteEditor.tsx
+++ b/apps/web/components/dashboard/preview/NoteEditor.tsx
@@ -5,7 +5,13 @@ import { useClientConfig } from "@/lib/clientConfig";
import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
-export function NoteEditor({ bookmark }: { bookmark: ZBookmark }) {
+export function NoteEditor({
+ bookmark,
+ disabled,
+}: {
+ bookmark: ZBookmark;
+ disabled?: boolean;
+}) {
const demoMode = !!useClientConfig().demoMode;
const updateBookmarkMutator = useUpdateBookmark({
@@ -26,7 +32,7 @@ export function NoteEditor({ bookmark }: { bookmark: ZBookmark }) {
<Textarea
className="h-44 w-full overflow-auto rounded bg-background p-2 text-sm text-gray-400 dark:text-gray-300"
defaultValue={bookmark.note ?? ""}
- disabled={demoMode}
+ disabled={demoMode || disabled}
placeholder="Write some notes ..."
onBlur={(e) => {
if (e.currentTarget.value == bookmark.note) {
diff --git a/apps/web/components/dashboard/preview/ReaderView.tsx b/apps/web/components/dashboard/preview/ReaderView.tsx
index bf4c27a5..1974626a 100644
--- a/apps/web/components/dashboard/preview/ReaderView.tsx
+++ b/apps/web/components/dashboard/preview/ReaderView.tsx
@@ -15,10 +15,12 @@ export default function ReaderView({
bookmarkId,
className,
style,
+ readOnly,
}: {
bookmarkId: string;
className?: string;
style?: React.CSSProperties;
+ readOnly: boolean;
}) {
const { data: highlights } = api.highlights.getForBookmark.useQuery({
bookmarkId,
@@ -93,6 +95,7 @@ export default function ReaderView({
style={style}
htmlContent={cachedContent || ""}
highlights={highlights?.highlights ?? []}
+ readOnly={readOnly}
onDeleteHighlight={(h) =>
deleteHighlight({
highlightId: h.id,
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 7f322a8a..5db867a2 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -470,6 +470,7 @@
"lists": {
"all_lists": "All Lists",
"favourites": "Favourites",
+ "shared": "Shared",
"new_list": "New List",
"edit_list": "Edit List",
"share_list": "Share List",
@@ -501,6 +502,37 @@
"title": "Public List",
"description": "Allow others to view this list",
"share_link": "Share Link"
+ },
+ "collaborators": {
+ "manage": "Manage Collaborators",
+ "view": "View Collaborators",
+ "collaborators": "Collaborators",
+ "add": "Add Collaborator",
+ "current": "Current Collaborators",
+ "enter_email": "Enter email address",
+ "please_enter_email": "Please enter an email address",
+ "added_successfully": "Collaborator added successfully",
+ "failed_to_add": "Failed to add collaborator",
+ "removed": "Collaborator removed",
+ "failed_to_remove": "Failed to remove collaborator",
+ "role_updated": "Role updated",
+ "failed_to_update_role": "Failed to update role",
+ "viewer": "Viewer",
+ "editor": "Editor",
+ "owner": "Owner",
+ "viewer_description": "Can view bookmarks in the list",
+ "editor_description": "Can add and remove bookmarks",
+ "no_collaborators": "No collaborators yet. Add someone to start collaborating!",
+ "no_collaborators_readonly": "No collaborators for this list.",
+ "people_with_access": "People who have access to this list",
+ "add_or_remove": "Add or remove people who can access this list"
+ },
+ "leave_list": {
+ "title": "Leave List",
+ "confirm_message": "Are you sure you want to leave {{icon}} {{name}}?",
+ "warning": "You will no longer be able to view or access bookmarks in this list. The list owner can add you back if needed.",
+ "action": "Leave List",
+ "success": "You have left \"{{icon}} {{name}}\""
}
},
"tags": {