diff --git a/apps/web/cypress/e2e/recipe-sources.feature b/apps/web/cypress/e2e/recipe-sources.feature index 16dfee8..ee6a3ae 100644 --- a/apps/web/cypress/e2e/recipe-sources.feature +++ b/apps/web/cypress/e2e/recipe-sources.feature @@ -45,6 +45,29 @@ Feature: Browsing external recipe sources And the recipe detail panel heading should be "Fish Pie" And I should see the highlighted technique "Cuire" + Scenario: Loads further pages automatically, with no "load more" button + Given the recipe catalog contains nothing + And the sources reference list has options + And the household has enabled TheMealDB + And browsing TheMealDB returns two pages of items + When I visit "/recettes" + And I click the button "TheMealDB" + Then I should see the source item "Chicken Handi" + And I should see the source item "Beef Wellington" + And I should not see "Voir plus" + + Scenario: Offers a retry when loading the next page fails + Given the recipe catalog contains nothing + And the sources reference list has options + And the household has enabled TheMealDB + And browsing TheMealDB's next page fails once, then succeeds + When I visit "/recettes" + And I click the button "TheMealDB" + Then I should see the source item "Chicken Handi" + And I should see a message to retry loading more + When I click the button "Réessayer" + Then I should see the source item "Beef Wellington" + Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all Given the recipe catalog contains nothing And the sources reference list has options diff --git a/apps/web/cypress/e2e/recipe-sources.ts b/apps/web/cypress/e2e/recipe-sources.ts index f183bc2..01e50d2 100644 --- a/apps/web/cypress/e2e/recipe-sources.ts +++ b/apps/web/cypress/e2e/recipe-sources.ts @@ -83,6 +83,81 @@ Given("browsing TheMealDB returns some items", () => { }); }); +/** + * A second, distinct item from `Given("browsing TheMealDB returns some + * items")`'s page-1 pair — used by the infinite-scroll/retry scenarios + * below, which need to tell "the item that only shows up once the *next* + * page has loaded" apart from what's already visible on page 1. + */ +const BEEF_WELLINGTON_ITEM = { + externalId: "77123", + title: "Beef Wellington", + picture: null, + url: "https://www.themealdb.com/meal/77123", + alreadyImported: false, + recipeId: null, +}; + +const CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 = { + items: [ + { + externalId: "52795", + title: "Chicken Handi", + picture: null, + url: "https://www.themealdb.com/meal/52795", + alreadyImported: true, + recipeId: 2, + }, + { + externalId: "9999", + title: "Fish Pie", + picture: null, + url: "https://www.themealdb.com/meal/9999", + alreadyImported: false, + recipeId: null, + }, + ], + nextCursor: "2", +}; + +Given("browsing TheMealDB returns two pages of items", () => { + cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => { + const isNextPage = req.url.includes("cursor="); + req.reply({ + statusCode: 200, + body: isNextPage + ? { items: [BEEF_WELLINGTON_ITEM], nextCursor: null } + : CHICKEN_HANDI_AND_FISH_PIE_PAGE_1, + }); + }); +}); + +// The panel prefetches the next page as soon as page 1 is on screen (before +// anyone's actually waited on it), so the *first* request for it is that +// prefetch — this is what actually fails "once", not a request triggered by +// a click. `handleLoadMore`'s own retry then makes a genuinely fresh +// request (see its own doc comment on why a failed prefetch gets cleared), +// which is the one that succeeds here. +Given("browsing TheMealDB's next page fails once, then succeeds", () => { + let nextPageAttempts = 0; + cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => { + if (!req.url.includes("cursor=")) { + req.reply({ statusCode: 200, body: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 }); + return; + } + nextPageAttempts += 1; + if (nextPageAttempts === 1) { + req.reply({ statusCode: 500, body: {} }); + } else { + req.reply({ statusCode: 200, body: { items: [BEEF_WELLINGTON_ITEM], nextCursor: null } }); + } + }); +}); + +Then("I should see a message to retry loading more", () => { + cy.contains(".recipes-page__status--error", "Réessayer").should("be.visible"); +}); + Given("previewing TheMealDB item {string} is available", (externalId: string) => { cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, { statusCode: 200, diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index a9ed09d..b09b953 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -372,20 +372,75 @@ gap: var(--space-sm); } -.source-items-load-more { - flex-shrink: 0; - align-self: center; - padding: 0.4rem var(--space-lg); - font-family: var(--font-body); - font-size: var(--font-size-sm); +// Browsing a source scrolls infinitely (SourceItemTable's own sentinel row +// triggers RecipeSourcesPanel's handleLoadMore) — this is only the inline +// retry action shown alongside recipes.sources.loadMoreError when a page +// fetch actually fails, not a persistent "load more" control. +.source-items-retry { + padding: 0; + font-family: inherit; + font-size: inherit; + font-weight: 600; color: var(--color-primary); background: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-pill); + border: none; + text-decoration: underline; cursor: pointer; +} + +// Pulsing placeholder rows `SourceItemTable` appends below the real items +// while `RecipeSourcesPanel`'s "load more" fetch is in flight — with that +// panel prefetching the next page ahead of time (as soon as the sentinel +// row scrolls near view), this is usually a very brief flash rather than +// an actual wait, but it keeps the list +// filling in instead of looking like nothing happened either way. +@keyframes source-item-skeleton-pulse { + 0%, + 100% { + opacity: 0.4; + } + + 50% { + opacity: 0.9; + } +} + +// Invisible marker row `SourceItemTable`'s `IntersectionObserver` watches +// to trigger infinite scroll — no padding/border of its own, unlike a real +// row, so it doesn't show up as a stray empty stripe at the bottom of the +// list. +.source-item-table__sentinel td { + padding: 0; + border: none; +} + +.source-item-table__row--skeleton { + cursor: default; + pointer-events: none; &:hover { - border-color: var(--color-primary); + background: none; + } +} + +.source-item-table__photo--skeleton, +.source-item-table__skeleton-bar { + background: var(--color-border); + animation: source-item-skeleton-pulse 1.4s ease-in-out infinite; +} + +.source-item-table__skeleton-bar { + display: block; + width: 60%; + height: 0.9rem; + border-radius: var(--radius-pill); +} + +@media (prefers-reduced-motion: reduce) { + .source-item-table__photo--skeleton, + .source-item-table__skeleton-bar { + animation: none; + opacity: 0.6; } } diff --git a/apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx b/apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx index bf01cb4..3b86981 100644 --- a/apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx +++ b/apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx @@ -1,5 +1,5 @@ import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { apiClient } from "../../../api/client"; import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel"; @@ -9,6 +9,15 @@ 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; @@ -76,6 +85,30 @@ export function RecipeSourcesPanel({ 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, ); @@ -127,6 +160,11 @@ export function RecipeSourcesPanel({ 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 }) @@ -142,21 +180,78 @@ export function RecipeSourcesPanel({ }; }, [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; - apiClient - .browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor }) + 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(() => setBrowseState({ status: "error" })); + .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) { @@ -217,11 +312,17 @@ export function RecipeSourcesPanel({ items={browseState.items} selectedExternalId={selectedExternalId} onSelect={handleSelectItem} + placeholderCount={loadMoreStatus === "loading" ? LOAD_MORE_PLACEHOLDER_COUNT : 0} + hasMore={browseState.nextCursor !== null} + onLoadMore={handleLoadMore} /> - {browseState.nextCursor && ( - + {loadMoreStatus === "error" && ( +

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

)} )} diff --git a/apps/web/src/features/recipes/sources/SourceItemTable.tsx b/apps/web/src/features/recipes/sources/SourceItemTable.tsx index bd5ff89..df7ee14 100644 --- a/apps/web/src/features/recipes/sources/SourceItemTable.tsx +++ b/apps/web/src/features/recipes/sources/SourceItemTable.tsx @@ -1,4 +1,5 @@ import type { BrowsableSourceItemView } from "@batch-cooking/shared"; +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import "../recipes.scss"; @@ -8,20 +9,66 @@ import "../recipes.scss"; * an "already imported" badge in place of allergen/regime columns (a * source item has neither, it's not resolved against our catalogs until * previewed). + * + * Infinite scroll, not a "voir plus" button: a zero-content sentinel row + * (`source-item-table__sentinel`) sits right after `items`, watched by an + * `IntersectionObserver` scoped to this table's own scrolling container + * (`.recipe-table-wrap`, not the page) — scrolling it into view calls + * `onLoadMore`, the same callback a button's `onClick` would have. Kept + * entirely inside this component rather than exposed as a prop callback + * signature change on every call site: `RecipeSourcesPanel` doesn't need to + * know *how* "load more" gets triggered, only that it does. */ export function SourceItemTable({ items, selectedExternalId, onSelect, + placeholderCount = 0, + hasMore = false, + onLoadMore, }: { items: BrowsableSourceItemView[]; selectedExternalId: string | null; onSelect: (item: BrowsableSourceItemView) => void; + /** + * Extra pulsing skeleton rows appended after `items` — `RecipeSourcesPanel` + * sets this while a "load more" fetch is in flight, so the list fills in + * right away instead of looking like nothing happened. Purely decorative: + * never clickable/focusable, unlike a real row. + */ + placeholderCount?: number; + /** Whether a further page exists — renders the sentinel row (and therefore observes it) only when true; the observer would otherwise have nothing meaningful to trigger once the catalog is exhausted. */ + hasMore?: boolean; + /** Called once when the sentinel row scrolls into view — `RecipeSourcesPanel`'s own re-entrancy guard (not this component) is what keeps a still-visible sentinel from firing this repeatedly while a fetch is already in flight. */ + onLoadMore?: () => void; }) { const { t } = useTranslation(); + const scrollContainerRef = useRef(null); + const sentinelRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: `onLoadMore` is deliberately excluded — RecipeSourcesPanel passes a fresh function identity on every render, and re-subscribing the observer on every single render (rather than only when hasMore actually flips) would be wasteful busywork for no behavioral difference. + useEffect(() => { + const root = scrollContainerRef.current; + const sentinel = sentinelRef.current; + if (!hasMore || !onLoadMore || !root || !sentinel) return; + + // `rootMargin` starts loading the next page slightly before the + // sentinel actually reaches the visible edge — combined with + // RecipeSourcesPanel's own prefetch-ahead-of-time, the goal is that a + // page finishes arriving before anyone actually scrolls far enough to + // need it, not just "as soon as" they do. + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) onLoadMore(); + }, + { root, rootMargin: "200px 0px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMore]); return ( -
+
@@ -60,6 +107,23 @@ export function SourceItemTable({ ))} + {hasMore && ( + + + )} + {Array.from({ length: placeholderCount }, (_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: a fixed-length run of interchangeable, content-less placeholders — there's no stable identity to key on, and the count never reorders. + + + + + ))}
+
+ + + + +
diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index dc9d516..a9ec9aa 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -184,10 +184,11 @@ "sources": { "searchPlaceholder": "Rechercher…", "empty": "Aucune recette trouvée.", - "loadMore": "Voir plus", "alreadyImported": "Déjà importée", "loading": "Chargement…", "loadError": "Impossible de charger cette source pour le moment.", + "loadMoreError": "Impossible de charger la suite pour le moment.", + "retry": "Réessayer", "detail": { "viewSource": "Voir sur le site d'origine" }, diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md index b037865..f36d477 100644 --- a/specs/frontend-architecture.md +++ b/specs/frontend-architecture.md @@ -463,6 +463,18 @@ Contenu de l'onglet d'une source : sa propre paire maître-détail — `nextCursor`) + `RecipeDetailPanel` pour la prévisualisation. Scopé à un seul `sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de source, même convention "monté seulement tant que pertinent" que `Dialog`. + +Pagination en scroll infini, pas de bouton "voir plus" : une ligne +sentinelle invisible en fin de liste (`SourceItemTable`, un +`IntersectionObserver` scopé à son propre conteneur scrollable) déclenche le +chargement de la page suivante dès qu'elle approche du bas. `RecipeSourcesPanel` +précharge en plus la page suivante dès que la page courante s'affiche (avant +même que la sentinelle soit visible), pour qu'un défilement rapide tombe le +plus souvent sur une réponse déjà arrivée. Pendant un chargement (préchargé +ou non), des lignes squelettes qui pulsent s'ajoutent en bas de la liste +plutôt que de laisser un vide ; un échec affiche un message avec un lien +"Réessayer" sans effacer les lignes déjà chargées. + Clic sur une ligne : - **Déjà importé** (`alreadyImported && recipeId !== null`) : `GET /recipes/:id`, prévisualisé en état `"loaded"`.