@ssejal8/recipe-ui-kit 0.1.0

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 (87) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.npmr +1 -0
  3. package/README.md +354 -0
  4. package/apps/recipe-web/package.json +31 -0
  5. package/apps/recipe-web/src/app.css +418 -0
  6. package/apps/recipe-web/src/app.d.ts +32 -0
  7. package/apps/recipe-web/src/app.html +25 -0
  8. package/apps/recipe-web/src/lib/api/mealdb.ts +240 -0
  9. package/apps/recipe-web/src/lib/components/AppHeader.svelte +290 -0
  10. package/apps/recipe-web/src/lib/components/ConfirmDialog.svelte +46 -0
  11. package/apps/recipe-web/src/lib/components/Modal.svelte +33 -0
  12. package/apps/recipe-web/src/lib/components/RecipeCard.svelte +100 -0
  13. package/apps/recipe-web/src/lib/components/RecipeForm.svelte +560 -0
  14. package/apps/recipe-web/src/lib/components/RecipeGrid.svelte +77 -0
  15. package/apps/recipe-web/src/lib/components/RecipePickerDialog.svelte +185 -0
  16. package/apps/recipe-web/src/lib/components/Toaster.svelte +111 -0
  17. package/apps/recipe-web/src/lib/domain/recipe.ts +130 -0
  18. package/apps/recipe-web/src/lib/domain/validation.ts +189 -0
  19. package/apps/recipe-web/src/lib/stencil/WebComponent.svelte +145 -0
  20. package/apps/recipe-web/src/lib/stencil/register.ts +28 -0
  21. package/apps/recipe-web/src/lib/stores/favorites.svelte.ts +81 -0
  22. package/apps/recipe-web/src/lib/stores/myRecipes.svelte.ts +98 -0
  23. package/apps/recipe-web/src/lib/stores/persisted.svelte.ts +73 -0
  24. package/apps/recipe-web/src/lib/stores/planner.svelte.ts +275 -0
  25. package/apps/recipe-web/src/lib/stores/theme.svelte.ts +67 -0
  26. package/apps/recipe-web/src/lib/stores/toast.svelte.ts +66 -0
  27. package/apps/recipe-web/src/routes/+error.svelte +36 -0
  28. package/apps/recipe-web/src/routes/+layout.svelte +62 -0
  29. package/apps/recipe-web/src/routes/+page.svelte +352 -0
  30. package/apps/recipe-web/src/routes/+page.ts +107 -0
  31. package/apps/recipe-web/src/routes/favorites/+page.svelte +170 -0
  32. package/apps/recipe-web/src/routes/my-recipes/+layout.ts +55 -0
  33. package/apps/recipe-web/src/routes/my-recipes/+page.svelte +180 -0
  34. package/apps/recipe-web/src/routes/my-recipes/[id]/edit/+page.svelte +104 -0
  35. package/apps/recipe-web/src/routes/my-recipes/new/+page.svelte +67 -0
  36. package/apps/recipe-web/src/routes/planner/+page.svelte +422 -0
  37. package/apps/recipe-web/src/routes/recipes/[id]/+page.svelte +538 -0
  38. package/apps/recipe-web/src/routes/recipes/[id]/+page.ts +24 -0
  39. package/apps/recipe-web/static/favicon.svg +8 -0
  40. package/apps/recipe-web/static/robots.txt +2 -0
  41. package/apps/recipe-web/svelte.config.js +35 -0
  42. package/apps/recipe-web/tsconfig.json +14 -0
  43. package/apps/recipe-web/vite.config.ts +23 -0
  44. package/package.json +29 -0
  45. package/packages/recipe-kit-stencil/LICENSE +21 -0
  46. package/packages/recipe-kit-stencil/README.md +185 -0
  47. package/packages/recipe-kit-stencil/package-lock.json +4772 -0
  48. package/packages/recipe-kit-stencil/package.json +91 -0
  49. package/packages/recipe-kit-stencil/src/components/rk-badge/readme.md +38 -0
  50. package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.css +102 -0
  51. package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.tsx +31 -0
  52. package/packages/recipe-kit-stencil/src/components/rk-chip/readme.md +52 -0
  53. package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.css +155 -0
  54. package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.tsx +105 -0
  55. package/packages/recipe-kit-stencil/src/components/rk-empty-state/readme.md +44 -0
  56. package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.css +64 -0
  57. package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.tsx +50 -0
  58. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/readme.md +58 -0
  59. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.css +225 -0
  60. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.tsx +182 -0
  61. package/packages/recipe-kit-stencil/src/components/rk-modal/readme.md +57 -0
  62. package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.css +163 -0
  63. package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.tsx +176 -0
  64. package/packages/recipe-kit-stencil/src/components/rk-rating/readme.md +42 -0
  65. package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.css +71 -0
  66. package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.tsx +114 -0
  67. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/readme.md +66 -0
  68. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.css +280 -0
  69. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.tsx +179 -0
  70. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/test/rk-recipe-card.spec.tsx +70 -0
  71. package/packages/recipe-kit-stencil/src/components/rk-search-bar/readme.md +67 -0
  72. package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.css +141 -0
  73. package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.tsx +152 -0
  74. package/packages/recipe-kit-stencil/src/components/rk-search-bar/test/rk-search-bar.spec.tsx +93 -0
  75. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/readme.md +41 -0
  76. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.css +91 -0
  77. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.tsx +115 -0
  78. package/packages/recipe-kit-stencil/src/components/rk-tag-input/readme.md +47 -0
  79. package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.css +146 -0
  80. package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.tsx +181 -0
  81. package/packages/recipe-kit-stencil/src/components/rk-tag-input/test/rk-tag-input.spec.tsx +76 -0
  82. package/packages/recipe-kit-stencil/src/components.d.ts +1192 -0
  83. package/packages/recipe-kit-stencil/src/global/rk-global.css +104 -0
  84. package/packages/recipe-kit-stencil/src/index.html +259 -0
  85. package/packages/recipe-kit-stencil/src/index.ts +6 -0
  86. package/packages/recipe-kit-stencil/stencil.config.ts +41 -0
  87. package/packages/recipe-kit-stencil/tsconfig.json +24 -0
