feat(recipes): onglet Sources — parcourir les recettes externes (étape 2/4)
Deuxième étape du chantier "onglet Sources" : l'UI de parcours, construite contre les endpoints backend de l'étape 1 (#45). L'onglet désactivé placeholder de RecipeTabs devient un vrai onglet fonctionnel. - RecipeTabs.tsx : nouveau type RecipesPageTab (RecipeTab | "sources") — gardé hors du type partagé RecipeTab puisque l'API n'a pas de tab=sources à valider. Un prop `tabs` optionnel restreint quels onglets s'affichent — RecipePickerDialog (choix d'une recette pour un planning) s'y restreint aux 4 onglets réels, parcourir des sources externes en plein milieu de ce dialogue n'a pas de sens sans le flux de revue/import. - Nouveau RecipeSourcesPanel.tsx : contenu de l'onglet "Sources" — autonome (son propre master-detail), ne partage pas le fetching RecipeTab de RecipesPage puisqu'il parcourt le catalogue *live* d'une source (GET /sources/:key/browse), pas la table Recipe sauvegardée. Sélecteur de source si le foyer en a activé plusieurs ; sélectionner un item déjà importé navigue directement vers la vraie recette (SourceItemTable + navigate), un item pas encore importé affiche un aperçu en lecture seule (SourceItemPreviewPanel, réutilise StepDescription — les tech steps sont donc déjà surlignés dans l'aperçu). - Bug trouvé et corrigé en écrivant le scénario Cypress : cliquer un item déjà importé changeait l'URL mais restait affiché sur l'onglet Sources (RecipesPage ne rend RecipeDetailPanel/RecipeTable qu'en dehors de l'onglet "sources"). RecipeSourcesPanel prend maintenant un callback `onViewImportedRecipe` pour repasser sur un onglet réel avant de naviguer. Tests : nouveau recipe-sources.feature (parcours utilisateur complet — onglet vide, parcours avec items importés/non importés, aperçu avec surlignage de technique) ; recipes.cy.ts corrigé (assertion obsolète sur l'ancien placeholder désactivé). Étape suivante (3/4) : écran de revue (corriger les ingrédients non résolus) + finalisation de l'import. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
44ef5e071f
commit
b5a12cf489
13 changed files with 767 additions and 61 deletions
46
apps/web/cypress/e2e/recipe-sources.feature
Normal file
46
apps/web/cypress/e2e/recipe-sources.feature
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
Feature: Browsing external recipe sources
|
||||||
|
As a signed-in user
|
||||||
|
I want to browse the recipes available from my household's enabled sources
|
||||||
|
So that I can find new recipes to import, or jump straight to ones I already have
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And my household id is 1
|
||||||
|
And the disliked ingredients list is empty
|
||||||
|
And the planning request returns nothing
|
||||||
|
|
||||||
|
Scenario: Prompts to enable a source when the household hasn't enabled any
|
||||||
|
Given the recipe catalog contains "Omelette"
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household's enabled sources are empty
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the button "Sources"
|
||||||
|
Then I should see "Aucune source n'est activée"
|
||||||
|
|
||||||
|
Scenario: Browses an enabled source, distinguishing already-imported items from new ones
|
||||||
|
Given the recipe catalog contains "Omelette"
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household has enabled TheMealDB
|
||||||
|
And browsing TheMealDB returns some items
|
||||||
|
And recipe 2's detail is available
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the button "Sources"
|
||||||
|
Then I should see the source item "Chicken Handi"
|
||||||
|
And I should see the source item "Fish Pie"
|
||||||
|
And the source item "Chicken Handi" should be marked as already imported
|
||||||
|
|
||||||
|
When I click the source item "Chicken Handi"
|
||||||
|
Then the URL should include "/recettes/2"
|
||||||
|
And the recipe detail panel heading should be "Omelette"
|
||||||
|
|
||||||
|
Scenario: Previews a not-yet-imported item, highlighting its detected techniques
|
||||||
|
Given the recipe catalog contains "Omelette"
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household has enabled TheMealDB
|
||||||
|
And browsing TheMealDB returns some items
|
||||||
|
And previewing TheMealDB item "9999" is available
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the button "Sources"
|
||||||
|
And I click the source item "Fish Pie"
|
||||||
|
Then the recipe detail panel heading should be "Fish Pie"
|
||||||
|
And I should see the highlighted technique "Cuire"
|
||||||
93
apps/web/cypress/e2e/recipe-sources.ts
Normal file
93
apps/web/cypress/e2e/recipe-sources.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
||||||
|
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
|
||||||
|
// behavior against a real database (see test/sources.test.ts).
|
||||||
|
//
|
||||||
|
// "the sources reference list has options" (theMealDb id 1, marmiton id 2)
|
||||||
|
// and "recipe 2's detail is available" are shared with reference-data.steps.ts
|
||||||
|
// / recipes.ts respectively — Cucumber step matching is global across every
|
||||||
|
// step-definition file, not scoped per feature.
|
||||||
|
|
||||||
|
Given("the household has enabled TheMealDB", () => {
|
||||||
|
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("browsing TheMealDB returns some items", () => {
|
||||||
|
cy.intercept("GET", "**/sources/theMealDb/browse*", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
externalId: "52795",
|
||||||
|
title: "Chicken Handi",
|
||||||
|
picture: null,
|
||||||
|
url: "https://www.themealdb.com/meal/52795",
|
||||||
|
alreadyImported: true,
|
||||||
|
recipeId: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
externalId: "9999",
|
||||||
|
title: "Fish Pie",
|
||||||
|
picture: null,
|
||||||
|
url: "https://www.themealdb.com/meal/9999",
|
||||||
|
alreadyImported: false,
|
||||||
|
recipeId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
|
||||||
|
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
sourceKey: "theMealDb",
|
||||||
|
externalId,
|
||||||
|
name: "Fish Pie",
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
portions: 4,
|
||||||
|
sourceUrl: "https://www.themealdb.com/meal/9999",
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
rawText: "1 onion",
|
||||||
|
quantity: 1,
|
||||||
|
ingredient: {
|
||||||
|
id: 1,
|
||||||
|
key: "onion",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "freshProduce",
|
||||||
|
subcategory: "vegetables",
|
||||||
|
reproducible: false,
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
unit: null,
|
||||||
|
},
|
||||||
|
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
description: "Cuire à la poêle.",
|
||||||
|
picture: null,
|
||||||
|
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("I should see the source item {string}", (title: string) => {
|
||||||
|
cy.contains(".recipe-table__name", title).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the source item {string}", (title: string) => {
|
||||||
|
cy.contains(".recipe-table__name", title).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the source item {string} should be marked as already imported", (title: string) => {
|
||||||
|
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
|
||||||
|
});
|
||||||
|
|
@ -136,9 +136,6 @@ describe("Recipe catalog", () => {
|
||||||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
|
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
|
||||||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
||||||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
||||||
|
|
||||||
// The disabled "Sources (bientôt)" placeholder never becomes active.
|
|
||||||
cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("searches within the active tab, debounced", () => {
|
it("searches within the active tab, debounced", () => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
type AddPlanningItemInput,
|
type AddPlanningItemInput,
|
||||||
type AllergyView,
|
type AllergyView,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
|
type BrowsableSourceItemView,
|
||||||
type CreateRecipeInput,
|
type CreateRecipeInput,
|
||||||
type DietView,
|
type DietView,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
|
@ -11,6 +12,7 @@ import {
|
||||||
type PlanningItemView,
|
type PlanningItemView,
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
type PreferencesView,
|
type PreferencesView,
|
||||||
|
type RecipeImportDraftView,
|
||||||
type RecipeSummaryView,
|
type RecipeSummaryView,
|
||||||
type RecipeTab,
|
type RecipeTab,
|
||||||
type RecipeView,
|
type RecipeView,
|
||||||
|
|
@ -173,6 +175,23 @@ export class ApiClient {
|
||||||
return this.request("/reference/sources");
|
return this.request("/reference/sources");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */
|
||||||
|
public browseSource(
|
||||||
|
sourceKey: string,
|
||||||
|
params: { query?: string; cursor?: string } = {},
|
||||||
|
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.query) search.set("query", params.query);
|
||||||
|
if (params.cursor) search.set("cursor", params.cursor);
|
||||||
|
const queryString = search.toString();
|
||||||
|
return this.request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */
|
||||||
|
public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> {
|
||||||
|
return this.request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
||||||
* optionally narrowed further — `search` (name substring),
|
* optionally narrowed further — `search` (name substring),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import "./recipe-picker-dialog.scss";
|
||||||
/** Debounce for the search field — same value as `RecipesPage`'s. */
|
/** Debounce for the search field — same value as `RecipesPage`'s. */
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/** Restricts this dialog's `RecipeTabs` to the four real, DB-backed tabs — browsing external sources mid-dialog (`RecipeTabs`' "sources" tab) doesn't make sense here yet, with no review/import flow to hand a picked item off to. */
|
||||||
|
const REAL_RECIPE_TABS: readonly RecipeTab[] = ["favoris", "perso", "foyer", "publique"];
|
||||||
|
|
||||||
/** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */
|
/** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */
|
||||||
type ListState =
|
type ListState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
|
|
@ -258,7 +261,11 @@ export function RecipePickerDialog({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
<RecipeTabs
|
||||||
|
active={activeTab}
|
||||||
|
onChange={(tab) => setActiveTab(tab as RecipeTab)}
|
||||||
|
tabs={REAL_RECIPE_TABS}
|
||||||
|
/>
|
||||||
|
|
||||||
{listState.status === "loading" && (
|
{listState.status === "loading" && (
|
||||||
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
|
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
|
||||||
|
|
|
||||||
215
apps/web/src/features/recipes/RecipeSourcesPanel.tsx
Normal file
215
apps/web/src/features/recipes/RecipeSourcesPanel.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import type { BrowsableSourceItemView, SourceView } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { apiClient } from "../../api/client";
|
||||||
|
import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
|
||||||
|
import { SourceItemTable } from "./SourceItemTable";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
type EnabledSourcesState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; sources: SourceView[] }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
type BrowseState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sources" tab content of the recipe catalog (`RecipesPage`) — a
|
||||||
|
* self-contained master-detail pair of its own (source selector + browsable
|
||||||
|
* list on the left, `SourceItemPreviewPanel` on the right), independent of
|
||||||
|
* `RecipeTable`/`RecipeDetailPanel`: it browses a source's *live* catalog
|
||||||
|
* (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table, so it
|
||||||
|
* doesn't share their `RecipeTab`-based fetching at all.
|
||||||
|
*
|
||||||
|
* Selecting an already-imported item navigates straight to its real
|
||||||
|
* recipe (`/recettes/:id`, leaving this tab) — selecting one that isn't
|
||||||
|
* imported yet shows a read-only preview here instead. Turning that
|
||||||
|
* preview into an actual saved recipe (reviewing/fixing unresolved
|
||||||
|
* ingredients first) is a later stage of the same plan, not built here.
|
||||||
|
*
|
||||||
|
* `onViewImportedRecipe` must switch the caller's active tab away from
|
||||||
|
* `"sources"` before/alongside navigating — `RecipesPage` only renders
|
||||||
|
* `RecipeDetailPanel` (and fetches the real recipe list `RecipeTable`
|
||||||
|
* needs) outside the `"sources"` tab, so without this the URL would change
|
||||||
|
* but the sources panel would keep rendering over it. Which real tab it
|
||||||
|
* lands on doesn't have to include the recipe (the table and the detail
|
||||||
|
* panel are only loosely coupled via the URL's `:id` everywhere else in
|
||||||
|
* the app too — a deep link to a recipe outside the active tab's own list
|
||||||
|
* already just shows the detail without highlighting a row).
|
||||||
|
*/
|
||||||
|
export function RecipeSourcesPanel({
|
||||||
|
onViewImportedRecipe,
|
||||||
|
}: {
|
||||||
|
onViewImportedRecipe: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
|
||||||
|
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
||||||
|
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(null);
|
||||||
|
const [previewState, setPreviewState] = useState<SourceItemPreviewState>({ status: "empty" });
|
||||||
|
|
||||||
|
// Loaded once — which sources exist, crossed with which the household
|
||||||
|
// has enabled (`/parametres/foyer`). Defaults the selector to the first
|
||||||
|
// enabled one, if any.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
|
||||||
|
.then(([sources, enabledIds]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const enabled = sources.filter((source) => enabledIds.includes(source.id));
|
||||||
|
setEnabledSources({ status: "loaded", sources: enabled });
|
||||||
|
setSelectedSourceKey((current) => current ?? enabled[0]?.key ?? null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setEnabledSources({ status: "error" });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSourceKey === null) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setBrowseState({ status: "loading" });
|
||||||
|
setSelectedExternalId(null);
|
||||||
|
setPreviewState({ status: "empty" });
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
|
||||||
|
.then(({ items, nextCursor }) => {
|
||||||
|
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setBrowseState({ status: "error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedSourceKey, debouncedSearch]);
|
||||||
|
|
||||||
|
function handleLoadMore() {
|
||||||
|
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cursor = browseState.nextCursor;
|
||||||
|
apiClient
|
||||||
|
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor })
|
||||||
|
.then(({ items, nextCursor }) => {
|
||||||
|
setBrowseState((prev) =>
|
||||||
|
prev.status === "loaded"
|
||||||
|
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => setBrowseState({ status: "error" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectItem(item: BrowsableSourceItemView) {
|
||||||
|
if (item.alreadyImported && item.recipeId !== null) {
|
||||||
|
onViewImportedRecipe();
|
||||||
|
navigate(`/recettes/${item.recipeId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedSourceKey === null) return;
|
||||||
|
setSelectedExternalId(item.externalId);
|
||||||
|
setPreviewState({ status: "loading" });
|
||||||
|
apiClient
|
||||||
|
.previewSourceItem(selectedSourceKey, item.externalId)
|
||||||
|
.then((draft) => setPreviewState({ status: "loaded", draft }))
|
||||||
|
.catch(() => setPreviewState({ status: "error" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabledSources.status === "loading") {
|
||||||
|
return <p className="recipes-page__status">{t("recipes.loading")}</p>;
|
||||||
|
}
|
||||||
|
if (enabledSources.status === "error") {
|
||||||
|
return (
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (enabledSources.sources.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="recipes-page__status">
|
||||||
|
{t("recipes.sources.noneEnabled")}{" "}
|
||||||
|
<Link to="/parametres/foyer">{t("recipes.sources.noneEnabledLink")}</Link>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="recipes-page__header recipes-page__header--sources">
|
||||||
|
{enabledSources.sources.length > 1 && (
|
||||||
|
<select
|
||||||
|
className="source-sources-select"
|
||||||
|
aria-label={t("recipes.sources.sourceLabel")}
|
||||||
|
value={selectedSourceKey ?? ""}
|
||||||
|
onChange={(e) => setSelectedSourceKey(e.target.value)}
|
||||||
|
>
|
||||||
|
{enabledSources.sources.map((source) => (
|
||||||
|
<option key={source.key} value={source.key}>
|
||||||
|
{source.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="recipes-page__search"
|
||||||
|
placeholder={t("recipes.sources.searchPlaceholder")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="recipes-page__catalog">
|
||||||
|
{browseState.status === "loading" && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
|
||||||
|
)}
|
||||||
|
{browseState.status === "error" && (
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
{t("recipes.sources.loadError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{browseState.status === "loaded" && browseState.items.length === 0 && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
|
||||||
|
)}
|
||||||
|
{browseState.status === "loaded" && browseState.items.length > 0 && (
|
||||||
|
<div className="source-items-column">
|
||||||
|
<SourceItemTable
|
||||||
|
items={browseState.items}
|
||||||
|
selectedExternalId={selectedExternalId}
|
||||||
|
onSelect={handleSelectItem}
|
||||||
|
/>
|
||||||
|
{browseState.nextCursor && (
|
||||||
|
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
|
||||||
|
{t("recipes.sources.loadMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SourceItemPreviewPanel state={previewState} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,61 @@
|
||||||
import type { RecipeTab } from "@batch-cooking/shared";
|
import type { RecipeTab } from "@batch-cooking/shared";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons";
|
import {
|
||||||
|
AccountIcon,
|
||||||
|
FavoriteIcon,
|
||||||
|
HouseholdIcon,
|
||||||
|
PublicIcon,
|
||||||
|
SourcesIcon,
|
||||||
|
} from "../../layouts/nav-icons";
|
||||||
import "./recipes.scss";
|
import "./recipes.scss";
|
||||||
|
|
||||||
/** Every functional tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
|
/**
|
||||||
const TABS: Array<{ value: RecipeTab; Icon: LucideIcon }> = [
|
* A tab of the recipe catalog — either a real {@link RecipeTab} (`GET
|
||||||
|
* /recipes?tab=`, `recipe.service.ts`'s `listRecipes`) or `"sources"`, a
|
||||||
|
* web-only mode that doesn't query the recipe table at all: it browses a
|
||||||
|
* household-enabled external source's own catalog live
|
||||||
|
* (`GET /sources/:sourceKey/browse`, `RecipeSourcesPanel`) instead of
|
||||||
|
* listing saved `Recipe` rows. Kept out of the shared `RecipeTab` type on
|
||||||
|
* purpose — the API has no `tab=sources` to validate.
|
||||||
|
*/
|
||||||
|
export type RecipesPageTab = RecipeTab | "sources";
|
||||||
|
|
||||||
|
/** Every possible tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
|
||||||
|
const ALL_TABS: Array<{ value: RecipesPageTab; Icon: LucideIcon }> = [
|
||||||
{ value: "favoris", Icon: FavoriteIcon },
|
{ value: "favoris", Icon: FavoriteIcon },
|
||||||
{ value: "perso", Icon: AccountIcon },
|
{ value: "perso", Icon: AccountIcon },
|
||||||
{ value: "foyer", Icon: HouseholdIcon },
|
{ value: "foyer", Icon: HouseholdIcon },
|
||||||
{ value: "publique", Icon: PublicIcon },
|
{ value: "publique", Icon: PublicIcon },
|
||||||
|
{ value: "sources", Icon: SourcesIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Catalog tab bar — Favoris / Perso / Foyer / Publique, plus a disabled
|
* Catalog tab bar — Favoris / Perso / Foyer / Publique / Sources by
|
||||||
* placeholder for external sources (not built yet, see the plan's "hors
|
* default (`/recettes`, `RecipesPage`). `tabs` narrows which of those
|
||||||
* scope" note) so the eventual nav slot is visible without being
|
* show — `RecipePickerDialog` (picking a recipe for a planning slot)
|
||||||
* functional. No "toutes" tab: every recipe a viewer can see falls under
|
* passes just the four real ones: browsing external sources mid-dialog,
|
||||||
* exactly one of perso/foyer/publique (its own visibility) — see
|
* without the review/import flow, doesn't make sense there yet (its
|
||||||
|
* `onChange` narrows the result back to `RecipeTab` itself, safe exactly
|
||||||
|
* because `tabs` guarantees `"sources"` is never clickable there). No
|
||||||
|
* "toutes" tab among the real ones: every recipe a viewer can see falls
|
||||||
|
* under exactly one of perso/foyer/publique (its own visibility) — see
|
||||||
* `recipe.service.ts`'s `listRecipes`.
|
* `recipe.service.ts`'s `listRecipes`.
|
||||||
*/
|
*/
|
||||||
export function RecipeTabs({
|
export function RecipeTabs({
|
||||||
active,
|
active,
|
||||||
onChange,
|
onChange,
|
||||||
|
tabs = ALL_TABS.map((tab) => tab.value),
|
||||||
}: {
|
}: {
|
||||||
active: RecipeTab;
|
active: RecipesPageTab;
|
||||||
onChange: (tab: RecipeTab) => void;
|
onChange: (tab: RecipesPageTab) => void;
|
||||||
|
tabs?: readonly RecipesPageTab[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="recipe-tabs">
|
<div className="recipe-tabs">
|
||||||
{TABS.map(({ value, Icon }) => (
|
{ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -42,14 +66,6 @@ export function RecipeTabs({
|
||||||
{t(`recipes.tabs.${value}`)}
|
{t(`recipes.tabs.${value}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="recipe-tabs__tab placeholder"
|
|
||||||
disabled
|
|
||||||
title={t("recipes.tabs.sourcesSoonHint")}
|
|
||||||
>
|
|
||||||
{t("recipes.tabs.sourcesSoon")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
128
apps/web/src/features/recipes/SourceItemPreviewPanel.tsx
Normal file
128
apps/web/src/features/recipes/SourceItemPreviewPanel.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import type { RecipeImportDraftView } from "@batch-cooking/shared";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StepDescription } from "./StepDescription";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/** State {@link SourceItemPreviewPanel} renders — mirrors `RecipeDetailState`'s shape (`RecipeDetailPanel`), one status short (no "not-found": an invalid `externalId` surfaces as `"error"`, there's no separate "id was well-formed but nothing matched it" case here). */
|
||||||
|
export type SourceItemPreviewState =
|
||||||
|
| { status: "empty" }
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; draft: RecipeImportDraftView }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-hand panel of the catalog's "Sources" tab (`RecipeSourcesPanel`) —
|
||||||
|
* a read-only preview of a not-yet-imported item: nothing here can be
|
||||||
|
* edited or saved yet (no favorite/edit/delete actions, unlike
|
||||||
|
* `RecipeDetailPanel`) — turning this into an actual import with a review
|
||||||
|
* step for unresolved ingredients is a later stage of the same plan.
|
||||||
|
* Reuses `StepDescription` so a step's detected techniques are already
|
||||||
|
* highlighted here too, exactly like a saved recipe's detail.
|
||||||
|
*/
|
||||||
|
export function SourceItemPreviewPanel({ state }: { state: SourceItemPreviewState }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (state.status === "empty") {
|
||||||
|
return (
|
||||||
|
<aside className="recipe-detail-panel">
|
||||||
|
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.empty")}</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<aside className="recipe-detail-panel">
|
||||||
|
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.loading")}</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status === "error") {
|
||||||
|
return (
|
||||||
|
<aside className="recipe-detail-panel">
|
||||||
|
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
|
||||||
|
{t("recipes.sources.detail.loadError")}
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { draft } = state;
|
||||||
|
const hasUnresolvedIngredient = draft.ingredients.some(
|
||||||
|
(ingredient) => ingredient.ingredient === null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="recipe-detail-panel">
|
||||||
|
<div className="recipe-detail-panel__header">
|
||||||
|
<div className="recipe-detail-panel__photo" aria-hidden="true">
|
||||||
|
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="recipe-detail-panel__title-row">
|
||||||
|
<div className="recipe-detail-panel__title-main">
|
||||||
|
<h2>{draft.name}</h2>
|
||||||
|
{draft.portions !== null && (
|
||||||
|
<p className="recipe-detail-panel__portions">
|
||||||
|
{t("recipes.detail.portions", { count: draft.portions })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="recipe-detail-panel__actions">
|
||||||
|
<a
|
||||||
|
href={draft.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="recipes-page__new-button"
|
||||||
|
>
|
||||||
|
{t("recipes.sources.detail.viewSource")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.description && (
|
||||||
|
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
|
||||||
|
<p className="recipe-detail-panel__description">{draft.description}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="recipe-detail-panel__section">
|
||||||
|
<h3>{t("recipes.sources.detail.ingredientsCount", { count: draft.ingredients.length })}</h3>
|
||||||
|
{hasUnresolvedIngredient && (
|
||||||
|
<p className="source-item-preview__hint">
|
||||||
|
{t("recipes.sources.detail.unresolvedIngredientsHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="source-item-preview__ingredients">
|
||||||
|
{draft.ingredients.map((ingredient, index) => (
|
||||||
|
// Draft lines have no id of their own (nothing is saved yet) —
|
||||||
|
// `rawText` alone could collide (a source repeating the same
|
||||||
|
// line), so it's paired with its position; this list is fully
|
||||||
|
// regenerated from `draft` on every render, never reordered in
|
||||||
|
// place, so that's safe here (same reasoning as
|
||||||
|
// StepDescription.tsx's segment keys).
|
||||||
|
<li
|
||||||
|
key={`${index}-${ingredient.rawText}`}
|
||||||
|
className={ingredient.ingredient === null ? "is-unresolved" : undefined}
|
||||||
|
>
|
||||||
|
{ingredient.rawText}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="recipe-detail-panel__section">
|
||||||
|
<h3>{t("recipes.sources.detail.stepsCount", { count: draft.steps.length })}</h3>
|
||||||
|
<ol className="recipe-detail-panel__steps">
|
||||||
|
{draft.steps.map((step, index) => (
|
||||||
|
<li key={`${index}-${step.description}`}>
|
||||||
|
{step.picture && <img src={step.picture} alt="" />}
|
||||||
|
<StepDescription description={step.description} techSteps={step.techSteps} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
apps/web/src/features/recipes/SourceItemTable.tsx
Normal file
67
apps/web/src/features/recipes/SourceItemTable.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of one source's browsable items (`RecipeSourcesPanel`) — same
|
||||||
|
* "photo + name, click/Enter to select" row shape as `RecipeTable`, plus
|
||||||
|
* an "already imported" badge in place of allergen/regime columns (a
|
||||||
|
* source item has neither, it's not resolved against our catalogs until
|
||||||
|
* previewed).
|
||||||
|
*/
|
||||||
|
export function SourceItemTable({
|
||||||
|
items,
|
||||||
|
selectedExternalId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
items: BrowsableSourceItemView[];
|
||||||
|
selectedExternalId: string | null;
|
||||||
|
onSelect: (item: BrowsableSourceItemView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="recipe-table-wrap">
|
||||||
|
<table className="recipe-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>{t("recipes.table.name")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.externalId}
|
||||||
|
className={item.externalId === selectedExternalId ? "selected" : undefined}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-current={item.externalId === selectedExternalId ? "true" : undefined}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span className="recipe-table__photo" aria-hidden="true">
|
||||||
|
{item.picture ? <img src={item.picture} alt="" /> : "🍽️"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="recipe-table__name">{item.title}</td>
|
||||||
|
<td>
|
||||||
|
{item.alreadyImported && (
|
||||||
|
<span className="source-item-table__imported-badge">
|
||||||
|
{t("recipes.sources.alreadyImported")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -334,6 +334,91 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sources tab (RecipeSourcesPanel) ---------------------------------------
|
||||||
|
// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap)
|
||||||
|
// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's
|
||||||
|
// actually new to this tab gets its own rules here.
|
||||||
|
|
||||||
|
.recipes-page__header--sources {
|
||||||
|
// The source <select> only renders when the household has more than one
|
||||||
|
// enabled source (see RecipeSourcesPanel) — this just keeps it visually
|
||||||
|
// grouped with the search field when it does.
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sources-select {
|
||||||
|
flex: none;
|
||||||
|
padding: 0.5rem var(--space-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item-table__imported-badge {
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-items-column {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-items-load-more {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
padding: 0.4rem var(--space-lg);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item-preview__hint {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-warning);
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item-preview__ingredients {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-unresolved {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Recipe detail panel -----------------------------------------------------
|
// --- Recipe detail panel -----------------------------------------------------
|
||||||
// 100% of the grid row's height (see `.recipes-page__catalog` above): the
|
// 100% of the grid row's height (see `.recipes-page__catalog` above): the
|
||||||
// panel itself never scrolls, only its content does past that height
|
// panel itself never scrolls, only its content does past that height
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,5 @@ export {
|
||||||
ChevronLeft as ChevronLeftIcon,
|
ChevronLeft as ChevronLeftIcon,
|
||||||
Star as FavoriteIcon,
|
Star as FavoriteIcon,
|
||||||
Globe as PublicIcon,
|
Globe as PublicIcon,
|
||||||
|
Rss as SourcesIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -162,14 +162,35 @@
|
||||||
"perso": "Perso",
|
"perso": "Perso",
|
||||||
"foyer": "Foyer",
|
"foyer": "Foyer",
|
||||||
"publique": "Publique",
|
"publique": "Publique",
|
||||||
"sourcesSoon": "Sources (bientôt)",
|
"sources": "Sources"
|
||||||
"sourcesSoonHint": "Un onglet par source externe, une fois l'import de recettes construit"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"allergens": "Allergènes / intolérances",
|
"allergens": "Allergènes / intolérances",
|
||||||
"diets": "Régime associé"
|
"diets": "Régime associé"
|
||||||
},
|
},
|
||||||
|
"sources": {
|
||||||
|
"sourceLabel": "Source",
|
||||||
|
"noneEnabled": "Aucune source n'est activée pour votre foyer.",
|
||||||
|
"noneEnabledLink": "Activez-en une dans les paramètres du foyer",
|
||||||
|
"searchPlaceholder": "Rechercher…",
|
||||||
|
"empty": "Aucune recette trouvée.",
|
||||||
|
"loadMore": "Voir plus",
|
||||||
|
"alreadyImported": "Déjà importée",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"loadError": "Impossible de charger cette source pour le moment.",
|
||||||
|
"detail": {
|
||||||
|
"empty": "Sélectionnez une recette dans la liste pour voir son aperçu ici.",
|
||||||
|
"loading": "Chargement de l'aperçu…",
|
||||||
|
"loadError": "Impossible de charger l'aperçu de cette recette.",
|
||||||
|
"viewSource": "Voir sur le site d'origine",
|
||||||
|
"ingredientsCount_one": "{{count}} ingrédient",
|
||||||
|
"ingredientsCount_other": "{{count}} ingrédients",
|
||||||
|
"unresolvedIngredientsHint": "Certains ingrédients n'ont pas été reconnus automatiquement — ils pourront être corrigés à l'import.",
|
||||||
|
"stepsCount_one": "{{count}} étape",
|
||||||
|
"stepsCount_other": "{{count}} étapes"
|
||||||
|
}
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
|
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
|
||||||
"favorite": "Ajouter aux favoris",
|
"favorite": "Ajouter aux favoris",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
|
import { ErrorCode, type RecipeSummaryView } from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../api/client";
|
import { ApiError, apiClient } from "../api/client";
|
||||||
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
||||||
|
import { RecipeSourcesPanel } from "../features/recipes/RecipeSourcesPanel";
|
||||||
import { RecipeTable } from "../features/recipes/RecipeTable";
|
import { RecipeTable } from "../features/recipes/RecipeTable";
|
||||||
import { RecipeTabs } from "../features/recipes/RecipeTabs";
|
import { RecipeTabs, type RecipesPageTab } from "../features/recipes/RecipeTabs";
|
||||||
import "../features/recipes/recipes.scss";
|
import "../features/recipes/recipes.scss";
|
||||||
|
|
||||||
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
|
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
|
||||||
|
|
@ -37,7 +38,7 @@ export function RecipesPage() {
|
||||||
// filter view would).
|
// filter view would).
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
||||||
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
|
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
|
||||||
// Seeded from the same initial value as `search` — otherwise the first
|
// Seeded from the same initial value as `search` — otherwise the first
|
||||||
// fetch below would fire with an empty term (the debounce effect hasn't
|
// fetch below would fire with an empty term (the debounce effect hasn't
|
||||||
|
|
@ -53,6 +54,10 @@ export function RecipesPage() {
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// The "sources" tab doesn't query the recipe table at all — it browses
|
||||||
|
// a source's own live catalog instead (see `RecipeSourcesPanel`, which
|
||||||
|
// owns its own fetching entirely).
|
||||||
|
if (activeTab === "sources") return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setListState({ status: "loading" });
|
setListState({ status: "loading" });
|
||||||
|
|
||||||
|
|
@ -137,6 +142,7 @@ export function RecipesPage() {
|
||||||
<div className="recipes-page">
|
<div className="recipes-page">
|
||||||
<div className="recipes-page__header">
|
<div className="recipes-page__header">
|
||||||
<h1>{t("recipes.title")}</h1>
|
<h1>{t("recipes.title")}</h1>
|
||||||
|
{activeTab !== "sources" && (
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="recipes-page__search"
|
className="recipes-page__search"
|
||||||
|
|
@ -144,6 +150,7 @@ export function RecipesPage() {
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
||||||
{t("recipes.newButton")}
|
{t("recipes.newButton")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -151,6 +158,9 @@ export function RecipesPage() {
|
||||||
|
|
||||||
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
|
{activeTab === "sources" ? (
|
||||||
|
<RecipeSourcesPanel onViewImportedRecipe={() => setActiveTab("favoris")} />
|
||||||
|
) : (
|
||||||
<div className="recipes-page__catalog">
|
<div className="recipes-page__catalog">
|
||||||
{listState.status === "loading" && (
|
{listState.status === "loading" && (
|
||||||
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
||||||
|
|
@ -178,6 +188,7 @@ export function RecipesPage() {
|
||||||
onDeleted={handleDeleted}
|
onDeleted={handleDeleted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue