import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { apiClient } from "../../api/client"; import { RecipeDetailPanel, type RecipeDetailState } from "./RecipeDetailPanel"; import { SourceItemTable } from "./SourceItemTable"; import "./recipes.scss"; /** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */ const SEARCH_DEBOUNCE_MS = 300; /** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */ export interface SourceItemSelection { sourceKey: string; externalId: string; } type EnabledSourcesState = | { status: "loading" } | { status: "loaded"; sources: SourceView[] } | { status: "error" }; type BrowseState = | { status: "loading" } | { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null } | { status: "error" }; /** * "Sources" tab content of the recipe catalog (`RecipesPage`) — a * self-contained master-detail pair of its own (source selector + browsable * list on the left, a preview on the right), independent of `RecipeTable`'s * own `RecipeTab`-based fetching: it browses a source's *live* catalog * (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table. * * The right-hand preview reuses `RecipeDetailPanel` itself (its * `"loaded-draft"` state) rather than a separate component — viewing a * not-yet-imported item is meant to look and feel exactly like viewing any * other recipe, differing only in which actions are offered (there's an * "Importer" button where Modifier/Supprimer would be). * * Selecting an already-imported item navigates straight to its real * recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe` * hands back the id instead of this panel navigating anywhere itself, since * what "viewing" an already-imported item means depends on the caller: * `RecipesPage` switches its own active tab away from `"sources"` (its * `RecipeDetailPanel`/`RecipeTable` only render outside that tab, so * without switching first the URL would change but this panel would keep * rendering over it) and navigates to the recipe's detail page, while * `RecipePickerDialog` instead treats it exactly like picking that recipe * from one of the regular tabs — moving to its own confirm-portions step, * no navigation at all. * * `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a * not-yet-imported item's preview addressable by URL * (`/recettes/sources/:sourceKey/:externalId`) without this panel needing * to know anything about routing itself — it reports selection changes * upward, and re-previews on mount/prop-change if handed one back. * `RecipePickerDialog` leaves both unset: previewing inside that modal has * no URL of its own to keep in sync. */ export function RecipeSourcesPanel({ onSelectImportedRecipe, planningSlot, initialSelection, onItemSelected, }: { onSelectImportedRecipe: (recipeId: number) => void; /** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */ planningSlot?: { date: string; weekDay: WeekDay; meal: Meal }; initialSelection?: SourceItemSelection; onItemSelected?: (item: SourceItemSelection | null) => void; }) { const { t } = useTranslation(); const [enabledSources, setEnabledSources] = useState({ status: "loading" }); const [selectedSourceKey, setSelectedSourceKey] = useState( initialSelection?.sourceKey ?? null, ); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [browseState, setBrowseState] = useState({ status: "loading" }); const [selectedExternalId, setSelectedExternalId] = useState( initialSelection?.externalId ?? null, ); const [previewState, setPreviewState] = useState({ status: "empty" }); // Which item `previewState` actually reflects (or is in flight for) — // lets the `initialSelection` effect below tell "the URL just changed to // match a selection this component already made itself" (a row click // already fetched/is fetching this exact item; `onItemSelected` only // round-trips that same pair back in as a new `initialSelection` prop) // apart from "the URL changed to point somewhere new" (a deep link, or // the browser's back/forward button) — only the latter needs a fetch. // Always starts at `null`, even when `initialSelection` is already set on // mount — nothing has been fetched yet at that point, that's exactly the // "needs a fetch" case the effect below must still run for. const [previewedItem, setPreviewedItem] = useState(null); // Loaded once — which sources exist, crossed with which the household // has enabled (`/parametres/foyer`). Defaults the selector to the first // enabled one, if any — but never overrides a source `initialSelection` // already picked (the `current ??` below), so a deep link always wins. useEffect(() => { let cancelled = false; Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()]) .then(([sources, enabledIds]) => { if (cancelled) return; const enabled = sources.filter((source) => enabledIds.includes(source.id)); setEnabledSources({ status: "loaded", sources: enabled }); setSelectedSourceKey((current) => current ?? enabled[0]?.key ?? null); }) .catch(() => { if (!cancelled) setEnabledSources({ status: "error" }); }); return () => { cancelled = true; }; }, []); // Re-previews whenever `initialSelection` itself changes (a fresh deep // link, or the browser's back/forward button landing on a different // item) — not just once on mount. Deliberately doesn't touch // `browseState`/the source dropdown beyond `selectedSourceKey` above: the // item list for whichever source this belongs to loads independently // (see the effect below), on its own schedule. Keyed on the primitive // fields below, not `initialSelection` itself — a fresh object literal // from the caller on every render (see `RecipesPage`) would otherwise // re-run this on every render too. // biome-ignore lint/correctness/useExhaustiveDependencies: see above. useEffect(() => { if (!initialSelection) return; if ( previewedItem?.sourceKey === initialSelection.sourceKey && previewedItem?.externalId === initialSelection.externalId ) { return; } let cancelled = false; setPreviewedItem(initialSelection); setSelectedSourceKey(initialSelection.sourceKey); setSelectedExternalId(initialSelection.externalId); setPreviewState({ status: "loading" }); apiClient .previewSourceItem(initialSelection.sourceKey, initialSelection.externalId) .then((draft) => { if (!cancelled) setPreviewState({ status: "loaded-draft", draft }); }) .catch(() => { if (!cancelled) setPreviewState({ status: "error" }); }); return () => { cancelled = true; }; }, [initialSelection?.sourceKey, initialSelection?.externalId]); useEffect(() => { const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(timeout); }, [search]); // Only manages `browseState` — deliberately doesn't reset the current // item selection/preview when `selectedSourceKey` changes, so that the // `initialSelection` effect above (which also sets `selectedSourceKey`, // to reflect a deep link) isn't immediately undone by this one running // straight after it in the same commit. Switching source via the // dropdown clears the selection explicitly, in its own `onChange` below, // where that reset is actually wanted. useEffect(() => { if (selectedSourceKey === null) return; let cancelled = false; setBrowseState({ status: "loading" }); apiClient .browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined }) .then(({ items, nextCursor }) => { if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor }); }) .catch(() => { if (!cancelled) setBrowseState({ status: "error" }); }); return () => { cancelled = true; }; }, [selectedSourceKey, debouncedSearch]); function handleLoadMore() { if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) { return; } const cursor = browseState.nextCursor; apiClient .browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor }) .then(({ items, nextCursor }) => { setBrowseState((prev) => prev.status === "loaded" ? { status: "loaded", items: [...prev.items, ...items], nextCursor } : prev, ); }) .catch(() => setBrowseState({ status: "error" })); } function handleSelectItem(item: BrowsableSourceItemView) { if (item.alreadyImported && item.recipeId !== null) { onSelectImportedRecipe(item.recipeId); return; } if (selectedSourceKey === null) return; const selection = { sourceKey: selectedSourceKey, externalId: item.externalId }; setSelectedExternalId(item.externalId); setPreviewState({ status: "loading" }); // Set before `onItemSelected` so the `initialSelection` effect above // recognizes the URL change it triggers as reflecting this same fetch, // not a fresh one to make (see that effect's own doc comment). setPreviewedItem(selection); onItemSelected?.(selection); apiClient .previewSourceItem(selectedSourceKey, item.externalId) .then((draft) => setPreviewState({ status: "loaded-draft", draft })) .catch(() => setPreviewState({ status: "error" })); } if (enabledSources.status === "loading") { return

{t("recipes.loading")}

; } if (enabledSources.status === "error") { return (

{t("common.loadError")}

); } if (enabledSources.sources.length === 0) { return (

{t("recipes.sources.noneEnabled")}{" "} {t("recipes.sources.noneEnabledLink")}

); } return ( <>
{enabledSources.sources.length > 1 && ( )} setSearch(e.target.value)} />
{browseState.status === "loading" && (

{t("recipes.sources.loading")}

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

{t("recipes.sources.loadError")}

)} {browseState.status === "loaded" && browseState.items.length === 0 && (

{t("recipes.sources.empty")}

)} {browseState.status === "loaded" && browseState.items.length > 0 && (
{browseState.nextCursor && ( )}
)}
); }