All checks were successful
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>
252 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|