import { useEffect, useState, type ReactNode } from 'react'; import type { ColumnMeta, TableMeta } from '@wellformed/shared'; import { api } from './api/client'; import { usePublicConfig } from './api/hooks'; import { useI18n } from './i18n'; import { useSession } from './state/session'; import { useDismissSsr } from './state/ssr'; import { useWorkspace, WorkspaceProvider } from './state/workspace'; import { useToasts } from './state/toasts'; import { readRoute, type Route } from './state/route'; import { LoginScreen } from './components/LoginScreen'; import { SignupScreen } from './components/SignupScreen'; import { InviteScreen } from './components/InviteScreen'; import { ForgotScreen, ResetScreen, VerifyScreen } from './components/Recovery'; import { MarketingScreen } from './components/MarketingScreen'; import { EmptyWorkspace } from './components/EmptyWorkspace'; import { ImportDialog } from './components/ImportDialog'; import { TidyDialog } from './components/TidyDialog'; import { BillingDialog } from './components/BillingDialog'; import { PlanBanner } from './components/PlanBanner'; import { WorkspacePicker } from './components/WorkspacePicker'; import { TopBar } from './components/TopBar'; import { Sidebar } from './components/Sidebar'; import { ViewBar } from './components/ViewBar'; import { FormulaBar } from './components/FormulaBar'; import { Grid } from './components/Grid'; import { SqlBar } from './components/SqlBar'; import { AuditPanel } from './components/AuditPanel'; import { ColumnDialog } from './components/ColumnDialog'; import { TightenDialog } from './components/TightenDialog'; import { TableDialog } from './components/TableDialog'; import { PeopleDialog } from './components/PeopleDialog'; import { AgentKeysDialog } from './components/AgentKeysDialog'; import { AccountDialog } from './components/AccountDialog'; import { WorkspaceDialog } from './components/WorkspaceDialog'; import { ConfirmDialog } from './components/Modal'; export function App({ route = readRoute() }: { route?: Route }): ReactNode { const session = useSession(); const { t } = useI18n(); if (session.loading) { return
{t('app.starting')}
; } if (!session.reachable) { return ( {t('app.unreachableBefore')} npm run dev {t('app.unreachableAfter')} ); } // Links that arrive by mail have to work for someone with no account, or no // usable password, so they are answered before the sign-in gate rather than // behind it. A reset link in particular is aimed at exactly the person who // cannot get past that gate. if (route.kind === 'invite') return ; if (route.kind === 'reset') return ; if (route.kind === 'verify') return ; if (route.kind === 'pricing') return ; if (!session.account) { if (route.kind === 'signup') return ; if (route.kind === 'forgot') return ; if (route.kind === 'signin') return ; // A named workspace with no session is either the published demo, which // reads without one, or somewhere this browser has to sign in to reach. if (route.kind === 'workspace') { if (!session.workspace) return ; return ( ); } // Landing here with nothing in the URL is someone who has not decided yet. return ; } // Signed in, but the URL names no workspace — or names one this account // cannot open. Either way the honest answer is the list of the ones it can. if (!session.workspace) { return ( ); } return ( ); } /** * What answers at the root, which is not the same page on every install. * * A hosted Wellformed opens with Wellformed selling itself: a headline, a * screenshot, four selling points and a price list. None of that belongs on an * install somebody bought and runs themselves. The prices are not theirs, the * pitch is aimed at a decision their people already made, and "Start free" is * an offer nobody on that server can accept — so the front door is the door. * * Held back until the answer is actually known. Guessing "hosted" and * correcting a moment later would flash Wellformed's own sales page across the * front of somebody else's business, which is the one thing this must not do; * guessing the other way costs a hosted visitor a few hundred milliseconds of * the same card the app already shows while it starts. */ function FrontDoor({ focus }: { focus?: 'pricing' }): ReactNode { const install = usePublicConfig(); const { t } = useI18n(); if (install.isPending) return
{t('app.starting')}
; if (install.data && !install.data.metered) return ; return ; } /** The centred card the app falls back to before it has a grid to show. */ export function Splash({ tagline, children, }: { tagline: string; children?: ReactNode; }): ReactNode { // Splash only renders as a terminal state (the API is unreachable), and a // server-rendered page whose buttons all need that API should not stay up // to disagree with it. useDismissSsr(); return (
Wellformed
{tagline}
{children &&

{children}

}
); } type Dialog = | { kind: 'addColumn' } | { kind: 'editColumn'; column: ColumnMeta } | { kind: 'tightenColumn'; column: ColumnMeta } | { kind: 'deleteColumn'; column: ColumnMeta } | { kind: 'addTable' } | { kind: 'editTable'; table: TableMeta } | { kind: 'deleteTable'; table: TableMeta } | { kind: 'people' } | { kind: 'agentKeys' } | { kind: 'account' } | { kind: 'workspace' } | { kind: 'import'; table?: string } | { kind: 'tidy'; table: string } | { kind: 'billing' } | null; function Workspace(): ReactNode { const ws = useWorkspace(); const toasts = useToasts(); const { t, locale } = useI18n(); const [dialog, setDialog] = useState(null); const [busy, setBusy] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); // The drawer traps nothing, so Escape has to be an explicit way out of it. useEffect(() => { if (!sidebarOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSidebarOpen(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [sidebarOpen]); if (ws.metaLoading) { return
{t('app.loadingCatalogue')}
; } if (ws.metaError) { return (
{ws.metaError instanceof Error ? ws.metaError.message : t('app.catalogueFailed')}
); } async function confirmDeleteColumn(column: ColumnMeta) { setBusy(true); try { await api.dropColumn(ws.tableName, column.name); await ws.refresh(); toasts.push(t('app.droppedThing', column.label), 'success'); setDialog(null); } catch (error) { toasts.fail(error); } finally { setBusy(false); } } async function confirmDeleteTable(table: TableMeta) { setBusy(true); try { await api.dropTable(table.name); // The table being looked at may be the one that just went; the workspace // state falls back to the first table left on the next render. await ws.refresh(); toasts.push(t('app.droppedThing', table.name), 'success'); setDialog(null); } catch (error) { toasts.fail(error); } finally { setBusy(false); } } return (
setDialog({ kind: 'account' })} onOpenPeople={() => setDialog({ kind: 'people' })} onOpenKeys={() => setDialog({ kind: 'agentKeys' })} onOpenWorkspace={() => setDialog({ kind: 'workspace' })} onOpenBilling={() => setDialog({ kind: 'billing' })} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} /> setDialog({ kind: 'billing' })} />
setDialog({ kind: 'addTable' })} onImport={(table) => setDialog({ kind: 'import', table })} onAddColumn={() => setDialog({ kind: 'addColumn' })} onEditTable={(table) => setDialog({ kind: 'editTable', table })} onDeleteTable={(table) => setDialog({ kind: 'deleteTable', table })} onTidy={(table) => setDialog({ kind: 'tidy', table })} open={sidebarOpen} onNavigate={() => setSidebarOpen(false)} /> {sidebarOpen && (
setSidebarOpen(false)} aria-hidden="true" /> )}
{/* A workspace with no tables has no grid to draw, no view bar to fill and no query to echo — the whole chrome would be empty furniture around the one thing that matters here. */} {ws.tables.length === 0 ? ( setDialog({ kind: 'addTable' })} onImport={() => setDialog({ kind: 'import' })} /> ) : ( <> setDialog({ kind: 'addColumn' })} onEditColumn={(column) => setDialog({ kind: 'editColumn', column })} onTightenColumn={(column) => setDialog({ kind: 'tightenColumn', column })} onDeleteColumn={(column) => setDialog({ kind: 'deleteColumn', column })} /> )}
{ws.showAudit && }
{dialog?.kind === 'addColumn' && setDialog(null)} />} {dialog?.kind === 'editColumn' && ( setDialog(null)} /> )} {dialog?.kind === 'tightenColumn' && ( setDialog(null)} /> )} {dialog?.kind === 'deleteColumn' && (

{t('app.dropColumnBefore')} ALTER TABLE {ws.tableName} DROP COLUMN {dialog.column.name} {t('app.dropColumnAfter')}

{t('app.dropColumnRefuses')}

} onCancel={() => setDialog(null)} onConfirm={() => void confirmDeleteColumn(dialog.column)} /> )} {dialog?.kind === 'addTable' && setDialog(null)} />} {dialog?.kind === 'editTable' && ( setDialog(null)} /> )} {dialog?.kind === 'deleteTable' && (

{t('app.dropTableBefore')} DROP TABLE {dialog.table.name} {t('app.dropTableAfter', dialog.table.rowCount, locale)}

{t('app.dropTableRefuses')}

} onCancel={() => setDialog(null)} onConfirm={() => void confirmDeleteTable(dialog.table)} /> )} {dialog?.kind === 'import' && ( setDialog(null)} onTidy={(table) => setDialog({ kind: 'tidy', table })} /> )} {dialog?.kind === 'tidy' && ( setDialog(null)} /> )} {dialog?.kind === 'billing' && setDialog(null)} />} {dialog?.kind === 'people' && setDialog(null)} />} {dialog?.kind === 'agentKeys' && setDialog(null)} />} {dialog?.kind === 'account' && setDialog(null)} />} {dialog?.kind === 'workspace' && setDialog(null)} />}
); }