@ssejal8/recipe-ui-kit 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -277
- package/dist/cjs/index-BEr8BsSA.js +1712 -0
- package/dist/cjs/index-BEr8BsSA.js.map +1 -0
- package/dist/cjs/index.cjs.js +5 -0
- package/dist/cjs/index.cjs.js.map +1 -0
- package/dist/cjs/loader.cjs.js +15 -0
- package/dist/cjs/loader.cjs.js.map +1 -0
- package/dist/cjs/recipe-kit.cjs.js +27 -0
- package/dist/cjs/recipe-kit.cjs.js.map +1 -0
- package/dist/cjs/rk-badge_10.cjs.entry.js +638 -0
- package/dist/cjs/rk-badge_10.cjs.entry.js.map +1 -0
- package/dist/collection/collection-manifest.json +22 -0
- package/dist/collection/components/rk-badge/rk-badge.js +97 -0
- package/dist/collection/components/rk-badge/rk-badge.js.map +1 -0
- package/dist/collection/components/rk-chip/rk-chip.js +245 -0
- package/dist/collection/components/rk-chip/rk-chip.js.map +1 -0
- package/dist/collection/components/rk-empty-state/rk-empty-state.js +138 -0
- package/dist/collection/components/rk-empty-state/rk-empty-state.js.map +1 -0
- package/dist/collection/components/rk-meal-slot/rk-meal-slot.js +312 -0
- package/dist/collection/components/rk-meal-slot/rk-meal-slot.js.map +1 -0
- package/dist/collection/components/rk-modal/rk-modal.js +256 -0
- package/dist/collection/components/rk-modal/rk-modal.js.map +1 -0
- package/dist/collection/components/rk-rating/rk-rating.js +228 -0
- package/dist/collection/components/rk-rating/rk-rating.js.map +1 -0
- package/dist/collection/components/rk-recipe-card/rk-recipe-card.js +366 -0
- package/dist/collection/components/rk-recipe-card/rk-recipe-card.js.map +1 -0
- package/dist/collection/components/rk-search-bar/rk-search-bar.js +291 -0
- package/dist/collection/components/rk-search-bar/rk-search-bar.js.map +1 -0
- package/dist/collection/components/rk-segmented-control/rk-segmented-control.js +196 -0
- package/dist/collection/components/rk-segmented-control/rk-segmented-control.js.map +1 -0
- package/dist/collection/components/rk-tag-input/rk-tag-input.js +264 -0
- package/dist/collection/components/rk-tag-input/rk-tag-input.js.map +1 -0
- package/dist/collection/index.js +2 -0
- package/dist/collection/index.js.map +1 -0
- package/dist/components/index.d.ts +35 -0
- package/dist/components/index.js +2 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/rk-badge.d.ts +11 -0
- package/dist/components/rk-badge.js +2 -0
- package/dist/components/rk-badge.js.map +1 -0
- package/dist/components/rk-chip.d.ts +11 -0
- package/dist/components/rk-chip.js +2 -0
- package/dist/components/rk-chip.js.map +1 -0
- package/dist/components/rk-empty-state.d.ts +11 -0
- package/dist/components/rk-empty-state.js +2 -0
- package/dist/components/rk-empty-state.js.map +1 -0
- package/dist/components/rk-meal-slot.d.ts +11 -0
- package/dist/components/rk-meal-slot.js +2 -0
- package/dist/components/rk-meal-slot.js.map +1 -0
- package/dist/components/rk-modal.d.ts +11 -0
- package/dist/components/rk-modal.js +2 -0
- package/dist/components/rk-modal.js.map +1 -0
- package/dist/components/rk-rating.d.ts +11 -0
- package/dist/components/rk-rating.js +2 -0
- package/dist/components/rk-rating.js.map +1 -0
- package/dist/components/rk-recipe-card.d.ts +11 -0
- package/dist/components/rk-recipe-card.js +2 -0
- package/dist/components/rk-recipe-card.js.map +1 -0
- package/dist/components/rk-search-bar.d.ts +11 -0
- package/dist/components/rk-search-bar.js +2 -0
- package/dist/components/rk-search-bar.js.map +1 -0
- package/dist/components/rk-segmented-control.d.ts +11 -0
- package/dist/components/rk-segmented-control.js +2 -0
- package/dist/components/rk-segmented-control.js.map +1 -0
- package/dist/components/rk-tag-input.d.ts +11 -0
- package/dist/components/rk-tag-input.js +2 -0
- package/dist/components/rk-tag-input.js.map +1 -0
- package/dist/esm/index-Bxo0C8lB.js +1684 -0
- package/dist/esm/index-Bxo0C8lB.js.map +1 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/loader.js +13 -0
- package/dist/esm/loader.js.map +1 -0
- package/dist/esm/recipe-kit.js +23 -0
- package/dist/esm/recipe-kit.js.map +1 -0
- package/dist/esm/rk-badge_10.entry.js +627 -0
- package/dist/esm/rk-badge_10.entry.js.map +1 -0
- package/dist/index.cjs.js +1 -0
- package/dist/index.js +1 -0
- package/dist/recipe-kit/index.esm.js +2 -0
- package/dist/recipe-kit/index.esm.js.map +1 -0
- package/dist/recipe-kit/p-Bxo0C8lB.js +3 -0
- package/dist/recipe-kit/p-Bxo0C8lB.js.map +1 -0
- package/dist/recipe-kit/p-fcb955c7.entry.js +2 -0
- package/dist/recipe-kit/p-fcb955c7.entry.js.map +1 -0
- package/dist/recipe-kit/recipe-kit.css +1 -0
- package/dist/recipe-kit/recipe-kit.esm.js +2 -0
- package/dist/recipe-kit/recipe-kit.esm.js.map +1 -0
- package/dist/recipe-kit/rk-global.css +104 -0
- package/dist/types/components/rk-badge/rk-badge.d.ts +15 -0
- package/dist/types/components/rk-chip/rk-chip.d.ts +37 -0
- package/dist/types/components/rk-empty-state/rk-empty-state.d.ts +19 -0
- package/dist/types/components/rk-meal-slot/rk-meal-slot.d.ts +53 -0
- package/dist/types/components/rk-modal/rk-modal.d.ts +41 -0
- package/dist/types/components/rk-rating/rk-rating.d.ts +26 -0
- package/dist/types/components/rk-recipe-card/rk-recipe-card.d.ts +67 -0
- package/dist/types/components/rk-search-bar/rk-search-bar.d.ts +39 -0
- package/dist/types/components/rk-segmented-control/rk-segmented-control.d.ts +37 -0
- package/dist/types/components/rk-tag-input/rk-tag-input.d.ts +35 -0
- package/{packages/recipe-kit-stencil/src → dist/types}/components.d.ts +1 -1
- package/{packages/recipe-kit-stencil/src/index.ts → dist/types/index.d.ts} +0 -2
- package/dist/types/stencil-public-runtime.d.ts +1873 -0
- package/loader/cdn.js +1 -0
- package/loader/index.cjs.js +1 -0
- package/loader/index.d.ts +24 -0
- package/loader/index.es2017.js +1 -0
- package/loader/index.js +2 -0
- package/package.json +84 -22
- package/.claude/settings.local.json +0 -10
- package/.npmr +0 -1
- package/apps/recipe-web/package.json +0 -31
- package/apps/recipe-web/src/app.css +0 -418
- package/apps/recipe-web/src/app.d.ts +0 -32
- package/apps/recipe-web/src/app.html +0 -25
- package/apps/recipe-web/src/lib/api/mealdb.ts +0 -240
- package/apps/recipe-web/src/lib/components/AppHeader.svelte +0 -290
- package/apps/recipe-web/src/lib/components/ConfirmDialog.svelte +0 -46
- package/apps/recipe-web/src/lib/components/Modal.svelte +0 -33
- package/apps/recipe-web/src/lib/components/RecipeCard.svelte +0 -100
- package/apps/recipe-web/src/lib/components/RecipeForm.svelte +0 -560
- package/apps/recipe-web/src/lib/components/RecipeGrid.svelte +0 -77
- package/apps/recipe-web/src/lib/components/RecipePickerDialog.svelte +0 -185
- package/apps/recipe-web/src/lib/components/Toaster.svelte +0 -111
- package/apps/recipe-web/src/lib/domain/recipe.ts +0 -130
- package/apps/recipe-web/src/lib/domain/validation.ts +0 -189
- package/apps/recipe-web/src/lib/stencil/WebComponent.svelte +0 -145
- package/apps/recipe-web/src/lib/stencil/register.ts +0 -28
- package/apps/recipe-web/src/lib/stores/favorites.svelte.ts +0 -81
- package/apps/recipe-web/src/lib/stores/myRecipes.svelte.ts +0 -98
- package/apps/recipe-web/src/lib/stores/persisted.svelte.ts +0 -73
- package/apps/recipe-web/src/lib/stores/planner.svelte.ts +0 -275
- package/apps/recipe-web/src/lib/stores/theme.svelte.ts +0 -67
- package/apps/recipe-web/src/lib/stores/toast.svelte.ts +0 -66
- package/apps/recipe-web/src/routes/+error.svelte +0 -36
- package/apps/recipe-web/src/routes/+layout.svelte +0 -62
- package/apps/recipe-web/src/routes/+page.svelte +0 -352
- package/apps/recipe-web/src/routes/+page.ts +0 -107
- package/apps/recipe-web/src/routes/favorites/+page.svelte +0 -170
- package/apps/recipe-web/src/routes/my-recipes/+layout.ts +0 -55
- package/apps/recipe-web/src/routes/my-recipes/+page.svelte +0 -180
- package/apps/recipe-web/src/routes/my-recipes/[id]/edit/+page.svelte +0 -104
- package/apps/recipe-web/src/routes/my-recipes/new/+page.svelte +0 -67
- package/apps/recipe-web/src/routes/planner/+page.svelte +0 -422
- package/apps/recipe-web/src/routes/recipes/[id]/+page.svelte +0 -538
- package/apps/recipe-web/src/routes/recipes/[id]/+page.ts +0 -24
- package/apps/recipe-web/static/favicon.svg +0 -8
- package/apps/recipe-web/static/robots.txt +0 -2
- package/apps/recipe-web/svelte.config.js +0 -35
- package/apps/recipe-web/tsconfig.json +0 -14
- package/apps/recipe-web/vite.config.ts +0 -23
- package/packages/recipe-kit-stencil/README.md +0 -185
- package/packages/recipe-kit-stencil/package-lock.json +0 -4772
- package/packages/recipe-kit-stencil/package.json +0 -91
- package/packages/recipe-kit-stencil/src/components/rk-badge/readme.md +0 -38
- package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.tsx +0 -31
- package/packages/recipe-kit-stencil/src/components/rk-chip/readme.md +0 -52
- package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.tsx +0 -105
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/readme.md +0 -44
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.tsx +0 -50
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/readme.md +0 -58
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.tsx +0 -182
- package/packages/recipe-kit-stencil/src/components/rk-modal/readme.md +0 -57
- package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.tsx +0 -176
- package/packages/recipe-kit-stencil/src/components/rk-rating/readme.md +0 -42
- package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.tsx +0 -114
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/readme.md +0 -66
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.tsx +0 -179
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/test/rk-recipe-card.spec.tsx +0 -70
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/readme.md +0 -67
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.tsx +0 -152
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/test/rk-search-bar.spec.tsx +0 -93
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/readme.md +0 -41
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.tsx +0 -115
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/readme.md +0 -47
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.tsx +0 -181
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/test/rk-tag-input.spec.tsx +0 -76
- package/packages/recipe-kit-stencil/src/index.html +0 -259
- package/packages/recipe-kit-stencil/stencil.config.ts +0 -41
- package/packages/recipe-kit-stencil/tsconfig.json +0 -24
- /package/{packages/recipe-kit-stencil/LICENSE → LICENSE} +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-badge/rk-badge.css +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-chip/rk-chip.css +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-empty-state/rk-empty-state.css +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-meal-slot/rk-meal-slot.css +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-modal/rk-modal.css +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-rating/rk-rating.css +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-recipe-card/rk-recipe-card.css +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-search-bar/rk-search-bar.css +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-segmented-control/rk-segmented-control.css +0 -0
- /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-tag-input/rk-tag-input.css +0 -0
- /package/{packages/recipe-kit-stencil/src/global → dist/collection}/rk-global.css +0 -0
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Typed client for TheMealDB (https://www.themealdb.com/api.php).
|
|
3
|
-
*
|
|
4
|
-
* The public test key `1` is used, which needs no signup. Every function takes
|
|
5
|
-
* an optional `fetch` so SvelteKit `load` functions can pass their own instance
|
|
6
|
-
* and get request de-duplication plus SSR-safe relative URLs.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { splitInstructions, type Recipe, type RecipeSummary } from '$domain/recipe';
|
|
10
|
-
|
|
11
|
-
const BASE_URL = 'https://www.themealdb.com/api/json/v1/1';
|
|
12
|
-
|
|
13
|
-
type Fetch = typeof globalThis.fetch;
|
|
14
|
-
|
|
15
|
-
/** Raw meal record. TheMealDB returns 20 flat ingredient/measure column pairs. */
|
|
16
|
-
interface RawMeal {
|
|
17
|
-
idMeal: string;
|
|
18
|
-
strMeal: string;
|
|
19
|
-
strCategory: string | null;
|
|
20
|
-
strArea: string | null;
|
|
21
|
-
strMealThumb: string | null;
|
|
22
|
-
strInstructions: string | null;
|
|
23
|
-
strTags: string | null;
|
|
24
|
-
strYoutube: string | null;
|
|
25
|
-
strSource: string | null;
|
|
26
|
-
[key: string]: string | null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** `filter.php` responses only carry enough data to render a card. */
|
|
30
|
-
interface RawMealLite {
|
|
31
|
-
idMeal: string;
|
|
32
|
-
strMeal: string;
|
|
33
|
-
strMealThumb: string | null;
|
|
34
|
-
strArea?: string | null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface RawCategory {
|
|
38
|
-
idCategory: string;
|
|
39
|
-
strCategory: string;
|
|
40
|
-
strCategoryThumb: string;
|
|
41
|
-
strCategoryDescription: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface Category {
|
|
45
|
-
name: string;
|
|
46
|
-
thumbnail: string;
|
|
47
|
-
description: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export class MealDbError extends Error {
|
|
51
|
-
constructor(
|
|
52
|
-
message: string,
|
|
53
|
-
readonly status?: number
|
|
54
|
-
) {
|
|
55
|
-
super(message);
|
|
56
|
-
this.name = 'MealDbError';
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function request<T>(path: string, fetchImpl: Fetch = fetch): Promise<T> {
|
|
61
|
-
let response: Response;
|
|
62
|
-
try {
|
|
63
|
-
response = await fetchImpl(`${BASE_URL}${path}`);
|
|
64
|
-
} catch (cause) {
|
|
65
|
-
throw new MealDbError(`Could not reach the recipe service. ${(cause as Error)?.message ?? ''}`.trim());
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (!response.ok) {
|
|
69
|
-
throw new MealDbError(`The recipe service responded with ${response.status}.`, response.status);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
return (await response.json()) as T;
|
|
74
|
-
} catch {
|
|
75
|
-
throw new MealDbError('The recipe service returned an unreadable response.');
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/* ------------------------------------------------------------------ mapping */
|
|
80
|
-
|
|
81
|
-
/** Tags arrive comma-joined and are not guaranteed unique or clean. */
|
|
82
|
-
function parseTags(raw: string | null): string[] {
|
|
83
|
-
const tags = (raw ?? '')
|
|
84
|
-
.split(',')
|
|
85
|
-
.map((tag) => tag.trim())
|
|
86
|
-
.filter(Boolean);
|
|
87
|
-
|
|
88
|
-
const seen = new Set<string>();
|
|
89
|
-
return tags.filter((tag) => {
|
|
90
|
-
const key = tag.toLowerCase();
|
|
91
|
-
if (seen.has(key)) return false;
|
|
92
|
-
seen.add(key);
|
|
93
|
-
return true;
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function collectIngredients(meal: RawMeal) {
|
|
98
|
-
const ingredients = [];
|
|
99
|
-
for (let index = 1; index <= 20; index += 1) {
|
|
100
|
-
const name = meal[`strIngredient${index}`]?.trim();
|
|
101
|
-
if (!name) continue;
|
|
102
|
-
ingredients.push({ name, measure: meal[`strMeasure${index}`]?.trim() ?? '' });
|
|
103
|
-
}
|
|
104
|
-
return ingredients;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function normaliseMeal(meal: RawMeal): Recipe {
|
|
108
|
-
return {
|
|
109
|
-
id: meal.idMeal,
|
|
110
|
-
name: meal.strMeal?.trim() ?? 'Untitled recipe',
|
|
111
|
-
category: meal.strCategory?.trim() || 'Uncategorised',
|
|
112
|
-
area: meal.strArea?.trim() || 'Unknown',
|
|
113
|
-
image: meal.strMealThumb ?? undefined,
|
|
114
|
-
steps: splitInstructions(meal.strInstructions ?? ''),
|
|
115
|
-
ingredients: collectIngredients(meal),
|
|
116
|
-
tags: parseTags(meal.strTags),
|
|
117
|
-
origin: 'api',
|
|
118
|
-
youtubeUrl: meal.strYoutube || undefined,
|
|
119
|
-
sourceUrl: meal.strSource || undefined,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function summariseMeal(meal: RawMeal): RecipeSummary {
|
|
124
|
-
return {
|
|
125
|
-
id: meal.idMeal,
|
|
126
|
-
name: meal.strMeal?.trim() ?? 'Untitled recipe',
|
|
127
|
-
image: meal.strMealThumb ?? undefined,
|
|
128
|
-
category: meal.strCategory?.trim() || 'Uncategorised',
|
|
129
|
-
area: meal.strArea?.trim() || 'Unknown',
|
|
130
|
-
origin: 'api',
|
|
131
|
-
ingredientCount: collectIngredients(meal).length,
|
|
132
|
-
tags: parseTags(meal.strTags),
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* `filter.php` omits category and area, but the caller knows which filter
|
|
138
|
-
* produced the list, so the known dimension is threaded back in.
|
|
139
|
-
*/
|
|
140
|
-
function summariseLite(meal: RawMealLite, known: { category?: string; area?: string }): RecipeSummary {
|
|
141
|
-
return {
|
|
142
|
-
id: meal.idMeal,
|
|
143
|
-
name: meal.strMeal?.trim() ?? 'Untitled recipe',
|
|
144
|
-
image: meal.strMealThumb ?? undefined,
|
|
145
|
-
category: known.category ?? 'Uncategorised',
|
|
146
|
-
area: known.area ?? meal.strArea?.trim() ?? 'Unknown',
|
|
147
|
-
origin: 'api',
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/* ---------------------------------------------------------------- endpoints */
|
|
152
|
-
|
|
153
|
-
/** Full-text search by recipe name. Returns fully populated recipes. */
|
|
154
|
-
export async function searchRecipes(query: string, fetchImpl?: Fetch): Promise<RecipeSummary[]> {
|
|
155
|
-
const data = await request<{ meals: RawMeal[] | null }>(`/search.php?s=${encodeURIComponent(query)}`, fetchImpl);
|
|
156
|
-
return (data.meals ?? []).map(summariseMeal);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** The whole catalogue, used for the default browse view. */
|
|
160
|
-
export function browseAll(fetchImpl?: Fetch): Promise<RecipeSummary[]> {
|
|
161
|
-
return searchRecipes('', fetchImpl);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export async function filterByCategory(category: string, fetchImpl?: Fetch): Promise<RecipeSummary[]> {
|
|
165
|
-
const data = await request<{ meals: RawMealLite[] | null }>(`/filter.php?c=${encodeURIComponent(category)}`, fetchImpl);
|
|
166
|
-
return (data.meals ?? []).map((meal) => summariseLite(meal, { category }));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export async function filterByArea(area: string, fetchImpl?: Fetch): Promise<RecipeSummary[]> {
|
|
170
|
-
const data = await request<{ meals: RawMealLite[] | null }>(`/filter.php?a=${encodeURIComponent(area)}`, fetchImpl);
|
|
171
|
-
return (data.meals ?? []).map((meal) => summariseLite(meal, { area }));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export async function filterByIngredient(ingredient: string, fetchImpl?: Fetch): Promise<RecipeSummary[]> {
|
|
175
|
-
// TheMealDB expects underscores for multi-word ingredients.
|
|
176
|
-
const slug = ingredient.trim().replace(/\s+/g, '_');
|
|
177
|
-
const data = await request<{ meals: RawMealLite[] | null }>(`/filter.php?i=${encodeURIComponent(slug)}`, fetchImpl);
|
|
178
|
-
return (data.meals ?? []).map((meal) => summariseLite(meal, {}));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export async function getRecipe(id: string, fetchImpl?: Fetch): Promise<Recipe | null> {
|
|
182
|
-
const data = await request<{ meals: RawMeal[] | null }>(`/lookup.php?i=${encodeURIComponent(id)}`, fetchImpl);
|
|
183
|
-
const meal = data.meals?.[0];
|
|
184
|
-
return meal ? normaliseMeal(meal) : null;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function getRandomRecipe(fetchImpl?: Fetch): Promise<Recipe | null> {
|
|
188
|
-
const data = await request<{ meals: RawMeal[] | null }>('/random.php', fetchImpl);
|
|
189
|
-
const meal = data.meals?.[0];
|
|
190
|
-
return meal ? normaliseMeal(meal) : null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* De-duplicates a facet list case-insensitively, preserving first-seen order.
|
|
195
|
-
*
|
|
196
|
-
* TheMealDB genuinely returns repeats: `list.php?a=list` currently ships
|
|
197
|
-
* "Dominican", "Congolese" and "Channel Islander" twice. Facet values are the
|
|
198
|
-
* natural key for an `{#each}` block and Svelte throws on duplicate keys, so
|
|
199
|
-
* this has to be cleaned up before the data reaches any component.
|
|
200
|
-
*/
|
|
201
|
-
function uniqueBy<T>(items: T[], identity: (item: T) => string): T[] {
|
|
202
|
-
const seen = new Set<string>();
|
|
203
|
-
const out: T[] = [];
|
|
204
|
-
|
|
205
|
-
for (const item of items) {
|
|
206
|
-
const key = identity(item).trim().toLowerCase();
|
|
207
|
-
if (key === '' || seen.has(key)) continue;
|
|
208
|
-
seen.add(key);
|
|
209
|
-
out.push(item);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return out;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export async function listCategories(fetchImpl?: Fetch): Promise<Category[]> {
|
|
216
|
-
const data = await request<{ categories: RawCategory[] | null }>('/categories.php', fetchImpl);
|
|
217
|
-
const categories = (data.categories ?? []).map((category) => ({
|
|
218
|
-
name: category.strCategory,
|
|
219
|
-
thumbnail: category.strCategoryThumb,
|
|
220
|
-
description: category.strCategoryDescription,
|
|
221
|
-
}));
|
|
222
|
-
return uniqueBy(categories, (category) => category.name);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function listAreas(fetchImpl?: Fetch): Promise<string[]> {
|
|
226
|
-
const data = await request<{ meals: { strArea: string }[] | null }>('/list.php?a=list', fetchImpl);
|
|
227
|
-
const areas = uniqueBy(
|
|
228
|
-
(data.meals ?? []).map((meal) => meal.strArea).filter(Boolean),
|
|
229
|
-
(area) => area
|
|
230
|
-
);
|
|
231
|
-
return areas.sort((a, b) => a.localeCompare(b));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export async function listIngredients(fetchImpl?: Fetch): Promise<string[]> {
|
|
235
|
-
const data = await request<{ meals: { strIngredient: string }[] | null }>('/list.php?i=list', fetchImpl);
|
|
236
|
-
return uniqueBy(
|
|
237
|
-
(data.meals ?? []).map((meal) => meal.strIngredient).filter(Boolean),
|
|
238
|
-
(ingredient) => ingredient
|
|
239
|
-
);
|
|
240
|
-
}
|
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { page } from '$app/state';
|
|
3
|
-
import { favorites } from '$stores/favorites.svelte';
|
|
4
|
-
import { myRecipes } from '$stores/myRecipes.svelte';
|
|
5
|
-
import { planner } from '$stores/planner.svelte';
|
|
6
|
-
import { theme } from '$stores/theme.svelte';
|
|
7
|
-
|
|
8
|
-
const links = $derived([
|
|
9
|
-
{ href: '/', label: 'Discover', badge: 0 },
|
|
10
|
-
{ href: '/favorites', label: 'Favorites', badge: favorites.count },
|
|
11
|
-
{ href: '/my-recipes', label: 'My recipes', badge: myRecipes.count },
|
|
12
|
-
{ href: '/planner', label: 'Planner', badge: planner.countFor() }
|
|
13
|
-
]);
|
|
14
|
-
|
|
15
|
-
function isActive(href: string): boolean {
|
|
16
|
-
const path = page.url.pathname;
|
|
17
|
-
if (href === '/') return path === '/';
|
|
18
|
-
return path === href || path.startsWith(`${href}/`);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
let menuOpen = $state(false);
|
|
22
|
-
|
|
23
|
-
// Close the mobile menu whenever the route changes.
|
|
24
|
-
$effect(() => {
|
|
25
|
-
void page.url.pathname;
|
|
26
|
-
menuOpen = false;
|
|
27
|
-
});
|
|
28
|
-
</script>
|
|
29
|
-
|
|
30
|
-
<header class="header">
|
|
31
|
-
<div class="inner">
|
|
32
|
-
<a class="brand" href="/" aria-label="Mise — home">
|
|
33
|
-
<span class="brand-mark" aria-hidden="true">🥘</span>
|
|
34
|
-
<span class="brand-text">
|
|
35
|
-
<strong>Mise</strong>
|
|
36
|
-
<small>Recipe finder & meal planner</small>
|
|
37
|
-
</span>
|
|
38
|
-
</a>
|
|
39
|
-
|
|
40
|
-
<button
|
|
41
|
-
class="menu-toggle"
|
|
42
|
-
type="button"
|
|
43
|
-
aria-expanded={menuOpen}
|
|
44
|
-
aria-controls="primary-nav"
|
|
45
|
-
onclick={() => (menuOpen = !menuOpen)}
|
|
46
|
-
>
|
|
47
|
-
<span class="sr-only">{menuOpen ? 'Close menu' : 'Open menu'}</span>
|
|
48
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
49
|
-
{#if menuOpen}
|
|
50
|
-
<path d="M6 6l12 12M18 6 6 18" />
|
|
51
|
-
{:else}
|
|
52
|
-
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
53
|
-
{/if}
|
|
54
|
-
</svg>
|
|
55
|
-
</button>
|
|
56
|
-
|
|
57
|
-
<nav class="nav" class:is-open={menuOpen} id="primary-nav" aria-label="Primary">
|
|
58
|
-
{#each links as link (link.href)}
|
|
59
|
-
<a class="nav-link" class:is-active={isActive(link.href)} href={link.href} aria-current={isActive(link.href) ? 'page' : undefined}>
|
|
60
|
-
{link.label}
|
|
61
|
-
{#if link.badge > 0}
|
|
62
|
-
<span class="nav-badge">{link.badge}</span>
|
|
63
|
-
{/if}
|
|
64
|
-
</a>
|
|
65
|
-
{/each}
|
|
66
|
-
|
|
67
|
-
<div class="nav-tail">
|
|
68
|
-
<a class="btn btn-primary btn-sm" href="/my-recipes/new">
|
|
69
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
|
70
|
-
<path d="M12 5v14M5 12h14" />
|
|
71
|
-
</svg>
|
|
72
|
-
Add recipe
|
|
73
|
-
</a>
|
|
74
|
-
|
|
75
|
-
<button class="theme-toggle" type="button" onclick={() => theme.toggle()} aria-label="Switch to {theme.current === 'dark' ? 'light' : 'dark'} theme">
|
|
76
|
-
{#if theme.current === 'dark'}
|
|
77
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
78
|
-
<circle cx="12" cy="12" r="4.2" />
|
|
79
|
-
<path d="M12 2v2.4M12 19.6V22M2 12h2.4M19.6 12H22M4.9 4.9l1.7 1.7M17.4 17.4l1.7 1.7M19.1 4.9l-1.7 1.7M6.6 17.4l-1.7 1.7" />
|
|
80
|
-
</svg>
|
|
81
|
-
{:else}
|
|
82
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
83
|
-
<path d="M20.5 14.6A8.6 8.6 0 0 1 9.4 3.5 8.6 8.6 0 1 0 20.5 14.6Z" />
|
|
84
|
-
</svg>
|
|
85
|
-
{/if}
|
|
86
|
-
</button>
|
|
87
|
-
</div>
|
|
88
|
-
</nav>
|
|
89
|
-
</div>
|
|
90
|
-
</header>
|
|
91
|
-
|
|
92
|
-
<style>
|
|
93
|
-
.header {
|
|
94
|
-
position: sticky;
|
|
95
|
-
top: 0;
|
|
96
|
-
z-index: 100;
|
|
97
|
-
background: color-mix(in srgb, var(--rk-bg) 88%, transparent);
|
|
98
|
-
backdrop-filter: blur(12px);
|
|
99
|
-
border-bottom: 1px solid var(--rk-border);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
.inner {
|
|
103
|
-
display: flex;
|
|
104
|
-
align-items: center;
|
|
105
|
-
gap: 18px;
|
|
106
|
-
max-width: var(--app-max);
|
|
107
|
-
margin: 0 auto;
|
|
108
|
-
padding: 11px var(--app-gutter);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
.brand {
|
|
112
|
-
display: inline-flex;
|
|
113
|
-
align-items: center;
|
|
114
|
-
gap: 10px;
|
|
115
|
-
text-decoration: none;
|
|
116
|
-
color: inherit;
|
|
117
|
-
margin-right: auto;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
.brand-mark {
|
|
121
|
-
font-size: 1.5rem;
|
|
122
|
-
line-height: 1;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
.brand-text {
|
|
126
|
-
display: grid;
|
|
127
|
-
line-height: 1.15;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
.brand-text strong {
|
|
131
|
-
font-family: var(--rk-font-display);
|
|
132
|
-
font-size: 1.16rem;
|
|
133
|
-
letter-spacing: -0.02em;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
.brand-text small {
|
|
137
|
-
font-size: 0.68rem;
|
|
138
|
-
color: var(--rk-muted);
|
|
139
|
-
letter-spacing: 0.01em;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
.nav {
|
|
143
|
-
display: flex;
|
|
144
|
-
align-items: center;
|
|
145
|
-
gap: 3px;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
.nav-link {
|
|
149
|
-
display: inline-flex;
|
|
150
|
-
align-items: center;
|
|
151
|
-
gap: 6px;
|
|
152
|
-
padding: 8px 14px;
|
|
153
|
-
border-radius: var(--rk-radius-pill);
|
|
154
|
-
color: var(--rk-ink-2);
|
|
155
|
-
text-decoration: none;
|
|
156
|
-
font-size: 0.9rem;
|
|
157
|
-
font-weight: 600;
|
|
158
|
-
white-space: nowrap;
|
|
159
|
-
transition:
|
|
160
|
-
background var(--rk-transition),
|
|
161
|
-
color var(--rk-transition);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
.nav-link:hover {
|
|
165
|
-
background: var(--rk-surface-2);
|
|
166
|
-
color: var(--rk-ink);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
.nav-link.is-active {
|
|
170
|
-
background: var(--rk-primary-soft);
|
|
171
|
-
color: var(--rk-primary-strong);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
.nav-badge {
|
|
175
|
-
display: inline-flex;
|
|
176
|
-
align-items: center;
|
|
177
|
-
justify-content: center;
|
|
178
|
-
min-width: 19px;
|
|
179
|
-
height: 19px;
|
|
180
|
-
padding: 0 5px;
|
|
181
|
-
border-radius: var(--rk-radius-pill);
|
|
182
|
-
background: var(--rk-ink);
|
|
183
|
-
color: var(--rk-bg);
|
|
184
|
-
font-size: 0.68rem;
|
|
185
|
-
font-weight: 700;
|
|
186
|
-
font-variant-numeric: tabular-nums;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
.nav-link.is-active .nav-badge {
|
|
190
|
-
background: var(--rk-primary);
|
|
191
|
-
color: #fff;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
.nav-tail {
|
|
195
|
-
display: flex;
|
|
196
|
-
align-items: center;
|
|
197
|
-
gap: 8px;
|
|
198
|
-
margin-left: 12px;
|
|
199
|
-
padding-left: 14px;
|
|
200
|
-
border-left: 1px solid var(--rk-border);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
.theme-toggle {
|
|
204
|
-
display: inline-flex;
|
|
205
|
-
align-items: center;
|
|
206
|
-
justify-content: center;
|
|
207
|
-
width: 36px;
|
|
208
|
-
height: 36px;
|
|
209
|
-
padding: 0;
|
|
210
|
-
border: 1px solid var(--rk-border);
|
|
211
|
-
border-radius: 50%;
|
|
212
|
-
background: var(--rk-surface);
|
|
213
|
-
color: var(--rk-ink-2);
|
|
214
|
-
cursor: pointer;
|
|
215
|
-
transition:
|
|
216
|
-
background var(--rk-transition),
|
|
217
|
-
color var(--rk-transition);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
.theme-toggle:hover {
|
|
221
|
-
background: var(--rk-surface-2);
|
|
222
|
-
color: var(--rk-primary);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
.theme-toggle svg {
|
|
226
|
-
width: 17px;
|
|
227
|
-
height: 17px;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
.menu-toggle {
|
|
231
|
-
display: none;
|
|
232
|
-
align-items: center;
|
|
233
|
-
justify-content: center;
|
|
234
|
-
width: 40px;
|
|
235
|
-
height: 40px;
|
|
236
|
-
padding: 0;
|
|
237
|
-
border: 1px solid var(--rk-border);
|
|
238
|
-
border-radius: var(--rk-radius);
|
|
239
|
-
background: var(--rk-surface);
|
|
240
|
-
color: var(--rk-ink);
|
|
241
|
-
cursor: pointer;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
.menu-toggle svg {
|
|
245
|
-
width: 20px;
|
|
246
|
-
height: 20px;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
@media (max-width: 860px) {
|
|
250
|
-
.menu-toggle {
|
|
251
|
-
display: inline-flex;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
.nav {
|
|
255
|
-
position: absolute;
|
|
256
|
-
top: 100%;
|
|
257
|
-
left: 0;
|
|
258
|
-
right: 0;
|
|
259
|
-
flex-direction: column;
|
|
260
|
-
align-items: stretch;
|
|
261
|
-
gap: 4px;
|
|
262
|
-
padding: 12px var(--app-gutter) 18px;
|
|
263
|
-
background: var(--rk-bg);
|
|
264
|
-
border-bottom: 1px solid var(--rk-border);
|
|
265
|
-
box-shadow: var(--rk-shadow);
|
|
266
|
-
display: none;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
.nav.is-open {
|
|
270
|
-
display: flex;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
.nav-link {
|
|
274
|
-
justify-content: space-between;
|
|
275
|
-
padding: 12px 14px;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
.nav-tail {
|
|
279
|
-
margin: 8px 0 0;
|
|
280
|
-
padding: 12px 0 0;
|
|
281
|
-
border-left: 0;
|
|
282
|
-
border-top: 1px solid var(--rk-border);
|
|
283
|
-
justify-content: space-between;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
.brand-text small {
|
|
287
|
-
display: none;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
</style>
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import Modal from './Modal.svelte';
|
|
3
|
-
|
|
4
|
-
interface Props {
|
|
5
|
-
open: boolean;
|
|
6
|
-
heading: string;
|
|
7
|
-
message: string;
|
|
8
|
-
confirmLabel?: string;
|
|
9
|
-
cancelLabel?: string;
|
|
10
|
-
destructive?: boolean;
|
|
11
|
-
onconfirm: () => void;
|
|
12
|
-
oncancel: () => void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
let {
|
|
16
|
-
open,
|
|
17
|
-
heading,
|
|
18
|
-
message,
|
|
19
|
-
confirmLabel = 'Confirm',
|
|
20
|
-
cancelLabel = 'Cancel',
|
|
21
|
-
destructive = false,
|
|
22
|
-
onconfirm,
|
|
23
|
-
oncancel
|
|
24
|
-
}: Props = $props();
|
|
25
|
-
</script>
|
|
26
|
-
|
|
27
|
-
<Modal {open} {heading} size="sm" onclose={oncancel}>
|
|
28
|
-
<p>{message}</p>
|
|
29
|
-
|
|
30
|
-
{#snippet footer()}
|
|
31
|
-
<div slot="footer" class="actions">
|
|
32
|
-
<button class="btn" type="button" onclick={oncancel}>{cancelLabel}</button>
|
|
33
|
-
<button class="btn" class:btn-danger={destructive} class:btn-primary={!destructive} type="button" onclick={onconfirm}>
|
|
34
|
-
{confirmLabel}
|
|
35
|
-
</button>
|
|
36
|
-
</div>
|
|
37
|
-
{/snippet}
|
|
38
|
-
</Modal>
|
|
39
|
-
|
|
40
|
-
<style>
|
|
41
|
-
.actions {
|
|
42
|
-
display: flex;
|
|
43
|
-
gap: 10px;
|
|
44
|
-
justify-content: flex-end;
|
|
45
|
-
}
|
|
46
|
-
</style>
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
Wraps `<rk-modal>`. The web component owns presentation, focus trapping and
|
|
3
|
-
Escape handling; the app owns the `open` state and reacts to `rkModalClose`.
|
|
4
|
-
-->
|
|
5
|
-
<script lang="ts">
|
|
6
|
-
import WebComponent from '$lib/stencil/WebComponent.svelte';
|
|
7
|
-
|
|
8
|
-
interface Props {
|
|
9
|
-
open: boolean;
|
|
10
|
-
heading: string;
|
|
11
|
-
subheading?: string;
|
|
12
|
-
size?: 'sm' | 'md' | 'lg';
|
|
13
|
-
dismissible?: boolean;
|
|
14
|
-
onclose: () => void;
|
|
15
|
-
children: import('svelte').Snippet;
|
|
16
|
-
/** Rendered into the component's `footer` slot. */
|
|
17
|
-
footer?: import('svelte').Snippet;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
let { open, heading, subheading, size = 'md', dismissible = true, onclose, children, footer }: Props = $props();
|
|
21
|
-
</script>
|
|
22
|
-
|
|
23
|
-
<WebComponent
|
|
24
|
-
tag="rk-modal"
|
|
25
|
-
props={{ open, heading, subheading, size, dismissible }}
|
|
26
|
-
events={{ rkModalClose: onclose }}
|
|
27
|
-
>
|
|
28
|
-
{@render children()}
|
|
29
|
-
|
|
30
|
-
{#if footer}
|
|
31
|
-
{@render footer()}
|
|
32
|
-
{/if}
|
|
33
|
-
</WebComponent>
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
Wraps the `<rk-recipe-card>` web component.
|
|
3
|
-
|
|
4
|
-
This is where the SvelteKit ↔ Stencil contract lives for the most-used
|
|
5
|
-
component: primitive props go down, custom events come back up, and slots
|
|
6
|
-
carry app-specific content (badges, action buttons) into the card.
|
|
7
|
-
-->
|
|
8
|
-
<script lang="ts">
|
|
9
|
-
import { goto } from '$app/navigation';
|
|
10
|
-
import WebComponent from '$lib/stencil/WebComponent.svelte';
|
|
11
|
-
import { favorites } from '$stores/favorites.svelte';
|
|
12
|
-
import { toasts } from '$stores/toast.svelte';
|
|
13
|
-
import type { RecipeSummary } from '$domain/recipe';
|
|
14
|
-
|
|
15
|
-
interface Props {
|
|
16
|
-
recipe: RecipeSummary;
|
|
17
|
-
/** Dense horizontal layout, used in the planner picker. */
|
|
18
|
-
compact?: boolean;
|
|
19
|
-
hideFavorite?: boolean;
|
|
20
|
-
/** Overrides navigation on activation. Return `true` to skip the default. */
|
|
21
|
-
onselect?: (recipe: RecipeSummary) => boolean | void;
|
|
22
|
-
/** Extra content for the card's `actions` slot. */
|
|
23
|
-
actions?: import('svelte').Snippet<[RecipeSummary]>;
|
|
24
|
-
/** Extra content for the card's default (metadata) slot. */
|
|
25
|
-
badges?: import('svelte').Snippet<[RecipeSummary]>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
let { recipe, compact = false, hideFavorite = false, onselect, actions, badges }: Props = $props();
|
|
29
|
-
|
|
30
|
-
// Favorite state is owned by the app; the component only reports intent.
|
|
31
|
-
const isFavorite = $derived(favorites.has(recipe.id));
|
|
32
|
-
|
|
33
|
-
function handleSelect(event: CustomEvent<{ recipeId: string }>) {
|
|
34
|
-
if (onselect?.(recipe) === true) return;
|
|
35
|
-
void goto(`/recipes/${event.detail.recipeId}`);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function handleFavoriteToggle(event: CustomEvent<{ favorite: boolean }>) {
|
|
39
|
-
const nowFavorite = favorites.toggle(recipe);
|
|
40
|
-
// Trust the store's resulting state over the event payload — the store is
|
|
41
|
-
// the source of truth and may have been changed in another tab.
|
|
42
|
-
if (nowFavorite !== event.detail.favorite) return;
|
|
43
|
-
|
|
44
|
-
toasts.push(nowFavorite ? `Saved “${recipe.name}” to favorites.` : `Removed “${recipe.name}” from favorites.`, {
|
|
45
|
-
tone: nowFavorite ? 'success' : 'info',
|
|
46
|
-
action: nowFavorite ? undefined : { label: 'Undo', run: () => favorites.add(recipe) }
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const timeLabel = $derived(recipe.minutes && recipe.minutes > 0 ? `${recipe.minutes} min` : null);
|
|
51
|
-
</script>
|
|
52
|
-
|
|
53
|
-
<WebComponent
|
|
54
|
-
tag="rk-recipe-card"
|
|
55
|
-
class="card-host"
|
|
56
|
-
props={{
|
|
57
|
-
recipeId: recipe.id,
|
|
58
|
-
name: recipe.name,
|
|
59
|
-
image: recipe.image,
|
|
60
|
-
category: recipe.category,
|
|
61
|
-
area: recipe.area,
|
|
62
|
-
minutes: recipe.minutes,
|
|
63
|
-
servings: recipe.servings,
|
|
64
|
-
favorite: isFavorite,
|
|
65
|
-
origin: recipe.origin,
|
|
66
|
-
href: `/recipes/${recipe.id}`,
|
|
67
|
-
compact,
|
|
68
|
-
hideFavorite
|
|
69
|
-
}}
|
|
70
|
-
events={{
|
|
71
|
-
rkCardSelect: handleSelect as (event: CustomEvent<never>) => void,
|
|
72
|
-
rkFavoriteToggle: handleFavoriteToggle as (event: CustomEvent<never>) => void
|
|
73
|
-
}}
|
|
74
|
-
>
|
|
75
|
-
<!-- Default slot: metadata badges rendered under the title. -->
|
|
76
|
-
{#if badges}
|
|
77
|
-
{@render badges(recipe)}
|
|
78
|
-
{:else}
|
|
79
|
-
{#if timeLabel}
|
|
80
|
-
<rk-badge tone="accent" small>{timeLabel}</rk-badge>
|
|
81
|
-
{/if}
|
|
82
|
-
{#if recipe.ingredientCount}
|
|
83
|
-
<rk-badge small>{recipe.ingredientCount} ingredients</rk-badge>
|
|
84
|
-
{/if}
|
|
85
|
-
{#each (recipe.tags ?? []).slice(0, 2) as tag (tag)}
|
|
86
|
-
<rk-badge tone="primary" variant="outline" small>{tag}</rk-badge>
|
|
87
|
-
{/each}
|
|
88
|
-
{/if}
|
|
89
|
-
|
|
90
|
-
<!-- Named slot: action row in the card footer. -->
|
|
91
|
-
{#if actions}
|
|
92
|
-
{@render actions(recipe)}
|
|
93
|
-
{/if}
|
|
94
|
-
</WebComponent>
|
|
95
|
-
|
|
96
|
-
<style>
|
|
97
|
-
:global(.card-host) {
|
|
98
|
-
height: 100%;
|
|
99
|
-
}
|
|
100
|
-
</style>
|