@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,422 @@
1
+ <script lang="ts">
2
+ import { goto } from '$app/navigation';
3
+ import ConfirmDialog from '$components/ConfirmDialog.svelte';
4
+ import RecipePickerDialog from '$components/RecipePickerDialog.svelte';
5
+ import type { RecipeSummary } from '$domain/recipe';
6
+ import WebComponent from '$lib/stencil/WebComponent.svelte';
7
+ import {
8
+ currentWeekKey,
9
+ dateForDay,
10
+ DAYS,
11
+ formatWeekRange,
12
+ MEALS,
13
+ planner,
14
+ shiftWeekKey,
15
+ type DayKey,
16
+ type MealKey
17
+ } from '$stores/planner.svelte';
18
+ import { toasts } from '$stores/toast.svelte';
19
+
20
+ const weekKey = $derived(planner.selectedWeek);
21
+ const isCurrentWeek = $derived(weekKey === currentWeekKey());
22
+ const plannedCount = $derived(planner.countFor(weekKey));
23
+ const weekRecipes = $derived(planner.recipesFor(weekKey));
24
+ const totalSlots = DAYS.length * MEALS.length;
25
+
26
+ const previousWeekKey = $derived(shiftWeekKey(weekKey, -1));
27
+ const previousWeekCount = $derived(planner.countFor(previousWeekKey));
28
+
29
+ const today = $derived(new Date().toDateString());
30
+
31
+ /* ---------------------------------------------------------- picker dialog */
32
+
33
+ let pickerOpen = $state(false);
34
+ let target = $state<{ day: DayKey; mealType: MealKey } | null>(null);
35
+
36
+ const targetLabel = $derived.by(() => {
37
+ if (!target) return '';
38
+ const day = DAYS.find((entry) => entry.key === target!.day)?.label ?? target.day;
39
+ const meal = MEALS.find((entry) => entry.key === target!.mealType)?.label ?? target.mealType;
40
+ return `${day} · ${meal} · week of ${formatWeekRange(weekKey)}`;
41
+ });
42
+
43
+ function openPicker(day: DayKey, mealType: MealKey) {
44
+ target = { day, mealType };
45
+ pickerOpen = true;
46
+ }
47
+
48
+ function handlePick(recipe: RecipeSummary) {
49
+ if (!target) return;
50
+ planner.assign(target.day, target.mealType, recipe, weekKey);
51
+
52
+ const dayLabel = DAYS.find((entry) => entry.key === target!.day)?.label ?? target.day;
53
+ const mealLabel = MEALS.find((entry) => entry.key === target!.mealType)?.label ?? target.mealType;
54
+ toasts.success(`${recipe.name} → ${dayLabel} ${mealLabel.toLowerCase()}.`);
55
+
56
+ pickerOpen = false;
57
+ target = null;
58
+ }
59
+
60
+ /* ------------------------------------------------------ slot interactions */
61
+
62
+ /**
63
+ * The component's payload identifies the slot by its display label, so the
64
+ * canonical `DayKey` / `MealKey` are closed over from the grid loop instead.
65
+ */
66
+ function removeMeal(day: DayKey, mealType: MealKey) {
67
+ const removed = planner.meal(day, mealType, weekKey);
68
+ if (!removed) return;
69
+
70
+ planner.remove(day, mealType, weekKey);
71
+ toasts.push(`Removed ${removed.name} from the plan.`, {
72
+ tone: 'info',
73
+ action: {
74
+ label: 'Undo',
75
+ run: () => planner.assign(day, mealType, { ...removed, id: removed.recipeId }, weekKey)
76
+ }
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Drag payloads are `day:mealType` strings, so a drop can identify the source
82
+ * slot and move the meal rather than copying it.
83
+ */
84
+ function dropOnto(day: DayKey, mealType: MealKey, payload: string) {
85
+ const [fromDay, fromMeal] = payload.split(':');
86
+ if (!fromDay || !fromMeal) return;
87
+
88
+ planner.move({ day: fromDay as DayKey, mealType: fromMeal as MealKey }, { day, mealType }, weekKey);
89
+ }
90
+
91
+ function handleDragStart(event: DragEvent, day: DayKey, mealType: MealKey) {
92
+ event.dataTransfer?.setData('text/plain', `${day}:${mealType}`);
93
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
94
+ }
95
+
96
+ /* ------------------------------------------------------------ week actions */
97
+
98
+ let confirmClear = $state(false);
99
+
100
+ function clearWeek() {
101
+ const count = plannedCount;
102
+ planner.clearWeek(weekKey);
103
+ confirmClear = false;
104
+ toasts.push(`Cleared ${count} planned ${count === 1 ? 'meal' : 'meals'}.`, { tone: 'info' });
105
+ }
106
+
107
+ function repeatLastWeek() {
108
+ planner.copyWeek(previousWeekKey, weekKey);
109
+ toasts.success(`Copied ${previousWeekCount} ${previousWeekCount === 1 ? 'meal' : 'meals'} from last week.`);
110
+ }
111
+
112
+ /** Aggregated ingredient-free shopping overview: recipes and their slot count. */
113
+ let showSummary = $state(true);
114
+ </script>
115
+
116
+ <svelte:head>
117
+ <title>Weekly planner — Mise</title>
118
+ </svelte:head>
119
+
120
+ <div class="page-head">
121
+ <div>
122
+ <p class="eyebrow">Meal planner</p>
123
+ <h1 class="page-title">Week of {formatWeekRange(weekKey)}</h1>
124
+ <p class="page-lede">
125
+ {plannedCount} of {totalSlots} slots planned.
126
+ {#if plannedCount > 0}
127
+ Drag a meal to another slot to move it.
128
+ {:else}
129
+ Pick a slot below to start building the week.
130
+ {/if}
131
+ </p>
132
+ </div>
133
+
134
+ <div class="week-nav">
135
+ <button class="btn btn-sm" type="button" onclick={() => (planner.selectedWeek = shiftWeekKey(weekKey, -1))} aria-label="Previous week">
136
+ ← Prev
137
+ </button>
138
+ <button class="btn btn-sm" type="button" onclick={() => (planner.selectedWeek = currentWeekKey())} disabled={isCurrentWeek}>
139
+ This week
140
+ </button>
141
+ <button class="btn btn-sm" type="button" onclick={() => (planner.selectedWeek = shiftWeekKey(weekKey, 1))} aria-label="Next week">
142
+ Next →
143
+ </button>
144
+ </div>
145
+ </div>
146
+
147
+ <div class="toolbar">
148
+ {#if previousWeekCount > 0 && plannedCount === 0}
149
+ <button class="btn btn-accent btn-sm" type="button" onclick={repeatLastWeek}>
150
+ Repeat last week ({previousWeekCount} {previousWeekCount === 1 ? 'meal' : 'meals'})
151
+ </button>
152
+ {/if}
153
+
154
+ {#if plannedCount > 0}
155
+ <button class="btn btn-danger btn-sm" type="button" onclick={() => (confirmClear = true)}>Clear this week</button>
156
+ {/if}
157
+
158
+ <a class="btn btn-ghost btn-sm" href="/favorites">Browse favorites</a>
159
+ </div>
160
+
161
+ <div class="planner-scroll">
162
+ <div class="planner-grid" style={`--day-count: ${DAYS.length}`}>
163
+ <!-- Header row: one column per day. -->
164
+ <div aria-hidden="true"></div>
165
+ {#each DAYS as day (day.key)}
166
+ {@const date = dateForDay(weekKey, day.key)}
167
+ <div class="day-head" class:is-today={date.toDateString() === today}>
168
+ <span class="day-name">{day.short}</span>
169
+ <span class="day-date">{date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}</span>
170
+ </div>
171
+ {/each}
172
+
173
+ <!-- One row per meal type. -->
174
+ {#each MEALS as meal (meal.key)}
175
+ <div class="meal-head">{meal.label}</div>
176
+
177
+ {#each DAYS as day (day.key)}
178
+ {@const planned = planner.meal(day.key, meal.key, weekKey)}
179
+ <div
180
+ class="cell"
181
+ draggable={planned ? 'true' : 'false'}
182
+ ondragstart={(event) => handleDragStart(event, day.key, meal.key)}
183
+ role="presentation"
184
+ >
185
+ <!--
186
+ The planner cell is a Stencil component. Props carry the planned
187
+ recipe down; add / open / remove / drop come back as custom events.
188
+ -->
189
+ <WebComponent
190
+ tag="rk-meal-slot"
191
+ props={{
192
+ day: day.label,
193
+ mealType: meal.key,
194
+ mealLabel: meal.label,
195
+ recipeId: planned?.recipeId,
196
+ recipeName: planned?.name,
197
+ recipeImage: planned?.image
198
+ }}
199
+ events={{
200
+ rkMealAdd: (() => openPicker(day.key, meal.key)) as (event: CustomEvent<never>) => void,
201
+ rkMealSwap: (() => openPicker(day.key, meal.key)) as (event: CustomEvent<never>) => void,
202
+ rkMealOpen: ((event: CustomEvent<{ recipeId: string }>) => goto(`/recipes/${event.detail.recipeId}`)) as (
203
+ event: CustomEvent<never>
204
+ ) => void,
205
+ rkMealRemove: (() => removeMeal(day.key, meal.key)) as (event: CustomEvent<never>) => void,
206
+ rkMealDrop: ((event: CustomEvent<{ payload: string }>) => dropOnto(day.key, meal.key, event.detail.payload)) as (
207
+ event: CustomEvent<never>
208
+ ) => void
209
+ }}
210
+ >
211
+ {#snippet children()}
212
+ {#if planned?.origin === 'custom'}
213
+ <rk-badge tone="success" small>yours</rk-badge>
214
+ {/if}
215
+ {/snippet}
216
+ </WebComponent>
217
+ </div>
218
+ {/each}
219
+ {/each}
220
+ </div>
221
+ </div>
222
+
223
+ {#if plannedCount === 0}
224
+ <div class="section">
225
+ <rk-empty-state
226
+ icon="🗓️"
227
+ heading="Nothing planned for this week yet"
228
+ message="Tap any empty slot to pick a recipe from your favorites, your own collection, or the whole catalogue."
229
+ >
230
+ <a class="btn btn-primary" href="/">Find recipes</a>
231
+ </rk-empty-state>
232
+ </div>
233
+ {:else}
234
+ <section class="section">
235
+ <div class="section-head">
236
+ <h2 class="section-title">This week's recipes</h2>
237
+ <button class="btn btn-ghost btn-sm" type="button" onclick={() => (showSummary = !showSummary)} aria-expanded={showSummary}>
238
+ {showSummary ? 'Hide' : 'Show'}
239
+ </button>
240
+ </div>
241
+
242
+ {#if showSummary}
243
+ <ul class="summary">
244
+ {#each weekRecipes as entry (entry.meal.recipeId)}
245
+ <li class="summary-row card-surface">
246
+ {#if entry.meal.image}
247
+ <img class="summary-thumb" src={entry.meal.image} alt="" width="56" height="56" loading="lazy" />
248
+ {:else}
249
+ <span class="summary-thumb summary-thumb-fallback" aria-hidden="true">🍽️</span>
250
+ {/if}
251
+
252
+ <div class="summary-text">
253
+ <a class="summary-name" href={`/recipes/${entry.meal.recipeId}`}>{entry.meal.name}</a>
254
+ <p class="small muted">{entry.meal.category}{entry.meal.area && entry.meal.area !== 'Unknown' ? ` · ${entry.meal.area}` : ''}</p>
255
+ </div>
256
+
257
+ <rk-badge tone={entry.slots > 1 ? 'primary' : 'neutral'}>
258
+ {entry.slots} {entry.slots === 1 ? 'slot' : 'slots'}
259
+ </rk-badge>
260
+ </li>
261
+ {/each}
262
+ </ul>
263
+ {/if}
264
+ </section>
265
+ {/if}
266
+
267
+ <RecipePickerDialog
268
+ open={pickerOpen}
269
+ slotLabel={targetLabel}
270
+ onpick={handlePick}
271
+ onclose={() => {
272
+ pickerOpen = false;
273
+ target = null;
274
+ }}
275
+ />
276
+
277
+ <ConfirmDialog
278
+ open={confirmClear}
279
+ heading="Clear this week?"
280
+ message={`All ${plannedCount} planned ${plannedCount === 1 ? 'meal' : 'meals'} for the week of ${formatWeekRange(weekKey)} will be removed. Other weeks are not affected.`}
281
+ confirmLabel="Clear week"
282
+ destructive
283
+ onconfirm={clearWeek}
284
+ oncancel={() => (confirmClear = false)}
285
+ />
286
+
287
+ <style>
288
+ .week-nav {
289
+ display: flex;
290
+ gap: 6px;
291
+ }
292
+
293
+ .toolbar {
294
+ display: flex;
295
+ flex-wrap: wrap;
296
+ gap: 8px;
297
+ margin-bottom: 22px;
298
+ }
299
+
300
+ /* Horizontal scroll keeps all seven days addressable on narrow screens. */
301
+ .planner-scroll {
302
+ overflow-x: auto;
303
+ margin: 0 calc(-1 * var(--app-gutter));
304
+ padding: 2px var(--app-gutter);
305
+ }
306
+
307
+ .planner-grid {
308
+ display: grid;
309
+ grid-template-columns: 96px repeat(var(--day-count), minmax(148px, 1fr));
310
+ gap: 8px;
311
+ min-width: 860px;
312
+ }
313
+
314
+ .day-head {
315
+ display: grid;
316
+ gap: 1px;
317
+ padding: 8px 10px;
318
+ border-radius: var(--rk-radius);
319
+ background: var(--rk-surface-2);
320
+ border: 1px solid var(--rk-border);
321
+ text-align: center;
322
+ }
323
+
324
+ .day-head.is-today {
325
+ background: var(--rk-primary-soft);
326
+ border-color: var(--rk-primary);
327
+ }
328
+
329
+ .day-name {
330
+ font-weight: 700;
331
+ font-size: 0.85rem;
332
+ }
333
+
334
+ .day-date {
335
+ font-size: 0.72rem;
336
+ color: var(--rk-muted);
337
+ }
338
+
339
+ .day-head.is-today .day-date {
340
+ color: var(--rk-primary-strong);
341
+ }
342
+
343
+ .meal-head {
344
+ display: flex;
345
+ align-items: center;
346
+ font-size: 0.74rem;
347
+ font-weight: 700;
348
+ text-transform: uppercase;
349
+ letter-spacing: 0.07em;
350
+ color: var(--rk-muted);
351
+ padding-right: 8px;
352
+ }
353
+
354
+ .cell {
355
+ display: flex;
356
+ }
357
+
358
+ .cell > :global(*) {
359
+ flex: 1 1 auto;
360
+ }
361
+
362
+ .cell[draggable='true'] {
363
+ cursor: grab;
364
+ }
365
+
366
+ .cell[draggable='true']:active {
367
+ cursor: grabbing;
368
+ }
369
+
370
+ .summary {
371
+ list-style: none;
372
+ margin: 0;
373
+ padding: 0;
374
+ display: grid;
375
+ gap: 10px;
376
+ }
377
+
378
+ .summary-row {
379
+ display: flex;
380
+ align-items: center;
381
+ gap: 14px;
382
+ padding: 12px 16px;
383
+ }
384
+
385
+ .summary-thumb {
386
+ flex: 0 0 auto;
387
+ width: 56px;
388
+ height: 56px;
389
+ border-radius: var(--rk-radius-sm);
390
+ object-fit: cover;
391
+ background: var(--rk-surface-2);
392
+ }
393
+
394
+ .summary-thumb-fallback {
395
+ display: grid;
396
+ place-items: center;
397
+ font-size: 1.4rem;
398
+ }
399
+
400
+ .summary-text {
401
+ flex: 1 1 auto;
402
+ min-width: 0;
403
+ }
404
+
405
+ .summary-name {
406
+ font-family: var(--rk-font-display);
407
+ font-size: 1.03rem;
408
+ font-weight: 650;
409
+ color: var(--rk-ink);
410
+ text-decoration: none;
411
+ }
412
+
413
+ .summary-name:hover {
414
+ color: var(--rk-primary);
415
+ }
416
+
417
+ @media (max-width: 720px) {
418
+ .page-head {
419
+ align-items: flex-start;
420
+ }
421
+ }
422
+ </style>