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:
parent
738e1919ce
commit
9e0fa4aa7e
126
ui/src/App.jsx
126
ui/src/App.jsx
@ -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">
|
||||
|
||||
183
ui/src/components/Sidebar.jsx
Normal file
183
ui/src/components/Sidebar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
ui/src/components/StatusBar.jsx
Normal file
53
ui/src/components/StatusBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user