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
|
// 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
|
// returns an HTML fragment of recipe cards for a free-text query. Verified
|
||||||
// live: works with a bare `fetch`, no special headers/cookies/session
|
// 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/";
|
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 —
|
* Matches every `<script type="application/ld+json">…</script>` block —
|
||||||
* same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its
|
* 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
|
* - `list()` has no `ItemList` JSON-LD to read off its search results (see
|
||||||
* {@link extractRecipeCards}) — its site search is a client-side widget,
|
* {@link extractRecipeCards}) — its site search is a client-side widget,
|
||||||
* so this instead calls the plain GET endpoint that widget's own JS calls
|
* so a non-empty query instead calls the plain GET endpoint that widget's
|
||||||
* internally (`SEARCH_URL`), an "AI answer engine" that returns a curated
|
* own JS calls internally (`SEARCH_URL`), an "AI answer engine" that
|
||||||
* batch of cards for a free-text query rather than an exhaustive,
|
* returns a curated batch of cards rather than an exhaustive, paginated
|
||||||
* paginated catalog — verified live, requesting `page=2` of the same
|
* catalog — verified live, requesting `page=2` of the same query always
|
||||||
* query always comes back empty, so `nextCursor` is always `null` here,
|
* comes back empty, so `nextCursor` is always `null` in that case, same
|
||||||
* same as `theMealDbAdapter`'s "one response holds every match".
|
* 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
|
* - `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
|
* 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
|
* 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> {
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
try {
|
try {
|
||||||
const query = params.query ?? "";
|
const query = params.query ?? "";
|
||||||
// `params.cursor` est ignoré : voir le commentaire du module — cette
|
const page = params.cursor ? Number(params.cursor) : 1;
|
||||||
// recherche ne pagine pas réellement, il n'existe donc jamais de
|
const hasQuery = query.length > 0;
|
||||||
// curseur légitime à faire transiter (`nextCursor` vaut toujours
|
|
||||||
// `null` ci-dessous).
|
// Deux endpoints distincts selon qu'il y a un texte de recherche ou
|
||||||
const searchUrl = `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`;
|
// 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;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(searchUrl);
|
response = await fetch(listUrl);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new RecipeSourceFetchError(
|
throw new RecipeSourceFetchError(
|
||||||
SOURCE_KEY,
|
SOURCE_KEY,
|
||||||
`Network error searching 750g (${searchUrl})`,
|
`Network error listing 750g recipes (${listUrl})`,
|
||||||
{
|
{
|
||||||
cause,
|
cause,
|
||||||
},
|
},
|
||||||
|
|
@ -333,7 +362,7 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new RecipeSourceFetchError(
|
throw new RecipeSourceFetchError(
|
||||||
SOURCE_KEY,
|
SOURCE_KEY,
|
||||||
`750g search responded ${response.status} (${searchUrl})`,
|
`750g responded ${response.status} (${listUrl})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
|
|
@ -350,7 +379,14 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string
|
||||||
url: card.url,
|
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) {
|
} catch (err) {
|
||||||
// Rethrown as-is (already keyed "750g" by whichever branch above
|
// Rethrown as-is (already keyed "750g" by whichever branch above
|
||||||
// threw it) — this adapter's only caller (`sources.service.ts`)
|
// threw it) — this adapter's only caller (`sources.service.ts`)
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ describe("sevenFiftyGAdapter", () => {
|
||||||
expect(result.nextCursor).to.be.null;
|
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;
|
let requestedUrl: string | undefined;
|
||||||
globalThis.fetch = (async (url: string) => {
|
globalThis.fetch = (async (url: string) => {
|
||||||
requestedUrl = url;
|
requestedUrl = url;
|
||||||
|
|
@ -160,6 +160,53 @@ describe("sevenFiftyGAdapter", () => {
|
||||||
expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
|
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 () => {
|
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
|
||||||
stubFetchHtml("", 500);
|
stubFetchHtml("", 500);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,29 @@ Feature: Browsing external recipe sources
|
||||||
And the recipe detail panel heading should be "Fish Pie"
|
And the recipe detail panel heading should be "Fish Pie"
|
||||||
And I should see the highlighted technique "Cuire"
|
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
|
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
|
Given the recipe catalog contains nothing
|
||||||
And the sources reference list has options
|
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) => {
|
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
|
||||||
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
|
||||||
|
|
@ -372,20 +372,75 @@
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-items-load-more {
|
// Browsing a source scrolls infinitely (SourceItemTable's own sentinel row
|
||||||
flex-shrink: 0;
|
// triggers RecipeSourcesPanel's handleLoadMore) — this is only the inline
|
||||||
align-self: center;
|
// retry action shown alongside recipes.sources.loadMoreError when a page
|
||||||
padding: 0.4rem var(--space-lg);
|
// fetch actually fails, not a persistent "load more" control.
|
||||||
font-family: var(--font-body);
|
.source-items-retry {
|
||||||
font-size: var(--font-size-sm);
|
padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: none;
|
||||||
border-radius: var(--radius-pill);
|
text-decoration: underline;
|
||||||
cursor: pointer;
|
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 {
|
&: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 type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiClient } from "../../../api/client";
|
import { apiClient } from "../../../api/client";
|
||||||
import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel";
|
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. */
|
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
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. */
|
/** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */
|
||||||
export interface SourceItemSelection {
|
export interface SourceItemSelection {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
|
|
@ -76,6 +85,30 @@ export function RecipeSourcesPanel({
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
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>(
|
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(
|
||||||
initialSelection?.externalId ?? null,
|
initialSelection?.externalId ?? null,
|
||||||
);
|
);
|
||||||
|
|
@ -127,6 +160,11 @@ export function RecipeSourcesPanel({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setBrowseState({ status: "loading" });
|
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
|
apiClient
|
||||||
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
|
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
|
||||||
|
|
@ -142,21 +180,78 @@ export function RecipeSourcesPanel({
|
||||||
};
|
};
|
||||||
}, [sourceKey, debouncedSearch]);
|
}, [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() {
|
function handleLoadMore() {
|
||||||
if (browseState.status !== "loaded" || !browseState.nextCursor) {
|
if (browseState.status !== "loaded" || !browseState.nextCursor) {
|
||||||
return;
|
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 cursor = browseState.nextCursor;
|
||||||
apiClient
|
const query = debouncedSearch.trim();
|
||||||
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor })
|
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 }) => {
|
.then(({ items, nextCursor }) => {
|
||||||
|
nextPagePrefetchRef.current = null;
|
||||||
|
isLoadingMoreRef.current = false;
|
||||||
setBrowseState((prev) =>
|
setBrowseState((prev) =>
|
||||||
prev.status === "loaded"
|
prev.status === "loaded"
|
||||||
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
|
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
|
||||||
: prev,
|
: 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) {
|
function handleSelectItem(item: BrowsableSourceItemView) {
|
||||||
|
|
@ -217,11 +312,17 @@ export function RecipeSourcesPanel({
|
||||||
items={browseState.items}
|
items={browseState.items}
|
||||||
selectedExternalId={selectedExternalId}
|
selectedExternalId={selectedExternalId}
|
||||||
onSelect={handleSelectItem}
|
onSelect={handleSelectItem}
|
||||||
|
placeholderCount={loadMoreStatus === "loading" ? LOAD_MORE_PLACEHOLDER_COUNT : 0}
|
||||||
|
hasMore={browseState.nextCursor !== null}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
/>
|
/>
|
||||||
{browseState.nextCursor && (
|
{loadMoreStatus === "error" && (
|
||||||
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
{t("recipes.sources.loadMore")}
|
{t("recipes.sources.loadMoreError")}{" "}
|
||||||
</button>
|
<button type="button" className="source-items-retry" onClick={handleLoadMore}>
|
||||||
|
{t("recipes.sources.retry")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
|
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import "../recipes.scss";
|
import "../recipes.scss";
|
||||||
|
|
||||||
|
|
@ -8,20 +9,66 @@ import "../recipes.scss";
|
||||||
* an "already imported" badge in place of allergen/regime columns (a
|
* an "already imported" badge in place of allergen/regime columns (a
|
||||||
* source item has neither, it's not resolved against our catalogs until
|
* source item has neither, it's not resolved against our catalogs until
|
||||||
* previewed).
|
* 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({
|
export function SourceItemTable({
|
||||||
items,
|
items,
|
||||||
selectedExternalId,
|
selectedExternalId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
placeholderCount = 0,
|
||||||
|
hasMore = false,
|
||||||
|
onLoadMore,
|
||||||
}: {
|
}: {
|
||||||
items: BrowsableSourceItemView[];
|
items: BrowsableSourceItemView[];
|
||||||
selectedExternalId: string | null;
|
selectedExternalId: string | null;
|
||||||
onSelect: (item: BrowsableSourceItemView) => void;
|
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 { 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 (
|
return (
|
||||||
<div className="recipe-table-wrap">
|
<div className="recipe-table-wrap" ref={scrollContainerRef}>
|
||||||
<table className="recipe-table">
|
<table className="recipe-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -60,6 +107,23 @@ export function SourceItemTable({
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -184,10 +184,11 @@
|
||||||
"sources": {
|
"sources": {
|
||||||
"searchPlaceholder": "Rechercher…",
|
"searchPlaceholder": "Rechercher…",
|
||||||
"empty": "Aucune recette trouvée.",
|
"empty": "Aucune recette trouvée.",
|
||||||
"loadMore": "Voir plus",
|
|
||||||
"alreadyImported": "Déjà importée",
|
"alreadyImported": "Déjà importée",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"loadError": "Impossible de charger cette source pour le moment.",
|
"loadError": "Impossible de charger cette source pour le moment.",
|
||||||
|
"loadMoreError": "Impossible de charger la suite pour le moment.",
|
||||||
|
"retry": "Réessayer",
|
||||||
"detail": {
|
"detail": {
|
||||||
"viewSource": "Voir sur le site d'origine"
|
"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
|
`nextCursor`) + `RecipeDetailPanel` pour la prévisualisation. Scopé à un seul
|
||||||
`sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de
|
`sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de
|
||||||
source, même convention "monté seulement tant que pertinent" que `Dialog`.
|
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 :
|
Clic sur une ligne :
|
||||||
- **Déjà importé** (`alreadyImported && recipeId !== null`) : `GET
|
- **Déjà importé** (`alreadyImported && recipeId !== null`) : `GET
|
||||||
/recipes/:id`, prévisualisé en état `"loaded"`.
|
/recipes/:id`, prévisualisé en état `"loaded"`.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue