Collapsible sidebar for mobile, fix logout button visibility

On mobile: hamburger button in top bar opens sidebar as a slide-over
with a backdrop overlay. Nav links and source selector close it on tap.
On desktop: sidebar is static as before.

Logout button now sits alongside the close button in the sidebar header
with a gap so both are always visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-05 17:48:07 -04:00
parent 2c573a5eeb
commit 71c4654361

View File

@ -20,6 +20,7 @@ export default function App() {
const [authed, setAuthed] = useState(false) const [authed, setAuthed] = useState(false)
const [sources, setSources] = useState([]) const [sources, setSources] = useState([])
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '') const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
const [sidebarOpen, setSidebarOpen] = useState(false)
async function handleLogin(user, pass) { async function handleLogin(user, pass) {
setCredentials(user, pass) setCredentials(user, pass)
@ -53,64 +54,89 @@ export default function App() {
if (!authed) return <Login onLogin={handleLogin} /> if (!authed) return <Login onLogin={handleLogin} />
const sidebar = (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-4 border-b border-gray-200 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-2">
<button onClick={handleLogout} className="text-xs text-gray-400 hover:text-gray-600 leading-none" title="Sign out"></button>
<button onClick={() => setSidebarOpen(false)} className="md:hidden text-gray-400 hover:text-gray-600 leading-none" title="Close"></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" 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 ( return (
<BrowserRouter> <BrowserRouter>
<div className="flex h-screen bg-gray-50"> <div className="flex h-screen bg-gray-50">
{/* Sidebar */}
<div className="w-44 bg-white border-r border-gray-200 flex flex-col">
<div className="px-4 py-4 border-b border-gray-200 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
<button onClick={handleLogout} className="text-xs text-gray-400 hover:text-gray-600" title="Sign out"></button>
</div>
{/* Source selector */} {/* Mobile overlay */}
<div className="px-3 py-3 border-b border-gray-200"> {sidebarOpen && (
<div className="flex items-center justify-between mb-1"> <div className="fixed inset-0 z-20 bg-black/30 md:hidden" onClick={() => setSidebarOpen(false)} />
<label className="text-xs text-gray-500">Source</label> )}
<NavLink to="/sources"
className="text-xs text-blue-400 hover:text-blue-600 leading-none"
title="New source"
onClick={() => {/* nav handled by link */}}
>+</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 */} {/* Sidebar — fixed on mobile, static on desktop */}
<nav className="flex-1 py-2"> <div className={`
{NAV.map(({ to, label }) => ( fixed inset-y-0 left-0 z-30 w-44 bg-white border-r border-gray-200 transform transition-transform duration-200
<NavLink md:static md:translate-x-0 md:z-auto md:transition-none
key={to} ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
to={to} `}>
className={({ isActive }) => {sidebar}
`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> </div>
{/* Main */} {/* Main */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto flex flex-col min-w-0">
<Routes> {/* Mobile top bar */}
<Route path="/" element={<Navigate to="/sources" replace />} /> <div className="md:hidden flex items-center px-3 py-2 bg-white border-b border-gray-200">
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} /> <button onClick={() => setSidebarOpen(true)} className="text-gray-500 hover:text-gray-700 mr-3 text-lg leading-none"></button>
<Route path="/import" element={<Import source={source} />} /> <span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
<Route path="/rules" element={<Rules source={source} />} /> </div>
<Route path="/mappings" element={<Mappings source={source} />} />
<Route path="/records" element={<Records source={source} />} /> <div className="flex-1 overflow-auto">
</Routes> <Routes>
<Route path="/" element={<Navigate to="/sources" replace />} />
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} />
<Route path="/import" element={<Import source={source} />} />
<Route path="/rules" element={<Rules source={source} />} />
<Route path="/mappings" element={<Mappings source={source} />} />
<Route path="/records" element={<Records source={source} />} />
</Routes>
</div>
</div> </div>
</div> </div>
</BrowserRouter> </BrowserRouter>