@@ -0,0 +1,67 @@
1
+ import { browser } from '$app/environment';
2
+ import { Persisted, STORAGE_KEYS } from './persisted.svelte';
3
+
4
+ export type Theme = 'light' | 'dark';
5
+
6
+ /**
7
+ * Light / dark theme.
8
+ *
9
+ * Both `data-theme` (used by the app shell) and `data-rk-theme` (used by
10
+ * recipe-kit-stencil) are set, so the Svelte chrome and the web components
11
+ * always agree. The initial value is applied by an inline script in `app.html`
12
+ * to avoid a flash of the wrong palette before hydration.
13
+ */
14
+ class ThemeStore {
15
+ #store = new Persisted<Theme | null>(STORAGE_KEYS.theme, () => null, (raw) => (raw === 'dark' || raw === 'light' ? raw : null));
16
+
17
+ #system = $state<Theme>('light');
18
+
19
+ constructor() {
20
+ if (!browser) return;
21
+
22
+ const query = window.matchMedia('(prefers-color-scheme: dark)');
23
+ this.#system = query.matches ? 'dark' : 'light';
24
+ query.addEventListener('change', (event) => {
25
+ this.#system = event.matches ? 'dark' : 'light';
26
+ if (this.#store.current === null) this.#apply();
27
+ });
28
+
29
+ this.#apply();
30
+ }
31
+
32
+ /** The theme in effect: the explicit choice, or the OS preference. */
33
+ get current(): Theme {
34
+ return this.#store.current ?? this.#system;
35
+ }
36
+
37
+ /** True when the user has not made an explicit choice. */
38
+ get followsSystem(): boolean {
39
+ return this.#store.current === null;
40
+ }
41
+
42
+ #apply() {
43
+ if (!browser) return;
44
+ const resolved = this.current;
45
+ document.documentElement.dataset.theme = resolved;
46
+ document.documentElement.dataset.rkTheme = resolved;
47
+ }
48
+
49
+ set(theme: Theme) {
50
+ this.#store.current = theme;
51
+ this.#store.save();
52
+ this.#apply();
53
+ }
54
+
55
+ toggle() {
56
+ this.set(this.current === 'dark' ? 'light' : 'dark');
57
+ }
58
+
59
+ /** Clears the explicit choice and follows the OS again. */
60
+ useSystem() {
61
+ this.#store.current = null;
62
+ this.#store.save();
63
+ this.#apply();
64
+ }
65
+ }
66
+
67
+ export const theme = new ThemeStore();
@@ -0,0 +1,66 @@
1
+ export type ToastTone = 'info' | 'success' | 'error';
2
+
3
+ export interface Toast {
4
+ id: number;
5
+ message: string;
6
+ tone: ToastTone;
7
+ /** Optional single action, e.g. "Undo". */
8
+ action?: { label: string; run: () => void };
9
+ }
10
+
11
+ const DEFAULT_TIMEOUT = 4200;
12
+
13
+ /**
14
+ * Transient feedback queue. Not persisted — toasts are session-only by design.
15
+ */
16
+ class ToastStore {
17
+ #items = $state<Toast[]>([]);
18
+ #nextId = 1;
19
+ #timers = new Map<number, ReturnType<typeof setTimeout>>();
20
+
21
+ get all(): Toast[] {
22
+ return this.#items;
23
+ }
24
+
25
+ push(message: string, options: { tone?: ToastTone; action?: Toast['action']; timeout?: number } = {}): number {
26
+ const id = this.#nextId++;
27
+ const toast: Toast = { id, message, tone: options.tone ?? 'info', action: options.action };
28
+ this.#items = [...this.#items, toast];
29
+
30
+ // A toast offering an action gets longer to live, since it needs reading.
31
+ const timeout = options.timeout ?? (options.action ? DEFAULT_TIMEOUT * 1.6 : DEFAULT_TIMEOUT);
32
+ this.#timers.set(
33
+ id,
34
+ setTimeout(() => this.dismiss(id), timeout)
35
+ );
36
+
37
+ return id;
38
+ }
39
+
40
+ success(message: string, action?: Toast['action']) {
41
+ return this.push(message, { tone: 'success', action });
42
+ }
43
+
44
+ error(message: string, action?: Toast['action']) {
45
+ return this.push(message, { tone: 'error', action, timeout: 6500 });
46
+ }
47
+
48
+ dismiss(id: number) {
49
+ const timer = this.#timers.get(id);
50
+ if (timer) {
51
+ clearTimeout(timer);
52
+ this.#timers.delete(id);
53
+ }
54
+ this.#items = this.#items.filter((toast) => toast.id !== id);
55
+ }
56
+
57
+ /** Runs a toast's action and dismisses it, so an "Undo" can't fire twice. */
58
+ runAction(id: number) {
59
+ const toast = this.#items.find((entry) => entry.id === id);
60
+ if (!toast?.action) return;
61
+ toast.action.run();
62
+ this.dismiss(id);
63
+ }
64
+ }
65
+
66
+ export const toasts = new ToastStore();
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+
4
+ const status = $derived(page.status);
5
+ const message = $derived(page.error?.message ?? 'Something went wrong.');
6
+
7
+ const heading = $derived(status === 404 ? 'We could not find that page' : 'Something went wrong');
8
+ </script>
9
+
10
+ <svelte:head>
11
+ <title>{status} — Mise</title>
12
+ </svelte:head>
13
+
14
+ <div class="wrap">
15
+ <p class="eyebrow">Error {status}</p>
16
+ <rk-empty-state icon={status === 404 ? '🧭' : '⚠️'} heading={heading} message={message} tone={status === 404 ? 'neutral' : 'error'}>
17
+ <div class="actions">
18
+ <a class="btn btn-primary" href="/">Back to Discover</a>
19
+ <a class="btn" href="/favorites">My favorites</a>
20
+ </div>
21
+ </rk-empty-state>
22
+ </div>
23
+
24
+ <style>
25
+ .wrap {
26
+ max-width: 640px;
27
+ margin: 6vh auto;
28
+ }
29
+
30
+ .actions {
31
+ display: flex;
32
+ flex-wrap: wrap;
33
+ gap: 10px;
34
+ justify-content: center;
35
+ }
36
+ </style>
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import '../app.css';
4
+ // Design tokens from the published component library. Importing the library's
5
+ // own stylesheet is what keeps the app shell and the web components in sync.
6
+ import 'recipe-kit-stencil/dist/recipe-kit/recipe-kit.css';
7
+
8
+ import AppHeader from '$components/AppHeader.svelte';
9
+ import Toaster from '$components/Toaster.svelte';
10
+ import { registerRecipeKit } from '$lib/stencil/register';
11
+ // Instantiating the theme store applies the persisted palette on the client.
12
+ import '$stores/theme.svelte';
13
+
14
+ let { children } = $props();
15
+
16
+ onMount(() => {
17
+ // Custom elements can only be defined in the browser, after hydration.
18
+ void registerRecipeKit();
19
+ });
20
+ </script>
21
+
22
+ <svelte:head>
23
+ <title>Mise — Recipe Finder &amp; Meal Planner</title>
24
+ </svelte:head>
25
+
26
+ <div class="shell">
27
+ <a class="skip-link" href="#main">Skip to content</a>
28
+ <AppHeader />
29
+
30
+ <main class="page" id="main">
31
+ {@render children()}
32
+ </main>
33
+
34
+ <footer class="site-footer">
35
+ <div class="footer-inner">
36
+ <p class="small muted">
37
+ Recipe data from <a href="https://www.themealdb.com/" rel="noreferrer noopener" target="_blank">TheMealDB</a>. UI components from
38
+ <a href="https://www.npmjs.com/package/recipe-kit-stencil" rel="noreferrer noopener" target="_blank">recipe-kit-stencil</a>.
39
+ </p>
40
+ <p class="small muted">Favorites, your recipes and meal plans are stored in this browser.</p>
41
+ </div>
42
+ </footer>
43
+ </div>
44
+
45
+ <Toaster />
46
+
47
+ <style>
48
+ .site-footer {
49
+ border-top: 1px solid var(--rk-border);
50
+ background: var(--rk-surface);
51
+ }
52
+
53
+ .footer-inner {
54
+ max-width: var(--app-max);
55
+ margin: 0 auto;
56
+ padding: 26px var(--app-gutter);
57
+ display: flex;
58
+ flex-wrap: wrap;
59
+ justify-content: space-between;
60
+ gap: 8px 24px;
61
+ }
62
+ </style>
@@ -0,0 +1,352 @@
1
+ <script lang="ts">
2
+ import { goto } from '$app/navigation';
3
+ import { page } from '$app/state';
4
+ import RecipeGrid from '$components/RecipeGrid.svelte';
5
+ import { toSummary, type RecipeSummary } from '$domain/recipe';
6
+ import WebComponent from '$lib/stencil/WebComponent.svelte';
7
+ import { myRecipes } from '$stores/myRecipes.svelte';
8
+ import type { PageData } from './$types';
9
+ import type { SortKey } from './+page';
10
+
11
+ let { data }: { data: PageData } = $props();
12
+
13
+ const PAGE_SIZE = 24;
14
+ let visibleCount = $state(PAGE_SIZE);
15
+ let navigating = $state(false);
16
+
17
+ const SORT_OPTIONS = [
18
+ { label: 'Relevance', value: 'relevance' },
19
+ { label: 'A–Z', value: 'az' },
20
+ { label: 'Z–A', value: 'za' }
21
+ ];
22
+
23
+ /**
24
+ * Every filter lives in the URL, which makes searches shareable, survives a
25
+ * reload, and lets SvelteKit's `load` re-run instead of duplicating fetch
26
+ * logic in the component.
27
+ */
28
+ async function updateFilters(changes: Partial<Record<'q' | 'category' | 'area' | 'ingredient' | 'sort', string>>) {
29
+ const params = new URLSearchParams(page.url.searchParams);
30
+
31
+ for (const [key, value] of Object.entries(changes)) {
32
+ if (value) params.set(key, value);
33
+ else params.delete(key);
34
+ }
35
+
36
+ visibleCount = PAGE_SIZE;
37
+ navigating = true;
38
+ const search = params.toString();
39
+ await goto(search ? `/?${search}` : '/', { keepFocus: true, noScroll: true, replaceState: true });
40
+ navigating = false;
41
+ }
42
+
43
+ /**
44
+ * The user's own recipes live in localStorage, so they cannot be part of the
45
+ * server-rendered `load` result. They are matched here on the client and
46
+ * shown first — a recipe you wrote should outrank the catalogue.
47
+ */
48
+ const ownMatches = $derived.by(() => {
49
+ const { q, category, area, ingredient } = data.filters;
50
+ const needle = q.toLowerCase();
51
+
52
+ return myRecipes.all
53
+ .filter((recipe) => {
54
+ if (needle && !recipe.name.toLowerCase().includes(needle) && !recipe.tags.some((tag) => tag.toLowerCase().includes(needle))) return false;
55
+ if (category && recipe.category !== category) return false;
56
+ if (area && recipe.area !== area) return false;
57
+ if (ingredient && !recipe.ingredients.some((row) => row.name.toLowerCase().includes(ingredient.toLowerCase()))) return false;
58
+ return true;
59
+ })
60
+ .map(toSummary);
61
+ });
62
+
63
+ const allResults = $derived<RecipeSummary[]>([...ownMatches, ...data.recipes]);
64
+ const visible = $derived(allResults.slice(0, visibleCount));
65
+ const hasMore = $derived(allResults.length > visibleCount);
66
+
67
+ const activeFilters = $derived(
68
+ [
69
+ data.filters.category && { key: 'category' as const, label: data.filters.category },
70
+ data.filters.area && { key: 'area' as const, label: data.filters.area },
71
+ data.filters.ingredient && { key: 'ingredient' as const, label: data.filters.ingredient }
72
+ ].filter(Boolean) as { key: 'category' | 'area' | 'ingredient'; label: string }[]
73
+ );
74
+
75
+ const hasAnyFilter = $derived(activeFilters.length > 0 || data.filters.q !== '');
76
+
77
+ let ingredientDraft = $state('');
78
+ $effect(() => {
79
+ ingredientDraft = data.filters.ingredient;
80
+ });
81
+ </script>
82
+
83
+ <svelte:head>
84
+ <title>
85
+ {data.filters.q ? `“${data.filters.q}” — Mise` : 'Discover recipes — Mise'}
86
+ </title>
87
+ <meta name="description" content="Search and filter thousands of recipes by name, category, cuisine or ingredient." />
88
+ </svelte:head>
89
+
90
+ <section class="hero">
91
+ <p class="eyebrow">Recipe finder</p>
92
+ <h1 class="page-title">What are you cooking today?</h1>
93
+ <p class="page-lede">
94
+ Search recipes by name, then narrow down by category, cuisine, or a single ingredient you want to use up. Your own recipes are searched alongside the
95
+ catalogue.
96
+ </p>
97
+
98
+ <div class="search-slot">
99
+ <!-- Stencil search bar: value down as a prop, query up as `rkSearch`. -->
100
+ <WebComponent
101
+ tag="rk-search-bar"
102
+ props={{ value: data.filters.q, loading: navigating, debounce: 420 }}
103
+ events={{
104
+ rkSearch: ((event: CustomEvent<string>) => updateFilters({ q: event.detail })) as (event: CustomEvent<never>) => void
105
+ }}
106
+ >
107
+ {#snippet children()}
108
+ <span slot="actions" class="result-count small muted">
109
+ {allResults.length}
110
+ </span>
111
+ {/snippet}
112
+ </WebComponent>
113
+ </div>
114
+
115
+ <!--
116
+ Category chips are the quickest way to browse. Selecting one hits
117
+ `filter.php?c=…`, which returns that category's full list, so this is also
118
+ how the user gets past the small default "featured" selection.
119
+ -->
120
+ <div class="category-rail" role="group" aria-label="Browse by category">
121
+ {#each data.categories as category (category.name)}
122
+ <WebComponent
123
+ tag="rk-chip"
124
+ props={{
125
+ label: category.name,
126
+ value: category.name,
127
+ selected: data.filters.category === category.name,
128
+ tone: 'primary'
129
+ }}
130
+ events={{
131
+ rkChipToggle: ((event: CustomEvent<{ selected: boolean }>) =>
132
+ updateFilters({ category: event.detail.selected ? category.name : '' })) as (event: CustomEvent<never>) => void
133
+ }}
134
+ />
135
+ {/each}
136
+ </div>
137
+ </section>
138
+
139
+ <div class="filters card-surface">
140
+ <div class="filter-row">
141
+ <label class="field">
142
+ <span class="field-label">Category</span>
143
+ <select class="select" value={data.filters.category} onchange={(event) => updateFilters({ category: event.currentTarget.value })}>
144
+ <option value="">All categories</option>
145
+ {#each data.categories as category (category.name)}
146
+ <option value={category.name}>{category.name}</option>
147
+ {/each}
148
+ </select>
149
+ </label>
150
+
151
+ <label class="field">
152
+ <span class="field-label">Cuisine</span>
153
+ <select class="select" value={data.filters.area} onchange={(event) => updateFilters({ area: event.currentTarget.value })}>
154
+ <option value="">All cuisines</option>
155
+ {#each data.areas as area (area)}
156
+ <option value={area}>{area}</option>
157
+ {/each}
158
+ </select>
159
+ </label>
160
+
161
+ <form
162
+ class="field"
163
+ onsubmit={(event) => {
164
+ event.preventDefault();
165
+ void updateFilters({ ingredient: ingredientDraft.trim() });
166
+ }}
167
+ >
168
+ <span class="field-label">Main ingredient</span>
169
+ <div class="ingredient-row">
170
+ <input class="input" type="text" bind:value={ingredientDraft} placeholder="e.g. chicken breast" aria-label="Main ingredient" />
171
+ <button class="btn btn-sm" type="submit">Apply</button>
172
+ </div>
173
+ </form>
174
+
175
+ <div class="field sort-field">
176
+ <span class="field-label">Sort</span>
177
+ <!-- Array prop assigned as a DOM property by the bridge component. -->
178
+ <WebComponent
179
+ tag="rk-segmented-control"
180
+ props={{ options: SORT_OPTIONS, value: data.filters.sort, label: 'Sort recipes', stretch: true }}
181
+ events={{
182
+ rkSegmentChange: ((event: CustomEvent<SortKey>) => updateFilters({ sort: event.detail })) as (event: CustomEvent<never>) => void
183
+ }}
184
+ />
185
+ </div>
186
+ </div>
187
+
188
+ {#if hasAnyFilter}
189
+ <div class="active-filters">
190
+ <span class="small muted">Active:</span>
191
+
192
+ {#if data.filters.q}
193
+ <!-- Dismissible chip: `rkChipDismiss` clears the matching URL param. -->
194
+ <WebComponent
195
+ tag="rk-chip"
196
+ props={{ label: `“${data.filters.q}”`, value: 'q', dismissible: true, tone: 'primary' }}
197
+ events={{ rkChipDismiss: (() => updateFilters({ q: '' })) as (event: CustomEvent<never>) => void }}
198
+ />
199
+ {/if}
200
+
201
+ {#each activeFilters as filter (filter.key)}
202
+ <WebComponent
203
+ tag="rk-chip"
204
+ props={{ label: filter.label, value: filter.key, dismissible: true }}
205
+ events={{ rkChipDismiss: (() => updateFilters({ [filter.key]: '' })) as (event: CustomEvent<never>) => void }}
206
+ />
207
+ {/each}
208
+
209
+ <button class="btn btn-ghost btn-sm" type="button" onclick={() => updateFilters({ q: '', category: '', area: '', ingredient: '' })}>
210
+ Clear all
211
+ </button>
212
+ </div>
213
+ {/if}
214
+ </div>
215
+
216
+ {#if data.error}
217
+ <div class="section">
218
+ <rk-empty-state
219
+ tone="error"
220
+ icon="⚠️"
221
+ heading="Could not load recipes"
222
+ message={data.error}
223
+ >
224
+ <button class="btn btn-primary" type="button" onclick={() => location.reload()}>Try again</button>
225
+ </rk-empty-state>
226
+ </div>
227
+ {:else}
228
+ <div class="section">
229
+ <div class="section-head">
230
+ <h2 class="section-title">
231
+ {#if data.filters.q}
232
+ Results for “{data.filters.q}”
233
+ {:else if hasAnyFilter}
234
+ {[data.filters.category, data.filters.area, data.filters.ingredient].filter(Boolean).join(' · ')}
235
+ {:else}
236
+ Featured recipes
237
+ {/if}
238
+ </h2>
239
+ <p class="small muted">
240
+ {allResults.length}
241
+ {allResults.length === 1 ? 'recipe' : 'recipes'}
242
+ {#if ownMatches.length > 0}
243
+ · {ownMatches.length} of your own
244
+ {/if}
245
+ </p>
246
+ </div>
247
+
248
+ {#if !hasAnyFilter}
249
+ <p class="small muted own-note">Pick a category above to browse the full catalogue.</p>
250
+ {/if}
251
+
252
+ <RecipeGrid recipes={visible} loading={navigating && visible.length === 0}>
253
+ {#snippet empty()}
254
+ <rk-empty-state
255
+ icon="🫙"
256
+ heading={hasAnyFilter ? 'No recipes match those filters' : 'No recipes to show'}
257
+ message={hasAnyFilter
258
+ ? 'Try removing a filter, or search for a different dish.'
259
+ : 'The recipe service returned nothing. Please try again in a moment.'}
260
+ >
261
+ {#if hasAnyFilter}
262
+ <button class="btn btn-primary" type="button" onclick={() => updateFilters({ q: '', category: '', area: '', ingredient: '' })}>
263
+ Clear all filters
264
+ </button>
265
+ {/if}
266
+ </rk-empty-state>
267
+ {/snippet}
268
+ </RecipeGrid>
269
+
270
+ {#if hasMore}
271
+ <div class="more">
272
+ <button class="btn" type="button" onclick={() => (visibleCount += PAGE_SIZE)}>
273
+ Show {Math.min(PAGE_SIZE, allResults.length - visibleCount)} more
274
+ </button>
275
+ <p class="small muted">Showing {visible.length} of {allResults.length}</p>
276
+ </div>
277
+ {/if}
278
+ </div>
279
+ {/if}
280
+
281
+ <style>
282
+ .hero {
283
+ padding-bottom: 8px;
284
+ }
285
+
286
+ .search-slot {
287
+ margin-top: 22px;
288
+ max-width: 720px;
289
+ }
290
+
291
+ .result-count {
292
+ padding-right: 8px;
293
+ font-variant-numeric: tabular-nums;
294
+ font-weight: 650;
295
+ }
296
+
297
+ /* Scrolls horizontally on narrow screens instead of wrapping to many rows. */
298
+ .category-rail {
299
+ display: flex;
300
+ gap: 8px;
301
+ margin-top: 18px;
302
+ padding-bottom: 4px;
303
+ overflow-x: auto;
304
+ scrollbar-width: thin;
305
+ }
306
+
307
+ .filters {
308
+ margin-top: 22px;
309
+ display: grid;
310
+ gap: 16px;
311
+ }
312
+
313
+ .filter-row {
314
+ display: grid;
315
+ gap: 16px;
316
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
317
+ align-items: end;
318
+ }
319
+
320
+ .ingredient-row {
321
+ display: flex;
322
+ gap: 8px;
323
+ }
324
+
325
+ .ingredient-row .input {
326
+ min-width: 0;
327
+ }
328
+
329
+ .sort-field {
330
+ min-width: 0;
331
+ }
332
+
333
+ .active-filters {
334
+ display: flex;
335
+ flex-wrap: wrap;
336
+ align-items: center;
337
+ gap: 8px;
338
+ padding-top: 14px;
339
+ border-top: 1px solid var(--rk-border);
340
+ }
341
+
342
+ .own-note {
343
+ margin-bottom: 12px;
344
+ }
345
+
346
+ .more {
347
+ display: grid;
348
+ justify-items: center;
349
+ gap: 8px;
350
+ margin-top: 32px;
351
+ }
352
+ </style>
@@ -0,0 +1,107 @@
1
+ import {
2
+ browseAll,
3
+ filterByArea,
4
+ filterByCategory,
5
+ filterByIngredient,
6
+ listAreas,
7
+ listCategories,
8
+ searchRecipes,
9
+ type Category
10
+ } from '$api/mealdb';
11
+ import type { RecipeSummary } from '$domain/recipe';
12
+ import type { PageLoad } from './$types';
13
+
14
+ export type SortKey = 'relevance' | 'az' | 'za';
15
+
16
+ export interface DiscoverFilters {
17
+ q: string;
18
+ category: string;
19
+ area: string;
20
+ ingredient: string;
21
+ sort: SortKey;
22
+ }
23
+
24
+ const SORTS: SortKey[] = ['relevance', 'az', 'za'];
25
+
26
+ function readFilters(url: URL): DiscoverFilters {
27
+ const rawSort = url.searchParams.get('sort') as SortKey | null;
28
+ return {
29
+ q: url.searchParams.get('q')?.trim() ?? '',
30
+ category: url.searchParams.get('category')?.trim() ?? '',
31
+ area: url.searchParams.get('area')?.trim() ?? '',
32
+ ingredient: url.searchParams.get('ingredient')?.trim() ?? '',
33
+ sort: rawSort && SORTS.includes(rawSort) ? rawSort : 'relevance'
34
+ };
35
+ }
36
+
37
+ function sortRecipes(recipes: RecipeSummary[], sort: SortKey): RecipeSummary[] {
38
+ if (sort === 'az') return [...recipes].sort((a, b) => a.name.localeCompare(b.name));
39
+ if (sort === 'za') return [...recipes].sort((a, b) => b.name.localeCompare(a.name));
40
+ return recipes;
41
+ }
42
+
43
+ export const load: PageLoad = async ({ url, fetch }) => {
44
+ const filters = readFilters(url);
45
+
46
+ // Facets are static enough to fetch alongside results; a failure here must not
47
+ // take the page down, so each one degrades to an empty list.
48
+ const facets = Promise.all([
49
+ listCategories(fetch).catch((): Category[] => []),
50
+ listAreas(fetch).catch((): string[] => [])
51
+ ]);
52
+
53
+ /*
54
+ * Each active filter maps to one `filter.php` call returning a set of ids.
55
+ * Intersecting those sets is what makes combined filters ("Seafood" + "Italian")
56
+ * work, since the API only supports one filter dimension per request.
57
+ */
58
+ const filterRequests: Promise<RecipeSummary[]>[] = [];
59
+ if (filters.category) filterRequests.push(filterByCategory(filters.category, fetch));
60
+ if (filters.area) filterRequests.push(filterByArea(filters.area, fetch));
61
+ if (filters.ingredient) filterRequests.push(filterByIngredient(filters.ingredient, fetch));
62
+
63
+ // With a query, search results are the base list; otherwise the first filter
64
+ // response is, falling back to the whole catalogue when nothing is active.
65
+ const basePromise = filters.q
66
+ ? searchRecipes(filters.q, fetch)
67
+ : filterRequests.length > 0
68
+ ? filterRequests[0]
69
+ : browseAll(fetch);
70
+
71
+ let recipes: RecipeSummary[] = [];
72
+ let error: string | null = null;
73
+
74
+ try {
75
+ const [base, ...filterResults] = await Promise.all([basePromise, ...filterRequests]);
76
+
77
+ recipes = base;
78
+ for (const result of filterResults) {
79
+ const allowed = new Set(result.map((recipe) => recipe.id));
80
+ recipes = recipes.filter((recipe) => allowed.has(recipe.id));
81
+ }
82
+
83
+ // `filter.php` omits the dimension it filtered on, so fill in what is known.
84
+ recipes = recipes.map((recipe) => ({
85
+ ...recipe,
86
+ category: filters.category || recipe.category,
87
+ area: filters.area || recipe.area
88
+ }));
89
+
90
+ // The grid keys on `recipe.id`, and Svelte throws on a duplicate key, so a
91
+ // repeated id from the API must never reach the component.
92
+ const seen = new Set<string>();
93
+ recipes = recipes.filter((recipe) => {
94
+ if (seen.has(recipe.id)) return false;
95
+ seen.add(recipe.id);
96
+ return true;
97
+ });
98
+
99
+ recipes = sortRecipes(recipes, filters.sort);
100
+ } catch (cause) {
101
+ error = cause instanceof Error ? cause.message : 'Could not load recipes.';
102
+ }
103
+
104
+ const [categories, areas] = await facets;
105
+
106
+ return { filters, recipes, categories, areas, error };
107
+ };