106 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|