From 4381e63045e2b4989b68c11082e24112c8b885b3 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 20:31:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20chaque=20source=20activ=C3=A9e?= =?UTF-8?q?=20devient=20sa=20propre=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle correction demandée sur cette PR : l'onglet générique « Sources » (avec un `, 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 --- apps/web/cypress/e2e/planning.feature | 2 +- apps/web/cypress/e2e/recipe-sources.feature | 11 +- .../features/planning/RecipePickerDialog.tsx | 47 ++++-- .../features/recipes/RecipeSourcesPanel.tsx | 148 +++++------------- apps/web/src/features/recipes/RecipeTabs.tsx | 91 ++++++++--- apps/web/src/features/recipes/recipes.scss | 29 ++-- .../src/features/recipes/useEnabledSources.ts | 40 +++++ apps/web/src/locales/fr/translation.json | 6 +- apps/web/src/pages/RecipesPage.tsx | 54 +++++-- 9 files changed, 236 insertions(+), 192 deletions(-) create mode 100644 apps/web/src/features/recipes/useEnabledSources.ts diff --git a/apps/web/cypress/e2e/planning.feature b/apps/web/cypress/e2e/planning.feature index 3b3b30a..53692ee 100644 --- a/apps/web/cypress/e2e/planning.feature +++ b/apps/web/cypress/e2e/planning.feature @@ -21,7 +21,7 @@ Feature: Adding a recipe to the planning And adding the imported recipe to the planning will succeed When I visit "/" And I click the add button for the first empty planning slot - And I click the button "Sources" + And I click the button "TheMealDB" And I click the source item "Fish Pie" And I click the link "Importer cette recette" Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée." diff --git a/apps/web/cypress/e2e/recipe-sources.feature b/apps/web/cypress/e2e/recipe-sources.feature index 483f490..85a7379 100644 --- a/apps/web/cypress/e2e/recipe-sources.feature +++ b/apps/web/cypress/e2e/recipe-sources.feature @@ -9,13 +9,12 @@ Feature: Browsing external recipe sources And the disliked ingredients list is empty And the planning request returns nothing - Scenario: Prompts to enable a source when the household hasn't enabled any + Scenario: Shows no source tab when the household hasn't enabled any Given the recipe catalog contains nothing And the sources reference list has options And the household's enabled sources are empty When I visit "/recettes" - And I click the button "Sources" - Then I should see "Aucune source n'est activée" + Then I should not see "TheMealDB" Scenario: Browses an enabled source, distinguishing already-imported items from new ones Given the recipe catalog contains nothing @@ -24,7 +23,7 @@ Feature: Browsing external recipe sources And browsing TheMealDB returns some items And recipe 2's detail is available When I visit "/recettes" - And I click the button "Sources" + And I click the button "TheMealDB" Then I should see the source item "Chicken Handi" And I should see the source item "Fish Pie" And the source item "Chicken Handi" should be marked as already imported @@ -40,7 +39,7 @@ Feature: Browsing external recipe sources And browsing TheMealDB returns some items And previewing TheMealDB item "9999" is available When I visit "/recettes" - And I click the button "Sources" + And I click the button "TheMealDB" And I click the source item "Fish Pie" Then the URL should include "/recettes/sources/theMealDb/9999" 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 importing the previewed item will succeed and return id 99 When I visit "/recettes" - And I click the button "Sources" + And I click the button "TheMealDB" And I click the source item "Fish Pie" And I click the link "Importer cette recette" Then the "recipe-name" field should have the value "Fish Pie" diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx index feaf62e..6958790 100644 --- a/apps/web/src/features/planning/RecipePickerDialog.tsx +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -17,7 +17,13 @@ import { DietTagSelect } from "../recipes/DietTagSelect"; import { IngredientPicker } from "../recipes/IngredientPicker"; import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel"; import { RecipeTable } from "../recipes/RecipeTable"; -import { RecipeTabs, type RecipesPageTab } from "../recipes/RecipeTabs"; +import { + RecipeTabs, + type RecipesPageTab, + isSourceTab, + parseSourceTabValue, +} from "../recipes/RecipeTabs"; +import { useEnabledSources } from "../recipes/useEnabledSources"; import "./recipe-picker-dialog.scss"; /** Debounce for the search field — same value as `RecipesPage`'s. */ @@ -48,13 +54,14 @@ export interface PlanningSlot { * with three extra filters layered on top of the plain name search * (ingredients / regime / "convient à tout le foyer" toggle, all wired to * `GET /recipes`'s corresponding query params) since browsing here is - * about finding something to cook, not just looking something up. The - * "Sources" tab is included too (unlike an earlier version of this dialog - * — see `ImportRecipePage`'s `planningSlot`, the review/import flow that - * made including it here worthwhile): picking an already-imported item - * behaves exactly like picking a regular recipe, and picking one that - * isn't imported yet hands off to that review screen, which adds the - * freshly-created recipe straight to this slot once it's saved. + * about finding something to cook, not just looking something up. Each + * household-enabled source's own tab is included too (unlike an earlier + * version of this dialog — see `ImportRecipePage`'s `planningSlot`, the + * review/import flow that made including them here worthwhile): picking + * an already-imported item behaves exactly like picking a regular recipe, + * and picking one that isn't imported yet hands off to that review + * screen, which adds the freshly-created recipe straight to this slot + * once it's saved. * * Mounted only while open (see `PlanningPage`, same conditional-mount * convention as its own `CalendarPopover`) — every piece of local state @@ -81,6 +88,8 @@ export function RecipePickerDialog({ const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("favoris"); + const activeSourceKey = parseSourceTabValue(activeTab); + const enabledSources = useEnabledSources(); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [selectedIngredientIds, setSelectedIngredientIds] = useState([]); @@ -128,9 +137,11 @@ export function RecipePickerDialog({ }, []); useEffect(() => { - // The "sources" tab doesn't query the recipe table at all — same guard - // as `RecipesPage`'s own identical effect. - if (activeTab === "sources") return; + // A source's own tab doesn't query the recipe table at all — same + // guard as `RecipesPage`'s own identical effect (the type-guard, not + // just `activeSourceKey !== null`, is what narrows `activeTab` to + // `RecipeTab` below). + if (isSourceTab(activeTab)) return; let cancelled = false; setListState({ status: "loading" }); @@ -157,7 +168,7 @@ export function RecipePickerDialog({ selectedIngredientIds.includes(ingredient.id), ); - /** Picking an already-imported source item (`RecipeSourcesPanel`'s "sources" tab) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */ + /** Picking an already-imported source item (one of the source tabs' `RecipeSourcesPanel`) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */ function handleSelectImportedRecipe(recipeId: number) { setSourceSelectError(false); apiClient @@ -230,7 +241,7 @@ export function RecipePickerDialog({ return ( - {activeTab !== "sources" && ( + {activeSourceKey === null && (
)} - + - {activeTab === "sources" ? ( + {activeSourceKey !== null ? ( <> {sourceSelectError && (

@@ -305,6 +320,8 @@ export function RecipePickerDialog({

)} diff --git a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx index 3717448..51c59ca 100644 --- a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx +++ b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx @@ -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 { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; import { apiClient } from "../../api/client"; import { RecipeDetailPanel, type RecipeDetailState } from "./RecipeDetailPanel"; import { SourceItemTable } from "./SourceItemTable"; @@ -16,22 +15,27 @@ export interface SourceItemSelection { externalId: string; } -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, a preview on the right), independent of `RecipeTable`'s - * own `RecipeTab`-based fetching: it browses a source's *live* catalog - * (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table. + * One household-enabled source's own tab content in the recipe catalog + * (`RecipesPage`/`RecipePickerDialog`) — a master-detail pair of its own + * (browsable list on the left, a preview on the right), independent of + * `RecipeTable`'s own `RecipeTab`-based fetching: it browses this one + * source's *live* catalog (`GET /sources/:sourceKey/browse`), not the + * saved `Recipe` table. + * + * Scoped to exactly one source — every enabled source gets its own tab + * now (`RecipeTabs`), rather than a single generic "Sources" tab + * switching between them internally, so `sourceKey` is a fixed prop, not + * something this component ever changes itself. Callers remount this + * (via a React `key={sourceKey}` on it) when switching which source's tab + * is active, the same "mounted only while relevant" convention as + * `RecipePickerDialog`/`CalendarPopover` elsewhere — simpler than this + * component reacting to its own `sourceKey` prop changing mid-lifetime. * * The right-hand preview reuses `RecipeDetailPanel` itself (its * `"loaded-draft"` state) rather than a separate component — viewing a @@ -43,13 +47,11 @@ type BrowseState = * recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe` * hands back the id instead of this panel navigating anywhere itself, since * what "viewing" an already-imported item means depends on the caller: - * `RecipesPage` switches its own active tab away from `"sources"` (its - * `RecipeDetailPanel`/`RecipeTable` only render outside that tab, so - * without switching first the URL would change but this panel would keep - * rendering over it) and navigates to the recipe's detail page, while - * `RecipePickerDialog` instead treats it exactly like picking that recipe - * from one of the regular tabs — moving to its own confirm-portions step, - * no navigation at all. + * `RecipesPage` navigates to the recipe's detail page (switching its own + * active tab first — see its own doc comment), while `RecipePickerDialog` + * instead treats it exactly like picking that recipe from one of the + * regular tabs — moving to its own confirm-portions step, no navigation + * at all. * * `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a * not-yet-imported item's preview addressable by URL @@ -60,11 +62,13 @@ type BrowseState = * no URL of its own to keep in sync. */ export function RecipeSourcesPanel({ + sourceKey, onSelectImportedRecipe, planningSlot, initialSelection, onItemSelected, }: { + sourceKey: string; onSelectImportedRecipe: (recipeId: number) => void; /** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */ planningSlot?: { date: string; weekDay: WeekDay; meal: Meal }; @@ -73,10 +77,6 @@ export function RecipeSourcesPanel({ }) { const { t } = useTranslation(); - const [enabledSources, setEnabledSources] = useState({ status: "loading" }); - const [selectedSourceKey, setSelectedSourceKey] = useState( - initialSelection?.sourceKey ?? null, - ); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [browseState, setBrowseState] = useState({ status: "loading" }); @@ -96,52 +96,22 @@ export function RecipeSourcesPanel({ // "needs a fetch" case the effect below must still run for. const [previewedItem, setPreviewedItem] = useState(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 // link, or the browser's back/forward button landing on a different - // item) — not just once on mount. Deliberately doesn't touch - // `browseState`/the source dropdown beyond `selectedSourceKey` above: the - // item list for whichever source this belongs to loads independently - // (see the effect below), on its own schedule. Keyed on the primitive - // fields below, not `initialSelection` itself — a fresh object literal - // from the caller on every render (see `RecipesPage`) would otherwise - // re-run this on every render too. + // item within this same source) — not just once on mount. Keyed on the + // primitive field below, not `initialSelection` itself — a fresh object + // literal from the caller on every render (see `RecipesPage`) would + // otherwise re-run this on every render too. // biome-ignore lint/correctness/useExhaustiveDependencies: see above. useEffect(() => { if (!initialSelection) return; - if ( - previewedItem?.sourceKey === initialSelection.sourceKey && - previewedItem?.externalId === initialSelection.externalId - ) { - return; - } + if (previewedItem?.externalId === initialSelection.externalId) return; let cancelled = false; setPreviewedItem(initialSelection); - setSelectedSourceKey(initialSelection.sourceKey); setSelectedExternalId(initialSelection.externalId); setPreviewState({ status: "loading" }); apiClient - .previewSourceItem(initialSelection.sourceKey, initialSelection.externalId) + .previewSourceItem(sourceKey, initialSelection.externalId) .then((draft) => { if (!cancelled) setPreviewState({ status: "loaded-draft", draft }); }) @@ -151,27 +121,19 @@ export function RecipeSourcesPanel({ return () => { cancelled = true; }; - }, [initialSelection?.sourceKey, initialSelection?.externalId]); + }, [initialSelection?.externalId]); useEffect(() => { const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(timeout); }, [search]); - // Only manages `browseState` — deliberately doesn't reset the current - // item selection/preview when `selectedSourceKey` changes, so that the - // `initialSelection` effect above (which also sets `selectedSourceKey`, - // to reflect a deep link) isn't immediately undone by this one running - // straight after it in the same commit. Switching source via the - // dropdown clears the selection explicitly, in its own `onChange` below, - // where that reset is actually wanted. useEffect(() => { - if (selectedSourceKey === null) return; let cancelled = false; setBrowseState({ status: "loading" }); apiClient - .browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined }) + .browseSource(sourceKey, { query: debouncedSearch.trim() || undefined }) .then(({ items, nextCursor }) => { if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor }); }) @@ -182,15 +144,15 @@ export function RecipeSourcesPanel({ return () => { cancelled = true; }; - }, [selectedSourceKey, debouncedSearch]); + }, [sourceKey, debouncedSearch]); function handleLoadMore() { - if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) { + if (browseState.status !== "loaded" || !browseState.nextCursor) { return; } const cursor = browseState.nextCursor; apiClient - .browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor }) + .browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor }) .then(({ items, nextCursor }) => { setBrowseState((prev) => prev.status === "loaded" @@ -206,8 +168,7 @@ export function RecipeSourcesPanel({ onSelectImportedRecipe(item.recipeId); return; } - if (selectedSourceKey === null) return; - const selection = { sourceKey: selectedSourceKey, externalId: item.externalId }; + const selection = { sourceKey, externalId: item.externalId }; setSelectedExternalId(item.externalId); setPreviewState({ status: "loading" }); // Set before `onItemSelected` so the `initialSelection` effect above @@ -216,51 +177,14 @@ export function RecipeSourcesPanel({ setPreviewedItem(selection); onItemSelected?.(selection); apiClient - .previewSourceItem(selectedSourceKey, item.externalId) + .previewSourceItem(sourceKey, item.externalId) .then((draft) => setPreviewState({ status: "loaded-draft", draft })) .catch(() => setPreviewState({ status: "error" })); } - if (enabledSources.status === "loading") { - return

{t("recipes.loading")}

; - } - if (enabledSources.status === "error") { - return ( -

{t("common.loadError")}

- ); - } - if (enabledSources.sources.length === 0) { - return ( -

- {t("recipes.sources.noneEnabled")}{" "} - {t("recipes.sources.noneEnabledLink")} -

- ); - } - return ( <>
- {enabledSources.sources.length > 1 && ( - - )} "` + * value identifying one household-enabled external source, browsed live * (`GET /sources/:sourceKey/browse`, `RecipeSourcesPanel`) instead of - * listing saved `Recipe` rows. Kept out of the shared `RecipeTab` type on - * purpose — the API has no `tab=sources` to validate. + * listing saved `Recipe` rows — one tab per enabled source (see + * `RecipeTabs` below), so switching between sources is as direct as + * switching between Perso/Foyer/Publique, not a single generic "Sources" + * tab hiding a second selector inside it. Kept out of the shared + * `RecipeTab` type on purpose — the API has no such `tab=` value to + * validate, this is a web-only browsing mode. */ -export type RecipesPageTab = RecipeTab | "sources"; +export type RecipesPageTab = RecipeTab | `${typeof SOURCE_TAB_PREFIX}${string}`; -/** Every possible tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */ -const ALL_TABS: Array<{ value: RecipesPageTab; Icon: LucideIcon }> = [ +/** Builds the tab value identifying `sourceKey`'s own tab. */ +export function sourceTabValue(sourceKey: string): RecipesPageTab { + return `${SOURCE_TAB_PREFIX}${sourceKey}`; +} + +/** The reverse of {@link sourceTabValue} — `null` for any tab that isn't a source tab (a real `RecipeTab`). */ +export function parseSourceTabValue(tab: RecipesPageTab): string | null { + return isSourceTab(tab) ? tab.slice(SOURCE_TAB_PREFIX.length) : null; +} + +/** Type guard version of the same check — narrows `tab` to a real `RecipeTab` in the `false` branch, which a plain `parseSourceTabValue(tab) === null` check can't (TS can't see through the function call). Needed wherever the narrowed value gets passed on to something typed as `RecipeTab`, e.g. `apiClient.listRecipes`. */ +export function isSourceTab(tab: RecipesPageTab): tab is `${typeof SOURCE_TAB_PREFIX}${string}` { + return tab.startsWith(SOURCE_TAB_PREFIX); +} + +/** The four real, DB-backed tabs, in display order, with their icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. No "toutes" tab among them: every recipe a viewer can see falls under exactly one of perso/foyer/publique (its own visibility) — see `recipe.service.ts`'s `listRecipes`. */ +const REAL_TABS: Array<{ value: RecipeTab; Icon: LucideIcon }> = [ { value: "favoris", Icon: FavoriteIcon }, { value: "perso", Icon: AccountIcon }, { value: "foyer", Icon: HouseholdIcon }, { value: "publique", Icon: PublicIcon }, - { value: "sources", Icon: SourcesIcon }, ]; /** - * Catalog tab bar — Favoris / Perso / Foyer / Publique / Sources by - * default (`/recettes`, `RecipesPage`). `tabs` narrows which of those - * show — `RecipePickerDialog` (picking a recipe for a planning slot) - * passes just the four real ones: browsing external sources mid-dialog, - * without the review/import flow, doesn't make sense there yet (its - * `onChange` narrows the result back to `RecipeTab` itself, safe exactly - * because `tabs` guarantees `"sources"` is never clickable there). No - * "toutes" tab among the real ones: every recipe a viewer can see falls - * under exactly one of perso/foyer/publique (its own visibility) — see - * `recipe.service.ts`'s `listRecipes`. + * Catalog tab bar — Favoris / Perso / Foyer / Publique, plus one tab per + * household-enabled source (e.g. "TheMealDB"), in that order. A source's + * own icon (`SourceView.iconUrl`) is used when it has one, `SourcesIcon` + * otherwise — unlike the four real tabs, whose label comes from an i18n + * key, a source tab's label is its own name as-is (there's no translation + * for an arbitrary household-picked source's name). + * + * `tabs` narrows which of the four *real* tabs show — every enabled + * source still gets its own tab regardless (narrowing individual sources + * doesn't make sense the way narrowing the four real ones does). + * `RecipePickerDialog` used to pass just the four real ones, before its + * own review/import flow existed to hand a picked source item off to; now + * every caller shows the full set, but the narrowing stays available for + * a future caller that still wants it. */ export function RecipeTabs({ active, onChange, - tabs = ALL_TABS.map((tab) => tab.value), + sources, + tabs = REAL_TABS.map((tab) => tab.value), }: { active: RecipesPageTab; onChange: (tab: RecipesPageTab) => void; - tabs?: readonly RecipesPageTab[]; + /** Household-enabled sources, one tab each. Pass `[]` while still loading (see `useEnabledSources`) — that's indistinguishable from "none enabled" for this bar, which simply renders no source tabs either way. */ + sources: readonly SourceView[]; + tabs?: readonly RecipeTab[]; }) { const { t } = useTranslation(); return (
- {ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => ( + {REAL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => ( + ); + })}
); } diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index d916f86..49a6e72 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -190,8 +190,9 @@ gap: var(--space-xs); // Never lets a tab overflow the page (which would force the whole body // to scroll horizontally, see global.scss's rule against that) — scrolls - // within itself instead once the tabs (including the disabled "Sources" - // placeholder) don't all fit, same pattern as the sidebar's own nav. + // within itself instead once the tabs (favoris/perso/foyer/publique, + // plus one per household-enabled source) don't all fit, same pattern as + // the sidebar's own nav. overflow-x: auto; border-bottom: 1px solid var(--color-border); margin-bottom: var(--space-md); @@ -222,6 +223,16 @@ flex: none; } + // A source tab's own icon (`SourceView.iconUrl`) — sized to match the + // Lucide `svg` icons above so a source tab doesn't stand out from the + // four real ones. + .recipe-tabs__source-icon { + width: 1.05rem; + height: 1.05rem; + flex: none; + object-fit: contain; + } + &:hover { color: var(--color-text); } @@ -340,23 +351,9 @@ // actually new to this tab gets its own rules here. .recipes-page__header--sources { - // The source
- + - {activeTab === "sources" ? ( + {activeSourceKey !== null ? ( navigate( item