Soft-deleting a page no longer opens a "Move this page to trash?" confirmation modal. The page is moved to trash immediately and the "Page moved to trash" toast now exposes an inline Undo action that restores the page via the existing restore flow. - Add move-to-trash-notification.tsx helper that builds the toast body (status text + Undo button) as a ReactNode, so it can be used from the non-TSX page-query module. - useRemovePageMutation: show the toast with a stable id, 8s autoClose, and an Undo handler that hides the toast and triggers restore. - space-tree-node-menu / page-header-menu: call handleDelete directly and drop the now-unused useDeletePageModal usage. Permanent delete keeps its confirmation modal. - useRestorePageMutation: read the live tree from the jotai store at execution time and insert via a functional updater, so Undo restores child pages correctly even after the originating component unmounted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
292 lines
8.8 KiB
TypeScript
292 lines
8.8 KiB
TypeScript
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,
|
|
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 } 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 { 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 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 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;
|
|
|
|
const treeNodeData: SpaceTreeNode = {
|
|
id: duplicatedPage.id,
|
|
slugId: duplicatedPage.slugId,
|
|
name: duplicatedPage.title,
|
|
position: duplicatedPage.position,
|
|
spaceId: duplicatedPage.spaceId,
|
|
parentPageId: duplicatedPage.parentPageId,
|
|
icon: duplicatedPage.icon,
|
|
hasChildren: duplicatedPage.hasChildren,
|
|
canEdit: true,
|
|
children: [],
|
|
};
|
|
|
|
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 (
|
|
<>
|
|
<Menu shadow="md" width={200}>
|
|
<Menu.Target>
|
|
<ActionIcon
|
|
size={20}
|
|
variant="subtle"
|
|
color="gray"
|
|
className={classes.actionIcon}
|
|
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
|
|
tabIndex={-1}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<IconDotsVertical
|
|
style={{ width: rem(20), height: rem(20) }}
|
|
stroke={2}
|
|
/>
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<IconLink size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleCopyLink();
|
|
}}
|
|
>
|
|
{t("Copy link")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={
|
|
isFavorited ? <IconStarFilled size={16} /> : <IconStar size={16} />
|
|
}
|
|
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")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconFileExport size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openExportModal();
|
|
}}
|
|
>
|
|
{t("Export page")}
|
|
</Menu.Item>
|
|
|
|
{canEdit && (
|
|
<>
|
|
<Menu.Item
|
|
leftSection={<IconCopy size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleDuplicatePage();
|
|
}}
|
|
>
|
|
{t("Duplicate")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconArrowRight size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openMovePageModal();
|
|
}}
|
|
>
|
|
{t("Move")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconCopy size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openCopyPageModal();
|
|
}}
|
|
>
|
|
{t("Copy to space")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconTemplate size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleToggleTemplate();
|
|
}}
|
|
>
|
|
{isTemplate ? t("Unset as template") : t("Make template")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
c="red"
|
|
leftSection={<IconTrash size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleDelete(node.id);
|
|
}}
|
|
>
|
|
{t("Move to trash")}
|
|
</Menu.Item>
|
|
</>
|
|
)}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
|
|
<MovePageModal
|
|
pageId={node.id}
|
|
slugId={node.slugId}
|
|
currentSpaceSlug={spaceSlug}
|
|
onClose={closeMoveSpaceModal}
|
|
open={movePageModalOpened}
|
|
/>
|
|
|
|
<CopyPageModal
|
|
pageId={node.id}
|
|
currentSpaceSlug={spaceSlug}
|
|
onClose={closeCopySpaceModal}
|
|
open={copyPageModalOpened}
|
|
/>
|
|
|
|
<ExportModal
|
|
type="page"
|
|
id={node.id}
|
|
open={exportOpened}
|
|
onClose={closeExportModal}
|
|
/>
|
|
</>
|
|
);
|
|
}
|