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 (
{props.mode === 'edit' ? : }
{props.mode === 'edit' &&
}
{props.hasTools && (
)}
{/* Between labels and history by design (issue #132). */}
);
}
/** 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 {
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 (
void save()}
>
);
}
/** 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(null);
useDismissable(menuRef, open, () => setOpen(false));
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet(`/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(`/ponds/${pond.data!.id}/pages`),
enabled: Boolean(pond.data),
});
async function copyMarkdown(): Promise {
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 {
// 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 (
setOpen((value) => !value)}
>
{open && (
setOpen(false)}
>
{t('page.downloadMarkdown')}
{EXPORT_FORMATS.map((format) => (
))}
)}
{moving && (
setMoving(false)}
returnFocusRef={menuRef}
/>
)}
{deleting && (
{
setDeleting(null);
void queryClient.invalidateQueries({ queryKey: ['pages'] });
navigate(`/p/${pondSlug}`);
}}
onClose={() => setDeleting(null)}
/>
)}
);
}