Add collapsable sidebar with icons; move source picker to status bar

- New Sidebar component (modelled on pf_app): collapses 200px→48px via
  hamburger toggle, persists state to df_sidebar in localStorage; each
  nav item has an SVG icon with label that fades out when collapsed;
  user avatar + sign-out at bottom
- New StatusBar component: source picker + dark-mode toggle across the
  top of the content area
- Fix Pivot theme: setAttribute('theme') moved to after flush() so
  viewer.restore() can no longer reset it back to light

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-05-02 23:14:49 -04:00
parent 738e1919ce
commit 9e0fa4aa7e
4 changed files with 253 additions and 111 deletions

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { api, setCredentials, clearCredentials } from './api'
import useTheme from './theme.jsx'
import StatusBar from './components/StatusBar.jsx'
import Sidebar from './components/Sidebar.jsx'
import Login from './pages/Login'
import Sources from './pages/Sources'
import Import from './pages/Import'
@ -13,25 +14,12 @@ import Pivot from './pages/Pivot'
import Remap from './pages/Remap'
import Stacks from './pages/Stacks'
const NAV = [
{ to: '/sources', label: 'Sources' },
{ to: '/import', label: 'Import' },
{ to: '/rules', label: 'Rules' },
{ to: '/mappings', label: 'Mappings' },
{ to: '/remap', label: 'Remap' },
{ to: '/records', label: 'Records' },
{ to: '/pivot', label: 'Pivot' },
{ to: '/stacks', label: 'Stacks' },
{ to: '/log', label: 'Log' },
]
export default function App() {
const { dark, setDark } = useTheme()
const [authed, setAuthed] = useState(false)
const [loginUser, setLoginUser] = useState('')
const [sources, setSources] = useState([])
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
const [sidebarOpen, setSidebarOpen] = useState(false)
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())
@ -121,108 +109,26 @@ export default function App() {
if (source) localStorage.setItem('selectedSource', source)
}, [source])
useEffect(() => {
localStorage.setItem('df_sidebar', sidebarExpanded ? 'expanded' : 'collapsed')
}, [sidebarExpanded])
if (!authed) return <Login onLogin={handleLogin} />
const sidebar = (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-200">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
<div className="flex items-center gap-1 ml-auto">
<button
onClick={() => setDark(d => !d)}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600"
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05"/>
<line x1="16.95" y1="16.95" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95"/>
<line x1="16.95" y1="7.05" x2="19.07" y2="4.93"/>
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
)}
</button>
<button onClick={() => setSidebarOpen(false)} className="md:hidden text-gray-400 hover:text-gray-600 leading-none" title="Close"></button>
</div>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-gray-400">{loginUser}</span>
<button onClick={handleLogout} className="text-xs text-gray-400 hover:text-red-500" title="Sign out">Sign out</button>
</div>
</div>
{/* Source selector */}
<div className="px-3 py-3 border-b border-gray-200">
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-gray-500">Source</label>
<NavLink to="/sources?new=1" className="text-xs text-blue-400 hover:text-blue-600 leading-none" title="New source" onClick={() => setSidebarOpen(false)}>+</NavLink>
</div>
<select
className="w-full text-sm border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:border-blue-400"
value={source}
onChange={e => setSource(e.target.value)}
>
{sources.length === 0 && <option value=""></option>}
{sources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</select>
</div>
{/* Nav */}
<nav className="flex-1 py-2">
{NAV.map(({ to, label }) => (
<NavLink
key={to}
to={to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
`block px-4 py-2 text-sm ${isActive
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-600 hover:bg-gray-50'}`
}
>
{label}
</NavLink>
))}
</nav>
</div>
)
return (
<BrowserRouter>
<div className="flex h-screen">
{/* Mobile overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-20 bg-black/30 md:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* Sidebar — fixed on mobile, static on desktop */}
<div className={`
fixed inset-y-0 left-0 z-30 w-44 bg-white border-r border-gray-200 transform transition-transform duration-200
md:static md:translate-x-0 md:z-auto md:transition-none
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
{sidebar}
</div>
<Sidebar
expanded={sidebarExpanded}
setExpanded={setSidebarExpanded}
loginUser={loginUser}
onLogout={handleLogout}
/>
{/* Main */}
<div className="flex-1 overflow-auto flex flex-col min-w-0">
{/* Mobile top bar */}
<div className="md:hidden flex items-center px-3 py-2 bg-white border-b border-gray-200">
<button onClick={() => setSidebarOpen(true)} className="text-gray-500 hover:text-gray-700 mr-3 text-lg leading-none"></button>
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
</div>
<div className="flex-1 overflow-hidden flex flex-col min-w-0">
<StatusBar sources={sources} source={source} setSource={setSource} />
{(staleSources.size > 0 || staleStacks.size > 0) && (
<div className="bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-xs text-amber-800 flex flex-wrap items-center gap-x-3 gap-y-1">

View File

@ -0,0 +1,183 @@
import { NavLink } from 'react-router-dom'
const NAV = [
{
to: '/sources',
label: 'Sources',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
</svg>
),
},
{
to: '/import',
label: 'Import',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" y1="3" x2="10" y2="14"/>
<polyline points="6,10 10,14 14,10"/>
<line x1="3" y1="18" x2="17" y2="18"/>
</svg>
),
},
{
to: '/rules',
label: 'Rules',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6,7 2,10 6,13"/>
<polyline points="14,7 18,10 14,13"/>
<line x1="12" y1="4" x2="8" y2="16"/>
</svg>
),
},
{
to: '/mappings',
label: 'Mappings',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<line x1="2" y1="7" x2="12" y2="7"/>
<polyline points="9,4 12,7 9,10"/>
<line x1="8" y1="13" x2="18" y2="13"/>
<polyline points="11,10 14,13 11,16"/>
</svg>
),
},
{
to: '/remap',
label: 'Remap',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,8 2,4 6,4"/>
<path d="M2 4a8 8 0 0 1 14 2"/>
<polyline points="18,12 18,16 14,16"/>
<path d="M18 16a8 8 0 0 1-14-2"/>
</svg>
),
},
{
to: '/records',
label: 'Records',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="16" height="14" rx="1.5"/>
<line x1="2" y1="8" x2="18" y2="8"/>
<line x1="7" y1="8" x2="7" y2="17"/>
</svg>
),
},
{
to: '/pivot',
label: 'Pivot',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="7" height="7" rx="1"/>
<rect x="11" y="2" width="7" height="7" rx="1"/>
<rect x="2" y="11" width="7" height="7" rx="1"/>
<rect x="11" y="11" width="7" height="7" rx="1"/>
</svg>
),
},
{
to: '/stacks',
label: 'Stacks',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<polygon points="10,2 18,6 10,10 2,6"/>
<polyline points="2,10 10,14 18,10"/>
<polyline points="2,14 10,18 18,14"/>
</svg>
),
},
{
to: '/log',
label: 'Log',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="10" cy="10" r="8"/>
<polyline points="10,5 10,10 14,12"/>
</svg>
),
},
]
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) {
return (
<div
className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150"
style={{ width: expanded ? 200 : 48 }}
>
{/* Header */}
<div className="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0">
<button
onClick={() => setExpanded(e => !e)}
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0"
title="Toggle sidebar"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="3" width="14" height="1.5" rx="0.75" fill="currentColor"/>
<rect x="1" y="7.25" width="14" height="1.5" rx="0.75" fill="currentColor"/>
<rect x="1" y="11.5" width="14" height="1.5" rx="0.75" fill="currentColor"/>
</svg>
</button>
<span
className="text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }}
>
Dataflow
</span>
</div>
{/* Nav */}
<nav className="flex flex-col gap-0.5 p-2 flex-1">
{NAV.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
title={!expanded ? label : undefined}
className={({ isActive }) =>
`flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${
isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
}`
}
>
<span className="shrink-0">{icon}</span>
<span
className="text-sm whitespace-nowrap transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
>
{label}
</span>
</NavLink>
))}
</nav>
{/* User / logout */}
<div className="border-t border-gray-100 px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden">
<div
className="w-6 h-6 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center shrink-0 text-xs font-medium"
title={!expanded ? loginUser : undefined}
>
{loginUser ? loginUser[0].toUpperCase() : '?'}
</div>
<div
className="flex-1 flex items-center justify-between min-w-0 transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
>
<span className="text-xs text-gray-400 truncate">{loginUser}</span>
<button
onClick={onLogout}
className="text-xs text-gray-400 hover:text-red-500 ml-2 shrink-0"
>
Sign out
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,53 @@
import { NavLink } from 'react-router-dom'
import useTheme from '../theme.jsx'
export default function StatusBar({ sources = [], source, setSource }) {
const { dark, setDark } = useTheme()
return (
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs">
<span className="text-gray-400">Source</span>
<select
value={source || ''}
onChange={e => setSource(e.target.value)}
disabled={sources.length === 0}
className="border border-gray-200 rounded px-2 py-0.5 bg-white focus:outline-none focus:border-blue-400"
>
{sources.length === 0
? <option value=""> no sources </option>
: sources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</select>
<NavLink
to="/sources?new=1"
className="text-blue-400 hover:text-blue-600 leading-none"
title="New source"
>+</NavLink>
<div className="ml-auto">
<button
onClick={() => setDark(d => !d)}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500"
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05"/>
<line x1="16.95" y1="16.95" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95"/>
<line x1="16.95" y1="7.05" x2="19.07" y2="4.93"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
)}
</button>
</div>
</div>
)
}

View File

@ -253,7 +253,6 @@ export default function Pivot({ source }) {
viewer.addEventListener('perspective-click', perspClickHandlerRef.current)
await viewer.load(worker)
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
const plugin = await viewer.getPlugin()
const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView))
@ -267,6 +266,7 @@ export default function Pivot({ source }) {
await plugin.restore(DEFAULT_PLUGIN_CONFIG)
}
await viewer.flush()
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
setStatus('ready')
} catch (err) {