chore(web): session de polish global — version, checkbox, danger zone, icônes
- Affiche le numéro de version (package.json, injecté via Vite) en bas de la sidebar, masqué en mode collapse et en mobile. - Factorise les checkbox/radio dupliqués (AllergySelect, DietTagSelect, IngredientPicker, UserPreferencesPage) en composants partagés CheckboxOption/RadioOption (components/ui/), et inverse le layout pour que la case soit à gauche du label. - Teinte la "zone de danger" de suppression de compte en rouge (fond + bordure), pas seulement le bouton. - Migre les icônes de navigation générale vers lucide-react (nav-icons.tsx devient un fichier de ré-export) ; les pictogrammes d'ingrédients métier restent en SVG custom (pas d'équivalents fins côté lucide). Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et vérification visuelle manuelle (sidebar desktop/collapsed/mobile, light/dark).
This commit is contained in:
parent
c38097f522
commit
66f2a36b8c
17 changed files with 207 additions and 196 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"@batch-cooking/date-tools": "workspace:*",
|
"@batch-cooking/date-tools": "workspace:*",
|
||||||
"@batch-cooking/shared": "workspace:*",
|
"@batch-cooking/shared": "workspace:*",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
|
"lucide-react": "^1.32.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
|
|
|
||||||
41
apps/web/src/components/ui/Checkbox.tsx
Normal file
41
apps/web/src/components/ui/Checkbox.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app-wide "selectable card" checkbox — see `global.scss`'s
|
||||||
|
* `label:has(> input[type="checkbox"])` rule for the actual look (hidden
|
||||||
|
* native input, a `.check-mark` that scales in, `is-selected` driving the
|
||||||
|
* tinted/bordered state). Factors out the JSX triplet (`label` → hidden
|
||||||
|
* `input` → `span.check-mark` → label text) that used to be duplicated
|
||||||
|
* across `AllergySelect`, `DietTagSelect`, `IngredientPicker`'s display
|
||||||
|
* menu, and `UserPreferencesPage`'s theme picker (see {@link RadioOption}
|
||||||
|
* for its `type="radio"` sibling) — one place to get the markup/a11y right
|
||||||
|
* instead of four.
|
||||||
|
*
|
||||||
|
* `is-selected` is applied in JS from the same `checked` boolean the caller
|
||||||
|
* already has, not derived via a CSS `:has(:checked)` chain — that turned
|
||||||
|
* out unreliable across browsers (see the callers this replaces for the
|
||||||
|
* original note).
|
||||||
|
*
|
||||||
|
* `className` is for the *container* layout only (grid item, flex-wrap
|
||||||
|
* chip, stacked list…) — the control's own look never varies, so there's
|
||||||
|
* no `variant` prop here.
|
||||||
|
*/
|
||||||
|
export function CheckboxOption({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className={[className, checked && "is-selected"].filter(Boolean).join(" ")}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
<span className="check-mark" aria-hidden="true" />
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/web/src/components/ui/Radio.tsx
Normal file
38
apps/web/src/components/ui/Radio.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `type="radio"` sibling of {@link CheckboxOption} — same "selectable
|
||||||
|
* card" markup/look (see `global.scss`'s `label:has(> input[...])` rule,
|
||||||
|
* shared by both), just a native radio input under the hood so a group of
|
||||||
|
* `RadioOption`s sharing `name` behaves as mutually exclusive (see
|
||||||
|
* `UserPreferencesPage`'s theme picker, the one caller so far).
|
||||||
|
*/
|
||||||
|
export function RadioOption<T extends string>({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
value: T;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className={[className, checked && "is-selected"].filter(Boolean).join(" ")}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onChange(value)}
|
||||||
|
/>
|
||||||
|
<span className="check-mark" aria-hidden="true" />
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { AllergyView } from "@batch-cooking/shared";
|
import type { AllergyView } from "@batch-cooking/shared";
|
||||||
|
import { CheckboxOption } from "../../components/ui/Checkbox";
|
||||||
import "./profile-forms.scss";
|
import "./profile-forms.scss";
|
||||||
|
|
||||||
interface AllergySelectProps {
|
interface AllergySelectProps {
|
||||||
|
|
@ -38,19 +39,14 @@ export function AllergySelect({ legend, allergies, value, onChange }: AllergySel
|
||||||
{allergies.map((allergy) => {
|
{allergies.map((allergy) => {
|
||||||
const checked = value.includes(allergy.id);
|
const checked = value.includes(allergy.id);
|
||||||
return (
|
return (
|
||||||
<label
|
<CheckboxOption
|
||||||
key={allergy.id}
|
key={allergy.id}
|
||||||
// `is-selected` (not a `:has(:checked)` CSS rule) drives the
|
checked={checked}
|
||||||
// selected look — chaining `:has(...):has(:checked)` to react to
|
onChange={() => toggle(allergy.id)}
|
||||||
// a *state* change (rather than a DOM mutation) turned out to be
|
className="allergy-select__option"
|
||||||
// unreliable, so this stays a plain, always-correct React class
|
|
||||||
// instead of relying on CSS to derive it.
|
|
||||||
className={`allergy-select__option${checked ? " is-selected" : ""}`}
|
|
||||||
>
|
>
|
||||||
<input type="checkbox" checked={checked} onChange={() => toggle(allergy.id)} />
|
|
||||||
<span className="check-mark" aria-hidden="true" />
|
|
||||||
{allergy.name}
|
{allergy.name}
|
||||||
</label>
|
</CheckboxOption>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { DietView } from "@batch-cooking/shared";
|
import type { DietView } from "@batch-cooking/shared";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CheckboxOption } from "../../components/ui/Checkbox";
|
||||||
import "./recipes.scss";
|
import "./recipes.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,11 +30,9 @@ export function DietTagSelect({
|
||||||
{diets.map((diet) => {
|
{diets.map((diet) => {
|
||||||
const checked = value.includes(diet.id);
|
const checked = value.includes(diet.id);
|
||||||
return (
|
return (
|
||||||
<label key={diet.id} className={checked ? "is-selected" : undefined}>
|
<CheckboxOption key={diet.id} checked={checked} onChange={() => toggle(diet.id)}>
|
||||||
<input type="checkbox" checked={checked} onChange={() => toggle(diet.id)} />
|
|
||||||
<span className="check-mark" aria-hidden="true" />
|
|
||||||
{diet.name}
|
{diet.name}
|
||||||
</label>
|
</CheckboxOption>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function FavoriteStarButton({
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
title={t(isFavorite ? "recipes.detail.unfavorite" : "recipes.detail.favorite")}
|
title={t(isFavorite ? "recipes.detail.unfavorite" : "recipes.detail.favorite")}
|
||||||
>
|
>
|
||||||
<FavoriteIcon />
|
<FavoriteIcon aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CheckboxOption } from "../../components/ui/Checkbox";
|
||||||
import { SettingsIcon } from "../../layouts/nav-icons";
|
import { SettingsIcon } from "../../layouts/nav-icons";
|
||||||
import { AllergenBadges } from "./AllergenBadges";
|
import { AllergenBadges } from "./AllergenBadges";
|
||||||
import { DietBadges } from "./DietBadges";
|
import { DietBadges } from "./DietBadges";
|
||||||
|
|
@ -101,28 +102,16 @@ export function IngredientPicker({
|
||||||
aria-expanded={isDisplayMenuOpen}
|
aria-expanded={isDisplayMenuOpen}
|
||||||
title={t("recipes.form.displayOptions")}
|
title={t("recipes.form.displayOptions")}
|
||||||
>
|
>
|
||||||
<SettingsIcon />
|
<SettingsIcon aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{isDisplayMenuOpen && (
|
{isDisplayMenuOpen && (
|
||||||
<div className="ingredient-picker__display-menu">
|
<div className="ingredient-picker__display-menu">
|
||||||
<label className={showDiets ? "is-selected" : ""}>
|
<CheckboxOption checked={showDiets} onChange={setShowDiets}>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showDiets}
|
|
||||||
onChange={(e) => setShowDiets(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="check-mark" aria-hidden="true" />
|
|
||||||
{t("recipes.form.showDietsLabel")}
|
{t("recipes.form.showDietsLabel")}
|
||||||
</label>
|
</CheckboxOption>
|
||||||
<label className={showAllergens ? "is-selected" : ""}>
|
<CheckboxOption checked={showAllergens} onChange={setShowAllergens}>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showAllergens}
|
|
||||||
onChange={(e) => setShowAllergens(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="check-mark" aria-hidden="true" />
|
|
||||||
{t("recipes.form.showAllergensLabel")}
|
{t("recipes.form.showAllergensLabel")}
|
||||||
</label>
|
</CheckboxOption>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { RecipeTab } from "@batch-cooking/shared";
|
import type { RecipeTab } from "@batch-cooking/shared";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons";
|
import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons";
|
||||||
import "./recipes.scss";
|
import "./recipes.scss";
|
||||||
|
|
||||||
/** Every functional tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
|
/** Every functional tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
|
||||||
const TABS: Array<{ value: RecipeTab; Icon: () => JSX.Element }> = [
|
const TABS: Array<{ value: RecipeTab; Icon: LucideIcon }> = [
|
||||||
{ value: "favoris", Icon: FavoriteIcon },
|
{ value: "favoris", Icon: FavoriteIcon },
|
||||||
{ value: "perso", Icon: AccountIcon },
|
{ value: "perso", Icon: AccountIcon },
|
||||||
{ value: "foyer", Icon: HouseholdIcon },
|
{ value: "foyer", Icon: HouseholdIcon },
|
||||||
|
|
@ -37,7 +38,7 @@ export function RecipeTabs({
|
||||||
className={`recipe-tabs__tab${value === active ? " active" : ""}`}
|
className={`recipe-tabs__tab${value === active ? " active" : ""}`}
|
||||||
onClick={() => onChange(value)}
|
onClick={() => onChange(value)}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon aria-hidden="true" />
|
||||||
{t(`recipes.tabs.${value}`)}
|
{t(`recipes.tabs.${value}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App version — a quiet diagnostic footnote below the account menu, not
|
||||||
|
// an interactive element (hence `aria-hidden` on the `<p>` in
|
||||||
|
// AppLayout.tsx). Hidden whenever space is at a premium — the icon-only
|
||||||
|
// rail and the mobile horizontal bar (see the `.collapsed` block and the
|
||||||
|
// `@media (max-width: 640px)` block below).
|
||||||
|
&__version {
|
||||||
|
margin: var(--space-xs) 0 0;
|
||||||
|
padding: 0 var(--space-sm);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Collapsed (icon-only rail) state -----------------------------------
|
// --- Collapsed (icon-only rail) state -----------------------------------
|
||||||
// A single class toggle on the root element — every nested rule below
|
// A single class toggle on the root element — every nested rule below
|
||||||
// just hides labels/chevrons and re-centers icons via CSS, no child
|
// just hides labels/chevrons and re-centers icons via CSS, no child
|
||||||
|
|
@ -282,6 +295,10 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-sidebar__version {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
// The popover would otherwise shrink to the icon rail's own width,
|
// The popover would otherwise shrink to the icon rail's own width,
|
||||||
// squashing "Mon compte"/"Se déconnecter" — give it a normal,
|
// squashing "Mon compte"/"Se déconnecter" — give it a normal,
|
||||||
// comfortable width instead, still anchored to the rail's left edge.
|
// comfortable width instead, still anchored to the rail's left edge.
|
||||||
|
|
@ -342,6 +359,11 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No room for this on a horizontal bar either.
|
||||||
|
&__version {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&__nav {
|
&__nav {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export function AppLayout() {
|
||||||
onClick={toggleCollapsed}
|
onClick={toggleCollapsed}
|
||||||
title={t(isCollapsed ? "layout.sidebar.expand" : "layout.sidebar.collapse")}
|
title={t(isCollapsed ? "layout.sidebar.expand" : "layout.sidebar.collapse")}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -102,7 +102,7 @@ export function AppLayout() {
|
||||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||||
title={t(`layout.nav.${key}`)}
|
title={t(`layout.nav.${key}`)}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon aria-hidden="true" />
|
||||||
<span className="label">{t(`layout.nav.${key}`)}</span>
|
<span className="label">{t(`layout.nav.${key}`)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
@ -110,6 +110,9 @@ export function AppLayout() {
|
||||||
|
|
||||||
<SettingsMenu />
|
<SettingsMenu />
|
||||||
<AccountMenu />
|
<AccountMenu />
|
||||||
|
<p className="app-sidebar__version" aria-hidden="true">
|
||||||
|
v{__APP_VERSION__}
|
||||||
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="app-content">
|
<main className="app-content">
|
||||||
|
|
@ -146,7 +149,7 @@ function SettingsMenu() {
|
||||||
title={t("layout.settings.toggle")}
|
title={t("layout.settings.toggle")}
|
||||||
>
|
>
|
||||||
<span className="app-sidebar__settings-toggle-left">
|
<span className="app-sidebar__settings-toggle-left">
|
||||||
<SettingsIcon />
|
<SettingsIcon aria-hidden="true" />
|
||||||
<span className="label">{t("layout.settings.toggle")}</span>
|
<span className="label">{t("layout.settings.toggle")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="chevron" aria-hidden="true">
|
<span className="chevron" aria-hidden="true">
|
||||||
|
|
@ -163,7 +166,7 @@ function SettingsMenu() {
|
||||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||||
title={t(`layout.settings.nav.${key}`)}
|
title={t(`layout.settings.nav.${key}`)}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon aria-hidden="true" />
|
||||||
<span className="label">{t(`layout.settings.nav.${key}`)}</span>
|
<span className="label">{t(`layout.settings.nav.${key}`)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,130 +1,27 @@
|
||||||
import type { ReactNode } from "react";
|
// Sidebar/nav icon set — thin, named re-exports of lucide-react glyphs
|
||||||
|
// rather than importing `lucide-react` directly in every consumer. Keeps a
|
||||||
// Small, hand-drawn line-icon set for the sidebar nav (24×24 viewBox,
|
// single place documenting "which glyph stands for which app concept"
|
||||||
// matches the reviewed mockup — see the plan/PR description) rather than
|
// (the mapping itself, decided once, isn't obvious from a bare `Settings`
|
||||||
// pulling in an icon library for a handful of glyphs. Sized entirely via
|
// or `Home` import) and keeps consumer diffs small if a glyph ever needs
|
||||||
// CSS (`.app-sidebar__nav svg` etc., see AppLayout.scss) — no width/height
|
// to change. Previously a hand-drawn custom SVG set (24×24, stroke-based,
|
||||||
// attribute here, so the same markup works at any size the caller picks.
|
// no fill) — lucide-react uses the same stroke-based "line icon" language
|
||||||
// `aria-hidden` on every icon: each one is always paired with visible text
|
// (default `strokeWidth={2}`, round caps/joins) so the switch is visually
|
||||||
// (the nav label, or a `title` tooltip when collapsed) that already
|
// a no-op, just swaps who draws the paths.
|
||||||
// conveys the meaning — the icon itself is decorative.
|
//
|
||||||
|
// Every one of these is always paired with visible text (the nav label, or
|
||||||
function Icon({ children }: { children: ReactNode }) {
|
// a `title` tooltip when the sidebar is collapsed) — purely decorative, so
|
||||||
return (
|
// every consumer passes `aria-hidden="true"` itself (unlike the old custom
|
||||||
<svg
|
// `Icon` wrapper, lucide doesn't set this by default).
|
||||||
viewBox="0 0 24 24"
|
export {
|
||||||
fill="none"
|
Calendar as PlanningIcon,
|
||||||
stroke="currentColor"
|
BookOpen as RecipesIcon,
|
||||||
strokeWidth="2"
|
ShoppingCart as ShoppingListIcon,
|
||||||
strokeLinecap="round"
|
Settings as SettingsIcon,
|
||||||
strokeLinejoin="round"
|
User as AccountIcon,
|
||||||
aria-hidden="true"
|
Leaf as DietPreferencesIcon,
|
||||||
>
|
Home as HouseholdIcon,
|
||||||
{children}
|
Palette as UserPreferencesIcon,
|
||||||
</svg>
|
ChevronLeft as ChevronLeftIcon,
|
||||||
);
|
Star as FavoriteIcon,
|
||||||
}
|
Globe as PublicIcon,
|
||||||
|
} from "lucide-react";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Favorites — the recipe catalog's "Favoris" tab (`RecipeTabs`) and the recipe detail panel's favorite toggle (`FavoriteStarButton`). */
|
|
||||||
export function FavoriteIcon() {
|
|
||||||
return (
|
|
||||||
<Icon>
|
|
||||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
||||||
</Icon>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Public recipes — the recipe catalog's "Publique" tab (`RecipeTabs`). */
|
|
||||||
export function PublicIcon() {
|
|
||||||
return (
|
|
||||||
<Icon>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M2 12h20" />
|
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
|
||||||
</Icon>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { ErrorCode, THEME_PREFERENCES, type ThemePreference } from "@batch-cooki
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
|
import { RadioOption } from "../../components/ui/Radio";
|
||||||
import { useTheme } from "../../features/theme/ThemeContext";
|
import { useTheme } from "../../features/theme/ThemeContext";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
import "./settings-pages.scss";
|
import "./settings-pages.scss";
|
||||||
|
|
@ -46,23 +47,16 @@ export function UserPreferencesPage() {
|
||||||
{THEME_PREFERENCES.map((option) => {
|
{THEME_PREFERENCES.map((option) => {
|
||||||
const checked = theme === option;
|
const checked = theme === option;
|
||||||
return (
|
return (
|
||||||
<label
|
<RadioOption
|
||||||
key={option}
|
key={option}
|
||||||
// `is-selected` (not a `:has(:checked)` CSS rule) drives the
|
name="theme"
|
||||||
// selected look — see AllergySelect.tsx for why this stays
|
value={option}
|
||||||
// in JS rather than pure CSS.
|
checked={checked}
|
||||||
className={`theme-select__option${checked ? " is-selected" : ""}`}
|
onChange={handleChange}
|
||||||
|
className="theme-select__option"
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="theme"
|
|
||||||
value={option}
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => handleChange(option)}
|
|
||||||
/>
|
|
||||||
<span className="check-mark" aria-hidden="true" />
|
|
||||||
{t(`userPreferences.theme.${option}`)}
|
{t(`userPreferences.theme.${option}`)}
|
||||||
</label>
|
</RadioOption>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,8 @@
|
||||||
// visually distinct, consistent "danger" treatment wherever they appear.
|
// visually distinct, consistent "danger" treatment wherever they appear.
|
||||||
.settings-page__danger-zone {
|
.settings-page__danger-zone {
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
border-color: var(--color-error);
|
border: 1.5px solid var(--color-error);
|
||||||
|
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-page__danger-button {
|
.settings-page__danger-button {
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,16 @@ h1 {
|
||||||
// `display: none`) and the whole label row it lives in becomes the
|
// `display: none`) and the whole label row it lives in becomes the
|
||||||
// interactive surface instead: a flat bordered box that fills in with a
|
// interactive surface instead: a flat bordered box that fills in with a
|
||||||
// tinted background + primary border once selected, with a checkmark
|
// tinted background + primary border once selected, with a checkmark
|
||||||
// fading in on the trailing edge.
|
// fading in on the leading edge.
|
||||||
//
|
//
|
||||||
// The base (unselected) look below is detected structurally with `:has()`
|
// The base (unselected) look below is detected structurally with `:has()`
|
||||||
// — safe, since "does this label contain a checkbox/radio" never changes
|
// — safe, since "does this label contain a checkbox/radio" never changes
|
||||||
// after mount. The *selected* look is instead driven by the `is-selected`
|
// after mount. The *selected* look is instead driven by the `is-selected`
|
||||||
// class each caller (AllergySelect.tsx, UserPreferencesPage.tsx) toggles
|
// class {@link CheckboxOption}/{@link RadioOption} (components/ui/) toggle
|
||||||
// in JS from the same boolean it already passes to `checked` — chaining a
|
// in JS from the same boolean their caller already passes to `checked` —
|
||||||
// second `:has(:checked)` to react to that live state turned out to be
|
// chaining a second `:has(:checked)` to react to that live state turned
|
||||||
// unreliable across browsers, so this only needs one always-true `:has()`.
|
// out to be unreliable across browsers, so this only needs one
|
||||||
|
// always-true `:has()`.
|
||||||
//
|
//
|
||||||
// Every checkbox/radio in the app goes through this one place (the allergy
|
// Every checkbox/radio in the app goes through this one place (the allergy
|
||||||
// grid, the theme picker, anywhere future) rather than each feature styling
|
// grid, the theme picker, anywhere future) rather than each feature styling
|
||||||
|
|
@ -126,15 +127,15 @@ input[type="radio"] {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The checkmark — a real element (see AllergySelect.tsx /
|
// The checkmark — a real element (see components/ui/Checkbox.tsx /
|
||||||
// UserPreferencesPage.tsx) shown via the same `is-selected` class as the
|
// Radio.tsx) shown via the same `is-selected` class as the label's own
|
||||||
// label's own look above, not a separate CSS-only trigger. Scaled in from
|
// look above, not a separate CSS-only trigger. Scaled in from nothing so
|
||||||
// nothing so toggling has a bit of motion. Same mark for both checkbox and
|
// toggling has a bit of motion. Same mark for both checkbox and radio: one
|
||||||
// radio: one consistent "selected" language app-wide rather than a
|
// consistent "selected" language app-wide rather than a checkmark here and
|
||||||
// checkmark here and a dot there.
|
// a dot there. Sits first in the row (before the label text, per DOM
|
||||||
|
// order) — a classic "control on the left" layout rather than trailing.
|
||||||
.check-mark {
|
.check-mark {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
|
||||||
width: 0.9rem;
|
width: 0.9rem;
|
||||||
height: 0.9rem;
|
height: 0.9rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
|
|
|
||||||
5
apps/web/src/vite-env.d.ts
vendored
Normal file
5
apps/web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Injected by `define` in vite.config.ts, sourced from package.json's
|
||||||
|
// version field — see AppLayout.tsx for where it's rendered.
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// Read once at config-eval time — cheaper than a plugin hook, and this file
|
||||||
|
// only ever runs in Node (dev server / build), never bundled into the app.
|
||||||
|
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8"));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
css: {
|
css: {
|
||||||
|
|
@ -13,4 +18,10 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Exposes package.json's version as a compile-time constant — see
|
||||||
|
// src/vite-env.d.ts for the matching ambient declaration, and
|
||||||
|
// AppLayout.tsx for where it's rendered.
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,9 @@ importers:
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.3.6
|
specifier: ^26.3.6
|
||||||
version: 26.3.6(typescript@5.9.3)
|
version: 26.3.6(typescript@5.9.3)
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^1.32.0
|
||||||
|
version: 1.32.0(react@18.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
|
@ -2098,6 +2101,11 @@ packages:
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz}
|
||||||
|
|
||||||
|
lucide-react@1.32.0:
|
||||||
|
resolution: {integrity: sha512-txX56hMFnRxPi1f9/nH69YN8uvAO6a7Y1KSWKjCDAtdD9+soEgmWuCt6iRm1pkxUZo2+YntSdsE1L6bIuKoY8Q==, tarball: https://registry.npmjs.org/lucide-react/-/lucide-react-1.32.0.tgz}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
luxon@3.7.2:
|
luxon@3.7.2:
|
||||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==, tarball: https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz}
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==, tarball: https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -4764,6 +4772,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lucide-react@1.32.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
luxon@3.7.2: {}
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
make-dir@3.1.0:
|
make-dir@3.1.0:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue