feat(recipes): chaque source activée devient sa propre tab
Nouvelle correction demandée sur cette PR : l'onglet générique
« Sources » (avec un <select> interne quand le foyer en a activé
plusieurs) devient une tab à part entière par source activée — au même
niveau que Favoris/Perso/Foyer/Publique, plus transparent qu'un
sélecteur caché dans un sous-menu.
- `RecipeTabs` accepte désormais `sources: SourceView[]` et rend une
tab par source (icône propre à la source si elle en a une, sinon
l'icône générique `SourcesIcon` ; libellé = le nom réel de la
source, pas une clé i18n). Nouveau type `RecipesPageTab` en
`RecipeTab | "source:<key>"`, avec `sourceTabValue`/
`parseSourceTabValue`/`isSourceTab` comme seul point d'assemblage/
lecture de ce format.
- Nouveau hook partagé `useEnabledSources` (déplacé hors de
`RecipeSourcesPanel`, maintenant utilisé par `RecipesPage` ET
`RecipePickerDialog` pour construire leurs tabs).
- `RecipeSourcesPanel` simplifié : `sourceKey` devient une prop requise
(fournie par la tab elle-même) au lieu d'un état interne avec son
propre sélecteur — plus de `<select>`, plus de message « aucune
source activée » (une tab qui n'existe pas ne peut plus être
cliquée). Remonté via `key={sourceKey}` par l'appelant au changement
de tab, même convention que `RecipePickerDialog`/`CalendarPopover`
ailleurs dans l'app.
- Un bug distinct trouvé en écrivant ce changement : passer tel quel
`initialSelection` (dérivé de l'URL) au panneau nouvellement monté
en changeant directement de tab source à tab source aurait fait
prévisualiser l'ancien item contre la nouvelle source. Gardé en ne
transmettant `initialSelection` que lorsqu'il appartient réellement
à `activeSourceKey`.
Aucun changement backend.
Tests :
- Vérifié manuellement en local (foyer avec TheMealDB activé) :
tab dédiée dans /recettes et dans le sélecteur du planning, parcours
d'un item, aperçu unifié, aucune régression console.
- Cypress : `recipe-sources.feature`/`planning.feature` mis à jour
(« I click the button "Sources" » → « ... "TheMealDB" »), scénario
« aucune source activée » réécrit pour vérifier l'absence de tab
plutôt qu'un message dans un onglet qui n'existe plus.
- `pnpm exec tsc -b --force` (web) — propre.
- `pnpm exec biome check` — propre.
- `pnpm --filter web build` — propre.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d5172c2c66
commit
4381e63045
9 changed files with 236 additions and 192 deletions
|
|
@ -21,7 +21,7 @@ Feature: Adding a recipe to the planning
|
||||||
And adding the imported recipe to the planning will succeed
|
And adding the imported recipe to the planning will succeed
|
||||||
When I visit "/"
|
When I visit "/"
|
||||||
And I click the add button for the first empty planning slot
|
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 source item "Fish Pie"
|
||||||
And I click the link "Importer cette recette"
|
And I click the link "Importer cette recette"
|
||||||
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."
|
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 disliked ingredients list is empty
|
||||||
And the planning request returns nothing
|
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
|
Given the recipe catalog contains nothing
|
||||||
And the sources reference list has options
|
And the sources reference list has options
|
||||||
And the household's enabled sources are empty
|
And the household's enabled sources are empty
|
||||||
When I visit "/recettes"
|
When I visit "/recettes"
|
||||||
And I click the button "Sources"
|
Then I should not see "TheMealDB"
|
||||||
Then I should see "Aucune source n'est activée"
|
|
||||||
|
|
||||||
Scenario: Browses an enabled source, distinguishing already-imported items from new ones
|
Scenario: Browses an enabled source, distinguishing already-imported items from new ones
|
||||||
Given the recipe catalog contains nothing
|
Given the recipe catalog contains nothing
|
||||||
|
|
@ -24,7 +23,7 @@ Feature: Browsing external recipe sources
|
||||||
And browsing TheMealDB returns some items
|
And browsing TheMealDB returns some items
|
||||||
And recipe 2's detail is available
|
And recipe 2's detail is available
|
||||||
When I visit "/recettes"
|
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"
|
Then I should see the source item "Chicken Handi"
|
||||||
And I should see the source item "Fish Pie"
|
And I should see the source item "Fish Pie"
|
||||||
And the source item "Chicken Handi" should be marked as already imported
|
And the source item "Chicken Handi" should be marked as already imported
|
||||||
|
|
@ -40,7 +39,7 @@ Feature: Browsing external recipe sources
|
||||||
And browsing TheMealDB returns some items
|
And browsing TheMealDB returns some items
|
||||||
And previewing TheMealDB item "9999" is available
|
And previewing TheMealDB item "9999" is available
|
||||||
When I visit "/recettes"
|
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 source item "Fish Pie"
|
||||||
Then the URL should include "/recettes/sources/theMealDb/9999"
|
Then the URL should include "/recettes/sources/theMealDb/9999"
|
||||||
And the recipe detail panel heading should be "Fish Pie"
|
And the recipe detail panel heading should be "Fish Pie"
|
||||||
|
|
@ -65,7 +64,7 @@ Feature: Browsing external recipe sources
|
||||||
And the ingredient and diet catalog is available for import
|
And the ingredient and diet catalog is available for import
|
||||||
And importing the previewed item will succeed and return id 99
|
And importing the previewed item will succeed and return id 99
|
||||||
When I visit "/recettes"
|
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 source item "Fish Pie"
|
||||||
And I click the link "Importer cette recette"
|
And I click the link "Importer cette recette"
|
||||||
Then the "recipe-name" field should have the value "Fish Pie"
|
Then the "recipe-name" field should have the value "Fish Pie"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,13 @@ import { DietTagSelect } from "../recipes/DietTagSelect";
|
||||||
import { IngredientPicker } from "../recipes/IngredientPicker";
|
import { IngredientPicker } from "../recipes/IngredientPicker";
|
||||||
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
|
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
|
||||||
import { RecipeTable } from "../recipes/RecipeTable";
|
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";
|
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. */
|
||||||
|
|
@ -48,13 +54,14 @@ export interface PlanningSlot {
|
||||||
* with three extra filters layered on top of the plain name search
|
* with three extra filters layered on top of the plain name search
|
||||||
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
|
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
|
||||||
* `GET /recipes`'s corresponding query params) since browsing here is
|
* `GET /recipes`'s corresponding query params) since browsing here is
|
||||||
* about finding something to cook, not just looking something up. The
|
* about finding something to cook, not just looking something up. Each
|
||||||
* "Sources" tab is included too (unlike an earlier version of this dialog
|
* household-enabled source's own tab is included too (unlike an earlier
|
||||||
* — see `ImportRecipePage`'s `planningSlot`, the review/import flow that
|
* version of this dialog — see `ImportRecipePage`'s `planningSlot`, the
|
||||||
* made including it here worthwhile): picking an already-imported item
|
* review/import flow that made including them here worthwhile): picking
|
||||||
* behaves exactly like picking a regular recipe, and picking one that
|
* an already-imported item behaves exactly like picking a regular recipe,
|
||||||
* isn't imported yet hands off to that review screen, which adds the
|
* and picking one that isn't imported yet hands off to that review
|
||||||
* freshly-created recipe straight to this slot once it's saved.
|
* screen, which adds the freshly-created recipe straight to this slot
|
||||||
|
* once it's saved.
|
||||||
*
|
*
|
||||||
* Mounted only while open (see `PlanningPage`, same conditional-mount
|
* Mounted only while open (see `PlanningPage`, same conditional-mount
|
||||||
* convention as its own `CalendarPopover`) — every piece of local state
|
* convention as its own `CalendarPopover`) — every piece of local state
|
||||||
|
|
@ -81,6 +88,8 @@ export function RecipePickerDialog({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
||||||
|
const activeSourceKey = parseSourceTabValue(activeTab);
|
||||||
|
const enabledSources = useEnabledSources();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
|
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
|
||||||
|
|
@ -128,9 +137,11 @@ export function RecipePickerDialog({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The "sources" tab doesn't query the recipe table at all — same guard
|
// A source's own tab doesn't query the recipe table at all — same
|
||||||
// as `RecipesPage`'s own identical effect.
|
// guard as `RecipesPage`'s own identical effect (the type-guard, not
|
||||||
if (activeTab === "sources") return;
|
// just `activeSourceKey !== null`, is what narrows `activeTab` to
|
||||||
|
// `RecipeTab` below).
|
||||||
|
if (isSourceTab(activeTab)) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setListState({ status: "loading" });
|
setListState({ status: "loading" });
|
||||||
|
|
||||||
|
|
@ -157,7 +168,7 @@ export function RecipePickerDialog({
|
||||||
selectedIngredientIds.includes(ingredient.id),
|
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) {
|
function handleSelectImportedRecipe(recipeId: number) {
|
||||||
setSourceSelectError(false);
|
setSourceSelectError(false);
|
||||||
apiClient
|
apiClient
|
||||||
|
|
@ -230,7 +241,7 @@ export function RecipePickerDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
||||||
{activeTab !== "sources" && (
|
{activeSourceKey === null && (
|
||||||
<div className="recipe-picker__filters">
|
<div className="recipe-picker__filters">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
|
|
@ -295,9 +306,13 @@ export function RecipePickerDialog({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
<RecipeTabs
|
||||||
|
active={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
|
||||||
|
/>
|
||||||
|
|
||||||
{activeTab === "sources" ? (
|
{activeSourceKey !== null ? (
|
||||||
<>
|
<>
|
||||||
{sourceSelectError && (
|
{sourceSelectError && (
|
||||||
<p className="recipes-page__status recipes-page__status--error">
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
|
@ -305,6 +320,8 @@ export function RecipePickerDialog({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<RecipeSourcesPanel
|
<RecipeSourcesPanel
|
||||||
|
key={activeSourceKey}
|
||||||
|
sourceKey={activeSourceKey}
|
||||||
planningSlot={slot}
|
planningSlot={slot}
|
||||||
onSelectImportedRecipe={handleSelectImportedRecipe}
|
onSelectImportedRecipe={handleSelectImportedRecipe}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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 { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { apiClient } from "../../api/client";
|
import { apiClient } from "../../api/client";
|
||||||
import { RecipeDetailPanel, type RecipeDetailState } from "./RecipeDetailPanel";
|
import { RecipeDetailPanel, type RecipeDetailState } from "./RecipeDetailPanel";
|
||||||
import { SourceItemTable } from "./SourceItemTable";
|
import { SourceItemTable } from "./SourceItemTable";
|
||||||
|
|
@ -16,22 +15,27 @@ export interface SourceItemSelection {
|
||||||
externalId: string;
|
externalId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnabledSourcesState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; sources: SourceView[] }
|
|
||||||
| { status: "error" };
|
|
||||||
|
|
||||||
type BrowseState =
|
type BrowseState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
|
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
|
||||||
| { status: "error" };
|
| { status: "error" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Sources" tab content of the recipe catalog (`RecipesPage`) — a
|
* One household-enabled source's own tab content in the recipe catalog
|
||||||
* self-contained master-detail pair of its own (source selector + browsable
|
* (`RecipesPage`/`RecipePickerDialog`) — a master-detail pair of its own
|
||||||
* list on the left, a preview on the right), independent of `RecipeTable`'s
|
* (browsable list on the left, a preview on the right), independent of
|
||||||
* own `RecipeTab`-based fetching: it browses a source's *live* catalog
|
* `RecipeTable`'s own `RecipeTab`-based fetching: it browses this one
|
||||||
* (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table.
|
* 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
|
* The right-hand preview reuses `RecipeDetailPanel` itself (its
|
||||||
* `"loaded-draft"` state) rather than a separate component — viewing a
|
* `"loaded-draft"` state) rather than a separate component — viewing a
|
||||||
|
|
@ -43,13 +47,11 @@ type BrowseState =
|
||||||
* recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe`
|
* recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe`
|
||||||
* hands back the id instead of this panel navigating anywhere itself, since
|
* hands back the id instead of this panel navigating anywhere itself, since
|
||||||
* what "viewing" an already-imported item means depends on the caller:
|
* what "viewing" an already-imported item means depends on the caller:
|
||||||
* `RecipesPage` switches its own active tab away from `"sources"` (its
|
* `RecipesPage` navigates to the recipe's detail page (switching its own
|
||||||
* `RecipeDetailPanel`/`RecipeTable` only render outside that tab, so
|
* active tab first — see its own doc comment), while `RecipePickerDialog`
|
||||||
* without switching first the URL would change but this panel would keep
|
* instead treats it exactly like picking that recipe from one of the
|
||||||
* rendering over it) and navigates to the recipe's detail page, while
|
* regular tabs — moving to its own confirm-portions step, no navigation
|
||||||
* `RecipePickerDialog` instead treats it exactly like picking that recipe
|
* at all.
|
||||||
* from one of the regular tabs — moving to its own confirm-portions step,
|
|
||||||
* no navigation at all.
|
|
||||||
*
|
*
|
||||||
* `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a
|
* `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a
|
||||||
* not-yet-imported item's preview addressable by URL
|
* not-yet-imported item's preview addressable by URL
|
||||||
|
|
@ -60,11 +62,13 @@ type BrowseState =
|
||||||
* no URL of its own to keep in sync.
|
* no URL of its own to keep in sync.
|
||||||
*/
|
*/
|
||||||
export function RecipeSourcesPanel({
|
export function RecipeSourcesPanel({
|
||||||
|
sourceKey,
|
||||||
onSelectImportedRecipe,
|
onSelectImportedRecipe,
|
||||||
planningSlot,
|
planningSlot,
|
||||||
initialSelection,
|
initialSelection,
|
||||||
onItemSelected,
|
onItemSelected,
|
||||||
}: {
|
}: {
|
||||||
|
sourceKey: string;
|
||||||
onSelectImportedRecipe: (recipeId: number) => void;
|
onSelectImportedRecipe: (recipeId: number) => void;
|
||||||
/** Forwarded as-is to `RecipeDetailPanel` — 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 };
|
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
||||||
|
|
@ -73,10 +77,6 @@ export function RecipeSourcesPanel({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
|
|
||||||
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(
|
|
||||||
initialSelection?.sourceKey ?? null,
|
|
||||||
);
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
||||||
|
|
@ -96,52 +96,22 @@ export function RecipeSourcesPanel({
|
||||||
// "needs a fetch" case the effect below must still run for.
|
// "needs a fetch" case the effect below must still run for.
|
||||||
const [previewedItem, setPreviewedItem] = useState<SourceItemSelection | null>(null);
|
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 — but never overrides a source `initialSelection`
|
|
||||||
// already picked (the `current ??` below), so a deep link always wins.
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
|
|
||||||
.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;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Re-previews whenever `initialSelection` itself changes (a fresh deep
|
// Re-previews whenever `initialSelection` itself changes (a fresh deep
|
||||||
// link, or the browser's back/forward button landing on a different
|
// link, or the browser's back/forward button landing on a different
|
||||||
// item) — not just once on mount. Deliberately doesn't touch
|
// item within this same source) — not just once on mount. Keyed on the
|
||||||
// `browseState`/the source dropdown beyond `selectedSourceKey` above: the
|
// primitive field below, not `initialSelection` itself — a fresh object
|
||||||
// item list for whichever source this belongs to loads independently
|
// literal from the caller on every render (see `RecipesPage`) would
|
||||||
// (see the effect below), on its own schedule. Keyed on the primitive
|
// otherwise re-run this on every render too.
|
||||||
// fields below, not `initialSelection` itself — a fresh object literal
|
|
||||||
// from the caller on every render (see `RecipesPage`) would otherwise
|
|
||||||
// re-run this on every render too.
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: see above.
|
// biome-ignore lint/correctness/useExhaustiveDependencies: see above.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialSelection) return;
|
if (!initialSelection) return;
|
||||||
if (
|
if (previewedItem?.externalId === initialSelection.externalId) return;
|
||||||
previewedItem?.sourceKey === initialSelection.sourceKey &&
|
|
||||||
previewedItem?.externalId === initialSelection.externalId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setPreviewedItem(initialSelection);
|
setPreviewedItem(initialSelection);
|
||||||
setSelectedSourceKey(initialSelection.sourceKey);
|
|
||||||
setSelectedExternalId(initialSelection.externalId);
|
setSelectedExternalId(initialSelection.externalId);
|
||||||
setPreviewState({ status: "loading" });
|
setPreviewState({ status: "loading" });
|
||||||
apiClient
|
apiClient
|
||||||
.previewSourceItem(initialSelection.sourceKey, initialSelection.externalId)
|
.previewSourceItem(sourceKey, initialSelection.externalId)
|
||||||
.then((draft) => {
|
.then((draft) => {
|
||||||
if (!cancelled) setPreviewState({ status: "loaded-draft", draft });
|
if (!cancelled) setPreviewState({ status: "loaded-draft", draft });
|
||||||
})
|
})
|
||||||
|
|
@ -151,27 +121,19 @@ export function RecipeSourcesPanel({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [initialSelection?.sourceKey, initialSelection?.externalId]);
|
}, [initialSelection?.externalId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
// Only manages `browseState` — deliberately doesn't reset the current
|
|
||||||
// item selection/preview when `selectedSourceKey` changes, so that the
|
|
||||||
// `initialSelection` effect above (which also sets `selectedSourceKey`,
|
|
||||||
// to reflect a deep link) isn't immediately undone by this one running
|
|
||||||
// straight after it in the same commit. Switching source via the
|
|
||||||
// dropdown clears the selection explicitly, in its own `onChange` below,
|
|
||||||
// where that reset is actually wanted.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSourceKey === null) return;
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setBrowseState({ status: "loading" });
|
setBrowseState({ status: "loading" });
|
||||||
|
|
||||||
apiClient
|
apiClient
|
||||||
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
|
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
|
||||||
.then(({ items, nextCursor }) => {
|
.then(({ items, nextCursor }) => {
|
||||||
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
|
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
|
||||||
})
|
})
|
||||||
|
|
@ -182,15 +144,15 @@ export function RecipeSourcesPanel({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [selectedSourceKey, debouncedSearch]);
|
}, [sourceKey, debouncedSearch]);
|
||||||
|
|
||||||
function handleLoadMore() {
|
function handleLoadMore() {
|
||||||
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) {
|
if (browseState.status !== "loaded" || !browseState.nextCursor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cursor = browseState.nextCursor;
|
const cursor = browseState.nextCursor;
|
||||||
apiClient
|
apiClient
|
||||||
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor })
|
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor })
|
||||||
.then(({ items, nextCursor }) => {
|
.then(({ items, nextCursor }) => {
|
||||||
setBrowseState((prev) =>
|
setBrowseState((prev) =>
|
||||||
prev.status === "loaded"
|
prev.status === "loaded"
|
||||||
|
|
@ -206,8 +168,7 @@ export function RecipeSourcesPanel({
|
||||||
onSelectImportedRecipe(item.recipeId);
|
onSelectImportedRecipe(item.recipeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedSourceKey === null) return;
|
const selection = { sourceKey, externalId: item.externalId };
|
||||||
const selection = { sourceKey: selectedSourceKey, externalId: item.externalId };
|
|
||||||
setSelectedExternalId(item.externalId);
|
setSelectedExternalId(item.externalId);
|
||||||
setPreviewState({ status: "loading" });
|
setPreviewState({ status: "loading" });
|
||||||
// Set before `onItemSelected` so the `initialSelection` effect above
|
// Set before `onItemSelected` so the `initialSelection` effect above
|
||||||
|
|
@ -216,51 +177,14 @@ export function RecipeSourcesPanel({
|
||||||
setPreviewedItem(selection);
|
setPreviewedItem(selection);
|
||||||
onItemSelected?.(selection);
|
onItemSelected?.(selection);
|
||||||
apiClient
|
apiClient
|
||||||
.previewSourceItem(selectedSourceKey, item.externalId)
|
.previewSourceItem(sourceKey, item.externalId)
|
||||||
.then((draft) => setPreviewState({ status: "loaded-draft", draft }))
|
.then((draft) => setPreviewState({ status: "loaded-draft", draft }))
|
||||||
.catch(() => setPreviewState({ status: "error" }));
|
.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="recipes-page__header recipes-page__header--sources">
|
<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);
|
|
||||||
setSelectedExternalId(null);
|
|
||||||
setPreviewState({ status: "empty" });
|
|
||||||
setPreviewedItem(null);
|
|
||||||
onItemSelected?.(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{enabledSources.sources.map((source) => (
|
|
||||||
<option key={source.key} value={source.key}>
|
|
||||||
{source.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="recipes-page__search"
|
className="recipes-page__search"
|
||||||
|
|
|
||||||
|
|
@ -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 type { LucideIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,52 +10,79 @@ import {
|
||||||
} from "../../layouts/nav-icons";
|
} from "../../layouts/nav-icons";
|
||||||
import "./recipes.scss";
|
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
|
* A tab of the recipe catalog — either a real {@link RecipeTab} (`GET
|
||||||
* /recipes?tab=`, `recipe.service.ts`'s `listRecipes`) or `"sources"`, a
|
* /recipes?tab=`, `recipe.service.ts`'s `listRecipes`) or a `"source:<key>"`
|
||||||
* web-only mode that doesn't query the recipe table at all: it browses a
|
* value identifying one household-enabled external source, browsed live
|
||||||
* household-enabled external source's own catalog live
|
|
||||||
* (`GET /sources/:sourceKey/browse`, `RecipeSourcesPanel`) instead of
|
* (`GET /sources/:sourceKey/browse`, `RecipeSourcesPanel`) instead of
|
||||||
* listing saved `Recipe` rows. Kept out of the shared `RecipeTab` type on
|
* listing saved `Recipe` rows — one tab per enabled source (see
|
||||||
* purpose — the API has no `tab=sources` to validate.
|
* `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. */
|
/** Builds the tab value identifying `sourceKey`'s own tab. */
|
||||||
const ALL_TABS: Array<{ value: RecipesPageTab; Icon: LucideIcon }> = [
|
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: "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 / Sources by
|
* Catalog tab bar — Favoris / Perso / Foyer / Publique, plus one tab per
|
||||||
* default (`/recettes`, `RecipesPage`). `tabs` narrows which of those
|
* household-enabled source (e.g. "TheMealDB"), in that order. A source's
|
||||||
* show — `RecipePickerDialog` (picking a recipe for a planning slot)
|
* own icon (`SourceView.iconUrl`) is used when it has one, `SourcesIcon`
|
||||||
* passes just the four real ones: browsing external sources mid-dialog,
|
* otherwise — unlike the four real tabs, whose label comes from an i18n
|
||||||
* without the review/import flow, doesn't make sense there yet (its
|
* key, a source tab's label is its own name as-is (there's no translation
|
||||||
* `onChange` narrows the result back to `RecipeTab` itself, safe exactly
|
* for an arbitrary household-picked source's name).
|
||||||
* because `tabs` guarantees `"sources"` is never clickable there). No
|
*
|
||||||
* "toutes" tab among the real ones: every recipe a viewer can see falls
|
* `tabs` narrows which of the four *real* tabs show — every enabled
|
||||||
* under exactly one of perso/foyer/publique (its own visibility) — see
|
* source still gets its own tab regardless (narrowing individual sources
|
||||||
* `recipe.service.ts`'s `listRecipes`.
|
* 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({
|
export function RecipeTabs({
|
||||||
active,
|
active,
|
||||||
onChange,
|
onChange,
|
||||||
tabs = ALL_TABS.map((tab) => tab.value),
|
sources,
|
||||||
|
tabs = REAL_TABS.map((tab) => tab.value),
|
||||||
}: {
|
}: {
|
||||||
active: RecipesPageTab;
|
active: RecipesPageTab;
|
||||||
onChange: (tab: RecipesPageTab) => void;
|
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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="recipe-tabs">
|
<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
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -66,6 +93,24 @@ export function RecipeTabs({
|
||||||
{t(`recipes.tabs.${value}`)}
|
{t(`recipes.tabs.${value}`)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,8 +190,9 @@
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
// Never lets a tab overflow the page (which would force the whole body
|
// Never lets a tab overflow the page (which would force the whole body
|
||||||
// to scroll horizontally, see global.scss's rule against that) — scrolls
|
// to scroll horizontally, see global.scss's rule against that) — scrolls
|
||||||
// within itself instead once the tabs (including the disabled "Sources"
|
// within itself instead once the tabs (favoris/perso/foyer/publique,
|
||||||
// placeholder) don't all fit, same pattern as the sidebar's own nav.
|
// plus one per household-enabled source) don't all fit, same pattern as
|
||||||
|
// the sidebar's own nav.
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
|
|
@ -222,6 +223,16 @@
|
||||||
flex: none;
|
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 {
|
&:hover {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
@ -340,23 +351,9 @@
|
||||||
// actually new to this tab gets its own rules here.
|
// actually new to this tab gets its own rules here.
|
||||||
|
|
||||||
.recipes-page__header--sources {
|
.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);
|
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 {
|
.source-item-table__imported-badge {
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
|
|
|
||||||
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",
|
"favoris": "Favoris",
|
||||||
"perso": "Perso",
|
"perso": "Perso",
|
||||||
"foyer": "Foyer",
|
"foyer": "Foyer",
|
||||||
"publique": "Publique",
|
"publique": "Publique"
|
||||||
"sources": "Sources"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
|
|
@ -171,9 +170,6 @@
|
||||||
"diets": "Régime associé"
|
"diets": "Régime associé"
|
||||||
},
|
},
|
||||||
"sources": {
|
"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…",
|
"searchPlaceholder": "Rechercher…",
|
||||||
"empty": "Aucune recette trouvée.",
|
"empty": "Aucune recette trouvée.",
|
||||||
"loadMore": "Voir plus",
|
"loadMore": "Voir plus",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ import {
|
||||||
type SourceItemSelection,
|
type SourceItemSelection,
|
||||||
} from "../features/recipes/RecipeSourcesPanel";
|
} from "../features/recipes/RecipeSourcesPanel";
|
||||||
import { RecipeTable } from "../features/recipes/RecipeTable";
|
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";
|
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`). */
|
||||||
|
|
@ -32,8 +39,8 @@ type RecipeListState =
|
||||||
*
|
*
|
||||||
* The `sources` route exists so a not-yet-imported item is just as
|
* The `sources` route exists so a not-yet-imported item is just as
|
||||||
* addressable/deep-linkable as a real recipe's `/recettes/:id` — without
|
* addressable/deep-linkable as a real recipe's `/recettes/:id` — without
|
||||||
* it, selecting one inside the "Sources" tab only changed local component
|
* it, selecting one inside its source's own tab only changed local
|
||||||
* state, with no URL of its own (see `RecipeSourcesPanel`'s
|
* component state, with no URL of its own (see `RecipeSourcesPanel`'s
|
||||||
* `initialSelection`/`onItemSelected`, which this page drives).
|
* `initialSelection`/`onItemSelected`, which this page drives).
|
||||||
*/
|
*/
|
||||||
export function RecipesPage() {
|
export function RecipesPage() {
|
||||||
|
|
@ -47,6 +54,7 @@ export function RecipesPage() {
|
||||||
const selectedId = id !== undefined ? Number(id) : null;
|
const selectedId = id !== undefined ? Number(id) : null;
|
||||||
const selectedSourceItem: SourceItemSelection | undefined =
|
const selectedSourceItem: SourceItemSelection | undefined =
|
||||||
sourceKey !== undefined && externalId !== undefined ? { sourceKey, externalId } : undefined;
|
sourceKey !== undefined && externalId !== undefined ? { sourceKey, externalId } : undefined;
|
||||||
|
const enabledSources = useEnabledSources();
|
||||||
// `?search=` lets another page (the recipe form's "faisable maison"
|
// `?search=` lets another page (the recipe form's "faisable maison"
|
||||||
// badge, see `ReproducibleBadge`) deep-link straight into a pre-filled
|
// badge, see `ReproducibleBadge`) deep-link straight into a pre-filled
|
||||||
// search — read once on mount, not kept in sync on every keystroke
|
// search — read once on mount, not kept in sync on every keystroke
|
||||||
|
|
@ -55,13 +63,14 @@ export function RecipesPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// Deep-linking straight into `/recettes/sources/:sourceKey/:externalId`
|
// Deep-linking straight into `/recettes/sources/:sourceKey/:externalId`
|
||||||
// must land on the "Sources" tab — otherwise `RecipeSourcesPanel` (which
|
// must land on that source's own tab — otherwise `RecipeSourcesPanel`
|
||||||
// reads this URL via `selectedSourceItem` below) wouldn't even be
|
// (which reads this URL via `selectedSourceItem` below) wouldn't even be
|
||||||
// mounted to show it. Lazy initializer: only matters for this page's
|
// mounted to show it. Lazy initializer: only matters for this page's
|
||||||
// very first render, same reasoning as `search`'s below.
|
// very first render, same reasoning as `search`'s below.
|
||||||
const [activeTab, setActiveTab] = useState<RecipesPageTab>(() =>
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>(() =>
|
||||||
selectedSourceItem ? "sources" : "favoris",
|
selectedSourceItem ? sourceTabValue(selectedSourceItem.sourceKey) : "favoris",
|
||||||
);
|
);
|
||||||
|
const activeSourceKey = parseSourceTabValue(activeTab);
|
||||||
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
|
||||||
|
|
@ -77,10 +86,12 @@ export function RecipesPage() {
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The "sources" tab doesn't query the recipe table at all — it browses
|
// A source's own tab doesn't query the recipe table at all — it
|
||||||
// a source's own live catalog instead (see `RecipeSourcesPanel`, which
|
// browses that source's live catalog instead (see `RecipeSourcesPanel`,
|
||||||
// owns its own fetching entirely).
|
// which owns its own fetching entirely). The type-guard (not just
|
||||||
if (activeTab === "sources") return;
|
// `activeSourceKey !== null`) is what lets `activeTab` narrow to
|
||||||
|
// `RecipeTab` below, for `apiClient.listRecipes`.
|
||||||
|
if (isSourceTab(activeTab)) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setListState({ status: "loading" });
|
setListState({ status: "loading" });
|
||||||
|
|
||||||
|
|
@ -165,7 +176,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" && (
|
{activeSourceKey === null && (
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="recipes-page__search"
|
className="recipes-page__search"
|
||||||
|
|
@ -179,11 +190,26 @@ export function RecipesPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
<RecipeTabs
|
||||||
|
active={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
|
||||||
|
/>
|
||||||
|
|
||||||
{activeTab === "sources" ? (
|
{activeSourceKey !== null ? (
|
||||||
<RecipeSourcesPanel
|
<RecipeSourcesPanel
|
||||||
initialSelection={selectedSourceItem}
|
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) =>
|
onItemSelected={(item) =>
|
||||||
navigate(
|
navigate(
|
||||||
item
|
item
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue