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"
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"nav": {
|
||||
"planning": "Planning",
|
||||
"recipes": "Recettes",
|
||||
"shoppingList": "Liste de courses",
|
||||
"household": "Foyer & profil"
|
||||
},
|
||||
"greeting": "Bonjour {{firstName}} 👋",
|
||||
"logout": "Se déconnecter"
|
||||
},
|
||||
"home": {
|
||||
"greeting": "Bonjour {{firstName}} {{lastName}} 👋",
|
||||
"logout": "Se déconnecter"
|
||||
|
|
|
|||
Loading…
Reference in a new issue