Merge pull request #49 from kyuno053/feat/unify-source-detail-view

feat(recipes): unifie l'affichage des recettes externes avec les recettes réelles
This commit is contained in:
kyuno053 2026-08-20 21:06:51 +02:00 committed by GitHub
commit 0f5abb1749
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 452 additions and 364 deletions

View file

@ -21,7 +21,7 @@ Feature: Adding a recipe to the planning
And adding the imported recipe to the planning will succeed
When I visit "/"
And I click the add button for the first empty planning slot
And I click the button "Sources"
And I click the button "TheMealDB"
And I click the source item "Fish Pie"
And I click the link "Importer cette recette"
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."

View file

@ -9,13 +9,12 @@ Feature: Browsing external recipe sources
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
Scenario: Shows no source tab when the household hasn't enabled any
Given the recipe catalog contains nothing
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"
Then I should not see "TheMealDB"
Scenario: Browses an enabled source, distinguishing already-imported items from new ones
Given the recipe catalog contains nothing
@ -24,7 +23,7 @@ Feature: Browsing external recipe sources
And browsing TheMealDB returns some items
And recipe 2's detail is available
When I visit "/recettes"
And I click the button "Sources"
And I click the button "TheMealDB"
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
@ -40,11 +39,22 @@ Feature: Browsing external recipe sources
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 button "TheMealDB"
And I click the source item "Fish Pie"
Then the recipe detail panel heading should be "Fish Pie"
Then the URL should include "/recettes/sources/theMealDb/9999"
And the recipe detail panel heading should be "Fish Pie"
And I should see the highlighted technique "Cuire"
Scenario: Deep-links straight to a not-yet-imported item's own page
Given the recipe catalog contains nothing
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/sources/theMealDb/9999"
Then the recipe detail panel heading should be "Fish Pie"
And I should see "Importer cette recette"
Scenario: Reviews an import, resolving an unrecognized ingredient before confirming
Given the recipe catalog contains nothing
And the sources reference list has options
@ -54,7 +64,7 @@ Feature: Browsing external recipe sources
And the ingredient and diet catalog is available for import
And importing the previewed item will succeed and return id 99
When I visit "/recettes"
And I click the button "Sources"
And I click the button "TheMealDB"
And I click the source item "Fish Pie"
And I click the link "Importer cette recette"
Then the "recipe-name" field should have the value "Fish Pie"

View file

@ -133,10 +133,10 @@ When("I scroll to the section {string}", (legend: string) => {
cy.contains("legend", legend).scrollIntoView();
});
// `.recipe-detail-panel` is used by both a saved recipe's real detail
// (RecipeDetailPanel) and an unsaved source item's read-only preview
// (SourceItemPreviewPanel) — recipes.feature and recipe-sources.feature
// both need this.
// `.recipe-detail-panel` is used by both a saved recipe's real detail and
// an unsaved source item's read-only preview (RecipeDetailPanel's
// `"loaded"`/`"loaded-draft"` states, same component for both) —
// recipes.feature and recipe-sources.feature both need this.
Then("the recipe detail panel heading should be {string}", (text: string) => {
cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible");
});

View file

@ -61,6 +61,7 @@ export function App() {
<Route path="/recettes" element={<RecipesPage />} />
<Route path="/recettes/nouvelle" element={<RecipeFormPage />} />
<Route path="/recettes/importer/:sourceKey/:externalId" element={<ImportRecipePage />} />
<Route path="/recettes/sources/:sourceKey/:externalId" element={<RecipesPage />} />
<Route path="/recettes/:id" element={<RecipesPage />} />
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} />

View file

