storebound/apps/desktop/src/ui/Exports.tsx

106 lines
5.3 KiB
TypeScript

// 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<string>;
}
interface Ctx { items: ExportItem[]; set: (owner: string, items: ExportItem[] | null) => void }
const ExportCtx = createContext<Ctx | null>(null);
export function ExportProvider({ children }: { children: ReactNode }) {
const [map, setMap] = useState<Record<string, ExportItem[]>>({});
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 <ExportCtx.Provider value={useMemo(() => ({ items, set }), [items, set])}>{children}</ExportCtx.Provider>;
}
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<HTMLDivElement>(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<HTMLElement>('button')?.focus(); } };
window.addEventListener('mousedown', close);
window.addEventListener('keydown', key);
root.current?.querySelector<HTMLElement>('[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<HTMLElement>('[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 (
<div className="menu-wrap" ref={root}>
<button className="btn btn-sm" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(o => !o)} title={t('export.hint')}>
<Icon name="share" />{t('export.button')}<Icon name="chevronDown" />
</button>
{open && (
<div className="menu" role="menu" aria-label={t('export.button')} onKeyDown={onMenuKey}>
{items.map(it => (
<button key={it.id} role="menuitem" className="menu-item" aria-disabled={it.disabled ? true : undefined} onClick={() => { setOpen(false); run(it); }}>
<Icon name={it.icon ?? 'download'} />
<span className="grow"><span className="mi-t">{it.label}</span>{it.disabled ? <span className="mi-s">{it.disabled}</span> : <span className="mi-s">.{it.kind}{it.personal ? ` · ${t('export.personal')}` : ''}</span>}</span>
{it.personal && <Icon name="lock" className="ic13 muted" />}
</button>
))}
</div>
)}
</div>
);
}