"use client"; import { ChangeEvent, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useClientConfig } from "@/lib/clientConfig"; import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; import { Archive, Download, FileDown, FileText, ImagePlus, Link, List, ListX, MoreHorizontal, Pencil, RotateCw, SquarePen, Trash2, } from "lucide-react"; import { useSession } from "next-auth/react"; import { toast } from "sonner"; import type { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; import { useAttachBookmarkAsset, useReplaceBookmarkAsset, } from "@karakeep/shared-react/hooks/assets"; import { useBookmarkGridContext } from "@karakeep/shared-react/hooks/bookmark-grid-context"; import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; import { useRecrawlBookmark, useUpdateBookmark, } from "@karakeep/shared-react/hooks/bookmarks"; import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; import DeleteBookmarkConfirmationDialog from "./DeleteBookmarkConfirmationDialog"; import { EditBookmarkDialog } from "./EditBookmarkDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "./icons"; import { useManageListsModal } from "./ManageListsModal"; interface ActionItem { id: string; title: string; icon: React.ReactNode; visible: boolean; disabled: boolean; className?: string; onClick: () => void; } interface SubsectionItem { id: string; title: string; icon: React.ReactNode; visible: boolean; items: ActionItem[]; } const getBannerSonnerId = (bookmarkId: string) => `replace-banner-${bookmarkId}`; type ActionItemType = ActionItem | SubsectionItem; function isSubsectionItem(item: ActionItemType): item is SubsectionItem { return "items" in item; } export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); 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(() => { // This code only runs in the browser setIsClipboardAvailable( typeof window !== "undefined" && window.navigator && !!window.navigator.clipboard, ); }, []); const { setOpen: setManageListsModalOpen, content: manageListsModal } = useManageListsModal(bookmark.id); const [deleteBookmarkDialogOpen, setDeleteBookmarkDialogOpen] = useState(false); const [isTextEditorOpen, setTextEditorOpen] = useState(false); const [isEditBookmarkDialogOpen, setEditBookmarkDialogOpen] = useState(false); const bannerFileInputRef = useRef(null); const { mutate: uploadBannerAsset } = useUpload({ onError: (e) => { toast.error(e.error, { id: getBannerSonnerId(bookmark.id) }); }, }); const { mutate: attachAsset, isPending: isAttaching } = useAttachBookmarkAsset({ onSuccess: () => { toast.success(t("toasts.bookmarks.update_banner"), { id: getBannerSonnerId(bookmark.id), }); }, onError: (e) => { toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); }, }); const { mutate: replaceAsset, isPending: isReplacing } = useReplaceBookmarkAsset({ onSuccess: () => { toast.success(t("toasts.bookmarks.update_banner"), { id: getBannerSonnerId(bookmark.id), }); }, onError: (e) => { toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); }, }); const { listId } = useBookmarkGridContext() ?? {}; const withinListContext = useBookmarkListContext(); const onError = () => { toast.error(t("common.something_went_wrong")); }; const updateBookmarkMutator = useUpdateBookmark({ onSuccess: () => { toast.success(t("toasts.bookmarks.updated")); }, onError, }); const crawlBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { toast.success(t("toasts.bookmarks.refetch")); }, onError, }); const fullPageArchiveBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { toast.success(t("toasts.bookmarks.full_page_archive")); }, onError, }); const preservePdfMutator = useRecrawlBookmark({ onSuccess: () => { toast.success(t("toasts.bookmarks.preserve_pdf")); }, onError, }); const removeFromListMutator = useRemoveBookmarkFromList({ onSuccess: () => { toast.success(t("toasts.bookmarks.delete_from_list")); }, onError, }); const handleBannerFileChange = (event: ChangeEvent) => { const files = event.target.files; if (files && files.length > 0) { const file = files[0]; const existingBanner = bookmark.assets.find( (asset) => asset.assetType === "bannerImage", ); if (existingBanner) { toast.loading(t("toasts.bookmarks.uploading_banner"), { id: getBannerSonnerId(bookmark.id), }); uploadBannerAsset(file, { onSuccess: (resp) => { replaceAsset({ bookmarkId: bookmark.id, oldAssetId: existingBanner.id, newAssetId: resp.assetId, }); }, }); } else { toast.loading(t("toasts.bookmarks.uploading_banner"), { id: getBannerSonnerId(bookmark.id), }); uploadBannerAsset(file, { onSuccess: (resp) => { attachAsset({ bookmarkId: bookmark.id, asset: { id: resp.assetId, assetType: "bannerImage", }, }); }, }); } } }; // Define action items array const actionItems: ActionItemType[] = [ { id: "edit", title: t("actions.edit"), icon: , visible: isOwner, disabled: false, onClick: () => setEditBookmarkDialogOpen(true), }, { id: "open-editor", title: t("actions.open_editor"), icon: , visible: isOwner && bookmark.content.type === BookmarkTypes.TEXT, disabled: false, onClick: () => setTextEditorOpen(true), }, { id: "favorite", title: bookmark.favourited ? t("actions.unfavorite") : t("actions.favorite"), icon: ( ), visible: isOwner, disabled: demoMode, onClick: () => updateBookmarkMutator.mutate({ bookmarkId: linkId, favourited: !bookmark.favourited, }), }, { id: "archive", title: bookmark.archived ? t("actions.unarchive") : t("actions.archive"), icon: ( ), visible: isOwner, disabled: demoMode, onClick: () => updateBookmarkMutator.mutate({ bookmarkId: linkId, archived: !bookmark.archived, }), }, { id: "copy-link", title: t("actions.copy_link"), icon: , visible: bookmark.content.type === BookmarkTypes.LINK, disabled: !isClipboardAvailable, onClick: () => { navigator.clipboard.writeText( (bookmark.content as ZBookmarkedLink).url, ); toast.success(t("toasts.bookmarks.clipboard_copied")); }, }, { id: "manage-lists", title: t("actions.manage_lists"), icon: , visible: isOwner, disabled: false, onClick: () => setManageListsModalOpen(true), }, { id: "remove-from-list", title: t("actions.remove_from_list"), icon: , visible: Boolean( (isOwner || (withinListContext && (withinListContext.userRole === "editor" || withinListContext.userRole === "owner"))) && !!listId && !!withinListContext && withinListContext.type === "manual", ), disabled: demoMode, onClick: () => removeFromListMutator.mutate({ listId: listId!, bookmarkId: bookmark.id, }), }, { id: "offline-copies", title: t("actions.offline_copies"), icon: , visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, items: [ { id: "download-full-page", title: t("actions.preserve_offline_archive"), icon: , visible: true, disabled: demoMode, onClick: () => { fullPageArchiveBookmarkMutator.mutate({ bookmarkId: bookmark.id, archiveFullPage: true, }); }, }, { id: "preserve-pdf", title: t("actions.preserve_as_pdf"), icon: , visible: true, disabled: demoMode, onClick: () => { preservePdfMutator.mutate({ bookmarkId: bookmark.id, storePdf: true, }); }, }, { id: "download-full-page-archive", title: t("actions.download_full_page_archive_file"), icon: , visible: bookmark.content.type === BookmarkTypes.LINK && !!( bookmark.content.fullPageArchiveAssetId || bookmark.content.precrawledArchiveAssetId ), disabled: false, onClick: () => { const link = bookmark.content as ZBookmarkedLink; const archiveAssetId = link.fullPageArchiveAssetId ?? link.precrawledArchiveAssetId; if (archiveAssetId) { window.open(getAssetUrl(archiveAssetId), "_blank"); } }, }, { id: "download-pdf", title: t("actions.download_pdf_file"), icon: , visible: !!(bookmark.content as ZBookmarkedLink).pdfAssetId, disabled: false, onClick: () => { const link = bookmark.content as ZBookmarkedLink; if (link.pdfAssetId) { window.open(getAssetUrl(link.pdfAssetId), "_blank"); } }, }, ], }, { id: "more", title: t("actions.more"), icon: , visible: isOwner, items: [ { id: "refresh", title: t("actions.refresh"), icon: , visible: bookmark.content.type === BookmarkTypes.LINK, disabled: demoMode, onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), }, { id: "replace-banner", title: bookmark.assets.find((a) => a.assetType === "bannerImage") ? t("actions.replace_banner") : t("actions.add_banner"), icon: , visible: true, disabled: demoMode || isAttaching || isReplacing, onClick: () => bannerFileInputRef.current?.click(), }, ], }, { id: "delete", title: t("actions.delete"), icon: , visible: isOwner, disabled: demoMode, className: "text-destructive", onClick: () => setDeleteBookmarkDialogOpen(true), }, ]; // Filter visible items const visibleItems: ActionItemType[] = actionItems.filter((item) => { if (isSubsectionItem(item)) { return item.visible && item.items.some((subItem) => subItem.visible); } return item.visible; }); // If no items are visible, don't render the dropdown if (visibleItems.length === 0) { return null; } return ( <> {manageListsModal} {visibleItems.map((item) => { if (isSubsectionItem(item)) { const visibleSubItems = item.items.filter( (subItem) => subItem.visible, ); if (visibleSubItems.length === 0) { return null; } return ( {item.icon} {item.title} {visibleSubItems.map((subItem) => ( {subItem.icon} {subItem.title} ))} ); } return ( {item.icon} {item.title} ); })} ); }