@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.
Files changed (191) hide show
  1. package/README.md +108 -277
  2. package/dist/cjs/index-BEr8BsSA.js +1712 -0
  3. package/dist/cjs/index-BEr8BsSA.js.map +1 -0
  4. package/dist/cjs/index.cjs.js +5 -0
  5. package/dist/cjs/index.cjs.js.map +1 -0
  6. package/dist/cjs/loader.cjs.js +15 -0
  7. package/dist/cjs/loader.cjs.js.map +1 -0
  8. package/dist/cjs/recipe-kit.cjs.js +27 -0
  9. package/dist/cjs/recipe-kit.cjs.js.map +1 -0
  10. package/dist/cjs/rk-badge_10.cjs.entry.js +638 -0
  11. package/dist/cjs/rk-badge_10.cjs.entry.js.map +1 -0
  12. package/dist/collection/collection-manifest.json +22 -0
  13. package/dist/collection/components/rk-badge/rk-badge.js +97 -0
  14. package/dist/collection/components/rk-badge/rk-badge.js.map +1 -0
  15. package/dist/collection/components/rk-chip/rk-chip.js +245 -0
  16. package/dist/collection/components/rk-chip/rk-chip.js.map +1 -0
  17. package/dist/collection/components/rk-empty-state/rk-empty-state.js +138 -0
  18. package/dist/collection/components/rk-empty-state/rk-empty-state.js.map +1 -0
  19. package/dist/collection/components/rk-meal-slot/rk-meal-slot.js +312 -0
  20. package/dist/collection/components/rk-meal-slot/rk-meal-slot.js.map +1 -0
  21. package/dist/collection/components/rk-modal/rk-modal.js +256 -0
  22. package/dist/collection/components/rk-modal/rk-modal.js.map +1 -0
  23. package/dist/collection/components/rk-rating/rk-rating.js +228 -0
  24. package/dist/collection/components/rk-rating/rk-rating.js.map +1 -0
  25. package/dist/collection/components/rk-recipe-card/rk-recipe-card.js +366 -0
  26. package/dist/collection/components/rk-recipe-card/rk-recipe-card.js.map +1 -0
  27. package/dist/collection/components/rk-search-bar/rk-search-bar.js +291 -0
  28. package/dist/collection/components/rk-search-bar/rk-search-bar.js.map +1 -0
  29. package/dist/collection/components/rk-segmented-control/rk-segmented-control.js +196 -0
  30. package/dist/collection/components/rk-segmented-control/rk-segmented-control.js.map +1 -0
  31. package/dist/collection/components/rk-tag-input/rk-tag-input.js +264 -0
  32. package/dist/collection/components/rk-tag-input/rk-tag-input.js.map +1 -0
  33. package/dist/collection/index.js +2 -0
  34. package/dist/collection/index.js.map +1 -0
  35. package/dist/components/index.d.ts +35 -0
  36. package/dist/components/index.js +2 -0
  37. package/dist/components/index.js.map +1 -0
  38. package/dist/components/rk-badge.d.ts +11 -0
  39. package/dist/components/rk-badge.js +2 -0
  40. package/dist/components/rk-badge.js.map +1 -0
  41. package/dist/components/rk-chip.d.ts +11 -0
  42. package/dist/components/rk-chip.js +2 -0
  43. package/dist/components/rk-chip.js.map +1 -0
  44. package/dist/components/rk-empty-state.d.ts +11 -0
  45. package/dist/components/rk-empty-state.js +2 -0
  46. package/dist/components/rk-empty-state.js.map +1 -0
  47. package/dist/components/rk-meal-slot.d.ts +11 -0
  48. package/dist/components/rk-meal-slot.js +2 -0
  49. package/dist/components/rk-meal-slot.js.map +1 -0
  50. package/dist/components/rk-modal.d.ts +11 -0
  51. package/dist/components/rk-modal.js +2 -0
  52. package/dist/components/rk-modal.js.map +1 -0
  53. package/dist/components/rk-rating.d.ts +11 -0
  54. package/dist/components/rk-rating.js +2 -0
  55. package/dist/components/rk-rating.js.map +1 -0
  56. package/dist/components/rk-recipe-card.d.ts +11 -0
  57. package/dist/components/rk-recipe-card.js +2 -0
  58. package/dist/components/rk-recipe-card.js.map +1 -0
  59. package/dist/components/rk-search-bar.d.ts +11 -0
  60. package/dist/components/rk-search-bar.js +2 -0
  61. package/dist/components/rk-search-bar.js.map +1 -0
  62. package/dist/components/rk-segmented-control.d.ts +11 -0
  63. package/dist/components/rk-segmented-control.js +2 -0
  64. package/dist/components/rk-segmented-control.js.map +1 -0
  65. package/dist/components/rk-tag-input.d.ts +11 -0
  66. package/dist/components/rk-tag-input.js +2 -0
  67. package/dist/components/rk-tag-input.js.map +1 -0
  68. package/dist/esm/index-Bxo0C8lB.js +1684 -0
  69. package/dist/esm/index-Bxo0C8lB.js.map +1 -0
  70. package/dist/esm/index.js +4 -0
  71. package/dist/esm/index.js.map +1 -0
  72. package/dist/esm/loader.js +13 -0
  73. package/dist/esm/loader.js.map +1 -0
  74. package/dist/esm/recipe-kit.js +23 -0
  75. package/dist/esm/recipe-kit.js.map +1 -0
  76. package/dist/esm/rk-badge_10.entry.js +627 -0
  77. package/dist/esm/rk-badge_10.entry.js.map +1 -0
  78. package/dist/index.cjs.js +1 -0
  79. package/dist/index.js +1 -0
  80. package/dist/recipe-kit/index.esm.js +2 -0
  81. package/dist/recipe-kit/index.esm.js.map +1 -0
  82. package/dist/recipe-kit/p-Bxo0C8lB.js +3 -0
  83. package/dist/recipe-kit/p-Bxo0C8lB.js.map +1 -0
  84. package/dist/recipe-kit/p-fcb955c7.entry.js +2 -0
  85. package/dist/recipe-kit/p-fcb955c7.entry.js.map +1 -0
  86. package/dist/recipe-kit/recipe-kit.css +1 -0
  87. package/dist/recipe-kit/recipe-kit.esm.js +2 -0
  88. package/dist/recipe-kit/recipe-kit.esm.js.map +1 -0
  89. package/dist/recipe-kit/rk-global.css +104 -0
  90. package/dist/types/components/rk-badge/rk-badge.d.ts +15 -0
  91. package/dist/types/components/rk-chip/rk-chip.d.ts +37 -0
  92. package/dist/types/components/rk-empty-state/rk-empty-state.d.ts +19 -0
  93. package/dist/types/components/rk-meal-slot/rk-meal-slot.d.ts +53 -0
  94. package/dist/types/components/rk-modal/rk-modal.d.ts +41 -0
  95. package/dist/types/components/rk-rating/rk-rating.d.ts +26 -0
  96. package/dist/types/components/rk-recipe-card/rk-recipe-card.d.ts +67 -0
  97. package/dist/types/components/rk-search-bar/rk-search-bar.d.ts +39 -0
  98. package/dist/types/components/rk-segmented-control/rk-segmented-control.d.ts +37 -0
  99. package/dist/types/components/rk-tag-input/rk-tag-input.d.ts +35 -0
  100. package/{packages/recipe-kit-stencil/src → dist/types}/components.d.ts +1 -1
  101. package/{packages/recipe-kit-stencil/src/index.ts → dist/types/index.d.ts} +0 -2
  102. package/dist/types/stencil-public-runtime.d.ts +1873 -0
  103. package/loader/cdn.js +1 -0
  104. package/loader/index.cjs.js +1 -0
  105. package/loader/index.d.ts +24 -0
  106. package/loader/index.es2017.js +1 -0
  107. package/loader/index.js +2 -0
  108. package/package.json +84 -22
  109. package/.claude/settings.local.json +0 -10
  110. package/.npmr +0 -1
  111. package/apps/recipe-web/package.json +0 -31
  112. package/apps/recipe-web/src/app.css +0 -418
  113. package/apps/recipe-web/src/app.d.ts +0 -32
  114. package/apps/recipe-web/src/app.html +0 -25
  115. package/apps/recipe-web/src/lib/api/mealdb.ts +0 -240
  116. package/apps/recipe-web/src/lib/components/AppHeader.svelte +0 -290
  117. package/apps/recipe-web/src/lib/components/ConfirmDialog.svelte +0 -46
  118. package/apps/recipe-web/src/lib/components/Modal.svelte +0 -33
  119. package/apps/recipe-web/src/lib/components/RecipeCard.svelte +0 -100
  120. package/apps/recipe-web/src/lib/components/RecipeForm.svelte +0 -560
  121. package/apps/recipe-web/src/lib/components/RecipeGrid.svelte +0 -77
  122. package/apps/recipe-web/src/lib/components/RecipePickerDialog.svelte +0 -185
  123. package/apps/recipe-web/src/lib/components/Toaster.svelte +0 -111
  124. package/apps/recipe-web/src/lib/domain/recipe.ts +0 -130
  125. package/apps/recipe-web/src/lib/domain/validation.ts +0 -189
  126. package/apps/recipe-web/src/lib/stencil/WebComponent.svelte +0 -145
  127. package/apps/recipe-web/src/lib/stencil/register.ts +0 -28
  128. package/apps/recipe-web/src/lib/stores/favorites.svelte.ts +0 -81
  129. package/apps/recipe-web/src/lib/stores/myRecipes.svelte.ts +0 -98
  130. package/apps/recipe-web/src/lib/stores/persisted.svelte.ts +0 -73
  131. package/apps/recipe-web/src/lib/stores/planner.svelte.ts +0 -275
  132. package/apps/recipe-web/src/lib/stores/theme.svelte.ts +0 -67
  133. package/apps/recipe-web/src/lib/stores/toast.svelte.ts +0 -66
  134. package/apps/recipe-web/src/routes/+error.svelte +0 -36
  135. package/apps/recipe-web/src/routes/+layout.svelte +0 -62
  136. package/apps/recipe-web/src/routes/+page.svelte +0 -352
  137. package/apps/recipe-web/src/routes/+page.ts +0 -107
  138. package/apps/recipe-web/src/routes/favorites/+page.svelte +0 -170
  139. package/apps/recipe-web/src/routes/my-recipes/+layout.ts +0 -55
  140. package/apps/recipe-web/src/routes/my-recipes/+page.svelte +0 -180
  141. package/apps/recipe-web/src/routes/my-recipes/[id]/edit/+page.svelte +0 -104
  142. package/apps/recipe-web/src/routes/my-recipes/new/+page.svelte +0 -67
  143. package/apps/recipe-web/src/routes/planner/+page.svelte +0 -422
  144. package/apps/recipe-web/src/routes/recipes/[id]/+page.svelte +0 -538
  145. package/apps/recipe-web/src/routes/recipes/[id]/+page.ts +0 -24
  146. package/apps/recipe-web/static/favicon.svg +0 -8
  147. package/apps/recipe-web/static/robots.txt +0 -2
  148. package/apps/recipe-web/svelte.config.js +0 -35
  149. package/apps/recipe-web/tsconfig.json +0 -14
  150. package/apps/recipe-web/vite.config.ts +0 -23
  151. package/packages/recipe-kit-stencil/README.md +0 -185
  152. package/packages/recipe-kit-stencil/package-lock.json +0 -4772
  153. package/packages/recipe-kit-stencil/package.json +0 -91
  154. package/packages/recipe-kit-stencil/src/components/rk-badge/readme.md +0 -38
  155. package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.tsx +0 -31
  156. package/packages/recipe-kit-stencil/src/components/rk-chip/readme.md +0 -52
  157. package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.tsx +0 -105
  158. package/packages/recipe-kit-stencil/src/components/rk-empty-state/readme.md +0 -44
  159. package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.tsx +0 -50
  160. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/readme.md +0 -58
  161. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.tsx +0 -182
  162. package/packages/recipe-kit-stencil/src/components/rk-modal/readme.md +0 -57
  163. package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.tsx +0 -176
  164. package/packages/recipe-kit-stencil/src/components/rk-rating/readme.md +0 -42
  165. package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.tsx +0 -114
  166. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/readme.md +0 -66
  167. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.tsx +0 -179
  168. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/test/rk-recipe-card.spec.tsx +0 -70
  169. package/packages/recipe-kit-stencil/src/components/rk-search-bar/readme.md +0 -67
  170. package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.tsx +0 -152
  171. package/packages/recipe-kit-stencil/src/components/rk-search-bar/test/rk-search-bar.spec.tsx +0 -93
  172. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/readme.md +0 -41
  173. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.tsx +0 -115
  174. package/packages/recipe-kit-stencil/src/components/rk-tag-input/readme.md +0 -47
  175. package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.tsx +0 -181
  176. package/packages/recipe-kit-stencil/src/components/rk-tag-input/test/rk-tag-input.spec.tsx +0 -76
  177. package/packages/recipe-kit-stencil/src/index.html +0 -259
  178. package/packages/recipe-kit-stencil/stencil.config.ts +0 -41
  179. package/packages/recipe-kit-stencil/tsconfig.json +0 -24
  180. /package/{packages/recipe-kit-stencil/LICENSE → LICENSE} +0 -0
  181. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-badge/rk-badge.css +0 -0
  182. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-chip/rk-chip.css +0 -0
  183. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-empty-state/rk-empty-state.css +0 -0
  184. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-meal-slot/rk-meal-slot.css +0 -0
  185. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-modal/rk-modal.css +0 -0
  186. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-rating/rk-rating.css +0 -0
  187. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-recipe-card/rk-recipe-card.css +0 -0
  188. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-search-bar/rk-search-bar.css +0 -0
  189. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-segmented-control/rk-segmented-control.css +0 -0
  190. /package/{packages/recipe-kit-stencil/src → dist/collection}/components/rk-tag-input/rk-tag-input.css +0 -0
  191. /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 &amp; 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>