feat(recipes): onglet Sources — parcourir les recettes externes (étape 2/4)

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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-20 16:14:20 +02:00
parent 44ef5e071f
commit b5a12cf489
13 changed files with 767 additions and 61 deletions

View file

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

View file

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

View file

@ -136,9 +136,6 @@ describe("Recipe catalog", () => {
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso"); cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
cy.contains(".recipe-table__name", "Omelette").should("be.visible"); cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist"); 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", () => { it("searches within the active tab, debounced", () => {

View file

@ -2,6 +2,7 @@ import {
type AddPlanningItemInput, type AddPlanningItemInput,
type AllergyView, type AllergyView,
type ApiErrorResponse, type ApiErrorResponse,
type BrowsableSourceItemView,
type CreateRecipeInput, type CreateRecipeInput,
type DietView, type DietView,
ErrorCode, ErrorCode,
@ -11,6 +12,7 @@ import {
type PlanningItemView, type PlanningItemView,
type PlanningView, type PlanningView,
type PreferencesView, type PreferencesView,
type RecipeImportDraftView,
type RecipeSummaryView, type RecipeSummaryView,
type RecipeTab, type RecipeTab,
type RecipeView, type RecipeView,
@ -173,6 +175,23 @@ export class ApiClient {
return this.request("/reference/sources"); 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<RecipeImportDraftView> {
return this.request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
}
/** /**
* One catalog tab (favoris/perso/foyer/publique see `RecipeTab`), * One catalog tab (favoris/perso/foyer/publique see `RecipeTab`),
* optionally narrowed further `search` (name substring), * optionally narrowed further `search` (name substring),

View file

@ -23,6 +23,9 @@ import "./recipe-picker-dialog.scss";
/** Debounce for the search field — same value as `RecipesPage`'s. */ /** Debounce for the search field — same value as `RecipesPage`'s. */
const SEARCH_DEBOUNCE_MS = 300; 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`. */ /** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */
type ListState = type ListState =
| { status: "loading" } | { status: "loading" }
@ -258,7 +261,11 @@ export function RecipePickerDialog({
)} )}
</div> </div>
<RecipeTabs active={activeTab} onChange={setActiveTab} /> <RecipeTabs
active={activeTab}
onChange={(tab) => setActiveTab(tab as RecipeTab)}
tabs={REAL_RECIPE_TABS}
/>
{listState.status === "loading" && ( {listState.status === "loading" && (
<p className="recipes-page__status">{t("planning.picker.loading")}</p> <p className="recipes-page__status">{t("planning.picker.loading")}</p>

View file

@ -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<EnabledSourcesState>({ status: "loading" });
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(null);
const [previewState, setPreviewState] = useState<SourceItemPreviewState>({ 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 <p className="recipes-page__status">{t("recipes.loading")}</p>;
}
if (enabledSources.status === "error") {
return (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
);
}
if (enabledSources.sources.length === 0) {
return (
<p className="recipes-page__status">
{t("recipes.sources.noneEnabled")}{" "}
<Link to="/parametres/foyer">{t("recipes.sources.noneEnabledLink")}</Link>
</p>
);
}
return (
<>
<div className="recipes-page__header recipes-page__header--sources">
{enabledSources.sources.length > 1 && (
<select
className="source-sources-select"
aria-label={t("recipes.sources.sourceLabel")}
value={selectedSourceKey ?? ""}
onChange={(e) => setSelectedSourceKey(e.target.value)}
>
{enabledSources.sources.map((source) => (
<option key={source.key} value={source.key}>
{source.name}
</option>
))}
</select>
)}
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.sources.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="recipes-page__catalog">
{browseState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
)}
{browseState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.loadError")}
</p>
)}
{browseState.status === "loaded" && browseState.items.length === 0 && (
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
)}
{browseState.status === "loaded" && browseState.items.length > 0 && (
<div className="source-items-column">
<SourceItemTable
items={browseState.items}
selectedExternalId={selectedExternalId}
onSelect={handleSelectItem}
/>
{browseState.nextCursor && (
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
{t("recipes.sources.loadMore")}
</button>
)}
</div>
)}
<SourceItemPreviewPanel state={previewState} />
</div>
</>
);
}

View file

@ -1,37 +1,61 @@
import type { RecipeTab } from "@batch-cooking/shared"; import type { RecipeTab } from "@batch-cooking/shared";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; 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"; 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: "favoris", Icon: FavoriteIcon },
{ value: "perso", Icon: AccountIcon }, { value: "perso", Icon: AccountIcon },
{ value: "foyer", Icon: HouseholdIcon }, { value: "foyer", Icon: HouseholdIcon },
{ value: "publique", Icon: PublicIcon }, { value: "publique", Icon: PublicIcon },
{ value: "sources", Icon: SourcesIcon },
]; ];
/** /**
* Catalog tab bar Favoris / Perso / Foyer / Publique, plus a disabled * Catalog tab bar Favoris / Perso / Foyer / Publique / Sources by
* placeholder for external sources (not built yet, see the plan's "hors * default (`/recettes`, `RecipesPage`). `tabs` narrows which of those
* scope" note) so the eventual nav slot is visible without being * show `RecipePickerDialog` (picking a recipe for a planning slot)
* functional. No "toutes" tab: every recipe a viewer can see falls under * passes just the four real ones: browsing external sources mid-dialog,
* exactly one of perso/foyer/publique (its own visibility) see * 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`. * `recipe.service.ts`'s `listRecipes`.
*/ */
export function RecipeTabs({ export function RecipeTabs({
active, active,
onChange, onChange,
tabs = ALL_TABS.map((tab) => tab.value),
}: { }: {
active: RecipeTab; active: RecipesPageTab;
onChange: (tab: RecipeTab) => void; onChange: (tab: RecipesPageTab) => void;
tabs?: readonly RecipesPageTab[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="recipe-tabs"> <div className="recipe-tabs">
{TABS.map(({ value, Icon }) => ( {ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
<button <button
key={value} key={value}
type="button" type="button"
@ -42,14 +66,6 @@ export function RecipeTabs({
{t(`recipes.tabs.${value}`)} {t(`recipes.tabs.${value}`)}
</button> </button>
))} ))}
<button
type="button"
className="recipe-tabs__tab placeholder"
disabled
title={t("recipes.tabs.sourcesSoonHint")}
>
{t("recipes.tabs.sourcesSoon")}
</button>
</div> </div>
); );
} }

View file

@ -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 (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.empty")}</p>
</aside>
);
}
if (state.status === "loading") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.loading")}</p>
</aside>
);
}
if (state.status === "error") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
{t("recipes.sources.detail.loadError")}
</p>
</aside>
);
}
const { draft } = state;
const hasUnresolvedIngredient = draft.ingredients.some(
(ingredient) => ingredient.ingredient === null,
);
return (
<aside className="recipe-detail-panel">
<div className="recipe-detail-panel__header">
<div className="recipe-detail-panel__photo" aria-hidden="true">
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
</div>
</div>
<div className="recipe-detail-panel__title-row">
<div className="recipe-detail-panel__title-main">
<h2>{draft.name}</h2>
{draft.portions !== null && (
<p className="recipe-detail-panel__portions">
{t("recipes.detail.portions", { count: draft.portions })}
</p>
)}
</div>
</div>
<div className="recipe-detail-panel__actions">
<a
href={draft.sourceUrl}
target="_blank"
rel="noreferrer"
className="recipes-page__new-button"
>
{t("recipes.sources.detail.viewSource")}
</a>
</div>
{draft.description && (
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
<p className="recipe-detail-panel__description">{draft.description}</p>
</section>
)}
<section className="recipe-detail-panel__section">
<h3>{t("recipes.sources.detail.ingredientsCount", { count: draft.ingredients.length })}</h3>
{hasUnresolvedIngredient && (
<p className="source-item-preview__hint">
{t("recipes.sources.detail.unresolvedIngredientsHint")}
</p>
)}
<ul className="source-item-preview__ingredients">
{draft.ingredients.map((ingredient, index) => (
// Draft lines have no id of their own (nothing is saved yet) —
// `rawText` alone could collide (a source repeating the same
// line), so it's paired with its position; this list is fully
// regenerated from `draft` on every render, never reordered in
// place, so that's safe here (same reasoning as
// StepDescription.tsx's segment keys).
<li
key={`${index}-${ingredient.rawText}`}
className={ingredient.ingredient === null ? "is-unresolved" : undefined}
>
{ingredient.rawText}
</li>
))}
</ul>
</section>
<section className="recipe-detail-panel__section">
<h3>{t("recipes.sources.detail.stepsCount", { count: draft.steps.length })}</h3>
<ol className="recipe-detail-panel__steps">
{draft.steps.map((step, index) => (
<li key={`${index}-${step.description}`}>
{step.picture && <img src={step.picture} alt="" />}
<StepDescription description={step.description} techSteps={step.techSteps} />
</li>
))}
</ol>
</section>
</aside>
);
}

View file

@ -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 (
<div className="recipe-table-wrap">
<table className="recipe-table">
<thead>
<tr>
<th />
<th>{t("recipes.table.name")}</th>
<th />
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.externalId}
className={item.externalId === selectedExternalId ? "selected" : undefined}
onClick={() => onSelect(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(item);
}
}}
tabIndex={0}
aria-current={item.externalId === selectedExternalId ? "true" : undefined}
>
<td>
<span className="recipe-table__photo" aria-hidden="true">
{item.picture ? <img src={item.picture} alt="" /> : "🍽️"}
</span>
</td>
<td className="recipe-table__name">{item.title}</td>
<td>
{item.alreadyImported && (
<span className="source-item-table__imported-badge">
{t("recipes.sources.alreadyImported")}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -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 <select> only renders when the household has more than one
// enabled source (see RecipeSourcesPanel) this just keeps it visually
// grouped with the search field when it does.
gap: var(--space-sm);
}
.source-sources-select {
flex: none;
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
.source-item-table__imported-badge {
padding: 0.1rem 0.5rem;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface-alt);
border-radius: var(--radius-pill);
white-space: nowrap;
}
.source-items-column {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
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);
color: var(--color-primary);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
}
.source-item-preview__hint {
margin: 0 0 var(--space-sm);
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
border-radius: var(--radius-base);
}
.source-item-preview__ingredients {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.3rem;
li {
font-size: var(--font-size-sm);
}
.is-unresolved {
color: var(--color-text-muted);
font-style: italic;
}
}
// --- Recipe detail panel ----------------------------------------------------- // --- Recipe detail panel -----------------------------------------------------
// 100% of the grid row's height (see `.recipes-page__catalog` above): the // 100% of the grid row's height (see `.recipes-page__catalog` above): the
// panel itself never scrolls, only its content does past that height // panel itself never scrolls, only its content does past that height

View file

@ -25,4 +25,5 @@ export {
ChevronLeft as ChevronLeftIcon, ChevronLeft as ChevronLeftIcon,
Star as FavoriteIcon, Star as FavoriteIcon,
Globe as PublicIcon, Globe as PublicIcon,
Rss as SourcesIcon,
} from "lucide-react"; } from "lucide-react";

View file

@ -162,14 +162,35 @@
"perso": "Perso", "perso": "Perso",
"foyer": "Foyer", "foyer": "Foyer",
"publique": "Publique", "publique": "Publique",
"sourcesSoon": "Sources (bientôt)", "sources": "Sources"
"sourcesSoonHint": "Un onglet par source externe, une fois l'import de recettes construit"
}, },
"table": { "table": {
"name": "Nom", "name": "Nom",
"allergens": "Allergènes / intolérances", "allergens": "Allergènes / intolérances",
"diets": "Régime associé" "diets": "Régime associé"
}, },
"sources": {
"sourceLabel": "Source",
"noneEnabled": "Aucune source n'est activée pour votre foyer.",
"noneEnabledLink": "Activez-en une dans les paramètres du foyer",
"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.",
"detail": {
"empty": "Sélectionnez une recette dans la liste pour voir son aperçu ici.",
"loading": "Chargement de l'aperçu…",
"loadError": "Impossible de charger l'aperçu de cette recette.",
"viewSource": "Voir sur le site d'origine",
"ingredientsCount_one": "{{count}} ingrédient",
"ingredientsCount_other": "{{count}} ingrédients",
"unresolvedIngredientsHint": "Certains ingrédients n'ont pas été reconnus automatiquement — ils pourront être corrigés à l'import.",
"stepsCount_one": "{{count}} étape",
"stepsCount_other": "{{count}} étapes"
}
},
"detail": { "detail": {
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.", "empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
"favorite": "Ajouter aux favoris", "favorite": "Ajouter aux favoris",

View file

@ -1,11 +1,12 @@
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared"; import { ErrorCode, type RecipeSummaryView } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client"; import { ApiError, apiClient } from "../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel"; import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
import { RecipeSourcesPanel } from "../features/recipes/RecipeSourcesPanel";
import { RecipeTable } from "../features/recipes/RecipeTable"; import { RecipeTable } from "../features/recipes/RecipeTable";
import { RecipeTabs } from "../features/recipes/RecipeTabs"; import { RecipeTabs, type RecipesPageTab } from "../features/recipes/RecipeTabs";
import "../features/recipes/recipes.scss"; import "../features/recipes/recipes.scss";
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */ /** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
@ -37,7 +38,7 @@ export function RecipesPage() {
// filter view would). // filter view would).
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris"); const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const [search, setSearch] = useState(() => searchParams.get("search") ?? ""); const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
// Seeded from the same initial value as `search` — otherwise the first // Seeded from the same initial value as `search` — otherwise the first
// fetch below would fire with an empty term (the debounce effect hasn't // fetch below would fire with an empty term (the debounce effect hasn't
@ -53,6 +54,10 @@ export function RecipesPage() {
}, [search]); }, [search]);
useEffect(() => { useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — it browses
// a source's own live catalog instead (see `RecipeSourcesPanel`, which
// owns its own fetching entirely).
if (activeTab === "sources") return;
let cancelled = false; let cancelled = false;
setListState({ status: "loading" }); setListState({ status: "loading" });
@ -137,13 +142,15 @@ export function RecipesPage() {
<div className="recipes-page"> <div className="recipes-page">
<div className="recipes-page__header"> <div className="recipes-page__header">
<h1>{t("recipes.title")}</h1> <h1>{t("recipes.title")}</h1>
<input {activeTab !== "sources" && (
type="search" <input
className="recipes-page__search" type="search"
placeholder={t("recipes.searchPlaceholder")} className="recipes-page__search"
value={search} placeholder={t("recipes.searchPlaceholder")}
onChange={(e) => setSearch(e.target.value)} value={search}
/> onChange={(e) => setSearch(e.target.value)}
/>
)}
<Link to="/recettes/nouvelle" className="recipes-page__new-button"> <Link to="/recettes/nouvelle" className="recipes-page__new-button">
{t("recipes.newButton")} {t("recipes.newButton")}
</Link> </Link>
@ -151,33 +158,37 @@ export function RecipesPage() {
<RecipeTabs active={activeTab} onChange={setActiveTab} /> <RecipeTabs active={activeTab} onChange={setActiveTab} />
<div className="recipes-page__catalog"> {activeTab === "sources" ? (
{listState.status === "loading" && ( <RecipeSourcesPanel onViewImportedRecipe={() => setActiveTab("favoris")} />
<p className="recipes-page__status">{t("recipes.loading")}</p> ) : (
)} <div className="recipes-page__catalog">
{listState.status === "error" && ( {listState.status === "loading" && (
<p className="recipes-page__status recipes-page__status--error"> <p className="recipes-page__status">{t("recipes.loading")}</p>
{t("common.loadError")} )}
</p> {listState.status === "error" && (
)} <p className="recipes-page__status recipes-page__status--error">
{listState.status === "loaded" && listState.recipes.length === 0 && ( {t("common.loadError")}
<p className="recipes-page__status">{t("recipes.empty")}</p> </p>
)} )}
{listState.status === "loaded" && listState.recipes.length > 0 && ( {listState.status === "loaded" && listState.recipes.length === 0 && (
<RecipeTable <p className="recipes-page__status">{t("recipes.empty")}</p>
recipes={listState.recipes} )}
selectedId={selectedId} {listState.status === "loaded" && listState.recipes.length > 0 && (
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)} <RecipeTable
/> recipes={listState.recipes}
)} selectedId={selectedId}
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
/>
)}
<RecipeDetailPanel <RecipeDetailPanel
state={detailState} state={detailState}
dislikedIngredientIds={dislikedIngredientIds} dislikedIngredientIds={dislikedIngredientIds}
onFavoriteToggled={handleFavoriteToggled} onFavoriteToggled={handleFavoriteToggled}
onDeleted={handleDeleted} onDeleted={handleDeleted}
/> />
</div> </div>
)}
</div> </div>
); );
} }