feat(recipes): catégories d'ingrédients + nouveau sélecteur
- Prisma: enum IngredientCategory (18 valeurs) + Ingredient.category, migration appliquée - reference-seed-data.ts restructuré en 18 groupes de catégories - packages/shared: INGREDIENT_CATEGORIES + IngredientView.category - API: category exposé par /reference/ingredients et /recipes - Web: nouveau IngredientPicker (chips catégories + recherche + grille de cartes) remplaçant IngredientAutocomplete, branché dans le formulaire de recette et le champ aliments-pas-aimés - i18n: clés recipes.form.category.* et libellés du picker - Fix test reference.test.ts pour la nouvelle forme d'IngredientView Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
acab18ac4a
commit
ea86a4a7f1
15 changed files with 954 additions and 621 deletions
|
|
@ -0,0 +1,6 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "IngredientCategory" AS ENUM ('CEREALES_FECULENTS', 'LEGUMINEUSES', 'VIANDES_VOLAILLES', 'POISSONS_FRUITS_DE_MER', 'PRODUITS_LAITIERS_OEUFS', 'LEGUMES', 'FRUITS', 'FRUITS_SECS_OLEAGINEUX', 'CONDIMENTS_SAUCES', 'EPICES_HERBES', 'SUCRE_PATISSERIE', 'CUISINE_ITALIENNE', 'CUISINE_ASIATIQUE', 'CUISINE_MEXICAINE', 'MAGHREB_MOYEN_ORIENT', 'PAINS_SANDWICHS', 'EPICERIE_DIVERS', 'LIQUIDES_BOISSONS');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ingredients" ADD COLUMN "category" "IngredientCategory" NOT NULL DEFAULT 'EPICERIE_DIVERS';
|
||||
|
||||
|
|
@ -292,11 +292,44 @@ model RecipeDiet {
|
|||
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`.
|
||||
/// Ingredients are reference data (like Diet/Allergy): seeded, never
|
||||
/// created/edited/deleted through the API.
|
||||
/// Not in the original spec doc — coarse grouping (viandes, légumes,
|
||||
/// épices...) so the ingredient picker (apps/web) can offer category
|
||||
/// browsing, not just free-text search: with 400+ reference ingredients,
|
||||
/// search alone doesn't scale to actually *finding* one. Mirrors
|
||||
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file
|
||||
/// is the single source of truth for which ingredient belongs to which
|
||||
/// category, this enum just gives it a type-safe column to live in.
|
||||
/// `@default(EPICERIE_DIVERS)` exists only so this column can be added
|
||||
/// `NOT NULL` to a table that may already have rows — the seed script
|
||||
/// corrects every row's real category on the very next run, this default
|
||||
/// is never the intended value for a real ingredient.
|
||||
enum IngredientCategory {
|
||||
CEREALES_FECULENTS
|
||||
LEGUMINEUSES
|
||||
VIANDES_VOLAILLES
|
||||
POISSONS_FRUITS_DE_MER
|
||||
PRODUITS_LAITIERS_OEUFS
|
||||
LEGUMES
|
||||
FRUITS
|
||||
FRUITS_SECS_OLEAGINEUX
|
||||
CONDIMENTS_SAUCES
|
||||
EPICES_HERBES
|
||||
SUCRE_PATISSERIE
|
||||
CUISINE_ITALIENNE
|
||||
CUISINE_ASIATIQUE
|
||||
CUISINE_MEXICAINE
|
||||
MAGHREB_MOYEN_ORIENT
|
||||
PAINS_SANDWICHS
|
||||
EPICERIE_DIVERS
|
||||
LIQUIDES_BOISSONS
|
||||
}
|
||||
|
||||
model Ingredient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
icon String?
|
||||
alternateRecipeId Int? @map("alternate_recipe")
|
||||
category IngredientCategory @default(EPICERIE_DIVERS)
|
||||
alternateRecipeId Int? @map("alternate_recipe")
|
||||
|
||||
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
||||
recipes RecipeIngredient[]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -38,6 +38,7 @@ function toIngredientView(ingredient: IngredientWithAllergies): IngredientView {
|
|||
id: ingredient.id,
|
||||
name: ingredient.name,
|
||||
icon: ingredient.icon,
|
||||
category: ingredient.category,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
name: allergy.category.name,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export async function getIngredients(): Promise<IngredientView[]> {
|
|||
id: ingredient.id,
|
||||
name: ingredient.name,
|
||||
icon: ingredient.icon,
|
||||
category: ingredient.category,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
name: allergy.category.name,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ describe("Reference data", () => {
|
|||
expect(res.status).to.equal(200);
|
||||
expect(res.body.length).to.be.greaterThan(0);
|
||||
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate");
|
||||
expect(res.body[0]).to.have.keys(["id", "name", "icon", "allergens"]);
|
||||
expect(res.body[0]).to.have.keys(["id", "name", "icon", "category", "allergens"]);
|
||||
});
|
||||
|
||||
it("resolves each ingredient's linked allergens, empty for one with none", async () => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import type { IngredientView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// Reuses `IngredientAutocomplete` verbatim (built for the recipe form's
|
||||
// ingredient picker, features/recipes/) rather than a second search field —
|
||||
// same reference ingredient list, same "type to filter" behavior, just
|
||||
// without the recipe form's quantity/unit per line. Its own recipes.scss
|
||||
// import already covers `.ingredient-autocomplete`; this file's explicit
|
||||
// import below only adds `.disliked-ingredients-field__*` (see recipes.scss
|
||||
// — colocated there since it's the same "reference ingredient picker"
|
||||
// visual family, even though this field lives in the profile feature).
|
||||
import { IngredientAutocomplete } from "../recipes/IngredientAutocomplete";
|
||||
// Reuses `IngredientPicker` verbatim (built for the recipe form's
|
||||
// ingredient picker, features/recipes/) rather than a second search+browse
|
||||
// field — same reference ingredient list, same category/search browsing,
|
||||
// just without the recipe form's quantity/unit per line. Its own
|
||||
// recipes.scss import already covers `.ingredient-picker`; this file's
|
||||
// explicit import below only adds `.disliked-ingredients-field__*` (see
|
||||
// recipes.scss — colocated there since it's the same "reference ingredient
|
||||
// picker" visual family, even though this field lives in the profile
|
||||
// feature).
|
||||
import { IngredientPicker } from "../recipes/IngredientPicker";
|
||||
import "../recipes/recipes.scss";
|
||||
import "./profile-forms.scss";
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ export function DislikedIngredientsField({
|
|||
))}
|
||||
</ul>
|
||||
)}
|
||||
<IngredientAutocomplete ingredients={ingredients} excludeIds={value} onSelect={add} />
|
||||
<IngredientPicker ingredients={ingredients} excludeIds={value} onSelect={add} />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
import type { IngredientView } from "@batch-cooking/shared";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** Caps how many matches are shown at once — the reference list is small, but an unbounded dropdown would still be unwieldy for a broad query like a single letter. */
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
|
||||
/**
|
||||
* Text field over the static reference ingredient list (`GET
|
||||
* /reference/ingredients`) — receives the full list as a prop rather than
|
||||
* fetching it itself, same rationale as `AllergySelect`/`DietSelect`, and
|
||||
* filters it client-side as the user types: it's small, non-administrable
|
||||
* reference data, no dedicated search endpoint needed. `excludeIds` (the
|
||||
* ingredients already on the recipe) keeps the same one from being added
|
||||
* twice.
|
||||
*/
|
||||
export function IngredientAutocomplete({
|
||||
ingredients,
|
||||
excludeIds,
|
||||
onSelect,
|
||||
}: {
|
||||
ingredients: IngredientView[];
|
||||
excludeIds: number[];
|
||||
onSelect: (ingredient: IngredientView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const suggestions =
|
||||
normalizedQuery.length === 0
|
||||
? []
|
||||
: ingredients
|
||||
.filter(
|
||||
(ingredient) =>
|
||||
!excludeIds.includes(ingredient.id) &&
|
||||
ingredient.name.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
.slice(0, MAX_SUGGESTIONS);
|
||||
|
||||
function handleSelect(ingredient: IngredientView) {
|
||||
onSelect(ingredient);
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ingredient-autocomplete">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("recipes.form.addIngredientPlaceholder")}
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<ul className="ingredient-autocomplete__suggestions">
|
||||
{suggestions.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
<button type="button" onClick={() => handleSelect(ingredient)}>
|
||||
<span aria-hidden="true">{ingredient.icon}</span>
|
||||
<span className="ingredient-autocomplete__name">{ingredient.name}</span>
|
||||
<AllergenBadges allergens={ingredient.allergens} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
apps/web/src/features/recipes/IngredientPicker.tsx
Normal file
109
apps/web/src/features/recipes/IngredientPicker.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import {
|
||||
INGREDIENT_CATEGORIES,
|
||||
type IngredientCategory,
|
||||
type IngredientView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** "No category filter" — a UI-only pseudo-category, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES} for the real, closed set). */
|
||||
const ALL_CATEGORIES = "ALL" as const;
|
||||
|
||||
/**
|
||||
* Browsable ingredient picker — category chips + search + a card grid,
|
||||
* replacing the earlier `IngredientAutocomplete` (a plain type-to-filter
|
||||
* dropdown). With 400+ reference ingredients, search alone doesn't scale to
|
||||
* actually *finding* one — category browsing is the main fix; search still
|
||||
* narrows within (or across) categories for when the name is already known.
|
||||
*
|
||||
* Receives `ingredients` as a prop rather than fetching them itself — same
|
||||
* rationale as `AllergySelect`/`DietSelect`. `excludeIds` (already-selected
|
||||
* ingredients — a recipe's ingredient list, or a profile's disliked list)
|
||||
* keeps the same one from being added twice.
|
||||
*/
|
||||
export function IngredientPicker({
|
||||
ingredients,
|
||||
excludeIds,
|
||||
onSelect,
|
||||
}: {
|
||||
ingredients: IngredientView[];
|
||||
excludeIds: number[];
|
||||
onSelect: (ingredient: IngredientView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [category, setCategory] = useState<IngredientCategory | typeof ALL_CATEGORIES>(
|
||||
ALL_CATEGORIES,
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const visible = ingredients.filter((ingredient) => {
|
||||
if (excludeIds.includes(ingredient.id)) return false;
|
||||
if (category !== ALL_CATEGORIES && ingredient.category !== category) return false;
|
||||
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function handleSelect(ingredient: IngredientView) {
|
||||
onSelect(ingredient);
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ingredient-picker">
|
||||
<div className="ingredient-picker__search">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("recipes.form.searchIngredientPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ingredient-picker__categories">
|
||||
<button
|
||||
type="button"
|
||||
className={`ingredient-picker__category${category === ALL_CATEGORIES ? " active" : ""}`}
|
||||
onClick={() => setCategory(ALL_CATEGORIES)}
|
||||
>
|
||||
{t("recipes.form.allCategories")}
|
||||
</button>
|
||||
{INGREDIENT_CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`ingredient-picker__category${category === c ? " active" : ""}`}
|
||||
onClick={() => setCategory(c)}
|
||||
>
|
||||
{t(`recipes.form.category.${c}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
|
||||
) : (
|
||||
<div className="ingredient-picker__grid">
|
||||
{visible.map((ingredient) => (
|
||||
<button
|
||||
key={ingredient.id}
|
||||
type="button"
|
||||
className="ingredient-picker__card"
|
||||
onClick={() => handleSelect(ingredient)}
|
||||
>
|
||||
<span className="ingredient-picker__card-icon" aria-hidden="true">
|
||||
{ingredient.icon}
|
||||
</span>
|
||||
<span className="ingredient-picker__card-name">{ingredient.name}</span>
|
||||
<AllergenBadges allergens={ingredient.allergens} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientAutocomplete`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
|
||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
|
||||
export function IngredientRow({
|
||||
ingredient,
|
||||
quantity,
|
||||
|
|
|
|||
|
|
@ -140,11 +140,17 @@
|
|||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
// Never lets a tab overflow the page (which would force the whole body
|
||||
// to scroll horizontally, see global.scss's rule against that) — scrolls
|
||||
// within itself instead once the tabs (including the disabled "Sources"
|
||||
// placeholder) don't all fit, same pattern as the sidebar's own nav.
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
&__tab {
|
||||
appearance: none;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
|
|
@ -661,62 +667,109 @@
|
|||
}
|
||||
}
|
||||
|
||||
// --- Ingredient autocomplete (recipe form + disliked-ingredients field) -----
|
||||
.ingredient-autocomplete {
|
||||
position: relative;
|
||||
// --- Ingredient picker (recipe form + disliked-ingredients field) ----------
|
||||
// Always-visible category chips + search + card grid — replaces the earlier
|
||||
// type-to-filter dropdown (`.ingredient-autocomplete`, now gone), which
|
||||
// stopped scaling once the reference list passed a couple hundred items.
|
||||
// The category row uses the same "scroll, don't crush" pattern as the app
|
||||
// sidebar nav (see AppLayout.scss's mobile breakpoint): chips never shrink
|
||||
// below a tappable size, the row scrolls horizontally instead.
|
||||
.ingredient-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
|
||||
> input {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__suggestions {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-xs) 0 0;
|
||||
padding: var(--space-xs);
|
||||
list-style: none;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
&__search {
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: left;
|
||||
border: none;
|
||||
font-size: var(--font-size-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: none;
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-right: auto;
|
||||
&__categories {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
&__category {
|
||||
flex-shrink: 0;
|
||||
appearance: none;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
|
||||
gap: var(--space-xs);
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
&__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
}
|
||||
|
||||
&__card-icon {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__card-name {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -305,6 +305,17 @@
|
|||
// viewport (relevant early given this app is meant to be embedded via
|
||||
// Capacitor later, see the root README) — collapse it into a horizontal
|
||||
// top bar instead of a side rail.
|
||||
//
|
||||
// The primary nav (Planning/Recettes/Liste de courses) must stay fully
|
||||
// legible and tappable at any width — it's the app's main navigation, not
|
||||
// optional chrome. Without `&__nav { min-width: 0 }` + `a { flex-shrink: 0
|
||||
// }` below, `&__settings`/`&__footer`'s own natural (non-shrinking) width
|
||||
// silently crushed it down to ~16px unlabeled slivers on a narrow phone
|
||||
// (measured on a 375px viewport) — invisible labels, no real tap target.
|
||||
// The fix: `__settings`/`__footer` collapse to icon-only instead (same
|
||||
// look as the desktop rail's `.collapsed` state), freeing width for the
|
||||
// nav, which falls back to horizontal scroll (`overflow-x: auto`) rather
|
||||
// than shrinking if it still doesn't fit.
|
||||
@media (max-width: 640px) {
|
||||
.app-layout {
|
||||
flex-direction: column;
|
||||
|
|
@ -326,15 +337,75 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
// Nothing to collapse into on a horizontal bar — there's no rail.
|
||||
&__collapse-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
|
||||
a {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__settings,
|
||||
&__footer {
|
||||
position: relative;
|
||||
flex: none;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&__settings-toggle,
|
||||
&__account-toggle {
|
||||
padding-left: var(--space-xs);
|
||||
padding-right: var(--space-xs);
|
||||
}
|
||||
|
||||
&__settings-toggle-left .label,
|
||||
&__account-toggle .label,
|
||||
&__settings-toggle .chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Both reveals become floating panels anchored under their icon-only
|
||||
// toggle — on the desktop rail one is inline (settings, stacks fine in
|
||||
// a column) and the other already floats (account); neither can stay
|
||||
// in normal flow on this horizontal bar without breaking the row.
|
||||
// Selector order matters: `&__settings-nav` also carries the plain
|
||||
// `&__nav` class (for the link styling), so this must come after it to
|
||||
// win on `flex-direction`/`overflow-x`.
|
||||
&__settings-nav {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-xs));
|
||||
right: 0;
|
||||
flex-direction: column;
|
||||
width: 14rem;
|
||||
padding: var(--space-xs);
|
||||
overflow-x: visible;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
// Desktop opens this *upward* (`bottom: calc(100% + ...)`, see the base
|
||||
// rule above) because the footer sits at the bottom of a tall rail —
|
||||
// on this horizontal top bar the footer is near `y: 0`, so "upward"
|
||||
// pushed the menu entirely off-screen above the viewport. Flip it to
|
||||
// open downward here instead.
|
||||
&__account-menu {
|
||||
top: calc(100% + var(--space-xs));
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
right: 0;
|
||||
width: 12rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,7 +165,29 @@
|
|||
"PUBLIC": "Tout le monde"
|
||||
},
|
||||
"dietsLabel": "Régime(s) associé(s)",
|
||||
"addIngredientPlaceholder": "Ajouter un ingrédient…",
|
||||
"searchIngredientPlaceholder": "Rechercher un ingrédient…",
|
||||
"allCategories": "Tout",
|
||||
"noIngredientFound": "Aucun ingrédient trouvé.",
|
||||
"category": {
|
||||
"CEREALES_FECULENTS": "Céréales & féculents",
|
||||
"LEGUMINEUSES": "Légumineuses",
|
||||
"VIANDES_VOLAILLES": "Viandes & volailles",
|
||||
"POISSONS_FRUITS_DE_MER": "Poissons & fruits de mer",
|
||||
"PRODUITS_LAITIERS_OEUFS": "Produits laitiers & œufs",
|
||||
"LEGUMES": "Légumes",
|
||||
"FRUITS": "Fruits",
|
||||
"FRUITS_SECS_OLEAGINEUX": "Fruits secs & oléagineux",
|
||||
"CONDIMENTS_SAUCES": "Condiments & sauces",
|
||||
"EPICES_HERBES": "Épices & herbes",
|
||||
"SUCRE_PATISSERIE": "Sucre & pâtisserie",
|
||||
"CUISINE_ITALIENNE": "Cuisine italienne",
|
||||
"CUISINE_ASIATIQUE": "Cuisine asiatique",
|
||||
"CUISINE_MEXICAINE": "Cuisine mexicaine",
|
||||
"MAGHREB_MOYEN_ORIENT": "Maghreb & Moyen-Orient",
|
||||
"PAINS_SANDWICHS": "Pains & sandwichs",
|
||||
"EPICERIE_DIVERS": "Épicerie & divers",
|
||||
"LIQUIDES_BOISSONS": "Liquides & boissons"
|
||||
},
|
||||
"quantityLabel": "Quantité",
|
||||
"unitLabel": "Unité",
|
||||
"unitPlaceholder": "g, ml, unité…",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ApiError, apiClient } from "../api/client";
|
||||
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
||||
import { IngredientAutocomplete } from "../features/recipes/IngredientAutocomplete";
|
||||
import { IngredientPicker } from "../features/recipes/IngredientPicker";
|
||||
import { IngredientRow } from "../features/recipes/IngredientRow";
|
||||
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
||||
import "../features/recipes/recipes.scss";
|
||||
|
|
@ -251,7 +251,7 @@ export function RecipeFormPage() {
|
|||
/>
|
||||
))}
|
||||
</ul>
|
||||
<IngredientAutocomplete
|
||||
<IngredientPicker
|
||||
ingredients={ingredientsCatalog}
|
||||
excludeIds={selectedIds}
|
||||
onSelect={addIngredient}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,37 @@ export interface AllergyView {
|
|||
kind: AllergenKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse ingredient grouping (viandes, légumes, épices...) — mirrors
|
||||
* `IngredientCategory` in schema.prisma, declared by hand for the same
|
||||
* reason as {@link AllergenKind}. Lets the ingredient picker (`apps/web`'s
|
||||
* `IngredientPicker`) offer category browsing, not just free-text search:
|
||||
* with 400+ reference ingredients, search alone doesn't scale to actually
|
||||
* *finding* one.
|
||||
*/
|
||||
export const INGREDIENT_CATEGORIES = [
|
||||
"CEREALES_FECULENTS",
|
||||
"LEGUMINEUSES",
|
||||
"VIANDES_VOLAILLES",
|
||||
"POISSONS_FRUITS_DE_MER",
|
||||
"PRODUITS_LAITIERS_OEUFS",
|
||||
"LEGUMES",
|
||||
"FRUITS",
|
||||
"FRUITS_SECS_OLEAGINEUX",
|
||||
"CONDIMENTS_SAUCES",
|
||||
"EPICES_HERBES",
|
||||
"SUCRE_PATISSERIE",
|
||||
"CUISINE_ITALIENNE",
|
||||
"CUISINE_ASIATIQUE",
|
||||
"CUISINE_MEXICAINE",
|
||||
"MAGHREB_MOYEN_ORIENT",
|
||||
"PAINS_SANDWICHS",
|
||||
"EPICERIE_DIVERS",
|
||||
"LIQUIDES_BOISSONS",
|
||||
] as const;
|
||||
/** Inferred TS type for one {@link INGREDIENT_CATEGORIES} member. */
|
||||
export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
||||
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
||||
|
|
@ -49,5 +80,6 @@ export interface IngredientView {
|
|||
id: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
category: IngredientCategory;
|
||||
allergens: AllergyView[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue