Compare commits

...

2 commits

Author SHA1 Message Date
ee22469144 feat(recipes): scroll infini + placeholders sur le parcours des sources externes
Remplace le bouton "Voir plus" de RecipeSourcesPanel par un scroll
infini : une ligne sentinelle en fin de liste (SourceItemTable), observée
via IntersectionObserver scopé au conteneur scrollable de la table,
déclenche le chargement de la page suivante quand elle approche du bas.

Le panel précharge en plus la page suivante dès que la page courante
s'affiche (pas seulement au moment où la sentinelle devient visible), pour
qu'un défilement rapide tombe le plus souvent sur une réponse déjà
arrivée plutôt que de déclencher un aller-retour réseau à ce moment précis.

Pendant un chargement (préchargé ou non), SourceItemTable ajoute des
lignes squelettes qui pulsent en bas de la liste au lieu de laisser un
vide. Un échec de chargement n'efface plus la liste déjà chargée comme
avant (bug corrigé au passage) — un message avec un lien "Réessayer"
s'affiche à la place ; ce correctif inclut aussi le nettoyage d'un
préchargement en échec qui, sinon, aurait fait rejouer indéfiniment la
même promesse déjà rejetée à chaque tentative de réessai.

Deux nouveaux scénarios Cypress (recipe-sources.feature) : chargement
automatique de pages supplémentaires sans bouton, et réessai après un
échec du chargement suivant. Suite e2e complète relancée (78/79, le seul
échec restant est un test préexistant sans rapport, recipe-form.feature,
signalé séparément).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:55:13 +02:00
8eb949fd28 fix(recipes): corrige la liste vide de sevenFiftyGAdapter quand le filtre est vide
L'endpoint IA que list() utilisait pour toute recherche (SEARCH_URL,
/genius/query/) répond avec un corps de réponse vide dès que query est
vide — vérifié en direct. Résultat : parcourir la source 750g sans filtre
ne remontait jamais aucune recette.

