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("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({ status: "loading" }); const [detailState, setDetailState] = useState({ status: "empty" }); const [dislikedIngredientIds, setDislikedIngredientIds] = useState([]); 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 (

{t("recipes.title")}

setSearch(e.target.value)} /> {t("recipes.newButton")}
{listState.status === "loading" && (

{t("recipes.loading")}

)} {listState.status === "error" && (

{t("common.loadError")}

)} {listState.status === "loaded" && listState.recipes.length === 0 && (

{t("recipes.empty")}

)} {listState.status === "loaded" && listState.recipes.length > 0 && ( navigate(`/recettes/${recipeId}`)} /> )}
); }