Files
gitmost/apps/client/src/features/editor/full-editor.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

248 lines
7.5 KiB
TypeScript

import classes from "@/features/editor/styles/editor.module.css";
import React, { useEffect } from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import {
ActionIcon,
Container,
Divider,
Group,
Popover,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useAtom, useAtomValue } from "jotai";
import {
userAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts";
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import { TemporaryNoteBanner } from "@/features/page/components/temporary-note-banner.tsx";
import clsx from "clsx";
import {
currentPageEditModeAtom,
pageEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
const MemoizedFixedToolbar = React.memo(FixedToolbar);
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner);
type PageUser = {
id: string;
name: string;
avatarUrl: string;
};
// Module-level flag: survives component unmount/remount on page navigation,
// reset only on full page reload (i.e. a new app session).
let defaultEditModeApplied = false;
export interface FullEditorProps {
pageId: string;
slugId: string;
title: string;
content: string;
spaceSlug: string;
editable: boolean;
creator?: PageUser;
contributors?: IContributor[];
canComment?: boolean;
}
export function FullEditor({
pageId,
title,
slugId,
content,
spaceSlug,
editable,
creator,
contributors,
canComment,
}: FullEditorProps) {
const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
if (!defaultEditModeApplied) {
setCurrentPageEditMode(userPageEditMode as PageEditMode);
defaultEditModeApplied = true;
}
}, [userPageEditMode, setCurrentPageEditMode]);
return (
<Container
fluid={fullPageWidth}
size={!fullPageWidth && 900}
className={classes.editor}
>
{editorToolbarEnabled && editable && isEditMode && (
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTemporaryNoteBanner slugId={slugId} />
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</Container>
);
}
type PageBylineProps = {
creator?: PageUser;
contributors?: IContributor[];
editable?: boolean;
isEditMode?: boolean;
isDictationEnabled?: boolean;
};
function PageByline({
creator,
contributors,
editable,
isEditMode,
isDictationEnabled,
}: PageBylineProps) {
const { t } = useTranslation();
const detailsTriggerProps = useAsideTriggerProps("details");
const editor = useAtomValue(pageEditorAtom);
const showDictation = Boolean(
isDictationEnabled && editable && isEditMode && editor,
);
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
);
return (
<Group
gap="sm"
mb="md"
className={clsx("print-hide", classes.byline)}
style={{ marginTop: "-0.5em" }}
>
{creator && (
<Popover position="bottom-start" shadow="md" width={280} withArrow>
<Popover.Target>
<UnstyledButton
aria-label={t("Created by {{name}}", { name: creator.name })}
>
<Group gap={6}>
<CustomAvatar
avatarUrl={creator.avatarUrl}
name={creator.name}
size={22}
/>
<Text size="sm" c="dimmed">
{t("By {{name}}", { name: creator.name })}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Group gap="sm">
<CustomAvatar
avatarUrl={creator.avatarUrl}
name={creator.name}
size={36}
/>
<div>
<Text size="sm" fw={500}>
{creator.name}
</Text>
<Text size="xs" c="dimmed">
{otherContributors.length === 0
? t("Owner, no contributors")
: t("Owner")}
</Text>
</div>
</Group>
{otherContributors.length > 0 && (
<>
<Divider />
<Text size="xs" fw={500} c="dimmed" tt="uppercase">
{t("Contributors")}
</Text>
<Stack gap={6}>
{otherContributors.map((contributor) => (
<Group gap="sm" key={contributor.id}>
<CustomAvatar
avatarUrl={contributor.avatarUrl}
name={contributor.name}
size={28}
/>
<Text size="sm">{contributor.name}</Text>
</Group>
))}
</Stack>
</>
)}
</Stack>
</Popover.Dropdown>
</Popover>
)}
<Group gap={4} wrap="nowrap">
<Tooltip label={t("Details")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Details")}
{...detailsTriggerProps}
>
<IconInfoCircle size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
{/* Shown only in edit mode when workspace dictation is enabled, so
dictation stays reachable even when the fixed toolbar is hidden. */}
{showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} />
)}
</Group>
</Group>
);
}