batchCooking/apps/web/src/layouts/AppLayout.tsx
Nicolas 21cd8f014d
All checks were successful
CI / lint (push) Successful in 3m1s
CI / build (push) Successful in 4m32s
CI / intent-service-test (push) Successful in 6m16s
CI / e2e (push) Successful in 11m28s
CI / test (push) Successful in 22m7s
CI / build-android (push) Has been skipped
fix(auth): la deconnexion retombe sur / plutot que /login
RequireAuth.tsx affiche deja la LandingPage publique pour un visiteur
deconnecte exactement sur / (issue #41) — seul handleLogout naviguait
encore explicitement vers /login. Change juste la destination de
navigate() ; aucun autre changement necessaire.

Scenario Cypress mis a jour en consequence (le pas existant "the URL
should be the home page" suffit, pas besoin d'un nouveau pas).

Verifie en local, suite complete : pnpm lint, pnpm --filter web build,
pnpm run e2e (139/139, apres avoir nettoye un process vite zombie sur le
port 5173 laisse par une precedente invocation cypress dans cette meme
session — voir dev-environment-gotchas pour le detail).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:59:18 +02:00

252 lines
8.9 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { UpdateBanner } from "../features/app-update/UpdateBanner";
import { useAuth } from "../features/auth/AuthContext";
import "./AppLayout.scss";
import {
AccountIcon,
ChevronLeftIcon,
CreditsIcon,
DietPreferencesIcon,
HouseholdIcon,
PlanningIcon,
RecipesIcon,
SettingsIcon,
ShoppingListIcon,
UserPreferencesIcon,
} from "./nav-icons";
/** `localStorage` key persisting {@link AppLayout}'s collapsed/expanded sidebar state across reloads. */
const SIDEBAR_COLLAPSED_KEY = "batchcooking:sidebarCollapsed";
/**
* One entry in the sidebar's main 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", Icon: PlanningIcon, tourTarget: "nav-planning" },
{ to: "/recettes", key: "recipes", Icon: RecipesIcon, tourTarget: "nav-recipes" },
{
to: "/liste-de-courses",
key: "shoppingList",
Icon: ShoppingListIcon,
tourTarget: "nav-shopping-list",
},
] as const;
/**
* One entry in the "Paramètres" nav, revealed by {@link SettingsMenu}. `key`
* maps to `layout.settings.nav.<key>`.
*/
const SETTINGS_ITEMS = [
{ to: "/parametres/compte", key: "account", Icon: AccountIcon },
{ to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
{ to: "/parametres/credits", key: "credits", Icon: CreditsIcon },
] as const;
/**
* Shell for every authenticated page: a sidebar (brand, section nav, a
* collapsible "Paramètres" nav, and the account menu at the bottom) plus a
* main content area rendering the matched child route via `<Outlet />`.
*
* The sidebar itself can be collapsed to an icon-only rail (see
* `isCollapsed`/`SIDEBAR_COLLAPSED_KEY`) — every nav item keeps its icon
* and gains a `title` tooltip, its label just hides via CSS
* (`.app-sidebar.collapsed .label`, see AppLayout.scss), so collapsing is a
* single class toggle on the `<aside>`, not something each child component
* needs to know about.
*
* 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 { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(
() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
);
function toggleCollapsed() {
setIsCollapsed((collapsed) => {
const next = !collapsed;
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
return next;
});
}
return (
<div className="app-layout">
<aside className={isCollapsed ? "app-sidebar collapsed" : "app-sidebar"}>
<div className="app-sidebar__top">
<div className="app-sidebar__brand">
{/* Collapsed: a short monogram instead of the full wordmark — clearer than an arbitrary icon standing in for the brand itself. */}
<span className="app-sidebar__brand-full">batchCooking</span>
<span className="app-sidebar__brand-mark">bC</span>
</div>
<button
type="button"
className="app-sidebar__collapse-toggle"
onClick={toggleCollapsed}
title={t(isCollapsed ? "layout.sidebar.expand" : "layout.sidebar.collapse")}
>
<ChevronLeftIcon aria-hidden="true" />
</button>
</div>
<nav className="app-sidebar__nav">
{NAV_ITEMS.map(({ to, key, Icon, tourTarget }) => (
<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)}
title={t(`layout.nav.${key}`)}
data-tour={tourTarget}
>
<Icon aria-hidden="true" />
<span className="label">{t(`layout.nav.${key}`)}</span>
</NavLink>
))}
</nav>
<SettingsMenu />
<AccountMenu />
<p className="app-sidebar__version" aria-hidden="true">
v{__APP_VERSION__}
</p>
</aside>
<main className="app-content">
{/* Renders nothing on the web build (native-only check) or once no
newer version is known — see UpdateBanner's own doc comment. */}
<UpdateBanner />
<Outlet />
</main>
</div>
);
}
/**
* Collapsible "Paramètres" section revealing the settings pages (Compte /
* Préférences alimentaires / Foyer / Préférences utilisateur — see
* `pages/settings/`). Starts open whenever the current route is already
* under `/parametres`, so following a link there (e.g. from
* {@link AccountMenu}) doesn't land on a collapsed menu; otherwise starts
* closed to keep the sidebar's main focus on the primary nav above it.
*
* Independent of the whole-sidebar collapse toggle in {@link AppLayout} —
* this menu's own open/closed state persists across that (a rail-collapsed
* sidebar can still have "Paramètres" expanded, just showing icons).
*/
function SettingsMenu() {
const { t } = useTranslation();
const location = useLocation();
const [isOpen, setIsOpen] = useState(location.pathname.startsWith("/parametres"));
return (
<div className="app-sidebar__settings">
<button
type="button"
className="app-sidebar__settings-toggle"
aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)}
title={t("layout.settings.toggle")}
>
<span className="app-sidebar__settings-toggle-left">
<SettingsIcon aria-hidden="true" />
<span className="label">{t("layout.settings.toggle")}</span>
</span>
<span className="chevron" aria-hidden="true">
{isOpen ? "▾" : "▸"}
</span>
</button>
{isOpen && (
<nav className="app-sidebar__nav app-sidebar__settings-nav">
{SETTINGS_ITEMS.map(({ to, key, Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.settings.nav.${key}`)}
>
<Icon aria-hidden="true" />
<span className="label">{t(`layout.settings.nav.${key}`)}</span>
</NavLink>
))}
</nav>
)}
</div>
);
}
/**
* Account menu — a small dropdown opened from the signed-in user's name at
* the very bottom of the sidebar, replacing what used to be a plain
* greeting + logout button. Offers a shortcut straight to `/parametres/compte`
* plus the logout action; closes itself after either action so it never
* lingers open across a navigation.
*/
function AccountMenu() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
/**
* Ends the session and returns to `/` — `RequireAuth` shows the public
* {@link LandingPage} there for a logged-out visitor (issue #41), so this
* naturally lands on the marketing page rather than straight back at the
* login form.
*/
async function handleLogout() {
setIsOpen(false);
try {
await logout();
void navigate("/");
} catch (err) {
// Rethrown as-is — `AuthContext`'s `logout` already documents itself
// as throwing `ApiError` on failure; this handler just isn't allowed
// a bare `await` per the repo's async/try-catch convention.
throw err;
}
}
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";
return (
<div className="app-sidebar__footer">
<button
type="button"
className="app-sidebar__account-toggle"
aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)}
title={t("layout.greeting", { firstName: user?.firstName })}
>
<span className="app-sidebar__avatar" aria-hidden="true">
{initial}
</span>
<span className="label">{t("layout.greeting", { firstName: user?.firstName })}</span>
</button>
{isOpen && (
<div className="app-sidebar__account-menu">
<NavLink to="/parametres/compte" onClick={() => setIsOpen(false)}>
{t("layout.accountMenu.myAccount")}
</NavLink>
<button type="button" onClick={handleLogout}>
{t("layout.logout")}
</button>
</div>
)}
</div>
);
}