Web: menu Paramètres en bas de la sidebar + menu compte utilisateur (step 5/8)

- AppLayout: retire "Foyer & profil" de la nav principale
- SettingsMenu: bloc repliable (Compte/Préférences/Foyer), ouvert par
  défaut si la route courante est sous /parametres
- AccountMenu: remplace le footer statique (salutation + déconnexion)
  par un petit menu déroulant (Mon compte, Se déconnecter)
This commit is contained in:
Nicolas 2026-08-17 10:42:36 +02:00
parent 3363cfad75
commit 8e9457297d
2 changed files with 174 additions and 31 deletions

View file

@ -60,25 +60,51 @@
} }
} }
&__footer { // --- "Paramètres" collapsible menu -------------------------------------
display: flex; &__settings {
flex-direction: column;
gap: var(--space-sm);
padding-top: var(--space-md); padding-top: var(--space-md);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
&__user { &__settings-toggle {
padding: 0 var(--space-sm); display: flex;
color: var(--color-text-muted); align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 600;
text-align: left;
color: var(--color-text);
background: none;
border: none;
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
} }
&__footer button { &__settings-nav {
margin-top: var(--space-xs);
}
// --- Account menu (bottom of the sidebar) -------------------------------
&__footer {
position: relative;
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
&__account-toggle {
width: 100%;
padding: 0.5rem var(--space-sm); padding: 0.5rem var(--space-sm);
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 600; font-weight: 600;
text-align: left;
cursor: pointer; cursor: pointer;
border-radius: var(--radius-base); border-radius: var(--radius-base);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -89,6 +115,40 @@
background: var(--color-surface-alt); background: var(--color-surface-alt);
} }
} }
// Anchored just above the toggle rather than inline in the flow it's a
// transient overlay, not part of the sidebar's permanent layout.
&__account-menu {
position: absolute;
bottom: calc(100% + var(--space-xs));
left: 0;
right: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
box-shadow: var(--shadow-md);
a,
button {
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
text-align: left;
text-decoration: none;
color: var(--color-text);
background: none;
border: none;
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
}
} }
.app-content { .app-content {
@ -125,8 +185,8 @@
overflow-x: auto; overflow-x: auto;
} }
&__settings,
&__footer { &__footer {
flex-direction: row;
padding-top: 0; padding-top: 0;
border-top: none; border-top: none;
} }

View file

@ -1,10 +1,11 @@
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../features/auth/AuthContext"; import { useAuth } from "../features/auth/AuthContext";
import "./AppLayout.scss"; import "./AppLayout.scss";
/** /**
* One entry in the sidebar nav. `key` maps to `layout.nav.<key>` in * 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 * `locales/fr/translation.json` adding a section is one array entry plus
* one locale key, no other file to touch. * one locale key, no other file to touch.
*/ */
@ -12,29 +13,30 @@ const NAV_ITEMS = [
{ to: "/", key: "planning" }, { to: "/", key: "planning" },
{ to: "/recettes", key: "recipes" }, { to: "/recettes", key: "recipes" },
{ to: "/liste-de-courses", key: "shoppingList" }, { to: "/liste-de-courses", key: "shoppingList" },
{ to: "/foyer", key: "household" },
] as const; ] as const;
/** /**
* Shell for every authenticated page: a sidebar (brand, section nav, and * One entry in the "Paramètres" nav, revealed by {@link SettingsMenu}. `key`
* the signed-in user + logout at the bottom) plus a main content area * maps to `layout.settings.nav.<key>`.
* rendering the matched child route via `<Outlet />`. */
const SETTINGS_ITEMS = [
{ to: "/parametres/compte", key: "account" },
{ to: "/parametres/preferences", key: "preferences" },
{ to: "/parametres/foyer", key: "household" },
] 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 />`.
* *
* Mounted once as the parent element of the whole authenticated route * Mounted once as the parent element of the whole authenticated route
* group, itself wrapped in {@link RequireAuth} (see `App.tsx`) `user` is * group, itself wrapped in {@link RequireAuth} (see `App.tsx`) `user` is
* therefore guaranteed non-null by the time this renders. * therefore guaranteed non-null by the time this renders.
*/ */
export function AppLayout() { export function AppLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
/** Ends the session and returns to the login page. */
async function handleLogout() {
await logout();
navigate("/login");
}
return ( return (
<div className="app-layout"> <div className="app-layout">
<aside className="app-sidebar"> <aside className="app-sidebar">
@ -57,14 +59,8 @@ export function AppLayout() {
))} ))}
</nav> </nav>
<div className="app-sidebar__footer"> <SettingsMenu />
<span className="app-sidebar__user"> <AccountMenu />
{t("layout.greeting", { firstName: user?.firstName })}
</span>
<button type="button" onClick={handleLogout}>
{t("layout.logout")}
</button>
</div>
</aside> </aside>
<main className="app-content"> <main className="app-content">
@ -73,3 +69,90 @@ export function AppLayout() {
</div> </div>
); );
} }
/**
* Collapsible "Paramètres" section revealing the three settings pages
* (Compte/Préférences/Foyer 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.
*/
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)}
>
{t("layout.settings.toggle")}
<span aria-hidden="true">{isOpen ? "▾" : "▸"}</span>
</button>
{isOpen && (
<nav className="app-sidebar__nav app-sidebar__settings-nav">
{SETTINGS_ITEMS.map(({ to, key }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
{t(`layout.settings.nav.${key}`)}
</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 the login page. */
async function handleLogout() {
setIsOpen(false);
await logout();
navigate("/login");
}
return (
<div className="app-sidebar__footer">
<button
type="button"
className="app-sidebar__account-toggle"
aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)}
>
{t("layout.greeting", { firstName: user?.firstName })}
</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>
);
}