chore(web): session de polish global — version, checkbox, danger zone, icônes (#20)

* 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).

* feat(web): icônes d'ingrédients depuis foodiconpack.com + page de crédits

- Remplace 19 des 22 pictogrammes génériques d'ingrédients par des icônes
  curées du pack gratuit "Common ingredient icons"/"Common Utensils" de
  foodiconpack.com (CC BY 4.0) : carotte, pomme, basilic, bœuf, poulet,
  saumon, crevette, riz, pois chiches, amandes, lait, cheddar, œufs,
  cannelle, miel, huile d'olive, bière, marmite, sucre.
- BREAD/DOUGH/SPROUT restent en SVG custom : pas d'équivalent net dans le
  pack (packs "ingrédients"/"ustensiles"/"plats"/"boissons" vérifiés).
  Architecture inchangée : `icon` reste un enum de 22 valeurs partagées en
  base (pas de migration, pas de mapping par ingrédient — cf. le
  commentaire du fichier sur l'historique emoji→enum générique).
- Nouveau wrapper FilledIcon (fill="currentColor", viewBox 2048) à côté du
  wrapper Icon existant (stroke) — les deux stylent au même endroit via
  CSS, donc le mélange des 19+3 icônes reste visuellement homogène.
- Ajoute /parametres/credits (CreditsPage) créditant foodiconpack.com et
  liant la licence CC BY 4.0, requis par la licence des icônes utilisées ;
  nouvelle entrée de nav "Crédits" (icône lucide Info).

Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et
vérification visuelle (grille des 22 icônes dans le picker, page crédits).

* feat(web,api): zone dangereuse rouge, préférences élargies, onglet favoris par défaut, e2e recettes, catalogue en uid+i18n

- Zone dangereuse (compte) : le bouton "Supprimer mon compte" est rouge.
- Pages préférences/paramétrage : contenu centré et élargi (32rem -> 56rem)
  au lieu de coller à gauche sur un écran large.
- Page recettes : l'onglet "Favoris" est sélectionné par défaut.
- Ajout de apps/web/cypress/e2e/recipes.cy.ts (onglets, recherche, sélection
  master-detail, favori, suppression, lien nouvelle recette).
- Catalogue de référence (ingrédients/régimes/allergènes) : la colonne
  `name` (le libellé français, utilisé comme clé unique) devient `key`, un
  slug stable et opaque au sens produit (ex. "vegetarien", "boeuf_hache").
  Le libellé lui-même déménage entièrement côté client, dans
  apps/web/src/locales/fr/translation.json sous le namespace `catalog.*`,
  résolu via `t(\`catalog.ingredients.${key}\`)` etc. — même schéma que
  IngredientCategory/IngredientSubcategory. Migration Prisma
  (rename + backfill des ~456 lignes déjà seedées), seed/service/tests API
  et composants web mis à jour en conséquence.
  - apps/api/src/utils/slugify.ts + scripts/generate-catalog-i18n.ts
    (regénère le fichier de traduction depuis reference-seed-data.ts).
  - 102 tests Mocha + 32 scénarios Cucumber passent contre la base migrée.

Note : cypress run plante dans cet environnement (le processus GPU
Chromium/Electron crash même headless, indépendamment des flags) — les
recipes.cy.ts n'ont pas pu être exécutés ici ; vérifiés par lecture du code
source des composants visés et par un passage manuel dans le navigateur de
prévisualisation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(api): les uids du catalogue sont en anglais, pas des slugs français

reference-seed-data.ts reste rédigé en français (c'est juste le libellé
d'autoring, jamais stocké/exposé), mais la clé stable (`Diet.key`/
`Category.key`/`Ingredient.key`) qu'on en dérive doit elle-même être un
identifiant anglais, indépendant de la langue d'autoring — pas juste le
même texte français passé à slugify().

- apps/api/src/db/catalog-en-keys.ts : dictionnaire écrit à la main
  (label français -> clé anglaise) pour les 5 régimes, 14 allergènes et
  437 ingrédients ; getEnglishKey() lève une erreur explicite si un
  nouvel élément n'a pas encore d'entrée plutôt que de retomber sur un
  slug français silencieux.
- scripts/validate-catalog-en-keys.ts : vérifie que chaque diet/allergène/
  ingrédient de reference-seed-data.ts a une entrée, et que les clés
  anglaises résultantes sont uniques (437/437, 14/14, 5/5 — zéro manquant,
  zéro collision).
- reference-seed-data.ts et scripts/generate-catalog-i18n.ts utilisent
  désormais getEnglishKey() au lieu de slugify(nom français).
- Nouvelle migration (20260818193000_catalog_keys_to_english) qui
  remappe les lignes déjà seedées avec un slug français (par la migration
  précédente) vers leur clé anglaise définitive.
- apps/web/src/locales/fr/translation.json régénéré : catalog.* est
  maintenant indexé par clé anglaise ("vegetarian", "eggs",
  "ground_beef"...), toujours avec le libellé français en valeur.
- Tests/step-definitions mis à jour (getEnglishKey() au lieu de
  slugify()) ; 102 tests Mocha + 32 scénarios Cucumber passent contre la
  base migrée. Vérifié aussi en direct via GET /reference/diets et
  /reference/allergies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(web): crypto.randomUUID plante hors contexte sécurisé, empêchant d'associer un ingrédient

Écran noir + "TypeError: crypto.randomUUID is not a function" au clic sur
une carte d'ingrédient dans le formulaire de recette. crypto.randomUUID()
n'est défini que dans un "contexte sécurisé" (https, ou littéralement le
host "localhost") — il est absent sur une IP locale (test sur un vrai
appareil), dans une WebView Capacitor (l'enrobage mobile prévu pour cette
app), ou en http sur un vrai domaine. RecipeFormPage/StepListEditor s'en
servaient pour générer l'identité React (`key`) de chaque ligne
d'ingrédient/étape en brouillon.

- apps/web/src/lib/client-key.ts : remplace par un générateur qui ne
  touche jamais `crypto` — un compteur + Math.random suffit, cette valeur
  n'a besoin d'être unique que le temps de la session de rendu, jamais
  envoyée au serveur.
- apps/web/cypress/e2e/recipe-form.cy.ts : couvre l'association d'un
  ingrédient (recherche, sélection, exclusion du picker une fois
  sélectionné, retrait), la création et l'édition d'une recette, et un
  test de non-régression dédié qui supprime crypto.randomUUID avant le
  chargement de la page (comme le ferait un vrai contexte non sécurisé)
  pour vérifier que l'ajout de plusieurs ingrédients/étapes ne plante
  plus.

Vérifié en direct dans le navigateur de prévisualisation en supprimant
crypto.randomUUID à la main (reproduit le crash), puis en confirmant que
l'ajout d'ingrédient fonctionne à nouveau après le correctif. cypress run
ne peut toujours pas s'exécuter dans cet environnement (voir le commit
précédent) — non exécutés avec Cypress lui-même, mais vérifiés par
lecture des sélecteurs réels et rejoués à la main dans le navigateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(ci): corrige les specs Cypress cassées par le refactor uid+i18n, applique biome

- onboarding.cy.ts / preferences.cy.ts / recipes.cy.ts mockaient encore
  GET /reference/diets|allergies avec l'ancienne forme {id, name}. Depuis
  les deux derniers commits l'API renvoie {id, key} (uid anglais) et le
  composant résout le libellé via i18n (t(`catalog.diets.${key}`)) — avec
  key manquant, ça affichait littéralement "catalog.diets.undefined" au
  lieu de "Végétarien"/"Omnivore"/etc., faisant échouer cy.select()/
  cy.contains() dans ces 3 specs. Corrigé pour mocker {key: "vegetarian"},
  {key: "peanuts"}, etc.
- recipes.cy.ts : le test "shows a not-found message" utilisait le
  mauvais code d'erreur (4041 au lieu de ErrorCode.RECIPE_NOT_FOUND =
  4045), donc RecipeDetailPanel tombait dans son état d'erreur générique
  au lieu du message "Cette recette n'existe pas." — bug dans mon propre
  test, sans rapport avec le refactor.
- pnpm lint (biome) : les fichiers touchés par le refactor précédent
  avaient quelques soucis de formatage/tri d'imports (des sed multi-
  fichiers, pas d'édition via l'outil habituel) — corrigés par
  `biome check --write`.

Vérifié : ces 3 specs + recipe-form.cy.ts passent maintenant dans le job
CI GitHub Actions (Linux, Cypress s'y exécute réellement — contrairement
à cet environnement Windows sandboxé, voir les commits précédents) ; 102
tests Mocha + 32 scénarios Cucumber toujours au vert en local.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kyuno053 2026-08-18 20:52:12 +02:00 committed by GitHub
parent c38097f522
commit c34eaa89d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 3221 additions and 454 deletions

2
.gitignore vendored
View file

@ -149,4 +149,4 @@ Projet batch cooking.pdf
tmp-mockups/ tmp-mockups/
# IA # IA
.claude/ .claude/

View file

@ -1,5 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { Then, When } from "@cucumber/cucumber"; import { Then, When } from "@cucumber/cucumber";
import { getEnglishKey } from "../../src/db/catalog-en-keys.js";
import { prisma } from "../../src/db/prisma.js"; import { prisma } from "../../src/db/prisma.js";
import type { CustomWorld } from "../support/world.js"; import type { CustomWorld } from "../support/world.js";
@ -11,25 +12,32 @@ function splitNames(names: string): string[] {
.filter(Boolean); .filter(Boolean);
} }
/** Resolves allergen names (Category.name) to their Allergy id — see reference.service.ts for why the name lives on Category, not Allergy. */ /**
* Resolves allergen names (as written in a `.feature` file, e.g.
* "Arachides") to their Allergy id scenarios still name allergens by their
* French label for readability, so this slugifies before matching against
* `Category.key` (see reference.service.ts for why the key lives on
* Category, not Allergy).
*/
async function allergyIdsFor(names: string[]): Promise<number[]> { async function allergyIdsFor(names: string[]): Promise<number[]> {
const allergies = await prisma.allergy.findMany({ include: { category: true } }); const allergies = await prisma.allergy.findMany({ include: { category: true } });
return names.map((name) => { return names.map((name) => {
const match = allergies.find((allergy) => allergy.category.name === name); const key = getEnglishKey(name);
const match = allergies.find((allergy) => allergy.category.key === key);
if (!match) throw new Error(`No seeded allergen named "${name}"`); if (!match) throw new Error(`No seeded allergen named "${name}"`);
return match.id; return match.id;
}); });
} }
When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) { When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) {
const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey(dietName) } });
this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id }); this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id });
}); });
Then( Then(
"my profile's regime should be {string}", "my profile's regime should be {string}",
async function (this: CustomWorld, dietName: string) { async function (this: CustomWorld, dietName: string) {
const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey(dietName) } });
assert.equal(this.response.body.dietId, diet.id); assert.equal(this.response.body.dietId, diet.id);
}, },
); );

View file

@ -1,12 +1,20 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { Given, Then, When } from "@cucumber/cucumber"; import { Given, Then, When } from "@cucumber/cucumber";
import { getEnglishKey } from "../../src/db/catalog-en-keys.js";
import { prisma } from "../../src/db/prisma.js"; import { prisma } from "../../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js"; import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js";
import type { CustomWorld } from "../support/world.js"; import type { CustomWorld } from "../support/world.js";
/** Resolves a reference ingredient by its seeded name — every scenario below names an ingredient by its `reference-seed-data.ts` name, never a raw id. */ /**
* Resolves a reference ingredient by its seeded French name every
* scenario below names an ingredient by its `reference-seed-data.ts` name,
* never a raw id or its slug `key` directly, so this slugifies before
* matching.
*/
async function findIngredientId(name: string): Promise<number> { async function findIngredientId(name: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } }); const ingredient = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey(name) },
});
return ingredient.id; return ingredient.id;
} }
@ -55,10 +63,11 @@ Then(
"the created recipe should have ingredient {string} and step {string}", "the created recipe should have ingredient {string} and step {string}",
function (this: CustomWorld, ingredientName: string, step: string) { function (this: CustomWorld, ingredientName: string, step: string) {
const body = this.response.body as { const body = this.response.body as {
ingredients: Array<{ ingredient: { name: string } }>; ingredients: Array<{ ingredient: { key: string } }>;
steps: Array<{ description: string }>; steps: Array<{ description: string }>;
}; };
assert.ok(body.ingredients.some((line) => line.ingredient.name === ingredientName)); const expectedKey = getEnglishKey(ingredientName);
assert.ok(body.ingredients.some((line) => line.ingredient.key === expectedKey));
assert.ok(body.steps.some((s) => s.description === step)); assert.ok(body.steps.some((s) => s.description === step));
}, },
); );

View file

@ -1,11 +1,20 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { Then } from "@cucumber/cucumber"; import { Then } from "@cucumber/cucumber";
import { getEnglishKey } from "../../src/db/catalog-en-keys.js";
import type { CustomWorld } from "../support/world.js"; import type { CustomWorld } from "../support/world.js";
// The feature file still names an item by its French label, for
// readability — the response itself carries only the slug `key` (see
// reference.service.ts), so this slugifies the expected label before
// comparing.
Then( Then(
"the reference list response should include {string}", "the reference list response should include {string}",
function (this: CustomWorld, name: string) { function (this: CustomWorld, name: string) {
const names = (this.response.body as Array<{ name: string }>).map((item) => item.name); const keys = (this.response.body as Array<{ key: string }>).map((item) => item.key);
assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`); const expectedKey = getEnglishKey(name);
assert.ok(
keys.includes(expectedKey),
`expected ${JSON.stringify(keys)} to include "${expectedKey}"`,
);
}, },
); );

View file

@ -0,0 +1,480 @@
-- Renames the reference-data label column to a stable slug key on Diet,
-- Category (allergens), and Ingredient — see `utils/slugify.ts` and
-- `scripts/generate-catalog-i18n.ts`. The display label these columns used
-- to hold moves to `apps/web`'s `locales/fr/translation.json` (`catalog.*`
-- namespace) instead.
ALTER TABLE "diet" RENAME COLUMN "name" TO "key";
ALTER INDEX "diet_name_key" RENAME TO "diet_key_key";
ALTER TABLE "category" RENAME COLUMN "name" TO "key";
ALTER INDEX "category_name_key" RENAME TO "category_key_key";
ALTER TABLE "ingredients" RENAME COLUMN "name" TO "key";
ALTER INDEX "ingredients_name_key" RENAME TO "ingredients_key_key";
-- Auto-generated by scripts/generate-catalog-i18n.ts — do not hand-edit.
-- Backfills the "key" column (just renamed from "name" by this migration's
-- preceding statement, so it still holds the old French label) to its slug
-- value, for every row already seeded in a database this migration runs
-- against. A fresh database has none of these rows yet (the seed script
-- inserts by "key" from the start), so this is a no-op there.
UPDATE "diet" SET "key" = 'omnivore' WHERE "key" = 'Omnivore';
UPDATE "diet" SET "key" = 'vegetarien' WHERE "key" = 'Végétarien';
UPDATE "diet" SET "key" = 'vegan' WHERE "key" = 'Végan';
UPDATE "diet" SET "key" = 'pescetarien' WHERE "key" = 'Pescétarien';
UPDATE "diet" SET "key" = 'sans_gluten' WHERE "key" = 'Sans gluten';
UPDATE "category" SET "key" = 'gluten' WHERE "key" = 'Gluten';
UPDATE "category" SET "key" = 'crustaces' WHERE "key" = 'Crustacés';
UPDATE "category" SET "key" = 'oeufs' WHERE "key" = 'Œufs';
UPDATE "category" SET "key" = 'poissons' WHERE "key" = 'Poissons';
UPDATE "category" SET "key" = 'arachides' WHERE "key" = 'Arachides';
UPDATE "category" SET "key" = 'soja' WHERE "key" = 'Soja';
UPDATE "category" SET "key" = 'lait' WHERE "key" = 'Lait';
UPDATE "category" SET "key" = 'fruits_a_coque' WHERE "key" = 'Fruits à coque';
UPDATE "category" SET "key" = 'celeri' WHERE "key" = 'Céleri';
UPDATE "category" SET "key" = 'moutarde' WHERE "key" = 'Moutarde';
UPDATE "category" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame';
UPDATE "category" SET "key" = 'sulfites' WHERE "key" = 'Sulfites';
UPDATE "category" SET "key" = 'lupin' WHERE "key" = 'Lupin';
UPDATE "category" SET "key" = 'mollusques' WHERE "key" = 'Mollusques';
UPDATE "ingredients" SET "key" = 'tomate' WHERE "key" = 'Tomate';
UPDATE "ingredients" SET "key" = 'oignon' WHERE "key" = 'Oignon';
UPDATE "ingredients" SET "key" = 'echalote' WHERE "key" = 'Échalote';
UPDATE "ingredients" SET "key" = 'ail' WHERE "key" = 'Ail';
UPDATE "ingredients" SET "key" = 'carotte' WHERE "key" = 'Carotte';
UPDATE "ingredients" SET "key" = 'courgette' WHERE "key" = 'Courgette';
UPDATE "ingredients" SET "key" = 'concombre' WHERE "key" = 'Concombre';
UPDATE "ingredients" SET "key" = 'cornichons' WHERE "key" = 'Cornichons';
UPDATE "ingredients" SET "key" = 'poivron' WHERE "key" = 'Poivron';
UPDATE "ingredients" SET "key" = 'champignon' WHERE "key" = 'Champignon';
UPDATE "ingredients" SET "key" = 'cepes' WHERE "key" = 'Cèpes';
UPDATE "ingredients" SET "key" = 'aubergine' WHERE "key" = 'Aubergine';
UPDATE "ingredients" SET "key" = 'brocoli' WHERE "key" = 'Brocoli';
UPDATE "ingredients" SET "key" = 'chou_fleur' WHERE "key" = 'Chou-fleur';
UPDATE "ingredients" SET "key" = 'chou_blanc' WHERE "key" = 'Chou blanc';
UPDATE "ingredients" SET "key" = 'chou_rouge' WHERE "key" = 'Chou rouge';
UPDATE "ingredients" SET "key" = 'chou_de_bruxelles' WHERE "key" = 'Chou de Bruxelles';
UPDATE "ingredients" SET "key" = 'epinard' WHERE "key" = 'Épinard';
UPDATE "ingredients" SET "key" = 'blette' WHERE "key" = 'Blette';
UPDATE "ingredients" SET "key" = 'salade' WHERE "key" = 'Salade';
UPDATE "ingredients" SET "key" = 'roquette' WHERE "key" = 'Roquette';
UPDATE "ingredients" SET "key" = 'cresson' WHERE "key" = 'Cresson';
UPDATE "ingredients" SET "key" = 'poireau' WHERE "key" = 'Poireau';
UPDATE "ingredients" SET "key" = 'celeri' WHERE "key" = 'Céleri';
UPDATE "ingredients" SET "key" = 'radis' WHERE "key" = 'Radis';
UPDATE "ingredients" SET "key" = 'betterave' WHERE "key" = 'Betterave';
UPDATE "ingredients" SET "key" = 'navet' WHERE "key" = 'Navet';
UPDATE "ingredients" SET "key" = 'panais' WHERE "key" = 'Panais';
UPDATE "ingredients" SET "key" = 'haricot_vert' WHERE "key" = 'Haricot vert';
UPDATE "ingredients" SET "key" = 'petit_pois' WHERE "key" = 'Petit pois';
UPDATE "ingredients" SET "key" = 'mais' WHERE "key" = 'Maïs';
UPDATE "ingredients" SET "key" = 'artichaut' WHERE "key" = 'Artichaut';
UPDATE "ingredients" SET "key" = 'fenouil' WHERE "key" = 'Fenouil';
UPDATE "ingredients" SET "key" = 'endive' WHERE "key" = 'Endive';
UPDATE "ingredients" SET "key" = 'potiron' WHERE "key" = 'Potiron';
UPDATE "ingredients" SET "key" = 'butternut' WHERE "key" = 'Butternut';
UPDATE "ingredients" SET "key" = 'asperge' WHERE "key" = 'Asperge';
UPDATE "ingredients" SET "key" = 'avocat' WHERE "key" = 'Avocat';
UPDATE "ingredients" SET "key" = 'pomme_de_terre' WHERE "key" = 'Pomme de terre';
UPDATE "ingredients" SET "key" = 'patate_douce' WHERE "key" = 'Patate douce';
UPDATE "ingredients" SET "key" = 'tomates_cerises' WHERE "key" = 'Tomates cerises';
UPDATE "ingredients" SET "key" = 'pak_choi' WHERE "key" = 'Pak-choï';
UPDATE "ingredients" SET "key" = 'germes_de_soja' WHERE "key" = 'Germes de soja';
UPDATE "ingredients" SET "key" = 'shiitake' WHERE "key" = 'Shiitake';
UPDATE "ingredients" SET "key" = 'daikon' WHERE "key" = 'Daikon';
UPDATE "ingredients" SET "key" = 'piment_vert_frais' WHERE "key" = 'Piment vert frais';
UPDATE "ingredients" SET "key" = 'citron' WHERE "key" = 'Citron';
UPDATE "ingredients" SET "key" = 'citron_vert' WHERE "key" = 'Citron vert';
UPDATE "ingredients" SET "key" = 'pomme' WHERE "key" = 'Pomme';
UPDATE "ingredients" SET "key" = 'poire' WHERE "key" = 'Poire';
UPDATE "ingredients" SET "key" = 'banane' WHERE "key" = 'Banane';
UPDATE "ingredients" SET "key" = 'orange' WHERE "key" = 'Orange';
UPDATE "ingredients" SET "key" = 'clementine' WHERE "key" = 'Clémentine';
UPDATE "ingredients" SET "key" = 'pamplemousse' WHERE "key" = 'Pamplemousse';
UPDATE "ingredients" SET "key" = 'fraise' WHERE "key" = 'Fraise';
UPDATE "ingredients" SET "key" = 'framboise' WHERE "key" = 'Framboise';
UPDATE "ingredients" SET "key" = 'myrtille' WHERE "key" = 'Myrtille';
UPDATE "ingredients" SET "key" = 'mure' WHERE "key" = 'Mûre';
UPDATE "ingredients" SET "key" = 'cerise' WHERE "key" = 'Cerise';
UPDATE "ingredients" SET "key" = 'abricot' WHERE "key" = 'Abricot';
UPDATE "ingredients" SET "key" = 'peche' WHERE "key" = 'Pêche';
UPDATE "ingredients" SET "key" = 'prune' WHERE "key" = 'Prune';
UPDATE "ingredients" SET "key" = 'raisin' WHERE "key" = 'Raisin';
UPDATE "ingredients" SET "key" = 'melon' WHERE "key" = 'Melon';
UPDATE "ingredients" SET "key" = 'pasteque' WHERE "key" = 'Pastèque';
UPDATE "ingredients" SET "key" = 'ananas' WHERE "key" = 'Ananas';
UPDATE "ingredients" SET "key" = 'mangue' WHERE "key" = 'Mangue';
UPDATE "ingredients" SET "key" = 'kiwi' WHERE "key" = 'Kiwi';
UPDATE "ingredients" SET "key" = 'figue' WHERE "key" = 'Figue';
UPDATE "ingredients" SET "key" = 'datte' WHERE "key" = 'Datte';
UPDATE "ingredients" SET "key" = 'litchi' WHERE "key" = 'Litchi';
UPDATE "ingredients" SET "key" = 'grenade' WHERE "key" = 'Grenade';
UPDATE "ingredients" SET "key" = 'rhubarbe' WHERE "key" = 'Rhubarbe';
UPDATE "ingredients" SET "key" = 'coing' WHERE "key" = 'Coing';
UPDATE "ingredients" SET "key" = 'basilic' WHERE "key" = 'Basilic';
UPDATE "ingredients" SET "key" = 'persil' WHERE "key" = 'Persil';
UPDATE "ingredients" SET "key" = 'thym' WHERE "key" = 'Thym';
UPDATE "ingredients" SET "key" = 'romarin' WHERE "key" = 'Romarin';
UPDATE "ingredients" SET "key" = 'laurier' WHERE "key" = 'Laurier';
UPDATE "ingredients" SET "key" = 'ciboulette' WHERE "key" = 'Ciboulette';
UPDATE "ingredients" SET "key" = 'coriandre_fraiche' WHERE "key" = 'Coriandre fraîche';
UPDATE "ingredients" SET "key" = 'menthe' WHERE "key" = 'Menthe';
UPDATE "ingredients" SET "key" = 'origan' WHERE "key" = 'Origan';
UPDATE "ingredients" SET "key" = 'aneth' WHERE "key" = 'Aneth';
UPDATE "ingredients" SET "key" = 'estragon' WHERE "key" = 'Estragon';
UPDATE "ingredients" SET "key" = 'sarriette' WHERE "key" = 'Sarriette';
UPDATE "ingredients" SET "key" = 'marjolaine' WHERE "key" = 'Marjolaine';
UPDATE "ingredients" SET "key" = 'sauge' WHERE "key" = 'Sauge';
UPDATE "ingredients" SET "key" = 'cerfeuil' WHERE "key" = 'Cerfeuil';
UPDATE "ingredients" SET "key" = 'gingembre' WHERE "key" = 'Gingembre';
UPDATE "ingredients" SET "key" = 'citronnelle' WHERE "key" = 'Citronnelle';
UPDATE "ingredients" SET "key" = 'combava' WHERE "key" = 'Combava';
UPDATE "ingredients" SET "key" = 'lapin' WHERE "key" = 'Lapin';
UPDATE "ingredients" SET "key" = 'boeuf_hache' WHERE "key" = 'Bœuf haché';
UPDATE "ingredients" SET "key" = 'steak_de_boeuf' WHERE "key" = 'Steak de bœuf';
UPDATE "ingredients" SET "key" = 'roti_de_boeuf' WHERE "key" = 'Rôti de bœuf';
UPDATE "ingredients" SET "key" = 'escalope_de_veau' WHERE "key" = 'Escalope de veau';
UPDATE "ingredients" SET "key" = 'filet_mignon_de_porc' WHERE "key" = 'Filet mignon de porc';
UPDATE "ingredients" SET "key" = 'cote_de_porc' WHERE "key" = 'Côte de porc';
UPDATE "ingredients" SET "key" = 'agneau' WHERE "key" = 'Agneau';
UPDATE "ingredients" SET "key" = 'gigot_d_agneau' WHERE "key" = 'Gigot d''agneau';
UPDATE "ingredients" SET "key" = 'lardons' WHERE "key" = 'Lardons';
UPDATE "ingredients" SET "key" = 'bacon' WHERE "key" = 'Bacon';
UPDATE "ingredients" SET "key" = 'jambon_blanc' WHERE "key" = 'Jambon blanc';
UPDATE "ingredients" SET "key" = 'jambon_cru' WHERE "key" = 'Jambon cru';
UPDATE "ingredients" SET "key" = 'saucisse' WHERE "key" = 'Saucisse';
UPDATE "ingredients" SET "key" = 'chorizo' WHERE "key" = 'Chorizo';
UPDATE "ingredients" SET "key" = 'merguez' WHERE "key" = 'Merguez';
UPDATE "ingredients" SET "key" = 'prosciutto' WHERE "key" = 'Prosciutto';
UPDATE "ingredients" SET "key" = 'pancetta' WHERE "key" = 'Pancetta';
UPDATE "ingredients" SET "key" = 'mortadelle' WHERE "key" = 'Mortadelle';
UPDATE "ingredients" SET "key" = 'salami' WHERE "key" = 'Salami';
UPDATE "ingredients" SET "key" = 'poulet' WHERE "key" = 'Poulet';
UPDATE "ingredients" SET "key" = 'dinde' WHERE "key" = 'Dinde';
UPDATE "ingredients" SET "key" = 'canard' WHERE "key" = 'Canard';
UPDATE "ingredients" SET "key" = 'magret_de_canard' WHERE "key" = 'Magret de canard';
UPDATE "ingredients" SET "key" = 'saumon' WHERE "key" = 'Saumon';
UPDATE "ingredients" SET "key" = 'thon' WHERE "key" = 'Thon';
UPDATE "ingredients" SET "key" = 'cabillaud' WHERE "key" = 'Cabillaud';
UPDATE "ingredients" SET "key" = 'truite' WHERE "key" = 'Truite';
UPDATE "ingredients" SET "key" = 'sardine' WHERE "key" = 'Sardine';
UPDATE "ingredients" SET "key" = 'anchois' WHERE "key" = 'Anchois';
UPDATE "ingredients" SET "key" = 'merlan' WHERE "key" = 'Merlan';
UPDATE "ingredients" SET "key" = 'surimi' WHERE "key" = 'Surimi';
UPDATE "ingredients" SET "key" = 'bar_loup_de_mer' WHERE "key" = 'Bar (loup de mer)';
UPDATE "ingredients" SET "key" = 'dorade' WHERE "key" = 'Dorade';
UPDATE "ingredients" SET "key" = 'sole' WHERE "key" = 'Sole';
UPDATE "ingredients" SET "key" = 'turbot' WHERE "key" = 'Turbot';
UPDATE "ingredients" SET "key" = 'merlu' WHERE "key" = 'Merlu';
UPDATE "ingredients" SET "key" = 'colin' WHERE "key" = 'Colin';
UPDATE "ingredients" SET "key" = 'lieu_noir' WHERE "key" = 'Lieu noir';
UPDATE "ingredients" SET "key" = 'eglefin' WHERE "key" = 'Églefin';
UPDATE "ingredients" SET "key" = 'maquereau' WHERE "key" = 'Maquereau';
UPDATE "ingredients" SET "key" = 'hareng' WHERE "key" = 'Hareng';
UPDATE "ingredients" SET "key" = 'rouget' WHERE "key" = 'Rouget';
UPDATE "ingredients" SET "key" = 'raie' WHERE "key" = 'Raie';
UPDATE "ingredients" SET "key" = 'lotte' WHERE "key" = 'Lotte';
UPDATE "ingredients" SET "key" = 'fletan' WHERE "key" = 'Flétan';
UPDATE "ingredients" SET "key" = 'espadon' WHERE "key" = 'Espadon';
UPDATE "ingredients" SET "key" = 'carpe' WHERE "key" = 'Carpe';
UPDATE "ingredients" SET "key" = 'brochet' WHERE "key" = 'Brochet';
UPDATE "ingredients" SET "key" = 'perche' WHERE "key" = 'Perche';
UPDATE "ingredients" SET "key" = 'tilapia' WHERE "key" = 'Tilapia';
UPDATE "ingredients" SET "key" = 'panga' WHERE "key" = 'Panga';
UPDATE "ingredients" SET "key" = 'saumon_fume' WHERE "key" = 'Saumon fumé';
UPDATE "ingredients" SET "key" = 'poisson_seche' WHERE "key" = 'Poisson séché';
UPDATE "ingredients" SET "key" = 'crevettes' WHERE "key" = 'Crevettes';
UPDATE "ingredients" SET "key" = 'langoustines' WHERE "key" = 'Langoustines';
UPDATE "ingredients" SET "key" = 'homard' WHERE "key" = 'Homard';
UPDATE "ingredients" SET "key" = 'crabe' WHERE "key" = 'Crabe';
UPDATE "ingredients" SET "key" = 'langouste' WHERE "key" = 'Langouste';
UPDATE "ingredients" SET "key" = 'moules' WHERE "key" = 'Moules';
UPDATE "ingredients" SET "key" = 'huitres' WHERE "key" = 'Huîtres';
UPDATE "ingredients" SET "key" = 'saint_jacques' WHERE "key" = 'Saint-Jacques';
UPDATE "ingredients" SET "key" = 'calamar' WHERE "key" = 'Calamar';
UPDATE "ingredients" SET "key" = 'poulpe' WHERE "key" = 'Poulpe';
UPDATE "ingredients" SET "key" = 'palourdes' WHERE "key" = 'Palourdes';
UPDATE "ingredients" SET "key" = 'bulots' WHERE "key" = 'Bulots';
UPDATE "ingredients" SET "key" = 'semoule' WHERE "key" = 'Semoule';
UPDATE "ingredients" SET "key" = 'couscous' WHERE "key" = 'Couscous';
UPDATE "ingredients" SET "key" = 'boulgour' WHERE "key" = 'Boulgour';
UPDATE "ingredients" SET "key" = 'polenta' WHERE "key" = 'Polenta';
UPDATE "ingredients" SET "key" = 'quinoa' WHERE "key" = 'Quinoa';
UPDATE "ingredients" SET "key" = 'pates' WHERE "key" = 'Pâtes';
UPDATE "ingredients" SET "key" = 'pates_completes' WHERE "key" = 'Pâtes complètes';
UPDATE "ingredients" SET "key" = 'riz' WHERE "key" = 'Riz';
UPDATE "ingredients" SET "key" = 'riz_basmati' WHERE "key" = 'Riz basmati';
UPDATE "ingredients" SET "key" = 'riz_complet' WHERE "key" = 'Riz complet';
UPDATE "ingredients" SET "key" = 'flocons_d_avoine' WHERE "key" = 'Flocons d''avoine';
UPDATE "ingredients" SET "key" = 'spaghetti' WHERE "key" = 'Spaghetti';
UPDATE "ingredients" SET "key" = 'penne' WHERE "key" = 'Penne';
UPDATE "ingredients" SET "key" = 'tagliatelles' WHERE "key" = 'Tagliatelles';
UPDATE "ingredients" SET "key" = 'lasagnes_feuilles' WHERE "key" = 'Lasagnes (feuilles)';
UPDATE "ingredients" SET "key" = 'gnocchi' WHERE "key" = 'Gnocchi';
UPDATE "ingredients" SET "key" = 'riz_arborio' WHERE "key" = 'Riz arborio';
UPDATE "ingredients" SET "key" = 'nouilles_de_riz' WHERE "key" = 'Nouilles de riz';
UPDATE "ingredients" SET "key" = 'nouilles_udon' WHERE "key" = 'Nouilles udon';
UPDATE "ingredients" SET "key" = 'nouilles_soba' WHERE "key" = 'Nouilles soba';
UPDATE "ingredients" SET "key" = 'nouilles_chinoises' WHERE "key" = 'Nouilles chinoises';
UPDATE "ingredients" SET "key" = 'vermicelles_de_riz' WHERE "key" = 'Vermicelles de riz';
UPDATE "ingredients" SET "key" = 'vermicelles_de_soja' WHERE "key" = 'Vermicelles de soja';
UPDATE "ingredients" SET "key" = 'riz_gluant' WHERE "key" = 'Riz gluant';
UPDATE "ingredients" SET "key" = 'riz_a_sushi' WHERE "key" = 'Riz à sushi';
UPDATE "ingredients" SET "key" = 'riz_jasmin' WHERE "key" = 'Riz jasmin';
UPDATE "ingredients" SET "key" = 'lentilles_vertes' WHERE "key" = 'Lentilles vertes';
UPDATE "ingredients" SET "key" = 'lentilles_corail' WHERE "key" = 'Lentilles corail';
UPDATE "ingredients" SET "key" = 'pois_chiches' WHERE "key" = 'Pois chiches';
UPDATE "ingredients" SET "key" = 'haricots_blancs' WHERE "key" = 'Haricots blancs';
UPDATE "ingredients" SET "key" = 'haricots_rouges' WHERE "key" = 'Haricots rouges';
UPDATE "ingredients" SET "key" = 'haricots_noirs' WHERE "key" = 'Haricots noirs';
UPDATE "ingredients" SET "key" = 'pois_casses' WHERE "key" = 'Pois cassés';
UPDATE "ingredients" SET "key" = 'feves' WHERE "key" = 'Fèves';
UPDATE "ingredients" SET "key" = 'edamame' WHERE "key" = 'Edamame';
UPDATE "ingredients" SET "key" = 'haricots_pinto' WHERE "key" = 'Haricots pinto';
UPDATE "ingredients" SET "key" = 'cacahuetes' WHERE "key" = 'Cacahuètes';
UPDATE "ingredients" SET "key" = 'amandes' WHERE "key" = 'Amandes';
UPDATE "ingredients" SET "key" = 'noix' WHERE "key" = 'Noix';
UPDATE "ingredients" SET "key" = 'noisettes' WHERE "key" = 'Noisettes';
UPDATE "ingredients" SET "key" = 'noix_de_cajou' WHERE "key" = 'Noix de cajou';
UPDATE "ingredients" SET "key" = 'pistaches' WHERE "key" = 'Pistaches';
UPDATE "ingredients" SET "key" = 'noix_de_pecan' WHERE "key" = 'Noix de pécan';
UPDATE "ingredients" SET "key" = 'poudre_d_amande' WHERE "key" = 'Poudre d''amande';
UPDATE "ingredients" SET "key" = 'pignons_de_pin' WHERE "key" = 'Pignons de pin';
UPDATE "ingredients" SET "key" = 'graines_de_tournesol' WHERE "key" = 'Graines de tournesol';
UPDATE "ingredients" SET "key" = 'graines_de_courge' WHERE "key" = 'Graines de courge';
UPDATE "ingredients" SET "key" = 'noix_de_coco_rapee' WHERE "key" = 'Noix de coco râpée';
UPDATE "ingredients" SET "key" = 'raisins_secs' WHERE "key" = 'Raisins secs';
UPDATE "ingredients" SET "key" = 'pruneaux' WHERE "key" = 'Pruneaux';
UPDATE "ingredients" SET "key" = 'abricots_secs' WHERE "key" = 'Abricots secs';
UPDATE "ingredients" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame';
UPDATE "ingredients" SET "key" = 'champignons_noirs' WHERE "key" = 'Champignons noirs';
UPDATE "ingredients" SET "key" = 'algue_nori' WHERE "key" = 'Algue nori';
UPDATE "ingredients" SET "key" = 'algue_wakame' WHERE "key" = 'Algue wakamé';
UPDATE "ingredients" SET "key" = 'algue_kombu' WHERE "key" = 'Algue kombu';
UPDATE "ingredients" SET "key" = 'pousses_de_bambou' WHERE "key" = 'Pousses de bambou';
UPDATE "ingredients" SET "key" = 'chataignes_d_eau' WHERE "key" = 'Châtaignes d''eau';
UPDATE "ingredients" SET "key" = 'pain' WHERE "key" = 'Pain';
UPDATE "ingredients" SET "key" = 'pain_de_mie' WHERE "key" = 'Pain de mie';
UPDATE "ingredients" SET "key" = 'pain_complet' WHERE "key" = 'Pain complet';
UPDATE "ingredients" SET "key" = 'baguette' WHERE "key" = 'Baguette';
UPDATE "ingredients" SET "key" = 'pain_de_seigle' WHERE "key" = 'Pain de seigle';
UPDATE "ingredients" SET "key" = 'chapelure' WHERE "key" = 'Chapelure';
UPDATE "ingredients" SET "key" = 'pain_a_burger' WHERE "key" = 'Pain à burger';
UPDATE "ingredients" SET "key" = 'pain_brioche' WHERE "key" = 'Pain brioché';
UPDATE "ingredients" SET "key" = 'pain_a_hot_dog' WHERE "key" = 'Pain à hot-dog';
UPDATE "ingredients" SET "key" = 'pain_pita' WHERE "key" = 'Pain pita';
UPDATE "ingredients" SET "key" = 'pain_bagel' WHERE "key" = 'Pain bagel';
UPDATE "ingredients" SET "key" = 'naan' WHERE "key" = 'Naan';
UPDATE "ingredients" SET "key" = 'pain_wrap' WHERE "key" = 'Pain wrap';
UPDATE "ingredients" SET "key" = 'pain_viennois' WHERE "key" = 'Pain viennois';
UPDATE "ingredients" SET "key" = 'pain_de_campagne' WHERE "key" = 'Pain de campagne';
UPDATE "ingredients" SET "key" = 'pain_aux_cereales' WHERE "key" = 'Pain aux céréales';
UPDATE "ingredients" SET "key" = 'petit_pain' WHERE "key" = 'Petit pain';
UPDATE "ingredients" SET "key" = 'pain_suedois' WHERE "key" = 'Pain suédois';
UPDATE "ingredients" SET "key" = 'pain_sans_gluten' WHERE "key" = 'Pain sans gluten';
UPDATE "ingredients" SET "key" = 'biscotte' WHERE "key" = 'Biscotte';
UPDATE "ingredients" SET "key" = 'croutons' WHERE "key" = 'Croûtons';
UPDATE "ingredients" SET "key" = 'focaccia' WHERE "key" = 'Focaccia';
UPDATE "ingredients" SET "key" = 'ciabatta' WHERE "key" = 'Ciabatta';
UPDATE "ingredients" SET "key" = 'tortilla_de_mais' WHERE "key" = 'Tortilla de maïs';
UPDATE "ingredients" SET "key" = 'tortilla_de_ble' WHERE "key" = 'Tortilla de blé';
UPDATE "ingredients" SET "key" = 'pate_feuilletee' WHERE "key" = 'Pâte feuilletée';
UPDATE "ingredients" SET "key" = 'pate_brisee' WHERE "key" = 'Pâte brisée';
UPDATE "ingredients" SET "key" = 'pate_a_pizza' WHERE "key" = 'Pâte à pizza';
UPDATE "ingredients" SET "key" = 'pate_a_tarte_sablee' WHERE "key" = 'Pâte à tarte sablée';
UPDATE "ingredients" SET "key" = 'lait' WHERE "key" = 'Lait';
UPDATE "ingredients" SET "key" = 'beurre' WHERE "key" = 'Beurre';
UPDATE "ingredients" SET "key" = 'creme_fraiche' WHERE "key" = 'Crème fraîche';
UPDATE "ingredients" SET "key" = 'creme_liquide' WHERE "key" = 'Crème liquide';
UPDATE "ingredients" SET "key" = 'fromage' WHERE "key" = 'Fromage';
UPDATE "ingredients" SET "key" = 'emmental' WHERE "key" = 'Emmental';
UPDATE "ingredients" SET "key" = 'gruyere' WHERE "key" = 'Gruyère';
UPDATE "ingredients" SET "key" = 'parmesan' WHERE "key" = 'Parmesan';
UPDATE "ingredients" SET "key" = 'mozzarella' WHERE "key" = 'Mozzarella';
UPDATE "ingredients" SET "key" = 'chevre_fromage' WHERE "key" = 'Chèvre (fromage)';
UPDATE "ingredients" SET "key" = 'feta' WHERE "key" = 'Feta';
UPDATE "ingredients" SET "key" = 'comte' WHERE "key" = 'Comté';
UPDATE "ingredients" SET "key" = 'fromage_blanc' WHERE "key" = 'Fromage blanc';
UPDATE "ingredients" SET "key" = 'mascarpone' WHERE "key" = 'Mascarpone';
UPDATE "ingredients" SET "key" = 'yaourt' WHERE "key" = 'Yaourt';
UPDATE "ingredients" SET "key" = 'burrata' WHERE "key" = 'Burrata';
UPDATE "ingredients" SET "key" = 'ricotta' WHERE "key" = 'Ricotta';
UPDATE "ingredients" SET "key" = 'pecorino' WHERE "key" = 'Pecorino';
UPDATE "ingredients" SET "key" = 'gorgonzola' WHERE "key" = 'Gorgonzola';
UPDATE "ingredients" SET "key" = 'cheddar' WHERE "key" = 'Cheddar';
UPDATE "ingredients" SET "key" = 'oeuf' WHERE "key" = 'Œuf';
UPDATE "ingredients" SET "key" = 'lait_de_coco' WHERE "key" = 'Lait de coco';
UPDATE "ingredients" SET "key" = 'creme_de_coco' WHERE "key" = 'Crème de coco';
UPDATE "ingredients" SET "key" = 'lait_d_amande' WHERE "key" = 'Lait d''amande';
UPDATE "ingredients" SET "key" = 'lait_d_avoine' WHERE "key" = 'Lait d''avoine';
UPDATE "ingredients" SET "key" = 'tofu' WHERE "key" = 'Tofu';
UPDATE "ingredients" SET "key" = 'tofu_soyeux' WHERE "key" = 'Tofu soyeux';
UPDATE "ingredients" SET "key" = 'herbes_de_provence' WHERE "key" = 'Herbes de Provence';
UPDATE "ingredients" SET "key" = 'poivre_noir' WHERE "key" = 'Poivre noir';
UPDATE "ingredients" SET "key" = 'paprika' WHERE "key" = 'Paprika';
UPDATE "ingredients" SET "key" = 'piment_d_espelette' WHERE "key" = 'Piment d''Espelette';
UPDATE "ingredients" SET "key" = 'piment_de_cayenne' WHERE "key" = 'Piment de Cayenne';
UPDATE "ingredients" SET "key" = 'cumin' WHERE "key" = 'Cumin';
UPDATE "ingredients" SET "key" = 'curry_poudre' WHERE "key" = 'Curry (poudre)';
UPDATE "ingredients" SET "key" = 'curcuma' WHERE "key" = 'Curcuma';
UPDATE "ingredients" SET "key" = 'cannelle' WHERE "key" = 'Cannelle';
UPDATE "ingredients" SET "key" = 'muscade' WHERE "key" = 'Muscade';
UPDATE "ingredients" SET "key" = 'safran' WHERE "key" = 'Safran';
UPDATE "ingredients" SET "key" = 'clou_de_girofle' WHERE "key" = 'Clou de girofle';
UPDATE "ingredients" SET "key" = 'vanille_gousse' WHERE "key" = 'Vanille (gousse)';
UPDATE "ingredients" SET "key" = 'poivre_blanc' WHERE "key" = 'Poivre blanc';
UPDATE "ingredients" SET "key" = 'poivre_rose' WHERE "key" = 'Poivre rose';
UPDATE "ingredients" SET "key" = 'poivre_du_sichuan' WHERE "key" = 'Poivre du Sichuan';
UPDATE "ingredients" SET "key" = 'paprika_fume' WHERE "key" = 'Paprika fumé';
UPDATE "ingredients" SET "key" = 'piment_oiseau' WHERE "key" = 'Piment oiseau';
UPDATE "ingredients" SET "key" = 'baies_de_genievre' WHERE "key" = 'Baies de genièvre';
UPDATE "ingredients" SET "key" = 'anis_etoile_badiane' WHERE "key" = 'Anis étoilé (badiane)';
UPDATE "ingredients" SET "key" = 'anis_vert' WHERE "key" = 'Anis vert';
UPDATE "ingredients" SET "key" = 'graines_de_fenouil' WHERE "key" = 'Graines de fenouil';
UPDATE "ingredients" SET "key" = 'sumac' WHERE "key" = 'Sumac';
UPDATE "ingredients" SET "key" = 'nigelle' WHERE "key" = 'Nigelle';
UPDATE "ingredients" SET "key" = 'quatre_epices' WHERE "key" = 'Quatre épices';
UPDATE "ingredients" SET "key" = 'colombo_poudre' WHERE "key" = 'Colombo (poudre)';
UPDATE "ingredients" SET "key" = 'baharat' WHERE "key" = 'Baharat';
UPDATE "ingredients" SET "key" = 'raifort' WHERE "key" = 'Raifort';
UPDATE "ingredients" SET "key" = 'sel_aux_herbes' WHERE "key" = 'Sel aux herbes';
UPDATE "ingredients" SET "key" = 'sel_de_celeri' WHERE "key" = 'Sel de céleri';
UPDATE "ingredients" SET "key" = 'fleur_de_sel' WHERE "key" = 'Fleur de sel';
UPDATE "ingredients" SET "key" = 'sel' WHERE "key" = 'Sel';
UPDATE "ingredients" SET "key" = 'cinq_epices' WHERE "key" = 'Cinq épices';
UPDATE "ingredients" SET "key" = 'garam_masala' WHERE "key" = 'Garam masala';
UPDATE "ingredients" SET "key" = 'graines_de_coriandre' WHERE "key" = 'Graines de coriandre';
UPDATE "ingredients" SET "key" = 'cardamome' WHERE "key" = 'Cardamome';
UPDATE "ingredients" SET "key" = 'fenugrec' WHERE "key" = 'Fenugrec';
UPDATE "ingredients" SET "key" = 'piment_jalapeno' WHERE "key" = 'Piment jalapeño';
UPDATE "ingredients" SET "key" = 'piment_chipotle' WHERE "key" = 'Piment chipotle';
UPDATE "ingredients" SET "key" = 'piment_poblano' WHERE "key" = 'Piment poblano';
UPDATE "ingredients" SET "key" = 'piment_habanero' WHERE "key" = 'Piment habanero';
UPDATE "ingredients" SET "key" = 'ras_el_hanout' WHERE "key" = 'Ras el hanout';
UPDATE "ingredients" SET "key" = 'za_atar' WHERE "key" = 'Za''atar';
UPDATE "ingredients" SET "key" = 'sauce_soja' WHERE "key" = 'Sauce soja';
UPDATE "ingredients" SET "key" = 'moutarde' WHERE "key" = 'Moutarde';
UPDATE "ingredients" SET "key" = 'mayonnaise' WHERE "key" = 'Mayonnaise';
UPDATE "ingredients" SET "key" = 'ketchup' WHERE "key" = 'Ketchup';
UPDATE "ingredients" SET "key" = 'tabasco' WHERE "key" = 'Tabasco';
UPDATE "ingredients" SET "key" = 'sauce_worcestershire' WHERE "key" = 'Sauce Worcestershire';
UPDATE "ingredients" SET "key" = 'sauce_nuoc_mam' WHERE "key" = 'Sauce nuoc-mâm';
UPDATE "ingredients" SET "key" = 'wasabi' WHERE "key" = 'Wasabi';
UPDATE "ingredients" SET "key" = 'harissa' WHERE "key" = 'Harissa';
UPDATE "ingredients" SET "key" = 'pate_de_curry' WHERE "key" = 'Pâte de curry';
UPDATE "ingredients" SET "key" = 'beurre_de_cacahuete' WHERE "key" = 'Beurre de cacahuète';
UPDATE "ingredients" SET "key" = 'moutarde_de_dijon' WHERE "key" = 'Moutarde de Dijon';
UPDATE "ingredients" SET "key" = 'moutarde_a_l_ancienne' WHERE "key" = 'Moutarde à l''ancienne';
UPDATE "ingredients" SET "key" = 'sauce_barbecue' WHERE "key" = 'Sauce barbecue';
UPDATE "ingredients" SET "key" = 'sauce_tartare' WHERE "key" = 'Sauce tartare';
UPDATE "ingredients" SET "key" = 'sauce_cocktail' WHERE "key" = 'Sauce cocktail';
UPDATE "ingredients" SET "key" = 'sauce_bearnaise' WHERE "key" = 'Sauce béarnaise';
UPDATE "ingredients" SET "key" = 'sauce_hollandaise' WHERE "key" = 'Sauce hollandaise';
UPDATE "ingredients" SET "key" = 'sauce_bechamel' WHERE "key" = 'Sauce béchamel';
UPDATE "ingredients" SET "key" = 'sauce_teriyaki' WHERE "key" = 'Sauce teriyaki';
UPDATE "ingredients" SET "key" = 'sauce_ponzu' WHERE "key" = 'Sauce ponzu';
UPDATE "ingredients" SET "key" = 'chimichurri' WHERE "key" = 'Chimichurri';
UPDATE "ingredients" SET "key" = 'pesto_rouge_tomates_sechees' WHERE "key" = 'Pesto rouge (tomates séchées)';
UPDATE "ingredients" SET "key" = 'pesto' WHERE "key" = 'Pesto';
UPDATE "ingredients" SET "key" = 'sauce_huitre' WHERE "key" = 'Sauce huître';
UPDATE "ingredients" SET "key" = 'sauce_hoisin' WHERE "key" = 'Sauce hoisin';
UPDATE "ingredients" SET "key" = 'sauce_sriracha' WHERE "key" = 'Sauce sriracha';
UPDATE "ingredients" SET "key" = 'sauce_sweet_chili' WHERE "key" = 'Sauce sweet chili';
UPDATE "ingredients" SET "key" = 'miso' WHERE "key" = 'Miso';
UPDATE "ingredients" SET "key" = 'pate_de_crevettes' WHERE "key" = 'Pâte de crevettes';
UPDATE "ingredients" SET "key" = 'pate_de_curry_rouge_thai' WHERE "key" = 'Pâte de curry rouge (thaï)';
UPDATE "ingredients" SET "key" = 'pate_de_curry_vert_thai' WHERE "key" = 'Pâte de curry vert (thaï)';
UPDATE "ingredients" SET "key" = 'tahini' WHERE "key" = 'Tahini';
UPDATE "ingredients" SET "key" = 'huile_d_olive' WHERE "key" = 'Huile d''olive';
UPDATE "ingredients" SET "key" = 'huile_de_tournesol' WHERE "key" = 'Huile de tournesol';
UPDATE "ingredients" SET "key" = 'huile_de_colza' WHERE "key" = 'Huile de colza';
UPDATE "ingredients" SET "key" = 'huile_de_coco' WHERE "key" = 'Huile de coco';
UPDATE "ingredients" SET "key" = 'huile_de_sesame' WHERE "key" = 'Huile de sésame';
UPDATE "ingredients" SET "key" = 'vinaigre_de_cidre' WHERE "key" = 'Vinaigre de cidre';
UPDATE "ingredients" SET "key" = 'vinaigre_blanc' WHERE "key" = 'Vinaigre blanc';
UPDATE "ingredients" SET "key" = 'vinaigre_balsamique' WHERE "key" = 'Vinaigre balsamique';
UPDATE "ingredients" SET "key" = 'capres' WHERE "key" = 'Câpres';
UPDATE "ingredients" SET "key" = 'olives' WHERE "key" = 'Olives';
UPDATE "ingredients" SET "key" = 'vin_blanc_cuisine' WHERE "key" = 'Vin blanc (cuisine)';
UPDATE "ingredients" SET "key" = 'vin_rouge_cuisine' WHERE "key" = 'Vin rouge (cuisine)';
UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_rouge' WHERE "key" = 'Vinaigre de vin rouge';
UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_blanc' WHERE "key" = 'Vinaigre de vin blanc';
UPDATE "ingredients" SET "key" = 'vinaigre_de_xeres' WHERE "key" = 'Vinaigre de xérès';
UPDATE "ingredients" SET "key" = 'huile_de_noix' WHERE "key" = 'Huile de noix';
UPDATE "ingredients" SET "key" = 'huile_de_noisette' WHERE "key" = 'Huile de noisette';
UPDATE "ingredients" SET "key" = 'huile_d_arachide' WHERE "key" = 'Huile d''arachide';
UPDATE "ingredients" SET "key" = 'huile_pimentee' WHERE "key" = 'Huile pimentée';
UPDATE "ingredients" SET "key" = 'vinaigre_de_riz' WHERE "key" = 'Vinaigre de riz';
UPDATE "ingredients" SET "key" = 'mirin' WHERE "key" = 'Mirin';
UPDATE "ingredients" SET "key" = 'sake_cuisine' WHERE "key" = 'Saké (cuisine)';
UPDATE "ingredients" SET "key" = 'jus_de_citron' WHERE "key" = 'Jus de citron';
UPDATE "ingredients" SET "key" = 'jus_de_citron_vert' WHERE "key" = 'Jus de citron vert';
UPDATE "ingredients" SET "key" = 'jus_d_orange' WHERE "key" = 'Jus d''orange';
UPDATE "ingredients" SET "key" = 'jus_de_pomme' WHERE "key" = 'Jus de pomme';
UPDATE "ingredients" SET "key" = 'jus_de_raisin' WHERE "key" = 'Jus de raisin';
UPDATE "ingredients" SET "key" = 'jus_de_tomate' WHERE "key" = 'Jus de tomate';
UPDATE "ingredients" SET "key" = 'jus_de_cranberry' WHERE "key" = 'Jus de cranberry';
UPDATE "ingredients" SET "key" = 'cafe' WHERE "key" = 'Café';
UPDATE "ingredients" SET "key" = 'the' WHERE "key" = 'Thé';
UPDATE "ingredients" SET "key" = 'biere_cuisine' WHERE "key" = 'Bière (cuisine)';
UPDATE "ingredients" SET "key" = 'cidre_cuisine' WHERE "key" = 'Cidre (cuisine)';
UPDATE "ingredients" SET "key" = 'champagne_vin_petillant_cuisine' WHERE "key" = 'Champagne / vin pétillant (cuisine)';
UPDATE "ingredients" SET "key" = 'porto_cuisine' WHERE "key" = 'Porto (cuisine)';
UPDATE "ingredients" SET "key" = 'vin_jaune_cuisine' WHERE "key" = 'Vin jaune (cuisine)';
UPDATE "ingredients" SET "key" = 'cognac' WHERE "key" = 'Cognac';
UPDATE "ingredients" SET "key" = 'rhum' WHERE "key" = 'Rhum';
UPDATE "ingredients" SET "key" = 'whisky' WHERE "key" = 'Whisky';
UPDATE "ingredients" SET "key" = 'vodka' WHERE "key" = 'Vodka';
UPDATE "ingredients" SET "key" = 'farine_de_ble' WHERE "key" = 'Farine de blé';
UPDATE "ingredients" SET "key" = 'farine_complete' WHERE "key" = 'Farine complète';
UPDATE "ingredients" SET "key" = 'farine_de_mais' WHERE "key" = 'Farine de maïs';
UPDATE "ingredients" SET "key" = 'farine_de_sarrasin' WHERE "key" = 'Farine de sarrasin';
UPDATE "ingredients" SET "key" = 'farine_de_riz' WHERE "key" = 'Farine de riz';
UPDATE "ingredients" SET "key" = 'bouillon_cube_legumes' WHERE "key" = 'Bouillon cube légumes';
UPDATE "ingredients" SET "key" = 'bouillon_cube_volaille' WHERE "key" = 'Bouillon cube volaille';
UPDATE "ingredients" SET "key" = 'concentre_de_tomate' WHERE "key" = 'Concentré de tomate';
UPDATE "ingredients" SET "key" = 'coulis_de_tomate' WHERE "key" = 'Coulis de tomate';
UPDATE "ingredients" SET "key" = 'tomates_pelees_conserve' WHERE "key" = 'Tomates pelées (conserve)';
UPDATE "ingredients" SET "key" = 'tomates_sechees' WHERE "key" = 'Tomates séchées';
UPDATE "ingredients" SET "key" = 'fond_de_veau' WHERE "key" = 'Fond de veau';
UPDATE "ingredients" SET "key" = 'fond_de_volaille' WHERE "key" = 'Fond de volaille';
UPDATE "ingredients" SET "key" = 'bouillon_cube_boeuf' WHERE "key" = 'Bouillon cube bœuf';
UPDATE "ingredients" SET "key" = 'bouillon_cube_poisson' WHERE "key" = 'Bouillon cube poisson';
UPDATE "ingredients" SET "key" = 'bouillon_de_legumes' WHERE "key" = 'Bouillon de légumes';
UPDATE "ingredients" SET "key" = 'bouillon_de_volaille' WHERE "key" = 'Bouillon de volaille';
UPDATE "ingredients" SET "key" = 'bouillon_de_boeuf' WHERE "key" = 'Bouillon de bœuf';
UPDATE "ingredients" SET "key" = 'court_bouillon' WHERE "key" = 'Court-bouillon';
UPDATE "ingredients" SET "key" = 'dashi_bouillon_japonais' WHERE "key" = 'Dashi (bouillon japonais)';
UPDATE "ingredients" SET "key" = 'bisque_de_crustaces' WHERE "key" = 'Bisque de crustacés';
UPDATE "ingredients" SET "key" = 'farine_de_tapioca' WHERE "key" = 'Farine de tapioca';
UPDATE "ingredients" SET "key" = 'masa_harina' WHERE "key" = 'Masa harina';
UPDATE "ingredients" SET "key" = 'eau' WHERE "key" = 'Eau';
UPDATE "ingredients" SET "key" = 'eau_gazeuse' WHERE "key" = 'Eau gazeuse';
UPDATE "ingredients" SET "key" = 'eau_de_fleur_d_oranger' WHERE "key" = 'Eau de fleur d''oranger';
UPDATE "ingredients" SET "key" = 'eau_de_rose' WHERE "key" = 'Eau de rose';
UPDATE "ingredients" SET "key" = 'fumet_de_poisson' WHERE "key" = 'Fumet de poisson';
UPDATE "ingredients" SET "key" = 'levure_boulangere' WHERE "key" = 'Levure boulangère';
UPDATE "ingredients" SET "key" = 'levure_chimique' WHERE "key" = 'Levure chimique';
UPDATE "ingredients" SET "key" = 'maizena' WHERE "key" = 'Maïzena';
UPDATE "ingredients" SET "key" = 'farine_de_lupin' WHERE "key" = 'Farine de lupin';
UPDATE "ingredients" SET "key" = 'gelatine' WHERE "key" = 'Gélatine';
UPDATE "ingredients" SET "key" = 'bicarbonate_de_soude' WHERE "key" = 'Bicarbonate de soude';
UPDATE "ingredients" SET "key" = 'fecule_de_pomme_de_terre' WHERE "key" = 'Fécule de pomme de terre';
UPDATE "ingredients" SET "key" = 'sucre' WHERE "key" = 'Sucre';
UPDATE "ingredients" SET "key" = 'miel' WHERE "key" = 'Miel';
UPDATE "ingredients" SET "key" = 'sirop_d_erable' WHERE "key" = 'Sirop d''érable';
UPDATE "ingredients" SET "key" = 'sucre_roux' WHERE "key" = 'Sucre roux';
UPDATE "ingredients" SET "key" = 'sucre_glace' WHERE "key" = 'Sucre glace';
UPDATE "ingredients" SET "key" = 'cassonade' WHERE "key" = 'Cassonade';
UPDATE "ingredients" SET "key" = 'chocolat_noir' WHERE "key" = 'Chocolat noir';
UPDATE "ingredients" SET "key" = 'chocolat_au_lait' WHERE "key" = 'Chocolat au lait';
UPDATE "ingredients" SET "key" = 'chocolat_blanc' WHERE "key" = 'Chocolat blanc';
UPDATE "ingredients" SET "key" = 'pepites_de_chocolat' WHERE "key" = 'Pépites de chocolat';
UPDATE "ingredients" SET "key" = 'cacao_en_poudre' WHERE "key" = 'Cacao en poudre';
UPDATE "ingredients" SET "key" = 'extrait_de_vanille' WHERE "key" = 'Extrait de vanille';
UPDATE "ingredients" SET "key" = 'sucre_de_palme' WHERE "key" = 'Sucre de palme';
UPDATE "ingredients" SET "key" = 'sirop_de_sucre_de_canne' WHERE "key" = 'Sirop de sucre de canne';

View file

@ -0,0 +1,465 @@
-- Auto-generated once by scripts/gen-english-remap-sql.ts — do not re-run,
-- do not hand-edit. Remaps every Diet/Category(allergen)/Ingredient row's
-- "key" from the French slug the previous migration
-- (20260818_catalog_labels_to_keys) produced to the hand-assigned English
-- key in catalog-en-keys.ts.
UPDATE "diet" SET "key" = 'omnivore' WHERE "key" = 'omnivore';
UPDATE "diet" SET "key" = 'vegetarian' WHERE "key" = 'vegetarien';
UPDATE "diet" SET "key" = 'vegan' WHERE "key" = 'vegan';
UPDATE "diet" SET "key" = 'pescatarian' WHERE "key" = 'pescetarien';
UPDATE "diet" SET "key" = 'gluten_free' WHERE "key" = 'sans_gluten';
UPDATE "category" SET "key" = 'gluten' WHERE "key" = 'gluten';
UPDATE "category" SET "key" = 'crustaceans' WHERE "key" = 'crustaces';
UPDATE "category" SET "key" = 'eggs' WHERE "key" = 'oeufs';
UPDATE "category" SET "key" = 'fish' WHERE "key" = 'poissons';
UPDATE "category" SET "key" = 'peanuts' WHERE "key" = 'arachides';
UPDATE "category" SET "key" = 'soy' WHERE "key" = 'soja';
UPDATE "category" SET "key" = 'milk' WHERE "key" = 'lait';
UPDATE "category" SET "key" = 'tree_nuts' WHERE "key" = 'fruits_a_coque';
UPDATE "category" SET "key" = 'celery' WHERE "key" = 'celeri';
UPDATE "category" SET "key" = 'mustard' WHERE "key" = 'moutarde';
UPDATE "category" SET "key" = 'sesame_seeds' WHERE "key" = 'graines_de_sesame';
UPDATE "category" SET "key" = 'sulfites' WHERE "key" = 'sulfites';
UPDATE "category" SET "key" = 'lupin' WHERE "key" = 'lupin';
UPDATE "category" SET "key" = 'molluscs' WHERE "key" = 'mollusques';
UPDATE "ingredients" SET "key" = 'tomato' WHERE "key" = 'tomate';
UPDATE "ingredients" SET "key" = 'onion' WHERE "key" = 'oignon';
UPDATE "ingredients" SET "key" = 'shallot' WHERE "key" = 'echalote';
UPDATE "ingredients" SET "key" = 'garlic' WHERE "key" = 'ail';
UPDATE "ingredients" SET "key" = 'carrot' WHERE "key" = 'carotte';
UPDATE "ingredients" SET "key" = 'zucchini' WHERE "key" = 'courgette';
UPDATE "ingredients" SET "key" = 'cucumber' WHERE "key" = 'concombre';
UPDATE "ingredients" SET "key" = 'gherkins' WHERE "key" = 'cornichons';
UPDATE "ingredients" SET "key" = 'bell_pepper' WHERE "key" = 'poivron';
UPDATE "ingredients" SET "key" = 'mushroom' WHERE "key" = 'champignon';
UPDATE "ingredients" SET "key" = 'porcini' WHERE "key" = 'cepes';
UPDATE "ingredients" SET "key" = 'eggplant' WHERE "key" = 'aubergine';
UPDATE "ingredients" SET "key" = 'broccoli' WHERE "key" = 'brocoli';
UPDATE "ingredients" SET "key" = 'cauliflower' WHERE "key" = 'chou_fleur';
UPDATE "ingredients" SET "key" = 'white_cabbage' WHERE "key" = 'chou_blanc';
UPDATE "ingredients" SET "key" = 'red_cabbage' WHERE "key" = 'chou_rouge';
UPDATE "ingredients" SET "key" = 'brussels_sprouts' WHERE "key" = 'chou_de_bruxelles';
UPDATE "ingredients" SET "key" = 'spinach' WHERE "key" = 'epinard';
UPDATE "ingredients" SET "key" = 'swiss_chard' WHERE "key" = 'blette';
UPDATE "ingredients" SET "key" = 'lettuce' WHERE "key" = 'salade';
UPDATE "ingredients" SET "key" = 'arugula' WHERE "key" = 'roquette';
UPDATE "ingredients" SET "key" = 'watercress' WHERE "key" = 'cresson';
UPDATE "ingredients" SET "key" = 'leek' WHERE "key" = 'poireau';
UPDATE "ingredients" SET "key" = 'celery' WHERE "key" = 'celeri';
UPDATE "ingredients" SET "key" = 'radish' WHERE "key" = 'radis';
UPDATE "ingredients" SET "key" = 'beetroot' WHERE "key" = 'betterave';
UPDATE "ingredients" SET "key" = 'turnip' WHERE "key" = 'navet';
UPDATE "ingredients" SET "key" = 'parsnip' WHERE "key" = 'panais';
UPDATE "ingredients" SET "key" = 'green_bean' WHERE "key" = 'haricot_vert';
UPDATE "ingredients" SET "key" = 'pea' WHERE "key" = 'petit_pois';
UPDATE "ingredients" SET "key" = 'corn' WHERE "key" = 'mais';
UPDATE "ingredients" SET "key" = 'artichoke' WHERE "key" = 'artichaut';
UPDATE "ingredients" SET "key" = 'fennel' WHERE "key" = 'fenouil';
UPDATE "ingredients" SET "key" = 'endive' WHERE "key" = 'endive';
UPDATE "ingredients" SET "key" = 'pumpkin' WHERE "key" = 'potiron';
UPDATE "ingredients" SET "key" = 'butternut_squash' WHERE "key" = 'butternut';
UPDATE "ingredients" SET "key" = 'asparagus' WHERE "key" = 'asperge';
UPDATE "ingredients" SET "key" = 'avocado' WHERE "key" = 'avocat';
UPDATE "ingredients" SET "key" = 'potato' WHERE "key" = 'pomme_de_terre';
UPDATE "ingredients" SET "key" = 'sweet_potato' WHERE "key" = 'patate_douce';
UPDATE "ingredients" SET "key" = 'cherry_tomato' WHERE "key" = 'tomates_cerises';
UPDATE "ingredients" SET "key" = 'bok_choy' WHERE "key" = 'pak_choi';
UPDATE "ingredients" SET "key" = 'soybean_sprouts' WHERE "key" = 'germes_de_soja';
UPDATE "ingredients" SET "key" = 'shiitake' WHERE "key" = 'shiitake';
UPDATE "ingredients" SET "key" = 'daikon' WHERE "key" = 'daikon';
UPDATE "ingredients" SET "key" = 'fresh_green_chili' WHERE "key" = 'piment_vert_frais';
UPDATE "ingredients" SET "key" = 'lemon' WHERE "key" = 'citron';
UPDATE "ingredients" SET "key" = 'lime' WHERE "key" = 'citron_vert';
UPDATE "ingredients" SET "key" = 'apple' WHERE "key" = 'pomme';
UPDATE "ingredients" SET "key" = 'pear' WHERE "key" = 'poire';
UPDATE "ingredients" SET "key" = 'banana' WHERE "key" = 'banane';
UPDATE "ingredients" SET "key" = 'orange' WHERE "key" = 'orange';
UPDATE "ingredients" SET "key" = 'clementine' WHERE "key" = 'clementine';
UPDATE "ingredients" SET "key" = 'grapefruit' WHERE "key" = 'pamplemousse';
UPDATE "ingredients" SET "key" = 'strawberry' WHERE "key" = 'fraise';
UPDATE "ingredients" SET "key" = 'raspberry' WHERE "key" = 'framboise';
UPDATE "ingredients" SET "key" = 'blueberry' WHERE "key" = 'myrtille';
UPDATE "ingredients" SET "key" = 'blackberry' WHERE "key" = 'mure';
UPDATE "ingredients" SET "key" = 'cherry' WHERE "key" = 'cerise';
UPDATE "ingredients" SET "key" = 'apricot' WHERE "key" = 'abricot';
UPDATE "ingredients" SET "key" = 'peach' WHERE "key" = 'peche';
UPDATE "ingredients" SET "key" = 'plum' WHERE "key" = 'prune';
UPDATE "ingredients" SET "key" = 'grape' WHERE "key" = 'raisin';
UPDATE "ingredients" SET "key" = 'melon' WHERE "key" = 'melon';
UPDATE "ingredients" SET "key" = 'watermelon' WHERE "key" = 'pasteque';
UPDATE "ingredients" SET "key" = 'pineapple' WHERE "key" = 'ananas';
UPDATE "ingredients" SET "key" = 'mango' WHERE "key" = 'mangue';
UPDATE "ingredients" SET "key" = 'kiwi' WHERE "key" = 'kiwi';
UPDATE "ingredients" SET "key" = 'fig' WHERE "key" = 'figue';
UPDATE "ingredients" SET "key" = 'date' WHERE "key" = 'datte';
UPDATE "ingredients" SET "key" = 'lychee' WHERE "key" = 'litchi';
UPDATE "ingredients" SET "key" = 'pomegranate' WHERE "key" = 'grenade';
UPDATE "ingredients" SET "key" = 'rhubarb' WHERE "key" = 'rhubarbe';
UPDATE "ingredients" SET "key" = 'quince' WHERE "key" = 'coing';
UPDATE "ingredients" SET "key" = 'basil' WHERE "key" = 'basilic';
UPDATE "ingredients" SET "key" = 'parsley' WHERE "key" = 'persil';
UPDATE "ingredients" SET "key" = 'thyme' WHERE "key" = 'thym';
UPDATE "ingredients" SET "key" = 'rosemary' WHERE "key" = 'romarin';
UPDATE "ingredients" SET "key" = 'bay_leaf' WHERE "key" = 'laurier';
UPDATE "ingredients" SET "key" = 'chives' WHERE "key" = 'ciboulette';
UPDATE "ingredients" SET "key" = 'fresh_cilantro' WHERE "key" = 'coriandre_fraiche';
UPDATE "ingredients" SET "key" = 'mint' WHERE "key" = 'menthe';
UPDATE "ingredients" SET "key" = 'oregano' WHERE "key" = 'origan';
UPDATE "ingredients" SET "key" = 'dill' WHERE "key" = 'aneth';
UPDATE "ingredients" SET "key" = 'tarragon' WHERE "key" = 'estragon';
UPDATE "ingredients" SET "key" = 'savory' WHERE "key" = 'sarriette';
UPDATE "ingredients" SET "key" = 'marjoram' WHERE "key" = 'marjolaine';
UPDATE "ingredients" SET "key" = 'sage' WHERE "key" = 'sauge';
UPDATE "ingredients" SET "key" = 'chervil' WHERE "key" = 'cerfeuil';
UPDATE "ingredients" SET "key" = 'ginger' WHERE "key" = 'gingembre';
UPDATE "ingredients" SET "key" = 'lemongrass' WHERE "key" = 'citronnelle';
UPDATE "ingredients" SET "key" = 'kaffir_lime' WHERE "key" = 'combava';
UPDATE "ingredients" SET "key" = 'rabbit' WHERE "key" = 'lapin';
UPDATE "ingredients" SET "key" = 'ground_beef' WHERE "key" = 'boeuf_hache';
UPDATE "ingredients" SET "key" = 'beef_steak' WHERE "key" = 'steak_de_boeuf';
UPDATE "ingredients" SET "key" = 'beef_roast' WHERE "key" = 'roti_de_boeuf';
UPDATE "ingredients" SET "key" = 'veal_cutlet' WHERE "key" = 'escalope_de_veau';
UPDATE "ingredients" SET "key" = 'pork_tenderloin' WHERE "key" = 'filet_mignon_de_porc';
UPDATE "ingredients" SET "key" = 'pork_chop' WHERE "key" = 'cote_de_porc';
UPDATE "ingredients" SET "key" = 'lamb' WHERE "key" = 'agneau';
UPDATE "ingredients" SET "key" = 'leg_of_lamb' WHERE "key" = 'gigot_d_agneau';
UPDATE "ingredients" SET "key" = 'bacon_lardons' WHERE "key" = 'lardons';
UPDATE "ingredients" SET "key" = 'bacon' WHERE "key" = 'bacon';
UPDATE "ingredients" SET "key" = 'ham' WHERE "key" = 'jambon_blanc';
UPDATE "ingredients" SET "key" = 'cured_ham' WHERE "key" = 'jambon_cru';
UPDATE "ingredients" SET "key" = 'sausage' WHERE "key" = 'saucisse';
UPDATE "ingredients" SET "key" = 'chorizo' WHERE "key" = 'chorizo';
UPDATE "ingredients" SET "key" = 'merguez' WHERE "key" = 'merguez';
UPDATE "ingredients" SET "key" = 'prosciutto' WHERE "key" = 'prosciutto';
UPDATE "ingredients" SET "key" = 'pancetta' WHERE "key" = 'pancetta';
UPDATE "ingredients" SET "key" = 'mortadella' WHERE "key" = 'mortadelle';
UPDATE "ingredients" SET "key" = 'salami' WHERE "key" = 'salami';
UPDATE "ingredients" SET "key" = 'chicken' WHERE "key" = 'poulet';
UPDATE "ingredients" SET "key" = 'turkey' WHERE "key" = 'dinde';
UPDATE "ingredients" SET "key" = 'duck' WHERE "key" = 'canard';
UPDATE "ingredients" SET "key" = 'duck_breast' WHERE "key" = 'magret_de_canard';
UPDATE "ingredients" SET "key" = 'salmon' WHERE "key" = 'saumon';
UPDATE "ingredients" SET "key" = 'tuna' WHERE "key" = 'thon';
UPDATE "ingredients" SET "key" = 'cod' WHERE "key" = 'cabillaud';
UPDATE "ingredients" SET "key" = 'trout' WHERE "key" = 'truite';
UPDATE "ingredients" SET "key" = 'sardine' WHERE "key" = 'sardine';
UPDATE "ingredients" SET "key" = 'anchovy' WHERE "key" = 'anchois';
UPDATE "ingredients" SET "key" = 'whiting' WHERE "key" = 'merlan';
UPDATE "ingredients" SET "key" = 'surimi' WHERE "key" = 'surimi';
UPDATE "ingredients" SET "key" = 'sea_bass' WHERE "key" = 'bar_loup_de_mer';
UPDATE "ingredients" SET "key" = 'sea_bream' WHERE "key" = 'dorade';
UPDATE "ingredients" SET "key" = 'sole' WHERE "key" = 'sole';
UPDATE "ingredients" SET "key" = 'turbot' WHERE "key" = 'turbot';
UPDATE "ingredients" SET "key" = 'hake' WHERE "key" = 'merlu';
UPDATE "ingredients" SET "key" = 'pollock' WHERE "key" = 'colin';
UPDATE "ingredients" SET "key" = 'saithe' WHERE "key" = 'lieu_noir';
UPDATE "ingredients" SET "key" = 'haddock' WHERE "key" = 'eglefin';
UPDATE "ingredients" SET "key" = 'mackerel' WHERE "key" = 'maquereau';
UPDATE "ingredients" SET "key" = 'herring' WHERE "key" = 'hareng';
UPDATE "ingredients" SET "key" = 'red_mullet' WHERE "key" = 'rouget';
UPDATE "ingredients" SET "key" = 'skate' WHERE "key" = 'raie';
UPDATE "ingredients" SET "key" = 'monkfish' WHERE "key" = 'lotte';
UPDATE "ingredients" SET "key" = 'halibut' WHERE "key" = 'fletan';
UPDATE "ingredients" SET "key" = 'swordfish' WHERE "key" = 'espadon';
UPDATE "ingredients" SET "key" = 'carp' WHERE "key" = 'carpe';
UPDATE "ingredients" SET "key" = 'pike' WHERE "key" = 'brochet';
UPDATE "ingredients" SET "key" = 'perch' WHERE "key" = 'perche';
UPDATE "ingredients" SET "key" = 'tilapia' WHERE "key" = 'tilapia';
UPDATE "ingredients" SET "key" = 'pangasius' WHERE "key" = 'panga';
UPDATE "ingredients" SET "key" = 'smoked_salmon' WHERE "key" = 'saumon_fume';
UPDATE "ingredients" SET "key" = 'dried_fish' WHERE "key" = 'poisson_seche';
UPDATE "ingredients" SET "key" = 'shrimp' WHERE "key" = 'crevettes';
UPDATE "ingredients" SET "key" = 'langoustine' WHERE "key" = 'langoustines';
UPDATE "ingredients" SET "key" = 'lobster' WHERE "key" = 'homard';
UPDATE "ingredients" SET "key" = 'crab' WHERE "key" = 'crabe';
UPDATE "ingredients" SET "key" = 'spiny_lobster' WHERE "key" = 'langouste';
UPDATE "ingredients" SET "key" = 'mussels' WHERE "key" = 'moules';
UPDATE "ingredients" SET "key" = 'oysters' WHERE "key" = 'huitres';
UPDATE "ingredients" SET "key" = 'scallops' WHERE "key" = 'saint_jacques';
UPDATE "ingredients" SET "key" = 'squid' WHERE "key" = 'calamar';
UPDATE "ingredients" SET "key" = 'octopus' WHERE "key" = 'poulpe';
UPDATE "ingredients" SET "key" = 'clams' WHERE "key" = 'palourdes';
UPDATE "ingredients" SET "key" = 'whelks' WHERE "key" = 'bulots';
UPDATE "ingredients" SET "key" = 'semolina' WHERE "key" = 'semoule';
UPDATE "ingredients" SET "key" = 'couscous' WHERE "key" = 'couscous';
UPDATE "ingredients" SET "key" = 'bulgur' WHERE "key" = 'boulgour';
UPDATE "ingredients" SET "key" = 'polenta' WHERE "key" = 'polenta';
UPDATE "ingredients" SET "key" = 'quinoa' WHERE "key" = 'quinoa';
UPDATE "ingredients" SET "key" = 'pasta' WHERE "key" = 'pates';
UPDATE "ingredients" SET "key" = 'whole_wheat_pasta' WHERE "key" = 'pates_completes';
UPDATE "ingredients" SET "key" = 'rice' WHERE "key" = 'riz';
UPDATE "ingredients" SET "key" = 'basmati_rice' WHERE "key" = 'riz_basmati';
UPDATE "ingredients" SET "key" = 'brown_rice' WHERE "key" = 'riz_complet';
UPDATE "ingredients" SET "key" = 'oats' WHERE "key" = 'flocons_d_avoine';
UPDATE "ingredients" SET "key" = 'spaghetti' WHERE "key" = 'spaghetti';
UPDATE "ingredients" SET "key" = 'penne' WHERE "key" = 'penne';
UPDATE "ingredients" SET "key" = 'tagliatelle' WHERE "key" = 'tagliatelles';
UPDATE "ingredients" SET "key" = 'lasagna_sheets' WHERE "key" = 'lasagnes_feuilles';
UPDATE "ingredients" SET "key" = 'gnocchi' WHERE "key" = 'gnocchi';
UPDATE "ingredients" SET "key" = 'arborio_rice' WHERE "key" = 'riz_arborio';
UPDATE "ingredients" SET "key" = 'rice_noodles' WHERE "key" = 'nouilles_de_riz';
UPDATE "ingredients" SET "key" = 'udon_noodles' WHERE "key" = 'nouilles_udon';
UPDATE "ingredients" SET "key" = 'soba_noodles' WHERE "key" = 'nouilles_soba';
UPDATE "ingredients" SET "key" = 'chinese_noodles' WHERE "key" = 'nouilles_chinoises';
UPDATE "ingredients" SET "key" = 'rice_vermicelli' WHERE "key" = 'vermicelles_de_riz';
UPDATE "ingredients" SET "key" = 'soy_vermicelli' WHERE "key" = 'vermicelles_de_soja';
UPDATE "ingredients" SET "key" = 'sticky_rice' WHERE "key" = 'riz_gluant';
UPDATE "ingredients" SET "key" = 'sushi_rice' WHERE "key" = 'riz_a_sushi';
UPDATE "ingredients" SET "key" = 'jasmine_rice' WHERE "key" = 'riz_jasmin';
UPDATE "ingredients" SET "key" = 'green_lentils' WHERE "key" = 'lentilles_vertes';
UPDATE "ingredients" SET "key" = 'red_lentils' WHERE "key" = 'lentilles_corail';
UPDATE "ingredients" SET "key" = 'chickpeas' WHERE "key" = 'pois_chiches';
UPDATE "ingredients" SET "key" = 'white_beans' WHERE "key" = 'haricots_blancs';
UPDATE "ingredients" SET "key" = 'kidney_beans' WHERE "key" = 'haricots_rouges';
UPDATE "ingredients" SET "key" = 'black_beans' WHERE "key" = 'haricots_noirs';
UPDATE "ingredients" SET "key" = 'split_peas' WHERE "key" = 'pois_casses';
UPDATE "ingredients" SET "key" = 'fava_beans' WHERE "key" = 'feves';
UPDATE "ingredients" SET "key" = 'edamame' WHERE "key" = 'edamame';
UPDATE "ingredients" SET "key" = 'pinto_beans' WHERE "key" = 'haricots_pinto';
UPDATE "ingredients" SET "key" = 'peanuts_shelled' WHERE "key" = 'cacahuetes';
UPDATE "ingredients" SET "key" = 'almonds' WHERE "key" = 'amandes';
UPDATE "ingredients" SET "key" = 'walnuts' WHERE "key" = 'noix';
UPDATE "ingredients" SET "key" = 'hazelnuts' WHERE "key" = 'noisettes';
UPDATE "ingredients" SET "key" = 'cashews' WHERE "key" = 'noix_de_cajou';
UPDATE "ingredients" SET "key" = 'pistachios' WHERE "key" = 'pistaches';
UPDATE "ingredients" SET "key" = 'pecans' WHERE "key" = 'noix_de_pecan';
UPDATE "ingredients" SET "key" = 'almond_powder' WHERE "key" = 'poudre_d_amande';
UPDATE "ingredients" SET "key" = 'pine_nuts' WHERE "key" = 'pignons_de_pin';
UPDATE "ingredients" SET "key" = 'sunflower_seeds' WHERE "key" = 'graines_de_tournesol';
UPDATE "ingredients" SET "key" = 'pumpkin_seeds' WHERE "key" = 'graines_de_courge';
UPDATE "ingredients" SET "key" = 'shredded_coconut' WHERE "key" = 'noix_de_coco_rapee';
UPDATE "ingredients" SET "key" = 'raisins' WHERE "key" = 'raisins_secs';
UPDATE "ingredients" SET "key" = 'prunes' WHERE "key" = 'pruneaux';
UPDATE "ingredients" SET "key" = 'dried_apricots' WHERE "key" = 'abricots_secs';
UPDATE "ingredients" SET "key" = 'sesame_seeds' WHERE "key" = 'graines_de_sesame';
UPDATE "ingredients" SET "key" = 'black_mushrooms' WHERE "key" = 'champignons_noirs';
UPDATE "ingredients" SET "key" = 'nori_seaweed' WHERE "key" = 'algue_nori';
UPDATE "ingredients" SET "key" = 'wakame_seaweed' WHERE "key" = 'algue_wakame';
UPDATE "ingredients" SET "key" = 'kombu_seaweed' WHERE "key" = 'algue_kombu';
UPDATE "ingredients" SET "key" = 'bamboo_shoots' WHERE "key" = 'pousses_de_bambou';
UPDATE "ingredients" SET "key" = 'water_chestnuts' WHERE "key" = 'chataignes_d_eau';
UPDATE "ingredients" SET "key" = 'bread' WHERE "key" = 'pain';
UPDATE "ingredients" SET "key" = 'sandwich_bread' WHERE "key" = 'pain_de_mie';
UPDATE "ingredients" SET "key" = 'whole_wheat_bread' WHERE "key" = 'pain_complet';
UPDATE "ingredients" SET "key" = 'baguette' WHERE "key" = 'baguette';
UPDATE "ingredients" SET "key" = 'rye_bread' WHERE "key" = 'pain_de_seigle';
UPDATE "ingredients" SET "key" = 'breadcrumbs' WHERE "key" = 'chapelure';
UPDATE "ingredients" SET "key" = 'burger_bun' WHERE "key" = 'pain_a_burger';
UPDATE "ingredients" SET "key" = 'brioche_bun' WHERE "key" = 'pain_brioche';
UPDATE "ingredients" SET "key" = 'hot_dog_bun' WHERE "key" = 'pain_a_hot_dog';
UPDATE "ingredients" SET "key" = 'pita_bread' WHERE "key" = 'pain_pita';
UPDATE "ingredients" SET "key" = 'bagel' WHERE "key" = 'pain_bagel';
UPDATE "ingredients" SET "key" = 'naan' WHERE "key" = 'naan';
UPDATE "ingredients" SET "key" = 'wrap_bread' WHERE "key" = 'pain_wrap';
UPDATE "ingredients" SET "key" = 'viennese_bread' WHERE "key" = 'pain_viennois';
UPDATE "ingredients" SET "key" = 'country_bread' WHERE "key" = 'pain_de_campagne';
UPDATE "ingredients" SET "key" = 'multigrain_bread' WHERE "key" = 'pain_aux_cereales';
UPDATE "ingredients" SET "key" = 'bread_roll' WHERE "key" = 'petit_pain';
UPDATE "ingredients" SET "key" = 'swedish_bread' WHERE "key" = 'pain_suedois';
UPDATE "ingredients" SET "key" = 'gluten_free_bread' WHERE "key" = 'pain_sans_gluten';
UPDATE "ingredients" SET "key" = 'rusk' WHERE "key" = 'biscotte';
UPDATE "ingredients" SET "key" = 'croutons' WHERE "key" = 'croutons';
UPDATE "ingredients" SET "key" = 'focaccia' WHERE "key" = 'focaccia';
UPDATE "ingredients" SET "key" = 'ciabatta' WHERE "key" = 'ciabatta';
UPDATE "ingredients" SET "key" = 'corn_tortilla' WHERE "key" = 'tortilla_de_mais';
UPDATE "ingredients" SET "key" = 'wheat_tortilla' WHERE "key" = 'tortilla_de_ble';
UPDATE "ingredients" SET "key" = 'puff_pastry' WHERE "key" = 'pate_feuilletee';
UPDATE "ingredients" SET "key" = 'shortcrust_pastry' WHERE "key" = 'pate_brisee';
UPDATE "ingredients" SET "key" = 'pizza_dough' WHERE "key" = 'pate_a_pizza';
UPDATE "ingredients" SET "key" = 'sweet_shortcrust_pastry' WHERE "key" = 'pate_a_tarte_sablee';
UPDATE "ingredients" SET "key" = 'milk' WHERE "key" = 'lait';
UPDATE "ingredients" SET "key" = 'butter' WHERE "key" = 'beurre';
UPDATE "ingredients" SET "key" = 'creme_fraiche' WHERE "key" = 'creme_fraiche';
UPDATE "ingredients" SET "key" = 'liquid_cream' WHERE "key" = 'creme_liquide';
UPDATE "ingredients" SET "key" = 'cheese' WHERE "key" = 'fromage';
UPDATE "ingredients" SET "key" = 'emmental' WHERE "key" = 'emmental';
UPDATE "ingredients" SET "key" = 'gruyere' WHERE "key" = 'gruyere';
UPDATE "ingredients" SET "key" = 'parmesan' WHERE "key" = 'parmesan';
UPDATE "ingredients" SET "key" = 'mozzarella' WHERE "key" = 'mozzarella';
UPDATE "ingredients" SET "key" = 'goat_cheese' WHERE "key" = 'chevre_fromage';
UPDATE "ingredients" SET "key" = 'feta' WHERE "key" = 'feta';
UPDATE "ingredients" SET "key" = 'comte' WHERE "key" = 'comte';
UPDATE "ingredients" SET "key" = 'fromage_blanc' WHERE "key" = 'fromage_blanc';
UPDATE "ingredients" SET "key" = 'mascarpone' WHERE "key" = 'mascarpone';
UPDATE "ingredients" SET "key" = 'yogurt' WHERE "key" = 'yaourt';
UPDATE "ingredients" SET "key" = 'burrata' WHERE "key" = 'burrata';
UPDATE "ingredients" SET "key" = 'ricotta' WHERE "key" = 'ricotta';
UPDATE "ingredients" SET "key" = 'pecorino' WHERE "key" = 'pecorino';
UPDATE "ingredients" SET "key" = 'gorgonzola' WHERE "key" = 'gorgonzola';
UPDATE "ingredients" SET "key" = 'cheddar' WHERE "key" = 'cheddar';
UPDATE "ingredients" SET "key" = 'egg' WHERE "key" = 'oeuf';
UPDATE "ingredients" SET "key" = 'coconut_milk' WHERE "key" = 'lait_de_coco';
UPDATE "ingredients" SET "key" = 'coconut_cream' WHERE "key" = 'creme_de_coco';
UPDATE "ingredients" SET "key" = 'almond_milk' WHERE "key" = 'lait_d_amande';
UPDATE "ingredients" SET "key" = 'oat_milk' WHERE "key" = 'lait_d_avoine';
UPDATE "ingredients" SET "key" = 'tofu' WHERE "key" = 'tofu';
UPDATE "ingredients" SET "key" = 'silken_tofu' WHERE "key" = 'tofu_soyeux';
UPDATE "ingredients" SET "key" = 'herbes_de_provence' WHERE "key" = 'herbes_de_provence';
UPDATE "ingredients" SET "key" = 'black_pepper' WHERE "key" = 'poivre_noir';
UPDATE "ingredients" SET "key" = 'paprika' WHERE "key" = 'paprika';
UPDATE "ingredients" SET "key" = 'espelette_pepper' WHERE "key" = 'piment_d_espelette';
UPDATE "ingredients" SET "key" = 'cayenne_pepper' WHERE "key" = 'piment_de_cayenne';
UPDATE "ingredients" SET "key" = 'cumin' WHERE "key" = 'cumin';
UPDATE "ingredients" SET "key" = 'curry_powder' WHERE "key" = 'curry_poudre';
UPDATE "ingredients" SET "key" = 'turmeric' WHERE "key" = 'curcuma';
UPDATE "ingredients" SET "key" = 'cinnamon' WHERE "key" = 'cannelle';
UPDATE "ingredients" SET "key" = 'nutmeg' WHERE "key" = 'muscade';
UPDATE "ingredients" SET "key" = 'saffron' WHERE "key" = 'safran';
UPDATE "ingredients" SET "key" = 'clove' WHERE "key" = 'clou_de_girofle';
UPDATE "ingredients" SET "key" = 'vanilla_bean' WHERE "key" = 'vanille_gousse';
UPDATE "ingredients" SET "key" = 'white_pepper' WHERE "key" = 'poivre_blanc';
UPDATE "ingredients" SET "key" = 'pink_pepper' WHERE "key" = 'poivre_rose';
UPDATE "ingredients" SET "key" = 'sichuan_pepper' WHERE "key" = 'poivre_du_sichuan';
UPDATE "ingredients" SET "key" = 'smoked_paprika' WHERE "key" = 'paprika_fume';
UPDATE "ingredients" SET "key" = 'bird_eye_chili' WHERE "key" = 'piment_oiseau';
UPDATE "ingredients" SET "key" = 'juniper_berries' WHERE "key" = 'baies_de_genievre';
UPDATE "ingredients" SET "key" = 'star_anise' WHERE "key" = 'anis_etoile_badiane';
UPDATE "ingredients" SET "key" = 'green_anise' WHERE "key" = 'anis_vert';
UPDATE "ingredients" SET "key" = 'fennel_seeds' WHERE "key" = 'graines_de_fenouil';
UPDATE "ingredients" SET "key" = 'sumac' WHERE "key" = 'sumac';
UPDATE "ingredients" SET "key" = 'nigella' WHERE "key" = 'nigelle';
UPDATE "ingredients" SET "key" = 'allspice' WHERE "key" = 'quatre_epices';
UPDATE "ingredients" SET "key" = 'colombo_powder' WHERE "key" = 'colombo_poudre';
UPDATE "ingredients" SET "key" = 'baharat' WHERE "key" = 'baharat';
UPDATE "ingredients" SET "key" = 'horseradish' WHERE "key" = 'raifort';
UPDATE "ingredients" SET "key" = 'herb_salt' WHERE "key" = 'sel_aux_herbes';
UPDATE "ingredients" SET "key" = 'celery_salt' WHERE "key" = 'sel_de_celeri';
UPDATE "ingredients" SET "key" = 'fleur_de_sel' WHERE "key" = 'fleur_de_sel';
UPDATE "ingredients" SET "key" = 'salt' WHERE "key" = 'sel';
UPDATE "ingredients" SET "key" = 'five_spice' WHERE "key" = 'cinq_epices';
UPDATE "ingredients" SET "key" = 'garam_masala' WHERE "key" = 'garam_masala';
UPDATE "ingredients" SET "key" = 'coriander_seeds' WHERE "key" = 'graines_de_coriandre';
UPDATE "ingredients" SET "key" = 'cardamom' WHERE "key" = 'cardamome';
UPDATE "ingredients" SET "key" = 'fenugreek' WHERE "key" = 'fenugrec';
UPDATE "ingredients" SET "key" = 'jalapeno' WHERE "key" = 'piment_jalapeno';
UPDATE "ingredients" SET "key" = 'chipotle' WHERE "key" = 'piment_chipotle';
UPDATE "ingredients" SET "key" = 'poblano_pepper' WHERE "key" = 'piment_poblano';
UPDATE "ingredients" SET "key" = 'habanero' WHERE "key" = 'piment_habanero';
UPDATE "ingredients" SET "key" = 'ras_el_hanout' WHERE "key" = 'ras_el_hanout';
UPDATE "ingredients" SET "key" = 'zaatar' WHERE "key" = 'za_atar';
UPDATE "ingredients" SET "key" = 'soy_sauce' WHERE "key" = 'sauce_soja';
UPDATE "ingredients" SET "key" = 'mustard' WHERE "key" = 'moutarde';
UPDATE "ingredients" SET "key" = 'mayonnaise' WHERE "key" = 'mayonnaise';
UPDATE "ingredients" SET "key" = 'ketchup' WHERE "key" = 'ketchup';
UPDATE "ingredients" SET "key" = 'tabasco' WHERE "key" = 'tabasco';
UPDATE "ingredients" SET "key" = 'worcestershire_sauce' WHERE "key" = 'sauce_worcestershire';
UPDATE "ingredients" SET "key" = 'fish_sauce' WHERE "key" = 'sauce_nuoc_mam';
UPDATE "ingredients" SET "key" = 'wasabi' WHERE "key" = 'wasabi';
UPDATE "ingredients" SET "key" = 'harissa' WHERE "key" = 'harissa';
UPDATE "ingredients" SET "key" = 'curry_paste' WHERE "key" = 'pate_de_curry';
UPDATE "ingredients" SET "key" = 'peanut_butter' WHERE "key" = 'beurre_de_cacahuete';
UPDATE "ingredients" SET "key" = 'dijon_mustard' WHERE "key" = 'moutarde_de_dijon';
UPDATE "ingredients" SET "key" = 'wholegrain_mustard' WHERE "key" = 'moutarde_a_l_ancienne';
UPDATE "ingredients" SET "key" = 'barbecue_sauce' WHERE "key" = 'sauce_barbecue';
UPDATE "ingredients" SET "key" = 'tartar_sauce' WHERE "key" = 'sauce_tartare';
UPDATE "ingredients" SET "key" = 'cocktail_sauce' WHERE "key" = 'sauce_cocktail';
UPDATE "ingredients" SET "key" = 'bearnaise_sauce' WHERE "key" = 'sauce_bearnaise';
UPDATE "ingredients" SET "key" = 'hollandaise_sauce' WHERE "key" = 'sauce_hollandaise';
UPDATE "ingredients" SET "key" = 'bechamel_sauce' WHERE "key" = 'sauce_bechamel';
UPDATE "ingredients" SET "key" = 'teriyaki_sauce' WHERE "key" = 'sauce_teriyaki';
UPDATE "ingredients" SET "key" = 'ponzu_sauce' WHERE "key" = 'sauce_ponzu';
UPDATE "ingredients" SET "key" = 'chimichurri' WHERE "key" = 'chimichurri';
UPDATE "ingredients" SET "key" = 'red_pesto' WHERE "key" = 'pesto_rouge_tomates_sechees';
UPDATE "ingredients" SET "key" = 'pesto' WHERE "key" = 'pesto';
UPDATE "ingredients" SET "key" = 'oyster_sauce' WHERE "key" = 'sauce_huitre';
UPDATE "ingredients" SET "key" = 'hoisin_sauce' WHERE "key" = 'sauce_hoisin';
UPDATE "ingredients" SET "key" = 'sriracha' WHERE "key" = 'sauce_sriracha';
UPDATE "ingredients" SET "key" = 'sweet_chili_sauce' WHERE "key" = 'sauce_sweet_chili';
UPDATE "ingredients" SET "key" = 'miso' WHERE "key" = 'miso';
UPDATE "ingredients" SET "key" = 'shrimp_paste' WHERE "key" = 'pate_de_crevettes';
UPDATE "ingredients" SET "key" = 'red_curry_paste' WHERE "key" = 'pate_de_curry_rouge_thai';
UPDATE "ingredients" SET "key" = 'green_curry_paste' WHERE "key" = 'pate_de_curry_vert_thai';
UPDATE "ingredients" SET "key" = 'tahini' WHERE "key" = 'tahini';
UPDATE "ingredients" SET "key" = 'olive_oil' WHERE "key" = 'huile_d_olive';
UPDATE "ingredients" SET "key" = 'sunflower_oil' WHERE "key" = 'huile_de_tournesol';
UPDATE "ingredients" SET "key" = 'rapeseed_oil' WHERE "key" = 'huile_de_colza';
UPDATE "ingredients" SET "key" = 'coconut_oil' WHERE "key" = 'huile_de_coco';
UPDATE "ingredients" SET "key" = 'sesame_oil' WHERE "key" = 'huile_de_sesame';
UPDATE "ingredients" SET "key" = 'cider_vinegar' WHERE "key" = 'vinaigre_de_cidre';
UPDATE "ingredients" SET "key" = 'white_vinegar' WHERE "key" = 'vinaigre_blanc';
UPDATE "ingredients" SET "key" = 'balsamic_vinegar' WHERE "key" = 'vinaigre_balsamique';
UPDATE "ingredients" SET "key" = 'capers' WHERE "key" = 'capres';
UPDATE "ingredients" SET "key" = 'olives' WHERE "key" = 'olives';
UPDATE "ingredients" SET "key" = 'white_wine' WHERE "key" = 'vin_blanc_cuisine';
UPDATE "ingredients" SET "key" = 'red_wine' WHERE "key" = 'vin_rouge_cuisine';
UPDATE "ingredients" SET "key" = 'red_wine_vinegar' WHERE "key" = 'vinaigre_de_vin_rouge';
UPDATE "ingredients" SET "key" = 'white_wine_vinegar' WHERE "key" = 'vinaigre_de_vin_blanc';
UPDATE "ingredients" SET "key" = 'sherry_vinegar' WHERE "key" = 'vinaigre_de_xeres';
UPDATE "ingredients" SET "key" = 'walnut_oil' WHERE "key" = 'huile_de_noix';
UPDATE "ingredients" SET "key" = 'hazelnut_oil' WHERE "key" = 'huile_de_noisette';
UPDATE "ingredients" SET "key" = 'peanut_oil' WHERE "key" = 'huile_d_arachide';
UPDATE "ingredients" SET "key" = 'chili_oil' WHERE "key" = 'huile_pimentee';
UPDATE "ingredients" SET "key" = 'rice_vinegar' WHERE "key" = 'vinaigre_de_riz';
UPDATE "ingredients" SET "key" = 'mirin' WHERE "key" = 'mirin';
UPDATE "ingredients" SET "key" = 'sake' WHERE "key" = 'sake_cuisine';
UPDATE "ingredients" SET "key" = 'lemon_juice' WHERE "key" = 'jus_de_citron';
UPDATE "ingredients" SET "key" = 'lime_juice' WHERE "key" = 'jus_de_citron_vert';
UPDATE "ingredients" SET "key" = 'orange_juice' WHERE "key" = 'jus_d_orange';
UPDATE "ingredients" SET "key" = 'apple_juice' WHERE "key" = 'jus_de_pomme';
UPDATE "ingredients" SET "key" = 'grape_juice' WHERE "key" = 'jus_de_raisin';
UPDATE "ingredients" SET "key" = 'tomato_juice' WHERE "key" = 'jus_de_tomate';
UPDATE "ingredients" SET "key" = 'cranberry_juice' WHERE "key" = 'jus_de_cranberry';
UPDATE "ingredients" SET "key" = 'coffee' WHERE "key" = 'cafe';
UPDATE "ingredients" SET "key" = 'tea' WHERE "key" = 'the';
UPDATE "ingredients" SET "key" = 'beer' WHERE "key" = 'biere_cuisine';
UPDATE "ingredients" SET "key" = 'cider' WHERE "key" = 'cidre_cuisine';
UPDATE "ingredients" SET "key" = 'champagne' WHERE "key" = 'champagne_vin_petillant_cuisine';
UPDATE "ingredients" SET "key" = 'port_wine' WHERE "key" = 'porto_cuisine';
UPDATE "ingredients" SET "key" = 'vin_jaune' WHERE "key" = 'vin_jaune_cuisine';
UPDATE "ingredients" SET "key" = 'cognac' WHERE "key" = 'cognac';
UPDATE "ingredients" SET "key" = 'rum' WHERE "key" = 'rhum';
UPDATE "ingredients" SET "key" = 'whisky' WHERE "key" = 'whisky';
UPDATE "ingredients" SET "key" = 'vodka' WHERE "key" = 'vodka';
UPDATE "ingredients" SET "key" = 'wheat_flour' WHERE "key" = 'farine_de_ble';
UPDATE "ingredients" SET "key" = 'whole_wheat_flour' WHERE "key" = 'farine_complete';
UPDATE "ingredients" SET "key" = 'corn_flour' WHERE "key" = 'farine_de_mais';
UPDATE "ingredients" SET "key" = 'buckwheat_flour' WHERE "key" = 'farine_de_sarrasin';
UPDATE "ingredients" SET "key" = 'rice_flour' WHERE "key" = 'farine_de_riz';
UPDATE "ingredients" SET "key" = 'vegetable_stock_cube' WHERE "key" = 'bouillon_cube_legumes';
UPDATE "ingredients" SET "key" = 'chicken_stock_cube' WHERE "key" = 'bouillon_cube_volaille';
UPDATE "ingredients" SET "key" = 'tomato_paste' WHERE "key" = 'concentre_de_tomate';
UPDATE "ingredients" SET "key" = 'tomato_coulis' WHERE "key" = 'coulis_de_tomate';
UPDATE "ingredients" SET "key" = 'canned_peeled_tomatoes' WHERE "key" = 'tomates_pelees_conserve';
UPDATE "ingredients" SET "key" = 'sun_dried_tomatoes' WHERE "key" = 'tomates_sechees';
UPDATE "ingredients" SET "key" = 'veal_stock' WHERE "key" = 'fond_de_veau';
UPDATE "ingredients" SET "key" = 'chicken_stock' WHERE "key" = 'fond_de_volaille';
UPDATE "ingredients" SET "key" = 'beef_stock_cube' WHERE "key" = 'bouillon_cube_boeuf';
UPDATE "ingredients" SET "key" = 'fish_stock_cube' WHERE "key" = 'bouillon_cube_poisson';
UPDATE "ingredients" SET "key" = 'vegetable_broth' WHERE "key" = 'bouillon_de_legumes';
UPDATE "ingredients" SET "key" = 'chicken_broth' WHERE "key" = 'bouillon_de_volaille';
UPDATE "ingredients" SET "key" = 'beef_broth' WHERE "key" = 'bouillon_de_boeuf';
UPDATE "ingredients" SET "key" = 'court_bouillon' WHERE "key" = 'court_bouillon';
UPDATE "ingredients" SET "key" = 'dashi' WHERE "key" = 'dashi_bouillon_japonais';
UPDATE "ingredients" SET "key" = 'shellfish_bisque' WHERE "key" = 'bisque_de_crustaces';
UPDATE "ingredients" SET "key" = 'tapioca_flour' WHERE "key" = 'farine_de_tapioca';
UPDATE "ingredients" SET "key" = 'masa_harina' WHERE "key" = 'masa_harina';
UPDATE "ingredients" SET "key" = 'water' WHERE "key" = 'eau';
UPDATE "ingredients" SET "key" = 'sparkling_water' WHERE "key" = 'eau_gazeuse';
UPDATE "ingredients" SET "key" = 'orange_blossom_water' WHERE "key" = 'eau_de_fleur_d_oranger';
UPDATE "ingredients" SET "key" = 'rose_water' WHERE "key" = 'eau_de_rose';
UPDATE "ingredients" SET "key" = 'fish_fumet' WHERE "key" = 'fumet_de_poisson';
UPDATE "ingredients" SET "key" = 'bakers_yeast' WHERE "key" = 'levure_boulangere';
UPDATE "ingredients" SET "key" = 'baking_powder' WHERE "key" = 'levure_chimique';
UPDATE "ingredients" SET "key" = 'cornstarch' WHERE "key" = 'maizena';
UPDATE "ingredients" SET "key" = 'lupin_flour' WHERE "key" = 'farine_de_lupin';
UPDATE "ingredients" SET "key" = 'gelatin' WHERE "key" = 'gelatine';
UPDATE "ingredients" SET "key" = 'baking_soda' WHERE "key" = 'bicarbonate_de_soude';
UPDATE "ingredients" SET "key" = 'potato_starch' WHERE "key" = 'fecule_de_pomme_de_terre';
UPDATE "ingredients" SET "key" = 'sugar' WHERE "key" = 'sucre';
UPDATE "ingredients" SET "key" = 'honey' WHERE "key" = 'miel';
UPDATE "ingredients" SET "key" = 'maple_syrup' WHERE "key" = 'sirop_d_erable';
UPDATE "ingredients" SET "key" = 'brown_sugar' WHERE "key" = 'sucre_roux';
UPDATE "ingredients" SET "key" = 'powdered_sugar' WHERE "key" = 'sucre_glace';
UPDATE "ingredients" SET "key" = 'demerara_sugar' WHERE "key" = 'cassonade';
UPDATE "ingredients" SET "key" = 'dark_chocolate' WHERE "key" = 'chocolat_noir';
UPDATE "ingredients" SET "key" = 'milk_chocolate' WHERE "key" = 'chocolat_au_lait';
UPDATE "ingredients" SET "key" = 'white_chocolate' WHERE "key" = 'chocolat_blanc';
UPDATE "ingredients" SET "key" = 'chocolate_chips' WHERE "key" = 'pepites_de_chocolat';
UPDATE "ingredients" SET "key" = 'cocoa_powder' WHERE "key" = 'cacao_en_poudre';
UPDATE "ingredients" SET "key" = 'vanilla_extract' WHERE "key" = 'extrait_de_vanille';
UPDATE "ingredients" SET "key" = 'palm_sugar' WHERE "key" = 'sucre_de_palme';
UPDATE "ingredients" SET "key" = 'cane_syrup' WHERE "key" = 'sirop_de_sucre_de_canne';

View file

@ -34,13 +34,17 @@ model House {
@@map("house") @@map("house")
} }
/// `name` is `@unique` — not in the original spec doc, added so the seed /// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (prisma/seed.ts) can `upsert` by name and stay idempotent/safe to /// script (prisma/seed.ts) can `upsert` by key and stay idempotent/safe to
/// re-run, and so two reference rows can never silently duplicate the same /// re-run, and so two reference rows can never silently duplicate the same
/// regime. /// regime. A stable slug (e.g. `"vegetarien"`), not the display label —
/// the label itself lives in `apps/web`'s `locales/fr/translation.json`
/// under `catalog.diets.<key>` (see `reference-seed-data.ts`'s `DIETS` and
/// `utils/slugify.ts`), so it can be edited/translated without ever
/// touching this column or the rows that reference it by id.
model Diet { model Diet {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique key String @unique
users UserProfile[] users UserProfile[]
recipes RecipeDiet[] recipes RecipeDiet[]
@ -59,11 +63,13 @@ enum AllergenKind {
} }
/// Enumeration-style table, meant to grow over time (e.g. allergy nuances). /// Enumeration-style table, meant to grow over time (e.g. allergy nuances).
/// `name` is `@unique` for the same reason as `Diet.name` above. `kind` is /// `key` is `@unique` for the same reason as `Diet.key` above — a stable
/// also not in the original spec doc — see {@link AllergenKind}. /// slug (`catalog.allergens.<key>` in `apps/web`'s locale file), not the
/// display label. `kind` is also not in the original spec doc — see
/// {@link AllergenKind}.
model Category { model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique key String @unique
kind AllergenKind @default(ALLERGY) kind AllergenKind @default(ALLERGY)
allergies Allergy[] allergies Allergy[]
@ -288,9 +294,11 @@ model RecipeDiet {
@@map("recipe_diet") @@map("recipe_diet")
} }
/// `name` is `@unique` — not in the original spec doc, added so the seed /// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by name and stay /// script (reference-seed-data.ts) can `upsert` by key and stay
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`. /// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
/// stable slug (`catalog.ingredients.<key>` in `apps/web`'s locale file),
/// not the display label.
/// Ingredients are reference data (like Diet/Allergy): seeded, never /// Ingredients are reference data (like Diet/Allergy): seeded, never
/// created/edited/deleted through the API. /// created/edited/deleted through the API.
/// Not in the original spec doc — supermarket-aisle grouping ("rayons") so /// Not in the original spec doc — supermarket-aisle grouping ("rayons") so
@ -424,7 +432,7 @@ enum IngredientIcon {
model Ingredient { model Ingredient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique key String @unique
icon IngredientIcon @default(JAR) icon IngredientIcon @default(JAR)
category IngredientCategory @default(EPICERIE_SECHE) category IngredientCategory @default(EPICERIE_SECHE)
subcategory IngredientSubcategory @default(AUTRES) subcategory IngredientSubcategory @default(AUTRES)

View file

@ -0,0 +1,49 @@
/**
* One-off generator, run by hand whenever the catalog's reference data
* changes (a new ingredient/diet/allergen added to
* `db/reference-seed-data.ts`, or an English key corrected in
* `catalog-en-keys.ts`): regenerates
* `apps/web/src/locales/fr/translation.json`'s
* `catalog.{diets,allergens,ingredients}` sections (English key -> French
* label), merged in without touching the rest of the file.
*
* Doesn't touch the database a brand new diet/allergen/ingredient is
* created fresh by `seedReferenceData`'s normal create path (see
* `reference-seed-data.ts`), no backfill needed. Renaming an *existing*
* item's English key in `catalog-en-keys.ts` does need a one-off migration
* (`UPDATE ... SET key = ...`, keyed by the *old* key value) written by
* hand for that occasion see
* `prisma/migrations/20260818193000_catalog_keys_to_english/` for the shape
* one looks like.
*
* Never imported by the app itself a dev-time tool, run via
* `tsx scripts/generate-catalog-i18n.ts`.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { ALLERGENS, DIETS, INGREDIENT_GROUPS } from "../src/db/reference-seed-data.js";
const here = fileURLToPath(new URL(".", import.meta.url));
function toKeyLabelMap(labels: string[]): Record<string, string> {
const map: Record<string, string> = {};
for (const label of labels) {
map[getEnglishKey(label)] = label;
}
return map;
}
const diets = toKeyLabelMap(DIETS);
const allergens = toKeyLabelMap(ALLERGENS.map((a) => a.name));
const ingredients = toKeyLabelMap(INGREDIENT_GROUPS.flatMap((g) => g.items.map((i) => i.name)));
console.log(
`diets: ${Object.keys(diets).length}, allergens: ${Object.keys(allergens).length}, ingredients: ${Object.keys(ingredients).length}`,
);
const localePath = `${here}../../web/src/locales/fr/translation.json`;
const locale = JSON.parse(readFileSync(localePath, "utf8"));
locale.catalog = { diets, allergens, ingredients };
writeFileSync(localePath, `${JSON.stringify(locale, null, 2)}\n`);
console.log(`wrote ${localePath}`);

View file

@ -0,0 +1,46 @@
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
/**
* One-off validation, run by hand: checks that `catalog-en-keys.ts` has an
* entry for every diet/allergen/ingredient currently in
* `reference-seed-data.ts`, and that the resulting English keys are unique
* within each table. Not part of the app or the seed itself just a
* pre-flight check while building/editing the dictionary by hand.
*/
import { ALLERGENS, DIETS, INGREDIENT_GROUPS } from "../src/db/reference-seed-data.js";
function check(label: string, names: string[]) {
const keys = new Map<string, string>();
const missing: string[] = [];
const duplicates: string[] = [];
for (const name of names) {
let key: string;
try {
key = getEnglishKey(name);
} catch {
missing.push(name);
continue;
}
const existing = keys.get(key);
if (existing !== undefined && existing !== name) {
duplicates.push(`"${existing}" and "${name}" both map to "${key}"`);
}
keys.set(key, name);
}
console.log(`${label}: ${names.length} names, ${keys.size} unique keys`);
if (missing.length > 0) {
console.log(` MISSING (${missing.length}):`, missing);
}
if (duplicates.length > 0) {
console.log(` DUPLICATES (${duplicates.length}):`, duplicates);
}
}
check("Diets", DIETS);
check(
"Allergens",
ALLERGENS.map((a) => a.name),
);
check(
"Ingredients",
INGREDIENT_GROUPS.flatMap((g) => g.items.map((i) => i.name)),
);

View file

@ -0,0 +1,557 @@
/**
* English key for every catalog reference label `Diet`/`Category`
* (allergens)/`Ingredient` rows must carry a stable, storage-safe `key`
* that is itself English, independent of whatever language the *seed's*
* authoring label (`DIETS`/`ALLERGENS`/`INGREDIENT_GROUPS` in
* `reference-seed-data.ts`, currently French) happens to be in a French
* `key` would tie the identifier to the one language it's meant to be
* decoupled from (see `utils/slugify.ts`'s doc comment and `apps/web`'s
* `locales/fr/translation.json` `catalog.*` namespace, which resolves the
* *display* label from this same key).
*
* Hand-assigned (not machine-translated) an English label is chosen once
* and never changes, exactly like the key it produces (via {@link
* slugify}). Keyed by the exact French authoring label so
* `reference-seed-data.ts` and `scripts/generate-catalog-i18n.ts` can look
* a row's key up by the same string they already have in hand.
*
* `getEnglishKey` throws on a missing entry rather than falling back to
* slugifying the French label a silently-French key defeats the point,
* so a newly-added diet/allergen/ingredient must get an entry here before
* it can seed.
*/
import { slugify } from "../utils/slugify.js";
const DIET_KEYS: Record<string, string> = {
Omnivore: "omnivore",
Végétarien: "vegetarian",
Végan: "vegan",
Pescétarien: "pescatarian",
"Sans gluten": "gluten_free",
};
const ALLERGEN_KEYS: Record<string, string> = {
Gluten: "gluten",
Crustacés: "crustaceans",
Œufs: "eggs",
Poissons: "fish",
Arachides: "peanuts",
Soja: "soy",
Lait: "milk",
"Fruits à coque": "tree_nuts",
Céleri: "celery",
Moutarde: "mustard",
"Graines de sésame": "sesame_seeds",
Sulfites: "sulfites",
Lupin: "lupin",
Mollusques: "molluscs",
};
// Grouped by (category, subcategory), mirroring `INGREDIENT_GROUPS` in
// reference-seed-data.ts, purely so a translator can find/check an entry
// against its source group — this is one flat lookup table at runtime.
const INGREDIENT_KEYS: Record<string, string> = {
// --- Produits frais / Légumes ---------------------------------------
Tomate: "tomato",
Oignon: "onion",
Échalote: "shallot",
Ail: "garlic",
Carotte: "carrot",
Courgette: "zucchini",
Concombre: "cucumber",
Cornichons: "gherkins",
Poivron: "bell_pepper",
Champignon: "mushroom",
Cèpes: "porcini",
Aubergine: "eggplant",
Brocoli: "broccoli",
"Chou-fleur": "cauliflower",
"Chou blanc": "white_cabbage",
"Chou rouge": "red_cabbage",
"Chou de Bruxelles": "brussels_sprouts",
Épinard: "spinach",
Blette: "swiss_chard",
Salade: "lettuce",
Roquette: "arugula",
Cresson: "watercress",
Poireau: "leek",
Radis: "radish",
Betterave: "beetroot",
Navet: "turnip",
Panais: "parsnip",
"Haricot vert": "green_bean",
"Petit pois": "pea",
Maïs: "corn",
Artichaut: "artichoke",
Fenouil: "fennel",
Endive: "endive",
Potiron: "pumpkin",
Butternut: "butternut_squash",
Asperge: "asparagus",
Avocat: "avocado",
"Pomme de terre": "potato",
"Patate douce": "sweet_potato",
"Tomates cerises": "cherry_tomato",
"Pak-choï": "bok_choy",
"Germes de soja": "soybean_sprouts",
Shiitake: "shiitake",
Daikon: "daikon",
"Piment vert frais": "fresh_green_chili",
// --- Produits frais / Fruits -----------------------------------------
Citron: "lemon",
"Citron vert": "lime",
Pomme: "apple",
Poire: "pear",
Banane: "banana",
Orange: "orange",
Clémentine: "clementine",
Pamplemousse: "grapefruit",
Fraise: "strawberry",
Framboise: "raspberry",
Myrtille: "blueberry",
Mûre: "blackberry",
Cerise: "cherry",
Abricot: "apricot",
Pêche: "peach",
Prune: "plum",
Raisin: "grape",
Melon: "melon",
Pastèque: "watermelon",
Ananas: "pineapple",
Mangue: "mango",
Kiwi: "kiwi",
Figue: "fig",
Datte: "date",
Litchi: "lychee",
Grenade: "pomegranate",
Rhubarbe: "rhubarb",
Coing: "quince",
// --- Produits frais / Herbes fraîches ---------------------------------
Basilic: "basil",
Persil: "parsley",
Thym: "thyme",
Romarin: "rosemary",
Laurier: "bay_leaf",
Ciboulette: "chives",
"Coriandre fraîche": "fresh_cilantro",
Menthe: "mint",
Origan: "oregano",
Aneth: "dill",
Estragon: "tarragon",
Sarriette: "savory",
Marjolaine: "marjoram",
Sauge: "sage",
Cerfeuil: "chervil",
Gingembre: "ginger",
Citronnelle: "lemongrass",
Combava: "kaffir_lime",
// --- Boucherie & poissonnerie / Viandes -------------------------------
Lapin: "rabbit",
"Bœuf haché": "ground_beef",
"Steak de bœuf": "beef_steak",
"Rôti de bœuf": "beef_roast",
"Escalope de veau": "veal_cutlet",
"Filet mignon de porc": "pork_tenderloin",
"Côte de porc": "pork_chop",
Agneau: "lamb",
"Gigot d'agneau": "leg_of_lamb",
Lardons: "bacon_lardons",
Bacon: "bacon",
"Jambon blanc": "ham",
"Jambon cru": "cured_ham",
Saucisse: "sausage",
Chorizo: "chorizo",
Merguez: "merguez",
Prosciutto: "prosciutto",
Pancetta: "pancetta",
Mortadelle: "mortadella",
Salami: "salami",
// --- Boucherie & poissonnerie / Volailles -----------------------------
Poulet: "chicken",
Dinde: "turkey",
Canard: "duck",
"Magret de canard": "duck_breast",
// --- Boucherie & poissonnerie / Poissons -------------------------------
Saumon: "salmon",
Thon: "tuna",
Cabillaud: "cod",
Truite: "trout",
Sardine: "sardine",
Anchois: "anchovy",
Merlan: "whiting",
Surimi: "surimi",
"Bar (loup de mer)": "sea_bass",
Dorade: "sea_bream",
Sole: "sole",
Turbot: "turbot",
Merlu: "hake",
Colin: "pollock",
"Lieu noir": "saithe",
Églefin: "haddock",
Maquereau: "mackerel",
Hareng: "herring",
Rouget: "red_mullet",
Raie: "skate",
Lotte: "monkfish",
Flétan: "halibut",
Espadon: "swordfish",
Carpe: "carp",
Brochet: "pike",
Perche: "perch",
Tilapia: "tilapia",
Panga: "pangasius",
"Saumon fumé": "smoked_salmon",
"Poisson séché": "dried_fish",
// --- Boucherie & poissonnerie / Crustacés & fruits de mer -------------
Crevettes: "shrimp",
Langoustines: "langoustine",
Homard: "lobster",
Crabe: "crab",
Langouste: "spiny_lobster",
Moules: "mussels",
Huîtres: "oysters",
"Saint-Jacques": "scallops",
Calamar: "squid",
Poulpe: "octopus",
Palourdes: "clams",
Bulots: "whelks",
// --- Épicerie sèche / Féculents ----------------------------------------
Semoule: "semolina",
Couscous: "couscous",
Boulgour: "bulgur",
Polenta: "polenta",
Quinoa: "quinoa",
Pâtes: "pasta",
"Pâtes complètes": "whole_wheat_pasta",
Riz: "rice",
"Riz basmati": "basmati_rice",
"Riz complet": "brown_rice",
"Flocons d'avoine": "oats",
Spaghetti: "spaghetti",
Penne: "penne",
Tagliatelles: "tagliatelle",
"Lasagnes (feuilles)": "lasagna_sheets",
Gnocchi: "gnocchi",
"Riz arborio": "arborio_rice",
"Nouilles de riz": "rice_noodles",
"Nouilles udon": "udon_noodles",
"Nouilles soba": "soba_noodles",
"Nouilles chinoises": "chinese_noodles",
"Vermicelles de riz": "rice_vermicelli",
"Vermicelles de soja": "soy_vermicelli",
"Riz gluant": "sticky_rice",
"Riz à sushi": "sushi_rice",
"Riz jasmin": "jasmine_rice",
// --- Épicerie sèche / Légumineuses --------------------------------------
"Lentilles vertes": "green_lentils",
"Lentilles corail": "red_lentils",
"Pois chiches": "chickpeas",
"Haricots blancs": "white_beans",
"Haricots rouges": "kidney_beans",
"Haricots noirs": "black_beans",
"Pois cassés": "split_peas",
Fèves: "fava_beans",
Edamame: "edamame",
"Haricots pinto": "pinto_beans",
// --- Épicerie sèche / Graines & fruits secs ----------------------------
Cacahuètes: "peanuts_shelled",
Amandes: "almonds",
Noix: "walnuts",
Noisettes: "hazelnuts",
"Noix de cajou": "cashews",
Pistaches: "pistachios",
"Noix de pécan": "pecans",
"Poudre d'amande": "almond_powder",
"Pignons de pin": "pine_nuts",
"Graines de tournesol": "sunflower_seeds",
"Graines de courge": "pumpkin_seeds",
"Noix de coco râpée": "shredded_coconut",
"Raisins secs": "raisins",
Pruneaux: "prunes",
"Abricots secs": "dried_apricots",
// --- Épicerie sèche / Autres --------------------------------------------
"Champignons noirs": "black_mushrooms",
"Algue nori": "nori_seaweed",
"Algue wakamé": "wakame_seaweed",
"Algue kombu": "kombu_seaweed",
"Pousses de bambou": "bamboo_shoots",
"Châtaignes d'eau": "water_chestnuts",
// --- Boulangerie / Pains -------------------------------------------------
Pain: "bread",
"Pain de mie": "sandwich_bread",
"Pain complet": "whole_wheat_bread",
Baguette: "baguette",
"Pain de seigle": "rye_bread",
Chapelure: "breadcrumbs",
"Pain à burger": "burger_bun",
"Pain brioché": "brioche_bun",
"Pain à hot-dog": "hot_dog_bun",
"Pain pita": "pita_bread",
"Pain bagel": "bagel",
Naan: "naan",
"Pain wrap": "wrap_bread",
"Pain viennois": "viennese_bread",
"Pain de campagne": "country_bread",
"Pain aux céréales": "multigrain_bread",
"Petit pain": "bread_roll",
"Pain suédois": "swedish_bread",
"Pain sans gluten": "gluten_free_bread",
Biscotte: "rusk",
Croûtons: "croutons",
Focaccia: "focaccia",
Ciabatta: "ciabatta",
"Tortilla de maïs": "corn_tortilla",
"Tortilla de blé": "wheat_tortilla",
// --- Boulangerie / Pâtes à cuire -----------------------------------------
"Pâte feuilletée": "puff_pastry",
"Pâte brisée": "shortcrust_pastry",
"Pâte à pizza": "pizza_dough",
"Pâte à tarte sablée": "sweet_shortcrust_pastry",
// --- Crémerie & fromage / Produits laitiers ------------------------------
Lait: "milk",
Beurre: "butter",
"Crème fraîche": "creme_fraiche",
"Crème liquide": "liquid_cream",
Fromage: "cheese",
Emmental: "emmental",
Gruyère: "gruyere",
Parmesan: "parmesan",
Mozzarella: "mozzarella",
"Chèvre (fromage)": "goat_cheese",
Feta: "feta",
Comté: "comte",
"Fromage blanc": "fromage_blanc",
Mascarpone: "mascarpone",
Yaourt: "yogurt",
Burrata: "burrata",
Ricotta: "ricotta",
Pecorino: "pecorino",
Gorgonzola: "gorgonzola",
Cheddar: "cheddar",
// --- Crémerie & fromage / Œufs -------------------------------------------
Œuf: "egg",
// --- Crémerie & fromage / Alternatives ------------------------------------
"Lait de coco": "coconut_milk",
"Crème de coco": "coconut_cream",
"Lait d'amande": "almond_milk",
"Lait d'avoine": "oat_milk",
Tofu: "tofu",
"Tofu soyeux": "silken_tofu",
// --- Condiments & épices / Épices -----------------------------------------
"Herbes de Provence": "herbes_de_provence",
"Poivre noir": "black_pepper",
Paprika: "paprika",
"Piment d'Espelette": "espelette_pepper",
"Piment de Cayenne": "cayenne_pepper",
Cumin: "cumin",
"Curry (poudre)": "curry_powder",
Curcuma: "turmeric",
Cannelle: "cinnamon",
Muscade: "nutmeg",
Safran: "saffron",
"Clou de girofle": "clove",
"Vanille (gousse)": "vanilla_bean",
"Poivre blanc": "white_pepper",
"Poivre rose": "pink_pepper",
"Poivre du Sichuan": "sichuan_pepper",
"Paprika fumé": "smoked_paprika",
"Piment oiseau": "bird_eye_chili",
"Baies de genièvre": "juniper_berries",
"Anis étoilé (badiane)": "star_anise",
"Anis vert": "green_anise",
"Graines de fenouil": "fennel_seeds",
Sumac: "sumac",
Nigelle: "nigella",
"Quatre épices": "allspice",
"Colombo (poudre)": "colombo_powder",
Baharat: "baharat",
Raifort: "horseradish",
"Sel aux herbes": "herb_salt",
"Sel de céleri": "celery_salt",
"Fleur de sel": "fleur_de_sel",
Sel: "salt",
"Cinq épices": "five_spice",
"Garam masala": "garam_masala",
"Graines de coriandre": "coriander_seeds",
Cardamome: "cardamom",
Fenugrec: "fenugreek",
"Piment jalapeño": "jalapeno",
"Piment chipotle": "chipotle",
"Piment poblano": "poblano_pepper",
"Piment habanero": "habanero",
"Ras el hanout": "ras_el_hanout",
"Za'atar": "zaatar",
// --- Condiments & épices / Sauces -------------------------------------------
"Sauce soja": "soy_sauce",
Moutarde: "mustard",
Mayonnaise: "mayonnaise",
Ketchup: "ketchup",
Tabasco: "tabasco",
"Sauce Worcestershire": "worcestershire_sauce",
"Sauce nuoc-mâm": "fish_sauce",
Wasabi: "wasabi",
Harissa: "harissa",
"Pâte de curry": "curry_paste",
"Beurre de cacahuète": "peanut_butter",
"Moutarde de Dijon": "dijon_mustard",
"Moutarde à l'ancienne": "wholegrain_mustard",
"Sauce barbecue": "barbecue_sauce",
"Sauce tartare": "tartar_sauce",
"Sauce cocktail": "cocktail_sauce",
"Sauce béarnaise": "bearnaise_sauce",
"Sauce hollandaise": "hollandaise_sauce",
"Sauce béchamel": "bechamel_sauce",
"Sauce teriyaki": "teriyaki_sauce",
"Sauce ponzu": "ponzu_sauce",
Chimichurri: "chimichurri",
"Pesto rouge (tomates séchées)": "red_pesto",
Pesto: "pesto",
"Sauce huître": "oyster_sauce",
"Sauce hoisin": "hoisin_sauce",
"Sauce sriracha": "sriracha",
"Sauce sweet chili": "sweet_chili_sauce",
Miso: "miso",
"Pâte de crevettes": "shrimp_paste",
"Pâte de curry rouge (thaï)": "red_curry_paste",
"Pâte de curry vert (thaï)": "green_curry_paste",
Tahini: "tahini",
// --- Condiments & épices / Assaisonnements -----------------------------------
"Huile d'olive": "olive_oil",
"Huile de tournesol": "sunflower_oil",
"Huile de colza": "rapeseed_oil",
"Huile de coco": "coconut_oil",
"Huile de sésame": "sesame_oil",
"Vinaigre de cidre": "cider_vinegar",
"Vinaigre blanc": "white_vinegar",
"Vinaigre balsamique": "balsamic_vinegar",
Câpres: "capers",
Olives: "olives",
"Vin blanc (cuisine)": "white_wine",
"Vin rouge (cuisine)": "red_wine",
"Vinaigre de vin rouge": "red_wine_vinegar",
"Vinaigre de vin blanc": "white_wine_vinegar",
"Vinaigre de xérès": "sherry_vinegar",
"Huile de noix": "walnut_oil",
"Huile de noisette": "hazelnut_oil",
"Huile d'arachide": "peanut_oil",
"Huile pimentée": "chili_oil",
"Vinaigre de riz": "rice_vinegar",
Mirin: "mirin",
"Saké (cuisine)": "sake",
"Jus de citron": "lemon_juice",
"Jus de citron vert": "lime_juice",
"Jus d'orange": "orange_juice",
"Jus de pomme": "apple_juice",
"Jus de raisin": "grape_juice",
"Jus de tomate": "tomato_juice",
"Jus de cranberry": "cranberry_juice",
Café: "coffee",
Thé: "tea",
"Bière (cuisine)": "beer",
"Cidre (cuisine)": "cider",
"Champagne / vin pétillant (cuisine)": "champagne",
"Porto (cuisine)": "port_wine",
"Vin jaune (cuisine)": "vin_jaune",
Cognac: "cognac",
Rhum: "rum",
Whisky: "whisky",
Vodka: "vodka",
// --- Aides culinaires / Bases -------------------------------------------
"Farine de blé": "wheat_flour",
"Farine complète": "whole_wheat_flour",
"Farine de maïs": "corn_flour",
"Farine de sarrasin": "buckwheat_flour",
"Farine de riz": "rice_flour",
"Bouillon cube légumes": "vegetable_stock_cube",
"Bouillon cube volaille": "chicken_stock_cube",
"Concentré de tomate": "tomato_paste",
"Coulis de tomate": "tomato_coulis",
"Tomates pelées (conserve)": "canned_peeled_tomatoes",
"Tomates séchées": "sun_dried_tomatoes",
"Fond de veau": "veal_stock",
"Fond de volaille": "chicken_stock",
"Bouillon cube bœuf": "beef_stock_cube",
"Bouillon cube poisson": "fish_stock_cube",
"Bouillon de légumes": "vegetable_broth",
"Bouillon de volaille": "chicken_broth",
"Bouillon de bœuf": "beef_broth",
"Court-bouillon": "court_bouillon",
"Dashi (bouillon japonais)": "dashi",
"Bisque de crustacés": "shellfish_bisque",
"Farine de tapioca": "tapioca_flour",
"Masa harina": "masa_harina",
Eau: "water",
"Eau gazeuse": "sparkling_water",
"Eau de fleur d'oranger": "orange_blossom_water",
"Eau de rose": "rose_water",
"Fumet de poisson": "fish_fumet",
// --- Aides culinaires / Épaississants -------------------------------------
"Levure boulangère": "bakers_yeast",
"Levure chimique": "baking_powder",
Maïzena: "cornstarch",
"Farine de lupin": "lupin_flour",
Gélatine: "gelatin",
"Bicarbonate de soude": "baking_soda",
"Fécule de pomme de terre": "potato_starch",
// --- Aides culinaires / Sucres ---------------------------------------------
Sucre: "sugar",
Miel: "honey",
"Sirop d'érable": "maple_syrup",
"Sucre roux": "brown_sugar",
"Sucre glace": "powdered_sugar",
Cassonade: "demerara_sugar",
"Chocolat noir": "dark_chocolate",
"Chocolat au lait": "milk_chocolate",
"Chocolat blanc": "white_chocolate",
"Pépites de chocolat": "chocolate_chips",
"Cacao en poudre": "cocoa_powder",
"Extrait de vanille": "vanilla_extract",
"Sucre de palme": "palm_sugar",
"Sirop de sucre de canne": "cane_syrup",
};
const ALL_KEYS: Record<string, string> = {
...DIET_KEYS,
...ALLERGEN_KEYS,
...INGREDIENT_KEYS,
};
/**
* Resolves a seed-time French authoring label to its English `key`
* throws if it's missing an entry above rather than silently falling back
* to a French slug, since a new diet/allergen/ingredient needs a
* deliberately-chosen English key before it can seed at all.
* `slugify` still runs over the result so a stray character/casing slip in
* the table above can't produce a key that doesn't match the
* `[a-z0-9_]`-only shape every other key has.
*/
export function getEnglishKey(frenchLabel: string): string {
const english = ALL_KEYS[frenchLabel];
if (english === undefined) {
throw new Error(
`No English key registered for "${frenchLabel}" — add one to catalog-en-keys.ts`,
);
}
return slugify(english);
}

View file

@ -5,10 +5,14 @@ import type {
IngredientSubcategory, IngredientSubcategory,
PrismaClient, PrismaClient,
} from "@prisma/client"; } from "@prisma/client";
import { getEnglishKey } from "./catalog-en-keys.js";
// Short, optional-to-pick regime list — `UserProfile.dietId` stays // Short, optional-to-pick regime list — `UserProfile.dietId` stays
// nullable, this is not meant to be exhaustive. // nullable, this is not meant to be exhaustive. Exported for
const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans gluten"]; // `apps/api/scripts/generate-catalog-i18n.ts` (one-off, regenerates
// `apps/web`'s `locales/fr/translation.json` `catalog.diets` section from
// this same list).
export const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans gluten"];
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
// businesses to declare — a standard, defensible reference list rather than // businesses to declare — a standard, defensible reference list rather than
@ -17,7 +21,7 @@ const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans glute
// sensitivity) per the product decision discussed in chat: only Gluten and // sensitivity) per the product decision discussed in chat: only Gluten and
// Sulfites are commonly-recognized intolerances among the 14; the rest are // Sulfites are commonly-recognized intolerances among the 14; the rest are
// true allergens. // true allergens.
const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [ export const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
{ name: "Gluten", kind: "INTOLERANCE" }, { name: "Gluten", kind: "INTOLERANCE" },
{ name: "Crustacés", kind: "ALLERGY" }, { name: "Crustacés", kind: "ALLERGY" },
{ name: "Œufs", kind: "ALLERGY" }, { name: "Œufs", kind: "ALLERGY" },
@ -92,7 +96,7 @@ interface IngredientSeed {
// repeating the same values on hundreds of items; only a group's // repeating the same values on hundreds of items; only a group's
// exceptions (a fish-based stock inside "bases", a cheese inside "produits // exceptions (a fish-based stock inside "bases", a cheese inside "produits
// laitiers"…) need a per-item override. // laitiers"…) need a per-item override.
const INGREDIENT_GROUPS: Array<{ export const INGREDIENT_GROUPS: Array<{
category: IngredientCategory; category: IngredientCategory;
subcategory: IngredientSubcategory; subcategory: IngredientSubcategory;
defaultDiets: string[]; defaultDiets: string[];
@ -879,27 +883,38 @@ const INGREDIENTS: Array<
/** /**
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent * Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
* (safe to call against a database that already has this data upserts by * (safe to call against a database that already has this data upserts by
* `name`, both `@unique`) used both by `prisma/seed.ts` (the CLI entry * `key`, all `@unique`) used both by `prisma/seed.ts` (the CLI entry
* point, `prisma db seed`) and by `test-support/reset-db.ts` (so every * point, `prisma db seed`) and by `test-support/reset-db.ts` (so every
* test starts from the same realistic reference data the real app seeds, * test starts from the same realistic reference data the real app seeds,
* not an empty table). * not an empty table).
*
* Every `name` below (`DIETS`, `ALLERGENS`, `INGREDIENT_GROUPS`) is an
* *authoring* label, never written to the database or seen by a client
* {@link getEnglishKey} resolves each row's real, stable, English `key`
* from it (see `catalog-en-keys.ts` for why the key must be English even
* though this file's labels are French). The database only ever stores
* that key; the French label itself lives in `apps/web`'s
* `locales/fr/translation.json` (`catalog.*`
* namespace, kept in sync by `scripts/generate-catalog-i18n.ts`).
*/ */
export async function seedReferenceData(prisma: PrismaClient): Promise<void> { export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
for (const name of DIETS) { for (const name of DIETS) {
await prisma.diet.upsert({ where: { name }, update: {}, create: { name } }); const key = getEnglishKey(name);
await prisma.diet.upsert({ where: { key }, update: {}, create: { key } });
} }
// `Allergy` itself carries no `name` — it's the selectable instance of a // `Allergy` itself carries no `key` — it's the selectable instance of a
// named `Category` (see schema.prisma) — so seeding an allergen means one // keyed `Category` (see schema.prisma) — so seeding an allergen means one
// Category (upserted by name) plus exactly one Allergy row under it, // Category (upserted by key) plus exactly one Allergy row under it,
// created only the first time. `update: { kind }` (not `{}`) — a reseed // created only the first time. `update: { kind }` (not `{}`) — a reseed
// must correct `kind` on an already-existing category if the // must correct `kind` on an already-existing category if the
// classification above ever changes, not just skip it. // classification above ever changes, not just skip it.
for (const { name, kind } of ALLERGENS) { for (const { name, kind } of ALLERGENS) {
const key = getEnglishKey(name);
const category = await prisma.category.upsert({ const category = await prisma.category.upsert({
where: { name }, where: { key },
update: { kind }, update: { kind },
create: { name, kind }, create: { key, kind },
}); });
const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } }); const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } });
if (!existing) { if (!existing) {
@ -916,17 +931,18 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
// test-suite case) that's zero updates, on a real re-deploy it's however // test-suite case) that's zero updates, on a real re-deploy it's however
// many rows were edited in code since the last deploy, never the full // many rows were edited in code since the last deploy, never the full
// list. // list.
const ingredientKeys = INGREDIENTS.map((i) => getEnglishKey(i.name));
const existingIngredients = await prisma.ingredient.findMany({ const existingIngredients = await prisma.ingredient.findMany({
where: { name: { in: INGREDIENTS.map((i) => i.name) } }, where: { key: { in: ingredientKeys } },
select: { id: true, name: true, icon: true, category: true, subcategory: true }, select: { id: true, key: true, icon: true, category: true, subcategory: true },
}); });
const existingByName = new Map(existingIngredients.map((i) => [i.name, i])); const existingByKey = new Map(existingIngredients.map((i) => [i.key, i]));
const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name)); const missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(getEnglishKey(i.name)));
if (missingIngredients.length > 0) { if (missingIngredients.length > 0) {
await prisma.ingredient.createMany({ await prisma.ingredient.createMany({
data: missingIngredients.map(({ name, icon, category, subcategory }) => ({ data: missingIngredients.map(({ name, icon, category, subcategory }) => ({
name, key: getEnglishKey(name),
icon, icon,
category, category,
subcategory, subcategory,
@ -935,7 +951,7 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
} }
const changed = INGREDIENTS.filter((i) => { const changed = INGREDIENTS.filter((i) => {
const existing = existingByName.get(i.name); const existing = existingByKey.get(getEnglishKey(i.name));
return ( return (
existing && existing &&
(existing.icon !== i.icon || (existing.icon !== i.icon ||
@ -944,29 +960,32 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
); );
}); });
for (const { name, icon, category, subcategory } of changed) { for (const { name, icon, category, subcategory } of changed) {
await prisma.ingredient.update({ where: { name }, data: { icon, category, subcategory } }); await prisma.ingredient.update({
where: { key: getEnglishKey(name) },
data: { icon, category, subcategory },
});
} }
// Re-resolve every ingredient's id (existing + just-created) and every // Re-resolve every ingredient's id (existing + just-created) and every
// allergy's id (by its category name) once, then link them in a single // allergy's id (by its category key) once, then link them in a single
// bulk insert — same "re-derived every time, not upserted per link" // bulk insert — same "re-derived every time, not upserted per link"
// reasoning as before for `IngredientAllergy` (it has no natural per-row // reasoning as before for `IngredientAllergy` (it has no natural per-row
// identity to upsert against), just batched instead of looped. // identity to upsert against), just batched instead of looped.
const allIngredients = await prisma.ingredient.findMany({ const allIngredients = await prisma.ingredient.findMany({
where: { name: { in: INGREDIENTS.map((i) => i.name) } }, where: { key: { in: ingredientKeys } },
select: { id: true, name: true }, select: { id: true, key: true },
}); });
const ingredientIdByName = new Map(allIngredients.map((i) => [i.name, i.id])); const ingredientIdByKey = new Map(allIngredients.map((i) => [i.key, i.id]));
const allergies = await prisma.allergy.findMany({ include: { category: true } }); const allergies = await prisma.allergy.findMany({ include: { category: true } });
const allergyIdByCategoryName = new Map(allergies.map((a) => [a.category.name, a.id])); const allergyIdByCategoryKey = new Map(allergies.map((a) => [a.category.key, a.id]));
const links: Array<{ ingredientId: number; allergyId: number }> = []; const links: Array<{ ingredientId: number; allergyId: number }> = [];
for (const { name, allergenNames } of INGREDIENTS) { for (const { name, allergenNames } of INGREDIENTS) {
const ingredientId = ingredientIdByName.get(name); const ingredientId = ingredientIdByKey.get(getEnglishKey(name));
if (ingredientId === undefined) continue; if (ingredientId === undefined) continue;
for (const allergenName of allergenNames) { for (const allergenName of allergenNames) {
const allergyId = allergyIdByCategoryName.get(allergenName); const allergyId = allergyIdByCategoryKey.get(getEnglishKey(allergenName));
if (allergyId !== undefined) links.push({ ingredientId, allergyId }); if (allergyId !== undefined) links.push({ ingredientId, allergyId });
} }
} }
@ -978,14 +997,14 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
// `dietNames` (item override, falling back to its group's `defaultDiets` // `dietNames` (item override, falling back to its group's `defaultDiets`
// in the `INGREDIENTS` flatten step) instead of `allergenNames`. // in the `INGREDIENTS` flatten step) instead of `allergenNames`.
const diets = await prisma.diet.findMany(); const diets = await prisma.diet.findMany();
const dietIdByName = new Map(diets.map((d) => [d.name, d.id])); const dietIdByKey = new Map(diets.map((d) => [d.key, d.id]));
const dietLinks: Array<{ ingredientId: number; dietId: number }> = []; const dietLinks: Array<{ ingredientId: number; dietId: number }> = [];
for (const { name, dietNames } of INGREDIENTS) { for (const { name, dietNames } of INGREDIENTS) {
const ingredientId = ingredientIdByName.get(name); const ingredientId = ingredientIdByKey.get(getEnglishKey(name));
if (ingredientId === undefined) continue; if (ingredientId === undefined) continue;
for (const dietName of dietNames) { for (const dietName of dietNames) {
const dietId = dietIdByName.get(dietName); const dietId = dietIdByKey.get(getEnglishKey(dietName));
if (dietId !== undefined) dietLinks.push({ ingredientId, dietId }); if (dietId !== undefined) dietLinks.push({ ingredientId, dietId });
} }
} }

View file

@ -39,21 +39,21 @@ type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredien
function toIngredientView(ingredient: IngredientWithDetails): IngredientView { function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
return { return {
id: ingredient.id, id: ingredient.id,
name: ingredient.name, key: ingredient.key,
icon: ingredient.icon, icon: ingredient.icon,
category: ingredient.category, category: ingredient.category,
subcategory: ingredient.subcategory, subcategory: ingredient.subcategory,
allergens: ingredient.allergies.map(({ allergy }) => ({ allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id, id: allergy.id,
name: allergy.category.name, key: allergy.category.key,
kind: allergy.category.kind, kind: allergy.category.kind,
})), })),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
}; };
} }
function toDietView(diet: { id: number; name: string }): DietView { function toDietView(diet: { id: number; key: string }): DietView {
return { id: diet.id, name: diet.name }; return { id: diet.id, key: diet.key };
} }
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */ /** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */

View file

@ -1,35 +1,43 @@
import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared"; import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */ /**
* All reference dietary regimes, ordered by `key` small, static list (see
* prisma/seed.ts). `key` is a stable slug, not the display label (see
* {@link DietView}), so this is an alphabetical-by-slug order rather than a
* true French alphabetical one close enough for a 5-item list, and the
* server has no other order to offer now that the label itself only exists
* client-side (`apps/web`'s `locales/fr/translation.json`).
*/
export async function getDiets(): Promise<DietView[]> { export async function getDiets(): Promise<DietView[]> {
return prisma.diet.findMany({ orderBy: { name: "asc" } }); return prisma.diet.findMany({ orderBy: { key: "asc" } });
} }
/** /**
* All reference allergens, alphabetically. `Allergy` carries no `name` of * All reference allergens, ordered by key (see {@link getDiets} for why key,
* its own it's the selectable instance of a named `Category` (see * not label). `Allergy` carries no `key` of its own it's the selectable
* schema.prisma) so this resolves each allergen's display name from its * instance of a keyed `Category` (see schema.prisma) so this resolves
* category and flattens the split away for callers. * each allergen's key from its category and flattens the split away for
* callers.
*/ */
export async function getAllergies(): Promise<AllergyView[]> { export async function getAllergies(): Promise<AllergyView[]> {
const allergies = await prisma.allergy.findMany({ const allergies = await prisma.allergy.findMany({
include: { category: { select: { name: true, kind: true } } }, include: { category: { select: { key: true, kind: true } } },
orderBy: { category: { name: "asc" } }, orderBy: { category: { key: "asc" } },
}); });
return allergies.map((allergy) => ({ return allergies.map((allergy) => ({
id: allergy.id, id: allergy.id,
name: allergy.category.name, key: allergy.category.key,
kind: allergy.category.kind, kind: allergy.category.kind,
})); }));
} }
/** /**
* All reference ingredients, alphabetically, each resolved to its allergens * All reference ingredients, ordered by key (see {@link getDiets} for why),
* (see `IngredientAllergy` in schema.prisma) and compatible diet regimes * each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
* (see `IngredientDiet`) same aplattening approach as {@link getAllergies}. * and compatible diet regimes (see `IngredientDiet`) same flattening
* Ingredients with no linked allergen/diet come back with `allergens: []`/ * approach as {@link getAllergies}. Ingredients with no linked
* `diets: []`. * allergen/diet come back with `allergens: []`/`diets: []`.
*/ */
export async function getIngredients(): Promise<IngredientView[]> { export async function getIngredients(): Promise<IngredientView[]> {
const ingredients = await prisma.ingredient.findMany({ const ingredients = await prisma.ingredient.findMany({
@ -37,19 +45,19 @@ export async function getIngredients(): Promise<IngredientView[]> {
allergies: { include: { allergy: { include: { category: true } } } }, allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } }, diets: { include: { diet: true } },
}, },
orderBy: { name: "asc" }, orderBy: { key: "asc" },
}); });
return ingredients.map((ingredient) => ({ return ingredients.map((ingredient) => ({
id: ingredient.id, id: ingredient.id,
name: ingredient.name, key: ingredient.key,
icon: ingredient.icon, icon: ingredient.icon,
category: ingredient.category, category: ingredient.category,
subcategory: ingredient.subcategory, subcategory: ingredient.subcategory,
allergens: ingredient.allergies.map(({ allergy }) => ({ allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id, id: allergy.id,
name: allergy.category.name, key: allergy.category.key,
kind: allergy.category.kind, kind: allergy.category.kind,
})), })),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
})); }));
} }

View file

@ -0,0 +1,26 @@
/**
* Deterministic slug for a French reference-data label used as the
* stable, storage-safe `key` for `Diet`/`Category`/`Ingredient` rows (see
* `db/reference-seed-data.ts`), decoupled from the display label so the
* label itself can live in `apps/web`'s `locales/fr/translation.json`
* (`catalog.*` namespace) instead of the database. A row's `key` is derived
* from its seed-time French name once and then never changes renaming the
* *label* later (a translation fix, a rewording) never touches the key, the
* FK-referencing rows, or any code that looks a row up by key.
*
* Handles the two French ligatures NFD decomposition doesn't touch (`œ`,
* `æ` aren't accented letters, they're distinct glyphs) explicitly, then
* strips every other accent via NFD decomposition + Unicode "Mark" removal
* (`\p{M}`, every combining diacritic NFD can produce), then collapses
* whatever isn't `[a-z0-9]` into single underscores.
*/
export function slugify(label: string): string {
return label
.toLowerCase()
.replace(/œ/g, "oe")
.replace(/æ/g, "ae")
.normalize("NFD")
.replace(/\p{M}/gu, "")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}

View file

@ -3,6 +3,7 @@ import { faker } from "@faker-js/faker";
import { expect } from "chai"; import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
@ -39,7 +40,9 @@ describe("Profile", () => {
it("sets the profile's regime to a valid, seeded diet", async () => { it("sets the profile's regime to a valid, seeded diet", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } }); const diet = await prisma.diet.findFirstOrThrow({
where: { key: getEnglishKey("Végétarien") },
});
const res = await agent.patch("/profile/diet").send({ dietId: diet.id }); const res = await agent.patch("/profile/diet").send({ dietId: diet.id });
@ -50,7 +53,7 @@ describe("Profile", () => {
it("clears the regime when dietId is null", async () => { it("clears the regime when dietId is null", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végan" } }); const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey("Végan") } });
await agent.patch("/profile/diet").send({ dietId: diet.id }); await agent.patch("/profile/diet").send({ dietId: diet.id });
const res = await agent.patch("/profile/diet").send({ dietId: null }); const res = await agent.patch("/profile/diet").send({ dietId: null });
@ -83,8 +86,8 @@ describe("Profile", () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const allergies = await prisma.allergy.findMany({ include: { category: true } }); const allergies = await prisma.allergy.findMany({ include: { category: true } });
const peanuts = allergies.find((a) => a.category.name === "Arachides"); const peanuts = allergies.find((a) => a.category.key === getEnglishKey("Arachides"));
const gluten = allergies.find((a) => a.category.name === "Gluten"); const gluten = allergies.find((a) => a.category.key === getEnglishKey("Gluten"));
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
const initial = await agent.get("/profile/allergies"); const initial = await agent.get("/profile/allergies");
@ -104,8 +107,8 @@ describe("Profile", () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const allergies = await prisma.allergy.findMany({ include: { category: true } }); const allergies = await prisma.allergy.findMany({ include: { category: true } });
const peanuts = allergies.find((a) => a.category.name === "Arachides"); const peanuts = allergies.find((a) => a.category.key === getEnglishKey("Arachides"));
const gluten = allergies.find((a) => a.category.name === "Gluten"); const gluten = allergies.find((a) => a.category.key === getEnglishKey("Gluten"));
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] }); await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] });
@ -140,8 +143,12 @@ describe("Profile", () => {
it("starts empty, then reflects a saved selection", async () => { it("starts empty, then reflects a saved selection", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } }); const tomate = await prisma.ingredient.findFirstOrThrow({
const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } }); where: { key: getEnglishKey("Tomate") },
});
const oignon = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey("Oignon") },
});
const initial = await agent.get("/profile/disliked-ingredients"); const initial = await agent.get("/profile/disliked-ingredients");
expect(initial.body).to.deep.equal([]); expect(initial.body).to.deep.equal([]);
@ -159,8 +166,12 @@ describe("Profile", () => {
it("replaces (not merges) the previous selection", async () => { it("replaces (not merges) the previous selection", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } }); const tomate = await prisma.ingredient.findFirstOrThrow({
const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } }); where: { key: getEnglishKey("Tomate") },
});
const oignon = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey("Oignon") },
});
await agent await agent
.patch("/profile/disliked-ingredients") .patch("/profile/disliked-ingredients")

View file

@ -4,6 +4,7 @@ import { faker } from "@faker-js/faker";
import { expect } from "chai"; import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
@ -19,9 +20,11 @@ function buildSignupPayload(): SignupInput {
}; };
} }
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` name. */ /** Resolves a reference ingredient's id by its `reference-seed-data.ts` French name (slugified to match its `key`). */
async function ingredientId(name: string): Promise<number> { async function ingredientId(name: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } }); const ingredient = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey(name) },
});
return ingredient.id; return ingredient.id;
} }
@ -163,7 +166,9 @@ describe("Recipes", () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("Tomate"); const tomate = await ingredientId("Tomate");
const oeuf = await ingredientId("Œuf"); const oeuf = await ingredientId("Œuf");
const vegetarien = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } }); const vegetarien = await prisma.diet.findFirstOrThrow({
where: { key: getEnglishKey("Végétarien") },
});
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Omelette provençale", name: "Omelette provençale",
@ -183,8 +188,12 @@ describe("Recipes", () => {
res.body.steps.map((s: { description: string; order: number }) => s.order), res.body.steps.map((s: { description: string; order: number }) => s.order),
).to.deep.equal([0, 1]); ).to.deep.equal([0, 1]);
// Allergens aggregated across ingredients — "Œuf" carries "Œufs". // Allergens aggregated across ingredients — "Œuf" carries "Œufs".
expect(res.body.allergens.map((a: { name: string }) => a.name)).to.include("Œufs"); expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include(
expect(res.body.diets.map((d: { name: string }) => d.name)).to.deep.equal(["Végétarien"]); getEnglishKey("Œufs"),
);
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([
getEnglishKey("Végétarien"),
]);
}); });
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => { it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
@ -279,7 +288,7 @@ describe("Recipes", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Salade"); expect(res.body.name).to.equal("Salade");
expect(res.body.ingredients[0].ingredient.name).to.equal("Tomate"); expect(res.body.ingredients[0].ingredient.key).to.equal(getEnglishKey("Tomate"));
expect(res.body.isFavorite).to.equal(false); expect(res.body.isFavorite).to.equal(false);
}); });
@ -364,7 +373,7 @@ describe("Recipes", () => {
expect(res.body.name).to.equal("Salade composée"); expect(res.body.name).to.equal("Salade composée");
expect(res.body.visibility).to.equal("PUBLIC"); expect(res.body.visibility).to.equal("PUBLIC");
expect(res.body.ingredients).to.have.length(1); expect(res.body.ingredients).to.have.length(1);
expect(res.body.ingredients[0].ingredient.name).to.equal("Oignon"); expect(res.body.ingredients[0].ingredient.key).to.equal(getEnglishKey("Oignon"));
expect(res.body.steps).to.have.length(2); expect(res.body.steps).to.have.length(2);
}); });

View file

@ -1,6 +1,7 @@
import { expect } from "chai"; import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
@ -21,28 +22,29 @@ describe("Reference data", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.length(5); expect(res.body).to.have.length(5);
expect(res.body.map((d: { name: string }) => d.name)).to.include("Végétarien"); expect(res.body.map((d: { key: string }) => d.key)).to.include(getEnglishKey("Végétarien"));
expect(res.body[0]).to.have.keys(["id", "name"]); expect(res.body[0]).to.have.keys(["id", "key"]);
}); });
}); });
describe("GET /reference/allergies", () => { describe("GET /reference/allergies", () => {
it("returns the seeded allergens with their name resolved, no session required", async () => { it("returns the seeded allergens with their key resolved, no session required", async () => {
const res = await request(app).get("/reference/allergies"); const res = await request(app).get("/reference/allergies");
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.length(14); expect(res.body).to.have.length(14);
expect(res.body.map((a: { name: string }) => a.name)).to.include("Arachides"); expect(res.body.map((a: { key: string }) => a.key)).to.include(getEnglishKey("Arachides"));
expect(res.body[0]).to.have.keys(["id", "name", "kind"]); expect(res.body[0]).to.have.keys(["id", "key", "kind"]);
}); });
it("classifies Gluten and Sulfites as intolerances, the rest as allergies", async () => { it("classifies Gluten and Sulfites as intolerances, the rest as allergies", async () => {
const res = await request(app).get("/reference/allergies"); const res = await request(app).get("/reference/allergies");
const byName = (name: string) => res.body.find((a: { name: string }) => a.name === name); const byKey = (name: string) =>
expect(byName("Gluten").kind).to.equal("INTOLERANCE"); res.body.find((a: { key: string }) => a.key === getEnglishKey(name));
expect(byName("Sulfites").kind).to.equal("INTOLERANCE"); expect(byKey("Gluten").kind).to.equal("INTOLERANCE");
expect(byName("Arachides").kind).to.equal("ALLERGY"); expect(byKey("Sulfites").kind).to.equal("INTOLERANCE");
expect(byKey("Arachides").kind).to.equal("ALLERGY");
expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2); expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2);
}); });
}); });
@ -53,10 +55,10 @@ describe("Reference data", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.length).to.be.greaterThan(0); expect(res.body.length).to.be.greaterThan(0);
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate"); expect(res.body.map((i: { key: string }) => i.key)).to.include(getEnglishKey("Tomate"));
expect(res.body[0]).to.have.keys([ expect(res.body[0]).to.have.keys([
"id", "id",
"name", "key",
"icon", "icon",
"category", "category",
"subcategory", "subcategory",
@ -68,9 +70,12 @@ describe("Reference data", () => {
it("resolves each ingredient's linked allergens, empty for one with none", async () => { it("resolves each ingredient's linked allergens, empty for one with none", async () => {
const res = await request(app).get("/reference/ingredients"); const res = await request(app).get("/reference/ingredients");
const byName = (name: string) => res.body.find((i: { name: string }) => i.name === name); const byKey = (name: string) =>
expect(byName("Œuf").allergens.map((a: { name: string }) => a.name)).to.include("Œufs"); res.body.find((i: { key: string }) => i.key === getEnglishKey(name));
expect(byName("Tomate").allergens).to.deep.equal([]); expect(byKey("Œuf").allergens.map((a: { key: string }) => a.key)).to.include(
getEnglishKey("Œufs"),
);
expect(byKey("Tomate").allergens).to.deep.equal([]);
}); });
}); });
}); });

View file

@ -32,8 +32,8 @@ describe("Onboarding wizard (regime → foyer → allergens)", () => {
cy.intercept("GET", "**/reference/diets", { cy.intercept("GET", "**/reference/diets", {
statusCode: 200, statusCode: 200,
body: [ body: [
{ id: 1, name: "Omnivore" }, { id: 1, key: "omnivore" },
{ id: 2, name: "Végétarien" }, { id: 2, key: "vegetarian" },
], ],
}); });
cy.intercept("PATCH", "**/profile/diet", { cy.intercept("PATCH", "**/profile/diet", {
@ -48,8 +48,8 @@ describe("Onboarding wizard (regime → foyer → allergens)", () => {
cy.intercept("GET", "**/reference/allergies", { cy.intercept("GET", "**/reference/allergies", {
statusCode: 200, statusCode: 200,
body: [ body: [
{ id: 1, name: "Arachides", kind: "ALLERGY" }, { id: 1, key: "peanuts", kind: "ALLERGY" },
{ id: 2, name: "Gluten", kind: "INTOLERANCE" }, { id: 2, key: "gluten", kind: "INTOLERANCE" },
], ],
}); });
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as( cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as(

View file

@ -16,15 +16,15 @@ describe("Dietary preferences (/parametres/preferences) — hot saving", () => {
cy.intercept("GET", "**/reference/diets", { cy.intercept("GET", "**/reference/diets", {
statusCode: 200, statusCode: 200,
body: [ body: [
{ id: 1, name: "Omnivore" }, { id: 1, key: "omnivore" },
{ id: 2, name: "Végétarien" }, { id: 2, key: "vegetarian" },
], ],
}); });
cy.intercept("GET", "**/reference/allergies", { cy.intercept("GET", "**/reference/allergies", {
statusCode: 200, statusCode: 200,
body: [ body: [
{ id: 1, name: "Arachides", kind: "ALLERGY" }, { id: 1, key: "peanuts", kind: "ALLERGY" },
{ id: 2, name: "Gluten", kind: "INTOLERANCE" }, { id: 2, key: "gluten", kind: "INTOLERANCE" },
], ],
}); });
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] }); cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });

View file

@ -0,0 +1,178 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
// real API behavior against a real database).
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
const tomato = {
id: 1,
key: "tomato",
icon: "VEGETABLE",
category: "PRODUITS_FRAIS",
subcategory: "LEGUMES",
allergens: [],
diets: [{ id: 2, key: "vegetarian" }],
};
const egg = {
id: 2,
key: "egg",
icon: "EGG",
category: "CREMERIE_FROMAGE",
subcategory: "OEUFS",
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
diets: [],
};
const carrot = {
id: 3,
key: "carrot",
icon: "VEGETABLE",
category: "PRODUITS_FRAIS",
subcategory: "LEGUMES",
allergens: [],
diets: [{ id: 2, key: "vegetarian" }],
};
const diets = [
{ id: 1, key: "omnivore" },
{ id: 2, key: "vegetarian" },
];
function interceptCatalog() {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/reference/ingredients", {
statusCode: 200,
body: [tomato, egg, carrot],
});
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets });
}
describe("Recipe form — associating ingredients", () => {
beforeEach(() => {
interceptCatalog();
});
it("adds an ingredient from the picker, fills its quantity/unit, and creates the recipe", () => {
cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id: 42 } }).as("createRecipe");
cy.visit("/recettes/nouvelle");
cy.get("#recipe-name").type("Salade de tomates");
cy.get("input[placeholder='Rechercher un ingrédient…']").type("tomat");
cy.contains(".ingredient-picker__card", "Tomate").click();
// The card disappears from the picker once selected (excludeIds), and
// a row for it appears in the recipe's own ingredient list.
cy.contains(".ingredient-picker__card", "Tomate").should("not.exist");
cy.contains(".ingredient-row__name", "Tomate").should("be.visible");
cy.get(".ingredient-row .ingredient-row__quantity").type("3");
cy.get(".ingredient-row .ingredient-row__unit").type("unité");
cy.contains("button", "Ajouter une étape").click();
cy.get(".step-list-editor__item textarea").type("Couper les tomates.");
cy.contains("button", "Enregistrer").should("not.be.disabled").click();
cy.wait("@createRecipe")
.its("request.body")
.should("deep.include", {
name: "Salade de tomates",
ingredients: [{ ingredientId: 1, quantity: 3, unit: "unité" }],
});
cy.url().should("include", "/recettes/42");
});
// Regression test for the exact bug reported: `crypto.randomUUID()` (used
// to mint each ingredient/step draft's client-only React key) throws
// outside a secure context — https, or literally the hostname
// `localhost` — so a LAN IP during on-device testing or a Capacitor
// WebView's `capacitor://` origin hit a black screen with "TypeError:
// crypto.randomUUID is not a function" the instant an ingredient was
// added. Cypress's own origin is secure, so this forces the same failure
// by deleting `crypto.randomUUID` before the app boots — see
// `apps/web/src/lib/client-key.ts`, which replaced it.
it("still works when crypto.randomUUID is unavailable (insecure-context regression)", () => {
cy.visit("/recettes/nouvelle", {
onBeforeLoad(win) {
Object.defineProperty(win.crypto, "randomUUID", {
value: undefined,
configurable: true,
});
},
});
cy.get("#recipe-name").type("Recette hors contexte sécurisé");
cy.contains(".ingredient-picker__card", "Tomate").click();
cy.contains(".ingredient-picker__card", "Œuf").click();
// Both rows rendered with distinct identities — no crash, no React
// "same key" warning silently collapsing one of them.
cy.get(".ingredient-row").should("have.length", 2);
cy.contains(".ingredient-row__name", "Tomate").should("be.visible");
cy.contains(".ingredient-row__name", "Œuf").should("be.visible");
cy.contains("button", "Ajouter une étape").click();
cy.contains("button", "Ajouter une étape").click();
cy.get(".step-list-editor__item").should("have.length", 2);
});
it("excludes an already-selected ingredient from the picker, and removing it brings it back", () => {
cy.visit("/recettes/nouvelle");
cy.contains(".ingredient-picker__card", "Carotte").click();
cy.contains(".ingredient-picker__card", "Carotte").should("not.exist");
cy.contains(".ingredient-row", "Carotte")
.find("button[title='Retirer cet ingrédient']")
.click();
cy.contains(".ingredient-picker__card", "Carotte").should("be.visible");
cy.get(".ingredient-row").should("have.length", 0);
});
it("preloads an existing recipe's ingredients when editing, and lets you add another", () => {
const existingRecipe = {
id: 7,
name: "Omelette",
description: null,
picture: null,
authorId: 1,
visibility: "PERSONAL",
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
diets: [],
isFavorite: false,
ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }],
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }],
};
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe });
cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe");
cy.visit("/recettes/7/modifier");
cy.contains(".ingredient-row__name", "Œuf").should("be.visible");
cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", "3");
cy.contains(".ingredient-picker__card", "Tomate").click();
cy.get(".ingredient-row").should("have.length", 2);
cy.get(".ingredient-row .ingredient-row__quantity").last().type("1");
cy.get(".ingredient-row .ingredient-row__unit").last().type("unité");
cy.contains("button", "Enregistrer").click();
cy.wait("@updateRecipe")
.its("request.body.ingredients")
.should("deep.equal", [
{ ingredientId: 2, quantity: 3, unit: "unité" },
{ ingredientId: 1, quantity: 1, unit: "unité" },
]);
});
});

View file

@ -0,0 +1,241 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
// real API behavior against a real database).
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
const vegetarien = { id: 1, key: "vegetarian" };
const gluten = { id: 1, key: "gluten", kind: "INTOLERANCE" };
const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" };
const ratatouille = {
id: 1,
name: "Ratatouille",
description: null,
picture: null,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [vegetarien],
isFavorite: true,
};
const omelette = {
id: 2,
name: "Omelette",
description: null,
picture: null,
authorId: 1,
visibility: "PERSONAL",
allergens: [oeufs],
diets: [],
isFavorite: false,
};
const omeletteDetail = {
...omelette,
description: "Une omelette toute simple.",
ingredients: [
{
ingredient: {
id: 10,
key: "egg",
icon: "EGG",
category: "CREMERIE_FROMAGE",
subcategory: "OEUFS",
allergens: [oeufs],
diets: [],
},
quantity: 3,
unit: "unité",
},
],
steps: [
{ id: 1, description: "Battre les œufs.", picture: null, order: 1 },
{ id: 2, description: "Cuire à la poêle.", picture: null, order: 2 },
],
};
function interceptAuth() {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
}
describe("Recipe catalog", () => {
beforeEach(() => {
interceptAuth();
});
it("defaults to the Favoris tab and lists its recipes", () => {
cy.intercept("GET", /\/recipes\?/, (req) => {
expect(req.url).to.include("tab=favoris");
req.reply({ statusCode: 200, body: [ratatouille] });
}).as("listRecipes");
cy.visit("/recettes");
cy.wait("@listRecipes");
cy.contains("h1", "Recettes").should("be.visible");
cy.get(".recipe-tabs__tab.active").should("contain.text", "Favoris");
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
// The favorited row carries the ★ fav-mark.
cy.contains(".recipe-table__name", "Ratatouille")
.find(".recipe-table__fav-mark")
.should("exist");
cy.contains(".recipe-table__name", "Ratatouille")
.parents("tr")
.find(".diet-badge")
.should("contain.text", "Végétarien");
});
it("shows the empty state when a tab has no recipes", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.visit("/recettes");
cy.contains("Aucune recette pour le moment.").should("be.visible");
});
it("shows an error state when the catalog fails to load", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 500, body: { code: 5000, message: "boom" } });
cy.visit("/recettes");
cy.contains(".recipes-page__status--error", "Impossible de charger").should("be.visible");
});
it("switches tabs, re-fetching each one's own recipes", () => {
cy.intercept("GET", /\/recipes\?/, (req) => {
const tab = new URL(req.url).searchParams.get("tab");
const body = tab === "perso" ? [omelette] : [ratatouille];
req.reply({ statusCode: 200, body });
}).as("listRecipes");
cy.visit("/recettes");
cy.wait("@listRecipes");
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
// Not asserting the specific request URL here — React StrictMode (see
// main.tsx) double-invokes mount/update effects in dev, so this can
// legitimately fire twice; the rendered result converges either way
// (same reasoning as planning-page.cy.ts's week-navigation tests).
cy.contains(".recipe-tabs__tab", "Perso").click();
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
// The disabled "Sources (bientôt)" placeholder never becomes active.
cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled");
});
it("searches within the active tab, debounced", () => {
cy.intercept("GET", /\/recipes\?/, (req) => {
const search = new URL(req.url).searchParams.get("search");
req.reply({ statusCode: 200, body: search ? [omelette] : [ratatouille, omelette] });
}).as("listRecipes");
cy.visit("/recettes");
cy.wait("@listRecipes");
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
// Same "assert the rendered result, not the request count/URL" reasoning
// as the tab-switch test above — the 300ms debounce plus StrictMode's
// double-invoked effects make the exact number/order of requests an
// implementation detail, not something worth pinning down here.
cy.get(".recipes-page__search").type("Omel");
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
});
it("opens a recipe's detail alongside the table when its row is selected", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe");
cy.visit("/recettes");
cy.contains(".recipe-table__name", "Omelette").click();
cy.wait("@getRecipe");
cy.url().should("include", "/recettes/2");
// The table stays mounted (master-detail, not a page navigation) —
// both rows are still visible next to the detail panel.
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
cy.get("tr.selected .recipe-table__name").should("contain.text", "Omelette");
cy.get(".recipe-detail-panel").within(() => {
cy.contains("h2", "Omelette").should("be.visible");
cy.contains("Une omelette toute simple.").should("be.visible");
cy.contains("Battre les œufs.").should("be.visible");
cy.contains("Cuire à la poêle.").should("be.visible");
});
});
it("shows a not-found message for a selected id the API rejects", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.intercept("GET", "**/recipes/999", {
statusCode: 404,
// ErrorCode.RECIPE_NOT_FOUND (packages/shared/src/errors/error-codes.ts)
// — RecipeDetailPanel only renders the "not found" message for this
// exact code, anything else falls into its generic error state.
body: { code: 4045, message: "not found" },
});
cy.visit("/recettes/999");
cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible");
});
it("toggles a recipe's favorite from the detail panel and reflects it in the table", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] });
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail });
cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite");
cy.visit("/recettes/2");
cy.contains(".recipe-table__name", "Omelette")
.find(".recipe-table__fav-mark")
.should("not.exist");
cy.get(".favorite-star-button").click();
cy.wait("@favorite");
cy.get(".favorite-star-button").should("have.class", "is-favorite");
cy.contains(".recipe-table__name", "Omelette").find(".recipe-table__fav-mark").should("exist");
});
it("deletes a recipe after a two-step confirmation, then clears the selection", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] });
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail });
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
cy.visit("/recettes/2");
cy.contains(".recipe-detail-panel", "Omelette").should("be.visible");
cy.contains(".recipe-detail-panel__danger-button", "Supprimer").click();
cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click();
cy.wait("@deleteRecipe");
cy.url().should("match", /\/recettes\/?$/);
cy.contains(".recipe-table__name", "Omelette").should("not.exist");
cy.contains("Sélectionnez une recette dans le tableau").should("be.visible");
});
it("links the new-recipe button to the recipe form", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.visit("/recettes");
cy.contains(".recipes-page__new-button", "Nouvelle recette").should(
"have.attr",
"href",
"/recettes/nouvelle",
);
});
});

View file

@ -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",

View file

@ -12,6 +12,7 @@ import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensP
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage"; import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage"; import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { CreditsPage } from "./pages/settings/CreditsPage";
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"; import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage";
@ -25,8 +26,8 @@ import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage";
* itself redirects to `/login` if needed. * itself redirects to `/login` if needed.
* *
* `/parametres/*` (compte/préférences alimentaires/foyer/préférences * `/parametres/*` (compte/préférences alimentaires/foyer/préférences
* utilisateur) are the settings pages, reachable from the sidebar's bottom * utilisateur/crédits) are the settings pages, reachable from the sidebar's
* "Paramètres" menu and the account menu (see `AppLayout`) nested under * bottom "Paramètres" menu and the account menu (see `AppLayout`) nested under
* `AppLayout` like every other authenticated section. `/foyer` is the old, * `AppLayout` like every other authenticated section. `/foyer` is the old,
* pre-split combined page's path; it now just redirects to * pre-split combined page's path; it now just redirects to
* `/parametres/foyer` so an existing bookmark/link keeps working. * `/parametres/foyer` so an existing bookmark/link keeps working.
@ -61,6 +62,7 @@ export function App() {
<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="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
<Route path="/parametres/credits" element={<CreditsPage />} />
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} /> <Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
</Route> </Route>
<Route <Route

View 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>
);
}

View 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>
);
}

View file

@ -1,4 +1,6 @@
import type { AllergyView } from "@batch-cooking/shared"; import type { AllergyView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { CheckboxOption } from "../../components/ui/Checkbox";
import "./profile-forms.scss"; import "./profile-forms.scss";
interface AllergySelectProps { interface AllergySelectProps {
@ -28,6 +30,8 @@ interface AllergySelectProps {
* rationale as `DietSelect`. * rationale as `DietSelect`.
*/ */
export function AllergySelect({ legend, allergies, value, onChange }: AllergySelectProps) { export function AllergySelect({ legend, allergies, value, onChange }: AllergySelectProps) {
const { t } = useTranslation();
function toggle(id: number) { function toggle(id: number) {
onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]); onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]);
} }
@ -38,19 +42,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)} /> {t(`catalog.allergens.${allergy.key}`)}
<span className="check-mark" aria-hidden="true" /> </CheckboxOption>
{allergy.name}
</label>
); );
})} })}
</fieldset> </fieldset>

View file

@ -33,7 +33,7 @@ export function DietSelect({ diets, value, onChange }: DietSelectProps) {
<option value="">{t("preferences.form.dietNone")}</option> <option value="">{t("preferences.form.dietNone")}</option>
{diets.map((diet) => ( {diets.map((diet) => (
<option key={diet.id} value={diet.id}> <option key={diet.id} value={diet.id}>
{diet.name} {t(`catalog.diets.${diet.key}`)}
</option> </option>
))} ))}
</select> </select>

View file

@ -54,7 +54,7 @@ export function DislikedIngredientsField({
<span aria-hidden="true"> <span aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} /> <IngredientTypeIcon icon={ingredient.icon} />
</span> </span>
{ingredient.name} {t(`catalog.ingredients.${ingredient.key}`)}
<button <button
type="button" type="button"
onClick={() => remove(ingredient.id)} onClick={() => remove(ingredient.id)}

View file

@ -1,4 +1,5 @@
import type { AllergyView } from "@batch-cooking/shared"; import type { AllergyView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import "./recipes.scss"; import "./recipes.scss";
/** /**
@ -10,6 +11,7 @@ import "./recipes.scss";
* mount it unconditionally. * mount it unconditionally.
*/ */
export function AllergenBadges({ allergens }: { allergens: AllergyView[] }) { export function AllergenBadges({ allergens }: { allergens: AllergyView[] }) {
const { t } = useTranslation();
if (allergens.length === 0) { if (allergens.length === 0) {
return null; return null;
} }
@ -17,7 +19,7 @@ export function AllergenBadges({ allergens }: { allergens: AllergyView[] }) {
<ul className="allergen-badges"> <ul className="allergen-badges">
{allergens.map((allergen) => ( {allergens.map((allergen) => (
<li key={allergen.id} className="allergen-badge"> <li key={allergen.id} className="allergen-badge">
{allergen.name} {t(`catalog.allergens.${allergen.key}`)}
</li> </li>
))} ))}
</ul> </ul>

View file

@ -1,4 +1,5 @@
import type { DietView } from "@batch-cooking/shared"; import type { DietView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import "./recipes.scss"; import "./recipes.scss";
/** /**
@ -10,6 +11,7 @@ import "./recipes.scss";
* Renders nothing for an empty list, same convention as `AllergenBadges`. * Renders nothing for an empty list, same convention as `AllergenBadges`.
*/ */
export function DietBadges({ diets }: { diets: DietView[] }) { export function DietBadges({ diets }: { diets: DietView[] }) {
const { t } = useTranslation();
if (diets.length === 0) { if (diets.length === 0) {
return null; return null;
} }
@ -17,7 +19,7 @@ export function DietBadges({ diets }: { diets: DietView[] }) {
<ul className="diet-badges"> <ul className="diet-badges">
{diets.map((diet) => ( {diets.map((diet) => (
<li key={diet.id} className="diet-badge"> <li key={diet.id} className="diet-badge">
{diet.name} {t(`catalog.diets.${diet.key}`)}
</li> </li>
))} ))}
</ul> </ul>

View file

@ -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)} /> {t(`catalog.diets.${diet.key}`)}
<span className="check-mark" aria-hidden="true" /> </CheckboxOption>
{diet.name}
</label>
); );
})} })}
</fieldset> </fieldset>

View file

@ -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>
); );
} }

View file

@ -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";
@ -73,8 +74,12 @@ export function IngredientPicker({
if (ingredient.category !== category) return false; if (ingredient.category !== category) return false;
if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false; if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false;
} }
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) { // Matches against the *displayed* (translated) label, not the raw slug
return false; // key — searching "œuf" should find "Œuf" the way it always has, not
// require typing its key.
if (normalizedQuery.length > 0) {
const label = t(`catalog.ingredients.${ingredient.key}`).toLowerCase();
if (!label.includes(normalizedQuery)) return false;
} }
return true; return true;
}); });
@ -101,28 +106,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>
@ -186,7 +179,9 @@ export function IngredientPicker({
<span className="ingredient-picker__card-icon" aria-hidden="true"> <span className="ingredient-picker__card-icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} /> <IngredientTypeIcon icon={ingredient.icon} />
</span> </span>
<span className="ingredient-picker__card-name">{ingredient.name}</span> <span className="ingredient-picker__card-name">
{t(`catalog.ingredients.${ingredient.key}`)}
</span>
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />} {showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
{showDiets && <DietBadges diets={ingredient.diets} />} {showDiets && <DietBadges diets={ingredient.diets} />}
</button> </button>

View file

@ -28,7 +28,7 @@ export function IngredientRow({
<span className="ingredient-row__icon" aria-hidden="true"> <span className="ingredient-row__icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} /> <IngredientTypeIcon icon={ingredient.icon} />
</span> </span>
<span className="ingredient-row__name">{ingredient.name}</span> <span className="ingredient-row__name">{t(`catalog.ingredients.${ingredient.key}`)}</span>
<input <input
type="number" type="number"
min="0" min="0"

View file

@ -97,7 +97,7 @@ export function RecipeDetailPanel({
<ul className="disliked-badges"> <ul className="disliked-badges">
{dislikedIngredients.map((ingredient) => ( {dislikedIngredients.map((ingredient) => (
<li key={ingredient.id} className="disliked-badge"> <li key={ingredient.id} className="disliked-badge">
🚫 {ingredient.name} 🚫 {t(`catalog.ingredients.${ingredient.key}`)}
</li> </li>
))} ))}
</ul> </ul>

View file

@ -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>
))} ))}

View file

@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { makeClientKey } from "../../lib/client-key";
import "./recipes.scss"; import "./recipes.scss";
/** One in-progress preparation step in the recipe form. `key` is a client-only stable identity for React/reordering — the server derives the real `order`/`id` from array position on save (see `schemas/recipe.ts`), never from this. */ /** One in-progress preparation step in the recipe form. `key` is a client-only stable identity for React/reordering — the server derives the real `order`/`id` from array position on save (see `schemas/recipe.ts`), never from this. */
@ -24,7 +25,7 @@ export function StepListEditor({
const { t } = useTranslation(); const { t } = useTranslation();
function addStep() { function addStep() {
onChange([...steps, { key: crypto.randomUUID(), description: "", picture: "" }]); onChange([...steps, { key: makeClientKey(), description: "", picture: "" }]);
} }
function updateStep(key: string, patch: Partial<Pick<StepDraft, "description" | "picture">>) { function updateStep(key: string, patch: Partial<Pick<StepDraft, "description" | "picture">>) {

View file

@ -9,13 +9,20 @@ import type { ReactNode } from "react";
// (schema.prisma), reused across the category chips, subcategory chips and // (schema.prisma), reused across the category chips, subcategory chips and
// ingredient cards in `IngredientPicker`/`IngredientRow`. Replaces the // ingredient cards in `IngredientPicker`/`IngredientRow`. Replaces the
// earlier per-ingredient emoji (437 different characters, one per // earlier per-ingredient emoji (437 different characters, one per
// ingredient) with a small, hand-drawn vocabulary grouped by *what kind of // ingredient) with a small vocabulary grouped by *what kind of thing* an
// thing* an ingredient is (a vegetable, a bottle of oil, a wedge of // ingredient is (a vegetable, a bottle of oil, a wedge of cheese…) rather
// cheese…) rather than a literal picture of each one — 437 unique, // than a literal picture of each one — 437 unique, professional icons
// professional icons isn't a realistic hand-drawn set, but ~20 generic // isn't a realistic set to draw or license one-by-one, but ~20 generic
// shapes reused across them is. Same 24×24, stroke-only house style as // shapes reused across them is.
// `layouts/nav-icons.tsx` (kept separate from that file since these are //
// ingredient-domain, not app-nav). // Most of these (everything using `FilledIcon` below) are solid-fill
// glyphs from foodiconpack.com's free "Common ingredient icons"/"Common
// Utensils" collections, licensed CC BY 4.0 — see the in-app credits page
// (`pages/CreditsPage.tsx`) for the required attribution. The three with
// no good match in that pack (`BreadIcon`, `DoughIcon`, `SproutIcon`) stay
// hand-drawn, using the stroke-based `Icon` wrapper that used to cover all
// 22 — both wrappers render at the same on-screen size (sized entirely via
// CSS, no width/height baked into either), so the mix reads as one set.
function Icon({ children }: { children: ReactNode }) { function Icon({ children }: { children: ReactNode }) {
return ( return (
<svg <svg
@ -32,111 +39,138 @@ function Icon({ children }: { children: ReactNode }) {
); );
} }
/** Carrot — root vegetables, leafy greens, and produce generally (`PRODUITS_FRAIS`/`LEGUMES`). */ /** Wrapper for the foodiconpack.com icons — solid-fill glyphs (2048×2048 source viewBox) rather than `Icon`'s stroke-only ones. `fill="currentColor"` on the root cascades to every child `<path>` (none set their own), so these theme/recolor exactly like the hand-drawn set. */
function FilledIcon({ children }: { children: ReactNode }) {
return (
<svg viewBox="0 0 2048 2048" fill="currentColor" aria-hidden="true">
{children}
</svg>
);
}
/** Carrot (foodiconpack.com, CC BY 4.0 — see the credits page) — root vegetables, leafy greens, and produce generally (`PRODUITS_FRAIS`/`LEGUMES`). */
export function VegetableIcon() { export function VegetableIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M12 3c1.5 1 2 2.5 1.3 4M15 2c1 1.2 1 2.6 0 4" /> <path d="M 1372.78 269.269 C 1451.37 259.868 1460.99 372.996 1461.44 425.661 C 1581.16 368.032 1695.37 431.416 1620.86 576.71 C 1685.93 569.075 1808.81 603.967 1777.39 694.986 C 1758.86 748.652 1687.55 747.579 1641.06 748.465 C 1580.54 749.618 1520.86 763.068 1465.02 788.033 C 1534.06 891.041 1509.32 1016.25 1431.63 1106.97 C 1379.12 1166.91 1315.36 1215.45 1253.34 1264.87 C 1058.07 1420.5 847.167 1562.19 628.521 1682.86 C 575.245 1711.66 526.145 1740.43 468.866 1762.97 C 390.259 1795.21 302.898 1750 274.227 1672.21 C 244.804 1591.18 312.531 1477.87 352.017 1402.88 C 476.741 1166.04 615.662 937.768 779.446 725.68 C 906.883 560.659 1021.56 455.144 1235.99 558.243 C 1264.92 502.026 1277.99 486.974 1284.17 416.13 C 1287.17 381.847 1282.64 350.416 1304.56 321.09 C 1322.93 296.516 1341.19 275.069 1372.78 269.269 z M 950.76 616.439 L 1013.63 678.467 C 1029.66 694.066 1071.68 724.095 1046.31 747.333 C 1024.75 767.082 1004.77 741.305 990.319 727.133 C 964.593 701.906 939.427 675.525 913.717 650.33 C 880.748 676.906 827.077 745.279 800.804 781.139 L 854.416 832.616 C 865.235 843.052 882.868 854.945 884.017 870.127 C 885.534 890.17 863.677 905.592 846.353 893.305 C 828.849 880.245 786.026 838.832 771.219 820.954 C 714.393 891.651 650.945 992.221 601.996 1070.21 C 624.959 1095 681.374 1144.78 697.215 1170.14 C 708.227 1187.77 690.155 1203.7 673.807 1207.19 C 653.583 1201.54 592.75 1131.47 575.668 1114.3 C 559.969 1135.77 548.023 1159.96 533.882 1182.5 C 510.398 1219.92 485.868 1260.5 465.325 1299.77 C 502.429 1335.29 536.293 1374.32 572.651 1410.85 C 616.262 1454.67 564.989 1469.73 552.538 1459.88 C 545.792 1454.54 539.672 1447.84 533.725 1441.61 C 507.768 1414.38 462.092 1372.67 441.283 1344.75 C 406.237 1391.7 337.706 1542.08 320.289 1594.46 C 307.509 1635.07 326.359 1680.01 360.377 1704.01 C 404.141 1734.89 437.219 1724.27 480.998 1702.92 C 588.332 1650.35 692.162 1590.27 793.354 1526.86 C 814.319 1513.73 835.284 1498.97 857.352 1487.95 C 835.987 1470.56 796.488 1430.13 780.352 1410.17 C 763.323 1389.11 795.041 1355.92 821.643 1382.07 C 847.186 1408.16 873.398 1433.7 899.028 1459.69 C 963.823 1420.72 1057.58 1356.01 1116.07 1307.2 C 1077.11 1269.13 1037.68 1230.01 1000.71 1190.1 C 982.941 1170.92 1012.3 1138.95 1033.56 1154.94 C 1048.09 1165.87 1062.35 1182.66 1075.26 1195.74 L 1155.51 1275.86 C 1166.47 1265.89 1185.51 1254.03 1198.05 1244 L 1260.92 1194.32 C 1274.19 1183.64 1289.57 1170.23 1302.77 1160.32 C 1278.8 1138.72 1256.47 1114.14 1233.14 1092.69 C 1217.61 1078.42 1202.94 1062.56 1221.87 1044.16 C 1249.46 1017.35 1315.96 1106.48 1340.25 1126.29 C 1444.56 1048.18 1496.33 911.829 1413.1 796.434 C 1406.16 786.802 1391.42 772.44 1382.72 763.523 L 1275.05 654.549 C 1222.53 602.189 1141.05 555.834 1065.3 567.094 C 1021.92 572.869 984.684 588.342 950.76 616.439 z M 1378.48 317.89 C 1330.92 337.14 1337 380.739 1333.96 418.637 C 1328.72 484.036 1311.97 533.086 1277.38 588.051 C 1293.37 600.283 1316.84 625.274 1331.99 640.442 L 1435.43 745.774 C 1500.7 717.893 1548.44 703.336 1620.74 699.414 C 1645.59 698.066 1687.15 701.875 1708.39 694.233 C 1721.56 688.881 1730.42 684.198 1734.14 669.597 C 1725.34 623.524 1643.09 621.963 1607.48 627.006 C 1580.34 630.85 1546.06 647.219 1546.46 602.242 C 1552.37 591.19 1562.16 583.393 1568.67 573.333 C 1587.79 543.812 1600.89 501.106 1592 466.268 C 1482.33 418.046 1449.83 520.39 1414.54 505.673 C 1378.89 490.801 1434.89 402.689 1400.54 340.129 C 1396.73 333.184 1387.45 318.271 1378.48 317.89 z" />
<path d="M13.3 7c2.8 0 4.7 2.9 4.1 5.6L15.8 20a2 2 0 0 1-3.9.2L9.2 12.6C8.3 9.9 10.3 7 13.3 7z" /> </FilledIcon>
</Icon>
); );
} }
/** Apple — `PRODUITS_FRAIS`/`FRUITS`. */ /** Apple (foodiconpack.com, CC BY 4.0) — `PRODUITS_FRAIS`/`FRUITS`. */
export function FruitIcon() { export function FruitIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M12 8c-.5-1.3-.2-3 1-4" /> <path d="M 639.796 267.013 C 781.434 262.103 956.112 338.089 1022.53 468.256 C 1028.76 480.476 1033.68 492.986 1039.98 505.103 C 1079.28 423.136 1119.59 362.725 1191.86 304.793 C 1207.29 295.22 1235.79 265.497 1254.81 269.055 C 1274.69 272.773 1277.84 292.064 1274.6 308.547 C 1266.47 318.866 1248.2 329.649 1237.12 338.285 C 1131.9 420.35 1060.45 546.672 1048.57 682.02 C 1107.04 669.285 1129.39 657.187 1183.99 637.63 C 1536.21 511.481 1760.44 792.38 1734.89 1120.39 C 1718.81 1354.66 1590.75 1616.21 1387.43 1738.88 C 1311.69 1785.07 1214.98 1792.8 1134.52 1754.06 C 1082.6 1729.07 1073.4 1712.81 1004.38 1718.28 C 917.695 1725.14 884.149 1804.95 750.621 1775.01 C 601.141 1741.49 473.114 1590.49 406.265 1456.56 C 327.693 1299.13 287.874 1104.16 324.793 930.29 C 366.48 733.959 526.268 590.689 731.532 609.409 C 820.954 617.563 903.048 662.498 993.64 682.02 C 995.341 671.368 996.474 657.971 997.837 647 C 980.828 646.119 962.537 647.498 945.443 646.853 C 794.823 641.173 687.284 529.291 639.45 392.227 C 628.619 361.191 596.29 281.598 639.796 267.013 z M 730.753 1713.44 C 832.193 1747.12 856.182 1715.04 934.669 1681.28 C 995.523 1655.1 1082.34 1658.82 1138.75 1694.17 C 1249.82 1763.75 1371.52 1705.24 1453.51 1620.95 C 1636.34 1440.7 1756.94 1086.45 1634.33 845.186 C 1571.31 719.014 1460.98 652.165 1319.22 660.997 C 1247.81 670.321 1204.55 687.291 1138.29 714.935 C 1006.74 769.809 916.081 711.143 793.279 675.575 C 667.76 638.278 533.232 676.049 451.257 780.278 C 245.567 1041.81 435.376 1604.58 730.753 1713.44 z M 1002.96 588.644 L 1004.74 588.061 C 1005.7 582.463 1002.62 570.998 1001.38 565.365 C 964.943 401.816 825.31 343.477 675.894 322.716 C 703.263 417.984 739.351 507.827 831.847 559.58 C 882.452 587.895 945.913 600.64 1002.96 588.644 z" />
<path d="M12 8c-4 0-6.5 3-6.5 7a6.5 6.5 0 0 0 13 0C18.5 11 16 8 12 8z" /> <path d="M 1447.63 774.31 C 1466.99 773.829 1479.84 789.768 1492.65 802.065 C 1598.84 903.998 1609.83 1072.78 1575.18 1207.76 C 1569.82 1228.55 1564.73 1260.7 1541.05 1267.95 C 1513.84 1272.68 1511.65 1247.61 1517.21 1228.75 C 1555.27 1099.42 1556.15 950.748 1461.25 844.839 C 1446.49 828.371 1414.28 815.596 1424.71 790.193 C 1429.12 779.44 1436.68 776.036 1447.63 774.31 z" />
</Icon> <path d="M 921.833 797.237 C 951.095 795.04 989.613 858.005 1079.32 816.787 C 1104.69 805.129 1153.42 774.482 1150.9 831.101 C 1144.48 842.746 1139.52 845.664 1128.52 851.943 C 974.142 937.063 833.621 814.285 921.833 797.237 z" />
<path d="M 1497.5 1305.04 C 1537.83 1303.8 1534.41 1331.11 1520.2 1360.63 C 1508.41 1385.13 1460.58 1486.18 1434.24 1491.8 C 1413.92 1495.19 1397.1 1475.37 1403.39 1458.8 C 1409.29 1443.25 1426.07 1419.47 1435.14 1405 C 1448.3 1384.01 1479.36 1312.89 1497.5 1305.04 z" />
</FilledIcon>
); );
} }
/** A leaf sprig — `PRODUITS_FRAIS`/`HERBES_FRAICHES`. */ /** Basil sprig (foodiconpack.com, CC BY 4.0) — `PRODUITS_FRAIS`/`HERBES_FRAICHES`. */
export function HerbIcon() { export function HerbIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M12 21V7" /> <path d="M 1297 375.152 C 1297.31 375.122 1297.62 375.092 1297.93 375.062 L 1299.5 374.946 C 1324.25 372.99 1330.3 393.76 1340.29 411.428 C 1445.38 597.423 1466.58 822.011 1335.29 1001.07 C 1538.83 1039.4 1693.43 1206.7 1764.63 1395.49 C 1780.62 1440.99 1802.85 1460.72 1755.17 1486.63 C 1511.01 1619.32 1256.57 1642.53 1054.51 1420.27 C 1044.41 1409.16 1035.39 1396.78 1024.69 1386.22 L 1023.41 1384.96 C 1020.62 1390.15 1017.8 1396.04 1015.36 1401.29 C 954.186 1533 852.17 1597.02 721.738 1643.36 C 635.872 1669.89 545.702 1676.59 456.49 1670 C 444.114 1669.08 424.62 1670.5 415.057 1660.86 C 399.096 1644.75 396.531 1553 396.403 1526.65 C 395.692 1380.85 439.29 1221.82 549.469 1120.14 C 556.91 1113.28 564.765 1107.03 571.903 1099.81 C 414.431 1016.69 311.984 867.702 267.408 696.354 C 260.292 669.001 292.765 660.426 312.264 650.538 C 464.952 573.114 662.94 553.712 812.003 649.983 C 839.647 667.809 858.12 688.164 882.624 708.423 C 952.609 553.039 1138.14 422.318 1297 375.152 z M 1058.31 1158.5 C 1086.64 1176.01 1110.56 1194.94 1141.8 1210.5 L 1143.45 1210.05 C 1161.11 1205.39 1177.37 1205.57 1195.39 1199.49 C 1211.62 1194.02 1222.58 1185.39 1238.1 1179.36 C 1265.26 1112.37 1353.03 1181.94 1236.12 1237.58 C 1226.69 1242.23 1215.48 1244.9 1205.92 1249.34 C 1271.27 1282.67 1347.14 1304.65 1419.69 1312.71 C 1444.43 1315.46 1471.38 1314.5 1495.71 1318.23 C 1516.07 1321.35 1518.21 1362.24 1492.81 1365.18 C 1396.9 1372.42 1308.35 1347.66 1221.61 1310.93 C 1176.05 1291.64 1092.58 1247.44 1054 1213.44 C 1054.22 1219.16 1054.24 1223.36 1054 1229.09 C 1054.21 1269.53 1049.24 1289.92 1043.83 1328.33 C 1055.08 1336.9 1066.99 1355.53 1076.09 1366.7 C 1168.79 1480.51 1310.86 1552.36 1459.54 1534.46 C 1539.73 1524.81 1652.75 1481.64 1726.47 1444.05 C 1719.96 1417.1 1708.08 1392.76 1696.41 1367.77 C 1628.45 1222.19 1504.2 1091.36 1343.67 1053.67 C 1329.31 1050.3 1305.36 1049.82 1293.46 1045.94 C 1216.88 1115.83 1148.01 1127.06 1058.31 1158.5 z M 1008.88 1104.51 C 1029.08 1016.58 1066.85 970.902 994.607 892.42 C 986.451 883.338 973.212 876.324 967.474 865.403 C 951.773 835.52 1009.39 801.066 1062.57 897.391 C 1074.35 864.076 1146.81 683.543 1177.85 667.485 C 1194.73 615.664 1245.36 655.028 1229.77 677.355 C 1162.43 773.807 1144.66 834.804 1103.59 935.529 C 1128.61 924.476 1194.49 891.272 1208.89 931.16 C 1219.69 961.081 1175.36 962.839 1160.07 967.194 C 1092.16 983.544 1071.27 1043.61 1062.57 1104.51 C 1114.58 1093.5 1194.46 1057 1237.44 1026.78 C 1282.73 994.928 1323.48 933.824 1344.58 883.971 C 1399.15 754.991 1376.26 618.983 1326.7 493.407 C 1319.33 474.74 1300.37 449.876 1294.56 430.498 L 1293.46 430.013 C 1073.91 508.331 826.989 716.516 924.55 976.739 C 949.002 1041.96 966.404 1054.78 1008.88 1104.51 z M 911.322 1220.42 C 908.583 1225.42 905.868 1230.48 903.012 1235.41 C 879.204 1305.9 892.966 1298.41 890.98 1355.46 C 869.513 1387.78 840.809 1367.92 837.943 1335.49 C 835.735 1310.5 834.461 1287.82 840.494 1263 C 772.153 1312.77 716.487 1369.47 664.741 1435.89 C 654.728 1448.75 636.703 1476.18 624.209 1484.63 C 603.237 1487.23 593.985 1482.23 588.611 1461.25 C 605.45 1395.46 769.662 1249.54 829.728 1210.5 C 804.37 1201.73 744.257 1193.55 731.185 1179.36 C 729.217 1161.72 731.122 1142.64 752.398 1142.95 C 790.952 1143.52 852.187 1158.43 882.624 1179.36 C 912.06 1159.77 938.23 1152.97 966.613 1137.46 C 933.936 1081.82 841.879 1065.99 783.499 1067 C 518.61 1094.25 425.858 1371.6 451.438 1602.06 L 453.382 1618.13 C 582.728 1636.62 748.134 1597.01 851.931 1521.4 C 953.222 1447.61 1028.59 1305.85 996.187 1179.36 C 969.744 1193.75 934.896 1205.5 911.322 1220.42 z M 624.209 1067 C 663.756 1024.24 801.711 1004.42 859.585 1024.71 C 834.882 993.777 768.557 949.403 734.582 928.067 C 689.329 896.195 626.684 867.979 574.45 850.664 C 565.801 847.797 555.964 845.051 548.231 840.425 C 529.357 829.134 535.202 795.346 557.806 793.29 C 574.762 791.747 594.243 803.066 610.101 808.112 C 686.088 836.217 805.124 901.186 863.317 957.456 C 852.613 903.863 843.249 823.055 863.317 771.526 C 803.078 672.149 665.648 629.126 555.357 635.989 C 500.936 641.624 449.889 649.695 398.311 669.264 C 372.256 679.149 347.839 692.36 322.412 703.094 C 351.371 820.526 445.376 959.627 543.493 1025.77 C 568.268 1042.47 598.946 1052.4 624.209 1067 z" />
<path d="M12 8c0-3 2-5 5-5 0 3-2 5-5 5z" /> </FilledIcon>
<path d="M12 13c0-3-2-5-5-5 0 3 2 5 5 5z" />
<path d="M12 18c0-2.5 1.7-4 4-4 0 2.5-1.7 4-4 4z" />
</Icon>
); );
} }
/** A cut of meat — `BOUCHERIE_POISSONNERIE`/`VIANDES`. */ /** Cut of beef (foodiconpack.com, CC BY 4.0) — `BOUCHERIE_POISSONNERIE`/`VIANDES`. */
export function MeatIcon() { export function MeatIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M8 16 17 7a3 3 0 1 1 4 4l-9 9a5 5 0 0 1-8-2 5 5 0 0 1 4-4z" /> <path d="M 1308.14 523.992 L 1312.6 523.924 C 1454.43 522.805 1610.61 548.182 1716.75 650.075 C 1775.28 706.261 1782.93 751.789 1783.01 826.54 L 1783.04 917.353 C 1783.06 1010.55 1771.33 1066.78 1700.13 1137.95 C 1596.62 1241.42 1403.81 1296.7 1259.76 1326.93 C 1209.71 1337.43 1160.48 1346.4 1120.16 1380.19 C 1012.81 1470.17 871.679 1517.01 732.041 1517.05 C 595.68 1521.18 446.238 1484.54 349.138 1386.9 C 249.032 1286.24 267.821 1219.77 264.451 1097.37 C 263.781 988.817 261.11 910.336 340.139 821.149 C 474.113 669.955 688.1 616.603 883.588 631.743 C 957.665 637.48 921.629 639.588 980.005 611.665 C 1080 554.854 1194.6 532.022 1308.14 523.992 z M 706.25 1286.5 C 818.707 1286.65 934.051 1253.71 1024.51 1185.44 C 1077.93 1145.12 1101.9 1119.99 1171.74 1105.09 C 1337.6 1069.69 1557.82 1016.23 1684.73 894.779 C 1750.71 831.638 1750.35 747.664 1684.27 684.643 C 1595.11 603.34 1458.42 569.51 1340.93 571.393 L 1333.37 571.545 C 1221.14 574.763 1097.45 597.84 999.775 655.417 C 940.411 690.411 951.287 685.266 883.111 678.262 C 713.082 660.793 509.707 714.149 388.559 839.132 C 285.627 945.322 291.099 1078.2 397.649 1178.69 C 480.66 1255.97 594.96 1285.65 706.25 1286.5 z M 311.865 1150.04 C 311.618 1193.12 310.214 1233.21 327.929 1274 C 360.387 1349.74 426.019 1399.76 500.238 1429.77 C 669.049 1498.04 907.387 1480.12 1056.81 1368.31 C 1144.06 1303.01 1155.66 1298.02 1263.78 1277.08 C 1411.68 1248.44 1661.21 1169.28 1720.81 1024.04 C 1727.44 1007.91 1741.85 923.985 1734.47 909.711 C 1629.45 1029.38 1445.12 1090.38 1294.98 1126.72 C 1243.16 1139.26 1192.05 1145.3 1142.31 1163.55 C 1106.31 1176.9 1076.19 1206.59 1044.46 1229.82 C 846.531 1374.72 495.37 1379.86 331.246 1177.56 C 324.617 1169.39 318.964 1158.23 311.865 1150.04 z" />
<circle cx="9" cy="15" r="1.2" fill="currentColor" stroke="none" /> <path d="M 1315.3 625.51 C 1403.29 621.006 1500.96 638.275 1580.27 677.164 C 1633.56 703.294 1709.65 758.76 1674.46 826.622 C 1617.4 936.633 1349.51 1014.12 1219.34 1042.33 C 1150.83 1057.18 1089.02 1071.24 1031.01 1112.69 C 1007.54 1129.45 986.187 1148.63 962.088 1164.65 C 832.042 1251.11 632.613 1256.92 496.247 1184.82 C 391.696 1129.54 330.391 1025.89 391.816 914.601 C 406.591 887.832 433.393 895.208 439.338 922.263 C 439.253 922.61 439.167 922.957 439.082 923.304 L 438.609 925.025 C 431.523 951.486 410.012 974.111 413.43 1009.19 C 415.396 1029.38 424.15 1059.52 439.338 1072.9 C 444.902 1083.93 450.491 1091.78 459.648 1100.35 C 584.804 1217.49 818.215 1211.08 952.793 1113.46 C 1069.9 1022 1106.29 1021.56 1252.02 986.806 C 1361.4 960.723 1533.36 906.124 1616.42 824.398 C 1680.03 768.986 1552.92 713.717 1512.33 700.09 C 1363.35 650.077 1194.11 669.799 1053.38 735.651 C 1022.03 750.322 997.224 723.169 1021.34 697.751 C 1072.9 656.981 1251.49 628.454 1315.3 625.51 z" />
</Icon> <path d="M 646.825 886.362 C 702.457 879.407 786.447 922.225 794.75 982.778 C 801.648 1033.08 763.457 1065.59 718.244 1072.9 C 567.004 1081.28 502.772 901.463 646.825 886.362 z M 718.244 1024.83 C 793.647 1002.49 716.16 931.349 646.825 932.999 C 561.064 956.042 655.25 1036.09 718.244 1024.83 z" />
<path d="M 802.826 729.574 C 828.698 729.076 924.149 731.655 942.262 748.115 C 956.777 761.306 943.306 787.771 925.012 787.846 C 907.327 787.919 888.961 782.426 871.344 780.184 C 846.828 777.066 821.805 776.935 797.139 776.997 C 760.02 779.065 724.895 784.441 688.234 789.836 C 663.605 793.461 645.396 771.717 661.968 753.577 C 679.575 734.304 774.344 730.066 802.826 729.574 z" />
</FilledIcon>
); );
} }
/** Drumstick — `BOUCHERIE_POISSONNERIE`/`VOLAILLES`. */ /** Chicken (foodiconpack.com, CC BY 4.0) — `BOUCHERIE_POISSONNERIE`/`VOLAILLES`. */
export function PoultryIcon() { export function PoultryIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M13 4c2.5 0 4.5 2 4.5 4.5 0 2.8-2.3 4.8-4 6.4l-3.2 3.2a2.3 2.3 0 0 1-3.3-3.3l3.2-3.2C11.7 10 13.7 7.7 13 4z" /> <path d="M 675.126 266.323 C 967.235 260.048 1214.44 528.773 1251.89 805.204 C 1266.26 911.248 1251.46 943.654 1312.46 1036.29 C 1335.55 1072.85 1332.23 1115.27 1333 1156.17 C 1341.12 1168.54 1356.96 1182.77 1368.02 1193.66 L 1523.48 1347.01 C 1571.93 1308.81 1626.77 1294.25 1686.33 1320.03 C 1837.19 1385.35 1796.26 1620.31 1605.98 1612.96 C 1607.1 1734 1500.17 1813.09 1387.71 1760.71 C 1313.41 1722.49 1280.75 1625.77 1323.94 1552.68 C 1330.42 1541.72 1340.23 1529.7 1347.93 1519.26 C 1328.45 1504.48 1304.03 1476.31 1285.74 1458.28 C 1242.55 1415.72 1196.92 1372.39 1155.75 1327.99 C 1115.44 1335.98 1066.84 1330.84 1034.25 1304.74 C 952.169 1239.01 919.31 1249.46 819.432 1240.34 C 552.64 1215.97 290.736 994.396 273.345 717.722 C 258.595 483.062 440.327 279.156 675.126 266.323 z M 1012.42 1226.73 C 1064.37 1255.02 1057.57 1277.45 1119.99 1283.74 L 1119.73 1282.45 C 1117.6 1272.41 1113.71 1263.24 1111.83 1252.97 C 1096.94 1171.59 1162.38 1099.72 1244.97 1108.95 C 1257.47 1110.35 1269.74 1114.56 1282.14 1115.55 C 1281.25 1066.26 1263.39 1056.6 1241.35 1018.29 C 1209.13 962.295 1212.42 888.346 1204.72 825.685 C 1172.36 562.105 946.595 307.481 667.216 315.994 C 336.567 353.769 220.698 717.712 423.74 970.752 C 524.402 1096.2 678.735 1180.6 839.46 1192.03 C 911.572 1197.16 943.937 1193.67 1012.42 1226.73 z M 1555.01 1604.22 C 1549.48 1592.78 1542.83 1579.52 1548.04 1566.93 C 1561.96 1533.24 1596 1559.93 1616.77 1561.54 C 1731.75 1570.5 1773.06 1412.29 1665.9 1366.04 C 1579.53 1328.72 1563.03 1415.3 1503.62 1392.76 C 1483.85 1385.26 1452.8 1348.4 1437.09 1332.91 L 1318.07 1213.59 C 1293.75 1189.22 1266.91 1153.69 1227.77 1159.34 C 1167.96 1167.95 1138.15 1231.74 1174.36 1275.3 C 1192.34 1296.95 1216.41 1318.31 1236.62 1338.38 L 1352.4 1454.75 C 1401.76 1503.76 1423.84 1509.57 1371.55 1571.75 C 1356.84 1590.46 1352.85 1621.38 1357.31 1643.95 C 1383.05 1774.38 1573.03 1740.94 1555.01 1604.22 z" />
<path d="M6.5 21 5 22.5" /> <path d="M 455.532 531 C 499.531 529.749 482.303 565.435 472.293 588.075 C 368.207 819.036 626.45 1051.94 846.232 1066.11 C 854.945 1066.67 864.015 1066.09 872.296 1067.15 C 898.816 1070.54 905.178 1109.95 875.701 1115.55 C 815.373 1120.97 737.3 1099.73 683.214 1075.56 C 484.697 986.826 328.482 768.729 435.173 551.515 C 440.697 540.27 443.403 535.311 455.532 531 z" />
<path d="M9 18.5 7.5 20" /> <path d="M 595.867 416.13 C 613.538 413.493 629.489 429.31 625.29 446.941 C 621.69 462.056 580.796 475.159 563.709 484.618 C 542.722 487.614 527.439 464.903 536.564 448.282 C 543.497 435.655 582.641 420.266 595.867 416.13 z" />
</Icon> </FilledIcon>
); );
} }
/** Fish — `BOUCHERIE_POISSONNERIE`/`POISSONS`. */ /** Salmon (foodiconpack.com, CC BY 4.0) — `BOUCHERIE_POISSONNERIE`/`POISSONS`. */
export function FishIcon() { export function FishIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M3 12c3-4 8-6 13-4 2.5 1 5 3 5 4s-2.5 3-5 4c-5 2-10 0-13-4z" /> <path d="M 848.8 611.959 C 956.068 608.708 1044.51 631.421 1131.39 698.914 C 1148.25 712.014 1165.22 725.562 1177.7 742.966 C 1227.5 742.17 1299.29 739.642 1347.16 745.629 C 1475.32 761.658 1584.27 822.795 1683.11 902.874 C 1728.9 939.97 1819.43 997.284 1769.04 1067.17 C 1695.69 1166.69 1489.7 1273.94 1375.76 1298.94 C 1360.87 1302.21 1346.37 1303.87 1331.48 1306.84 C 1329.74 1307.19 1324.42 1329.42 1322.71 1331.44 C 1312.15 1353.61 1273.12 1434.41 1240.09 1416.29 C 1205.22 1397.16 1161.41 1373.48 1131.54 1347.44 C 1077.87 1401.88 959.556 1430.72 883.367 1434.04 C 864.642 1434.85 841.26 1438.14 830.499 1420.07 C 824.192 1403.88 839.343 1383.72 845.775 1368.4 C 859.93 1334.68 868.734 1300.99 871.477 1264.51 C 811.517 1248.31 719.051 1202.21 660.649 1176.38 C 635.966 1165.47 610.267 1149.44 584.486 1142.76 C 569.097 1138.77 527.699 1150.22 516.836 1159.32 C 477.658 1192.15 410.834 1284.93 366.594 1293.68 C 328.759 1301.16 292.698 1283.67 276.582 1248.61 C 257.157 1207.52 278.639 1177.56 298.038 1142.08 L 333.333 1078.66 C 358.335 1034.54 356.085 1028.74 329.607 985.464 C 313.639 959.826 300.153 929.214 283.839 904.045 C 234.677 828.199 326.659 715.631 421.756 816.857 L 467.491 866.032 C 528.722 932.931 541.448 935.655 629.953 894.25 C 675.649 872.872 818.201 795.24 861.408 788.49 C 869.971 739.897 865.6 713.87 843.414 669.403 C 836.835 652.967 807.955 621.582 848.8 611.959 z M 1035.23 1260.01 C 1084.67 1266.26 1121.21 1264.44 1170.4 1264.52 C 1268.29 1264.66 1372.07 1266.46 1462.35 1221.55 C 1318.94 1111.49 1314.37 931.696 1452.56 819.114 C 1376.92 794.541 1343.35 788.251 1265.28 788.502 L 1181.4 788.532 C 1154.8 788.512 1108.16 785.885 1084.93 788.49 C 912.171 783.433 754.681 894.896 602.061 958.678 C 542.216 981.383 542.976 1064.44 597.361 1093.98 C 632.316 1112.96 667.984 1128.68 704.288 1144.7 C 784.236 1179.98 949.211 1257.44 1035.23 1260.01 z M 1513.21 1199.7 C 1538.49 1186.5 1565.23 1169.12 1588.88 1157.14 L 1590.18 1156 C 1637 1115.8 1692.77 1091.87 1732.84 1037.59 C 1761.99 998.107 1583.28 886.745 1555.05 868.355 C 1541.64 859.615 1517.43 849.614 1506.72 840.812 C 1354.84 928.932 1366.55 1109.04 1513.21 1199.7 z M 538.028 1100.01 C 508.305 1065.26 497.667 1001.63 528.261 965.1 C 463.125 943.727 445.497 907.33 398.237 859.615 C 383.478 844.714 370.882 820.78 347.424 822.164 C 278.723 836.198 344.977 918.628 362.722 950.128 C 411.207 1029.13 405.5 1044.14 363.451 1120.08 L 331.581 1177.83 C 318.823 1201.08 302.259 1225.54 331.962 1244.78 C 361.159 1263.68 384.283 1227.56 401.021 1210.56 C 422.042 1188.15 474.084 1130.68 497.432 1116.97 C 510.269 1109.44 524.88 1106.52 538.028 1100.01 z M 1207.23 1311.42 L 1162.01 1311.42 C 1184.76 1333.38 1215.11 1349.18 1241.53 1366.8 C 1243.02 1366.96 1246.95 1367.54 1248.19 1366.8 C 1261.56 1345.86 1269.05 1333.98 1280.47 1311.42 L 1207.23 1311.42 z M 1111.06 742.966 C 1058.95 692.943 961.972 655.509 888.733 656.971 C 908.887 698.018 911.838 724.895 910.908 769.027 C 958.966 755.726 1031.03 738.7 1080.72 742.966 C 1094.34 741.284 1097.55 741.422 1111.06 742.966 z M 917.364 1281.01 C 910.429 1325.55 901.194 1346.02 888.733 1386.83 C 940.753 1385.27 990.679 1371.4 1038.67 1352.01 C 1060.75 1342.49 1086.55 1329.1 1103.09 1311.42 C 1030.94 1309.91 986.962 1301.35 917.364 1281.01 z" />
<path d="M16 9v6" /> <path d="M 1138.71 911.138 C 1141.01 911.319 1146.16 911.143 1148.69 911.138 C 1179.13 922.018 1172.09 950.887 1143.5 958.005 C 1080.14 973.783 1025.18 1007.98 985.657 1060.08 C 976.182 1072.57 973.205 1084.48 959.841 1094.55 C 936.953 1100.24 920.042 1077.29 934.135 1054.15 C 980.888 977.368 1054.04 933.806 1138.71 911.138 z" />
<circle cx="6.5" cy="11" r=".6" fill="currentColor" stroke="none" /> <path d="M 935.933 915.073 C 953.045 914.272 967.591 929.458 961.074 946.565 C 955.463 961.292 877.195 1000.11 851.021 1070.08 C 846.765 1081.41 842.067 1089.7 830.499 1094.55 C 792.917 1099.31 802.165 1062.07 811.947 1040.99 C 832.235 997.279 888.251 932.996 935.933 915.073 z" />
</Icon> <path d="M 1562.6 950.18 C 1573.55 948.189 1584.28 954.59 1587.73 965.166 C 1589.08 969.304 1589.19 973.747 1588.04 977.944 C 1585.68 986.54 1578.42 992.9 1569.59 994.105 C 1563.05 994.998 1556.45 992.937 1551.57 988.479 C 1546.7 984.021 1544.06 977.628 1544.36 971.03 C 1544.85 960.681 1552.41 952.035 1562.6 950.18 z" />
</FilledIcon>
); );
} }
/** Shrimp — `BOUCHERIE_POISSONNERIE`/`CRUSTACES_FRUITS_DE_MER`. */ /** Shrimp (foodiconpack.com, CC BY 4.0) — `BOUCHERIE_POISSONNERIE`/`CRUSTACES_FRUITS_DE_MER`. */
export function ShellfishIcon() { export function ShellfishIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M6 6c6-2 12 1 13 7-4 2-9 2-12-1C5 10 4.5 8 6 6z" /> <path d="M 406.938 329.975 C 420.456 328.426 461.606 329.929 478.111 329.968 L 1131.51 329.551 C 1470.33 330.801 1773.34 636.729 1768.97 977.539 C 1764.28 1343.03 1477.67 1613.49 1117.55 1611 C 1016.13 1682.55 903.221 1736.61 783.079 1768.46 C 758.473 1774.04 730.58 1783.31 705.357 1777.76 C 660.154 1767.83 630.173 1723.58 639.98 1678 C 647.173 1644.57 671.241 1628.08 695.497 1606.99 L 753.833 1556.35 C 766.873 1545.02 790.931 1521.56 803.533 1513.61 C 790.311 1499.24 771.674 1488.17 755.927 1475.94 C 715.955 1444.91 649.476 1381.54 635.493 1332.75 C 627.423 1290.39 652.422 1253.62 692.825 1240.15 C 753.277 1220.01 936.175 1304.57 988.999 1331.68 C 1020.6 1347.9 1050.88 1366.76 1082.58 1383.33 C 1098.43 1377.29 1127.57 1377.77 1146.56 1371.73 C 1207.35 1352.41 1259.45 1319.26 1291.51 1263.48 C 1337.45 1180.16 1370.08 1123.42 1342.98 1021.19 C 1316.94 922.937 1222.75 846.004 1121.11 839.379 C 1097.59 837.846 1033.24 836.114 1012.94 842.367 C 953.966 860.532 924.33 939.719 967.505 988.121 C 993.163 1016.89 1013.51 1022.95 1040.23 1043.51 C 1064.01 1062.28 1038.26 1106.8 1000.82 1081.01 C 904.072 1014.36 858.029 948.67 929.502 838.946 C 864.678 837.299 799.62 883.674 812.342 953.152 C 821.604 1003.73 876.964 1022.7 892.321 1055.77 L 892.93 1057.12 C 883.291 1126.99 828.577 1062.01 807.666 1042.42 C 746.966 985.54 742.644 897.667 803.533 838.946 C 770.437 837.973 735.885 840.112 702.975 838.728 C 512.664 830.723 353.129 724.246 298.564 537.94 C 282.773 484.022 265.073 438.935 302.954 387.995 C 327.141 355.47 367.174 335.163 406.938 329.975 z M 359.088 401.252 C 381.815 416.537 394.861 433.427 415.235 451.468 C 467.804 498.019 526.934 511.39 594.352 518.344 C 592.133 535.017 589.861 552.495 589.477 569.33 C 513.374 562.715 444.814 542.9 385.02 493.222 C 368.174 479.227 346.168 454.633 330.488 443.841 C 330.307 514.756 386.704 628.592 434.824 676.604 C 507.857 749.474 621.768 788.396 723.801 788.689 L 843.532 788.441 C 860.28 788.413 882.053 787.742 898.5 788.49 L 1038.36 788.472 C 1140.07 788.443 1215.01 788.544 1299.22 861.535 C 1441.2 811.164 1518.19 711.434 1544.44 565.358 C 1402.87 437.853 1295.28 380.584 1095.5 380.613 L 693.374 380.636 L 433.545 380.606 C 406.526 380.464 381.311 384.734 359.088 401.252 z M 1338.86 903.254 C 1379.94 947.312 1405.12 1027.41 1402.49 1086.62 C 1431.25 1098.02 1443.96 1104.03 1475.01 1111.46 C 1567.78 1128.49 1642.63 1113.9 1713 1048.13 C 1737.19 927.145 1693.35 769.641 1627.82 668.707 C 1620.68 657.712 1597.84 623.903 1589.87 616.435 C 1585.79 627.183 1582.78 638.577 1579.17 649.494 C 1537.83 774.365 1453.88 850.262 1338.86 903.254 z M 1237.15 1388.06 C 1188.44 1418.7 1089.96 1431.25 1072.26 1448.24 C 1033.97 1485 1054.23 1546.57 1105.82 1555.71 C 1127.29 1559.52 1143.48 1559.29 1164.89 1558.99 C 1217.37 1553.15 1303.79 1539.24 1351.4 1513.61 C 1305.52 1486.7 1268.61 1456.41 1246.45 1406.36 C 1244.91 1402.89 1239.04 1389.72 1237.15 1388.06 z M 1696.63 1125.81 C 1619.77 1182.53 1484.66 1181.57 1398.81 1140.65 C 1380.05 1217.41 1341.24 1299.85 1284.26 1355.35 C 1290.07 1379.84 1300.31 1401.23 1315.82 1421.15 C 1344.11 1455.26 1377.21 1471.4 1419.23 1481.63 C 1518.71 1443.09 1623.53 1309.89 1665.65 1216.82 C 1671.57 1203.75 1698.88 1137.41 1696.63 1125.81 z M 713.193 1287.13 C 701.618 1289.96 695.911 1294.19 689.674 1304.42 C 663.742 1346.97 811.935 1456.07 841.285 1472.47 C 900.389 1505.48 885.289 1511.25 835.595 1553.03 C 815.59 1569.86 702.246 1665.68 692.995 1681.95 C 684.317 1697.21 693.107 1716.72 707.541 1724.7 C 725.346 1734.55 814.134 1707.12 840.752 1696.69 C 887.26 1680.39 1018.53 1623.86 1054.87 1588.48 C 982.295 1545.74 989.462 1476.4 1031.52 1412.55 C 939.345 1365.12 862.757 1323.81 759.293 1296.88 C 748.377 1294.04 723.137 1286.46 713.193 1287.13 z" />
<path d="M6 6 4 4M7 8 5 7M8 11l-2.2.6" /> <path d="M 663.454 505.915 C 691.733 504.193 716.17 525.462 718.366 553.708 C 720.562 581.954 699.707 606.745 671.502 609.415 C 652.83 611.183 634.657 602.742 623.963 587.335 C 613.268 571.929 611.715 551.952 619.9 535.077 C 628.084 518.202 644.734 507.055 663.454 505.915 z" />
</Icon> </FilledIcon>
); );
} }
/** Wheat ear — grains, pasta, rice, flour (`EPICERIE_SECHE`/`FECULENTS`, and flours under `AIDES_CULINAIRES`/`BASES`). */ /** Bowl of rice (foodiconpack.com, CC BY 4.0) — grains, pasta, rice, flour (`EPICERIE_SECHE`/`FECULENTS`, and flours under `AIDES_CULINAIRES`/`BASES`). */
export function GrainIcon() { export function GrainIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M12 22V6" /> <path d="M 910.895 546.004 C 963.142 539.715 976.976 568.025 1015.6 572.175 C 1067.55 579.738 1143.11 497.126 1227.25 585.607 C 1265.7 626.042 1281.37 619.731 1333.45 613.476 C 1383.16 607.852 1437.33 637.737 1461.72 680.486 C 1492.16 733.835 1478.5 737.073 1547.2 751.088 C 1597.77 761.402 1642.54 804.035 1653.56 854.79 C 1665.08 907.857 1662.91 902.985 1711.69 916.532 C 1799.22 942.499 1808.65 1051.5 1730.7 1105.25 C 1727.55 1124.49 1717.68 1147.67 1709.51 1165.97 C 1652.44 1293.6 1556 1408.56 1434.94 1479.6 C 1399.88 1500.17 1350.75 1495.6 1311.33 1495.59 L 1166.25 1495.54 L 723.987 1495.58 C 638.136 1495.6 617.325 1495.43 547.632 1438.36 C 439.637 1349.93 360.588 1238.54 320.186 1105.25 C 312.672 1099 303.794 1093.43 296.379 1086.6 C 244.517 1038.86 257.881 948.348 324.422 920.61 C 391.508 892.645 373.956 917.684 398.634 843.682 C 412.775 801.278 451.966 762.448 495.675 751.506 C 559.266 735.587 546.838 742.839 577.749 688.391 C 606.277 638.141 663.732 605.766 721.87 613.943 C 767.387 620.345 778.161 626.969 813.557 592.239 C 844.736 562.253 867.315 547.981 910.895 546.004 z M 1123.75 593.348 C 1084.78 598.665 1072.65 620.665 1024.21 620.51 C 969.014 620.334 957.472 587.74 904.098 595.381 C 848.119 603.395 838.626 672.347 751.279 667.428 C 704.28 664.781 671.206 644.723 631.072 697.809 C 616.411 717.201 609.97 741.06 592.558 758.55 C 559.137 801.379 512.288 783.678 474.207 817.233 C 408.119 875.466 479.926 921.396 341.041 966.749 C 318.781 974.018 305.366 1019.28 319.921 1037.81 C 341.458 1075.3 386.538 1066.19 423.368 1066.19 L 899.75 1066.17 L 1656.66 1066.27 C 1667.48 1066.37 1677.69 1066.49 1688.01 1066.01 C 1737.09 1063.73 1767.97 972.945 1671.43 957.275 C 1606.38 946.716 1612.76 858.359 1586.19 827.757 C 1544.59 779.835 1485.94 815.998 1438.22 741.598 C 1430.31 729.264 1425.74 714.695 1418.47 702.944 C 1361 610.666 1286.49 715.282 1206.1 632.781 C 1182.1 608.147 1161.13 589.594 1123.75 593.348 z M 1549.74 1321.46 C 1604.47 1258.73 1646.54 1193.93 1677.04 1115.97 C 1648.29 1115.12 1615.43 1115.97 1586.23 1115.97 L 422.623 1115.92 C 411.331 1115.9 386.023 1114.98 376.085 1115.97 C 407.412 1231.51 523.469 1368.3 622.564 1429.74 C 649.154 1446.22 669.529 1445.25 699.498 1445.35 L 1328.1 1445.41 C 1341.98 1445.41 1382.15 1446.27 1393.43 1442.45 C 1444.74 1425.09 1514.43 1360.06 1549.74 1321.46 z" />
<path d="M12 7c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 7c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" /> <path d="M 1141.2 687.994 C 1152.75 687.403 1193.43 686.078 1203.08 691.804 C 1220.91 702.389 1217.43 733.715 1193.16 737.983 C 1183.34 738.44 1167.74 737.105 1157.57 738.156 C 1108.99 743.18 1107.03 692.946 1141.2 687.994 z" />
<path d="M12 11c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 11c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" /> <path d="M 560.473 908.045 C 619.234 904.862 574.313 970.708 540.825 986.982 C 518.475 987.906 510.767 975.204 511.027 954.689 C 517.878 941.655 546.665 912.691 560.473 908.045 z" />
<path d="M12 15c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 15c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" /> <path d="M 779.781 947.003 C 793.571 946.901 828.187 944.959 838.316 951.744 C 856.542 963.953 848.523 992.185 826.199 996.97 C 815.153 997.062 780.364 998.789 771.828 994.339 C 750.301 983.115 754.338 953.94 779.781 947.003 z" />
<path d="M12 6c0-1.5.8-2.8 2-3.5" /> <path d="M 1056.75 865.234 C 1113.77 869.736 1066.37 938.797 1032.46 943.013 C 974.711 946.264 1028.33 872.554 1056.75 865.234 z" />
</Icon> <path d="M 936.348 737.983 C 958.3 731.257 1033.13 789.941 976.649 809.248 C 951.821 813.134 884.764 753.056 936.348 737.983 z" />
<path d="M 1231.76 918.185 C 1252.8 919.574 1319.83 980.518 1263.2 996.97 C 1239.04 996.092 1175.04 929.033 1231.76 918.185 z" />
<path d="M 1342.56 781.027 C 1369.08 780.782 1429.65 832.584 1379.48 858.001 C 1356.36 858.94 1292.24 799.262 1342.56 781.027 z" />
<path d="M 1472.67 943.013 C 1485.27 942.888 1509.89 941.296 1520.38 945.509 C 1544.12 955.043 1537.31 985.279 1514.98 993.49 C 1502.43 993.656 1468.83 995.776 1460.6 989.535 C 1440.72 974.456 1448.11 947.945 1472.67 943.013 z" />
<path d="M 709.837 784.339 C 734.276 782.429 793.224 845.928 739.437 858.001 C 711.809 858.665 655.635 792.466 709.837 784.339 z" />
<path d="M 1503.8 1239.21 C 1533.06 1239.99 1537.22 1267.62 1517.1 1287.65 C 1436.03 1368.35 1341.5 1414.67 1226.01 1414.01 C 1220.84 1414.36 1216.6 1414.17 1211.46 1414.01 C 1195.42 1398.13 1192.26 1372.96 1219.01 1365.73 C 1229.64 1362.85 1247.87 1365.62 1259.95 1364.16 C 1337.23 1354.87 1410.36 1318.8 1467.44 1266.26 C 1478.54 1256.05 1488.75 1242.33 1503.8 1239.21 z" />
</FilledIcon>
); );
} }
/** Bean pod — `EPICERIE_SECHE`/`LEGUMINEUSES`. */ /** Chickpeas (foodiconpack.com, CC BY 4.0) — `EPICERIE_SECHE`/`LEGUMINEUSES`. */
export function LegumeIcon() { export function LegumeIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M5 13c0-5 3.5-9 9-9 1 3-1 5-1 5s3 0 5 3c0 5-4 9-9 9-3 0-4-4-4-8z" /> <path d="M 1013.51 267.432 C 1054.43 264.207 1100.24 278.034 1122.05 315.518 C 1180.56 423.993 1241.02 436.027 1342.11 494.876 C 1471.74 570.336 1530.28 681.13 1486.64 829.74 C 1545.75 839.209 1566.11 892.267 1599.8 933.697 C 1671.83 1022.28 1715.25 1055.05 1758.15 1175.38 C 1841.93 1412.28 1678.03 1672.39 1450.72 1750.57 C 1308.73 1799.41 1173.53 1789.15 1039.67 1724.49 C 1033.19 1721.15 1031.84 1719.92 1026.34 1715.26 C 991.374 1733.25 959.676 1746.61 921.88 1758.18 C 494.809 1888.38 94.9953 1429.32 345.529 1050.14 C 361.138 1026.52 380.493 1006.19 399.199 985.2 L 459.204 916.706 C 496.056 874.184 500.503 852.333 555.689 829.74 C 553.61 813.771 548.9 797.541 547.223 780.596 C 535.851 665.686 596.471 557.081 693.829 497.779 C 775.149 450.088 855.239 428.656 910.864 340.514 C 940.561 293.459 948.947 274.699 1013.51 267.432 z M 603.727 818.271 C 656.652 822.916 692.575 834.894 725.311 879.589 C 742 902.376 784.999 948.72 789.685 973.144 C 786.727 981.272 784.008 987.91 775.434 991.692 C 729.185 1012.41 703.046 876.839 618.793 869.227 C 570.664 864.878 545.066 886.976 517.381 922.752 C 464.085 991.627 394.366 1049.45 357.354 1129.84 C 193.102 1485.16 574.214 1833.93 933.689 1702.98 C 946.781 1698.22 964.505 1691.1 976.179 1684.07 C 952.103 1667.53 938.458 1655.36 917.845 1634.5 C 744.671 1462.32 720.803 1208.92 873.734 1012.23 C 892.823 987.684 916.515 966.264 935.633 941.851 C 957.672 913.706 977.482 885.57 1001.01 858.542 C 1017.98 839.054 1039.72 835.474 1054.3 822.633 C 1082.47 752.252 1063.19 669.654 1042.05 599.577 C 1035.1 576.532 1012.05 548.352 1026.34 525.437 C 1041.77 515.724 1060.34 514.993 1068.52 533.407 C 1106.84 619.641 1132.29 725.908 1108.23 818.271 C 1202.19 821.085 1218.89 888.402 1272.32 942.071 C 1315.86 881.786 1339.44 833.735 1419.84 820.857 L 1436.6 818.271 C 1456.63 760.852 1462.07 712.001 1433.76 653.521 C 1376.68 535.596 1237.13 516.899 1150.84 435.265 C 1134.31 419.621 1120.85 404.766 1107.08 386.506 C 1081.94 353.152 1071.33 305.846 1013.51 316.999 C 969.085 322.72 966.599 349.547 944.081 380.567 C 880.308 468.417 812.995 487.203 728.093 534.855 C 623.605 594.958 577.059 702.123 603.727 818.271 z M 1528.15 1658.14 C 1627.22 1594.53 1696.94 1496.13 1723.35 1380.9 C 1762.22 1211.25 1665.02 1075.29 1557.48 958.821 C 1542.9 943.033 1532.25 922.398 1518.81 905.496 C 1486.7 865.13 1425.17 857.059 1382.59 885.343 C 1336.85 915.728 1307.92 1010.81 1265.44 1008.94 C 1253.38 1002.37 1245.86 990.451 1237.49 979.5 L 1198.79 928.664 C 1167.97 887.694 1148.14 864.117 1089.75 868.031 C 1026.85 877.015 1005.09 937.006 967.48 979.741 C 947.131 1002.86 924.883 1024.48 906.383 1049.2 C 617.866 1445.08 1108.89 1920.89 1528.15 1658.14 z" />
<circle cx="9.5" cy="14.5" r="1" fill="currentColor" stroke="none" /> <path d="M 803.837 570.263 C 816.631 568.791 828.258 576.677 831.432 589.333 C 842.771 634.54 728.622 623.76 711.544 730.095 C 708.383 749.772 710.529 763.493 688.265 769.926 C 612.6 773.1 694.982 592.905 803.837 570.263 z" />
<circle cx="13" cy="11" r="1" fill="currentColor" stroke="none" /> <path d="M 541.29 1433.12 C 592.324 1430.63 559.382 1475.46 570.723 1502.01 C 578.27 1519.67 633.086 1547.81 584.185 1569.25 C 532.537 1578.43 488.747 1458.63 541.29 1433.12 z" />
</Icon> <path d="M 497.481 1073.17 C 542.167 1068.57 514.288 1121.55 524.535 1142.28 C 531.879 1158.98 584.927 1184.58 537.784 1205.85 C 479.931 1210.95 445.843 1093.14 497.481 1073.17 z" />
<path d="M 460.572 1258.23 C 478.638 1255.82 486.678 1260.75 492.994 1277.94 C 476.761 1349.19 512.767 1321.04 523.439 1358.64 C 521.051 1370.57 519.267 1378.45 507.165 1383.62 C 448.938 1399.11 412.727 1273.75 460.572 1258.23 z" />
<path d="M 600.018 1258.23 C 618.259 1255.06 625.732 1263.03 633.327 1277.94 C 624.777 1322.36 625.753 1319.44 660.777 1346.4 C 669.325 1363.88 664.223 1374.38 647.895 1383.62 C 590.88 1393.42 551.674 1281.29 600.018 1258.23 z" />
<path d="M 682.829 1466.11 C 722.586 1464.19 702.352 1506.18 715.124 1525.8 C 731.787 1551.4 754.706 1540.44 750.813 1578.22 C 745.417 1586.64 742.894 1589.39 733.455 1592.05 C 674.449 1594.11 630.88 1478.9 682.829 1466.11 z" />
<path d="M 1521.8 1333.05 C 1604.54 1329.65 1516.57 1582.4 1323.12 1597.61 C 1283.49 1602.06 1282.02 1552.74 1320.79 1550.14 C 1411.56 1544.06 1487.33 1464.11 1502.59 1376.73 C 1505.99 1357.39 1500.88 1341.19 1521.8 1333.05 z" />
</FilledIcon>
); );
} }
/** Acorn — nuts, seeds, dried fruit (`EPICERIE_SECHE`/`GRAINES_FRUITS_SECS`). */ /** Almonds (foodiconpack.com, CC BY 4.0) — nuts, seeds, dried fruit (`EPICERIE_SECHE`/`GRAINES_FRUITS_SECS`). */
export function NutSeedIcon() { export function NutSeedIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M8 9c0-3 1.8-5 4-5s4 2 4 5" /> <path d="M 519.112 267.004 C 702.645 256.911 911.605 357.081 1054.42 464.673 C 1072.21 478.076 1088.34 493.554 1106.8 506.701 C 1245.69 449.076 1395.16 403.644 1547.04 409.112 C 1629.93 412.095 1660.51 492.578 1689.44 554.52 C 1857.55 927.686 1771.06 1348.1 1451.5 1602.11 C 1339.67 1689.88 1222.33 1748.42 1080.95 1770.26 C 771.484 1818.07 513.153 1603.38 513.066 1285.81 C 502.419 1269.82 489.949 1256.71 477.939 1241.8 C 284.456 1001.56 225.698 709.39 317.313 414.961 C 353.481 298.726 401.491 280.595 519.112 267.004 z M 808.738 1686.51 C 979.643 1758.72 1155.35 1723.52 1311.77 1634.91 C 1683.71 1424.22 1814.95 995.958 1657.65 609.48 C 1628.44 537.714 1608.99 455.036 1518.77 459.076 C 1470.96 458.583 1411.28 469.23 1364.38 478.775 C 987.814 554.845 568.571 857.845 563.56 1275.76 C 561.466 1450.38 646.303 1612.43 808.738 1686.51 z M 519.112 1210.8 C 522.564 1130.73 555.869 1031.88 594.69 962.196 C 703.455 761.458 852.304 636.013 1051.95 530.327 C 935.6 416.368 695.525 316.867 538.275 318.468 L 535.514 318.51 C 448.008 322.098 397.228 334.307 369.794 418.571 C 308.175 607.84 308.507 825.828 393.368 1008.1 C 422.163 1069.95 470.683 1160.88 519.112 1210.8 z" />
<path d="M8 9c-1.3.5-2 1.7-2 3 0 4 2.7 8 6 8s6-4 6-8c0-1.3-.7-2.5-2-3z" /> <path d="M 1598.68 637.027 C 1634.96 636.008 1631.43 677.59 1633.38 703.782 C 1660.05 1061.69 1409.99 1438.78 1084.43 1578.07 C 1064.07 1586.81 1041.58 1592.74 1021.7 1602.11 C 990.429 1606.96 975.681 1570.64 1001.28 1554.63 C 1009.08 1549.75 1029.21 1545.21 1039.32 1541.41 C 1365.76 1418.7 1617.24 1037.19 1580.86 690.388 C 1578.23 665.352 1572.45 648.646 1598.68 637.027 z" />
</Icon> <path d="M 1389.55 513.084 C 1420.88 510.153 1436.47 548.477 1389.63 565.042 C 1089.32 671.257 809.481 970.365 747.434 1282.07 C 742.975 1304.47 747.472 1327.49 723.119 1337.73 C 681.185 1342.02 692.713 1296.79 697.737 1271.86 C 761.679 954.621 1070.79 601.146 1389.55 513.084 z" />
<path d="M 1494.44 587.89 C 1524.5 579.666 1540.77 618.733 1515.32 632.093 C 1372.83 706.936 1263.57 829.757 1171.91 959.7 C 1139.08 1006.24 1014.2 1175.99 970.965 1198.24 C 947.407 1203.31 937.48 1187.92 938.726 1166.33 C 943.263 1158.9 948.465 1153.71 954.576 1147.64 C 1044.16 1059.82 1109.56 953.698 1185.75 855.011 C 1236.48 789.295 1408.64 609.858 1494.44 587.89 z" />
<path d="M 1401 947.194 C 1418.44 945.106 1439.07 954.077 1432.88 975.625 C 1421.27 1015.98 1145.22 1271.37 1107.5 1304.52 C 1042.37 1361.77 937.778 1453.82 860.358 1494.78 C 831.809 1503.03 814.986 1466.14 838.867 1449.69 C 1033.05 1315.88 1206.91 1153.58 1366.06 980.106 C 1377.42 967.723 1385.63 955.132 1401 947.194 z" />
<path d="M 527.238 530.327 C 549.916 527.673 555.705 542.414 564.717 559.516 C 588.26 604.191 625.411 639.87 670.146 662.994 C 694.395 675.528 698.057 700.841 670.807 714.082 C 626.871 720.144 456.047 564.148 527.238 530.327 z" />
<path d="M 660.624 459.076 C 692.344 451.902 788.132 507.422 813.942 524.957 C 838.38 541.56 870.027 569.964 826.773 587.89 C 807.313 588.78 792.835 572.687 776.913 563.127 C 746.27 544.726 715.103 527.143 681.523 514.651 C 670.352 510.495 655.348 506.303 645.654 499.785 C 635.539 480.478 638.726 465.073 660.624 459.076 z" />
<path d="M 482.823 714.082 C 516.153 713.195 527.206 774.263 543.132 805.241 C 552.817 824.079 600.887 864.414 554.102 881.663 C 513.988 888.85 477.292 790.388 467.37 760.79 C 460.711 740.926 458.572 720.487 482.823 714.082 z" />
</FilledIcon>
); );
} }
@ -161,34 +195,46 @@ export function DoughIcon() {
); );
} }
/** A glass of milk — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (non-cheese items). */ /** Milk carton (foodiconpack.com, CC BY 4.0) — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (non-cheese items). */
export function MilkIcon() { export function MilkIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M9 2h6l1 4-1 2v11a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2V8L8 6z" /> <path d="M 779.86 268.928 C 786.444 267.776 808.108 268.835 816.425 268.869 L 1239.47 268.892 C 1251.42 268.887 1268.83 268.222 1280.22 270.224 C 1339.49 280.641 1344.2 346.945 1336.34 394.552 C 1333.06 414.436 1314.38 431.172 1297.92 441.343 C 1343.66 501.777 1394.45 593.378 1433.99 659.263 C 1437.18 664.566 1436.96 682.486 1436.96 688.894 L 1436.9 1658.43 C 1436.9 1715.13 1419.86 1772.69 1350.61 1776.96 C 1324.54 1778.56 1292.75 1777 1265.75 1776.99 L 753.887 1777.04 C 732.737 1777.04 709.252 1778.28 688.335 1775.73 C 599.133 1764.87 608.939 1673.48 609.023 1609.46 L 608.924 696.324 C 608.915 673.385 605.675 665.681 618.805 645.763 L 726.465 482.971 C 733.856 471.575 743.878 455.001 752.327 444.99 C 733.226 431.892 711.239 414.788 710.597 389.124 C 709.27 336.038 711.774 275.09 779.86 268.928 z M 655.997 1095.86 C 657.228 1141.69 655.977 1193.52 655.975 1239.96 L 656.084 1623.69 C 655.355 1718.28 650.722 1730.19 740.845 1730.1 L 816.5 1730 L 1290.37 1730.04 C 1386.14 1730.08 1389.84 1729.99 1389.77 1623.23 L 1389.71 1148.13 C 1326.18 1144.56 1312.21 1136.26 1263.63 1103.33 C 1228.78 1079.71 1200.2 1069.82 1158.5 1074.03 C 1070.35 1066.06 1015.65 1198.45 855.662 1123.37 C 838.033 1115.1 822.796 1101.08 805.94 1091.4 C 757.478 1065.7 701.947 1067.96 655.767 1096.35 L 655.997 1095.86 z M 655.997 690.535 C 655.415 763.136 655.026 841.111 655.997 913.692 C 746.649 887.151 780.349 903.836 853.481 945.995 C 930.868 991.394 983.241 907.17 1087.85 963.051 C 1113.47 976.738 1106.78 1005.09 1077.6 1006.99 C 1066.09 1006.09 1056.19 1001.04 1045.4 997.401 C 1009.64 985.352 979.087 995.291 943.904 1003.48 C 839.891 1027.7 809.781 942.719 713.57 950.335 C 693.938 951.889 673.206 956.794 655.997 966.626 C 656.723 992.751 655.768 1015.71 655.997 1041.3 C 694.992 1023.56 752.692 1022.39 793.757 1034.87 C 869.483 1061.19 899.87 1121.11 996.655 1089.42 C 1022.81 1081.16 1043.67 1061.28 1067.84 1048.91 C 1135 1012.98 1229.18 1018.3 1290.63 1064.07 C 1328.35 1092.16 1342.12 1096.6 1389.71 1101.24 L 1389.71 690.535 L 831.485 690.535 L 655.997 690.535 z M 811.491 314.999 C 747.852 317.269 756.708 337.816 757.996 390.049 L 767.34 398.009 C 780.479 399.085 802.178 398.047 816.382 398.026 L 1238.5 398.009 C 1261.58 398.307 1274.96 401.836 1292.06 385.935 C 1293.53 346.238 1300.37 314.504 1250.44 314.994 L 811.491 314.999 z M 1247.5 642.987 L 1370.52 642.987 C 1343.23 602.272 1316.23 557.822 1290.1 516.091 L 1268.91 482.018 C 1260.74 469.147 1253.32 459.132 1247.5 444.99 L 805.895 444.99 C 768.179 506.706 711.428 581.218 679.918 642.987 C 742.577 643.629 806.436 642.983 869.179 642.982 L 1247.5 642.987 z" />
<path d="M8 12h8" /> <path d="M 1292.06 1165.05 C 1304.47 1164.51 1314.69 1166.59 1319.57 1179.63 C 1322.97 1188.7 1321.03 1350.47 1321.04 1369.31 L 1321.05 1461.22 C 1321.05 1481.43 1329.33 1524.23 1304.49 1531.79 C 1260.85 1538.42 1274.43 1475.3 1274.42 1445 L 1274.43 1237.1 C 1274.44 1210.88 1265.14 1174.39 1292.06 1165.05 z" />
</Icon> <path d="M 1292.06 1574.06 C 1335.71 1570.9 1327.92 1660.46 1304.49 1669.63 C 1268.65 1672.77 1273.3 1645.42 1274.39 1620.96 C 1275.27 1601.16 1267.84 1583.13 1292.06 1574.06 z" />
</FilledIcon>
); );
} }
/** A wedge of cheese with holes — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (cheese items). */ /** Wedge of cheddar (foodiconpack.com, CC BY 4.0) — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (cheese items). */
export function CheeseIcon() { export function CheeseIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M3 18 20 8l1 3-3 9H4z" /> <path d="M 499.059 422.998 C 555.378 418.116 619.709 441.465 674.623 455.762 L 996.114 542.998 C 1031.04 552.641 1041.28 552.872 1077.38 553.146 L 1616.68 553.023 C 1682.85 553.02 1770.16 548.867 1778.65 640.355 C 1780.01 655.048 1779.18 672.498 1779.17 687.571 L 1779.16 1476.37 C 1779.25 1597.67 1748.78 1621.04 1630.31 1620.91 L 1465.34 1620.9 C 1443.94 1620.93 1414.19 1625.68 1399.87 1607.61 C 1398.05 1604.38 1394.76 1596.66 1393.72 1593.03 C 1377.2 1535.41 1297.2 1530.02 1271.36 1582.6 C 1248.88 1628.34 1248.64 1620.97 1198.19 1620.66 L 427.301 1620.9 C 358.787 1620.91 283.221 1625.78 266.731 1535.13 C 264.224 1521.36 265.662 1500.67 265.734 1486.47 L 265.757 1263.78 C 265.759 1237.69 263.842 1189.05 268.742 1163.04 C 271.121 1150.41 300.728 1146.42 313.047 1140.68 C 369.139 1114.52 366.57 1036.24 306.189 1016.72 C 262.308 1002.37 265.669 999.106 265.716 951.744 L 265.77 737.682 C 265.716 702.461 264.337 660.984 270.354 626.626 C 291.571 505.489 383.535 437.517 499.059 422.998 z M 324.231 960.286 C 424.782 991.538 442.508 1139.67 345.468 1188.98 C 338.454 1192.54 331.35 1195.88 324.231 1199.2 L 324.133 1487.36 C 324.051 1514.94 321.811 1559.19 360.901 1561.89 C 390.466 1563.93 421.893 1562.96 451.923 1562.94 L 1215.09 1562.94 C 1251.18 1483.5 1357.74 1460.52 1418.55 1523.85 C 1430.27 1536.56 1437.26 1547.46 1445.09 1562.94 C 1494.56 1564.28 1550.54 1562.91 1600.43 1562.92 L 1652.1 1563.01 C 1695.43 1563.09 1720.53 1558.29 1720.97 1507.06 L 1721.02 796.34 L 700.446 523.084 C 646.538 508.564 558.008 475.4 503.232 480.643 C 403.945 488.725 329.744 562.227 324.905 662.706 C 323.854 684.536 324.21 706.469 324.206 728.326 L 324.253 884.955 C 324.264 905.156 325.564 942.061 324.231 960.286 z M 1721.02 735.631 L 1721.29 682.195 C 1722.41 598.797 1694.33 611.308 1624.05 611.291 L 1259.29 610.969 C 1258.13 610.958 1256.06 611.184 1254.84 611.306 C 1292.78 627.458 1364.28 641.475 1405.34 652.014 L 1665.84 720.616 C 1672.26 722.287 1717.93 735.862 1721.02 735.631 z" />
<circle cx="10" cy="15" r="1" fill="currentColor" stroke="none" /> <path d="M 734.395 1158.49 C 787.928 1151.1 837.236 1188.72 844.242 1242.31 C 851.249 1295.89 813.273 1344.93 759.641 1351.55 C 706.549 1358.11 658.114 1320.6 651.178 1267.55 C 644.241 1214.51 681.403 1165.81 734.395 1158.49 z M 752.487 1294.89 C 774.162 1292.22 789.602 1272.53 787.02 1250.85 C 784.437 1229.16 764.805 1213.65 743.111 1216.15 C 721.302 1218.67 705.69 1238.43 708.286 1260.23 C 710.882 1282.03 730.698 1297.56 752.487 1294.89 z" />
<circle cx="15" cy="13" r="1" fill="currentColor" stroke="none" /> <path d="M 1483.12 874.242 C 1536.37 868.906 1583.87 907.74 1589.22 960.991 C 1594.58 1014.24 1555.76 1061.76 1502.51 1067.13 C 1449.24 1072.51 1401.69 1033.66 1396.34 980.387 C 1390.98 927.109 1429.84 879.58 1483.12 874.242 z M 1500.64 1007.39 C 1521.24 1002.81 1534.16 982.322 1529.42 961.758 C 1524.69 941.194 1504.1 928.432 1483.57 933.333 C 1463.27 938.18 1450.68 958.491 1455.37 978.826 C 1460.06 999.162 1480.27 1011.91 1500.64 1007.39 z" />
<circle cx="14" cy="17" r="1" fill="currentColor" stroke="none" /> <path d="M 990.465 825.063 C 1043.7 818.146 1092.42 855.837 1099.09 909.108 C 1105.76 962.379 1067.84 1010.92 1014.54 1017.34 C 961.589 1023.72 913.435 986.109 906.808 933.185 C 900.181 880.262 937.573 831.934 990.465 825.063 z M 1008.88 959.34 C 1029.88 955.91 1044.22 936.238 1041.06 915.196 C 1037.91 894.153 1018.42 879.555 997.342 882.436 C 983.457 884.334 971.668 893.555 966.482 906.574 C 961.297 919.593 963.517 934.394 972.294 945.319 C 981.071 956.243 995.047 961.6 1008.88 959.34 z" />
</Icon> <path d="M 1531.75 1315.16 C 1567.83 1310.92 1581.26 1372.96 1537.23 1379.82 C 1496.94 1381.97 1490.57 1325.05 1531.75 1315.16 z" />
<path d="M 1113.52 1341.48 C 1124.59 1339.75 1135.73 1344.09 1142.71 1352.86 C 1149.68 1361.63 1151.41 1373.46 1147.23 1383.86 C 1143.05 1394.25 1133.62 1401.6 1122.51 1403.11 C 1105.59 1405.4 1089.98 1393.64 1087.52 1376.74 C 1085.05 1359.85 1096.65 1344.12 1113.52 1341.48 z" />
<path d="M 1308.82 1150.12 C 1325.59 1147.1 1341.64 1158.21 1344.71 1174.97 C 1347.78 1191.73 1336.72 1207.82 1319.97 1210.95 C 1303.14 1214.1 1286.96 1202.97 1283.87 1186.13 C 1280.79 1169.29 1291.97 1153.15 1308.82 1150.12 z" />
<path d="M 786.604 693.812 C 803.652 692.684 818.375 705.618 819.451 722.67 C 820.528 739.722 807.549 754.405 790.494 755.43 C 773.512 756.451 758.905 743.54 757.833 726.56 C 756.761 709.581 769.628 694.935 786.604 693.812 z" />
<path d="M 501.378 1363.47 C 517.816 1361.46 532.869 1372.89 535.346 1389.26 C 537.823 1405.64 526.824 1421.01 510.528 1423.95 C 499.565 1425.93 488.393 1421.8 481.36 1413.16 C 474.328 1404.52 472.546 1392.74 476.709 1382.41 C 480.871 1372.07 490.321 1364.82 501.378 1363.47 z" />
<path d="M 502.61 674.93 C 513.474 673.325 524.359 677.705 531.083 686.388 C 537.807 695.071 539.323 706.706 535.05 716.823 C 530.777 726.939 521.379 733.964 510.467 735.198 C 493.988 737.061 479.058 725.37 476.914 708.926 C 474.77 692.481 486.204 677.353 502.61 674.93 z" />
<path d="M 640.602 933.6 C 650.874 930.436 662.055 933.036 669.876 940.408 C 677.698 947.781 680.954 958.788 678.402 969.229 C 675.85 979.67 667.884 987.935 657.543 990.868 C 641.802 995.333 625.399 986.3 620.758 970.61 C 616.117 954.92 624.965 938.417 640.602 933.6 z" />
</FilledIcon>
); );
} }
/** An egg — `CREMERIE_FROMAGE`/`OEUFS`. */ /** Eggs (foodiconpack.com, CC BY 4.0) — `CREMERIE_FROMAGE`/`OEUFS`. */
export function EggIcon() { export function EggIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M12 3C8 8 6 12.5 6 15.5a6 6 0 0 0 12 0C18 12.5 16 8 12 3z" /> <path d="M 672.028 452.989 C 802.231 431.822 916.044 572.595 976.073 668.56 C 982.025 662.6 990.897 647.221 996.637 639.262 C 1056.03 556.903 1134.48 467.569 1240.98 454.261 C 1440.34 428.671 1578.12 667.305 1635.4 824.202 C 1650.71 866.151 1669.24 919.792 1674.32 963.863 C 1796.44 1050.34 1809.04 1195.17 1745.42 1324.69 C 1688.22 1441.14 1595.52 1527.32 1473.73 1571.05 C 1298.56 1633.57 1065.66 1573.42 958.295 1417.43 C 945.299 1429.42 925.899 1441.05 910.854 1450.18 C 632.974 1618.83 279.351 1421.44 265.261 1103.37 C 257.624 930.964 357.165 692.936 470.568 565.306 C 519.686 510.026 595.86 455.977 672.028 452.989 z M 662.243 1464 C 757.01 1464.06 858.31 1447.38 929.301 1378.54 C 814.231 1173.44 836.302 990.945 923.922 774.62 C 931.423 756.102 942.19 737.296 948.796 718.683 C 900.647 639.142 870.484 578.847 783.776 525.855 C 762.865 513.075 703.956 491.421 679.695 500.115 C 592.129 496.732 497.313 595.998 452.999 664.544 C 376.729 782.52 321.573 926.983 311.732 1067.93 C 300.626 1267.53 463.183 1450.54 662.243 1464 z M 1557.87 1477.91 C 1576.89 1460.83 1595.24 1447.76 1613.92 1429.68 C 1709.13 1337.16 1790.75 1153.1 1687.79 1036.91 C 1631.4 973.276 1537.02 953.186 1461.36 993.787 C 1448.89 1000.48 1438.13 1009.84 1426.48 1017.79 C 1369.1 1056.81 1294.02 1057.84 1233.37 1025.8 C 1180.17 997.693 1161.91 968.725 1087.78 972.993 C 807.16 1011.04 898.602 1337.03 1073.46 1465.96 C 1200.69 1561.72 1376.3 1576.66 1517.03 1502.16 C 1526.94 1496.91 1548.72 1480.65 1557.87 1477.91 z M 906.453 1017.56 C 979.391 933.074 1088.63 900.45 1194.17 947.19 C 1205.36 952.146 1219.05 961.719 1229.41 968.383 C 1288.55 1006.6 1355.65 1018.41 1413.05 970.324 C 1468.19 924.129 1552.4 915.263 1619.91 938.773 C 1597.48 779.347 1431.48 482.377 1253.19 500.115 C 1048.63 534.486 925.124 838.141 906.453 1017.56 z" />
</Icon> <path d="M 1641.18 1164.04 C 1655.96 1163.87 1667.24 1172.92 1666.31 1187.92 C 1655.64 1360.64 1490.45 1471.56 1326.72 1477.91 C 1313.35 1477.35 1308.32 1475.65 1301.24 1464 C 1298.93 1437.3 1309.46 1432.84 1334.84 1431.93 C 1467.26 1427.15 1593.28 1334.34 1618.18 1200.99 C 1619.88 1191.88 1620.69 1182.45 1623.87 1173.73 C 1630.25 1167.66 1632.69 1166.33 1641.18 1164.04 z" />
<path d="M 1571.63 1070.83 C 1584.72 1067.48 1598.05 1075.41 1601.34 1088.51 C 1604.64 1101.62 1596.65 1114.91 1583.52 1118.14 C 1570.49 1121.36 1557.3 1113.43 1554.03 1100.41 C 1550.75 1087.39 1558.62 1074.17 1571.63 1070.83 z" />
<path d="M 1141.5 1043.64 C 1154.47 1043.06 1165.52 1052.98 1166.35 1065.94 C 1167.17 1078.91 1157.46 1090.14 1144.52 1091.21 C 1136.66 1091.85 1128.99 1088.57 1124.04 1082.45 C 1119.08 1076.32 1117.47 1068.14 1119.74 1060.59 C 1122.65 1050.89 1131.38 1044.1 1141.5 1043.64 z" />
<path d="M 1036.59 1114.73 C 1049.08 1110.47 1062.65 1117.2 1066.8 1129.73 C 1070.95 1142.26 1064.08 1155.77 1051.52 1159.8 C 1039.12 1163.77 1025.83 1157.02 1021.73 1144.66 C 1017.64 1132.3 1024.27 1118.95 1036.59 1114.73 z" />
</FilledIcon>
); );
} }
@ -203,67 +249,69 @@ export function SproutIcon() {
); );
} }
/** A shaker — dried spices/herbs (`CONDIMENTS_EPICES`/`EPICES`). */ /** Cinnamon sticks (foodiconpack.com, CC BY 4.0) — dried spices/herbs (`CONDIMENTS_EPICES`/`EPICES`). */
export function SpiceIcon() { export function SpiceIcon() {
return ( return (
<Icon> <FilledIcon>
<rect x="8" y="8" width="8" height="13" rx="3" /> <path d="M 1299.23 499.315 C 1359.06 491.799 1390.3 536.11 1425.43 572.134 C 1496.5 553.162 1542.93 603.387 1572.87 661.811 C 1661.4 617.869 1693.59 665.685 1739.02 734.286 C 1766.76 776.167 1793.52 800.671 1780.45 855.484 C 1769.46 901.584 1737.1 918.753 1700.9 943.085 L 956.229 1453.57 C 881.032 1505.41 824.394 1564.64 727.781 1541.31 C 622.715 1509.59 598.438 1427.43 620.532 1331.46 C 593.107 1328.96 556.54 1312.76 537.915 1293.61 C 371.504 1425.39 178.623 1225.58 306.96 1081.41 C 326.186 1059.81 363.923 1042.27 388.969 1028.76 L 1109.88 640.263 C 1131.52 628.662 1150.34 620.99 1170.36 606.2 C 1223.61 566.87 1210.47 510.761 1299.23 499.315 z M 698.223 1153.03 C 685.564 1124.01 644.122 1103.62 652.014 1083.32 C 656.117 1072.77 665.7 1067.69 676.173 1065.41 L 677.523 1065.87 C 730.347 1084.36 771.371 1175.97 760.119 1229.24 L 760.916 1230.44 C 774.747 1226.74 829.275 1193.14 844.192 1184.62 L 1535.71 800.412 C 1597.34 766.704 1560.33 730.548 1538.73 696.859 C 1516.84 662.716 1510.46 619.145 1455.71 615.368 C 1443.29 616.688 1414.94 634.577 1402.39 641.277 L 879.346 923.682 C 841.372 943.965 556.595 1088.5 539.86 1109.98 C 489.374 1177.33 562.598 1300.61 654.807 1286.88 C 719.275 1277.29 729.816 1200 698.223 1153.03 z M 1598.52 704.214 C 1629.22 745.717 1610.85 806.574 1570.27 832.144 C 1528.59 858.413 1480.95 881.992 1438 906.347 L 748.377 1294.08 C 726.513 1306.49 700.23 1324.6 676.173 1331.46 C 642.753 1368.48 656.169 1436.47 691.565 1468.81 C 756.913 1528.51 865.349 1504.56 850.662 1403.24 C 846.258 1385.45 842.637 1374.38 832.194 1358.52 C 820.284 1340.43 782.796 1327.27 797.906 1301.05 C 804.787 1289.11 820.472 1287.3 830.907 1294.95 C 880.916 1329.97 899.477 1376.66 898.999 1435.05 C 919.996 1424.24 936.911 1410.37 956.218 1397.18 L 1666.61 909.295 C 1783.99 829.202 1730.19 819.511 1676.19 724.443 C 1665.66 705.918 1654.98 693.562 1631.02 693.416 C 1617.19 695.209 1610.81 697.532 1598.52 704.214 z M 424.954 1289.68 C 456.981 1288.58 481.153 1279.67 505.982 1258.74 C 482.024 1207.75 464.9 1174.25 485.217 1114.52 C 501.004 1068.11 539.721 1052.14 579.79 1030.9 L 1208.36 692.006 C 1262.31 662.286 1332.68 630.795 1383.21 598.302 C 1360.55 570.383 1339.51 542.641 1299.23 546.102 C 1275.97 551.717 1257.21 564.624 1246.66 586.131 C 1218.85 642.791 1177.24 657.382 1124.55 685.304 L 459.432 1043.62 C 436.067 1056.26 356.059 1096.31 341.245 1113.44 C 276.781 1187.98 331.053 1287.67 424.954 1289.68 z" />
<path d="M10 8V5a2 2 0 0 1 4 0v3" /> <path d="M 1360.08 777.002 C 1380.21 776.904 1390.76 790.877 1384.27 811.255 C 1381.62 819.58 1275.59 878.124 1250.15 886.907 C 1226.82 891.473 1218 863.959 1226.13 851.7 C 1233.68 840.3 1344.34 781.861 1360.08 777.002 z" />
<path d="M11 12h.01M13 12h.01M10.5 15h.01M13.5 15h.01M12 18h.01" /> <path d="M 589.734 1153.03 C 603.824 1152.09 645.581 1173.62 661.273 1178.77 C 683.217 1185.98 681.408 1216.89 657.535 1224.03 C 632.942 1225.26 519.247 1177.53 589.734 1153.03 z" />
</Icon> <path d="M 723.057 1360.97 C 731.948 1360.68 812.223 1392.94 814.451 1405.67 C 817.702 1424.25 811.966 1434.23 794.522 1438.58 C 774.923 1438.59 648.922 1386.08 723.057 1360.97 z" />
<path d="M 379.771 1163.13 C 388.374 1162.08 439.518 1180.94 450.159 1186.2 C 472.912 1198.35 467.313 1225.91 442.076 1230.67 C 427.597 1229.29 389.88 1214.43 374.813 1208.91 C 351.499 1200.37 351.948 1171.93 379.771 1163.13 z" />
</FilledIcon>
); );
} }
/** A condiment jar — sauces, pickles, tinned/preserved goods (`CONDIMENTS_EPICES`/`SAUCES` and the `EPICERIE_SECHE`/`AUTRES` catch-all). */ /** Honey jar (foodiconpack.com, CC BY 4.0) — sauces, pickles, tinned/preserved goods (`CONDIMENTS_EPICES`/`SAUCES` and the `EPICERIE_SECHE`/`AUTRES` catch-all). */
export function JarIcon() { export function JarIcon() {
return ( return (
<Icon> <FilledIcon>
<rect x="6" y="9" width="12" height="12" rx="2" /> <path d="M 441.421 842.864 C 447.786 842.313 464.211 842.793 471.395 842.807 L 1019.7 842.764 C 1032.89 842.741 1048.9 841.881 1061.84 843.353 C 1130.81 851.198 1163.98 918.556 1130.28 977.676 C 1193.73 1004.99 1195.17 1074.2 1153.32 1125.1 C 1169.13 1142.38 1192.59 1189.25 1202.41 1211.85 C 1250.22 1321.88 1252.68 1457.38 1205.46 1568.25 C 1179.01 1630.35 1126.32 1716.02 1060.36 1743.03 C 1018.13 1757.99 939.663 1751.84 893.846 1751.84 L 558.891 1751.86 C 485.108 1751.86 456.534 1757.33 399.168 1702.94 C 252.041 1563.46 226.559 1333.79 330.44 1161.63 C 336.605 1151.42 346.62 1133.29 354.405 1125.1 C 309.176 1074.05 316.393 1016.01 373.247 977.676 C 341.618 912.507 363.601 854.3 441.421 842.864 z M 675.49 1147.95 C 674.654 1202.31 686.841 1260.99 645.515 1301.98 C 605.095 1342.08 544.747 1339.55 505.417 1299.64 C 465.481 1259.11 483.176 1198.65 479.076 1147.95 L 402.038 1147.95 C 273.352 1299.41 297.324 1545.93 446.301 1676.73 C 482.42 1708.44 508.476 1701.08 553.305 1701.08 L 904.022 1701.09 C 937.651 1701.09 987.565 1702.82 1019.49 1700.23 C 1092.21 1694.33 1158.11 1565.46 1173.88 1504.48 C 1208.54 1389.77 1185.08 1240.15 1104.25 1147.95 L 1006.64 1147.91 C 955.106 1147.75 964.099 1136.4 950.944 1168.76 C 917.416 1251.22 805.109 1248.39 785.535 1147.95 C 750.82 1147.92 709.692 1146.79 675.49 1147.95 z M 830.367 1101.07 L 837.774 1107.07 C 837.983 1134.23 832.163 1176.53 873.289 1174.99 L 874.247 1174.95 C 927.062 1157.23 886.505 1109.61 920.058 1097.59 C 945.687 1088.41 1065.03 1106.22 1103.86 1094.32 C 1140.07 1083.22 1145.85 1020.86 1080.75 1017.52 C 1055.39 1016.22 1029.65 1017.07 1004.25 1017.07 L 448.493 1017.07 C 427.043 1018.23 402.017 1013.88 386.352 1031.96 C 371.738 1048.83 372.852 1073.39 390.862 1087.43 C 419.378 1109.66 500.067 1085.09 523.258 1103.42 C 535.236 1112.88 531.224 1139.24 531.155 1152.37 L 530.839 1214.29 C 530.615 1307.53 614.177 1284.58 621.411 1240.4 C 623.374 1228.41 622.701 1213.42 622.655 1201.04 L 622.434 1142.74 C 622.287 1095.93 627.961 1096.54 672.476 1096.79 L 719.123 1097 C 742.002 1097.03 812.594 1092.57 830.367 1101.07 z M 1064.76 965.18 C 1108.65 949.333 1095.92 898.437 1057.26 895.318 C 1035.28 893.545 1006.38 894.91 983.609 894.932 L 441.421 894.985 C 396.1 901.206 401.684 964.819 454.169 964.945 L 990.398 965.186 C 1007.59 965.192 1049.73 966.614 1064.76 965.18 z" />
<path d="M9 9V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" /> <path d="M 1289.39 292.367 C 1326.12 288.265 1352.75 308.707 1374.41 335.781 C 1418.32 306.712 1471.33 336.401 1495.2 378.493 C 1544.75 348.864 1592.39 377.915 1621.27 421.001 C 1671.12 420.78 1704.66 417.497 1743.36 455.769 C 1792.33 504.191 1793.6 591.975 1743.75 640.152 C 1705.04 677.57 1670.58 674.943 1621.27 674.832 C 1596.59 737.221 1552.79 745.834 1495.2 729.12 C 1472.47 770.532 1441.21 787.563 1394.57 778.379 C 1360.92 771.752 1380.82 770.173 1357.74 794.106 C 1310.02 844.096 1227.55 821.602 1211.46 754.37 C 1206.62 734.136 1208.65 704.247 1208.65 682.677 L 1208.7 581.596 L 1035.75 581.596 L 377.727 581.595 C 350.11 581.589 322.276 581.811 294.694 581.208 C 260.937 580.471 261.039 531.119 291.666 530.646 C 324.929 529.931 358.287 530.245 391.571 530.245 L 1208.7 530.263 C 1209.51 520.792 1208.8 501.437 1208.77 491.101 L 1208.73 381.027 C 1208.97 333.971 1241.73 296.636 1289.39 292.367 z M 1298.86 770.005 C 1313 767.514 1329.13 756.138 1329.26 740.59 L 1329.03 442.72 C 1329.03 411.9 1342.02 337.471 1292.72 343.188 C 1246.23 352.56 1259.98 412.832 1259.97 452.092 L 1259.96 688.959 C 1259.96 719.956 1252.41 770.674 1298.86 770.005 z M 1379.63 708.828 C 1390.61 719.865 1399.84 729.244 1416.52 729.12 C 1456.74 728.388 1450.53 683.254 1450.87 655.181 L 1451.07 469.27 C 1451.05 439.366 1460.98 378.539 1416.52 378.493 C 1372.3 382.068 1379.61 426.387 1379.61 459.837 L 1379.6 654.043 C 1379.58 669.976 1378.68 693.565 1379.63 708.828 z M 1541.6 687.466 C 1578 681.587 1574.03 642.844 1574.04 615.382 L 1574.03 489.826 C 1574.01 470.817 1572.28 404.792 1534.72 421.001 C 1506.93 420.198 1502.06 439.052 1501.89 462.447 L 1501.79 631.805 C 1501.87 659.021 1504.66 689.717 1541.6 687.466 z M 1624.49 624.418 C 1656.15 624.69 1683.84 627.86 1707.91 603.745 C 1736.48 570.656 1737.04 517.576 1703.82 486.797 C 1682.99 467.498 1651.28 471.716 1624.49 471.965 L 1624.45 572.781 C 1624.45 588.207 1623.7 609.679 1624.49 624.418 z" />
<path d="M6 13h12" /> </FilledIcon>
</Icon>
); );
} }
/** A bottle — oils, vinegars (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`, the pourable subset). */ /** Oil bottle (foodiconpack.com, CC BY 4.0) — oils, vinegars (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`, the pourable subset). */
export function BottleIcon() { export function BottleIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M10 2h4v3.5l1.5 2V20a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2V7.5L10 5.5z" /> <path d="M 1113 267.774 C 1118.93 267.838 1127.18 268.144 1132.79 267.575 C 1212.81 259.459 1291.26 334.177 1283.92 411.9 C 1320.46 426.872 1328.11 431.256 1348.99 466.537 C 1460.44 475.628 1555.19 437.956 1647.13 535.703 C 1705.58 597.853 1723.55 687.084 1698.19 767.766 C 1677.99 832.062 1578.77 923.958 1525.17 973.224 C 1578.32 1063.57 1591.03 1155.94 1591.05 1257.58 L 1591.36 1579.29 C 1591.7 1675.52 1530.2 1758.25 1434.53 1774.01 C 1404.53 1778.95 1376.38 1777.9 1346.23 1777.89 L 868.5 1777.85 C 817.993 1777.93 770.594 1783.03 726.147 1753.63 C 714.384 1745.84 702.133 1731.64 691.185 1725.99 C 670.808 1725.81 650.472 1726.52 630.147 1724.67 C 462.136 1709.37 366.732 1559.53 343.149 1404.74 C 325.158 1286.65 348.66 1166.97 389.895 1055.99 C 399.753 1029.46 408.922 999.898 429.075 979.14 C 503.845 902.125 585.964 984.301 642.107 1039.44 C 671.069 971.711 701.229 920.107 761.04 872.291 C 835.931 812.42 889.919 772.757 892.018 668.347 C 848.185 630.588 849.467 598.554 850.18 545.65 C 851.157 473.261 847.573 441.474 922.991 411.9 C 916.203 374.993 935.26 330.694 960.986 304.454 C 1004.3 260.277 1056.4 267.649 1113 267.774 z M 943.863 676.997 C 935.593 780.389 891.969 841.391 811.421 898.559 C 738.881 950.044 717.919 999.669 683.124 1075.69 C 718.508 1107.43 763.536 1151.02 793.91 1187.54 C 965.064 1029.92 1243.58 1024.81 1407.43 1196.22 C 1496.92 1289.84 1465.7 1413.13 1376.97 1494.79 C 1265.66 1597.23 1103.91 1616.32 962.658 1573.91 C 943.497 1567.53 925.733 1559.93 907.115 1552.11 C 874.749 1630.34 827.889 1676.83 750.715 1710.27 C 773.015 1721.48 788.554 1726.23 813.901 1726.13 L 1278.76 1725.99 C 1319.97 1726 1361.16 1726.13 1402.31 1725.57 C 1470.93 1724.62 1532.2 1669.6 1537.89 1600.7 C 1539.51 1581.09 1539.03 1561.64 1539.04 1541.91 L 1539.02 1298 C 1539.02 1153 1528.08 1021.46 1412.67 917.064 C 1373.18 881.342 1338.53 868.866 1305.8 822.534 C 1269.26 770.804 1262.58 738.027 1257.7 676.997 C 1240.38 680.459 1195.72 676.996 1176.1 676.986 L 1065.74 677.04 C 1051.68 677.053 1024.61 677.931 1011.73 676.997 C 993.467 677.85 961.651 678.239 943.863 676.997 z M 1495.04 929.233 C 1498.73 925.689 1502.55 921.909 1506.35 918.51 C 1522.16 905.383 1542.38 881.856 1557.55 867.044 C 1607.92 817.833 1649.8 786.169 1656.89 710.328 C 1661.24 663.759 1647.67 616.675 1618.03 580.681 C 1568.2 520.175 1514.1 518.813 1445.06 518.785 L 1351.01 518.841 L 1351.01 586.996 L 1446.99 586.952 C 1489.39 586.937 1526.58 582.18 1559.47 614.942 C 1606.7 661.976 1598.63 732.85 1552.52 778.096 L 1448.79 878.929 C 1461.81 889.697 1485.88 914.78 1495.04 929.233 z M 936.782 463.972 C 887.714 471.621 902.402 557.987 904.235 597.116 C 906.068 633.523 962.311 625.555 985.507 625.53 L 1203.16 625.533 C 1223.95 625.552 1246.82 626.359 1267.25 625.51 C 1292.35 624.448 1300.31 610.245 1300.03 586.659 C 1298.91 492.084 1326.13 463.961 1221.92 463.944 L 937.416 463.9 C 937.204 463.924 936.993 463.948 936.782 463.972 z M 1406.94 846.536 C 1423.38 832.363 1438.29 815.198 1454.25 800.985 C 1480.96 774.681 1538.13 728.203 1539.12 694.413 C 1540.91 632.943 1480.07 640.114 1440.08 640.261 L 1348.99 640.346 L 1337.81 640.346 C 1329.89 654.324 1323.13 658.656 1310.42 668.347 C 1308.51 755.891 1341.97 798.976 1406.94 846.536 z M 1154.01 411.9 C 1179.4 411.398 1205.04 411.981 1230.48 411.9 C 1234.21 366.538 1199.22 322.81 1152.61 321.161 C 1124.22 320.156 1079 318.458 1051.13 320.788 C 996.889 323.351 974.266 363.057 975.002 411.9 C 992.225 412.919 1016.43 411.918 1034.46 411.904 L 1154.01 411.9 z M 1077.65 1119.02 C 1006.5 1126.09 919.413 1149.46 863.243 1196.96 C 938.217 1212.28 999.725 1254.37 1070.65 1280.46 C 1108.58 1294.42 1137.83 1297.54 1176.69 1302 C 1259.65 1302.52 1312.94 1289.81 1387.05 1253.24 C 1329.02 1166.36 1182.39 1112.07 1077.65 1119.02 z M 840.44 1243.81 C 845.432 1252.5 853.131 1260.87 859.13 1268.88 C 897.565 1320.2 923.184 1382.53 922.872 1447.31 C 922.791 1464.28 921.612 1482.96 922.991 1499.54 C 934.619 1509.64 955.708 1517.09 970.31 1521.94 C 1105.09 1566.65 1256.66 1544.09 1358.35 1440.01 C 1397.17 1394.75 1410.91 1360.52 1410.01 1302 L 1408.42 1302.37 C 1395.32 1305.59 1377.94 1314.66 1365.06 1320.03 C 1294.67 1349.36 1212.93 1361.15 1137.19 1351.3 C 1041.82 1338.9 943.039 1272.16 840.44 1243.81 z M 495.507 999.144 C 453.53 1008.01 440.935 1065.13 428.748 1100.97 C 371.204 1269.09 360.088 1532 533.268 1638.59 C 598.933 1679 675.355 1687.18 746.437 1655.94 C 859.966 1603.37 901.891 1465.68 850.084 1355.36 C 800.354 1249.47 644.644 1104.07 549.506 1027.25 C 535.226 1015.89 514.258 997.224 495.507 999.144 z" />
<path d="M9.5 11h5" /> <path d="M 490.113 1171.08 C 526.074 1169.48 523.674 1192.77 517.597 1218.79 C 501.423 1288.06 501.968 1347.56 517.363 1416.46 C 514 1429.79 511.1 1433.52 498.914 1440.01 C 429.934 1456.78 461.2 1243.09 466.534 1210.88 C 469.864 1190.77 469.321 1178.34 490.113 1171.08 z" />
</Icon> <path d="M 1067.99 1178.67 C 1084.54 1178.69 1113.69 1177.22 1129 1179.34 C 1158.36 1183.39 1158.55 1230.38 1120.86 1230.63 C 1104.98 1230.58 1058.92 1234.3 1047.71 1223.62 C 1028.23 1205.06 1038.37 1179.18 1067.99 1178.67 z" />
</FilledIcon>
); );
} }
/** A glass — juices, coffee/tea, cooking alcohols, water (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`'s drinkable subset). */ /** Glass (foodiconpack.com, CC BY 4.0) — juices, coffee/tea, cooking alcohols, water (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`'s drinkable subset). */
export function DrinkIcon() { export function DrinkIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M6 3h12l-1.5 16.5A2 2 0 0 1 14.5 21h-5a2 2 0 0 1-2-1.5z" /> <path d="M 1267.05 274.485 C 1325.51 271.562 1335.62 290.356 1378.17 311.904 C 1386.06 307.975 1394.21 301.843 1401.71 297.203 C 1487.29 244.219 1616.96 297.102 1629.14 392.932 C 1734.88 398.312 1783.1 466.039 1783.5 563.848 L 1783.53 722.652 C 1783.49 830.259 1784.98 944.69 1727.69 1040.56 C 1659.77 1154.23 1656 1272.82 1655.97 1400.89 L 1656.05 1650.68 C 1656.02 1758.75 1621.13 1773.1 1521.07 1773.05 L 1259.5 1772.99 C 1231.98 1773.01 1203.87 1773.38 1176.4 1772.76 C 1160.67 1772.41 1144.87 1763.27 1128.16 1763.17 L 721.75 1763.02 L 588.607 1763 C 566.914 1763 544.536 1763.73 522.85 1763 C 456.776 1760.77 396.557 1710.05 383.737 1645.07 C 379.527 1623.73 380.955 1598.24 380.976 1576.49 L 381.024 1300.03 C 381.034 1273.38 380.023 1242.55 381.015 1216.37 C 333.984 1195.75 300.304 1175.9 277.68 1126.87 C 243.634 1053.08 280.583 957.367 355.016 924.877 C 355.565 907.942 353.79 884.25 356.109 868.341 C 372.867 753.37 467.596 697.043 578.822 710.005 C 632.246 644.377 685.672 601.154 778.393 611.55 C 850.674 619.653 890.27 657.13 932.09 710.005 L 964.049 710.005 C 963.638 624.745 947.76 498.792 1007.33 438.183 C 1037.36 407.629 1072.52 393.572 1114.67 392.932 C 1139.14 323.206 1192.38 279.094 1267.05 274.485 z M 1730 694.012 L 1016.32 694.012 C 1012.45 844.295 1013.09 909.673 1082.42 1040.58 C 1136.35 1152.04 1146.02 1252.12 1146.03 1374.77 L 1145.97 1611.06 C 1146.01 1682.73 1128.6 1719.82 1210.62 1719.9 L 1529.39 1719.92 C 1568.14 1719.97 1601.9 1723.64 1602.95 1675.39 C 1603.31 1647.44 1603.01 1619.46 1603.02 1591.5 L 1602.99 1414.19 C 1603.01 1277.52 1605.79 1170.54 1664.47 1044.8 C 1672.02 1028.62 1682.56 1012.72 1690.2 996.562 C 1732.11 907.991 1734.92 792.128 1730 694.012 z M 1064.3 1143.88 C 1060.97 1106.23 1018.67 1037.53 1001.98 1000.28 C 966.107 920.223 962.451 850.873 964.049 765.017 C 954.263 765.07 944.392 765.287 934.535 765.178 C 887.998 764.665 900.936 757.388 876.025 725.296 C 849.552 689.761 785.191 651.167 740.498 664.397 C 636.802 675.565 623.761 764.277 597.178 766.129 C 584.972 767.073 571.518 762.206 558.876 760.856 C 472.166 751.595 401.323 821.727 408.888 908 C 410.726 966.867 422.178 951.207 373.4 976.923 C 250.053 1041.95 354.688 1216.46 466.223 1151.84 C 499.004 1133 512.803 1095.67 562.276 1135.27 C 592.735 1159.65 588.097 1198.25 588.077 1232.88 L 587.986 1314.65 C 587.942 1343.37 583.657 1394.57 632.277 1379.55 C 658.53 1361.57 654.122 1349.31 654.111 1319.44 L 654.033 1252.24 C 654.004 1207.85 650.169 1176.52 691.026 1145.51 C 737.98 1109.87 775.436 1148.14 817.218 1149.31 C 841.254 1149.99 852.643 1149.72 875.92 1141.99 C 926.399 1125.21 906.257 1119.18 955.5 1138.03 C 992.889 1152.34 1026.34 1156.21 1064.3 1143.88 z M 433.999 1216.37 L 433.999 1550.43 C 475.685 1551.17 519.63 1550.43 561.541 1550.43 L 921.821 1550.38 C 938.878 1550.35 956.176 1550.16 973.027 1550.52 C 997.119 1551.04 1008.11 1578.93 993.12 1595.17 C 981.426 1607.83 945.098 1603.81 928.188 1603.81 L 565.302 1603.82 C 522.605 1603.82 476.507 1602.95 433.999 1603.84 C 434.202 1669.45 473.124 1710.64 541.776 1710.03 L 765.4 1709.63 L 1054.87 1709.55 C 1066.79 1709.42 1084.27 1708.4 1095.35 1709.63 C 1091.23 1685.26 1093.01 1631.26 1093.02 1603.84 C 1080.62 1604.81 1070.37 1606.45 1059.82 1598.09 C 1034.03 1577.67 1055.71 1549.13 1093.02 1550.43 L 1093.05 1401.69 C 1093.08 1325.3 1094.42 1270.1 1078.85 1193.74 C 1027.34 1219.45 971.168 1195.86 922.099 1183.84 C 909.765 1185.27 893.686 1193.02 880.867 1196.36 C 828.579 1209.97 789.396 1203.07 740.498 1183.84 C 713.843 1188.12 707.598 1201.37 706.941 1227 C 705.854 1269.36 711.046 1322.31 705.54 1363.54 C 697.442 1424.18 618.55 1456.12 569.879 1419.16 C 528.58 1389.91 533.915 1354.11 533.976 1309.77 L 534.185 1227.98 C 534.411 1208.95 539.307 1188.09 526.31 1172.61 C 495.427 1200.14 474.835 1210.36 433.999 1216.37 z M 1267.05 327.463 C 1149.56 339.214 1179.51 443.167 1135.59 443.939 C 996.152 443.732 1022.17 546.05 1016.32 641.005 C 1071.8 641.819 1129.63 641.006 1185.28 641.004 L 1520.25 641.005 L 1730 641.005 C 1726.75 542.326 1749.52 446.998 1610.37 443.425 C 1581.03 442.672 1585.35 410.202 1575.44 391.464 C 1549.22 336.931 1486.59 311.569 1431.89 340.646 C 1419.5 346.18 1392.44 368.293 1379.03 368.873 C 1353.16 369.99 1325.15 326.369 1267.05 327.463 z" />
<path d="M7 9h10" /> <path d="M 856.545 913.549 C 866.417 911.644 876.552 915.188 883.085 922.83 C 889.618 930.472 891.543 941.034 888.127 950.49 C 884.71 959.946 876.478 966.838 866.569 968.539 C 851.467 971.131 837.092 961.08 834.344 946.004 C 831.596 930.929 841.499 916.452 856.545 913.549 z" />
</Icon> <path d="M 728.404 789.654 C 737.003 786.898 746.405 788.493 753.615 793.928 C 760.826 799.364 764.946 807.964 764.664 816.989 C 764.296 828.745 756.569 838.996 745.369 842.586 C 730.752 847.272 715.103 839.221 710.419 824.603 C 705.734 809.985 713.786 794.338 728.404 789.654 z" />
<path d="M 625.937 963.277 C 640.819 961.847 654.064 972.7 655.588 987.572 C 657.111 1002.44 646.341 1015.76 631.478 1017.37 C 616.484 1019 603.027 1008.12 601.49 993.113 C 599.953 978.109 610.923 964.72 625.937 963.277 z" />
</FilledIcon>
); );
} }
/** A stockpot — broths, stocks, water bases (`AIDES_CULINAIRES`/`BASES`'s liquid-base subset). */ /** Stockpot (foodiconpack.com, CC BY 4.0) — broths, stocks, water bases (`AIDES_CULINAIRES`/`BASES`'s liquid-base subset). */
export function StockPotIcon() { export function StockPotIcon() {
return ( return (
<Icon> <FilledIcon>
<rect x="4" y="10" width="16" height="10" rx="2" /> <path d="M 956.689 498.026 L 1074.61 497.958 C 1137.01 497.691 1174.39 532.451 1174.48 597.104 C 1265.87 599.475 1358.96 595.285 1450.52 597.066 C 1551.82 599.036 1640.84 682.526 1638.99 785.253 C 1622.4 813.785 1611.98 785.183 1613 828.678 C 1639.21 825.311 1672.61 823.696 1698.78 818.712 C 1763.14 806.46 1805.62 869.267 1772.1 924.572 C 1750.8 959.702 1653.92 968.017 1613 976.35 L 1613.05 1323.99 C 1613.06 1352.99 1614.47 1381.48 1607.44 1409.65 C 1580.36 1518.21 1497.47 1548.07 1399.95 1547.95 L 1089.99 1547.91 L 663.723 1547.96 C 564.944 1547.94 471.975 1528.47 441.558 1414.71 C 432.646 1381.38 435.528 1339.45 435.545 1304.36 L 435.707 1017.76 C 435.822 956.555 438.134 978.73 368.191 964.482 C 334.482 957.614 301.664 960.737 279.03 929.523 C 256.702 898.732 261.782 855.965 292.641 833.135 C 320.337 812.91 350.476 820.964 381.769 826.296 L 435.591 835.521 C 435.902 826.211 435.618 815.518 435.591 806.105 C 421.651 801.018 412.723 797.094 412.559 780.488 C 410.831 699.26 472.524 621.061 551.615 603.033 C 592.371 593.743 648.136 597.11 691.291 597.11 L 875.146 597.104 C 871.062 542.426 901.02 503.078 956.689 498.026 z M 478.871 806.105 C 477.554 845.634 478.872 892.42 478.876 932.718 L 478.841 1313.78 C 478.773 1369.65 473.787 1415.83 516.462 1459.99 C 546.181 1490.75 580.38 1505.29 623.081 1504.86 L 1419.04 1505.01 C 1452.51 1505.2 1469.64 1502.53 1499.23 1486.78 C 1571.64 1439.94 1569.41 1384.54 1569.36 1306.19 L 1569.36 929.821 C 1569.37 891.098 1570.64 844.093 1569.34 806.105 L 528.372 806.105 C 512.279 806.198 494.852 806.61 478.871 806.105 z M 1264.5 762.998 L 1594.19 762.998 C 1586.34 721.917 1562.92 688.244 1528.97 664.169 C 1487.57 634.821 1441.84 640.706 1393.89 640.809 L 788.11 640.848 L 665.057 640.812 C 633.374 640.813 587.735 638.189 558.195 646.073 C 508.836 659.248 463.194 707.895 457.828 759.35 C 457.629 761.31 457.844 761.459 459.043 762.615 C 489.947 763.762 525.361 763.005 556.59 763.009 L 1264.5 762.998 z M 325.946 863.171 C 316.949 867.888 313.801 870.39 309.051 879.906 C 307.696 930.337 378.413 921.02 421.834 930.35 C 426.15 930.259 431.368 929.994 435.591 930.35 C 434.898 913.263 435.666 896.899 435.591 879.906 C 417.924 875.099 342.207 861.758 325.946 863.171 z M 956.689 541.052 C 922.748 546.685 916.547 566.328 917.645 597.104 L 1093.5 597.104 L 1130.73 597.104 C 1129.13 534.666 1094.11 540.855 1046.78 541.012 L 956.689 541.052 z M 1613 873.877 L 1613 930.35 C 1622.19 929.498 1629.94 928.246 1638.99 926.739 C 1666.38 921.093 1695.84 916.564 1722.96 910.501 C 1748.41 904.812 1745.27 855.434 1711.78 863.171 C 1684.18 864.363 1640.88 870.069 1613 873.877 z" />
<path d="M2 12h2M20 12h2" /> </FilledIcon>
<path d="M9 10V7a3 3 0 0 1 6 0v3" />
</Icon>
); );
} }
/** A sugar cube — `AIDES_CULINAIRES`/`SUCRES`. */ /** Sugar (foodiconpack.com, CC BY 4.0) — `AIDES_CULINAIRES`/`SUCRES`. */
export function SugarIcon() { export function SugarIcon() {
return ( return (
<Icon> <FilledIcon>
<path d="M4 8 12 4l8 4-8 4z" /> <path d="M 563.343 266.546 C 579.356 265.999 598.229 266.52 614.537 266.523 L 1165.31 266.51 C 1214.01 266.5 1267.02 255.617 1296.26 302.682 C 1323.29 346.167 1275.54 419.411 1264.41 467.576 C 1285.74 508.561 1315.63 552.485 1339.39 592.89 C 1380.58 662.943 1373.02 694.734 1373.02 771.075 L 1373.04 1068.44 C 1373.05 1091.57 1373.19 1114.08 1372.67 1137.74 C 1373.67 1157.45 1347.53 1169.18 1333.48 1156.58 C 1319.4 1143.95 1323.15 1103.91 1323.13 1086.51 L 1323.23 998.001 C 1323.35 921.011 1321.79 841.992 1323.04 765.249 C 1302.66 765.841 1263.43 767.077 1243.62 763.124 C 1209.14 756.243 1194.07 747.655 1156.63 747.651 C 1097.23 747.646 1050.15 776.567 981.876 761.345 C 948.843 753.98 929.738 747.792 895.257 747.199 C 833.498 746.136 788.155 775.837 714.942 762.372 C 621.546 745.195 631.078 743.658 537.012 759.443 C 519.46 762.388 489.969 762.19 471.997 762.221 L 471.995 1371.67 C 472.002 1396.15 472.97 1424.92 471.997 1448.84 L 597.5 1448.84 L 936.881 1448.84 C 951.994 1448.71 966.891 1448.41 981.989 1448.91 C 1013.24 1449.97 1014.95 1496.54 985.493 1498.58 C 968.334 1499.76 948.038 1498.99 930.633 1498.99 L 471.997 1498.96 C 471.9 1615.07 488.067 1658.29 618.58 1658.04 L 920.519 1658.05 C 941.436 1658 962.351 1657.71 983.263 1657.98 C 1023.72 1658.51 1019.41 1701.53 1001.19 1705 C 973.159 1710.33 929.732 1708.06 902.015 1708.05 L 632.237 1708.04 C 610.876 1708.04 589.378 1708.56 568.066 1707.31 C 490.732 1702.79 424.454 1638.6 422.594 1560.23 C 422.045 1537.12 422.175 1514.18 422.199 1491.06 L 422.163 798.425 C 422.067 611.542 423.495 654.442 516.951 491.098 C 522.459 481.473 529.099 472.824 533.608 462.528 C 524.397 441.605 516.805 417.28 507.32 397.071 C 479.406 337.599 491.407 270.834 563.343 266.546 z M 581.2 444.248 L 858.635 444.207 C 877.707 444.202 918.616 439.037 917.922 470.882 C 917.289 499.919 877.231 495.793 856.563 495.719 L 575.303 495.627 C 555.982 521.64 542.019 551.259 524.773 578.28 C 494.345 625.954 472.748 652.065 471.997 711.023 C 508.075 714.232 541.943 706.338 576.962 699.622 C 653.495 684.942 727.737 724.071 802.658 710.056 C 841.73 700.588 902.783 691.423 942.696 700.048 C 970.712 706.101 991.978 712.195 1020.99 713.991 C 1085.47 714.159 1144.93 686.96 1210.17 702.913 C 1261.33 714.301 1273.21 710.564 1323.04 713.991 C 1322.45 696.464 1322.69 677.961 1317.12 661.13 C 1311.61 644.465 1231.69 507.757 1221.07 497.37 C 1197.05 488.286 1118.19 504.122 1091.8 492.457 C 1072.38 483.875 1074.24 452.977 1094.37 445.85 C 1105.49 441.914 1200.19 444.253 1218.49 444.248 C 1224.39 414.175 1250.62 365.264 1255.09 334.51 C 1240.34 308.577 1197.61 318.459 1168.14 318.51 L 615.35 318.531 C 600.872 318.528 580.593 317.824 566.686 318.553 C 513.479 321.249 572.707 415.367 581.2 444.248 z" />
<path d="M4 8v8l8 4 8-4V8" /> <path d="M 979.84 444.248 C 1053.88 437.921 1046.87 490.04 1011.25 495.627 C 929.626 498.405 950.269 448.02 979.84 444.248 z" />
<path d="M12 12v8" /> <path d="M 1396.75 1217.47 C 1443.62 1216.15 1464.72 1245.77 1494.53 1275.78 C 1565.63 1347.33 1623.96 1384.38 1535.89 1472 L 1539.17 1471.74 C 1583.19 1468.48 1626.03 1501.91 1626.71 1547.84 C 1627.54 1603.5 1637.63 1714.43 1605.34 1752.92 C 1575.25 1788.59 1526.31 1777.12 1485.51 1778.93 C 1441.76 1778.97 1394.42 1787.64 1359.68 1755.43 C 1348.72 1763.2 1342.39 1767.96 1330.25 1773.29 C 1298.34 1783.83 1255.57 1778.81 1222.29 1778.9 C 1181.48 1779.02 1140.16 1786.51 1108.24 1755.67 C 1072.31 1720.97 1081.98 1654.29 1082 1607.15 C 1082.01 1583.57 1079.27 1553.57 1085.21 1530.78 C 1105.39 1453.44 1204.35 1472.15 1264.41 1472 C 1175.55 1397.29 1237.85 1339.22 1296.83 1283.61 C 1333.08 1249.42 1344.73 1222.99 1396.75 1217.47 z M 1247.12 1728.54 C 1321.35 1728.84 1337.7 1737.1 1336.56 1662.79 C 1336.02 1627.15 1337.61 1591.34 1335.69 1555.74 C 1333.59 1516.93 1302.77 1521.22 1275.51 1521.35 L 1218.49 1521.43 C 1198.62 1521.35 1133.22 1514.22 1132.35 1546.35 C 1131.11 1592.23 1132.44 1638.8 1132.04 1684.78 C 1131.18 1732.14 1158.14 1728.7 1194.19 1728.63 L 1247.12 1728.54 z M 1273.02 1405.46 C 1290.13 1431.7 1353.26 1490.83 1377.71 1512.41 C 1385.03 1518.87 1394.84 1519.46 1404.08 1519.51 C 1424.76 1511.3 1510.19 1429.67 1526.99 1405.46 L 1526.91 1403.58 C 1525.97 1379.71 1527.17 1378.58 1508.46 1360.21 C 1486.32 1338.47 1425.85 1265.45 1396.75 1267.2 C 1373.17 1272.79 1305.16 1345.42 1286.86 1364.36 C 1272.58 1379.15 1271.92 1385.74 1273.02 1405.46 z M 1385.12 1569.55 L 1385.1 1660 C 1385.17 1745.39 1402.36 1728.92 1477.75 1728.54 C 1544.14 1728.7 1578.04 1744.59 1578.07 1671.14 L 1578.06 1591.77 C 1577.93 1508.63 1563.04 1518.16 1483.56 1519.51 C 1457.92 1546.93 1437.27 1567.87 1396.85 1569.25 L 1385.12 1569.55 z" />
</Icon> </FilledIcon>
); );
} }

View file

@ -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;

View file

@ -6,6 +6,7 @@ import "./AppLayout.scss";
import { import {
AccountIcon, AccountIcon,
ChevronLeftIcon, ChevronLeftIcon,
CreditsIcon,
DietPreferencesIcon, DietPreferencesIcon,
HouseholdIcon, HouseholdIcon,
PlanningIcon, PlanningIcon,
@ -38,6 +39,7 @@ const SETTINGS_ITEMS = [
{ to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon }, { to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon }, { to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon }, { to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
{ to: "/parametres/credits", key: "credits", Icon: CreditsIcon },
] as const; ] as const;
/** /**
@ -85,7 +87,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 +104,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 +112,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 +151,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 +168,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>
))} ))}

View file

@ -1,130 +1,28 @@
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> Info as CreditsIcon,
); ChevronLeft as ChevronLeftIcon,
} Star as FavoriteIcon,
Globe as PublicIcon,
export function PlanningIcon() { } from "lucide-react";
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>
);
}

View file

@ -0,0 +1,24 @@
/**
* Generates a client-only, ephemeral identity for a draft list row (a
* recipe's ingredient lines/steps while editing see `RecipeFormPage`,
* `StepListEditor`) used purely as a React `key`/local identity to keep
* each row's own input state stable across reorders, never sent to the
* server or persisted anywhere.
*
* `crypto.randomUUID()` looks like the obvious choice, but the Web Crypto
* API only defines it in a "secure context" https, or the literal
* hostname `localhost` and throws `TypeError: crypto.randomUUID is not a
* function` everywhere else: a LAN IP while testing on a real device, a
* Capacitor WebView's `capacitor://` origin (this app's eventual mobile
* wrapper see `AllergySelect`'s doc comment for the same "mobile via
* Capacitor" context), plain `http://` on a real domain before TLS is set
* up. None of that matters for a value that only ever needs to be *unique
* within this one render session*, not cryptographically random, so this
* never touches `crypto` at all.
*/
let counter = 0;
export function makeClientKey(): string {
counter += 1;
return `${counter}-${Math.random().toString(36).slice(2)}`;
}

View file

@ -78,7 +78,8 @@
"account": "Compte", "account": "Compte",
"preferences": "Préférences alimentaires", "preferences": "Préférences alimentaires",
"household": "Foyer", "household": "Foyer",
"userPreferences": "Préférences utilisateur" "userPreferences": "Préférences utilisateur",
"credits": "Crédits"
} }
}, },
"accountMenu": { "accountMenu": {
@ -260,6 +261,14 @@
"SYSTEM": "Système" "SYSTEM": "Système"
} }
}, },
"credits": {
"title": "Crédits",
"icons": {
"title": "Icônes d'ingrédients",
"description": "La plupart des icônes d'ingrédients de l'application proviennent des collections gratuites « Common ingredient icons » et « Common Utensils » de Food Icon Pack, sous licence Creative Commons Attribution 4.0. Elles ont été recolorées pour s'adapter aux thèmes clair et sombre de l'application.",
"licenseLabel": "Licence CC BY 4.0"
}
},
"household": { "household": {
"title": "Foyer", "title": "Foyer",
"form": { "form": {
@ -288,5 +297,469 @@
"confirmButton": "Confirmer la suppression", "confirmButton": "Confirmer la suppression",
"cancelButton": "Annuler" "cancelButton": "Annuler"
} }
},
"catalog": {
"diets": {
"omnivore": "Omnivore",
"vegetarian": "Végétarien",
"vegan": "Végan",
"pescatarian": "Pescétarien",
"gluten_free": "Sans gluten"
},
"allergens": {
"gluten": "Gluten",
"crustaceans": "Crustacés",
"eggs": "Œufs",
"fish": "Poissons",
"peanuts": "Arachides",
"soy": "Soja",
"milk": "Lait",
"tree_nuts": "Fruits à coque",
"celery": "Céleri",
"mustard": "Moutarde",
"sesame_seeds": "Graines de sésame",
"sulfites": "Sulfites",
"lupin": "Lupin",
"molluscs": "Mollusques"
},
"ingredients": {
"tomato": "Tomate",
"onion": "Oignon",
"shallot": "Échalote",
"garlic": "Ail",
"carrot": "Carotte",
"zucchini": "Courgette",
"cucumber": "Concombre",
"gherkins": "Cornichons",
"bell_pepper": "Poivron",
"mushroom": "Champignon",
"porcini": "Cèpes",
"eggplant": "Aubergine",
"broccoli": "Brocoli",
"cauliflower": "Chou-fleur",
"white_cabbage": "Chou blanc",
"red_cabbage": "Chou rouge",
"brussels_sprouts": "Chou de Bruxelles",
"spinach": "Épinard",
"swiss_chard": "Blette",
"lettuce": "Salade",
"arugula": "Roquette",
"watercress": "Cresson",
"leek": "Poireau",
"celery": "Céleri",
"radish": "Radis",
"beetroot": "Betterave",
"turnip": "Navet",
"parsnip": "Panais",
"green_bean": "Haricot vert",
"pea": "Petit pois",
"corn": "Maïs",
"artichoke": "Artichaut",
"fennel": "Fenouil",
"endive": "Endive",
"pumpkin": "Potiron",
"butternut_squash": "Butternut",
"asparagus": "Asperge",
"avocado": "Avocat",
"potato": "Pomme de terre",
"sweet_potato": "Patate douce",
"cherry_tomato": "Tomates cerises",
"bok_choy": "Pak-choï",
"soybean_sprouts": "Germes de soja",
"shiitake": "Shiitake",
"daikon": "Daikon",
"fresh_green_chili": "Piment vert frais",
"lemon": "Citron",
"lime": "Citron vert",
"apple": "Pomme",
"pear": "Poire",
"banana": "Banane",
"orange": "Orange",
"clementine": "Clémentine",
"grapefruit": "Pamplemousse",
"strawberry": "Fraise",
"raspberry": "Framboise",
"blueberry": "Myrtille",
"blackberry": "Mûre",
"cherry": "Cerise",
"apricot": "Abricot",
"peach": "Pêche",
"plum": "Prune",
"grape": "Raisin",
"melon": "Melon",
"watermelon": "Pastèque",
"pineapple": "Ananas",
"mango": "Mangue",
"kiwi": "Kiwi",
"fig": "Figue",
"date": "Datte",
"lychee": "Litchi",
"pomegranate": "Grenade",
"rhubarb": "Rhubarbe",
"quince": "Coing",
"basil": "Basilic",
"parsley": "Persil",
"thyme": "Thym",
"rosemary": "Romarin",
"bay_leaf": "Laurier",
"chives": "Ciboulette",
"fresh_cilantro": "Coriandre fraîche",
"mint": "Menthe",
"oregano": "Origan",
"dill": "Aneth",
"tarragon": "Estragon",
"savory": "Sarriette",
"marjoram": "Marjolaine",
"sage": "Sauge",
"chervil": "Cerfeuil",
"ginger": "Gingembre",
"lemongrass": "Citronnelle",
"kaffir_lime": "Combava",
"rabbit": "Lapin",
"ground_beef": "Bœuf haché",
"beef_steak": "Steak de bœuf",
"beef_roast": "Rôti de bœuf",
"veal_cutlet": "Escalope de veau",
"pork_tenderloin": "Filet mignon de porc",
"pork_chop": "Côte de porc",
"lamb": "Agneau",
"leg_of_lamb": "Gigot d'agneau",
"bacon_lardons": "Lardons",
"bacon": "Bacon",
"ham": "Jambon blanc",
"cured_ham": "Jambon cru",
"sausage": "Saucisse",
"chorizo": "Chorizo",
"merguez": "Merguez",
"prosciutto": "Prosciutto",
"pancetta": "Pancetta",
"mortadella": "Mortadelle",
"salami": "Salami",
"chicken": "Poulet",
"turkey": "Dinde",
"duck": "Canard",
"duck_breast": "Magret de canard",
"salmon": "Saumon",
"tuna": "Thon",
"cod": "Cabillaud",
"trout": "Truite",
"sardine": "Sardine",
"anchovy": "Anchois",
"whiting": "Merlan",
"surimi": "Surimi",
"sea_bass": "Bar (loup de mer)",
"sea_bream": "Dorade",
"sole": "Sole",
"turbot": "Turbot",
"hake": "Merlu",
"pollock": "Colin",
"saithe": "Lieu noir",
"haddock": "Églefin",
"mackerel": "Maquereau",
"herring": "Hareng",
"red_mullet": "Rouget",
"skate": "Raie",
"monkfish": "Lotte",
"halibut": "Flétan",
"swordfish": "Espadon",
"carp": "Carpe",
"pike": "Brochet",
"perch": "Perche",
"tilapia": "Tilapia",
"pangasius": "Panga",
"smoked_salmon": "Saumon fumé",
"dried_fish": "Poisson séché",
"shrimp": "Crevettes",
"langoustine": "Langoustines",
"lobster": "Homard",
"crab": "Crabe",
"spiny_lobster": "Langouste",
"mussels": "Moules",
"oysters": "Huîtres",
"scallops": "Saint-Jacques",
"squid": "Calamar",
"octopus": "Poulpe",
"clams": "Palourdes",
"whelks": "Bulots",
"semolina": "Semoule",
"couscous": "Couscous",
"bulgur": "Boulgour",
"polenta": "Polenta",
"quinoa": "Quinoa",
"pasta": "Pâtes",
"whole_wheat_pasta": "Pâtes complètes",
"rice": "Riz",
"basmati_rice": "Riz basmati",
"brown_rice": "Riz complet",
"oats": "Flocons d'avoine",
"spaghetti": "Spaghetti",
"penne": "Penne",
"tagliatelle": "Tagliatelles",
"lasagna_sheets": "Lasagnes (feuilles)",
"gnocchi": "Gnocchi",
"arborio_rice": "Riz arborio",
"rice_noodles": "Nouilles de riz",
"udon_noodles": "Nouilles udon",
"soba_noodles": "Nouilles soba",
"chinese_noodles": "Nouilles chinoises",
"rice_vermicelli": "Vermicelles de riz",
"soy_vermicelli": "Vermicelles de soja",
"sticky_rice": "Riz gluant",
"sushi_rice": "Riz à sushi",
"jasmine_rice": "Riz jasmin",
"green_lentils": "Lentilles vertes",
"red_lentils": "Lentilles corail",
"chickpeas": "Pois chiches",
"white_beans": "Haricots blancs",
"kidney_beans": "Haricots rouges",
"black_beans": "Haricots noirs",
"split_peas": "Pois cassés",
"fava_beans": "Fèves",
"edamame": "Edamame",
"pinto_beans": "Haricots pinto",
"peanuts_shelled": "Cacahuètes",
"almonds": "Amandes",
"walnuts": "Noix",
"hazelnuts": "Noisettes",
"cashews": "Noix de cajou",
"pistachios": "Pistaches",
"pecans": "Noix de pécan",
"almond_powder": "Poudre d'amande",
"pine_nuts": "Pignons de pin",
"sunflower_seeds": "Graines de tournesol",
"pumpkin_seeds": "Graines de courge",
"shredded_coconut": "Noix de coco râpée",
"raisins": "Raisins secs",
"prunes": "Pruneaux",
"dried_apricots": "Abricots secs",
"sesame_seeds": "Graines de sésame",
"black_mushrooms": "Champignons noirs",
"nori_seaweed": "Algue nori",
"wakame_seaweed": "Algue wakamé",
"kombu_seaweed": "Algue kombu",
"bamboo_shoots": "Pousses de bambou",
"water_chestnuts": "Châtaignes d'eau",
"bread": "Pain",
"sandwich_bread": "Pain de mie",
"whole_wheat_bread": "Pain complet",
"baguette": "Baguette",
"rye_bread": "Pain de seigle",
"breadcrumbs": "Chapelure",
"burger_bun": "Pain à burger",
"brioche_bun": "Pain brioché",
"hot_dog_bun": "Pain à hot-dog",
"pita_bread": "Pain pita",
"bagel": "Pain bagel",
"naan": "Naan",
"wrap_bread": "Pain wrap",
"viennese_bread": "Pain viennois",
"country_bread": "Pain de campagne",
"multigrain_bread": "Pain aux céréales",
"bread_roll": "Petit pain",
"swedish_bread": "Pain suédois",
"gluten_free_bread": "Pain sans gluten",
"rusk": "Biscotte",
"croutons": "Croûtons",
"focaccia": "Focaccia",
"ciabatta": "Ciabatta",
"corn_tortilla": "Tortilla de maïs",
"wheat_tortilla": "Tortilla de blé",
"puff_pastry": "Pâte feuilletée",
"shortcrust_pastry": "Pâte brisée",
"pizza_dough": "Pâte à pizza",
"sweet_shortcrust_pastry": "Pâte à tarte sablée",
"milk": "Lait",
"butter": "Beurre",
"creme_fraiche": "Crème fraîche",
"liquid_cream": "Crème liquide",
"cheese": "Fromage",
"emmental": "Emmental",
"gruyere": "Gruyère",
"parmesan": "Parmesan",
"mozzarella": "Mozzarella",
"goat_cheese": "Chèvre (fromage)",
"feta": "Feta",
"comte": "Comté",
"fromage_blanc": "Fromage blanc",
"mascarpone": "Mascarpone",
"yogurt": "Yaourt",
"burrata": "Burrata",
"ricotta": "Ricotta",
"pecorino": "Pecorino",
"gorgonzola": "Gorgonzola",
"cheddar": "Cheddar",
"egg": "Œuf",
"coconut_milk": "Lait de coco",
"coconut_cream": "Crème de coco",
"almond_milk": "Lait d'amande",
"oat_milk": "Lait d'avoine",
"tofu": "Tofu",
"silken_tofu": "Tofu soyeux",
"herbes_de_provence": "Herbes de Provence",
"black_pepper": "Poivre noir",
"paprika": "Paprika",
"espelette_pepper": "Piment d'Espelette",
"cayenne_pepper": "Piment de Cayenne",
"cumin": "Cumin",
"curry_powder": "Curry (poudre)",
"turmeric": "Curcuma",
"cinnamon": "Cannelle",
"nutmeg": "Muscade",
"saffron": "Safran",
"clove": "Clou de girofle",
"vanilla_bean": "Vanille (gousse)",
"white_pepper": "Poivre blanc",
"pink_pepper": "Poivre rose",
"sichuan_pepper": "Poivre du Sichuan",
"smoked_paprika": "Paprika fumé",
"bird_eye_chili": "Piment oiseau",
"juniper_berries": "Baies de genièvre",
"star_anise": "Anis étoilé (badiane)",
"green_anise": "Anis vert",
"fennel_seeds": "Graines de fenouil",
"sumac": "Sumac",
"nigella": "Nigelle",
"allspice": "Quatre épices",
"colombo_powder": "Colombo (poudre)",
"baharat": "Baharat",
"horseradish": "Raifort",
"herb_salt": "Sel aux herbes",
"celery_salt": "Sel de céleri",
"fleur_de_sel": "Fleur de sel",
"salt": "Sel",
"five_spice": "Cinq épices",
"garam_masala": "Garam masala",
"coriander_seeds": "Graines de coriandre",
"cardamom": "Cardamome",
"fenugreek": "Fenugrec",
"jalapeno": "Piment jalapeño",
"chipotle": "Piment chipotle",
"poblano_pepper": "Piment poblano",
"habanero": "Piment habanero",
"ras_el_hanout": "Ras el hanout",
"zaatar": "Za'atar",
"soy_sauce": "Sauce soja",
"mustard": "Moutarde",
"mayonnaise": "Mayonnaise",
"ketchup": "Ketchup",
"tabasco": "Tabasco",
"worcestershire_sauce": "Sauce Worcestershire",
"fish_sauce": "Sauce nuoc-mâm",
"wasabi": "Wasabi",
"harissa": "Harissa",
"curry_paste": "Pâte de curry",
"peanut_butter": "Beurre de cacahuète",
"dijon_mustard": "Moutarde de Dijon",
"wholegrain_mustard": "Moutarde à l'ancienne",
"barbecue_sauce": "Sauce barbecue",
"tartar_sauce": "Sauce tartare",
"cocktail_sauce": "Sauce cocktail",
"bearnaise_sauce": "Sauce béarnaise",
"hollandaise_sauce": "Sauce hollandaise",
"bechamel_sauce": "Sauce béchamel",
"teriyaki_sauce": "Sauce teriyaki",
"ponzu_sauce": "Sauce ponzu",
"chimichurri": "Chimichurri",
"red_pesto": "Pesto rouge (tomates séchées)",
"pesto": "Pesto",
"oyster_sauce": "Sauce huître",
"hoisin_sauce": "Sauce hoisin",
"sriracha": "Sauce sriracha",
"sweet_chili_sauce": "Sauce sweet chili",
"miso": "Miso",
"shrimp_paste": "Pâte de crevettes",
"red_curry_paste": "Pâte de curry rouge (thaï)",
"green_curry_paste": "Pâte de curry vert (thaï)",
"tahini": "Tahini",
"olive_oil": "Huile d'olive",
"sunflower_oil": "Huile de tournesol",
"rapeseed_oil": "Huile de colza",
"coconut_oil": "Huile de coco",
"sesame_oil": "Huile de sésame",
"cider_vinegar": "Vinaigre de cidre",
"white_vinegar": "Vinaigre blanc",
"balsamic_vinegar": "Vinaigre balsamique",
"capers": "Câpres",
"olives": "Olives",
"white_wine": "Vin blanc (cuisine)",
"red_wine": "Vin rouge (cuisine)",
"red_wine_vinegar": "Vinaigre de vin rouge",
"white_wine_vinegar": "Vinaigre de vin blanc",
"sherry_vinegar": "Vinaigre de xérès",
"walnut_oil": "Huile de noix",
"hazelnut_oil": "Huile de noisette",
"peanut_oil": "Huile d'arachide",
"chili_oil": "Huile pimentée",
"rice_vinegar": "Vinaigre de riz",
"mirin": "Mirin",
"sake": "Saké (cuisine)",
"lemon_juice": "Jus de citron",
"lime_juice": "Jus de citron vert",
"orange_juice": "Jus d'orange",
"apple_juice": "Jus de pomme",
"grape_juice": "Jus de raisin",
"tomato_juice": "Jus de tomate",
"cranberry_juice": "Jus de cranberry",
"coffee": "Café",
"tea": "Thé",
"beer": "Bière (cuisine)",
"cider": "Cidre (cuisine)",
"champagne": "Champagne / vin pétillant (cuisine)",
"port_wine": "Porto (cuisine)",
"vin_jaune": "Vin jaune (cuisine)",
"cognac": "Cognac",
"rum": "Rhum",
"whisky": "Whisky",
"vodka": "Vodka",
"wheat_flour": "Farine de blé",
"whole_wheat_flour": "Farine complète",
"corn_flour": "Farine de maïs",
"buckwheat_flour": "Farine de sarrasin",
"rice_flour": "Farine de riz",
"vegetable_stock_cube": "Bouillon cube légumes",
"chicken_stock_cube": "Bouillon cube volaille",
"tomato_paste": "Concentré de tomate",
"tomato_coulis": "Coulis de tomate",
"canned_peeled_tomatoes": "Tomates pelées (conserve)",
"sun_dried_tomatoes": "Tomates séchées",
"veal_stock": "Fond de veau",
"chicken_stock": "Fond de volaille",
"beef_stock_cube": "Bouillon cube bœuf",
"fish_stock_cube": "Bouillon cube poisson",
"vegetable_broth": "Bouillon de légumes",
"chicken_broth": "Bouillon de volaille",
"beef_broth": "Bouillon de bœuf",
"court_bouillon": "Court-bouillon",
"dashi": "Dashi (bouillon japonais)",
"shellfish_bisque": "Bisque de crustacés",
"tapioca_flour": "Farine de tapioca",
"masa_harina": "Masa harina",
"water": "Eau",
"sparkling_water": "Eau gazeuse",
"orange_blossom_water": "Eau de fleur d'oranger",
"rose_water": "Eau de rose",
"fish_fumet": "Fumet de poisson",
"bakers_yeast": "Levure boulangère",
"baking_powder": "Levure chimique",
"cornstarch": "Maïzena",
"lupin_flour": "Farine de lupin",
"gelatin": "Gélatine",
"baking_soda": "Bicarbonate de soude",
"potato_starch": "Fécule de pomme de terre",
"sugar": "Sucre",
"honey": "Miel",
"maple_syrup": "Sirop d'érable",
"brown_sugar": "Sucre roux",
"powdered_sugar": "Sucre glace",
"demerara_sugar": "Cassonade",
"dark_chocolate": "Chocolat noir",
"milk_chocolate": "Chocolat au lait",
"white_chocolate": "Chocolat blanc",
"chocolate_chips": "Pépites de chocolat",
"cocoa_powder": "Cacao en poudre",
"vanilla_extract": "Extrait de vanille",
"palm_sugar": "Sucre de palme",
"cane_syrup": "Sirop de sucre de canne"
}
} }
} }

View file

@ -15,6 +15,7 @@ import { IngredientPicker } from "../features/recipes/IngredientPicker";
import { IngredientRow } from "../features/recipes/IngredientRow"; import { IngredientRow } from "../features/recipes/IngredientRow";
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor"; import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
import "../features/recipes/recipes.scss"; import "../features/recipes/recipes.scss";
import { makeClientKey } from "../lib/client-key";
import { errorMessageService } from "../services/error-message.service"; import { errorMessageService } from "../services/error-message.service";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */ /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
@ -82,7 +83,7 @@ export function RecipeFormPage() {
setDietIds(recipe.diets.map((diet) => diet.id)); setDietIds(recipe.diets.map((diet) => diet.id));
setIngredientLines( setIngredientLines(
recipe.ingredients.map((line) => ({ recipe.ingredients.map((line) => ({
key: crypto.randomUUID(), key: makeClientKey(),
ingredient: line.ingredient, ingredient: line.ingredient,
quantity: String(line.quantity), quantity: String(line.quantity),
unit: line.unit, unit: line.unit,
@ -90,7 +91,7 @@ export function RecipeFormPage() {
); );
setSteps( setSteps(
recipe.steps.map((step) => ({ recipe.steps.map((step) => ({
key: crypto.randomUUID(), key: makeClientKey(),
description: step.description, description: step.description,
picture: step.picture ?? "", picture: step.picture ?? "",
})), })),
@ -110,7 +111,7 @@ export function RecipeFormPage() {
function addIngredient(ingredient: IngredientView) { function addIngredient(ingredient: IngredientView) {
setIngredientLines((lines) => [ setIngredientLines((lines) => [
...lines, ...lines,
{ key: crypto.randomUUID(), ingredient, quantity: "", unit: "" }, { key: makeClientKey(), ingredient, quantity: "", unit: "" },
]); ]);
} }

View file

@ -31,7 +31,7 @@ export function RecipesPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const selectedId = id !== undefined ? Number(id) : null; const selectedId = id !== undefined ? Number(id) : null;
const [activeTab, setActiveTab] = useState<RecipeTab>("publique"); const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [listState, setListState] = useState<RecipeListState>({ status: "loading" }); const [listState, setListState] = useState<RecipeListState>({ status: "loading" });

View file

@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import "./settings-pages.scss";
/**
* Third-party attribution routed at `/parametres/credits`. Exists solely
* to satisfy the CC BY 4.0 requirement on the ingredient icon set (see
* `features/recipes/ingredient-icons.tsx`'s `FilledIcon`-based components):
* most of it comes from foodiconpack.com's free "Common ingredient icons"
* and "Common Utensils" collections, which require crediting the source,
* linking the license, and noting that the icons were adapted (recolored
* to `currentColor` for theming, original size/style attributes dropped
* see the generation note in `ingredient-icons.tsx`). Nothing else in the
* app currently needs a credits entry grow this page if that changes.
*/
export function CreditsPage() {
const { t } = useTranslation();
return (
<div className="settings-page">
<h1>{t("credits.title")}</h1>
<div className="settings-page__section">
<h2 className="settings-page__section-title">{t("credits.icons.title")}</h2>
<p className="settings-page__hint">{t("credits.icons.description")}</p>
<p className="settings-page__hint">
<a href="https://foodiconpack.com" target="_blank" rel="noreferrer">
foodiconpack.com
</a>
{" — "}
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noreferrer">
{t("credits.icons.licenseLabel")}
</a>
</p>
</div>
</div>
);
}

View file

@ -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>

View file

@ -8,7 +8,15 @@
// household/regime/allergies page was split in three. // household/regime/allergies page was split in three.
// ============================================================================= // =============================================================================
// Centered column, not pinned to the content area's left edge — on a wide
// desktop viewport that left plenty of unused space beside these forms.
// Wide enough to give grids (the allergy checkboxes, the ingredient
// picker's category chips) real room, still well short of `.app-content`'s
// full width so line lengths/inputs don't stretch edge-to-edge.
.settings-page { .settings-page {
max-width: 56rem;
margin: 0 auto;
&__status { &__status {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-md); font-size: var(--font-size-md);
@ -21,9 +29,10 @@
// Each setting lives in its own card, same surface treatment used // Each setting lives in its own card, same surface treatment used
// elsewhere (see .planning-table in HomePage.scss) reads as a distinct, // elsewhere (see .planning-table in HomePage.scss) reads as a distinct,
// self-contained unit rather than one long form. // self-contained unit rather than one long form. Width comes from
// `.settings-page` above, not its own cap, now that it's the one
// establishing the centered column every section sits in.
.settings-page__section { .settings-page__section {
max-width: 32rem;
margin-top: var(--space-lg); margin-top: var(--space-lg);
padding: var(--space-lg); padding: var(--space-lg);
background: var(--color-surface); background: var(--color-surface);
@ -127,15 +136,21 @@
// 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 { // `button.` prefix (not just `.settings-page__danger-button`) to outrank
// the generic `.settings-page button` rule above a class selector alone
// has lower specificity than "class + element", so without it the generic
// gray fill/border was winning and this never actually rendered red (same
// fix as `button.settings-page__link-button` below, same reason).
button.settings-page__danger-button {
color: #fff; color: #fff;
background: var(--color-error); background: var(--color-error);
border-color: var(--color-error); border-color: var(--color-error);
&:hover { &:hover:not(:disabled) {
opacity: 0.9; opacity: 0.9;
} }
} }

View file

@ -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
View 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;

View file

@ -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),
},
}); });

View file

@ -1,10 +1,17 @@
/** /**
* A dietary regime, as returned by `GET /reference/diets` reference data * A dietary regime, as returned by `GET /reference/diets` reference data
* (`Diet`, seeded via `apps/api/prisma/seed.ts`), not user-specific. * (`Diet`, seeded via `apps/api/prisma/seed.ts`), not user-specific.
*
* `key` is a stable slug (e.g. `"vegetarien"`), not a display label it
* never changes once seeded, unlike the label it stands in for. Callers
* resolve the label themselves via i18n (`t(\`catalog.diets.${key}\`)`,
* `apps/web`'s `locales/fr/translation.json`), the same way
* `IngredientCategory`/`IngredientSubcategory` enum values already do (see
* `recipes.form.category.*`/`recipes.form.subcategory.*` in that file).
*/ */
export interface DietView { export interface DietView {
id: number; id: number;
name: string; key: string;
} }
/** /**
@ -18,17 +25,19 @@ export type AllergenKind = "ALLERGY" | "INTOLERANCE";
/** /**
* A selectable allergen, as returned by `GET /reference/allergies`. * A selectable allergen, as returned by `GET /reference/allergies`.
* *
* `name` is resolved server-side from the parent `Category` the `Allergy` * `key` is resolved server-side from the parent `Category` the `Allergy`
* table itself carries no name of its own (see `schema.prisma`), so this * table itself carries no key of its own (see `schema.prisma`), so this
* flattens that split away: callers just get `{id, name}` and never need to * flattens that split away: callers just get `{id, key}` and never need to
* know a `Category` exists underneath. `kind` groups allergens into two * know a `Category` exists underneath. Like {@link DietView.key}, it's a
* stable slug (e.g. `"gluten"`), not a display label resolved via
* `t(\`catalog.allergens.${key}\`)`. `kind` groups allergens into two
* separate lists client-side (`AllergySelect`, `apps/web`) rather than one * separate lists client-side (`AllergySelect`, `apps/web`) rather than one
* flat "allergies & intolérances" list a single `PATCH /profile/allergies` * flat "allergies & intolérances" list a single `PATCH /profile/allergies`
* call still covers both, this is a display grouping only. * call still covers both, this is a display grouping only.
*/ */
export interface AllergyView { export interface AllergyView {
id: number; id: number;
name: string; key: string;
kind: AllergenKind; kind: AllergenKind;
} }
@ -164,10 +173,13 @@ export type IngredientIcon = (typeof INGREDIENT_ICONS)[number];
* catalog (`apps/web`'s recipe form and detail page) to pick ingredients and * catalog (`apps/web`'s recipe form and detail page) to pick ingredients and
* to surface which allergens/regimes a recipe contains, aggregated across * to surface which allergens/regimes a recipe contains, aggregated across
* its ingredients. * its ingredients.
*
* `key` is a stable slug (e.g. `"tomate"`), not a display label like
* {@link DietView.key}, resolved via `t(\`catalog.ingredients.${key}\`)`.
*/ */
export interface IngredientView { export interface IngredientView {
id: number; id: number;
name: string; key: string;
icon: IngredientIcon; icon: IngredientIcon;
category: IngredientCategory; category: IngredientCategory;
subcategory: IngredientSubcategory; subcategory: IngredientSubcategory;

View file

@ -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: