import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; 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; /** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */ const LOAD_MORE_PLACEHOLDER_COUNT = 4; /** One page of `sourceKey`'s browsable catalog, as returned by `apiClient.browseSource`. */ interface BrowsePage { items: BrowsableSourceItemView[]; nextCursor: string | null; } /** 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 BrowseState = | { status: "loading" } | { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null } | { status: "error" }; /** * One household-enabled source's own tab content in the recipe catalog * (`RecipesPage`/`RecipePickerDialog`) — a master-detail pair of its own * (browsable list on the left, a preview on the right), independent of * `RecipeTable`'s own `RecipeTab`-based fetching: it browses this one * source's *live* catalog (`GET /sources/:sourceKey/browse`), not the * saved `Recipe` table. * * Scoped to exactly one source — every enabled source gets its own tab * now (`RecipeTabs`), rather than a single generic "Sources" tab * switching between them internally, so `sourceKey` is a fixed prop, not * something this component ever changes itself. Callers remount this * (via a React `key={sourceKey}` on it) when switching which source's tab * is active, the same "mounted only while relevant" convention as * `RecipePickerDialog`/`CalendarPopover` elsewhere — simpler than this * component reacting to its own `sourceKey` prop changing mid-lifetime. * * The right-hand preview reuses `RecipeDetailPanel` itself — for a * not-yet-imported item, its `"loaded-draft"` state; for an already- * imported one, this panel fetches the real thing (`GET /recipes/:id`) * and shows it through the exact same `"loaded"` state `RecipesPage` uses, * `showActions={false}` since editing/deleting isn't a click that belongs * on a browsing/preview screen. Either way, selecting a row only ever * previews here — nothing about clicking one imports, saves, or navigates * by itself; what a selection *means* is entirely up to the caller * (`onSelectImportedRecipe`/`onDraftSelected` below just report which one * is currently previewed). * * `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 dialog * has no URL of its own to keep in sync. */ export function RecipeSourcesPanel({ sourceKey, onSelectImportedRecipe, onDraftSelected, initialSelection, onItemSelected, }: { sourceKey: string; /** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */ onSelectImportedRecipe: (recipe: RecipeView) => void; /** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */ onDraftSelected?: (selection: SourceItemSelection) => void; initialSelection?: SourceItemSelection; onItemSelected?: (item: SourceItemSelection | null) => void; }) { const { t } = useTranslation(); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [browseState, setBrowseState] = useState({ status: "loading" }); const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle"); // The *next* page, fetched ahead of time as soon as the current one is on // screen (see the effect below) — a ref, not state, since it's an // implementation detail `handleLoadMore` consumes, never itself rendered. // Keyed on exactly what makes a prefetch valid to reuse (source/query/ // cursor all matching) rather than just "is something in flight", so a // stale prefetch from before a search/source change is never mistaken for // the page that's actually needed next. const nextPagePrefetchRef = useRef<{ sourceKey: string; query: string; cursor: string; promise: Promise; } | null>(null); // Re-entrancy guard for `handleLoadMore` — infinite scroll (unlike a // button `onClick`) can call it again before the previous call has // settled (e.g. the sentinel row is still intersecting when the observer // re-evaluates after a layout shift). A ref, not `loadMoreStatus`: that // state only exists to drive what's rendered and is read from React's // closure at call time, which would still read the *previous* render's // (stale) value inside a handler fired synchronously off a fresh // browser event — this needs to be checked/set immediately and // synchronously, which only a ref does correctly here. const isLoadingMoreRef = useRef(false); 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); // Re-previews whenever `initialSelection` itself changes (a fresh deep // link, or the browser's back/forward button landing on a different // item within this same source) — not just once on mount. Keyed on the // primitive field 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?.externalId === initialSelection.externalId) return; let cancelled = false; setPreviewedItem(initialSelection); setSelectedExternalId(initialSelection.externalId); setPreviewState({ status: "loading" }); apiClient .previewSourceItem(sourceKey, initialSelection.externalId) .then((draft) => { if (!cancelled) setPreviewState({ status: "loaded-draft", draft }); }) .catch(() => { if (!cancelled) setPreviewState({ status: "error" }); }); return () => { cancelled = true; }; }, [initialSelection?.externalId]); useEffect(() => { const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(timeout); }, [search]); useEffect(() => { let cancelled = false; setBrowseState({ status: "loading" }); // A fresh search/source is a fresh list — any in-flight "load more" or // stale prefetch from the *previous* one no longer applies to anything. setLoadMoreStatus("idle"); nextPagePrefetchRef.current = null; isLoadingMoreRef.current = false; apiClient .browseSource(sourceKey, { query: debouncedSearch.trim() || undefined }) .then(({ items, nextCursor }) => { if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor }); }) .catch(() => { if (!cancelled) setBrowseState({ status: "error" }); }); return () => { cancelled = true; }; }, [sourceKey, debouncedSearch]); // Prefetches the page after the one currently on screen, so scrolling // near the bottom (SourceItemTable's sentinel row, which calls // handleLoadMore) usually just swaps in data that's already arrived // instead of starting a fresh round-trip right when someone's waiting on // it — `handleLoadMore` below reuses this when it matches. Re-runs on every // `browseState` change, so a load-more that appends a new page and a new // `nextCursor` immediately kicks off prefetching the page *after* that // one too, keeping the panel permanently one page ahead of what's shown. useEffect(() => { if (browseState.status !== "loaded" || !browseState.nextCursor) return; const cursor = browseState.nextCursor; const query = debouncedSearch.trim(); const already = nextPagePrefetchRef.current; if ( already && already.sourceKey === sourceKey && already.query === query && already.cursor === cursor ) { return; // already prefetching/prefetched exactly this page } const promise = apiClient.browseSource(sourceKey, { query: query || undefined, cursor }); nextPagePrefetchRef.current = { sourceKey, query, cursor, promise }; // A failed prefetch is swallowed here on purpose — nobody's actually // waiting on it yet. If `handleLoadMore` later reuses this same promise // it awaits/catches the rejection itself at that point; if it's never // reused (the prefetch just goes stale), this `.catch()` only exists to // keep the rejection from surfacing as an unhandled one. promise.catch(() => {}); }, [browseState, sourceKey, debouncedSearch]); function handleLoadMore() { if (browseState.status !== "loaded" || !browseState.nextCursor) { return; } if (isLoadingMoreRef.current) { return; // already fetching this exact next page — see the ref's own doc comment } isLoadingMoreRef.current = true; const cursor = browseState.nextCursor; const query = debouncedSearch.trim(); setLoadMoreStatus("loading"); const prefetch = nextPagePrefetchRef.current; const request = prefetch && prefetch.sourceKey === sourceKey && prefetch.query === query && prefetch.cursor === cursor ? prefetch.promise : apiClient.browseSource(sourceKey, { query: query || undefined, cursor }); request .then(({ items, nextCursor }) => { nextPagePrefetchRef.current = null; isLoadingMoreRef.current = false; setBrowseState((prev) => prev.status === "loaded" ? { status: "loaded", items: [...prev.items, ...items], nextCursor } : prev, ); setLoadMoreStatus("idle"); }) .catch(() => { // Also clears a failed *prefetch*, not just a failed manual retry — // otherwise a rejected promise would sit in the ref forever, and // "Réessayer" would just keep reusing (and re-rejecting on) that // same dead promise instead of ever making a fresh request. nextPagePrefetchRef.current = null; isLoadingMoreRef.current = false; setLoadMoreStatus("error"); }); } function handleSelectItem(item: BrowsableSourceItemView) { if (item.alreadyImported && item.recipeId !== null) { setSelectedExternalId(item.externalId); setPreviewState({ status: "loading" }); apiClient .getRecipe(item.recipeId) .then((recipe) => { setPreviewState({ status: "loaded", recipe }); onSelectImportedRecipe(recipe); }) .catch(() => setPreviewState({ status: "error" })); return; } const selection = { sourceKey, 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); onDraftSelected?.(selection); apiClient .previewSourceItem(sourceKey, item.externalId) .then((draft) => setPreviewState({ status: "loaded-draft", draft })) .catch(() => setPreviewState({ status: "error" })); } return ( <>
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 && (
{loadMoreStatus === "error" && (

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

)}
)}
); }