dorfteich/apps/web/src/pages/PageActions.tsx
Claude Fable 5 1eca7c334c #162: Fokus-Management für Dialoge und Such-Palette
Gemeinsamer useModalFocus-Hook: Initialfokus in den Dialog, Tab/Shift-Tab
zyklisch gefangen, Fokus-Rückgabe an den Auslöser (bzw. returnFocusRef,
wenn der öffnende Menüpunkt mit dem Menü unmountet). Dialoge tragen jetzt
aria-labelledby auf ihre Überschrift und tabindex=-1 als Fokus-Fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 13:52:59 +02:00

311 lines
10 KiB
TypeScript

import { EXPORT_FORMATS, ExportFormat, PageListItemView, PondView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
BookOpen,
Copy,
Download,
Ellipsis,
FolderInput,
History,
Paperclip,
Pencil,
Save,
Tag,
Trash2,
Wrench,
} from 'lucide-react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { IconButton } from '../components/IconButton';
import { useToast } from '../components/Toast';
import { useDocumentExport } from '../export/use-document-export';
import { FavoriteToggle } from '../favorites/FavoriteToggle';
import { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
import { WatchToggle } from '../watches/WatchToggle';
import { DeletePageDialog } from './DeletePageDialog';
import { MovePageDialog } from './MovePageDialog';
interface PageActionsProps {
pageId: string;
slug: string;
pondSlug: string;
mode: 'view' | 'edit';
onToggleMode: () => void;
showAttachments: boolean;
onToggleAttachments: () => void;
hasTools: boolean;
showPageTools: boolean;
onTogglePageTools: () => void;
showLabels: boolean;
onToggleLabels: () => void;
showHistory: boolean;
onToggleHistory: () => void;
}
/**
* The page's action icons in the TopBar (issue #101), portalled into the
* page-actions slot while a page route is active. Rarely used and
* destructive actions live behind the trailing overflow menu.
*/
export function PageActions(props: PageActionsProps): React.JSX.Element {
const { t } = useTranslation('editor');
return (
<div className="page-actions">
<IconButton
className="editor-page__mode-toggle"
label={props.mode === 'edit' ? t('mode.view') : t('mode.edit')}
onClick={props.onToggleMode}
>
{props.mode === 'edit' ? <BookOpen aria-hidden /> : <Pencil aria-hidden />}
</IconButton>
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
<IconButton
className="editor-shell__attachments-toggle"
label={t('files:title')}
active={props.showAttachments}
aria-expanded={props.showAttachments}
onClick={props.onToggleAttachments}
>
<Paperclip aria-hidden />
</IconButton>
{props.hasTools && (
<IconButton
className="editor-shell__page-tools-toggle"
label={t('plugins:tools.title')}
active={props.showPageTools}
aria-expanded={props.showPageTools}
onClick={props.onTogglePageTools}
>
<Wrench aria-hidden />
</IconButton>
)}
<IconButton
className="editor-page__labels-toggle"
label={t('labels:picker.open')}
active={props.showLabels}
aria-expanded={props.showLabels}
onClick={props.onToggleLabels}
>
<Tag aria-hidden />
</IconButton>
{/* Between labels and history by design (issue #132). */}
<FavoriteToggle pageId={props.pageId} pondSlug={props.pondSlug} />
<IconButton
label={t('history.open')}
active={props.showHistory}
aria-expanded={props.showHistory}
onClick={props.onToggleHistory}
>
<History aria-hidden />
</IconButton>
<PageOverflowMenu pageId={props.pageId} slug={props.slug} pondSlug={props.pondSlug} />
</div>
);
}
/** Manually snapshot the page as a named version (M10 follow-up; edit mode
* only). The name comes from a prompt — consistent with the confirm()-level
* dialogs used for delete/restore. */
function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element {
const { t } = useTranslation('editor');
const queryClient = useQueryClient();
const showToast = useToast();
const [busy, setBusy] = useState(false);
async function save(): Promise<void> {
const label = window.prompt(t('history.savePrompt'))?.trim();
if (!label) return;
setBusy(true);
try {
const shortLabel = label.slice(0, 100);
await apiPost(`/pages/${pageId}/versions`, { label: shortLabel });
await queryClient.invalidateQueries({ queryKey: ['versions', pageId] });
showToast(t('history.savedToastNamed', { label: shortLabel }));
} catch {
showToast(t('history.saveFailed'), 'error');
} finally {
setBusy(false);
}
}
return (
<IconButton
className="editor-page__save-version"
label={t('history.saveVersion')}
disabled={busy}
onClick={() => void save()}
>
<Save aria-hidden />
</IconButton>
);
}
/** Overflow "…" menu: Markdown copy/download (#30), office/PDF export
* (#65/#67), "Move to…" (issue #109), and the destructive move-to-trash
* (#31; childless pages keep the plain confirm, pages with subpages get
* the promote-vs-subtree decision dialog). */
function PageOverflowMenu({
pageId,
slug,
pondSlug,
}: {
pageId: string;
slug: string;
pondSlug: string;
}): React.JSX.Element {
const { t } = useTranslation('editor');
const navigate = useNavigate();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [moving, setMoving] = useState(false);
const [deleting, setDeleting] = useState<{ title: string; childCount: number } | null>(null);
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
const { status, exportPage } = useDocumentExport();
const menuRef = useRef<HTMLDivElement>(null);
useDismissable(menuRef, open, () => setOpen(false));
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
// Shares the sidebar's query (same key) — the children lookup below must
// be synchronous so the childless path keeps its click→confirm→DELETE
// rhythm (specs and users rely on the delete firing immediately).
const pages = useQuery({
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.data!.id}/pages`),
enabled: Boolean(pond.data),
});
async function copyMarkdown(): Promise<void> {
try {
const markdown = await apiGetText(`/pages/${pageId}/export/markdown`);
await navigator.clipboard.writeText(markdown);
setCopyStatus('copied');
} catch {
setCopyStatus('error');
}
setTimeout(() => setCopyStatus('idle'), 2000);
}
async function deletePage(): Promise<void> {
// The decision dialog only appears when there is something to decide:
// live children per the cached list (issue #109). A stale childless
// read errs toward the DEFAULT promote semantics — never toward a
// silent subtree delete — so no blocking fetch is needed here.
const list = pages.data ?? [];
const children = list.filter((p) => p.parentId === pageId);
if (children.length > 0) {
const title = list.find((p) => p.id === pageId)?.title ?? slug;
setOpen(false);
setDeleting({ title, childCount: children.length });
return;
}
if (!window.confirm(t('page.deleteConfirm'))) return;
await apiDelete(`/pages/${pageId}`);
await queryClient.invalidateQueries({ queryKey: ['pages'] });
navigate(`/p/${pondSlug}`);
}
const exportLabel = (format: ExportFormat): string => {
if (status[format] === 'busy') return t('export:exporting');
if (status[format] === 'error') return t('export:failed');
return t(`export:${format}`);
};
return (
<div className="page-actions__more" ref={menuRef}>
<IconButton
label={t('page.moreActions')}
active={open}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<Ellipsis aria-hidden />
</IconButton>
{open && (
<div className="page-actions__menu" role="menu">
<button type="button" role="menuitem" onClick={() => void copyMarkdown()}>
<Copy aria-hidden />
{copyStatus === 'idle' && t('page.copyMarkdown')}
{copyStatus === 'copied' && t('page.markdownCopied')}
{copyStatus === 'error' && t('page.markdownCopyFailed')}
</button>
<a
role="menuitem"
href={`/api/v1/pages/${pageId}/export/markdown`}
download={`${slug}.md`}
onClick={() => setOpen(false)}
>
<Download aria-hidden />
{t('page.downloadMarkdown')}
</a>
<span className="editor-page__export">
{EXPORT_FORMATS.map((format) => (
<button
key={format}
type="button"
role="menuitem"
disabled={status[format] === 'busy'}
onClick={() => exportPage(pageId, slug, format)}
>
<Download aria-hidden />
{exportLabel(format)}
</button>
))}
</span>
<button
type="button"
role="menuitem"
className="page-actions__move"
onClick={() => {
setOpen(false);
setMoving(true);
}}
>
<FolderInput aria-hidden />
{t('page.moveTo')}
</button>
<button
type="button"
role="menuitem"
className="page-actions__menu-danger"
onClick={() => void deletePage()}
>
<Trash2 aria-hidden />
{t('page.delete')}
</button>
</div>
)}
{moving && (
<MovePageDialog
pageId={pageId}
pondSlug={pondSlug}
onClose={() => setMoving(false)}
returnFocusRef={menuRef}
/>
)}
{deleting && (
<DeletePageDialog
pageId={pageId}
title={deleting.title}
childCount={deleting.childCount}
returnFocusRef={menuRef}
onDeleted={() => {
setDeleting(null);
void queryClient.invalidateQueries({ queryKey: ['pages'] });
navigate(`/p/${pondSlug}`);
}}
onClose={() => setDeleting(null)}
/>
)}
</div>
);
}