@ -17,7 +17,13 @@ import { DietTagSelect } from "../recipes/DietTagSelect";
import { IngredientPicker } from "../recipes/IngredientPicker";
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
import { RecipeTable } from "../recipes/RecipeTable";
import { RecipeTabs, type RecipesPageTab } from "../recipes/RecipeTabs";
import {
RecipeTabs,
type RecipesPageTab,
isSourceTab,
parseSourceTabValue,
} from "../recipes/RecipeTabs";
import { useEnabledSources } from "../recipes/useEnabledSources";
import "./recipe-picker-dialog.scss";
/** Debounce for the search field — same value as `RecipesPage`'s. */
@ -48,13 +54,14 @@ export interface PlanningSlot {
* with three extra filters layered on top of the plain name search
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
* `GET /recipes`'s corresponding query params) since browsing here is
* about finding something to cook, not just looking something up. The
* "Sources" tab is included too (unlike an earlier version of this dialog
* see `ImportRecipePage`'s `planningSlot`, the review/import flow that
* made including it here worthwhile): picking an already-imported item
* behaves exactly like picking a regular recipe, and picking one that
* isn't imported yet hands off to that review screen, which adds the
* freshly-created recipe straight to this slot once it's saved.
* about finding something to cook, not just looking something up. Each
* household-enabled source's own tab is included too (unlike an earlier
* version of this dialog see `ImportRecipePage`'s `planningSlot`, the
* review/import flow that made including them here worthwhile): picking
* an already-imported item behaves exactly like picking a regular recipe,
* and picking one that isn't imported yet hands off to that review
* screen, which adds the freshly-created recipe straight to this slot
* once it's saved.
*
* Mounted only while open (see `PlanningPage`, same conditional-mount
* convention as its own `CalendarPopover`) every piece of local state
@ -81,6 +88,8 @@ export function RecipePickerDialog({
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const activeSourceKey = parseSourceTabValue(activeTab);
const enabledSources = useEnabledSources();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
@ -128,9 +137,11 @@ export function RecipePickerDialog({
}, []);
useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — same guard
// as `RecipesPage`'s own identical effect.
if (activeTab === "sources") return;
// A source's own tab doesn't query the recipe table at all — same
// guard as `RecipesPage`'s own identical effect (the type-guard, not
// just `activeSourceKey !== null`, is what narrows `activeTab` to
// `RecipeTab` below).
if (isSourceTab(activeTab)) return;
let cancelled = false;
setListState({ status: "loading" });
@ -157,7 +168,7 @@ export function RecipePickerDialog({
selectedIngredientIds.includes(ingredient.id),
);
/** Picking an already-imported source item (`RecipeSourcesPanel`'s "sources" tab) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */
/** Picking an already-imported source item (one of the source tabs' `RecipeSourcesPanel`) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */
function handleSelectImportedRecipe(recipeId: number) {
setSourceSelectError(false);
apiClient
@ -230,7 +241,7 @@ export function RecipePickerDialog({
return (
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
{activeTab !== "sources" && (
{activeSourceKey === null && (
<div className="recipe-picker__filters">
<input
type="search"
@ -295,9 +306,13 @@ export function RecipePickerDialog({
</div>
)}
<RecipeTabs active={activeTab} onChange={setActiveTab} />
<RecipeTabs
active={activeTab}
onChange={setActiveTab}
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
/>
{activeTab === "sources" ? (
{activeSourceKey !== null ? (
<>
{sourceSelectError && (
<p className="recipes-page__status recipes-page__status--error">
@ -305,6 +320,8 @@ export function RecipePickerDialog({
</p>
)}
<RecipeSourcesPanel
key={activeSourceKey}
sourceKey={activeSourceKey}
planningSlot={slot}
onSelectImportedRecipe={handleSelectImportedRecipe}
/>

View file

@ -1,4 +1,10 @@
import { ErrorCode, type RecipeView } from "@batch-cooking/shared";
import {
ErrorCode,
type Meal,
type RecipeImportDraftView,
type RecipeView,
type WeekDay,
} from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@ -9,11 +15,22 @@ import { FavoriteStarButton } from "./FavoriteStarButton";
import { StepDescription } from "./StepDescription";
import "./recipes.scss";
/** State {@link RecipeDetailPanel} renders — `"empty"` (no row selected yet) is distinct from `"not-found"` (a selected id that turned out invalid/inaccessible), each with its own message. */
/**
* State {@link RecipeDetailPanel} renders `"empty"` (no row selected yet)
* is distinct from `"not-found"` (a selected id that turned out invalid/
* inaccessible), each with its own message. `"loaded-draft"` is the one
* variant that isn't a real, saved `Recipe`: a not-yet-imported source
* item's preview (`RecipeSourcesPanel`'s "Sources" tab) rendered through
* this exact same component so viewing one looks and behaves like viewing
* any other recipe ("comme si c'était importé"), differing only in which
* actions make sense (there's nothing to favorite/edit/delete yet, but
* there is something to *import*).
*/
export type RecipeDetailState =
| { status: "empty" }
| { status: "loading" }
| { status: "loaded"; recipe: RecipeView }
| { status: "loaded-draft"; draft: RecipeImportDraftView }
| { status: "not-found" }
| { status: "error" };
@ -24,18 +41,29 @@ export type RecipeDetailState =
* *viewer's* personal taste-preference list (`GET
* /profile/disliked-ingredients`) crossed here against this recipe's own
* ingredients to surface just the ones relevant to it, not the viewer's
* whole list.
* whole list. `onFavoriteToggled`/`onDeleted` are optional only the
* `"loaded"` (real recipe) branch ever calls them; callers that only ever
* pass `"loaded-draft"`/other states (`RecipeSourcesPanel`) can omit them.
*/
export function RecipeDetailPanel({
state,
dislikedIngredientIds,
dislikedIngredientIds = [],
onFavoriteToggled,
onDeleted,
planningSlot,
}: {
state: RecipeDetailState;
dislikedIngredientIds: number[];
onFavoriteToggled: (recipeId: number, isFavorite: boolean) => void;
onDeleted: (recipeId: number) => void;
dislikedIngredientIds?: number[];
onFavoriteToggled?: (recipeId: number, isFavorite: boolean) => void;
onDeleted?: (recipeId: number) => void;
/**
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
* recipe to one planning slot) carried along on a `"loaded-draft"`
* item's "Importer cette recette" link as query params, so `ImportRecipePage`
* knows to add the freshly-created recipe to this exact slot once the
* import succeeds. See `ImportRecipePage`'s own `planningSlot`.
*/
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
}) {
const { t } = useTranslation();
@ -72,6 +100,70 @@ export function RecipeDetailPanel({
);
}
if (state.status === "loaded-draft") {
const { draft } = state;
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">
<Link
to={{
pathname: `/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`,
search: planningSlot
? `?planningDate=${planningSlot.date}&planningWeekDay=${planningSlot.weekDay}&planningMeal=${planningSlot.meal}`
: undefined,
}}
className="recipes-page__new-button"
>
{t("recipes.sources.detail.importButton")}
</Link>
<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.stepsTitle")}</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>
);
}
const { recipe } = state;
const dislikedIngredients = recipe.ingredients
.map((line) => line.ingredient)
@ -86,7 +178,7 @@ export function RecipeDetailPanel({
<FavoriteStarButton
recipeId={recipe.id}
isFavorite={recipe.isFavorite}
onToggled={(isFavorite) => onFavoriteToggled(recipe.id, isFavorite)}
onToggled={(isFavorite) => onFavoriteToggled?.(recipe.id, isFavorite)}
/>
</div>
@ -115,7 +207,7 @@ export function RecipeDetailPanel({
<Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button">
{t("recipes.editButton")}
</Link>
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted(recipe.id)} />
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted?.(recipe.id)} />
</div>
{recipe.description && (

View file

@ -1,19 +1,19 @@
import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared";
import type { BrowsableSourceItemView, Meal, WeekDay } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { apiClient } from "../../api/client";
import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
import { RecipeDetailPanel, type RecipeDetailState } from "./RecipeDetailPanel";
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" };
/** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */
export interface SourceItemSelection {
sourceKey: string;
externalId: string;
}
type BrowseState =
| { status: "loading" }
@ -21,66 +21,107 @@ type BrowseState =
| { 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.
* One household-enabled source's own tab content in the recipe catalog
* (`RecipesPage`/`RecipePickerDialog`) a master-detail pair of its own
* (browsable list on the left, a preview on the right), independent of
* `RecipeTable`'s own `RecipeTab`-based fetching: it browses this one
* source's *live* catalog (`GET /sources/:sourceKey/browse`), not the
* saved `Recipe` table.
*
* Scoped to exactly one source every enabled source gets its own tab
* now (`RecipeTabs`), rather than a single generic "Sources" tab
* switching between them internally, so `sourceKey` is a fixed prop, not
* something this component ever changes itself. Callers remount this
* (via a React `key={sourceKey}` on it) when switching which source's tab
* is active, the same "mounted only while relevant" convention as
* `RecipePickerDialog`/`CalendarPopover` elsewhere simpler than this
* component reacting to its own `sourceKey` prop changing mid-lifetime.
*
* The right-hand preview reuses `RecipeDetailPanel` itself (its
* `"loaded-draft"` state) rather than a separate component viewing a
* not-yet-imported item is meant to look and feel exactly like viewing any
* other recipe, differing only in which actions are offered (there's an
* "Importer" button where Modifier/Supprimer would be).
*
* 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.
* recipe (`/recettes/:id`, leaving this tab) `onSelectImportedRecipe`
* hands back the id instead of this panel navigating anywhere itself, since
* what "viewing" an already-imported item means depends on the caller:
* `RecipesPage` navigates to the recipe's detail page (switching its own
* active tab first see its own doc comment), while `RecipePickerDialog`
* instead treats it exactly like picking that recipe from one of the
* regular tabs moving to its own confirm-portions step, no navigation
* at all.
*
* `onSelectImportedRecipe` hands back the id instead of this panel
* navigating anywhere itself what "viewing" an already-imported item
* means depends on the caller: `RecipesPage` switches its own active tab
* away from `"sources"` (its `RecipeDetailPanel`/`RecipeTable` only render
* outside that tab, so without switching first the URL would change but
* this panel would keep rendering over it) and navigates to the recipe's
* detail page, while `RecipePickerDialog` instead treats it exactly like
* picking that recipe from one of the regular tabs moving to its own
* confirm-portions step, no navigation at all.
* `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a
* not-yet-imported item's preview addressable by URL
* (`/recettes/sources/:sourceKey/:externalId`) without this panel needing
* to know anything about routing itself it reports selection changes
* upward, and re-previews on mount/prop-change if handed one back.
* `RecipePickerDialog` leaves both unset: previewing inside that modal has
* no URL of its own to keep in sync.
*/
export function RecipeSourcesPanel({
sourceKey,
onSelectImportedRecipe,
planningSlot,
initialSelection,
onItemSelected,
}: {
sourceKey: string;
onSelectImportedRecipe: (recipeId: number) => void;
/** Forwarded as-is to `SourceItemPreviewPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
/** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
initialSelection?: SourceItemSelection;
onItemSelected?: (item: SourceItemSelection | null) => void;
}) {
const { t } = useTranslation();
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" });
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(
initialSelection?.externalId ?? null,
);
const [previewState, setPreviewState] = useState<RecipeDetailState>({ status: "empty" });
// Which item `previewState` actually reflects (or is in flight for) —
// lets the `initialSelection` effect below tell "the URL just changed to
// match a selection this component already made itself" (a row click
// already fetched/is fetching this exact item; `onItemSelected` only
// round-trips that same pair back in as a new `initialSelection` prop)
// apart from "the URL changed to point somewhere new" (a deep link, or
// the browser's back/forward button) — only the latter needs a fetch.
// Always starts at `null`, even when `initialSelection` is already set on
// mount — nothing has been fetched yet at that point, that's exactly the
// "needs a fetch" case the effect below must still run for.
const [previewedItem, setPreviewedItem] = useState<SourceItemSelection | null>(null);
// Loaded once — which sources exist, crossed with which the household
// has enabled (`/parametres/foyer`). Defaults the selector to the first
// enabled one, if any.
// Re-previews whenever `initialSelection` itself changes (a fresh deep
// link, or the browser's back/forward button landing on a different
// item within this same source) — not just once on mount. Keyed on the
// primitive field below, not `initialSelection` itself — a fresh object
// literal from the caller on every render (see `RecipesPage`) would
// otherwise re-run this on every render too.
// biome-ignore lint/correctness/useExhaustiveDependencies: see above.
useEffect(() => {
if (!initialSelection) return;
if (previewedItem?.externalId === initialSelection.externalId) return;
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);
setPreviewedItem(initialSelection);
setSelectedExternalId(initialSelection.externalId);
setPreviewState({ status: "loading" });
apiClient
.previewSourceItem(sourceKey, initialSelection.externalId)
.then((draft) => {
if (!cancelled) setPreviewState({ status: "loaded-draft", draft });
})
.catch(() => {
if (!cancelled) setEnabledSources({ status: "error" });
if (!cancelled) setPreviewState({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
}, [initialSelection?.externalId]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
@ -88,14 +129,11 @@ export function RecipeSourcesPanel({
}, [search]);
useEffect(() => {
if (selectedSourceKey === null) return;
let cancelled = false;
setBrowseState({ status: "loading" });
setSelectedExternalId(null);
setPreviewState({ status: "empty" });
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
.then(({ items, nextCursor }) => {
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
})
@ -106,15 +144,15 @@ export function RecipeSourcesPanel({
return () => {
cancelled = true;
};
}, [selectedSourceKey, debouncedSearch]);
}, [sourceKey, debouncedSearch]);
function handleLoadMore() {
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) {
if (browseState.status !== "loaded" || !browseState.nextCursor) {
return;
}
const cursor = browseState.nextCursor;
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor })
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor })
.then(({ items, nextCursor }) => {
setBrowseState((prev) =>
prev.status === "loaded"
@ -130,49 +168,23 @@ export function RecipeSourcesPanel({
onSelectImportedRecipe(item.recipeId);
return;
}
if (selectedSourceKey === null) return;
const selection = { sourceKey, externalId: item.externalId };
setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" });
// Set before `onItemSelected` so the `initialSelection` effect above
// recognizes the URL change it triggers as reflecting this same fetch,
// not a fresh one to make (see that effect's own doc comment).
setPreviewedItem(selection);
onItemSelected?.(selection);
apiClient
.previewSourceItem(selectedSourceKey, item.externalId)
.then((draft) => setPreviewState({ status: "loaded", draft }))
.previewSourceItem(sourceKey, item.externalId)
.then((draft) => setPreviewState({ status: "loaded-draft", 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"
@ -209,7 +221,7 @@ export function RecipeSourcesPanel({
</div>
)}
<SourceItemPreviewPanel state={previewState} planningSlot={planningSlot} />
<RecipeDetailPanel state={previewState} planningSlot={planningSlot} />
</div>
</>
);

View file

@ -1,4 +1,4 @@
import type { RecipeTab } from "@batch-cooking/shared";
import type { RecipeTab, SourceView } from "@batch-cooking/shared";
import type { LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
@ -10,52 +10,79 @@ import {
} from "../../layouts/nav-icons";
import "./recipes.scss";
/** Prefix marking a tab value as "browse this one enabled source's live catalog" rather than a real `RecipeTab` — see {@link sourceTabValue}/{@link parseSourceTabValue}, the one place this shape is assembled/read apart. */
const SOURCE_TAB_PREFIX = "source:";
/**
* 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
* /recipes?tab=`, `recipe.service.ts`'s `listRecipes`) or a `"source:<key>"`
* value identifying one household-enabled external source, browsed 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.
* listing saved `Recipe` rows one tab per enabled source (see
* `RecipeTabs` below), so switching between sources is as direct as
* switching between Perso/Foyer/Publique, not a single generic "Sources"
* tab hiding a second selector inside it. Kept out of the shared
* `RecipeTab` type on purpose the API has no such `tab=` value to
* validate, this is a web-only browsing mode.
*/
export type RecipesPageTab = RecipeTab | "sources";
export type RecipesPageTab = RecipeTab | `${typeof SOURCE_TAB_PREFIX}${string}`;
/** 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 }> = [
/** Builds the tab value identifying `sourceKey`'s own tab. */
export function sourceTabValue(sourceKey: string): RecipesPageTab {
return `${SOURCE_TAB_PREFIX}${sourceKey}`;
}
/** The reverse of {@link sourceTabValue} — `null` for any tab that isn't a source tab (a real `RecipeTab`). */
export function parseSourceTabValue(tab: RecipesPageTab): string | null {
return isSourceTab(tab) ? tab.slice(SOURCE_TAB_PREFIX.length) : null;
}
/** Type guard version of the same check — narrows `tab` to a real `RecipeTab` in the `false` branch, which a plain `parseSourceTabValue(tab) === null` check can't (TS can't see through the function call). Needed wherever the narrowed value gets passed on to something typed as `RecipeTab`, e.g. `apiClient.listRecipes`. */
export function isSourceTab(tab: RecipesPageTab): tab is `${typeof SOURCE_TAB_PREFIX}${string}` {
return tab.startsWith(SOURCE_TAB_PREFIX);
}
/** The four real, DB-backed tabs, in display order, with their icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. No "toutes" tab among them: every recipe a viewer can see falls under exactly one of perso/foyer/publique (its own visibility) — see `recipe.service.ts`'s `listRecipes`. */
const REAL_TABS: Array<{ value: RecipeTab; 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 / 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`.
* Catalog tab bar Favoris / Perso / Foyer / Publique, plus one tab per
* household-enabled source (e.g. "TheMealDB"), in that order. A source's
* own icon (`SourceView.iconUrl`) is used when it has one, `SourcesIcon`
* otherwise unlike the four real tabs, whose label comes from an i18n
* key, a source tab's label is its own name as-is (there's no translation
* for an arbitrary household-picked source's name).
*
* `tabs` narrows which of the four *real* tabs show every enabled
* source still gets its own tab regardless (narrowing individual sources
* doesn't make sense the way narrowing the four real ones does).
* `RecipePickerDialog` used to pass just the four real ones, before its
* own review/import flow existed to hand a picked source item off to; now
* every caller shows the full set, but the narrowing stays available for
* a future caller that still wants it.
*/
export function RecipeTabs({
active,
onChange,
tabs = ALL_TABS.map((tab) => tab.value),
sources,
tabs = REAL_TABS.map((tab) => tab.value),
}: {
active: RecipesPageTab;
onChange: (tab: RecipesPageTab) => void;
tabs?: readonly RecipesPageTab[];
/** Household-enabled sources, one tab each. Pass `[]` while still loading (see `useEnabledSources`) — that's indistinguishable from "none enabled" for this bar, which simply renders no source tabs either way. */
sources: readonly SourceView[];
tabs?: readonly RecipeTab[];
}) {
const { t } = useTranslation();
return (
<div className="recipe-tabs">
{ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
{REAL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
<button
key={value}
type="button"
@ -66,6 +93,24 @@ export function RecipeTabs({
{t(`recipes.tabs.${value}`)}
</button>
))}
{sources.map((source) => {
const value = sourceTabValue(source.key);
return (
<button
key={source.key}
type="button"
className={`recipe-tabs__tab${value === active ? " active" : ""}`}
onClick={() => onChange(value)}
>
{source.iconUrl ? (
<img src={source.iconUrl} alt="" className="recipe-tabs__source-icon" />
) : (
<SourcesIcon aria-hidden="true" />
)}
{source.name}
</button>
);
})}
</div>
);
}

View file

@ -1,154 +0,0 @@
import type { Meal, RecipeImportDraftView, WeekDay } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
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,
planningSlot,
}: {
state: SourceItemPreviewState;
/**
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
* recipe to one planning slot) rather than the standalone `/recettes`
* catalog carried along on the "Importer cette recette" link as query
* params so `ImportRecipePage` knows to add the freshly-created recipe to
* this exact slot once the import succeeds, instead of landing on the
* recipe's own detail page. See `ImportRecipePage`'s `planningSlot`.
*/
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
}) {
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">
<Link
to={{
pathname: `/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`,
search: planningSlot
? `?planningDate=${planningSlot.date}&planningWeekDay=${planningSlot.weekDay}&planningMeal=${planningSlot.meal}`
: undefined,
}}
className="recipes-page__new-button"
>
{t("recipes.sources.detail.importButton")}
</Link>
<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

@ -190,8 +190,9 @@
gap: var(--space-xs);
// Never lets a tab overflow the page (which would force the whole body
// to scroll horizontally, see global.scss's rule against that) — scrolls
// within itself instead once the tabs (including the disabled "Sources"
// placeholder) don't all fit, same pattern as the sidebar's own nav.
// within itself instead once the tabs (favoris/perso/foyer/publique,
// plus one per household-enabled source) don't all fit, same pattern as
// the sidebar's own nav.
overflow-x: auto;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-md);
@ -222,6 +223,16 @@
flex: none;
}
// A source tab's own icon (`SourceView.iconUrl`) — sized to match the
// Lucide `svg` icons above so a source tab doesn't stand out from the
// four real ones.
.recipe-tabs__source-icon {
width: 1.05rem;
height: 1.05rem;
flex: none;
object-fit: contain;
}
&:hover {
color: var(--color-text);
}
@ -340,23 +351,9 @@
// 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);
@ -401,24 +398,6 @@
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 -----------------------------------------------------
// 100% of the grid row's height (see `.recipes-page__catalog` above): the
// panel itself never scrolls, only its content does past that height

View file

@ -0,0 +1,40 @@
import type { SourceView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { apiClient } from "../../api/client";
export type EnabledSourcesState =
| { status: "loading" }
| { status: "loaded"; sources: SourceView[] }
| { status: "error" };
/**
* Which sources exist, crossed with which the household has enabled
* (`/parametres/foyer`) shared by `RecipesPage` and `RecipePickerDialog`,
* both of which need this to render one `RecipeTabs` tab per enabled
* source (see `RecipeTabs`' own doc comment). Loaded once per mount, not
* re-fetched on every render a household's enabled sources only change
* from the settings page, never from here.
*/
export function useEnabledSources(): EnabledSourcesState {
const [state, setState] = useState<EnabledSourcesState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
.then(([sources, enabledIds]) => {
if (cancelled) return;
setState({
status: "loaded",
sources: sources.filter((source) => enabledIds.includes(source.id)),
});
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
return state;
}

View file

@ -162,8 +162,7 @@
"favoris": "Favoris",
"perso": "Perso",
"foyer": "Foyer",
"publique": "Publique",
"sources": "Sources"
"publique": "Publique"
},
"table": {
"name": "Nom",
@ -171,9 +170,6 @@
"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",
@ -181,16 +177,8 @@
"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",
"importButton": "Importer cette recette",
"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"
"importButton": "Importer cette recette"
},
"import": {
"title": "Revoir l'import",

View file

@ -73,7 +73,8 @@ function parsePlanningSlot(
/**
* Review screen for finalizing an import routed at
* `/recettes/importer/:sourceKey/:externalId` (reached from
* `SourceItemPreviewPanel`'s "Importer cette recette" button). Pre-filled
* `RecipeDetailPanel`'s "Importer cette recette" button, shown for its
* `"loaded-draft"` state). Pre-filled
* from `GET /sources/:sourceKey/preview/:externalId` (the same draft the
* preview panel already showed), structurally the same form as
* `RecipeFormPage` same sub-components (`IngredientRow`,
@ -92,8 +93,8 @@ function parsePlanningSlot(
*
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this
* page was reached from `RecipePickerDialog`'s "Sources" tab (via
* `SourceItemPreviewPanel`'s import link, see its own `planningSlot` prop)
* picking a not-yet-imported item there hands off to this full review
* `RecipeDetailPanel`'s import link, see its own `planningSlot` prop)
* picking a not-yet-imported item there hands off to this full review
* screen instead of the dialog's own small "how many portions?" step,
* since an unresolved-ingredient review doesn't fit in that step. When
* present and well-formed, a successful import also adds the freshly

View file

@ -4,9 +4,19 @@ import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
import { RecipeSourcesPanel } from "../features/recipes/RecipeSourcesPanel";
import {
RecipeSourcesPanel,
type SourceItemSelection,
} from "../features/recipes/RecipeSourcesPanel";
import { RecipeTable } from "../features/recipes/RecipeTable";
import { RecipeTabs, type RecipesPageTab } from "../features/recipes/RecipeTabs";
import {
RecipeTabs,
type RecipesPageTab,
isSourceTab,
parseSourceTabValue,
sourceTabValue,
} from "../features/recipes/RecipeTabs";
import { useEnabledSources } from "../features/recipes/useEnabledSources";
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`). */
@ -19,18 +29,32 @@ type RecipeListState =
| { status: "error" };
/**
* Recipe catalog routed at both `/recettes` and `/recettes/:id` (the same
* component either way, see `App.tsx`): a tab bar + table on the left stay
* mounted at all times, only the right-hand detail panel changes with the
* `:id` param a master-detail layout, not a navigation to a separate
* page (see `RecipeDetailPanel`, which replaces the earlier standalone
* Recipe catalog routed at `/recettes`, `/recettes/:id`, and
* `/recettes/sources/:sourceKey/:externalId` (the same component either
* way, see `App.tsx`): a tab bar + table on the left stay mounted at all
* times, only the right-hand detail panel changes with the URL a
* master-detail layout, not a navigation to a separate page (see
* `RecipeDetailPanel`, which replaces the earlier standalone
* `RecipeDetailPage`).
*
* The `sources` route exists so a not-yet-imported item is just as
* addressable/deep-linkable as a real recipe's `/recettes/:id` without
* it, selecting one inside its source's own tab only changed local
* component state, with no URL of its own (see `RecipeSourcesPanel`'s
* `initialSelection`/`onItemSelected`, which this page drives).
*/
export function RecipesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { id, sourceKey, externalId } = useParams<{
id: string;
sourceKey: string;
externalId: string;
}>();
const selectedId = id !== undefined ? Number(id) : null;
const selectedSourceItem: SourceItemSelection | undefined =
sourceKey !== undefined && externalId !== undefined ? { sourceKey, externalId } : undefined;
const enabledSources = useEnabledSources();
// `?search=` lets another page (the recipe form's "faisable maison"
// badge, see `ReproducibleBadge`) deep-link straight into a pre-filled
// search — read once on mount, not kept in sync on every keystroke
@ -38,7 +62,15 @@ export function RecipesPage() {
// filter view would).
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
// Deep-linking straight into `/recettes/sources/:sourceKey/:externalId`
// must land on that source's own tab — otherwise `RecipeSourcesPanel`
// (which reads this URL via `selectedSourceItem` below) wouldn't even be
// mounted to show it. Lazy initializer: only matters for this page's
// very first render, same reasoning as `search`'s below.
const [activeTab, setActiveTab] = useState<RecipesPageTab>(() =>
selectedSourceItem ? sourceTabValue(selectedSourceItem.sourceKey) : "favoris",
);
const activeSourceKey = parseSourceTabValue(activeTab);
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
// Seeded from the same initial value as `search` — otherwise the first
// fetch below would fire with an empty term (the debounce effect hasn't
@ -54,10 +86,12 @@ export function RecipesPage() {
}, [search]);
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;
// A source's own tab doesn't query the recipe table at all — it
// browses that source's live catalog instead (see `RecipeSourcesPanel`,
// which owns its own fetching entirely). The type-guard (not just
// `activeSourceKey !== null`) is what lets `activeTab` narrow to
// `RecipeTab` below, for `apiClient.listRecipes`.
if (isSourceTab(activeTab)) return;
let cancelled = false;
setListState({ status: "loading" });
@ -142,7 +176,7 @@ export function RecipesPage() {
<div className="recipes-page">
<div className="recipes-page__header">
<h1>{t("recipes.title")}</h1>
{activeTab !== "sources" && (
{activeSourceKey === null && (
<input
type="search"
className="recipes-page__search"
@ -156,10 +190,33 @@ export function RecipesPage() {
</Link>
</div>
<RecipeTabs active={activeTab} onChange={setActiveTab} />
<RecipeTabs
active={activeTab}
onChange={setActiveTab}
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
/>
{activeTab === "sources" ? (
{activeSourceKey !== null ? (
<RecipeSourcesPanel
key={activeSourceKey}
sourceKey={activeSourceKey}
// Only meaningful when it actually belongs to this source — e.g.
// clicking straight from TheMealDB's item "9999" to Marmiton's tab
// changes `activeSourceKey` before `selectedSourceItem` (URL-driven)
// catches up, since only picking a *row* navigates, not switching
// tabs. Passing it through unguarded would have the freshly
// (`key`-forced) remounted panel try to preview "9999" against the
// wrong source.
initialSelection={
selectedSourceItem?.sourceKey === activeSourceKey ? selectedSourceItem : undefined
}
onItemSelected={(item) =>
navigate(
item
? `/recettes/sources/${item.sourceKey}/${encodeURIComponent(item.externalId)}`
: "/recettes",
)
}
onSelectImportedRecipe={(recipeId) => {
setActiveTab("favoris");
navigate(`/recettes/${recipeId}`);