139 lines
4.7 KiB
TypeScript
139 lines
4.7 KiB
TypeScript
/**
|
|
* App — top-level provider stack and route tree.
|
|
*
|
|
* Provider order (outermost first):
|
|
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
|
|
*
|
|
* Route tree:
|
|
* /login → LoginPage (public)
|
|
* /change-password → ProtectedRoute → ChangePasswordPage (forced password change gate)
|
|
* / → ProtectedRoute → AppLayout → HomePage
|
|
* /config → ProtectedRoute → AppLayout → ConfigPage
|
|
* /records → ProtectedRoute → AppLayout → RecordsPage
|
|
* /energy → ProtectedRoute → AppLayout → EnergyPage
|
|
*
|
|
* 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, 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'
|
|
|
|
import { SessionProvider } from './auth/SessionProvider'
|
|
import { ProtectedRoute } from './auth/ProtectedRoute'
|
|
import { LoginPage } from './pages/LoginPage'
|
|
import { HomePage } from './pages/HomePage'
|
|
import { ConfigPage } from './pages/ConfigPage'
|
|
import { RecordsPage } from './pages/RecordsPage'
|
|
import { EnergyPage } from './pages/EnergyPage'
|
|
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
|
import { AppSidebar } from './components/AppSidebar'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TanStack Query client (singleton, created outside render to avoid re-creation)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
// Don't retry on 4xx — we handle 401 in the middleware
|
|
retry: (failureCount, error) => {
|
|
if (error instanceof Error && 'status' in error) {
|
|
const status = (error as unknown as { status: number }).status
|
|
if (status >= 400 && status < 500) return false
|
|
}
|
|
return failureCount < 2
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<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>
|
|
|
|
<AppSidebar onNavClick={() => setNavbarOpen(false)} />
|
|
|
|
<AppShell.Main>
|
|
<Outlet />
|
|
</AppShell.Main>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Root app
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function App() {
|
|
return (
|
|
<MantineProvider defaultColorScheme="auto">
|
|
<QueryClientProvider client={queryClient}>
|
|
<BrowserRouter>
|
|
<SessionProvider>
|
|
<Routes>
|
|
{/* Public routes */}
|
|
<Route path="/login" element={<LoginPage />} />
|
|
|
|
{/* Forced password change — protected (must be logged in) but outside AppLayout */}
|
|
<Route
|
|
path="/change-password"
|
|
element={
|
|
<ProtectedRoute>
|
|
<ChangePasswordPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
|
|
{/* Protected routes — all nested under AppLayout (sidebar) */}
|
|
<Route
|
|
element={
|
|
<ProtectedRoute>
|
|
<AppLayout />
|
|
</ProtectedRoute>
|
|
}
|
|
>
|
|
<Route index element={<HomePage />} />
|
|
<Route path="/config" element={<ConfigPage />} />
|
|
<Route path="/records" element={<RecordsPage />} />
|
|
<Route path="/energy" element={<EnergyPage />} />
|
|
</Route>
|
|
</Routes>
|
|
</SessionProvider>
|
|
</BrowserRouter>
|
|
</QueryClientProvider>
|
|
</MantineProvider>
|
|
)
|
|
}
|