Merge pull request #13 from kyuno053/feat/user-preferences-sidebar-nav
Préférences utilisateur (thème) + sidebar avec icônes et repli
This commit is contained in:
commit
25ff6fb8ba
27 changed files with 930 additions and 47 deletions
23
apps/api/features/preferences.feature
Normal file
23
apps/api/features/preferences.feature
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
Feature: User preferences (theme)
|
||||||
|
As a signed-in user
|
||||||
|
I want to choose a light, dark, or system theme
|
||||||
|
So that the app matches how I like to read it
|
||||||
|
|
||||||
|
Scenario: A visitor without a session cannot read preferences
|
||||||
|
When I send a GET request to "/preferences"
|
||||||
|
Then the response status should be 401
|
||||||
|
And the response error code should be "NOT_AUTHENTICATED"
|
||||||
|
|
||||||
|
Scenario: A signed-in user with no preferences yet defaults to SYSTEM
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I request my preferences
|
||||||
|
Then the response status should be 200
|
||||||
|
And my theme preference should be "SYSTEM"
|
||||||
|
|
||||||
|
Scenario: A signed-in user sets their theme preference
|
||||||
|
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||||
|
When I set my theme preference to "DARK"
|
||||||
|
Then the response status should be 200
|
||||||
|
And my theme preference should be "DARK"
|
||||||
15
apps/api/features/step-definitions/preferences.steps.ts
Normal file
15
apps/api/features/step-definitions/preferences.steps.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Then, When } from "@cucumber/cucumber";
|
||||||
|
import type { CustomWorld } from "../support/world.js";
|
||||||
|
|
||||||
|
When("I request my preferences", async function (this: CustomWorld) {
|
||||||
|
this.response = await this.agent.get("/preferences");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I set my theme preference to {string}", async function (this: CustomWorld, theme: string) {
|
||||||
|
this.response = await this.agent.patch("/preferences").send({ theme });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("my theme preference should be {string}", function (this: CustomWorld, theme: string) {
|
||||||
|
assert.equal(this.response.body.theme, theme);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ThemePreference" AS ENUM ('LIGHT', 'DARK', 'SYSTEM');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_preference" (
|
||||||
|
"user_profile_id" INTEGER NOT NULL,
|
||||||
|
"theme" "ThemePreference" NOT NULL DEFAULT 'SYSTEM',
|
||||||
|
|
||||||
|
CONSTRAINT "user_preference_pkey" PRIMARY KEY ("user_profile_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user_preference" ADD CONSTRAINT "user_preference_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -98,10 +98,36 @@ model UserProfile {
|
||||||
/// a time — but Prisma models the admin side of a one-to-many FK as a
|
/// a time — but Prisma models the admin side of a one-to-many FK as a
|
||||||
/// list regardless of that real-world cardinality.
|
/// list regardless of that real-world cardinality.
|
||||||
administeredHouses House[] @relation("HouseAdmin")
|
administeredHouses House[] @relation("HouseAdmin")
|
||||||
|
preferences UserPreference?
|
||||||
|
|
||||||
@@map("user_profiles")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Not in the original spec doc — personalization settings (theme for now,
|
||||||
|
/// meant to grow), one row per profile, created on demand (see
|
||||||
|
/// `preferences.service.ts`) rather than at signup — same "absent means the
|
||||||
|
/// default" philosophy as `dietId`/allergies.
|
||||||
|
enum ThemePreference {
|
||||||
|
LIGHT
|
||||||
|
DARK
|
||||||
|
/// Follow the OS/browser preference — the default. Not "no row yet" (that
|
||||||
|
/// case is handled in the service layer) but an explicit choice to track
|
||||||
|
/// the system, distinguishable from a user who hasn't decided yet if this
|
||||||
|
/// model ever needs that distinction.
|
||||||
|
SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserPreference {
|
||||||
|
/// Both the primary key and the FK — a strict 1-1 with UserProfile, no
|
||||||
|
/// separate auto-incrementing id (a profile has at most one preferences row).
|
||||||
|
userProfileId Int @id @map("user_profile_id")
|
||||||
|
theme ThemePreference @default(SYSTEM)
|
||||||
|
|
||||||
|
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("user_preference")
|
||||||
|
}
|
||||||
|
|
||||||
/// Explicit join table for the user_profiles <-> allergy association
|
/// Explicit join table for the user_profiles <-> allergy association
|
||||||
/// (documented in the spec as a plain many-to-many, no extra fields).
|
/// (documented in the spec as a plain many-to-many, no extra fields).
|
||||||
model UserProfileAllergy {
|
model UserProfileAllergy {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { env } from "./config/env.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
import { houseRouter } from "./modules/house/house.routes.js";
|
import { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
|
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||||
import { referenceRouter } from "./modules/reference/reference.routes.js";
|
import { referenceRouter } from "./modules/reference/reference.routes.js";
|
||||||
|
|
||||||
|
|
@ -28,6 +29,7 @@ export function createServer(): ExpressServer {
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
server.mountRouter("/house", houseRouter);
|
server.mountRouter("/house", houseRouter);
|
||||||
server.mountRouter("/planning", planningRouter);
|
server.mountRouter("/planning", planningRouter);
|
||||||
|
server.mountRouter("/preferences", preferencesRouter);
|
||||||
server.mountRouter("/profile", profileRouter);
|
server.mountRouter("/profile", profileRouter);
|
||||||
server.mountRouter("/reference", referenceRouter);
|
server.mountRouter("/reference", referenceRouter);
|
||||||
|
|
||||||
|
|
|
||||||
27
apps/api/src/modules/preferences/preferences.routes.ts
Normal file
27
apps/api/src/modules/preferences/preferences.routes.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
|
import { updatePreferencesSchema } from "@batch-cooking/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
|
import { getPreferences, updatePreferences } from "./preferences.service.js";
|
||||||
|
|
||||||
|
/** Router mounted at `/preferences` in app.ts. Every route requires a session — this is the authenticated user's own preferences. */
|
||||||
|
export const preferencesRouter = Router();
|
||||||
|
|
||||||
|
preferencesRouter.get(
|
||||||
|
"/",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||||
|
const preferences = await getPreferences(res.locals.userProfile.id);
|
||||||
|
res.status(200).json(preferences);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
preferencesRouter.patch(
|
||||||
|
"/",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const input = updatePreferencesSchema.parse(req.body);
|
||||||
|
const preferences = await updatePreferences(res.locals.userProfile.id, input.theme);
|
||||||
|
res.status(200).json(preferences);
|
||||||
|
}),
|
||||||
|
);
|
||||||
31
apps/api/src/modules/preferences/preferences.service.ts
Normal file
31
apps/api/src/modules/preferences/preferences.service.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { PreferencesView, ThemePreference } from "@batch-cooking/shared";
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A profile's personalization preferences. `SYSTEM` (the schema default)
|
||||||
|
* is returned both when a row already says so *and* when there's no row
|
||||||
|
* yet at all — same "absent means the default" philosophy as
|
||||||
|
* `dietId`/allergies elsewhere in `profile.service.ts`, no row is created
|
||||||
|
* just to read it.
|
||||||
|
*/
|
||||||
|
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
||||||
|
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
|
||||||
|
return { theme: preferences?.theme ?? "SYSTEM" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a profile's theme preference, creating its preferences row on first
|
||||||
|
* write (an `upsert` rather than requiring a separate "create" step — a
|
||||||
|
* profile never needs to explicitly initialize this row before using it).
|
||||||
|
*/
|
||||||
|
export async function updatePreferences(
|
||||||
|
userProfileId: number,
|
||||||
|
theme: ThemePreference,
|
||||||
|
): Promise<PreferencesView> {
|
||||||
|
const preferences = await prisma.userPreference.upsert({
|
||||||
|
where: { userProfileId },
|
||||||
|
create: { userProfileId, theme },
|
||||||
|
update: { theme },
|
||||||
|
});
|
||||||
|
return { theme: preferences.theme };
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||||
export async function resetDatabase() {
|
export async function resetDatabase() {
|
||||||
await prisma.$executeRawUnsafe(`
|
await prisma.$executeRawUnsafe(`
|
||||||
TRUNCATE TABLE
|
TRUNCATE TABLE
|
||||||
"user_profile_allergy", "allergy", "category",
|
"user_profile_allergy", "user_preference", "allergy", "category",
|
||||||
"planning_item", "planning",
|
"planning_item", "planning",
|
||||||
"recipe_ingredient", "step", "tech_step_mapping", "tech_step",
|
"recipe_ingredient", "step", "tech_step_mapping", "tech_step",
|
||||||
"recipe", "ingredients", "sources",
|
"recipe", "ingredients", "sources",
|
||||||
|
|
|
||||||
98
apps/api/test/preferences.test.ts
Normal file
98
apps/api/test/preferences.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../src/app.js";
|
||||||
|
import { prisma } from "../src/db/prisma.js";
|
||||||
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
|
function buildSignupPayload(): SignupInput {
|
||||||
|
const firstName = faker.person.firstName();
|
||||||
|
const lastName = faker.person.lastName();
|
||||||
|
return {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||||
|
password: faker.internet.password({ length: 16 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Preferences", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /preferences", () => {
|
||||||
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const res = await request(app).get("/preferences");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to SYSTEM when the profile has no preferences row yet", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.get("/preferences");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.deep.equal({ theme: "SYSTEM" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /preferences", () => {
|
||||||
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const res = await request(app).patch("/preferences").send({ theme: "DARK" });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown theme with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.patch("/preferences").send({ theme: "PURPLE" });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the preferences row on first write, and reuses it on later reads/writes", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const patchRes = await agent.patch("/preferences").send({ theme: "DARK" });
|
||||||
|
expect(patchRes.status).to.equal(200);
|
||||||
|
expect(patchRes.body).to.deep.equal({ theme: "DARK" });
|
||||||
|
|
||||||
|
const getRes = await agent.get("/preferences");
|
||||||
|
expect(getRes.body).to.deep.equal({ theme: "DARK" });
|
||||||
|
|
||||||
|
const secondPatchRes = await agent.patch("/preferences").send({ theme: "LIGHT" });
|
||||||
|
expect(secondPatchRes.body).to.deep.equal({ theme: "LIGHT" });
|
||||||
|
|
||||||
|
const secondGetRes = await agent.get("/preferences");
|
||||||
|
expect(secondGetRes.body).to.deep.equal({ theme: "LIGHT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes preferences to the caller's own profile", async () => {
|
||||||
|
const aliceAgent = request.agent(app);
|
||||||
|
await aliceAgent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
await aliceAgent.patch("/preferences").send({ theme: "DARK" });
|
||||||
|
|
||||||
|
const bobAgent = request.agent(app);
|
||||||
|
await bobAgent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await bobAgent.get("/preferences");
|
||||||
|
expect(res.body).to.deep.equal({ theme: "SYSTEM" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -76,7 +76,7 @@ describe("Signup", () => {
|
||||||
describe("Login", () => {
|
describe("Login", () => {
|
||||||
it("logs in and lands on the home page", () => {
|
it("logs in and lands on the home page", () => {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
||||||
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
||||||
cy.intercept("POST", "**/auth/login", {
|
cy.intercept("POST", "**/auth/login", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -130,7 +130,7 @@ describe("Already authenticated", () => {
|
||||||
dietId: null,
|
dietId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
||||||
|
|
||||||
cy.visit("/login");
|
cy.visit("/login");
|
||||||
cy.url().should("not.include", "/login");
|
cy.url().should("not.include", "/login");
|
||||||
|
|
@ -150,7 +150,7 @@ describe("Already authenticated", () => {
|
||||||
dietId: null,
|
dietId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
||||||
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
||||||
|
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const signupResponse = {
|
||||||
function signupAndReachOnboarding() {
|
function signupAndReachOnboarding() {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
||||||
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup");
|
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup");
|
||||||
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
||||||
|
|
||||||
cy.visit("/signup");
|
cy.visit("/signup");
|
||||||
cy.get("#firstName").type("Alice");
|
cy.get("#firstName").type("Alice");
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ const authenticatedProfile = {
|
||||||
describe("Sidebar — settings menu and account menu", () => {
|
describe("Sidebar — settings menu and account menu", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||||
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
// Not "**/planning*" — that glob also matches the Vite dev request for
|
||||||
|
// planning-page.scss (see planning-page.cy.ts for the same gotcha).
|
||||||
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no longer lists Foyer in the main nav", () => {
|
it("no longer lists Foyer in the main nav", () => {
|
||||||
|
|
@ -21,15 +23,24 @@ describe("Sidebar — settings menu and account menu", () => {
|
||||||
cy.get(".app-sidebar__nav a").should("not.contain", "Foyer");
|
cy.get(".app-sidebar__nav a").should("not.contain", "Foyer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reveals the three settings pages behind the Paramètres toggle", () => {
|
it("reveals the four settings pages behind the Paramètres toggle", () => {
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
|
|
||||||
cy.contains("a", "Compte").should("not.exist");
|
cy.contains("a", "Compte").should("not.exist");
|
||||||
cy.contains("button", "Paramètres").click();
|
cy.contains("button", "Paramètres").click();
|
||||||
|
|
||||||
cy.contains("a", "Compte").should("have.attr", "href", "/parametres/compte");
|
cy.contains("a", "Compte").should("have.attr", "href", "/parametres/compte");
|
||||||
cy.contains("a", "Préférences").should("have.attr", "href", "/parametres/preferences");
|
cy.contains("a", "Préférences alimentaires").should(
|
||||||
|
"have.attr",
|
||||||
|
"href",
|
||||||
|
"/parametres/preferences",
|
||||||
|
);
|
||||||
cy.contains("a", "Foyer").should("have.attr", "href", "/parametres/foyer");
|
cy.contains("a", "Foyer").should("have.attr", "href", "/parametres/foyer");
|
||||||
|
cy.contains("a", "Préférences utilisateur").should(
|
||||||
|
"have.attr",
|
||||||
|
"href",
|
||||||
|
"/parametres/preferences-utilisateur",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the account menu from the greeting and links to Mon compte", () => {
|
it("opens the account menu from the greeting and links to Mon compte", () => {
|
||||||
|
|
@ -47,4 +58,26 @@ describe("Sidebar — settings menu and account menu", () => {
|
||||||
cy.visit("/foyer");
|
cy.visit("/foyer");
|
||||||
cy.url().should("include", "/parametres/foyer");
|
cy.url().should("include", "/parametres/foyer");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("collapses to an icon-only rail and back, persisting the choice across reloads", () => {
|
||||||
|
cy.visit("/");
|
||||||
|
|
||||||
|
cy.get(".app-sidebar").should("not.have.class", "collapsed");
|
||||||
|
cy.contains("nav a", "Planning").should("be.visible");
|
||||||
|
|
||||||
|
cy.get(".app-sidebar__collapse-toggle").click();
|
||||||
|
cy.get(".app-sidebar").should("have.class", "collapsed");
|
||||||
|
// The label text hides (not removed from the DOM — still there for the
|
||||||
|
// `title` tooltip/accessibility), while the link itself stays visible,
|
||||||
|
// icon-only.
|
||||||
|
cy.contains("span.label", "Planning").should("exist").and("not.be.visible");
|
||||||
|
cy.get("nav a[title='Planning']").should("be.visible");
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
cy.get(".app-sidebar").should("have.class", "collapsed");
|
||||||
|
|
||||||
|
cy.get(".app-sidebar__collapse-toggle").click();
|
||||||
|
cy.get(".app-sidebar").should("not.have.class", "collapsed");
|
||||||
|
cy.contains("nav a", "Planning").should("be.visible");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
54
apps/web/cypress/e2e/user-preferences.cy.ts
Normal file
54
apps/web/cypress/e2e/user-preferences.cy.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
|
||||||
|
|
||||||
|
const authenticatedProfile = {
|
||||||
|
id: 1,
|
||||||
|
firstName: "Alice",
|
||||||
|
lastName: "Martin",
|
||||||
|
email: "alice@example.com",
|
||||||
|
tokenVersion: 0,
|
||||||
|
houseId: null,
|
||||||
|
dietId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("User preferences (/parametres/preferences-utilisateur)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows SYSTEM selected by default", () => {
|
||||||
|
cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "SYSTEM" } });
|
||||||
|
|
||||||
|
cy.visit("/parametres/preferences-utilisateur");
|
||||||
|
|
||||||
|
cy.contains("label", "Système").find("input[type=radio]").should("be.checked");
|
||||||
|
cy.contains("label", "Clair").find("input[type=radio]").should("not.be.checked");
|
||||||
|
cy.contains("label", "Sombre").find("input[type=radio]").should("not.be.checked");
|
||||||
|
// SYSTEM never sets an override — the OS/browser preference decides.
|
||||||
|
cy.get("html").should("not.have.attr", "data-theme");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the previously saved theme selected, and applies it to the document", () => {
|
||||||
|
cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "DARK" } });
|
||||||
|
|
||||||
|
cy.visit("/parametres/preferences-utilisateur");
|
||||||
|
|
||||||
|
cy.contains("label", "Sombre").find("input[type=radio]").should("be.checked");
|
||||||
|
cy.get("html").should("have.attr", "data-theme", "dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switching theme autosaves and applies immediately, no explicit save button", () => {
|
||||||
|
cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "SYSTEM" } });
|
||||||
|
cy.intercept("PATCH", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }).as(
|
||||||
|
"updatePreferences",
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.visit("/parametres/preferences-utilisateur");
|
||||||
|
cy.contains("button", "Enregistrer").should("not.exist");
|
||||||
|
|
||||||
|
cy.contains("label", "Clair").click();
|
||||||
|
|
||||||
|
cy.wait("@updatePreferences").its("request.body").should("deep.equal", { theme: "LIGHT" });
|
||||||
|
cy.contains("Enregistré ✓").should("be.visible");
|
||||||
|
cy.get("html").should("have.attr", "data-theme", "light");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
|
|
||||||
// Fixed-width nav rail. Its own surface (not the page background), same
|
// 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
|
// elevation language as a card, so it reads as a distinct, permanent piece
|
||||||
// of chrome rather than part of the scrolling content.
|
// of chrome rather than part of the scrolling content. `width` transitions
|
||||||
|
// so collapsing/expanding (see `.collapsed` below) reads as a deliberate
|
||||||
|
// motion rather than an abrupt layout jump.
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -24,13 +26,55 @@
|
||||||
padding: var(--space-lg) var(--space-md);
|
padding: var(--space-lg) var(--space-md);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
|
transition: width 0.15s ease;
|
||||||
|
|
||||||
|
&__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--space-sm) var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
&__brand {
|
&__brand {
|
||||||
padding: 0 var(--space-sm) var(--space-xl);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full wordmark by default; swapped for a short monogram when collapsed
|
||||||
|
// (see `.collapsed &__brand-full`/`&__brand-mark` below) — clearer than
|
||||||
|
// an arbitrary icon standing in for the brand itself.
|
||||||
|
&__brand-mark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__collapse-toggle {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__nav {
|
&__nav {
|
||||||
|
|
@ -40,12 +84,23 @@
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
padding: var(--space-sm);
|
padding: var(--space-sm);
|
||||||
border-radius: var(--radius-base);
|
border-radius: var(--radius-base);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-surface-alt);
|
||||||
|
|
@ -87,6 +142,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__settings-toggle-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__settings-nav {
|
&__settings-nav {
|
||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
@ -99,8 +167,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__account-toggle {
|
&__account-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem var(--space-sm);
|
padding: 0.4rem 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;
|
||||||
|
|
@ -110,12 +181,27 @@
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-surface-alt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
// Anchored just above the toggle rather than inline in the flow — it's a
|
// Anchored just above the toggle rather than inline in the flow — it's a
|
||||||
// transient overlay, not part of the sidebar's permanent layout.
|
// transient overlay, not part of the sidebar's permanent layout.
|
||||||
&__account-menu {
|
&__account-menu {
|
||||||
|
|
@ -143,12 +229,68 @@
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-surface-alt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Collapsed (icon-only rail) state -----------------------------------
|
||||||
|
// A single class toggle on the root element — every nested rule below
|
||||||
|
// just hides labels/chevrons and re-centers icons via CSS, no child
|
||||||
|
// component needs to know the sidebar is collapsed.
|
||||||
|
&.collapsed {
|
||||||
|
width: 4.25rem;
|
||||||
|
padding-left: var(--space-sm);
|
||||||
|
padding-right: var(--space-sm);
|
||||||
|
|
||||||
|
.app-sidebar__top {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar__brand-full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar__brand-mark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar__collapse-toggle svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar__nav a,
|
||||||
|
.app-sidebar__settings-toggle,
|
||||||
|
.app-sidebar__account-toggle {
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar__settings-toggle-left {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label,
|
||||||
|
.chevron {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The popover would otherwise shrink to the icon rail's own width,
|
||||||
|
// squashing "Mon compte"/"Se déconnecter" — give it a normal,
|
||||||
|
// comfortable width instead, still anchored to the rail's left edge.
|
||||||
|
.app-sidebar__account-menu {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
|
|
@ -176,10 +318,14 @@
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
&__brand {
|
&__top {
|
||||||
padding: 0 var(--space-sm) 0 0;
|
padding: 0 var(--space-sm) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__brand {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&__nav {
|
&__nav {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,20 @@ import { useTranslation } from "react-i18next";
|
||||||
import { NavLink, Outlet, useLocation, 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";
|
||||||
|
import {
|
||||||
|
AccountIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
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
|
* One entry in the sidebar's main nav. `key` maps to `layout.nav.<key>` in
|
||||||
|
|
@ -10,9 +24,9 @@ import "./AppLayout.scss";
|
||||||
* one locale key, no other file to touch.
|
* one locale key, no other file to touch.
|
||||||
*/
|
*/
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: "/", key: "planning" },
|
{ to: "/", key: "planning", Icon: PlanningIcon },
|
||||||
{ to: "/recettes", key: "recipes" },
|
{ to: "/recettes", key: "recipes", Icon: RecipesIcon },
|
||||||
{ to: "/liste-de-courses", key: "shoppingList" },
|
{ to: "/liste-de-courses", key: "shoppingList", Icon: ShoppingListIcon },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,9 +34,10 @@ const NAV_ITEMS = [
|
||||||
* maps to `layout.settings.nav.<key>`.
|
* maps to `layout.settings.nav.<key>`.
|
||||||
*/
|
*/
|
||||||
const SETTINGS_ITEMS = [
|
const SETTINGS_ITEMS = [
|
||||||
{ to: "/parametres/compte", key: "account" },
|
{ to: "/parametres/compte", key: "account", Icon: AccountIcon },
|
||||||
{ to: "/parametres/preferences", key: "preferences" },
|
{ to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
|
||||||
{ to: "/parametres/foyer", key: "household" },
|
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
|
||||||
|
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,20 +45,52 @@ const SETTINGS_ITEMS = [
|
||||||
* collapsible "Paramètres" nav, and the account menu at the bottom) plus a
|
* collapsible "Paramètres" nav, and the account menu at the bottom) plus a
|
||||||
* main content area rendering the matched child route via `<Outlet />`.
|
* 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
|
* 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 { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<aside className="app-sidebar">
|
<aside className={isCollapsed ? "app-sidebar collapsed" : "app-sidebar"}>
|
||||||
<div className="app-sidebar__brand">batchCooking</div>
|
<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 />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className="app-sidebar__nav">
|
<nav className="app-sidebar__nav">
|
||||||
{NAV_ITEMS.map(({ to, key }) => (
|
{NAV_ITEMS.map(({ to, key, Icon }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
|
|
@ -53,8 +100,10 @@ export function AppLayout() {
|
||||||
// same way `end` would.
|
// same way `end` would.
|
||||||
end={to === "/"}
|
end={to === "/"}
|
||||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||||
|
title={t(`layout.nav.${key}`)}
|
||||||
>
|
>
|
||||||
{t(`layout.nav.${key}`)}
|
<Icon />
|
||||||
|
<span className="label">{t(`layout.nav.${key}`)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -71,12 +120,16 @@ 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
|
||||||
* the current route is already under `/parametres`, so following a link
|
* `pages/settings/`). Starts open whenever the current route is already
|
||||||
* there (e.g. from {@link AccountMenu}) doesn't land on a collapsed menu;
|
* under `/parametres`, so following a link there (e.g. from
|
||||||
* otherwise starts closed to keep the sidebar's main focus on the primary
|
* {@link AccountMenu}) doesn't land on a collapsed menu; otherwise starts
|
||||||
* nav above it.
|
* 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() {
|
function SettingsMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -90,20 +143,28 @@ function SettingsMenu() {
|
||||||
className="app-sidebar__settings-toggle"
|
className="app-sidebar__settings-toggle"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
onClick={() => setIsOpen((open) => !open)}
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
title={t("layout.settings.toggle")}
|
||||||
>
|
>
|
||||||
{t("layout.settings.toggle")}
|
<span className="app-sidebar__settings-toggle-left">
|
||||||
<span aria-hidden="true">{isOpen ? "▾" : "▸"}</span>
|
<SettingsIcon />
|
||||||
|
<span className="label">{t("layout.settings.toggle")}</span>
|
||||||
|
</span>
|
||||||
|
<span className="chevron" aria-hidden="true">
|
||||||
|
{isOpen ? "▾" : "▸"}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<nav className="app-sidebar__nav app-sidebar__settings-nav">
|
<nav className="app-sidebar__nav app-sidebar__settings-nav">
|
||||||
{SETTINGS_ITEMS.map(({ to, key }) => (
|
{SETTINGS_ITEMS.map(({ to, key, Icon }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||||
|
title={t(`layout.settings.nav.${key}`)}
|
||||||
>
|
>
|
||||||
{t(`layout.settings.nav.${key}`)}
|
<Icon />
|
||||||
|
<span className="label">{t(`layout.settings.nav.${key}`)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -132,6 +193,8 @@ function AccountMenu() {
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-sidebar__footer">
|
<div className="app-sidebar__footer">
|
||||||
<button
|
<button
|
||||||
|
|
@ -139,8 +202,12 @@ function AccountMenu() {
|
||||||
className="app-sidebar__account-toggle"
|
className="app-sidebar__account-toggle"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
onClick={() => setIsOpen((open) => !open)}
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
title={t("layout.greeting", { firstName: user?.firstName })}
|
||||||
>
|
>
|
||||||
{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>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|
|
||||||
110
apps/web/src/layouts/nav-icons.tsx
Normal file
110
apps/web/src/layouts/nav-icons.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Small, hand-drawn line-icon set for the sidebar nav (24×24 viewBox,
|
||||||
|
// matches the reviewed mockup — see the plan/PR description) rather than
|
||||||
|
// pulling in an icon library for a handful of glyphs. Sized entirely via
|
||||||
|
// CSS (`.app-sidebar__nav svg` etc., see AppLayout.scss) — no width/height
|
||||||
|
// attribute here, so the same markup works at any size the caller picks.
|
||||||
|
// `aria-hidden` on every icon: each one is always paired with visible text
|
||||||
|
// (the nav label, or a `title` tooltip when collapsed) that already
|
||||||
|
// conveys the meaning — the icon itself is decorative.
|
||||||
|
|
||||||
|
function Icon({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanningIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecipesIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShoppingListIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<circle cx="9" cy="21" r="1" />
|
||||||
|
<circle cx="20" cy="21" r="1" />
|
||||||
|
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DietPreferencesIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z" />
|
||||||
|
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HouseholdIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<path d="M9 22V12h6v10" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserPreferencesIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<circle cx="13.5" cy="6.5" r=".5" />
|
||||||
|
<circle cx="17.5" cy="10.5" r=".5" />
|
||||||
|
<circle cx="8.5" cy="7.5" r=".5" />
|
||||||
|
<circle cx="6.5" cy="12.5" r=".5" />
|
||||||
|
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.5-.7 1.5-1.5 0-.4-.2-.8-.4-1.1-.2-.3-.4-.6-.4-1 0-.8.7-1.5 1.5-1.5H16c3.3 0 6-2.7 6-6 0-4.4-4-8-10-8z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChevronLeftIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
|
"sidebar": {
|
||||||
|
"collapse": "Replier le menu",
|
||||||
|
"expand": "Déplier le menu"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"planning": "Planning",
|
"planning": "Planning",
|
||||||
"recipes": "Recettes",
|
"recipes": "Recettes",
|
||||||
|
|
@ -68,8 +72,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 +150,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;
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ export * from "./schemas/account.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
export * from "./schemas/household.js";
|
export * from "./schemas/household.js";
|
||||||
export * from "./schemas/planning.js";
|
export * from "./schemas/planning.js";
|
||||||
|
export * from "./schemas/preferences.js";
|
||||||
export * from "./schemas/profile.js";
|
export * from "./schemas/profile.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
export * from "./types/household.js";
|
export * from "./types/household.js";
|
||||||
export * from "./types/planning.js";
|
export * from "./types/planning.js";
|
||||||
|
export * from "./types/preferences.js";
|
||||||
export * from "./types/reference.js";
|
export * from "./types/reference.js";
|
||||||
export * from "./types/user-profile.js";
|
export * from "./types/user-profile.js";
|
||||||
|
|
|
||||||
11
packages/shared/src/schemas/preferences.ts
Normal file
11
packages/shared/src/schemas/preferences.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { THEME_PREFERENCES } from "../types/preferences.js";
|
||||||
|
|
||||||
|
// See schemas/auth.ts for the shared client/server validation rationale.
|
||||||
|
|
||||||
|
/** Payload accepted by `PATCH /preferences`. */
|
||||||
|
export const updatePreferencesSchema = z.object({
|
||||||
|
theme: z.enum(THEME_PREFERENCES),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link updatePreferencesSchema}'s validated output. */
|
||||||
|
export type UpdatePreferencesInput = z.infer<typeof updatePreferencesSchema>;
|
||||||
15
packages/shared/src/types/preferences.ts
Normal file
15
packages/shared/src/types/preferences.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* The 3 values a profile's theme preference can take — `SYSTEM` means "no
|
||||||
|
* explicit choice, follow the OS/browser preference" (see `apps/web`'s
|
||||||
|
* `ThemeContext`, which maps this to *not* setting the `data-theme`
|
||||||
|
* attribute at all, letting `_theme.scss`'s `prefers-color-scheme` media
|
||||||
|
* query take over).
|
||||||
|
*/
|
||||||
|
export const THEME_PREFERENCES = ["LIGHT", "DARK", "SYSTEM"] as const;
|
||||||
|
/** Inferred TS type for one {@link THEME_PREFERENCES} member. */
|
||||||
|
export type ThemePreference = (typeof THEME_PREFERENCES)[number];
|
||||||
|
|
||||||
|
/** A profile's personalization preferences, as returned by `GET /preferences` / `PATCH /preferences`. */
|
||||||
|
export interface PreferencesView {
|
||||||
|
theme: ThemePreference;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue