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 { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel"; 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; 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, `SourceItemPreviewPanel` on the right), independent of * `RecipeTable`/`RecipeDetailPanel`: it browses a source's *live* catalog * (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table, so it * doesn't share their `RecipeTab`-based fetching at all. * * Selecting an already-imported item navigates straight to its real * recipe (`/recettes/:id`, leaving this tab) — selecting one that isn't * imported yet shows a read-only preview here instead. Turning that * preview into an actual saved recipe (reviewing/fixing unresolved * ingredients first) is a later stage of the same plan, not built here. * * `onSelectImportedRecipe` hands back the id instead of this panel * navigating anywhere itself — 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. */ export function RecipeSourcesPanel({ onSelectImportedRecipe, planningSlot, }: { onSelectImportedRecipe: (recipeId: number) => void; /** Forwarded as-is to `SourceItemPreviewPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */ planningSlot?: { date: string; weekDay: WeekDay; meal: Meal }; }) { const { t } = useTranslation(); const [enabledSources, setEnabledSources] = useState({ status: "loading" }); const [selectedSourceKey, setSelectedSourceKey] = useState(null); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [browseState, setBrowseState] = useState({ status: "loading" }); const [selectedExternalId, setSelectedExternalId] = useState(null); const [previewState, setPreviewState] = useState({ status: "empty" }); // Loaded once — which sources exist, crossed with which the household // has enabled (`/parametres/foyer`). Defaults the selector to the first // enabled one, if any. 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; }; }, []); useEffect(() => { const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(timeout); }, [search]); useEffect(() => { if (selectedSourceKey === null) return; let cancelled = false; setBrowseState({ status: "loading" }); setSelectedExternalId(null); setPreviewState({ status: "empty" }); 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; setSelectedExternalId(item.externalId); setPreviewState({ status: "loading" }); apiClient .previewSourceItem(selectedSourceKey, item.externalId) .then((draft) => setPreviewState({ status: "loaded", 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 && ( )}
)}
); }