diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index 38281b86..ab7827d6 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -30,7 +30,6 @@ import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
-import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
@@ -143,7 +142,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { data: page, isLoading } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
- const { openDeleteModal } = useDeletePageModal();
const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
@@ -189,7 +187,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
};
const handleDeletePage = () => {
- openDeleteModal({ onConfirm: () => handleDelete(page.id) });
+ handleDelete(page.id);
};
const handleToggleFavorite = () => {
diff --git a/apps/client/src/features/page/components/move-to-trash-notification.tsx b/apps/client/src/features/page/components/move-to-trash-notification.tsx
new file mode 100644
index 00000000..bfe06d1e
--- /dev/null
+++ b/apps/client/src/features/page/components/move-to-trash-notification.tsx
@@ -0,0 +1,27 @@
+import { Button, Group, Text } from "@mantine/core";
+import type { ReactNode } from "react";
+
+type MoveToTrashNotificationProps = {
+ message: string;
+ undoLabel: string;
+ onUndo: () => void;
+};
+
+// Builds the body of the "page moved to trash" toast: the status text plus an
+// inline Undo action that restores the page from trash. Returned as a ReactNode
+// so it can be passed as the `message` of a Mantine notification from a
+// non-TSX module (page-query.ts).
+export function moveToTrashNotificationMessage({
+ message,
+ undoLabel,
+ onUndo,
+}: MoveToTrashNotificationProps): ReactNode {
+ return (
+
+ {message}
+
+
+ );
+}
diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts
index c631f892..eaa871f5 100644
--- a/apps/client/src/features/page/queries/page-query.ts
+++ b/apps/client/src/features/page/queries/page-query.ts
@@ -35,11 +35,12 @@ import { buildTree } from "@/features/page/tree/utils";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
-import { useAtom } from "jotai";
+import { useSetAtom, useStore } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
+import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
export function usePageQuery(
pageInput: Partial,
@@ -118,10 +119,29 @@ export function useUpdatePageMutation() {
export function useRemovePageMutation() {
const { t } = useTranslation();
+ // Reuse the existing restore flow for the toast's Undo action. Its side
+ // effects (tree re-insert, cache updates, websocket emit, success toast) live
+ // in its useMutation-level onSuccess, so they still run after the originating
+ // tree node / page header has unmounted by the time Undo is clicked.
+ const restorePageMutation = useRestorePageMutation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: (_, pageId) => {
- notifications.show({ message: t("Page moved to trash") });
+ // Replace the former pre-delete confirmation dialog with an Undo action
+ // surfaced directly in the "moved to trash" toast.
+ const notificationId = `page-moved-to-trash-${pageId}`;
+ notifications.show({
+ id: notificationId,
+ autoClose: 8000,
+ message: moveToTrashNotificationMessage({
+ message: t("Page moved to trash"),
+ undoLabel: t("Undo"),
+ onUndo: () => {
+ notifications.hide(notificationId);
+ restorePageMutation.mutate(pageId);
+ },
+ }),
+ });
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
const cached = queryClient.getQueryData(["pages", pageId]);
@@ -173,7 +193,8 @@ export function useMovePageMutation() {
export function useRestorePageMutation() {
const { t } = useTranslation();
- const [treeData, setTreeData] = useAtom(treeDataAtom);
+ const setTreeData = useSetAtom(treeDataAtom);
+ const store = useStore();
const emit = useQueryEmit();
return useMutation({
@@ -181,8 +202,13 @@ export function useRestorePageMutation() {
onSuccess: async (restoredPage) => {
notifications.show({ message: t("Page restored successfully") });
+ // Undo can fire from the trash toast after the originating tree node /
+ // page header has unmounted, so a render-time `treeData` closure would be
+ // stale. Read the live tree imperatively from the store at execution time.
+ const currentTree = store.get(treeDataAtom);
+
// Check if the page already exists in the tree (it shouldn't)
- if (!treeModel.find(treeData, restoredPage.id)) {
+ if (!treeModel.find(currentTree, restoredPage.id)) {
// Create the tree node data with hasChildren from backend
const nodeData: SpaceTreeNode = {
id: restoredPage.id,
@@ -201,17 +227,22 @@ export function useRestorePageMutation() {
let index = 0;
if (parentId) {
- const parentNode = treeModel.find(treeData, parentId);
+ const parentNode = treeModel.find(currentTree, parentId);
if (parentNode) {
index = parentNode.children?.length || 0;
}
} else {
// Root level page
- index = treeData.length;
+ index = currentTree.length;
}
- // Add the node to the tree
- setTreeData(treeModel.insert(treeData, parentId, nodeData, index));
+ // Add the node to the tree via a functional updater, re-checking
+ // existence against the freshest state for idempotency.
+ setTreeData((prev) =>
+ treeModel.find(prev, restoredPage.id)
+ ? prev
+ : treeModel.insert(prev, parentId, nodeData, index),
+ );
// Emit websocket event to sync with other users
setTimeout(() => {
diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx
index d21305a3..54d75f3f 100644
--- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx
+++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx
@@ -19,7 +19,6 @@ import {
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 { useDeletePageModal } from "@/features/page/hooks/use-delete-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";
@@ -47,7 +46,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
- const { openDeleteModal } = useDeletePageModal();
const { handleDelete } = useTreeMutation(node.spaceId);
const [data, setData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
@@ -257,9 +255,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
- openDeleteModal({
- onConfirm: () => handleDelete(node.id),
- });
+ handleDelete(node.id);
}}
>
{t("Move to trash")}