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)} /> )}
); }