M5-T01: refactor AppLayout from top bar to sidebar navigation
This commit is contained in:
@@ -258,7 +258,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。
|
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。
|
||||||
|
|
||||||
### M5-T01 — 侧边栏布局重构 `[structural]`
|
### M5-T01 — 侧边栏布局重构 `[structural]`
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。
|
- **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。
|
||||||
- **Files**: `modify frontend/src/App.tsx`;`create frontend/src/components/AppSidebar.tsx`、`frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言)
|
- **Files**: `modify frontend/src/App.tsx`;`create frontend/src/components/AppSidebar.tsx`、`frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言)
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
|
|||||||
+36
-120
@@ -6,24 +6,19 @@
|
|||||||
*
|
*
|
||||||
* Route tree:
|
* Route tree:
|
||||||
* /login → LoginPage (public)
|
* /login → LoginPage (public)
|
||||||
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
|
* /change-password → ProtectedRoute → ChangePasswordPage (forced password change gate)
|
||||||
* / → ProtectedRoute → AppLayout → HomePage (T09)
|
* / → ProtectedRoute → AppLayout → HomePage
|
||||||
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
|
* /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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
|
||||||
import {
|
import { MantineProvider, AppShell, Burger } from '@mantine/core'
|
||||||
MantineProvider,
|
|
||||||
Group,
|
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
useMantineColorScheme,
|
|
||||||
useComputedColorScheme,
|
|
||||||
} from '@mantine/core'
|
|
||||||
import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
|
|
||||||
|
|
||||||
// Mantine requires its CSS to be imported once.
|
// Mantine requires its CSS to be imported once.
|
||||||
import '@mantine/core/styles.css'
|
import '@mantine/core/styles.css'
|
||||||
@@ -35,8 +30,7 @@ import { HomePage } from './pages/HomePage'
|
|||||||
import { ConfigPage } from './pages/ConfigPage'
|
import { ConfigPage } from './pages/ConfigPage'
|
||||||
import { RecordsPage } from './pages/RecordsPage'
|
import { RecordsPage } from './pages/RecordsPage'
|
||||||
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
||||||
import apiClient from './api/client'
|
import { AppSidebar } from './components/AppSidebar'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TanStack Query client (singleton, created outside render to avoid re-creation)
|
// 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)
|
// App shell layout (used by all protected pages)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
|
// Mobile navbar open/close state — controlled here so the Burger and
|
||||||
|
// AppShell share the same state.
|
||||||
|
const [navbarOpen, setNavbarOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<AppShell
|
||||||
{/* Top nav */}
|
navbar={{
|
||||||
<nav
|
width: 220,
|
||||||
style={{
|
breakpoint: 'sm',
|
||||||
display: 'flex',
|
collapsed: { mobile: !navbarOpen },
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
|
||||||
}}
|
}}
|
||||||
|
header={{ height: { base: 48, sm: 0 } }}
|
||||||
|
padding="md"
|
||||||
>
|
>
|
||||||
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
|
{/* Burger shown on mobile only (hidden on sm+) */}
|
||||||
Home Automation
|
<AppShell.Header hiddenFrom="sm" p="xs">
|
||||||
</Link>
|
<Burger
|
||||||
|
opened={navbarOpen}
|
||||||
|
onClick={() => setNavbarOpen((o) => !o)}
|
||||||
|
size="sm"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
data-testid="nav-burger"
|
||||||
|
/>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
<Group gap="xs">
|
<AppSidebar onNavClick={() => setNavbarOpen(false)} />
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* Page content */}
|
<AppShell.Main>
|
||||||
<main style={{ flex: 1 }}>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</AppShell.Main>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +114,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Protected routes — all nested under AppLayout */}
|
{/* Protected routes — all nested under AppLayout (sidebar) */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Tests for AppSidebar (M5-T01).
|
||||||
|
*
|
||||||
|
* Strategy: render AppSidebar inside a minimal AppShell (required by
|
||||||
|
* AppShell.Navbar) + MemoryRouter so useLocation() works.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. All three nav items render (Home, Records, Config).
|
||||||
|
* 2. Home nav item is active when pathname is '/'.
|
||||||
|
* 3. Records nav item is active when pathname is '/records'.
|
||||||
|
* 4. Config nav item is active when pathname is '/config'.
|
||||||
|
* 5. Home is NOT active when on '/records'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { MantineProvider, AppShell } from '@mantine/core'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { AppSidebar } from './AppSidebar'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient (LogoutButton calls POST /api/auth/logout)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
POST: vi.fn(),
|
||||||
|
GET: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: render AppSidebar inside the required AppShell + router providers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderSidebar(initialPath = '/') {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<AppShell
|
||||||
|
navbar={{ width: 220, breakpoint: 'sm', collapsed: { mobile: false } }}
|
||||||
|
header={{ height: { base: 48, sm: 0 } }}
|
||||||
|
>
|
||||||
|
<AppSidebar />
|
||||||
|
</AppShell>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MantineProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('AppSidebar', () => {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 1. All nav items render
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders Home, Records, and Config nav items', () => {
|
||||||
|
renderSidebar('/')
|
||||||
|
expect(screen.getByTestId('nav-home')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('nav-records')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('nav-config')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2. Home active on '/'
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('marks Home nav item as active when on "/"', () => {
|
||||||
|
renderSidebar('/')
|
||||||
|
// Mantine NavLink sets data-active="true" on the active item
|
||||||
|
const homeLink = screen.getByTestId('nav-home')
|
||||||
|
expect(homeLink).toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 3. Records active on '/records'
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('marks Records nav item as active when on "/records"', () => {
|
||||||
|
renderSidebar('/records')
|
||||||
|
const recordsLink = screen.getByTestId('nav-records')
|
||||||
|
expect(recordsLink).toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 4. Config active on '/config'
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('marks Config nav item as active when on "/config"', () => {
|
||||||
|
renderSidebar('/config')
|
||||||
|
const configLink = screen.getByTestId('nav-config')
|
||||||
|
expect(configLink).toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 5. Home NOT active on '/records' (exact match guard)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('does NOT mark Home as active when on "/records"', () => {
|
||||||
|
renderSidebar('/records')
|
||||||
|
const homeLink = screen.getByTestId('nav-home')
|
||||||
|
expect(homeLink).not.toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* AppSidebar — vertical sidebar navigation for all protected pages.
|
||||||
|
*
|
||||||
|
* Nav items: Home / Records / Config
|
||||||
|
* Utilities: ColorSchemeToggle + LogoutButton (at bottom)
|
||||||
|
*
|
||||||
|
* Current route is highlighted via useLocation().
|
||||||
|
* Mobile: burger button toggles the navbar open/closed (handled by AppShell context).
|
||||||
|
*
|
||||||
|
* NOTE: Energy nav item is intentionally absent — added in M5-T06 once /energy exists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NavLink, Stack, Divider, Tooltip, ActionIcon, useMantineColorScheme, useComputedColorScheme, AppShell, Text, Group } from '@mantine/core'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { Home, List, Settings, Sun, Moon, LogOut } from 'react-feather'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Individual nav entries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface NavEntry {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
testId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ENTRIES: NavEntry[] = [
|
||||||
|
{
|
||||||
|
to: '/',
|
||||||
|
label: 'Home',
|
||||||
|
icon: <Home size={18} />,
|
||||||
|
testId: 'nav-home',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/records',
|
||||||
|
label: 'Records',
|
||||||
|
icon: <List size={18} />,
|
||||||
|
testId: 'nav-records',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/config',
|
||||||
|
label: 'Config',
|
||||||
|
icon: <Settings size={18} />,
|
||||||
|
testId: 'nav-config',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Colour-scheme toggle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ColorSchemeToggle() {
|
||||||
|
const { setColorScheme } = useMantineColorScheme()
|
||||||
|
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||||
|
const isDark = computed === 'dark'
|
||||||
|
return (
|
||||||
|
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'} position="right">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logout button
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LogoutButton() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await apiClient.POST('/api/auth/logout')
|
||||||
|
} catch {
|
||||||
|
// Ignore errors — clear session regardless.
|
||||||
|
}
|
||||||
|
await qc.invalidateQueries({ queryKey: ['session'] })
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label="Log out" position="right">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleLogout}
|
||||||
|
aria-label="Log out"
|
||||||
|
data-testid="logout-button"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AppSidebarProps {
|
||||||
|
/** Called when a nav link is clicked on mobile (to close the navbar). */
|
||||||
|
onNavClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar({ onNavClick }: AppSidebarProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell.Navbar p="xs">
|
||||||
|
{/* App title */}
|
||||||
|
<AppShell.Section>
|
||||||
|
<Group px="xs" py="sm">
|
||||||
|
<Text fw={700} size="sm" component={Link} to="/" style={{ textDecoration: 'none' }}>
|
||||||
|
Home Automation
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
</AppShell.Section>
|
||||||
|
|
||||||
|
{/* Navigation links */}
|
||||||
|
<AppShell.Section grow mt="sm">
|
||||||
|
<Stack gap={4}>
|
||||||
|
{NAV_ENTRIES.map(({ to, label, icon, testId }) => {
|
||||||
|
// For the home route "/" we need an exact match; others prefix-match is fine.
|
||||||
|
const isActive =
|
||||||
|
to === '/'
|
||||||
|
? location.pathname === '/'
|
||||||
|
: location.pathname.startsWith(to)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
component={Link}
|
||||||
|
to={to}
|
||||||
|
label={label}
|
||||||
|
leftSection={icon}
|
||||||
|
active={isActive}
|
||||||
|
data-testid={testId}
|
||||||
|
onClick={onNavClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</AppShell.Section>
|
||||||
|
|
||||||
|
{/* Bottom utilities: theme toggle + logout */}
|
||||||
|
<AppShell.Section>
|
||||||
|
<Divider mb="xs" />
|
||||||
|
<Group gap="xs" px="xs" pb="xs">
|
||||||
|
<ColorSchemeToggle />
|
||||||
|
<LogoutButton />
|
||||||
|
</Group>
|
||||||
|
</AppShell.Section>
|
||||||
|
</AppShell.Navbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user