feat(recipes): unifie l'affichage des recettes externes avec les recettes réelles
Suite au retour utilisateur sur le plan « onglet Sources » livré précédemment (#44-#48) : navigation transparente, page recette pour un item externe, comportement d'ajout au planning déjà importé. L'onglet « Sources » (RecipesPage) reste un onglet à part (décision explicite : pas de fusion des listes perso/foyer/publique/externe) — mais son affichage se comporte désormais « comme si c'était importé » : - `SourceItemPreviewPanel` est supprimé, fusionné dans `RecipeDetailPanel` lui-même (nouvel état `"loaded-draft"`) : un item pas encore importé se voit exactement comme une vraie recette — même en-tête, même mise en page description/étapes — la seule différence étant les actions proposées (« Importer cette recette » là où une vraie recette montre Modifier/Supprimer). La liste brute des ingrédients et l'indice « non résolu » disparaissent de cette vue : cette complexité reste réservée à l'écran de revue d'import (ImportRecipePage), pas à un simple aperçu. - Un item pas encore importé gagne une vraie URL adressable — `/recettes/sources/:sourceKey/:externalId` (nouvelle route, RecipesPage) — au même titre qu'une vraie recette a `/recettes/:id`. Avant, le sélectionner ne changeait que de l'état React local dans `RecipeSourcesPanel`, sans URL propre : ni lien direct, ni retour arrière/rafraîchissement possibles. `RecipeSourcesPanel` gagne `initialSelection`/`onItemSelected` pour rester piloté par cette URL sans avoir à connaître le routage lui-même — `RecipePickerDialog` (qui prévisualise dans une modale sans URL propre) laisse les deux non renseignés et garde son comportement inchangé. - `onSelectImportedRecipe` (déjà présent) continue de traiter un item déjà importé exactement comme une vraie recette — c'est justement ce qui rend la navigation transparente pour ce cas. Le troisième point du retour (vérifier si la recette est déjà en base avant de l'ajouter au planning, ne rien faire si oui, l'importer sinon) était déjà le comportement de #48 — inchangé ici, aucune régression: `RecipePickerDialog` résout un item déjà importé vers sa vraie recette sans ré-import, et n'importe que les items qui ne le sont pas encore. Aucun changement backend. Tests : - Cypress : nouvelle assertion d'URL dans le scénario « Previews a not-yet-imported item » de recipe-sources.feature, et nouveau scénario « Deep-links straight to a not-yet-imported item's own page » — la CI confirmera. - `pnpm --filter api test` — 282 tests toujours au vert (aucun changement backend). - `pnpm exec tsc -b --force` (web) — propre. - `pnpm exec biome check` — propre. - `pnpm -r build` — propre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
be0d885269
commit
368ea08960
10 changed files with 278 additions and 234 deletions
|
|
@ -42,9 +42,20 @@ Feature: Browsing external recipe sources
|
|||
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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -3,13 +3,19 @@ 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;
|
||||
|
||||
/** 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 EnabledSourcesState =
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; sources: SourceView[] }
|
||||
|
|
@ -23,48 +29,77 @@ type BrowseState =
|
|||
/**
|
||||
* "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.
|
||||
* list on the left, a preview on the right), independent of `RecipeTable`'s
|
||||
* own `RecipeTab`-based fetching: it browses a source's *live* catalog
|
||||
* (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table.
|
||||
*
|
||||
* 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` 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.
|
||||
*
|
||||
* `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({
|
||||
onSelectImportedRecipe,
|
||||
planningSlot,
|
||||
initialSelection,
|
||||
onItemSelected,
|
||||
}: {
|
||||
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 [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(
|
||||
initialSelection?.sourceKey ?? 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.
|
||||
// enabled one, if any — but never overrides a source `initialSelection`
|
||||
// already picked (the `current ??` below), so a deep link always wins.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
|
||||
|
|
@ -82,17 +117,58 @@ export function RecipeSourcesPanel({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Re-previews whenever `initialSelection` itself changes (a fresh deep
|
||||
// link, or the browser's back/forward button landing on a different
|
||||
// item) — not just once on mount. Deliberately doesn't touch
|
||||
// `browseState`/the source dropdown beyond `selectedSourceKey` above: the
|
||||
// item list for whichever source this belongs to loads independently
|
||||
// (see the effect below), on its own schedule. Keyed on the primitive
|
||||
// fields 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?.sourceKey === initialSelection.sourceKey &&
|
||||
previewedItem?.externalId === initialSelection.externalId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPreviewedItem(initialSelection);
|
||||
setSelectedSourceKey(initialSelection.sourceKey);
|
||||
setSelectedExternalId(initialSelection.externalId);
|
||||
setPreviewState({ status: "loading" });
|
||||
apiClient
|
||||
.previewSourceItem(initialSelection.sourceKey, initialSelection.externalId)
|
||||
.then((draft) => {
|
||||
if (!cancelled) setPreviewState({ status: "loaded-draft", draft });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPreviewState({ status: "error" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialSelection?.sourceKey, initialSelection?.externalId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [search]);
|
||||
|
||||
// Only manages `browseState` — deliberately doesn't reset the current
|
||||
// item selection/preview when `selectedSourceKey` changes, so that the
|
||||
// `initialSelection` effect above (which also sets `selectedSourceKey`,
|
||||
// to reflect a deep link) isn't immediately undone by this one running
|
||||
// straight after it in the same commit. Switching source via the
|
||||
// dropdown clears the selection explicitly, in its own `onChange` below,
|
||||
// where that reset is actually wanted.
|
||||
useEffect(() => {
|
||||
if (selectedSourceKey === null) return;
|
||||
let cancelled = false;
|
||||
setBrowseState({ status: "loading" });
|
||||
setSelectedExternalId(null);
|
||||
setPreviewState({ status: "empty" });
|
||||
|
||||
apiClient
|
||||
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
|
||||
|
|
@ -131,11 +207,17 @@ export function RecipeSourcesPanel({
|
|||
return;
|
||||
}
|
||||
if (selectedSourceKey === null) return;
|
||||
const selection = { sourceKey: selectedSourceKey, 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 }))
|
||||
.then((draft) => setPreviewState({ status: "loaded-draft", draft }))
|
||||
.catch(() => setPreviewState({ status: "error" }));
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +246,13 @@ export function RecipeSourcesPanel({
|
|||
className="source-sources-select"
|
||||
aria-label={t("recipes.sources.sourceLabel")}
|
||||
value={selectedSourceKey ?? ""}
|
||||
onChange={(e) => setSelectedSourceKey(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSelectedSourceKey(e.target.value);
|
||||
setSelectedExternalId(null);
|
||||
setPreviewState({ status: "empty" });
|
||||
setPreviewedItem(null);
|
||||
onItemSelected?.(null);
|
||||
}}
|
||||
>
|
||||
{enabledSources.sources.map((source) => (
|
||||
<option key={source.key} value={source.key}>
|
||||
|
|
@ -209,7 +297,7 @@ export function RecipeSourcesPanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<SourceItemPreviewPanel state={previewState} planningSlot={planningSlot} />
|
||||
<RecipeDetailPanel state={previewState} planningSlot={planningSlot} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -401,24 +401,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
|
||||
|
|
|
|||
|
|
@ -181,16 +181,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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ 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 "../features/recipes/recipes.scss";
|
||||
|
|
@ -19,18 +22,31 @@ 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 the "Sources" 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;
|
||||
// `?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 +54,14 @@ 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 the "Sources" 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 ? "sources" : "favoris",
|
||||
);
|
||||
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
|
||||
|
|
@ -160,6 +183,14 @@ export function RecipesPage() {
|
|||
|
||||
{activeTab === "sources" ? (
|
||||
<RecipeSourcesPanel
|
||||
initialSelection={selectedSourceItem}
|
||||
onItemSelected={(item) =>
|
||||
navigate(
|
||||
item
|
||||
? `/recettes/sources/${item.sourceKey}/${encodeURIComponent(item.externalId)}`
|
||||
: "/recettes",
|
||||
)
|
||||
}
|
||||
onSelectImportedRecipe={(recipeId) => {
|
||||
setActiveTab("favoris");
|
||||
navigate(`/recettes/${recipeId}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue