dataflow/ui/src/App.jsx
Paul Trowbridge 356e61d043 Add a mobile layout and stop shipping Perspective to every page
Below md: the sidebar is replaced by a fixed bottom bar carrying the same
destinations plus the theme toggle. NAV moved to navItems.jsx so the two
can't drift apart.

Perspective is now lazy-loaded. It was ~90% of the bundle and only Pivot
uses it, so the initial download drops from 4.9 MB gzipped to 173 kB and
the rest arrives only when a pivot is opened. Desktop benefits as much as
phones do.

Bridge's balance table becomes stacked cards under sm: with the same
subtotals, source tabs scroll rather than wrap, and page gutters tighten
on small screens.

Rules, Mappings, and Records are deliberately untouched — they are wide
data tables and belong on a desktop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-02 14:09:06 -04:00

227 lines
9.4 KiB
JavaScript

import { useState, useEffect, createElement, lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'
import { api, setCredentials, clearCredentials } from './api'
import Sidebar from './components/Sidebar.jsx'
import BottomNav from './components/BottomNav.jsx'
import SourceTabs from './components/SourceTabs.jsx'
import Login from './pages/Login'
import SourceList from './pages/SourceList'
import SourceDetail from './pages/SourceDetail'
import Bridge from './pages/Bridge'
import ImportHub from './pages/ImportHub'
import Import from './pages/Import'
import Rules from './pages/Rules'
import Mappings from './pages/Mappings'
import Records from './pages/Records'
import Log from './pages/Log'
const Pivot = lazy(() => import('./pages/Pivot'))
import Remap from './pages/Remap'
import Stacks from './pages/Stacks'
// Source-scoped pages still take a `source` prop; this reads it off the URL so
// they didn't all need rewriting when selection moved out of the status bar.
function ScopedToSource({ component, ...props }) {
const { name } = useParams()
return createElement(component, { source: name, ...props })
}
// Pivot doubles as the stack viewer; a stack in the URL takes precedence there
function StackPivot() {
const { name } = useParams()
return <Pivot source={name} selectedStack={name} setSelectedStack={() => {}} />
}
export default function App() {
const [authed, setAuthed] = useState(false)
const [loginUser, setLoginUser] = useState('')
const [sources, setSources] = useState([])
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('df_sidebar') !== 'collapsed')
// Sets of names whose dfv view is out of sync with current definitions
const [staleSources, setStaleSources] = useState(new Set())
const [staleStacks, setStaleStacks] = useState(new Set())
const [reprocessSources, setReprocessSources] = useState(new Set())
const [generating, setGenerating] = useState({}) // { 'source:name': true }
async function handleLogin(user, pass) {
setCredentials(user, pass)
const s = await api.getSources()
sessionStorage.setItem('df_user', user)
sessionStorage.setItem('df_pass', pass)
setSources(s)
if (!source && s.length > 0) setSource(s[0].name)
setAuthed(true)
setLoginUser(user)
}
function handleLogout() {
clearCredentials()
sessionStorage.removeItem('df_user')
sessionStorage.removeItem('df_pass')
setAuthed(false)
setLoginUser('')
setSources([])
setStaleSources(new Set())
setStaleStacks(new Set())
setReprocessSources(new Set())
}
// Load initial stale state from DB once on login
useEffect(() => {
if (!authed) return
api.getStatus().then(s => {
setStaleSources(new Set((s.stale_sources || []).map(x => x.name)))
setStaleStacks(new Set((s.stale_stacks || []).map(x => x.name)))
}).catch(() => {})
}, [authed])
function markSourceStale(name) {
setStaleSources(prev => new Set([...prev, name]))
}
function markNeedsReprocess(name) {
setReprocessSources(prev => new Set([...prev, name]))
}
async function handleReprocessSource(name) {
setGenerating(g => ({ ...g, [`rp:${name}`]: true }))
try {
await api.reprocess(name)
setReprocessSources(prev => { const n = new Set(prev); n.delete(name); return n })
} catch (e) { alert(e.message) }
finally { setGenerating(g => { const n = { ...g }; delete n[`rp:${name}`]; return n }) }
}
function markStackStale(name) {
setStaleStacks(prev => new Set([...prev, name]))
}
function clearStackStale(name) {
setStaleStacks(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleGenerateSource(name) {
setGenerating(g => ({ ...g, [`src:${name}`]: true }))
try {
await api.generateView(name)
setStaleSources(prev => { const n = new Set(prev); n.delete(name); return n })
} catch (e) { alert(e.message) }
finally { setGenerating(g => { const n = { ...g }; delete n[`src:${name}`]; return n }) }
}
async function handleGenerateStack(name) {
setGenerating(g => ({ ...g, [`stk:${name}`]: true }))
try {
await api.generateStackView(name)
setStaleStacks(prev => { const n = new Set(prev); n.delete(name); return n })
} catch (e) { alert(e.message) }
finally { setGenerating(g => { const n = { ...g }; delete n[`stk:${name}`]; return n }) }
}
// On mount, restore session if credentials are saved
useEffect(() => {
const user = sessionStorage.getItem('df_user')
const pass = sessionStorage.getItem('df_pass')
if (user && pass) handleLogin(user, pass).catch(() => handleLogout())
}, [])
useEffect(() => {
if (source) localStorage.setItem('selectedSource', source)
}, [source])
useEffect(() => {
localStorage.setItem('df_sidebar', sidebarExpanded ? 'expanded' : 'collapsed')
}, [sidebarExpanded])
if (!authed) return <Login onLogin={handleLogin} />
return (
<BrowserRouter>
<div className="flex h-screen">
<div className="hidden md:flex">
<Sidebar
expanded={sidebarExpanded}
setExpanded={setSidebarExpanded}
loginUser={loginUser}
onLogout={handleLogout}
/>
</div>
{/* Main */}
<div className="flex-1 overflow-hidden flex flex-col min-w-0">
{(staleSources.size > 0 || staleStacks.size > 0) && (
<div className="bg-warn-soft border-b border-warn-line px-4 py-1.5 text-xs text-warn flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">View out of sync:</span>
{[...staleSources].map(name => (
<span key={name} className="flex items-center gap-1">
{name}
<button
onClick={() => handleGenerateSource(name)}
disabled={generating[`src:${name}`]}
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
>
{generating[`src:${name}`] ? '…' : 'Generate'}
</button>
</span>
))}
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-warn">|</span>}
{[...staleStacks].map(name => (
<span key={name} className="flex items-center gap-1">
stack: {name}
<button
onClick={() => handleGenerateStack(name)}
disabled={generating[`stk:${name}`]}
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
>
{generating[`stk:${name}`] ? '…' : 'Generate'}
</button>
</span>
))}
</div>
)}
{reprocessSources.size > 0 && (
<div className="bg-accent-soft border-b border-accent-line px-4 py-1.5 text-xs text-accent flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">Mappings updated:</span>
{[...reprocessSources].map(name => (
<span key={name} className="flex items-center gap-1">
{name}
<button
onClick={() => handleReprocessSource(name)}
disabled={generating[`rp:${name}`]}
className="px-1.5 py-0.5 rounded bg-blue-200 hover:bg-blue-300 disabled:opacity-50 font-medium"
>
{generating[`rp:${name}`] ? '…' : 'Reprocess'}
</button>
</span>
))}
</div>
)}
<div className="flex-1 overflow-auto pb-14 md:pb-0">
<Suspense fallback={<div className="p-6 text-sm text-muted">Loading</div>}>
<Routes>
<Route path="/" element={<Navigate to="/sources" replace />} />
<Route path="/sources" element={<SourceList sources={sources} setSources={setSources} setSource={setSource} />} />
<Route path="/sources/:name" element={<SourceTabs sources={sources} />}>
<Route index element={<SourceDetail sources={sources} setSources={setSources} />} />
<Route path="import" element={<ScopedToSource component={Import} />} />
<Route path="rules" element={<ScopedToSource component={Rules} onStale={markSourceStale} />} />
<Route path="mappings" element={<ScopedToSource component={Mappings} onNeedsReprocess={markNeedsReprocess} />} />
<Route path="records" element={<ScopedToSource component={Records} />} />
<Route path="pivot" element={<ScopedToSource component={Pivot} />} />
</Route>
<Route path="/import" element={<ImportHub sources={sources} />} />
<Route path="/bridge" element={<Bridge sources={sources} />} />
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} />} />
<Route path="/stacks/:name/pivot" element={<StackPivot />} />
<Route path="/remap" element={<Remap />} />
<Route path="/log" element={<Log />} />
</Routes>
</Suspense>
</div>
</div>
</div>
<BottomNav />
</BrowserRouter>
)
}