Files
gitmost/apps/client/src/features/page/tree/components/space-tree-row.tsx
claude code agent 227 eb5b696431 feat(page): temporary notes — auto-trash after X hours unless made permanent (#201)
"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>
2026-06-26 20:38:42 +03:00

335 lines
9.2 KiB
TypeScript

import { useRef } from "react";
import { Link, useParams } from "react-router-dom";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconClockHour4,
IconFileDescription,
IconPlus,
IconPointFilled,
IconTemplate,
} from "@tabler/icons-react";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { queryClient } from "@/main.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageById } from "@/features/page/services/page-service.ts";
import {
useUpdatePageMutation,
fetchAllAncestorChildren,
} from "@/features/page/queries/page-query.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
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 type { RenderRowProps } from "./doc-tree";
import { NodeMenu } from "./space-tree-node-menu";
import classes from "@/features/page/tree/styles/tree.module.css";
import { updateTreeNodeIcon } from "@/features/page/tree/utils/utils.ts";
type SpaceTreeRowProps = RenderRowProps<SpaceTreeNode> & {
readOnly: boolean;
};
export function SpaceTreeRow({
node,
isOpen,
hasChildren,
toggleOpen,
rowRef,
tabIndex,
treeItemProps,
readOnly,
}: SpaceTreeRowProps) {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const updatePageMutation = useUpdatePageMutation();
const [, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const canEdit = !readOnly && node.canEdit !== false;
const pageUrl = buildPageUrl(spaceSlug, node.slugId, node.name);
const prefetchPage = () => {
timerRef.current = setTimeout(async () => {
const page = await queryClient.fetchQuery({
queryKey: ["pages", node.id],
queryFn: () => getPageById({ pageId: node.id }),
staleTime: 5 * 60 * 1000,
});
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150);
};
const cancelPagePrefetch = () => {
if (timerRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
const handleUpdateNodeIcon = (nodeId: string, newIcon: string | null) => {
setTreeData((prev) =>
updateTreeNodeIcon(prev, nodeId, newIcon),
);
};
const handleEmojiIconClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleEmojiSelect = (emoji: { native: string }) => {
handleUpdateNodeIcon(node.id, emoji.native);
updatePageMutation
.mutateAsync({ pageId: node.id, icon: emoji.native })
.then((data) => {
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: emoji.native, parentPageId: data.parentPageId },
});
}, 50);
});
};
const handleRemoveEmoji = () => {
handleUpdateNodeIcon(node.id, null);
updatePageMutation.mutateAsync({ pageId: node.id, icon: null });
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: null },
});
}, 50);
};
const handleLoadChildren = async () => {
if (!node.hasChildren) return;
try {
const childrenTree = await fetchAllAncestorChildren({
pageId: node.id,
spaceId: node.spaceId,
});
setTreeData((prev) =>
treeModel.appendChildren(prev, node.id, childrenTree),
);
} catch (error) {
console.error("Failed to fetch children:", error);
}
};
return (
<Link
ref={rowRef as React.Ref<HTMLAnchorElement>}
to={pageUrl}
className={classes.node}
tabIndex={tabIndex}
{...treeItemProps}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
onMouseEnter={prefetchPage}
onMouseLeave={cancelPagePrefetch}
>
<PageArrow
isOpen={isOpen}
hasChildren={hasChildren}
onToggle={toggleOpen}
/>
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
<EmojiPicker
onEmojiSelect={handleEmojiSelect}
icon={
node.icon ? node.icon : <IconFileDescription size="18" />
}
readOnly={!canEdit}
removeEmojiAction={handleRemoveEmoji}
actionIconProps={{ tabIndex: -1 }}
/>
</div>
<span className={classes.text}>{node.name || t("Untitled")}</span>
{node.isTemplate === true && (
<Tooltip label={t("Template")} withArrow>
<IconTemplate
size={14}
stroke={1.5}
// Visual-only indicator: subtle and never shrinks. Pointer events
// stay enabled so the Tooltip's hover handlers fire; clicks fall
// through to the row link since no stopPropagation is used.
style={{
flexShrink: 0,
marginLeft: rem(4),
color: "var(--mantine-color-dimmed)",
}}
aria-label={t("Template")}
role="img"
/>
</Tooltip>
)}
{node.temporaryExpiresAt && (
<Tooltip
// Children ride along to trash with the note (recursive removePage).
label={t("Temporary note — moves to trash unless made permanent")}
withArrow
>
<IconClockHour4
size={14}
stroke={1.5}
// Same visual-only indicator pattern as the template icon, but
// orange to flag the impending death timer.
style={{
flexShrink: 0,
marginLeft: rem(4),
color: "var(--mantine-color-orange-6)",
}}
aria-label={t("Temporary note")}
role="img"
/>
</Tooltip>
)}
<div className={classes.actions}>
<NodeMenu node={node} canEdit={canEdit} />
{canEdit && (
<CreateNode
node={node}
isOpen={isOpen}
hasChildren={hasChildren}
onToggle={toggleOpen}
onExpandTree={handleLoadChildren}
/>
)}
</div>
</Link>
);
}
interface PageArrowProps {
isOpen: boolean;
hasChildren: boolean;
onToggle: () => void;
}
function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
const { t } = useTranslation();
if (!hasChildren) {
return (
<span
aria-hidden
className={classes.actionIcon}
style={{
width: 20,
height: 20,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<IconPointFilled size={8} />
</span>
);
}
return (
<ActionIcon
size={20}
variant="subtle"
color="gray"
className={classes.actionIcon}
aria-label={isOpen ? t("Collapse") : t("Expand")}
aria-expanded={isOpen}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle();
}}
>
{isOpen ? (
<IconChevronDown stroke={2} size={18} />
) : (
<IconChevronRight stroke={2} size={18} />
)}
</ActionIcon>
);
}
interface CreateNodeProps {
node: SpaceTreeNode;
isOpen: boolean;
hasChildren: boolean;
onToggle: () => void;
onExpandTree: () => Promise<void> | void;
}
function CreateNode({
node,
isOpen,
hasChildren,
onToggle,
onExpandTree,
}: CreateNodeProps) {
const { t } = useTranslation();
const { handleCreate } = useTreeMutation(node.spaceId);
async function handleClickCreate() {
if (node.hasChildren && !hasChildren) {
// Expand and lazy-load before creating a child. handleCreate reads the
// latest tree imperatively (via useStore) so we no longer need a
// setTimeout to wait for React to rerun the closure with fresh data.
if (!isOpen) onToggle();
await onExpandTree();
} else if (!isOpen) {
onToggle();
}
handleCreate(node.id);
}
return (
<ActionIcon
size={20}
variant="subtle"
color="gray"
className={classes.actionIcon}
aria-label={t("Create subpage of {{name}}", { name: node.name || t("Untitled") })}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleClickCreate();
}}
>
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
</ActionIcon>
);
}