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
227 lines
9.4 KiB
JavaScript
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>
|
|
)
|
|
}
|