@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,418 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Application shell styles.
|
|
3
|
+
*
|
|
4
|
+
* The `--rk-*` tokens come from recipe-kit-stencil's stylesheet and are the
|
|
5
|
+
* single source of truth for colour, radius and elevation. The app maps them to
|
|
6
|
+
* shorter `--app-*` aliases and adds only what the shell itself needs, so the
|
|
7
|
+
* Svelte chrome and the web components can never drift apart visually.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
:root {
|
|
11
|
+
--app-max: 1180px;
|
|
12
|
+
--app-gutter: clamp(16px, 4vw, 40px);
|
|
13
|
+
--app-header-h: 66px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
*,
|
|
17
|
+
*::before,
|
|
18
|
+
*::after {
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
html {
|
|
23
|
+
-webkit-text-size-adjust: 100%;
|
|
24
|
+
scroll-behavior: smooth;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@media (prefers-reduced-motion: reduce) {
|
|
28
|
+
html {
|
|
29
|
+
scroll-behavior: auto;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
body {
|
|
34
|
+
margin: 0;
|
|
35
|
+
min-height: 100vh;
|
|
36
|
+
background: var(--rk-bg);
|
|
37
|
+
color: var(--rk-ink);
|
|
38
|
+
font-family: var(--rk-font);
|
|
39
|
+
font-size: 15px;
|
|
40
|
+
line-height: 1.6;
|
|
41
|
+
-webkit-font-smoothing: antialiased;
|
|
42
|
+
text-rendering: optimizeLegibility;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
h1,
|
|
46
|
+
h2,
|
|
47
|
+
h3,
|
|
48
|
+
h4 {
|
|
49
|
+
font-family: var(--rk-font-display);
|
|
50
|
+
letter-spacing: -0.02em;
|
|
51
|
+
line-height: 1.2;
|
|
52
|
+
margin: 0;
|
|
53
|
+
font-weight: 650;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
p {
|
|
57
|
+
margin: 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
a {
|
|
61
|
+
color: var(--rk-primary);
|
|
62
|
+
text-decoration-thickness: 1px;
|
|
63
|
+
text-underline-offset: 2px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
img {
|
|
67
|
+
max-width: 100%;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
:focus-visible {
|
|
71
|
+
outline: none;
|
|
72
|
+
box-shadow: var(--rk-focus-ring);
|
|
73
|
+
border-radius: var(--rk-radius-sm);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ------------------------------------------------------------------ layout */
|
|
77
|
+
|
|
78
|
+
.shell {
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-direction: column;
|
|
81
|
+
min-height: 100vh;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.page {
|
|
85
|
+
flex: 1 1 auto;
|
|
86
|
+
width: 100%;
|
|
87
|
+
max-width: var(--app-max);
|
|
88
|
+
margin: 0 auto;
|
|
89
|
+
padding: clamp(24px, 4vw, 44px) var(--app-gutter) 88px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.page-head {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-wrap: wrap;
|
|
95
|
+
align-items: flex-end;
|
|
96
|
+
justify-content: space-between;
|
|
97
|
+
gap: 16px 24px;
|
|
98
|
+
margin-bottom: 26px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.page-title {
|
|
102
|
+
font-size: clamp(1.7rem, 4vw, 2.35rem);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.page-lede {
|
|
106
|
+
margin-top: 7px;
|
|
107
|
+
color: var(--rk-muted);
|
|
108
|
+
max-width: 62ch;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.eyebrow {
|
|
112
|
+
font-size: 0.72rem;
|
|
113
|
+
font-weight: 700;
|
|
114
|
+
letter-spacing: 0.1em;
|
|
115
|
+
text-transform: uppercase;
|
|
116
|
+
color: var(--rk-primary);
|
|
117
|
+
margin-bottom: 8px;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.section {
|
|
121
|
+
margin-top: 44px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.section-head {
|
|
125
|
+
display: flex;
|
|
126
|
+
flex-wrap: wrap;
|
|
127
|
+
align-items: baseline;
|
|
128
|
+
justify-content: space-between;
|
|
129
|
+
gap: 12px;
|
|
130
|
+
margin-bottom: 16px;
|
|
131
|
+
padding-bottom: 10px;
|
|
132
|
+
border-bottom: 1px solid var(--rk-border);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.section-title {
|
|
136
|
+
font-size: 1.15rem;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.muted {
|
|
140
|
+
color: var(--rk-muted);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.small {
|
|
144
|
+
font-size: 0.87rem;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.stack {
|
|
148
|
+
display: grid;
|
|
149
|
+
gap: 14px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.row {
|
|
153
|
+
display: flex;
|
|
154
|
+
flex-wrap: wrap;
|
|
155
|
+
align-items: center;
|
|
156
|
+
gap: 10px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.card-surface {
|
|
160
|
+
background: var(--rk-surface);
|
|
161
|
+
border: 1px solid var(--rk-border);
|
|
162
|
+
border-radius: var(--rk-radius-lg);
|
|
163
|
+
padding: clamp(18px, 3vw, 26px);
|
|
164
|
+
box-shadow: var(--rk-shadow-sm);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.recipe-grid {
|
|
168
|
+
display: grid;
|
|
169
|
+
grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
|
|
170
|
+
gap: clamp(14px, 2vw, 22px);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ------------------------------------------------------------------ buttons */
|
|
174
|
+
|
|
175
|
+
.btn {
|
|
176
|
+
display: inline-flex;
|
|
177
|
+
align-items: center;
|
|
178
|
+
justify-content: center;
|
|
179
|
+
gap: 8px;
|
|
180
|
+
padding: 10px 18px;
|
|
181
|
+
border-radius: var(--rk-radius-pill);
|
|
182
|
+
border: 1px solid var(--rk-border);
|
|
183
|
+
background: var(--rk-surface);
|
|
184
|
+
color: var(--rk-ink);
|
|
185
|
+
font: inherit;
|
|
186
|
+
font-size: 0.9rem;
|
|
187
|
+
font-weight: 600;
|
|
188
|
+
line-height: 1.2;
|
|
189
|
+
cursor: pointer;
|
|
190
|
+
text-decoration: none;
|
|
191
|
+
white-space: nowrap;
|
|
192
|
+
transition:
|
|
193
|
+
background var(--rk-transition),
|
|
194
|
+
border-color var(--rk-transition),
|
|
195
|
+
color var(--rk-transition),
|
|
196
|
+
transform var(--rk-transition);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.btn:hover:not(:disabled) {
|
|
200
|
+
border-color: var(--rk-border-strong);
|
|
201
|
+
background: var(--rk-surface-2);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.btn:active:not(:disabled) {
|
|
205
|
+
transform: translateY(1px);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.btn:disabled {
|
|
209
|
+
opacity: 0.5;
|
|
210
|
+
cursor: not-allowed;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.btn-primary {
|
|
214
|
+
background: var(--rk-primary);
|
|
215
|
+
border-color: var(--rk-primary);
|
|
216
|
+
color: #fff;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.btn-primary:hover:not(:disabled) {
|
|
220
|
+
background: var(--rk-primary-strong);
|
|
221
|
+
border-color: var(--rk-primary-strong);
|
|
222
|
+
color: #fff;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.btn-accent {
|
|
226
|
+
background: var(--rk-accent);
|
|
227
|
+
border-color: var(--rk-accent);
|
|
228
|
+
color: #fff;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.btn-accent:hover:not(:disabled) {
|
|
232
|
+
filter: brightness(1.1);
|
|
233
|
+
color: #fff;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.btn-danger {
|
|
237
|
+
background: transparent;
|
|
238
|
+
border-color: var(--rk-danger);
|
|
239
|
+
color: var(--rk-danger);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.btn-danger:hover:not(:disabled) {
|
|
243
|
+
background: var(--rk-danger);
|
|
244
|
+
border-color: var(--rk-danger);
|
|
245
|
+
color: #fff;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.btn-ghost {
|
|
249
|
+
background: transparent;
|
|
250
|
+
border-color: transparent;
|
|
251
|
+
color: var(--rk-ink-2);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.btn-ghost:hover:not(:disabled) {
|
|
255
|
+
background: var(--rk-surface-2);
|
|
256
|
+
border-color: transparent;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.btn-sm {
|
|
260
|
+
padding: 7px 13px;
|
|
261
|
+
font-size: 0.83rem;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.btn-block {
|
|
265
|
+
width: 100%;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.btn svg {
|
|
269
|
+
width: 16px;
|
|
270
|
+
height: 16px;
|
|
271
|
+
flex: 0 0 auto;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ------------------------------------------------------------------- forms */
|
|
275
|
+
|
|
276
|
+
.field {
|
|
277
|
+
display: grid;
|
|
278
|
+
gap: 7px;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.field-label {
|
|
282
|
+
font-size: 0.88rem;
|
|
283
|
+
font-weight: 600;
|
|
284
|
+
color: var(--rk-ink-2);
|
|
285
|
+
display: flex;
|
|
286
|
+
align-items: center;
|
|
287
|
+
justify-content: space-between;
|
|
288
|
+
gap: 10px;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.field-req {
|
|
292
|
+
color: var(--rk-primary);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.input,
|
|
296
|
+
.select,
|
|
297
|
+
.textarea {
|
|
298
|
+
width: 100%;
|
|
299
|
+
padding: 11px 13px;
|
|
300
|
+
border: 1px solid var(--rk-border);
|
|
301
|
+
border-radius: var(--rk-radius);
|
|
302
|
+
background: var(--rk-surface);
|
|
303
|
+
color: var(--rk-ink);
|
|
304
|
+
font: inherit;
|
|
305
|
+
transition:
|
|
306
|
+
border-color var(--rk-transition),
|
|
307
|
+
box-shadow var(--rk-transition);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.textarea {
|
|
311
|
+
resize: vertical;
|
|
312
|
+
min-height: 82px;
|
|
313
|
+
line-height: 1.55;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.select {
|
|
317
|
+
appearance: none;
|
|
318
|
+
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
|
|
319
|
+
background-position:
|
|
320
|
+
calc(100% - 19px) calc(50% + 2px),
|
|
321
|
+
calc(100% - 14px) calc(50% + 2px);
|
|
322
|
+
background-size:
|
|
323
|
+
5px 5px,
|
|
324
|
+
5px 5px;
|
|
325
|
+
background-repeat: no-repeat;
|
|
326
|
+
padding-right: 36px;
|
|
327
|
+
cursor: pointer;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.input:focus,
|
|
331
|
+
.select:focus,
|
|
332
|
+
.textarea:focus {
|
|
333
|
+
outline: none;
|
|
334
|
+
border-color: var(--rk-primary);
|
|
335
|
+
box-shadow: var(--rk-focus-ring);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.input[aria-invalid='true'],
|
|
339
|
+
.select[aria-invalid='true'],
|
|
340
|
+
.textarea[aria-invalid='true'] {
|
|
341
|
+
border-color: var(--rk-danger);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.field-error {
|
|
345
|
+
font-size: 0.83rem;
|
|
346
|
+
font-weight: 550;
|
|
347
|
+
color: var(--rk-danger);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.field-hint {
|
|
351
|
+
font-size: 0.83rem;
|
|
352
|
+
color: var(--rk-muted);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.field-grid {
|
|
356
|
+
display: grid;
|
|
357
|
+
gap: 16px;
|
|
358
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ------------------------------------------------------------------ effects */
|
|
362
|
+
|
|
363
|
+
.skeleton {
|
|
364
|
+
background: linear-gradient(90deg, var(--rk-surface-2) 25%, var(--rk-border) 50%, var(--rk-surface-2) 75%);
|
|
365
|
+
background-size: 200% 100%;
|
|
366
|
+
animation: shimmer 1.4s ease-in-out infinite;
|
|
367
|
+
border-radius: var(--rk-radius);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
@keyframes shimmer {
|
|
371
|
+
from {
|
|
372
|
+
background-position: 200% 0;
|
|
373
|
+
}
|
|
374
|
+
to {
|
|
375
|
+
background-position: -200% 0;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@media (prefers-reduced-motion: reduce) {
|
|
380
|
+
.skeleton {
|
|
381
|
+
animation: none;
|
|
382
|
+
}
|
|
383
|
+
.btn {
|
|
384
|
+
transition: none;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.sr-only {
|
|
389
|
+
position: absolute;
|
|
390
|
+
width: 1px;
|
|
391
|
+
height: 1px;
|
|
392
|
+
padding: 0;
|
|
393
|
+
margin: -1px;
|
|
394
|
+
overflow: hidden;
|
|
395
|
+
clip: rect(0, 0, 0, 0);
|
|
396
|
+
white-space: nowrap;
|
|
397
|
+
border: 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* Skip link, revealed on focus. */
|
|
401
|
+
.skip-link {
|
|
402
|
+
position: absolute;
|
|
403
|
+
top: 8px;
|
|
404
|
+
left: 8px;
|
|
405
|
+
z-index: 1200;
|
|
406
|
+
padding: 10px 16px;
|
|
407
|
+
background: var(--rk-ink);
|
|
408
|
+
color: var(--rk-bg);
|
|
409
|
+
border-radius: var(--rk-radius-pill);
|
|
410
|
+
font-weight: 600;
|
|
411
|
+
text-decoration: none;
|
|
412
|
+
transform: translateY(-200%);
|
|
413
|
+
transition: transform var(--rk-transition);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.skip-link:focus {
|
|
417
|
+
transform: translateY(0);
|
|
418
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// See https://svelte.dev/docs/kit/types#app
|
|
2
|
+
declare global {
|
|
3
|
+
namespace App {
|
|
4
|
+
// interface Error {}
|
|
5
|
+
// interface Locals {}
|
|
6
|
+
// interface PageData {}
|
|
7
|
+
// interface PageState {}
|
|
8
|
+
// interface Platform {}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The `rk-*` tags are registered at runtime by `recipe-kit-stencil`. Declaring
|
|
13
|
+
* them here stops `svelte-check` from treating them as typos while still
|
|
14
|
+
* allowing arbitrary props, which the `WebComponent` bridge assigns directly.
|
|
15
|
+
*/
|
|
16
|
+
namespace svelteHTML {
|
|
17
|
+
interface IntrinsicElements {
|
|
18
|
+
'rk-search-bar': Record<string, unknown>;
|
|
19
|
+
'rk-chip': Record<string, unknown>;
|
|
20
|
+
'rk-segmented-control': Record<string, unknown>;
|
|
21
|
+
'rk-recipe-card': Record<string, unknown>;
|
|
22
|
+
'rk-rating': Record<string, unknown>;
|
|
23
|
+
'rk-badge': Record<string, unknown>;
|
|
24
|
+
'rk-tag-input': Record<string, unknown>;
|
|
25
|
+
'rk-meal-slot': Record<string, unknown>;
|
|
26
|
+
'rk-modal': Record<string, unknown>;
|
|
27
|
+
'rk-empty-state': Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="theme-color" content="#d94f1b" />
|
|
7
|
+
<meta name="description" content="Discover recipes, build your own collection, and plan your week — a Recipe Finder & Meal Planner." />
|
|
8
|
+
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
|
9
|
+
<link rel="preconnect" href="https://www.themealdb.com" crossorigin />
|
|
10
|
+
<script>
|
|
11
|
+
// Applied before first paint so a dark-mode user never sees a light flash.
|
|
12
|
+
try {
|
|
13
|
+
const stored = localStorage.getItem('recipe-ui:theme:v1');
|
|
14
|
+
const theme = stored ? JSON.parse(stored) : null;
|
|
15
|
+
const resolved = theme ?? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
16
|
+
document.documentElement.dataset.theme = resolved;
|
|
17
|
+
document.documentElement.dataset.rkTheme = resolved;
|
|
18
|
+
} catch {}
|
|
19
|
+
</script>
|
|
20
|
+
%sveltekit.head%
|
|
21
|
+
</head>
|
|
22
|
+
<body data-sveltekit-preload-data="hover">
|
|
23
|
+
<div style="display: contents">%sveltekit.body%</div>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
}
|