@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.
- package/.claude/settings.local.json +10 -0
- package/.npmr +1 -0
- package/README.md +354 -0
- package/apps/recipe-web/package.json +31 -0
- package/apps/recipe-web/src/app.css +418 -0
- package/apps/recipe-web/src/app.d.ts +32 -0
- package/apps/recipe-web/src/app.html +25 -0
- package/apps/recipe-web/src/lib/api/mealdb.ts +240 -0
- package/apps/recipe-web/src/lib/components/AppHeader.svelte +290 -0
- package/apps/recipe-web/src/lib/components/ConfirmDialog.svelte +46 -0
- package/apps/recipe-web/src/lib/components/Modal.svelte +33 -0
- package/apps/recipe-web/src/lib/components/RecipeCard.svelte +100 -0
- package/apps/recipe-web/src/lib/components/RecipeForm.svelte +560 -0
- package/apps/recipe-web/src/lib/components/RecipeGrid.svelte +77 -0
- package/apps/recipe-web/src/lib/components/RecipePickerDialog.svelte +185 -0
- package/apps/recipe-web/src/lib/components/Toaster.svelte +111 -0
- package/apps/recipe-web/src/lib/domain/recipe.ts +130 -0
- package/apps/recipe-web/src/lib/domain/validation.ts +189 -0
- package/apps/recipe-web/src/lib/stencil/WebComponent.svelte +145 -0
- package/apps/recipe-web/src/lib/stencil/register.ts +28 -0
- package/apps/recipe-web/src/lib/stores/favorites.svelte.ts +81 -0
- package/apps/recipe-web/src/lib/stores/myRecipes.svelte.ts +98 -0
- package/apps/recipe-web/src/lib/stores/persisted.svelte.ts +73 -0
- package/apps/recipe-web/src/lib/stores/planner.svelte.ts +275 -0
- package/apps/recipe-web/src/lib/stores/theme.svelte.ts +67 -0
- package/apps/recipe-web/src/lib/stores/toast.svelte.ts +66 -0
- package/apps/recipe-web/src/routes/+error.svelte +36 -0
- package/apps/recipe-web/src/routes/+layout.svelte +62 -0
- package/apps/recipe-web/src/routes/+page.svelte +352 -0
- package/apps/recipe-web/src/routes/+page.ts +107 -0
- package/apps/recipe-web/src/routes/favorites/+page.svelte +170 -0
- package/apps/recipe-web/src/routes/my-recipes/+layout.ts +55 -0
- package/apps/recipe-web/src/routes/my-recipes/+page.svelte +180 -0
- package/apps/recipe-web/src/routes/my-recipes/[id]/edit/+page.svelte +104 -0
- package/apps/recipe-web/src/routes/my-recipes/new/+page.svelte +67 -0
- package/apps/recipe-web/src/routes/planner/+page.svelte +422 -0
- package/apps/recipe-web/src/routes/recipes/[id]/+page.svelte +538 -0
- package/apps/recipe-web/src/routes/recipes/[id]/+page.ts +24 -0
- package/apps/recipe-web/static/favicon.svg +8 -0
- package/apps/recipe-web/static/robots.txt +2 -0
- package/apps/recipe-web/svelte.config.js +35 -0
- package/apps/recipe-web/tsconfig.json +14 -0
- package/apps/recipe-web/vite.config.ts +23 -0
- package/package.json +29 -0
- package/packages/recipe-kit-stencil/LICENSE +21 -0
- package/packages/recipe-kit-stencil/README.md +185 -0
- package/packages/recipe-kit-stencil/package-lock.json +4772 -0
- package/packages/recipe-kit-stencil/package.json +91 -0
- package/packages/recipe-kit-stencil/src/components/rk-badge/readme.md +38 -0
- package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.css +102 -0
- package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.tsx +31 -0
- package/packages/recipe-kit-stencil/src/components/rk-chip/readme.md +52 -0
- package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.css +155 -0
- package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.tsx +105 -0
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/readme.md +44 -0
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.css +64 -0
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.tsx +50 -0
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/readme.md +58 -0
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.css +225 -0
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.tsx +182 -0
- package/packages/recipe-kit-stencil/src/components/rk-modal/readme.md +57 -0
- package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.css +163 -0
- package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.tsx +176 -0
- package/packages/recipe-kit-stencil/src/components/rk-rating/readme.md +42 -0
- package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.css +71 -0
- package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.tsx +114 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/readme.md +66 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.css +280 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.tsx +179 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/test/rk-recipe-card.spec.tsx +70 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/readme.md +67 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.css +141 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.tsx +152 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/test/rk-search-bar.spec.tsx +93 -0
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/readme.md +41 -0
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.css +91 -0
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.tsx +115 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/readme.md +47 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.css +146 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.tsx +181 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/test/rk-tag-input.spec.tsx +76 -0
- package/packages/recipe-kit-stencil/src/components.d.ts +1192 -0
- package/packages/recipe-kit-stencil/src/global/rk-global.css +104 -0
- package/packages/recipe-kit-stencil/src/index.html +259 -0
- package/packages/recipe-kit-stencil/src/index.ts +6 -0
- package/packages/recipe-kit-stencil/stencil.config.ts +41 -0
- 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 & 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
|
+
};
|