Files
home-automation/frontend/src/App.tsx
T

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>
)
}