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:
parent
dfeb6fd1ac
commit
f4ab482b52
3 changed files with 219 additions and 0 deletions
134
apps/web/src/layouts/AppLayout.scss
Normal file
134
apps/web/src/layouts/AppLayout.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/web/src/layouts/AppLayout.tsx
Normal file
75
apps/web/src/layouts/AppLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue