M5-T01: refactor AppLayout from top bar to sidebar navigation

This commit is contained in:
2026-06-22 12:26:52 +02:00
parent e49a6ad963
commit da7d0f9d62
4 changed files with 332 additions and 123 deletions
+38 -122
View File
@@ -6,24 +6,19 @@
*
* Route tree:
* /login → LoginPage (public)
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
* / → ProtectedRoute → AppLayout → HomePage (T09)
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
* /change-password → ProtectedRoute → ChangePasswordPage (forced password change gate)
* / → ProtectedRoute → AppLayout → HomePage
* /config → ProtectedRoute → AppLayout → ConfigPage
* /records → ProtectedRoute → AppLayout → RecordsPage
*
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
* AppLayout renders a sidebar (AppSidebar) on the left; page content via <Outlet/> on the right.
* /login and /change-password have no sidebar layout.
*/
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
import {
MantineProvider,
Group,
ActionIcon,
Tooltip,
useMantineColorScheme,
useComputedColorScheme,
} from '@mantine/core'
import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { MantineProvider, AppShell, Burger } from '@mantine/core'
// Mantine requires its CSS to be imported once.
import '@mantine/core/styles.css'
@@ -35,8 +30,7 @@ import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage'
import { RecordsPage } from './pages/RecordsPage'
import { ChangePasswordPage } from './pages/ChangePasswordPage'
import apiClient from './api/client'
import { useQueryClient } from '@tanstack/react-query'
import { AppSidebar } from './components/AppSidebar'
// ---------------------------------------------------------------------------
// TanStack Query client (singleton, created outside render to avoid re-creation)
@@ -57,120 +51,42 @@ const queryClient = new QueryClient({
},
})
// ---------------------------------------------------------------------------
// Logout button component (needs navigate + queryClient hooks, so it's a component)
// ---------------------------------------------------------------------------
function LogoutButton() {
const navigate = useNavigate()
const qc = useQueryClient()
async function handleLogout() {
try {
await apiClient.POST('/api/auth/logout')
} catch {
// Ignore errors on logout — we clear the session regardless.
}
// Invalidate session so SessionProvider becomes unauthenticated.
await qc.invalidateQueries({ queryKey: ['session'] })
navigate('/login', { replace: true })
}
return (
<Tooltip label="Log out">
<ActionIcon
variant="default"
size="lg"
onClick={handleLogout}
aria-label="Log out"
data-testid="logout-button"
>
<LogOut size={18} />
</ActionIcon>
</Tooltip>
)
}
// ---------------------------------------------------------------------------
// Dark-mode toggle (sits next to the gear / settings icon)
// ---------------------------------------------------------------------------
function ColorSchemeToggle() {
const { setColorScheme } = useMantineColorScheme()
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
const isDark = computed === 'dark'
return (
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
aria-label="Toggle color scheme"
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
data-testid="color-scheme-toggle"
>
{isDark ? <Sun size={18} /> : <Moon size={18} />}
</ActionIcon>
</Tooltip>
)
}
// ---------------------------------------------------------------------------
// App shell layout (used by all protected pages)
// ---------------------------------------------------------------------------
function AppLayout() {
// Mobile navbar open/close state — controlled here so the Burger and
// AppShell share the same state.
const [navbarOpen, setNavbarOpen] = useState(false)
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Top nav */}
<nav
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.5rem 1rem',
borderBottom: '1px solid var(--mantine-color-default-border)',
}}
>
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
Home Automation
</Link>
<AppShell
navbar={{
width: 220,
breakpoint: 'sm',
collapsed: { mobile: !navbarOpen },
}}
header={{ height: { base: 48, sm: 0 } }}
padding="md"
>
{/* Burger shown on mobile only (hidden on sm+) */}
<AppShell.Header hiddenFrom="sm" p="xs">
<Burger
opened={navbarOpen}
onClick={() => setNavbarOpen((o) => !o)}
size="sm"
aria-label="Toggle navigation"
data-testid="nav-burger"
/>
</AppShell.Header>
<Group gap="xs">
{/* Records nav link */}
<Tooltip label="Records">
<ActionIcon
component={Link}
to="/records"
variant="default"
size="lg"
aria-label="Records"
>
<List size={18} />
</ActionIcon>
</Tooltip>
{/* Dark-mode toggle — directly beside the settings gear */}
<ColorSchemeToggle />
{/* Settings — links to config page (§5#10) */}
<Tooltip label="Settings">
<ActionIcon
component={Link}
to="/config"
variant="default"
size="lg"
aria-label="Settings"
>
<Settings size={18} />
</ActionIcon>
</Tooltip>
<LogoutButton />
</Group>
</nav>
<AppSidebar onNavClick={() => setNavbarOpen(false)} />
{/* Page content */}
<main style={{ flex: 1 }}>
<AppShell.Main>
<Outlet />
</main>
</div>
</AppShell.Main>
</AppShell>
)
}
@@ -198,7 +114,7 @@ export default function App() {
}
/>
{/* Protected routes — all nested under AppLayout */}
{/* Protected routes — all nested under AppLayout (sidebar) */}
<Route
element={
<ProtectedRoute>