Web: AppLayout sidebar component (step 2/5)

Adds the shell for authenticated pages: a fixed-width sidebar with
brand, section nav (Planning/Recettes/Liste de courses/Foyer & profil),
and the signed-in user's name + logout at the bottom, plus a main
content area rendering the matched child route via react-router's
<Outlet />. Follows the "Mise en Place" theme tokens; collapses to a
top bar under 640px (this app is meant to be embedded via Capacitor
later, see the root README).

Not wired into App.tsx yet (next commit) — self-contained and builds/
typechecks on its own.
This commit is contained in:
Nicolas 2026-08-16 21:03:14 +02:00
parent dfeb6fd1ac
commit f4ab482b52
3 changed files with 219 additions and 0 deletions

View file

@ -0,0 +1,134 @@
// =============================================================================
// Styles for AppLayout the shell (sidebar + content area) wrapping every
// authenticated page. Colocated next to AppLayout.tsx since nothing else
// uses these classes.
// =============================================================================
// No `@use` of the theme partial needed here see HomePage.scss's identical
// note: every token below is a CSS custom property, available at runtime.
.app-layout {
min-height: 100vh;
display: flex;
background: var(--color-background);
}
// Fixed-width nav rail. Its own surface (not the page background), same
// elevation language as a card, so it reads as a distinct, permanent piece
// of chrome rather than part of the scrolling content.
.app-sidebar {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 15rem;
padding: var(--space-lg) var(--space-md);
background: var(--color-surface);
border-right: 1px solid var(--color-border);
&__brand {
padding: 0 var(--space-sm) var(--space-xl);
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-lg);
color: var(--color-primary);
}
&__nav {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-xs);
a {
padding: var(--space-sm);
border-radius: var(--radius-base);
color: var(--color-text);
font-weight: 600;
font-size: var(--font-size-sm);
text-decoration: none;
&:hover {
background: var(--color-surface-alt);
}
// Applied by NavLink itself (see the `className` prop in
// AppLayout.tsx), not a router-provided class plain `.active`.
&.active {
background: var(--color-primary);
color: #fff;
}
}
}
&__footer {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
&__user {
padding: 0 var(--space-sm);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
&__footer button {
padding: 0.5rem var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
border-radius: var(--radius-base);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
&:hover {
background: var(--color-surface-alt);
}
}
}
.app-content {
flex: 1;
// Content can scroll independently of the sidebar (e.g. a long planning
// table) without the fixed-width rail ever needing to shrink.
min-width: 0;
padding: var(--space-xl);
}
// Below this width the fixed-width rail would eat too much of the
// viewport (relevant early given this app is meant to be embedded via
// Capacitor later, see the root README) collapse it into a horizontal
// top bar instead of a side rail.
@media (max-width: 640px) {
.app-layout {
flex-direction: column;
}
.app-sidebar {
flex-direction: row;
align-items: center;
width: 100%;
padding: var(--space-sm) var(--space-md);
border-right: none;
border-bottom: 1px solid var(--color-border);
&__brand {
padding: 0 var(--space-sm) 0 0;
}
&__nav {
flex-direction: row;
overflow-x: auto;
}
&__footer {
flex-direction: row;
padding-top: 0;
border-top: none;
}
}
}

View file

@ -0,0 +1,75 @@
import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../features/auth/AuthContext";
import "./AppLayout.scss";
/**
* One entry in the sidebar nav. `key` maps to `layout.nav.<key>` in
* `locales/fr/translation.json` adding a section is one array entry plus
* one locale key, no other file to touch.
*/
const NAV_ITEMS = [
{ to: "/", key: "planning" },
{ to: "/recettes", key: "recipes" },
{ to: "/liste-de-courses", key: "shoppingList" },
{ to: "/foyer", key: "household" },
] as const;
/**
* Shell for every authenticated page: a sidebar (brand, section nav, and
* the signed-in user + logout at the bottom) plus a main content area
* rendering the matched child route via `<Outlet />`.
*
* Mounted once as the parent element of the whole authenticated route
* group, itself wrapped in {@link RequireAuth} (see `App.tsx`) `user` is
* therefore guaranteed non-null by the time this renders.
*/
export function AppLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation();
/** Ends the session and returns to the login page. */
async function handleLogout() {
await logout();
navigate("/login");
}
return (
<div className="app-layout">
<aside className="app-sidebar">
<div className="app-sidebar__brand">batchCooking</div>
<nav className="app-sidebar__nav">
{NAV_ITEMS.map(({ to, key }) => (
<NavLink
key={to}
to={to}
// Only the home ("/") entry needs exact matching — every
// other path is a leaf with nothing nested under it (yet),
// so NavLink's default prefix matching already behaves the
// same way `end` would.
end={to === "/"}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
{t(`layout.nav.${key}`)}
</NavLink>
))}
</nav>
<div className="app-sidebar__footer">
<span className="app-sidebar__user">
{t("layout.greeting", { firstName: user?.firstName })}
</span>
<button type="button" onClick={handleLogout}>
{t("layout.logout")}
</button>
</div>
</aside>
<main className="app-content">
<Outlet />
</main>
</div>
);
}

View file

@ -29,6 +29,16 @@
"loginLink": "Se connecter" "loginLink": "Se connecter"
} }
}, },
"layout": {
"nav": {
"planning": "Planning",
"recipes": "Recettes",
"shoppingList": "Liste de courses",
"household": "Foyer & profil"
},
"greeting": "Bonjour {{firstName}} 👋",
"logout": "Se déconnecter"
},
"home": { "home": {
"greeting": "Bonjour {{firstName}} {{lastName}} 👋", "greeting": "Bonjour {{firstName}} {{lastName}} 👋",
"logout": "Se déconnecter" "logout": "Se déconnecter"