/** * App — citation-evidence demo shell (CE-WP-0005). * * Composition: * * SessionProvider (cross-session) * └─ AppShell — owns routing + the top bar * ├─ if no active session → CreateFirstSession (empty state) * └─ else * EngineProvider key={sessionId} sessionId={sessionId} * └─ BinderProvider bus={engine.bus} * └─ ReviewLayout | FormsApp (per `mode`) * * The hash is the single source of truth for `{sessionId, mode}`. The * SessionService's active id is kept in sync with the hash via a * useEffect inside `AppShell`. Deep links to unknown sessions redirect * to the empty state with a toast. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BinderProvider } from "@binder/index"; import { EngineProvider, SessionProvider, useActiveSession, useEngine, usePdfByteStore, useSessionByteStoreRegistry, useSessionService, useSessionsHydrated, useSessionVersion, useSessionVersionBumper, } from "@work/index"; import { FormsApp } from "./forms/FormsApp"; import { ReviewLayout } from "./ReviewLayout"; import { CreateFirstSession, EMPTY_ROUTE, exportSessionZip, importSessionZip, parseRoute, navigateTo, SessionMenu, sessionZipFilename, Toast, triggerSessionDownload, UploadDropzone, useToast, type AppMode, type AppRoute, } from "./sessions"; function readRoute(): AppRoute { if (typeof window === "undefined") return EMPTY_ROUTE; return parseRoute(window.location.hash); } function useHashRoute(): AppRoute { const [route, setRoute] = useState(() => readRoute()); useEffect(() => { const handler = () => setRoute(readRoute()); window.addEventListener("hashchange", handler); return () => window.removeEventListener("hashchange", handler); }, []); return route; } function AppShell() { const route = useHashRoute(); const service = useSessionService(); const hydrated = useSessionsHydrated(); const toast = useToast(); // Guards the "unknown session id → toast + redirect" path against an // infinite loop: `useToast.show` creates a fresh `toast` object every // render, which would otherwise re-fire the effect. const lastHandledSessionIdRef = useRef(null); // Sync hash → SessionService.setActive. Unknown session ids fall back // to the empty state with a toast. useEffect(() => { if (!hydrated) return; const key = route.sessionId ?? ""; if (lastHandledSessionIdRef.current === key) return; lastHandledSessionIdRef.current = key; if (route.sessionId === null) { service.setActive(null); return; } const exists = service.get(route.sessionId); if (exists) { service.setActive(route.sessionId); } else { toast.show("Session not found — opened the empty state instead", "error"); navigateTo(EMPTY_ROUTE); } }, [route.sessionId, service, hydrated, toast]); if (!hydrated) { return (
Loading…
); } if (route.sessionId === null) { return (
); } return ; } function ActiveAppFrame({ route, toast, }: { route: AppRoute; toast: ReturnType; }) { // EngineProvider remounts whenever the session id OR the per-session // version counter changes. Import-into-active-session bumps the version // so the new state from storage is picked up. const sessionId = route.sessionId!; const version = useSessionVersion(sessionId); return (
); } function SessionScopedTree({ mode }: { mode: AppMode }) { const engine = useEngine(); return ( {mode === "forms" ? : } />} ); } function EmptyTopBar() { const sessionService = useSessionService(); const registry = useSessionByteStoreRegistry(); const bumpVersion = useSessionVersionBumper(); const toast = useToast(); // local toast — empty state has its own const handleImport = useCallback(async (file: File) => { try { const result = await importSessionZip(file, { sessionService, getOrCreateByteStore: registry.getOrCreateByteStore, bumpSessionVersion: bumpVersion, }); navigateTo({ sessionId: result.sessionId, mode: "review" }); toast.show( result.outcome === "created" ? "Imported as a new session" : "Merged into existing session", "success", ); } catch (err) { toast.show( err instanceof Error ? `Import failed: ${err.message}` : "Import failed", "error", ); } }, [sessionService, registry, bumpVersion, toast]); return (
citation-evidence pickAndImport(handleImport)} />
); } function pickAndImport(onPicked: (file: File) => void): void { if (typeof document === "undefined") return; const input = document.createElement("input"); input.type = "file"; input.accept = ".zip,application/zip"; input.onchange = () => { const file = input.files?.[0]; if (file) onPicked(file); }; input.click(); } function ActiveTopBar({ route, showToast, }: { route: AppRoute; showToast: (msg: string, tone?: "success" | "error" | "info") => void; }) { const engine = useEngine(); const byteStore = usePdfByteStore(); const session = useActiveSession(); const sessionService = useSessionService(); const registry = useSessionByteStoreRegistry(); const bumpVersion = useSessionVersionBumper(); const handleModeChange = useCallback( (next: AppMode) => { if (!route.sessionId) return; navigateTo({ sessionId: route.sessionId, mode: next }); }, [route.sessionId], ); const handleExport = useCallback(async () => { if (!session) return; try { const blob = await exportSessionZip(engine, byteStore, session); triggerSessionDownload(blob, sessionZipFilename(session)); showToast("Session exported", "success"); } catch (err) { showToast( err instanceof Error ? `Export failed: ${err.message}` : "Export failed", "error", ); } }, [engine, byteStore, session, showToast]); const handleImport = useCallback( async (file: File) => { try { const result = await importSessionZip(file, { sessionService, getOrCreateByteStore: registry.getOrCreateByteStore, bumpSessionVersion: bumpVersion, }); navigateTo({ sessionId: result.sessionId, mode: "review" }); const totals = result.stats; const summary = result.outcome === "created" ? `Imported new session — ${totals.documentsAdded} document${totals.documentsAdded === 1 ? "" : "s"}, ${totals.annotationsAdded} annotation${totals.annotationsAdded === 1 ? "" : "s"}` : `Merged into existing — ${totals.documentsAdded} new doc${totals.documentsAdded === 1 ? "" : "s"}, ${totals.documentsDeduped} deduped`; showToast(summary, "success"); } catch (err) { showToast( err instanceof Error ? `Import failed: ${err.message}` : "Import failed", "error", ); } }, [sessionService, registry, bumpVersion, showToast], ); const tabs = useMemo( () => [ { id: "review" as const, label: "Review" }, { id: "forms" as const, label: "Forms" }, ], [], ); return (
citation-evidence void handleExport()} onImportZip={() => pickAndImport((file) => void handleImport(file))} />
{tabs.map((t) => ( ))}
); } function tabStyle(active: boolean) { return { padding: "4px 12px", fontSize: 12, border: "1px solid #ccc", borderBottom: active ? "2px solid #0050b3" : "1px solid #ccc", background: active ? "#e8f0ff" : "white", cursor: "pointer" as const, }; } export function App() { return ( ); }