Remplace la FK morte `Ingredient.alternateRecipeId` (jamais branchée nulle part — confirmé par exploration : zéro usage en dehors de schema.prisma) par un flag booléen `reproducible`, plus simple : pas de liaison recette↔ingrédient en base, juste une info "ça vaut le coup d'être fait maison" plus un raccourci de recherche. - Migration : drop `alternate_recipe` (colonne + FK), ajoute `reproducible BOOLEAN NOT NULL DEFAULT false` sur `ingredients`. - `reference-seed-data.ts` : `IngredientSeed` gagne `reproducible?`, threadé dans le flatten + la réconciliation `seedReferenceData`. Premier lot de 27 ingrédients marqués (pains, pâtes à cuire, sauces de base, bouillons/fonds) — même logique que la curation Ciqual : un lot solide plutôt qu'exhaustif sur les 546 ingrédients. - `IngredientView` (shared) + les deux endroits qui la construisent (`reference.service.ts`, `recipe.service.ts`) gagnent `reproducible`. - `ReproducibleBadge` (nouveau) : pastille "Faisable maison" — simple dans `IngredientPicker` (avec son propre toggle d'affichage), lien cliquable dans `IngredientRow` vers `/recettes?search=<nom>` ouvert dans un nouvel onglet (pour ne jamais perdre le formulaire de recette en cours — pas de persistance de brouillon dans `RecipeFormPage`). - `RecipesPage` lit `?search=` au montage pour permettre ce deep-link. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
183 lines
7 KiB
TypeScript
183 lines
7 KiB
TypeScript
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import { ApiError, apiClient } from "../api/client";
|
|
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
|
import { RecipeTable } from "../features/recipes/RecipeTable";
|
|
import { RecipeTabs } from "../features/recipes/RecipeTabs";
|
|
import "../features/recipes/recipes.scss";
|
|
|
|
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
|
|
/** Load state for the catalog table (`GET /recipes?tab=...`) — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented, same pattern as `PlanningPage`'s `PlanningState`. */
|
|
type RecipeListState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; recipes: RecipeSummaryView[] }
|
|
| { status: "error" };
|
|
|
|
/**
|
|
* Recipe catalog — routed at both `/recettes` and `/recettes/:id` (the same
|
|
* component either way, see `App.tsx`): a tab bar + table on the left stay
|
|
* mounted at all times, only the right-hand detail panel changes with the
|
|
* `:id` param — a master-detail layout, not a navigation to a separate
|
|
* page (see `RecipeDetailPanel`, which replaces the earlier standalone
|
|
* `RecipeDetailPage`).
|
|
*/
|
|
export function RecipesPage() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const { id } = useParams<{ id: string }>();
|
|
const selectedId = id !== undefined ? Number(id) : null;
|
|
// `?search=` lets another page (the recipe form's "faisable maison"
|
|
// badge, see `ReproducibleBadge`) deep-link straight into a pre-filled
|
|
// search — read once on mount, not kept in sync on every keystroke
|
|
// afterwards (this page doesn't own the URL the way e.g. a shareable
|
|
// filter view would).
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
|
|
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
|
|
// Seeded from the same initial value as `search` — otherwise the first
|
|
// fetch below would fire with an empty term (the debounce effect hasn't
|
|
// run yet), then a second one 300ms later once it catches up.
|
|
const [debouncedSearch, setDebouncedSearch] = useState(() => searchParams.get("search") ?? "");
|
|
const [listState, setListState] = useState<RecipeListState>({ status: "loading" });
|
|
const [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
|
|
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
|
|
|
|
useEffect(() => {
|
|
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [search]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setListState({ status: "loading" });
|
|
|
|
apiClient
|
|
.listRecipes(activeTab, { search: debouncedSearch.trim() || undefined })
|
|
.then((recipes) => {
|
|
if (!cancelled) setListState({ status: "loaded", recipes });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setListState({ status: "error" });
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeTab, debouncedSearch]);
|
|
|
|
useEffect(() => {
|
|
if (selectedId === null) {
|
|
setDetailState({ status: "empty" });
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setDetailState({ status: "loading" });
|
|
|
|
apiClient
|
|
.getRecipe(selectedId)
|
|
.then((recipe) => {
|
|
if (!cancelled) setDetailState({ status: "loaded", recipe });
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
if (err instanceof ApiError && err.code === ErrorCode.RECIPE_NOT_FOUND) {
|
|
setDetailState({ status: "not-found" });
|
|
} else {
|
|
setDetailState({ status: "error" });
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [selectedId]);
|
|
|
|
// The viewer's personal "disliked" list only changes from the
|
|
// preferences page, never from here — loaded once, not re-fetched on
|
|
// every tab/selection change.
|
|
useEffect(() => {
|
|
apiClient
|
|
.getDislikedIngredientIds()
|
|
.then(setDislikedIngredientIds)
|
|
.catch(() => setDislikedIngredientIds([]));
|
|
}, []);
|
|
|
|
/** Keeps the table row's fav-mark and the `favoris` tab's membership in sync with a toggle made from the detail panel, without a full reload. */
|
|
function handleFavoriteToggled(recipeId: number, isFavorite: boolean) {
|
|
setDetailState((prev) =>
|
|
prev.status === "loaded" && prev.recipe.id === recipeId
|
|
? { status: "loaded", recipe: { ...prev.recipe, isFavorite } }
|
|
: prev,
|
|
);
|
|
setListState((prev) => {
|
|
if (prev.status !== "loaded") return prev;
|
|
const recipes = prev.recipes
|
|
.map((recipe) => (recipe.id === recipeId ? { ...recipe, isFavorite } : recipe))
|
|
.filter((recipe) => activeTab !== "favoris" || recipe.isFavorite);
|
|
return { status: "loaded", recipes };
|
|
});
|
|
}
|
|
|
|
/** After a delete, the removed recipe can no longer be selected, and the table must drop it too. */
|
|
function handleDeleted(recipeId: number) {
|
|
navigate("/recettes");
|
|
setListState((prev) =>
|
|
prev.status === "loaded"
|
|
? { status: "loaded", recipes: prev.recipes.filter((r) => r.id !== recipeId) }
|
|
: prev,
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="recipes-page">
|
|
<div className="recipes-page__header">
|
|
<h1>{t("recipes.title")}</h1>
|
|
<input
|
|
type="search"
|
|
className="recipes-page__search"
|
|
placeholder={t("recipes.searchPlaceholder")}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
|
{t("recipes.newButton")}
|
|
</Link>
|
|
</div>
|
|
|
|
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
|
|
|
<div className="recipes-page__catalog">
|
|
{listState.status === "loading" && (
|
|
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
|
)}
|
|
{listState.status === "error" && (
|
|
<p className="recipes-page__status recipes-page__status--error">
|
|
{t("common.loadError")}
|
|
</p>
|
|
)}
|
|
{listState.status === "loaded" && listState.recipes.length === 0 && (
|
|
<p className="recipes-page__status">{t("recipes.empty")}</p>
|
|
)}
|
|
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
|
<RecipeTable
|
|
recipes={listState.recipes}
|
|
selectedId={selectedId}
|
|
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
|
|
/>
|
|
)}
|
|
|
|
<RecipeDetailPanel
|
|
state={detailState}
|
|
dislikedIngredientIds={dislikedIngredientIds}
|
|
onFavoriteToggled={handleFavoriteToggled}
|
|
onDeleted={handleDeleted}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|