import { useAtom } from "jotai"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; import { ActionIcon, Menu, rem } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconArrowRight, IconClockHour4, IconCopy, IconDotsVertical, IconFileExport, IconLink, IconStar, IconStarFilled, IconTemplate, IconTrash, } from "@tabler/icons-react"; import ExportModal from "@/components/common/export-modal"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import CopyPageModal from "@/features/page/components/copy-page-modal.tsx"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { duplicatePage } from "@/features/page/services/page-service.ts"; import { useClipboard } from "@/hooks/use-clipboard"; import { getAppUrl } from "@/lib/config.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useFavoriteIds, useAddFavoriteMutation, useRemoveFavoriteMutation, } from "@/features/favorite/queries/favorite-query"; import { useToggleTemplateMutation, useToggleTemporaryMutation, } from "@/features/page-embed/queries/page-embed-query"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; import { pageToTreeNode } from "@/features/page/tree/utils"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; import classes from "@/features/page/tree/styles/tree.module.css"; export interface NodeMenuProps { node: SpaceTreeNode; canEdit: boolean; } export function NodeMenu({ node, canEdit }: NodeMenuProps) { const { t } = useTranslation(); const clipboard = useClipboard({ timeout: 500 }); const { spaceSlug } = useParams(); const { handleDelete } = useTreeMutation(node.spaceId); const [data, setData] = useAtom(treeDataAtom); const emit = useQueryEmit(); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); const [ movePageModalOpened, { open: openMovePageModal, close: closeMoveSpaceModal }, ] = useDisclosure(false); const [ copyPageModalOpened, { open: openCopyPageModal, close: closeCopySpaceModal }, ] = useDisclosure(false); const favoriteIds = useFavoriteIds("page", node.spaceId); const addFavorite = useAddFavoriteMutation(); const removeFavorite = useRemoveFavoriteMutation(); const isFavorited = favoriteIds.has(node.id); const toggleTemplate = useToggleTemplateMutation(); const isTemplate = !!node.isTemplate; const toggleTemporary = useToggleTemporaryMutation(); const isTemporary = !!node.temporaryExpiresAt; const handleToggleTemplate = async () => { const next = !isTemplate; try { await toggleTemplate.mutateAsync({ pageId: node.id, isTemplate: next }); // Reflect the new flag locally so the menu label updates immediately. setData((prev) => treeModel.update(prev, node.id, { isTemplate: next } as any), ); notifications.show({ message: next ? t("Page marked as template") : t("Page is no longer a template"), }); } catch { // mutation surfaces the error via notifications } }; const handleToggleTemporary = async () => { const next = !isTemporary; try { const res = await toggleTemporary.mutateAsync({ pageId: node.id, temporary: next, }); // Reflect the new deadline locally so the icon/menu update immediately. setData((prev) => treeModel.update(prev, node.id, { temporaryExpiresAt: res.temporaryExpiresAt, } as any), ); notifications.show({ message: next ? t("Note will move to trash unless made permanent") : t("Note is now permanent"), }); } catch { // mutation surfaces the error via notifications } }; const handleCopyLink = () => { const pageUrl = getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name); clipboard.copy(pageUrl); notifications.show({ message: t("Link copied") }); }; const handleDuplicatePage = async () => { try { const duplicatedPage = await duplicatePage({ pageId: node.id }); // figure out parent + insertion index const siblings = treeModel.siblingsOf(data, node.id); const parentId = siblings?.parentId ?? null; const currentIndex = siblings?.index ?? 0; const newIndex = currentIndex + 1; // Routed through the canonical mapper so the field copy stays in lockstep // with buildTree. The server does NOT arm a death timer on duplicate (the // copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper // carries that null through and the duplicated node correctly shows no // clock marker — matching the server without a reload. const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, { canEdit: true, }); setData((prev) => treeModel.insert(prev, parentId, treeNodeData, newIndex), ); setTimeout(() => { emit({ operation: "addTreeNode", spaceId: node.spaceId, payload: { parentId, index: newIndex, data: treeNodeData, }, }); }, 50); notifications.show({ message: t("Page duplicated successfully") }); } catch (err: any) { notifications.show({ message: err?.response?.data?.message || "An error occurred", color: "red", }); } }; return ( <> { e.preventDefault(); e.stopPropagation(); }} > } onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleCopyLink(); }} > {t("Copy link")} : } onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (isFavorited) { removeFavorite.mutate({ type: "page", pageId: node.id }); } else { addFavorite.mutate({ type: "page", pageId: node.id }); } }} > {isFavorited ? t("Remove from favorites") : t("Add to favorites")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openExportModal(); }} > {t("Export")} {canEdit && ( <> } onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDuplicatePage(); }} > {t("Duplicate")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openMovePageModal(); }} > {t("Move to space")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openCopyPageModal(); }} > {t("Copy to space")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleToggleTemplate(); }} > {isTemplate ? t("Unset as template") : t("Make template")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleToggleTemporary(); }} > {isTemporary ? t("Make permanent") : t("Make temporary")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDelete(node.id); }} > {t("Move to trash")} )} ); }