@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,290 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { page } from '$app/state';
|
|
3
|
+
import { favorites } from '$stores/favorites.svelte';
|
|
4
|
+
import { myRecipes } from '$stores/myRecipes.svelte';
|
|
5
|
+
import { planner } from '$stores/planner.svelte';
|
|
6
|
+
import { theme } from '$stores/theme.svelte';
|
|
7
|
+
|
|
8
|
+
const links = $derived([
|
|
9
|
+
{ href: '/', label: 'Discover', badge: 0 },
|
|
10
|
+
{ href: '/favorites', label: 'Favorites', badge: favorites.count },
|
|
11
|
+
{ href: '/my-recipes', label: 'My recipes', badge: myRecipes.count },
|
|
12
|
+
{ href: '/planner', label: 'Planner', badge: planner.countFor() }
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function isActive(href: string): boolean {
|
|
16
|
+
const path = page.url.pathname;
|
|
17
|
+
if (href === '/') return path === '/';
|
|
18
|
+
return path === href || path.startsWith(`${href}/`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let menuOpen = $state(false);
|
|
22
|
+
|
|
23
|
+
// Close the mobile menu whenever the route changes.
|
|
24
|
+
$effect(() => {
|
|
25
|
+
void page.url.pathname;
|
|
26
|
+
menuOpen = false;
|
|
27
|
+
});
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<header class="header">
|
|
31
|
+
<div class="inner">
|
|
32
|
+
<a class="brand" href="/" aria-label="Mise — home">
|
|
33
|
+
<span class="brand-mark" aria-hidden="true">🥘</span>
|
|
34
|
+
<span class="brand-text">
|
|
35
|
+
<strong>Mise</strong>
|
|
36
|
+
<small>Recipe finder & meal planner</small>
|
|
37
|
+
</span>
|
|
38
|
+
</a>
|
|
39
|
+
|
|
40
|
+
<button
|
|
41
|
+
class="menu-toggle"
|
|
42
|
+
type="button"
|
|
43
|
+
aria-expanded={menuOpen}
|
|
44
|
+
aria-controls="primary-nav"
|
|
45
|
+
onclick={() => (menuOpen = !menuOpen)}
|
|
46
|
+
>
|
|
47
|
+
<span class="sr-only">{menuOpen ? 'Close menu' : 'Open menu'}</span>
|
|
48
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
49
|
+
{#if menuOpen}
|
|
50
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
51
|
+
{:else}
|
|
52
|
+
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
53
|
+
{/if}
|
|
54
|
+
</svg>
|
|
55
|
+
</button>
|
|
56
|
+
|
|
57
|
+
<nav class="nav" class:is-open={menuOpen} id="primary-nav" aria-label="Primary">
|
|
58
|
+
{#each links as link (link.href)}
|
|
59
|
+
<a class="nav-link" class:is-active={isActive(link.href)} href={link.href} aria-current={isActive(link.href) ? 'page' : undefined}>
|
|
60
|
+
{link.label}
|
|
61
|
+
{#if link.badge > 0}
|
|
62
|
+
<span class="nav-badge">{link.badge}</span>
|
|
63
|
+
{/if}
|
|
64
|
+
</a>
|
|
65
|
+
{/each}
|
|
66
|
+
|
|
67
|
+
<div class="nav-tail">
|
|
68
|
+
<a class="btn btn-primary btn-sm" href="/my-recipes/new">
|
|
69
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
|
70
|
+
<path d="M12 5v14M5 12h14" />
|
|
71
|
+
</svg>
|
|
72
|
+
Add recipe
|
|
73
|
+
</a>
|
|
74
|
+
|
|
75
|
+
<button class="theme-toggle" type="button" onclick={() => theme.toggle()} aria-label="Switch to {theme.current === 'dark' ? 'light' : 'dark'} theme">
|
|
76
|
+
{#if theme.current === 'dark'}
|
|
77
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
78
|
+
<circle cx="12" cy="12" r="4.2" />
|
|
79
|
+
<path d="M12 2v2.4M12 19.6V22M2 12h2.4M19.6 12H22M4.9 4.9l1.7 1.7M17.4 17.4l1.7 1.7M19.1 4.9l-1.7 1.7M6.6 17.4l-1.7 1.7" />
|
|
80
|
+
</svg>
|
|
81
|
+
{:else}
|
|
82
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
83
|
+
<path d="M20.5 14.6A8.6 8.6 0 0 1 9.4 3.5 8.6 8.6 0 1 0 20.5 14.6Z" />
|
|
84
|
+
</svg>
|
|
85
|
+
{/if}
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</nav>
|
|
89
|
+
</div>
|
|
90
|
+
</header>
|
|
91
|
+
|
|
92
|
+
<style>
|
|
93
|
+
.header {
|
|
94
|
+
position: sticky;
|
|
95
|
+
top: 0;
|
|
96
|
+
z-index: 100;
|
|
97
|
+
background: color-mix(in srgb, var(--rk-bg) 88%, transparent);
|
|
98
|
+
backdrop-filter: blur(12px);
|
|
99
|
+
border-bottom: 1px solid var(--rk-border);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.inner {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 18px;
|
|
106
|
+
max-width: var(--app-max);
|
|
107
|
+
margin: 0 auto;
|
|
108
|
+
padding: 11px var(--app-gutter);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.brand {
|
|
112
|
+
display: inline-flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
gap: 10px;
|
|
115
|
+
text-decoration: none;
|
|
116
|
+
color: inherit;
|
|
117
|
+
margin-right: auto;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.brand-mark {
|
|
121
|
+
font-size: 1.5rem;
|
|
122
|
+
line-height: 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.brand-text {
|
|
126
|
+
display: grid;
|
|
127
|
+
line-height: 1.15;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.brand-text strong {
|
|
131
|
+
font-family: var(--rk-font-display);
|
|
132
|
+
font-size: 1.16rem;
|
|
133
|
+
letter-spacing: -0.02em;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.brand-text small {
|
|
137
|
+
font-size: 0.68rem;
|
|
138
|
+
color: var(--rk-muted);
|
|
139
|
+
letter-spacing: 0.01em;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.nav {
|
|
143
|
+
display: flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: 3px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.nav-link {
|
|
149
|
+
display: inline-flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: 6px;
|
|
152
|
+
padding: 8px 14px;
|
|
153
|
+
border-radius: var(--rk-radius-pill);
|
|
154
|
+
color: var(--rk-ink-2);
|
|
155
|
+
text-decoration: none;
|
|
156
|
+
font-size: 0.9rem;
|
|
157
|
+
font-weight: 600;
|
|
158
|
+
white-space: nowrap;
|
|
159
|
+
transition:
|
|
160
|
+
background var(--rk-transition),
|
|
161
|
+
color var(--rk-transition);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.nav-link:hover {
|
|
165
|
+
background: var(--rk-surface-2);
|
|
166
|
+
color: var(--rk-ink);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.nav-link.is-active {
|
|
170
|
+
background: var(--rk-primary-soft);
|
|
171
|
+
color: var(--rk-primary-strong);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.nav-badge {
|
|
175
|
+
display: inline-flex;
|
|
176
|
+
align-items: center;
|
|
177
|
+
justify-content: center;
|
|
178
|
+
min-width: 19px;
|
|
179
|
+
height: 19px;
|
|
180
|
+
padding: 0 5px;
|
|
181
|
+
border-radius: var(--rk-radius-pill);
|
|
182
|
+
background: var(--rk-ink);
|
|
183
|
+
color: var(--rk-bg);
|
|
184
|
+
font-size: 0.68rem;
|
|
185
|
+
font-weight: 700;
|
|
186
|
+
font-variant-numeric: tabular-nums;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.nav-link.is-active .nav-badge {
|
|
190
|
+
background: var(--rk-primary);
|
|
191
|
+
color: #fff;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.nav-tail {
|
|
195
|
+
display: flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
gap: 8px;
|
|
198
|
+
margin-left: 12px;
|
|
199
|
+
padding-left: 14px;
|
|
200
|
+
border-left: 1px solid var(--rk-border);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.theme-toggle {
|
|
204
|
+
display: inline-flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
justify-content: center;
|
|
207
|
+
width: 36px;
|
|
208
|
+
height: 36px;
|
|
209
|
+
padding: 0;
|
|
210
|
+
border: 1px solid var(--rk-border);
|
|
211
|
+
border-radius: 50%;
|
|
212
|
+
background: var(--rk-surface);
|
|
213
|
+
color: var(--rk-ink-2);
|
|
214
|
+
cursor: pointer;
|
|
215
|
+
transition:
|
|
216
|
+
background var(--rk-transition),
|
|
217
|
+
color var(--rk-transition);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.theme-toggle:hover {
|
|
221
|
+
background: var(--rk-surface-2);
|
|
222
|
+
color: var(--rk-primary);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.theme-toggle svg {
|
|
226
|
+
width: 17px;
|
|
227
|
+
height: 17px;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.menu-toggle {
|
|
231
|
+
display: none;
|
|
232
|
+
align-items: center;
|
|
233
|
+
justify-content: center;
|
|
234
|
+
width: 40px;
|
|
235
|
+
height: 40px;
|
|
236
|
+
padding: 0;
|
|
237
|
+
border: 1px solid var(--rk-border);
|
|
238
|
+
border-radius: var(--rk-radius);
|
|
239
|
+
background: var(--rk-surface);
|
|
240
|
+
color: var(--rk-ink);
|
|
241
|
+
cursor: pointer;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.menu-toggle svg {
|
|
245
|
+
width: 20px;
|
|
246
|
+
height: 20px;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@media (max-width: 860px) {
|
|
250
|
+
.menu-toggle {
|
|
251
|
+
display: inline-flex;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.nav {
|
|
255
|
+
position: absolute;
|
|
256
|
+
top: 100%;
|
|
257
|
+
left: 0;
|
|
258
|
+
right: 0;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
align-items: stretch;
|
|
261
|
+
gap: 4px;
|
|
262
|
+
padding: 12px var(--app-gutter) 18px;
|
|
263
|
+
background: var(--rk-bg);
|
|
264
|
+
border-bottom: 1px solid var(--rk-border);
|
|
265
|
+
box-shadow: var(--rk-shadow);
|
|
266
|
+
display: none;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.nav.is-open {
|
|
270
|
+
display: flex;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.nav-link {
|
|
274
|
+
justify-content: space-between;
|
|
275
|
+
padding: 12px 14px;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.nav-tail {
|
|
279
|
+
margin: 8px 0 0;
|
|
280
|
+
padding: 12px 0 0;
|
|
281
|
+
border-left: 0;
|
|
282
|
+
border-top: 1px solid var(--rk-border);
|
|
283
|
+
justify-content: space-between;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.brand-text small {
|
|
287
|
+
display: none;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
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>
|
|
@@ -0,0 +1,33 @@
|
|
|
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>
|
|
@@ -0,0 +1,100 @@
|
|
|
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>
|