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:
commit
0f5abb1749
14 changed files with 452 additions and 364 deletions
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
apps/web/src/features/recipes/useEnabledSources.ts
Normal file
40
apps/web/src/features/recipes/useEnabledSources.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,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}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue