Web: UserPreferencesPage (thème) + renommage "Préférences alimentaires" (step 3/4)
- ThemeContext (features/theme/) : charge/applique le thème du profil connecté via l'attribut data-theme (SYSTEM = pas d'attribut, laisse la media query prefers-color-scheme décider) ; échec réseau non bloquant (worst case reste au thème courant, pas d'unhandled rejection) - UserPreferencesPage, routée /parametres/preferences-utilisateur, hot-save (3 boutons radio Clair/Sombre/Système) - layout.settings.nav.preferences renommé "Préférences alimentaires" (évite la confusion avec ce nouveau concept plus large), nouvelle entrée "Préférences utilisateur" - _theme.scss: commentaires mis à jour (l'attribut data-theme est désormais réellement posé, plus une simple anticipation)
This commit is contained in:
parent
3acde696f5
commit
4bdc9c0470
9 changed files with 224 additions and 16 deletions
|
|
@ -13,6 +13,7 @@ import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdP
|
||||||
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
|
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
|
||||||
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
|
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
|
||||||
import { PreferencesPage } from "./pages/settings/PreferencesPage";
|
import { PreferencesPage } from "./pages/settings/PreferencesPage";
|
||||||
|
import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level route table. Every authenticated section is nested under one
|
* Top-level route table. Every authenticated section is nested under one
|
||||||
|
|
@ -22,12 +23,12 @@ import { PreferencesPage } from "./pages/settings/PreferencesPage";
|
||||||
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
|
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
|
||||||
* itself redirects to `/login` if needed.
|
* itself redirects to `/login` if needed.
|
||||||
*
|
*
|
||||||
* `/parametres/*` (compte/préférences/foyer) are the settings pages,
|
* `/parametres/*` (compte/préférences alimentaires/foyer/préférences
|
||||||
* reachable from the sidebar's bottom "Paramètres" menu and the account
|
* utilisateur) are the settings pages, reachable from the sidebar's bottom
|
||||||
* menu (see `AppLayout`) — nested under `AppLayout` like every other
|
* "Paramètres" menu and the account menu (see `AppLayout`) — nested under
|
||||||
* authenticated section. `/foyer` is the old, pre-split combined page's
|
* `AppLayout` like every other authenticated section. `/foyer` is the old,
|
||||||
* path; it now just redirects to `/parametres/foyer` so an existing
|
* pre-split combined page's path; it now just redirects to
|
||||||
* bookmark/link keeps working.
|
* `/parametres/foyer` so an existing bookmark/link keeps working.
|
||||||
*
|
*
|
||||||
* `/onboarding/*` (regime/foyer/allergens, in that order) is also
|
* `/onboarding/*` (regime/foyer/allergens, in that order) is also
|
||||||
* `RequireAuth`-gated — reached right after signup, once a session already
|
* `RequireAuth`-gated — reached right after signup, once a session already
|
||||||
|
|
@ -51,6 +52,7 @@ export function App() {
|
||||||
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
||||||
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
||||||
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
||||||
|
<Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
|
||||||
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
|
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import {
|
||||||
type HouseView,
|
type HouseView,
|
||||||
type LoginInput,
|
type LoginInput,
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
|
type PreferencesView,
|
||||||
type SafeUserProfile,
|
type SafeUserProfile,
|
||||||
type SignupInput,
|
type SignupInput,
|
||||||
|
type ThemePreference,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
|
/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
|
||||||
|
|
@ -174,6 +176,16 @@ export class ApiClient {
|
||||||
body: JSON.stringify({ allergyIds }),
|
body: JSON.stringify({ allergyIds }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
|
||||||
|
public getPreferences(): Promise<PreferencesView> {
|
||||||
|
return this.request("/preferences");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the current user's theme preference. */
|
||||||
|
public updatePreferences(theme: ThemePreference): Promise<PreferencesView> {
|
||||||
|
return this.request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single shared instance — this client is stateless, no need for one per caller. */
|
/** Single shared instance — this client is stateless, no need for one per caller. */
|
||||||
|
|
|
||||||
89
apps/web/src/features/theme/ThemeContext.tsx
Normal file
89
apps/web/src/features/theme/ThemeContext.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { ThemePreference } from "@batch-cooking/shared";
|
||||||
|
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { apiClient } from "../../api/client";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
|
||||||
|
/** Shape of the theme state/actions exposed via {@link useTheme}. */
|
||||||
|
interface ThemeContextValue {
|
||||||
|
/** The signed-in user's theme preference — `"SYSTEM"` (the default) for a logged-out visitor too, nothing to load a preference for. */
|
||||||
|
theme: ThemePreference;
|
||||||
|
/** Persists `theme` and applies it immediately. Throws `ApiError` on failure. */
|
||||||
|
setTheme: (theme: ThemePreference) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React context carrying {@link ThemeContextValue} — always accessed through {@link useTheme}, never directly. */
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies `theme` to the document. `SYSTEM` *removes* the override rather
|
||||||
|
* than setting a literal `"system"` value `_theme.scss` wouldn't recognize
|
||||||
|
* — with no `data-theme` attribute at all, its `prefers-color-scheme`
|
||||||
|
* media query decides instead, exactly the "system" behavior.
|
||||||
|
*/
|
||||||
|
function applyTheme(theme: ThemePreference) {
|
||||||
|
if (theme === "SYSTEM") {
|
||||||
|
delete document.documentElement.dataset.theme;
|
||||||
|
} else {
|
||||||
|
document.documentElement.dataset.theme = theme.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the signed-in user's theme preference and keeps the document in
|
||||||
|
* sync with it. Must be nested inside `AuthProvider` (reads `useAuth()`'s
|
||||||
|
* `user` to know when a profile is available to load a preference for) —
|
||||||
|
* see `main.tsx`.
|
||||||
|
*
|
||||||
|
* Keyed on `user?.id` rather than `user` itself: `AuthContext`'s `user`
|
||||||
|
* object is a fresh reference every time it refreshes (e.g. after an
|
||||||
|
* unrelated `refreshUser()` call elsewhere), which shouldn't re-trigger a
|
||||||
|
* preferences fetch — only actually signing in/out or switching accounts
|
||||||
|
* should.
|
||||||
|
*/
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [theme, setThemeState] = useState<ThemePreference>("SYSTEM");
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the id on purpose, see the doc comment above.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setThemeState("SYSTEM");
|
||||||
|
applyTheme("SYSTEM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
apiClient
|
||||||
|
.getPreferences()
|
||||||
|
.then((preferences) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setThemeState(preferences.theme);
|
||||||
|
applyTheme(preferences.theme);
|
||||||
|
})
|
||||||
|
// Failing to load a preference (e.g. a network hiccup) shouldn't break
|
||||||
|
// the rest of the app — worst case the theme just stays at its
|
||||||
|
// current/default value, same "not fatal" spirit as AuthContext's own
|
||||||
|
// `me()` call on mount.
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
const setTheme = useCallback(async (newTheme: ThemePreference) => {
|
||||||
|
await apiClient.updatePreferences(newTheme);
|
||||||
|
setThemeState(newTheme);
|
||||||
|
applyTheme(newTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the current theme state/actions. Must be called from within a {@link ThemeProvider}. */
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ const SETTINGS_ITEMS = [
|
||||||
{ to: "/parametres/compte", key: "account" },
|
{ to: "/parametres/compte", key: "account" },
|
||||||
{ to: "/parametres/preferences", key: "preferences" },
|
{ to: "/parametres/preferences", key: "preferences" },
|
||||||
{ to: "/parametres/foyer", key: "household" },
|
{ to: "/parametres/foyer", key: "household" },
|
||||||
|
{ to: "/parametres/preferences-utilisateur", key: "userPreferences" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -71,8 +72,9 @@ export function AppLayout() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapsible "Paramètres" section revealing the three settings pages
|
* Collapsible "Paramètres" section revealing the settings pages (Compte /
|
||||||
* (Compte/Préférences/Foyer — see `pages/settings/`). Starts open whenever
|
* 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
|
* the current route is already under `/parametres`, so following a link
|
||||||
* there (e.g. from {@link AccountMenu}) doesn't land on a collapsed menu;
|
* 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
|
* otherwise starts closed to keep the sidebar's main focus on the primary
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,9 @@
|
||||||
"toggle": "Paramètres",
|
"toggle": "Paramètres",
|
||||||
"nav": {
|
"nav": {
|
||||||
"account": "Compte",
|
"account": "Compte",
|
||||||
"preferences": "Préférences",
|
"preferences": "Préférences alimentaires",
|
||||||
"household": "Foyer"
|
"household": "Foyer",
|
||||||
|
"userPreferences": "Préférences utilisateur"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"accountMenu": {
|
"accountMenu": {
|
||||||
|
|
@ -145,6 +146,15 @@
|
||||||
"intolerancesLabel": "Intolérances"
|
"intolerancesLabel": "Intolérances"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"userPreferences": {
|
||||||
|
"title": "Préférences utilisateur",
|
||||||
|
"themeLabel": "Thème",
|
||||||
|
"theme": {
|
||||||
|
"LIGHT": "Clair",
|
||||||
|
"DARK": "Sombre",
|
||||||
|
"SYSTEM": "Système"
|
||||||
|
}
|
||||||
|
},
|
||||||
"household": {
|
"household": {
|
||||||
"title": "Foyer",
|
"title": "Foyer",
|
||||||
"form": {
|
"form": {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { AuthProvider } from "./features/auth/AuthContext";
|
import { AuthProvider } from "./features/auth/AuthContext";
|
||||||
|
import { ThemeProvider } from "./features/theme/ThemeContext";
|
||||||
// Side-effect import: initializes the i18next instance before anything
|
// Side-effect import: initializes the i18next instance before anything
|
||||||
// renders (react-i18next reads it via context under the hood). See i18n/i18n.ts.
|
// renders (react-i18next reads it via context under the hood). See i18n/i18n.ts.
|
||||||
import "./i18n/i18n";
|
import "./i18n/i18n";
|
||||||
|
|
@ -19,7 +20,9 @@ createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|
|
||||||
66
apps/web/src/pages/settings/UserPreferencesPage.tsx
Normal file
66
apps/web/src/pages/settings/UserPreferencesPage.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { ErrorCode, THEME_PREFERENCES, type ThemePreference } from "@batch-cooking/shared";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import { useTheme } from "../../features/theme/ThemeContext";
|
||||||
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
|
import "./settings-pages.scss";
|
||||||
|
|
||||||
|
/** Status of the theme choice's own autosave — see `PreferencesPage` for the same hot-saving pattern. */
|
||||||
|
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User (personalization) preferences — routed at
|
||||||
|
* `/parametres/preferences-utilisateur`. Just the theme choice for now
|
||||||
|
* (light/dark/system — see `features/theme/ThemeContext.tsx`), meant to
|
||||||
|
* grow. Distinct from `/parametres/preferences` ("Préférences
|
||||||
|
* alimentaires" — regime/allergies): that page is about the household's
|
||||||
|
* food constraints, this one is about how the app itself looks, unrelated
|
||||||
|
* concerns that happened to share a name before this page existed.
|
||||||
|
*/
|
||||||
|
export function UserPreferencesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>("idle");
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleChange(newTheme: ThemePreference) {
|
||||||
|
setSaveState("saving");
|
||||||
|
try {
|
||||||
|
await setTheme(newTheme);
|
||||||
|
setSaveState("saved");
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setSaveError(errorMessageService.getLabel(code));
|
||||||
|
setSaveState("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<h1>{t("userPreferences.title")}</h1>
|
||||||
|
|
||||||
|
<div className="settings-page__section">
|
||||||
|
<fieldset className="theme-select">
|
||||||
|
<legend>{t("userPreferences.themeLabel")}</legend>
|
||||||
|
{THEME_PREFERENCES.map((option) => (
|
||||||
|
<label key={option} className="theme-select__option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="theme"
|
||||||
|
value={option}
|
||||||
|
checked={theme === option}
|
||||||
|
onChange={() => handleChange(option)}
|
||||||
|
/>
|
||||||
|
{t(`userPreferences.theme.${option}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{saveState === "saving" && <p className="settings-page__saving">{t("common.saving")}</p>}
|
||||||
|
{saveState === "saved" && <p className="settings-page__saved">{t("common.saved")}</p>}
|
||||||
|
{saveState === "error" && <p className="field-error">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -149,3 +149,26 @@ button.settings-page__link-button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme choice (UserPreferencesPage) — a plain radio group, no fieldset/
|
||||||
|
// legend styling exists elsewhere yet to reuse (AllergySelect's `.allergy-
|
||||||
|
// select` in profile-forms.scss is checkbox-grid specific).
|
||||||
|
.theme-select {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0 0 var(--space-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-xs) 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,9 @@
|
||||||
|
|
||||||
// --- Dark mode ---------------------------------------------------------
|
// --- Dark mode ---------------------------------------------------------
|
||||||
// Follows the OS/browser preference by default. Guarded with
|
// Follows the OS/browser preference by default. Guarded with
|
||||||
// `:root:not([data-theme="light"])` so that, if a manual theme switch is
|
// `:root:not([data-theme="light"])` so an explicit "light" choice (see
|
||||||
// ever added, an explicit "light" choice can override a dark OS setting —
|
// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all —
|
||||||
// today nothing sets `data-theme`, so this simply tracks system preference.
|
// this block then decides) can override a dark OS setting.
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="light"]) {
|
:root:not([data-theme="light"]) {
|
||||||
--color-background: #14181a;
|
--color-background: #14181a;
|
||||||
|
|
@ -138,8 +138,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the block above for a future explicit "dark" choice (e.g. a
|
// Mirrors the block above for an explicit "dark" choice (`ThemeContext`
|
||||||
// theme toggle), so it wins over the OS setting in both directions.
|
// sets `data-theme="dark"` on `<html>`), so it wins over the OS setting in
|
||||||
|
// both directions.
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--color-background: #14181a;
|
--color-background: #14181a;
|
||||||
--color-surface: #1c221e;
|
--color-surface: #1c221e;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue