"Temporary notes" with a death timer: created via a dedicated hourglass button
in the space-tree header, a note auto-moves to Trash after a configurable X
hours (default 24) unless explicitly made permanent ("structure or die").
Reuses existing mechanisms, mirroring is_template and the trash-cleanup job:
- New nullable column pages.temporary_expires_at (NULL = permanent; non-NULL =
frozen deadline) + partial index for the sweep; workspace column
temporary_note_hours (default via DEFAULT_TEMPORARY_NOTE_HOURS = 24).
- create-page DTO `temporary` flag; the deadline is frozen at creation so later
setting changes never reschedule existing notes.
- POST /pages/toggle-temporary (mirror of toggle-template): arm/clear the timer,
CASL-guarded via validateCanEdit, cross-workspace NotFound defense-in-depth.
- TemporaryNoteCleanupService: hourly @Interval sweep that soft-deletes expired
notes through the exact PageRepo.removePage path (recursive over children,
emits PAGE_SOFT_DELETED), attributed to the creator; idempotent via
deletedAt IS NULL filters.
- restorePage clears temporary_expires_at so a restored note can't be re-trashed.
- Workspace setting temporary_note_hours (audit-tracked) + a hours editor in
workspace General settings.
- Client: second create button, orange tree icon, tree + page-header menu toggle
("Make temporary"/"Make permanent"), an open-note banner with a rescue action,
and en/ru i18n.
Tests (unit): toggle-temporary controller (toggle/explicit/permission/cross-ws +
DTO validation), cleanup-job sweep (selection filters, per-note removePage,
error isolation), and a migration up/down sanity. Server tsc, client tsc -b,
and the page+workspace jest suites are green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
332 lines
9.9 KiB
TypeScript
332 lines
9.9 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,
|
|
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 { 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;
|
|
|
|
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")}
|
|
</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 to space")}
|
|
</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.Item
|
|
leftSection={<IconClockHour4 size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleToggleTemporary();
|
|
}}
|
|
>
|
|
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
|
</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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|