// Exports of the current screen: each screen registers what it can save (useExports); the top bar shows them in one // "Export" menu at the top right of every screen, and the command palette lists them under "Export". Saving goes // through the host's native save dialog (Downloads first); exports with personal data get the host's note first. import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { api, errorText, type ExportKind } from '../api'; import { t } from '../i18n'; import { useData } from '../store'; import { Icon, type IconName } from './icons'; export interface ExportItem { id: string; label: string; icon?: IconName; kind: ExportKind; /** File name without the extension ("Prism Puddle prices 2026-10-11"). */ file: string; /** Reviewer names, texts: the host shows the personal-data note before the save dialog. */ personal?: boolean; /** Not available yet (nothing read): the menu says why. */ disabled?: string | false; build: () => string | Promise; } interface Ctx { items: ExportItem[]; set: (owner: string, items: ExportItem[] | null) => void } const ExportCtx = createContext(null); export function ExportProvider({ children }: { children: ReactNode }) { const [map, setMap] = useState>({}); const set = useCallback((owner: string, items: ExportItem[] | null) => setMap(m => { const next = { ...m }; if (items) next[owner] = items; else delete next[owner]; return next; }), []); const items = useMemo(() => Object.values(map).flat(), [map]); return ({ items, set }), [items, set])}>{children}; } export const useExportItems = (): ExportItem[] => useContext(ExportCtx)?.items ?? []; /** Registers this screen's exports while it is shown. `build` closures always see the latest data. */ export function useExports(owner: string, items: ExportItem[]) { const ctx = useContext(ExportCtx); const ref = useRef(items); ref.current = items; const key = items.map(i => `${i.id}:${i.label}:${i.disabled || ''}:${i.file}`).join('|'); useEffect(() => { if (!ctx) return; ctx.set(owner, ref.current.map((it, n) => ({ ...it, build: () => ref.current[n]!.build() }))); return () => ctx.set(owner, null); }, [owner, key]); // eslint-disable-line react-hooks/exhaustive-deps } /** Runs one export: build, native save dialog, a toast with where it went. */ export function useRunExport() { const d = useData(); return useCallback(async (it: ExportItem) => { if (it.disabled) { d.toast({ kind: 'info', title: it.label, detail: it.disabled }); return; } try { const content = await it.build(); const path = await api.exportSave(it.file, it.kind, content, !!it.personal); if (path) d.toast({ kind: 'ok', title: t('export.saved'), detail: path }); } catch (e) { d.toast({ kind: 'bad', title: t('export.failed'), detail: errorText(e) }); } }, [d]); } /** The top-right "Export" button and its menu (hidden when the screen has nothing to export). */ export function ExportMenu() { const items = useExportItems(); const run = useRunExport(); const [open, setOpen] = useState(false); const root = useRef(null); useEffect(() => { if (!open) return; const close = (e: MouseEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); }; const key = (e: KeyboardEvent) => { if (e.key === 'Escape') { setOpen(false); root.current?.querySelector('button')?.focus(); } }; window.addEventListener('mousedown', close); window.addEventListener('keydown', key); root.current?.querySelector('[role="menuitem"]')?.focus(); return () => { window.removeEventListener('mousedown', close); window.removeEventListener('keydown', key); }; }, [open]); if (!items.length) return null; const onMenuKey = (e: React.KeyboardEvent) => { const els = [...(root.current?.querySelectorAll('[role="menuitem"]') ?? [])]; const i = els.indexOf(document.activeElement as HTMLElement); if (e.key === 'ArrowDown') { e.preventDefault(); els[(i + 1) % els.length]?.focus(); } if (e.key === 'ArrowUp') { e.preventDefault(); els[(i - 1 + els.length) % els.length]?.focus(); } }; return (
{open && (
{items.map(it => ( ))}
)}
); }