Corrigé en lisant un endpoint différent quand query est vide/absent :
dernieres-recettes.htm, le vrai catalogue paginé "dernières recettes" de
750g.com (pagination réelle via &page=N, contrairement à l'endpoint de
recherche). nextCursor suit désormais cette même distinction : toujours
null pour une recherche par texte (l'endpoint ne pagine pas), calculé
normalement pour le parcours sans filtre (une page sans aucune carte en
est le signal de fin, cet endpoint ne renvoyant ni 404 ni redirection une
fois la dernière page dépassée).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:23:32 +02:00
9 changed files with 450 additions and 36 deletions

View file

@ -18,9 +18,26 @@ const SOURCE_KEY = "750g";
// that JS itself calls this plain GET endpoint, an "AI answer engine" that
// returns an HTML fragment of recipe cards for a free-text query. Verified
// live: works with a bare `fetch`, no special headers/cookies/session
// needed, same as every other adapter in this family.
// needed, same as every other adapter in this family. Only used for a
// non-empty query — see `LATEST_RECIPES_URL` for why: this endpoint answers
// a blank query with nothing at all.
const SEARCH_URL = "https://www.750g.com/genius/query/";
// What `list()` reads instead of `SEARCH_URL` for an empty/omitted `query`
// ("browse everything", per `RecipeSourceListParams.query`'s own doc
// comment) — verified live, `SEARCH_URL` responds to a blank query with a
// zero-length body, so browsing this source with no filter typed would
// otherwise always come back empty. `dernieres-recettes.htm` is 750g.com's
// own "latest recipes" archive: real, server-rendered pagination via
// `&page=N` (unlike `SEARCH_URL`, which doesn't paginate at all — see
// `list()`'s own comment on `nextCursor`), same `card-recipe`/`card-link`
// markup `extractRecipeCards` already reads elsewhere on the site. Checked
// live up to `page=500` — genuinely different recipes every time, no
// redirect/clamp once past whatever the real end is (unlike marmiton.ts's
// search, which 404s past its last page), so `list()` treats a page with no
// cards at all as the end-of-results signal instead.
const LATEST_RECIPES_URL = "https://www.750g.com/dernieres-recettes.htm";
/**
* Matches every `<script type="application/ld+json">…</script>` block
* same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its
@ -281,12 +298,16 @@ function rekeySourceError(err: unknown): unknown {
*
* - `list()` has no `ItemList` JSON-LD to read off its search results (see
* {@link extractRecipeCards}) its site search is a client-side widget,
* so this instead calls the plain GET endpoint that widget's own JS calls
* internally (`SEARCH_URL`), an "AI answer engine" that returns a curated
* batch of cards for a free-text query rather than an exhaustive,
* paginated catalog verified live, requesting `page=2` of the same
* query always comes back empty, so `nextCursor` is always `null` here,
* same as `theMealDbAdapter`'s "one response holds every match".
* so a non-empty query instead calls the plain GET endpoint that widget's
* own JS calls internally (`SEARCH_URL`), an "AI answer engine" that
* returns a curated batch of cards rather than an exhaustive, paginated
* catalog verified live, requesting `page=2` of the same query always
* comes back empty, so `nextCursor` is always `null` in that case, same
* as `theMealDbAdapter`'s "one response holds every match". An empty
* query reads `LATEST_RECIPES_URL` instead, a real paginated catalog
* `SEARCH_URL` itself answers a blank query with nothing at all, which
* would otherwise make browsing this source with no filter typed always
* come back empty.
* - `parse()` doesn't delegate to `jsonLdRecipeAdapter.parse` as directly as
* marmiton.ts's does — 750g.com's own JSON-LD generator has two real bugs
* this adapter works around: some pages embed literal, unescaped control
@ -312,19 +333,27 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const query = params.query ?? "";
// `params.cursor` est ignoré : voir le commentaire du module — cette
// recherche ne pagine pas réellement, il n'existe donc jamais de
// curseur légitime à faire transiter (`nextCursor` vaut toujours
// `null` ci-dessous).
const searchUrl = `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`;
const page = params.cursor ? Number(params.cursor) : 1;
const hasQuery = query.length > 0;
// Deux endpoints distincts selon qu'il y a un texte de recherche ou
// non — voir les commentaires de `SEARCH_URL`/`LATEST_RECIPES_URL` :
// le premier ne répond rien du tout à une requête vide, le second est
// le vrai catalogue paginé "dernières recettes" de 750g.com. `page`
// n'a de sens que pour le second (le premier ne pagine pas — voir
// plus bas) mais est toujours passé, y compris `page=1`, par
// cohérence avec le reste de cette famille d'adaptateurs.
const listUrl = hasQuery
? `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`
: `${LATEST_RECIPES_URL}?page=${page}`;
let response: Response;
try {
response = await fetch(searchUrl);
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error searching 750g (${searchUrl})`,
`Network error listing 750g recipes (${listUrl})`,
{
cause,
},
@ -333,7 +362,7 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`750g search responded ${response.status} (${searchUrl})`,
`750g responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
@ -350,7 +379,14 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string
url: card.url,
}));
return { items, nextCursor: null };
// La recherche par texte libre ne pagine pas du tout (voir le
// commentaire de `SEARCH_URL`) — `nextCursor` y vaut toujours `null`,
// même logique que `theMealDbAdapter`. "Dernières recettes" pagine
// réellement (voir le commentaire de `LATEST_RECIPES_URL`) — une page
// sans aucune carte en est le signal de fin.
const nextCursor = hasQuery ? null : items.length > 0 ? String(page + 1) : null;
return { items, nextCursor };
} catch (err) {
// Rethrown as-is (already keyed "750g" by whichever branch above
// threw it) — this adapter's only caller (`sources.service.ts`)

View file

@ -135,7 +135,7 @@ describe("sevenFiftyGAdapter", () => {
expect(result.nextCursor).to.be.null;
});
it("ignores params.cursor and always requests page=1 — there's never a legitimate cursor to pass back", async () => {
it("ignores params.cursor for a text search — always requests page=1, there's never a legitimate cursor for this (non-paginated) endpoint", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
@ -160,6 +160,53 @@ describe("sevenFiftyGAdapter", () => {
expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
});
describe("empty/omitted query (browsing with no filter)", () => {
it("reads 'dernières recettes' instead of the AI search — the search endpoint answers a blank query with nothing at all, which would otherwise make browsing with no filter always come back empty", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(CARDS_HTML, { status: 200 });
}) as typeof fetch;
const result = await sevenFiftyGAdapter.list({});
expect(requestedUrl).to.include("dernieres-recettes.htm");
expect(requestedUrl).not.to.include("genius/query");
expect(result.items).to.have.length(3);
});
it("also browses for an explicitly empty query string, not just an omitted one", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "" });
expect(result.items).to.have.length(3);
});
it("requests the given cursor's page", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(CARDS_HTML, { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ cursor: "5" });
expect(requestedUrl).to.include("page=5");
});
it("offers a next page when the page has cards, and none once a page comes back empty — this endpoint never 404s/redirects past its real end", async () => {
stubFetchHtml(CARDS_HTML);
const withItems = await sevenFiftyGAdapter.list({ cursor: "2" });
expect(withItems.nextCursor).to.equal("3");
stubFetchHtml("<html><body>Plus rien ici</body></html>");
const empty = await sevenFiftyGAdapter.list({ cursor: "50" });
expect(empty.nextCursor).to.be.null;
expect(empty.items).to.deep.equal([]);
});
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 500);

View file

@ -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

View file

@ -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,

View file

@ -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;
}
}

View file

@ -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<BrowseState>({ 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<BrowsePage>;
} | 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<string | null>(
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 && (
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
{t("recipes.sources.loadMore")}
{loadMoreStatus === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.loadMoreError")}{" "}
<button type="button" className="source-items-retry" onClick={handleLoadMore}>
{t("recipes.sources.retry")}
</button>
</p>
)}
</div>
)}

View file

@ -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<HTMLDivElement | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(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 (
<div className="recipe-table-wrap">
<div className="recipe-table-wrap" ref={scrollContainerRef}>
<table className="recipe-table">
<thead>
<tr>
@ -60,6 +107,23 @@ export function SourceItemTable({
</td>
</tr>
))}
{hasMore && (
<tr ref={sentinelRef} className="source-item-table__sentinel">
<td colSpan={3} />
</tr>
)}
{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.
<tr key={`skeleton-${index}`} className="source-item-table__row--skeleton">
<td>
<span className="recipe-table__photo source-item-table__photo--skeleton" />
</td>
<td>
<span className="source-item-table__skeleton-bar" />
</td>
<td />
</tr>
))}
</tbody>
</table>
</div>

View file

@ -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"
},

View file

@ -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"`.