From b5a12cf4893af59fdec3b94648c450c637920543 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 16:14:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20onglet=20Sources=20=E2=80=94?= =?UTF-8?q?=20parcourir=20les=20recettes=20externes=20(=C3=A9tape=202/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deuxième étape du chantier "onglet Sources" : l'UI de parcours, construite contre les endpoints backend de l'étape 1 (#45). L'onglet désactivé placeholder de RecipeTabs devient un vrai onglet fonctionnel. - RecipeTabs.tsx : nouveau type RecipesPageTab (RecipeTab | "sources") — gardé hors du type partagé RecipeTab puisque l'API n'a pas de tab=sources à valider. Un prop `tabs` optionnel restreint quels onglets s'affichent — RecipePickerDialog (choix d'une recette pour un planning) s'y restreint aux 4 onglets réels, parcourir des sources externes en plein milieu de ce dialogue n'a pas de sens sans le flux de revue/import. - Nouveau RecipeSourcesPanel.tsx : contenu de l'onglet "Sources" — autonome (son propre master-detail), ne partage pas le fetching RecipeTab de RecipesPage puisqu'il parcourt le catalogue *live* d'une source (GET /sources/:key/browse), pas la table Recipe sauvegardée. Sélecteur de source si le foyer en a activé plusieurs ; sélectionner un item déjà importé navigue directement vers la vraie recette (SourceItemTable + navigate), un item pas encore importé affiche un aperçu en lecture seule (SourceItemPreviewPanel, réutilise StepDescription — les tech steps sont donc déjà surlignés dans l'aperçu). - Bug trouvé et corrigé en écrivant le scénario Cypress : cliquer un item déjà importé changeait l'URL mais restait affiché sur l'onglet Sources (RecipesPage ne rend RecipeDetailPanel/RecipeTable qu'en dehors de l'onglet "sources"). RecipeSourcesPanel prend maintenant un callback `onViewImportedRecipe` pour repasser sur un onglet réel avant de naviguer. Tests : nouveau recipe-sources.feature (parcours utilisateur complet — onglet vide, parcours avec items importés/non importés, aperçu avec surlignage de technique) ; recipes.cy.ts corrigé (assertion obsolète sur l'ancien placeholder désactivé). Étape suivante (3/4) : écran de revue (corriger les ingrédients non résolus) + finalisation de l'import. Co-Authored-By: Claude Sonnet 5 --- apps/web/cypress/e2e/recipe-sources.feature | 46 ++++ apps/web/cypress/e2e/recipe-sources.ts | 93 ++++++++ apps/web/cypress/e2e/recipes.cy.ts | 3 - apps/web/src/api/client.ts | 19 ++ .../features/planning/RecipePickerDialog.tsx | 9 +- .../features/recipes/RecipeSourcesPanel.tsx | 215 ++++++++++++++++++ apps/web/src/features/recipes/RecipeTabs.tsx | 54 +++-- .../recipes/SourceItemPreviewPanel.tsx | 128 +++++++++++ .../src/features/recipes/SourceItemTable.tsx | 67 ++++++ apps/web/src/features/recipes/recipes.scss | 85 +++++++ apps/web/src/layouts/nav-icons.tsx | 1 + apps/web/src/locales/fr/translation.json | 25 +- apps/web/src/pages/RecipesPage.tsx | 83 ++++--- 13 files changed, 767 insertions(+), 61 deletions(-) create mode 100644 apps/web/cypress/e2e/recipe-sources.feature create mode 100644 apps/web/cypress/e2e/recipe-sources.ts create mode 100644 apps/web/src/features/recipes/RecipeSourcesPanel.tsx create mode 100644 apps/web/src/features/recipes/SourceItemPreviewPanel.tsx create mode 100644 apps/web/src/features/recipes/SourceItemTable.tsx diff --git a/apps/web/cypress/e2e/recipe-sources.feature b/apps/web/cypress/e2e/recipe-sources.feature new file mode 100644 index 0000000..ff1a95b --- /dev/null +++ b/apps/web/cypress/e2e/recipe-sources.feature @@ -0,0 +1,46 @@ +Feature: Browsing external recipe sources + As a signed-in user + I want to browse the recipes available from my household's enabled sources + So that I can find new recipes to import, or jump straight to ones I already have + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the disliked ingredients list is empty + And the planning request returns nothing + + Scenario: Prompts to enable a source when the household hasn't enabled any + Given the recipe catalog contains "Omelette" + And the sources reference list has options + And the household's enabled sources are empty + When I visit "/recettes" + And I click the button "Sources" + Then I should see "Aucune source n'est activée" + + Scenario: Browses an enabled source, distinguishing already-imported items from new ones + Given the recipe catalog contains "Omelette" + And the sources reference list has options + And the household has enabled TheMealDB + And browsing TheMealDB returns some items + And recipe 2's detail is available + When I visit "/recettes" + And I click the button "Sources" + Then I should see the source item "Chicken Handi" + And I should see the source item "Fish Pie" + And the source item "Chicken Handi" should be marked as already imported + + When I click the source item "Chicken Handi" + Then the URL should include "/recettes/2" + And the recipe detail panel heading should be "Omelette" + + Scenario: Previews a not-yet-imported item, highlighting its detected techniques + Given the recipe catalog contains "Omelette" + And the sources reference list has options + And the household has enabled TheMealDB + And browsing TheMealDB returns some items + And previewing TheMealDB item "9999" is available + When I visit "/recettes" + And I click the button "Sources" + And I click the source item "Fish Pie" + Then the recipe detail panel heading should be "Fish Pie" + And I should see the highlighted technique "Cuire" diff --git a/apps/web/cypress/e2e/recipe-sources.ts b/apps/web/cypress/e2e/recipe-sources.ts new file mode 100644 index 0000000..e587fab --- /dev/null +++ b/apps/web/cypress/e2e/recipe-sources.ts @@ -0,0 +1,93 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database (see test/sources.test.ts). +// +// "the sources reference list has options" (theMealDb id 1, marmiton id 2) +// and "recipe 2's detail is available" are shared with reference-data.steps.ts +// / recipes.ts respectively — Cucumber step matching is global across every +// step-definition file, not scoped per feature. + +Given("the household has enabled TheMealDB", () => { + cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] }); +}); + +Given("browsing TheMealDB returns some items", () => { + cy.intercept("GET", "**/sources/theMealDb/browse*", { + statusCode: 200, + body: { + 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: null, + }, + }); +}); + +Given("previewing TheMealDB item {string} is available", (externalId: string) => { + cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, { + statusCode: 200, + body: { + sourceKey: "theMealDb", + externalId, + name: "Fish Pie", + description: null, + picture: null, + portions: 4, + sourceUrl: "https://www.themealdb.com/meal/9999", + ingredients: [ + { + rawText: "1 onion", + quantity: 1, + ingredient: { + id: 1, + key: "onion", + icon: "VEGETABLE", + category: "freshProduce", + subcategory: "vegetables", + reproducible: false, + allergens: [], + diets: [], + }, + unit: null, + }, + { rawText: "some mystery paste", quantity: null, ingredient: null, unit: null }, + ], + steps: [ + { + description: "Cuire à la poêle.", + picture: null, + techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }], + }, + ], + }, + }); +}); + +Then("I should see the source item {string}", (title: string) => { + cy.contains(".recipe-table__name", title).should("be.visible"); +}); + +When("I click the source item {string}", (title: string) => { + cy.contains(".recipe-table__name", title).click(); +}); + +Then("the source item {string} should be marked as already imported", (title: string) => { + cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible"); +}); diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts index 8b52664..da874e1 100644 --- a/apps/web/cypress/e2e/recipes.cy.ts +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -136,9 +136,6 @@ describe("Recipe catalog", () => { cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso"); cy.contains(".recipe-table__name", "Omelette").should("be.visible"); cy.contains(".recipe-table__name", "Ratatouille").should("not.exist"); - - // The disabled "Sources (bientôt)" placeholder never becomes active. - cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled"); }); it("searches within the active tab, debounced", () => { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index dabc2ae..07b9126 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -2,6 +2,7 @@ import { type AddPlanningItemInput, type AllergyView, type ApiErrorResponse, + type BrowsableSourceItemView, type CreateRecipeInput, type DietView, ErrorCode, @@ -11,6 +12,7 @@ import { type PlanningItemView, type PlanningView, type PreferencesView, + type RecipeImportDraftView, type RecipeSummaryView, type RecipeTab, type RecipeView, @@ -173,6 +175,23 @@ export class ApiClient { return this.request("/reference/sources"); } + /** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */ + public browseSource( + sourceKey: string, + params: { query?: string; cursor?: string } = {}, + ): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> { + const search = new URLSearchParams(); + if (params.query) search.set("query", params.query); + if (params.cursor) search.set("cursor", params.cursor); + const queryString = search.toString(); + return this.request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`); + } + + /** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */ + public previewSourceItem(sourceKey: string, externalId: string): Promise { + return this.request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`); + } + /** * One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`), * optionally narrowed further — `search` (name substring), diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx index cd0f4c4..050b387 100644 --- a/apps/web/src/features/planning/RecipePickerDialog.tsx +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -23,6 +23,9 @@ import "./recipe-picker-dialog.scss"; /** Debounce for the search field — same value as `RecipesPage`'s. */ const SEARCH_DEBOUNCE_MS = 300; +/** Restricts this dialog's `RecipeTabs` to the four real, DB-backed tabs — browsing external sources mid-dialog (`RecipeTabs`' "sources" tab) doesn't make sense here yet, with no review/import flow to hand a picked item off to. */ +const REAL_RECIPE_TABS: readonly RecipeTab[] = ["favoris", "perso", "foyer", "publique"]; + /** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */ type ListState = | { status: "loading" } @@ -258,7 +261,11 @@ export function RecipePickerDialog({ )} - + setActiveTab(tab as RecipeTab)} + tabs={REAL_RECIPE_TABS} + /> {listState.status === "loading" && (

{t("planning.picker.loading")}

diff --git a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx new file mode 100644 index 0000000..e98c789 --- /dev/null +++ b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx @@ -0,0 +1,215 @@ +import type { BrowsableSourceItemView, SourceView } from "@batch-cooking/shared"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { apiClient } from "../../api/client"; +import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel"; +import { SourceItemTable } from "./SourceItemTable"; +import "./recipes.scss"; + +/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */ +const SEARCH_DEBOUNCE_MS = 300; + +type EnabledSourcesState = + | { status: "loading" } + | { status: "loaded"; sources: SourceView[] } + | { status: "error" }; + +type BrowseState = + | { status: "loading" } + | { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null } + | { status: "error" }; + +/** + * "Sources" tab content of the recipe catalog (`RecipesPage`) — a + * self-contained master-detail pair of its own (source selector + browsable + * list on the left, `SourceItemPreviewPanel` on the right), independent of + * `RecipeTable`/`RecipeDetailPanel`: it browses a source's *live* catalog + * (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table, so it + * doesn't share their `RecipeTab`-based fetching at all. + * + * Selecting an already-imported item navigates straight to its real + * recipe (`/recettes/:id`, leaving this tab) — selecting one that isn't + * imported yet shows a read-only preview here instead. Turning that + * preview into an actual saved recipe (reviewing/fixing unresolved + * ingredients first) is a later stage of the same plan, not built here. + * + * `onViewImportedRecipe` must switch the caller's active tab away from + * `"sources"` before/alongside navigating — `RecipesPage` only renders + * `RecipeDetailPanel` (and fetches the real recipe list `RecipeTable` + * needs) outside the `"sources"` tab, so without this the URL would change + * but the sources panel would keep rendering over it. Which real tab it + * lands on doesn't have to include the recipe (the table and the detail + * panel are only loosely coupled via the URL's `:id` everywhere else in + * the app too — a deep link to a recipe outside the active tab's own list + * already just shows the detail without highlighting a row). + */ +export function RecipeSourcesPanel({ + onViewImportedRecipe, +}: { + onViewImportedRecipe: () => void; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [enabledSources, setEnabledSources] = useState({ status: "loading" }); + const [selectedSourceKey, setSelectedSourceKey] = useState(null); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [browseState, setBrowseState] = useState({ status: "loading" }); + const [selectedExternalId, setSelectedExternalId] = useState(null); + const [previewState, setPreviewState] = useState({ status: "empty" }); + + // Loaded once — which sources exist, crossed with which the household + // has enabled (`/parametres/foyer`). Defaults the selector to the first + // enabled one, if any. + useEffect(() => { + let cancelled = false; + Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()]) + .then(([sources, enabledIds]) => { + if (cancelled) return; + const enabled = sources.filter((source) => enabledIds.includes(source.id)); + setEnabledSources({ status: "loaded", sources: enabled }); + setSelectedSourceKey((current) => current ?? enabled[0]?.key ?? null); + }) + .catch(() => { + if (!cancelled) setEnabledSources({ status: "error" }); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timeout); + }, [search]); + + useEffect(() => { + if (selectedSourceKey === null) return; + let cancelled = false; + setBrowseState({ status: "loading" }); + setSelectedExternalId(null); + setPreviewState({ status: "empty" }); + + apiClient + .browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined }) + .then(({ items, nextCursor }) => { + if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor }); + }) + .catch(() => { + if (!cancelled) setBrowseState({ status: "error" }); + }); + + return () => { + cancelled = true; + }; + }, [selectedSourceKey, debouncedSearch]); + + function handleLoadMore() { + if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) { + return; + } + const cursor = browseState.nextCursor; + apiClient + .browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor }) + .then(({ items, nextCursor }) => { + setBrowseState((prev) => + prev.status === "loaded" + ? { status: "loaded", items: [...prev.items, ...items], nextCursor } + : prev, + ); + }) + .catch(() => setBrowseState({ status: "error" })); + } + + function handleSelectItem(item: BrowsableSourceItemView) { + if (item.alreadyImported && item.recipeId !== null) { + onViewImportedRecipe(); + navigate(`/recettes/${item.recipeId}`); + return; + } + if (selectedSourceKey === null) return; + setSelectedExternalId(item.externalId); + setPreviewState({ status: "loading" }); + apiClient + .previewSourceItem(selectedSourceKey, item.externalId) + .then((draft) => setPreviewState({ status: "loaded", draft })) + .catch(() => setPreviewState({ status: "error" })); + } + + if (enabledSources.status === "loading") { + return

{t("recipes.loading")}

; + } + if (enabledSources.status === "error") { + return ( +

{t("common.loadError")}

+ ); + } + if (enabledSources.sources.length === 0) { + return ( +

+ {t("recipes.sources.noneEnabled")}{" "} + {t("recipes.sources.noneEnabledLink")} +

+ ); + } + + return ( + <> +
+ {enabledSources.sources.length > 1 && ( + + )} + setSearch(e.target.value)} + /> +
+ +
+ {browseState.status === "loading" && ( +

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

+ )} + {browseState.status === "error" && ( +

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

+ )} + {browseState.status === "loaded" && browseState.items.length === 0 && ( +

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

+ )} + {browseState.status === "loaded" && browseState.items.length > 0 && ( +
+ + {browseState.nextCursor && ( + + )} +
+ )} + + +
+ + ); +} diff --git a/apps/web/src/features/recipes/RecipeTabs.tsx b/apps/web/src/features/recipes/RecipeTabs.tsx index 6826eba..8fb8399 100644 --- a/apps/web/src/features/recipes/RecipeTabs.tsx +++ b/apps/web/src/features/recipes/RecipeTabs.tsx @@ -1,37 +1,61 @@ import type { RecipeTab } from "@batch-cooking/shared"; import type { LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons"; +import { + AccountIcon, + FavoriteIcon, + HouseholdIcon, + PublicIcon, + SourcesIcon, +} from "../../layouts/nav-icons"; import "./recipes.scss"; -/** Every functional tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */ -const TABS: Array<{ value: RecipeTab; Icon: LucideIcon }> = [ +/** + * A tab of the recipe catalog — either a real {@link RecipeTab} (`GET + * /recipes?tab=`, `recipe.service.ts`'s `listRecipes`) or `"sources"`, a + * web-only mode that doesn't query the recipe table at all: it browses a + * household-enabled external source's own catalog live + * (`GET /sources/:sourceKey/browse`, `RecipeSourcesPanel`) instead of + * listing saved `Recipe` rows. Kept out of the shared `RecipeTab` type on + * purpose — the API has no `tab=sources` to validate. + */ +export type RecipesPageTab = RecipeTab | "sources"; + +/** Every possible tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */ +const ALL_TABS: Array<{ value: RecipesPageTab; Icon: LucideIcon }> = [ { value: "favoris", Icon: FavoriteIcon }, { value: "perso", Icon: AccountIcon }, { value: "foyer", Icon: HouseholdIcon }, { value: "publique", Icon: PublicIcon }, + { value: "sources", Icon: SourcesIcon }, ]; /** - * Catalog tab bar — Favoris / Perso / Foyer / Publique, plus a disabled - * placeholder for external sources (not built yet, see the plan's "hors - * scope" note) so the eventual nav slot is visible without being - * functional. No "toutes" tab: every recipe a viewer can see falls under - * exactly one of perso/foyer/publique (its own visibility) — see + * Catalog tab bar — Favoris / Perso / Foyer / Publique / Sources by + * default (`/recettes`, `RecipesPage`). `tabs` narrows which of those + * show — `RecipePickerDialog` (picking a recipe for a planning slot) + * passes just the four real ones: browsing external sources mid-dialog, + * without the review/import flow, doesn't make sense there yet (its + * `onChange` narrows the result back to `RecipeTab` itself, safe exactly + * because `tabs` guarantees `"sources"` is never clickable there). No + * "toutes" tab among the real ones: every recipe a viewer can see falls + * under exactly one of perso/foyer/publique (its own visibility) — see * `recipe.service.ts`'s `listRecipes`. */ export function RecipeTabs({ active, onChange, + tabs = ALL_TABS.map((tab) => tab.value), }: { - active: RecipeTab; - onChange: (tab: RecipeTab) => void; + active: RecipesPageTab; + onChange: (tab: RecipesPageTab) => void; + tabs?: readonly RecipesPageTab[]; }) { const { t } = useTranslation(); return (
- {TABS.map(({ value, Icon }) => ( + {ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
); } diff --git a/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx b/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx new file mode 100644 index 0000000..f51e448 --- /dev/null +++ b/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx @@ -0,0 +1,128 @@ +import type { RecipeImportDraftView } from "@batch-cooking/shared"; +import { useTranslation } from "react-i18next"; +import { StepDescription } from "./StepDescription"; +import "./recipes.scss"; + +/** State {@link SourceItemPreviewPanel} renders — mirrors `RecipeDetailState`'s shape (`RecipeDetailPanel`), one status short (no "not-found": an invalid `externalId` surfaces as `"error"`, there's no separate "id was well-formed but nothing matched it" case here). */ +export type SourceItemPreviewState = + | { status: "empty" } + | { status: "loading" } + | { status: "loaded"; draft: RecipeImportDraftView } + | { status: "error" }; + +/** + * Right-hand panel of the catalog's "Sources" tab (`RecipeSourcesPanel`) — + * a read-only preview of a not-yet-imported item: nothing here can be + * edited or saved yet (no favorite/edit/delete actions, unlike + * `RecipeDetailPanel`) — turning this into an actual import with a review + * step for unresolved ingredients is a later stage of the same plan. + * Reuses `StepDescription` so a step's detected techniques are already + * highlighted here too, exactly like a saved recipe's detail. + */ +export function SourceItemPreviewPanel({ state }: { state: SourceItemPreviewState }) { + const { t } = useTranslation(); + + if (state.status === "empty") { + return ( + + ); + } + if (state.status === "loading") { + return ( + + ); + } + if (state.status === "error") { + return ( + + ); + } + + const { draft } = state; + const hasUnresolvedIngredient = draft.ingredients.some( + (ingredient) => ingredient.ingredient === null, + ); + + return ( + + ); +} diff --git a/apps/web/src/features/recipes/SourceItemTable.tsx b/apps/web/src/features/recipes/SourceItemTable.tsx new file mode 100644 index 0000000..16b46dd --- /dev/null +++ b/apps/web/src/features/recipes/SourceItemTable.tsx @@ -0,0 +1,67 @@ +import type { BrowsableSourceItemView } from "@batch-cooking/shared"; +import { useTranslation } from "react-i18next"; +import "./recipes.scss"; + +/** + * List of one source's browsable items (`RecipeSourcesPanel`) — same + * "photo + name, click/Enter to select" row shape as `RecipeTable`, plus + * an "already imported" badge in place of allergen/regime columns (a + * source item has neither, it's not resolved against our catalogs until + * previewed). + */ +export function SourceItemTable({ + items, + selectedExternalId, + onSelect, +}: { + items: BrowsableSourceItemView[]; + selectedExternalId: string | null; + onSelect: (item: BrowsableSourceItemView) => void; +}) { + const { t } = useTranslation(); + + return ( +
+ + + + + + + + {items.map((item) => ( + onSelect(item)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(item); + } + }} + tabIndex={0} + aria-current={item.externalId === selectedExternalId ? "true" : undefined} + > + + + + + ))} + +
+ {t("recipes.table.name")} +
+ + {item.title} + {item.alreadyImported && ( + + {t("recipes.sources.alreadyImported")} + + )} +
+
+ ); +} diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index 4523b00..a613b8d 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -334,6 +334,91 @@ } } +// --- Sources tab (RecipeSourcesPanel) --------------------------------------- +// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap) +// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's +// actually new to this tab gets its own rules here. + +.recipes-page__header--sources { + // The source setSearch(e.target.value)} - /> + {activeTab !== "sources" && ( + setSearch(e.target.value)} + /> + )} {t("recipes.newButton")} @@ -151,33 +158,37 @@ export function RecipesPage() { -
- {listState.status === "loading" && ( -

{t("recipes.loading")}

- )} - {listState.status === "error" && ( -

- {t("common.loadError")} -

- )} - {listState.status === "loaded" && listState.recipes.length === 0 && ( -

{t("recipes.empty")}

- )} - {listState.status === "loaded" && listState.recipes.length > 0 && ( - navigate(`/recettes/${recipeId}`)} - /> - )} + {activeTab === "sources" ? ( + setActiveTab("favoris")} /> + ) : ( +
+ {listState.status === "loading" && ( +

{t("recipes.loading")}

+ )} + {listState.status === "error" && ( +

+ {t("common.loadError")} +

+ )} + {listState.status === "loaded" && listState.recipes.length === 0 && ( +

{t("recipes.empty")}

+ )} + {listState.status === "loaded" && listState.recipes.length > 0 && ( + navigate(`/recettes/${recipeId}`)} + /> + )} - -
+ +
+ )} ); }