Compare commits
2 commits
main
...
feat/sourc
| Author | SHA1 | Date | |
|---|---|---|---|
| ee22469144 | |||
| 8eb949fd28 |
9 changed files with 450 additions and 36 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"`.
|
||||
|
|
|
|||
Loading…
Reference in a new issue