@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,560 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Create / edit form for user-authored recipes.
|
|
3
|
+
|
|
4
|
+
Validation runs on submit and then live on every change, so the first
|
|
5
|
+
interaction is not pre-emptively covered in red messages. `rk-tag-input`
|
|
6
|
+
handles the tag field and reports changes through `rkTagsChange`.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { Ingredient, Recipe } from '$domain/recipe';
|
|
10
|
+
import { joinSteps } from '$domain/recipe';
|
|
11
|
+
import { countErrors, emptyDraft, isValid, LIMITS, parseOptionalCount, validateDraft, type RecipeDraft, type RecipeErrors } from '$domain/validation';
|
|
12
|
+
import WebComponent from '$lib/stencil/WebComponent.svelte';
|
|
13
|
+
import { untrack } from 'svelte';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Categories offered in the select. */
|
|
17
|
+
categories: string[];
|
|
18
|
+
/** Cuisines offered in the select. */
|
|
19
|
+
areas: string[];
|
|
20
|
+
/** Existing recipe when editing; omitted when creating. */
|
|
21
|
+
recipe?: Recipe;
|
|
22
|
+
submitLabel?: string;
|
|
23
|
+
/** Returns an error message to display, or nothing on success. */
|
|
24
|
+
onsubmit: (payload: Omit<Recipe, 'id' | 'origin' | 'createdAt' | 'updatedAt'>) => string | void;
|
|
25
|
+
oncancel: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let { categories, areas, recipe, submitLabel = 'Save recipe', onsubmit, oncancel }: Props = $props();
|
|
29
|
+
|
|
30
|
+
function draftFrom(source?: Recipe): RecipeDraft {
|
|
31
|
+
if (!source) return emptyDraft();
|
|
32
|
+
return {
|
|
33
|
+
name: source.name,
|
|
34
|
+
category: source.category,
|
|
35
|
+
area: source.area,
|
|
36
|
+
image: source.image ?? '',
|
|
37
|
+
minutes: source.minutes ? String(source.minutes) : '',
|
|
38
|
+
servings: source.servings ? String(source.servings) : '',
|
|
39
|
+
ingredients: source.ingredients.length > 0 ? source.ingredients.map((row) => ({ ...row })) : emptyDraft().ingredients,
|
|
40
|
+
steps: source.steps.length > 0 ? [...source.steps] : [''],
|
|
41
|
+
tags: [...source.tags],
|
|
42
|
+
notes: source.notes ?? ''
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `untrack` is deliberate: the draft is seeded once from `recipe` and then
|
|
47
|
+
// owned by the form. Re-seeding on every prop read would discard user edits.
|
|
48
|
+
let draft = $state<RecipeDraft>(untrack(() => draftFrom(recipe)));
|
|
49
|
+
let submitted = $state(false);
|
|
50
|
+
let formError = $state<string | null>(null);
|
|
51
|
+
|
|
52
|
+
// Reload the draft if the target recipe changes (e.g. navigating between edits).
|
|
53
|
+
let loadedId = $state(untrack(() => recipe?.id));
|
|
54
|
+
$effect(() => {
|
|
55
|
+
if (recipe?.id === loadedId) return;
|
|
56
|
+
loadedId = recipe?.id;
|
|
57
|
+
draft = draftFrom(recipe);
|
|
58
|
+
submitted = false;
|
|
59
|
+
formError = null;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const errors = $derived<RecipeErrors>(validateDraft(draft));
|
|
63
|
+
/** Errors are only surfaced after the first submit attempt. */
|
|
64
|
+
const shown = $derived<RecipeErrors>(submitted ? errors : {});
|
|
65
|
+
const problemCount = $derived(countErrors(errors));
|
|
66
|
+
|
|
67
|
+
/* ------------------------------------------------------- ingredient rows */
|
|
68
|
+
|
|
69
|
+
function addIngredient() {
|
|
70
|
+
if (draft.ingredients.length >= LIMITS.maxIngredients) return;
|
|
71
|
+
draft.ingredients = [...draft.ingredients, { name: '', measure: '' }];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function removeIngredient(index: number) {
|
|
75
|
+
// Always leave one row so the user has somewhere to type.
|
|
76
|
+
if (draft.ingredients.length <= 1) {
|
|
77
|
+
draft.ingredients = [{ name: '', measure: '' }];
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
draft.ingredients = draft.ingredients.filter((_, position) => position !== index);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function moveIngredient(index: number, direction: -1 | 1) {
|
|
84
|
+
const target = index + direction;
|
|
85
|
+
if (target < 0 || target >= draft.ingredients.length) return;
|
|
86
|
+
const next = [...draft.ingredients];
|
|
87
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
88
|
+
draft.ingredients = next;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ------------------------------------------------------------- step rows */
|
|
92
|
+
|
|
93
|
+
function addStep() {
|
|
94
|
+
if (draft.steps.length >= LIMITS.maxSteps) return;
|
|
95
|
+
draft.steps = [...draft.steps, ''];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function removeStep(index: number) {
|
|
99
|
+
if (draft.steps.length <= 1) {
|
|
100
|
+
draft.steps = [''];
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
draft.steps = draft.steps.filter((_, position) => position !== index);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function moveStep(index: number, direction: -1 | 1) {
|
|
107
|
+
const target = index + direction;
|
|
108
|
+
if (target < 0 || target >= draft.steps.length) return;
|
|
109
|
+
const next = [...draft.steps];
|
|
110
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
111
|
+
draft.steps = next;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pastes a multi-line block into separate steps. Recipes are usually copied
|
|
116
|
+
* from somewhere, so honouring line breaks saves a lot of manual splitting.
|
|
117
|
+
*/
|
|
118
|
+
function handleStepPaste(event: ClipboardEvent, index: number) {
|
|
119
|
+
const text = event.clipboardData?.getData('text/plain') ?? '';
|
|
120
|
+
const lines = text
|
|
121
|
+
.split(/\n+/)
|
|
122
|
+
.map((line) => line.replace(/^\s*(?:step\s*)?\d+[.)]?\s*/i, '').trim())
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
if (lines.length < 2) return;
|
|
125
|
+
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
const next = [...draft.steps];
|
|
128
|
+
next.splice(index, 1, ...lines.slice(0, LIMITS.maxSteps - next.length + 1));
|
|
129
|
+
draft.steps = next;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ---------------------------------------------------------------- submit */
|
|
133
|
+
|
|
134
|
+
function handleSubmit(event: SubmitEvent) {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
submitted = true;
|
|
137
|
+
formError = null;
|
|
138
|
+
|
|
139
|
+
if (!isValid(errors)) {
|
|
140
|
+
// Move focus to the first field with a problem for keyboard users.
|
|
141
|
+
const firstInvalid = document.querySelector<HTMLElement>('[aria-invalid="true"]');
|
|
142
|
+
firstInvalid?.focus();
|
|
143
|
+
firstInvalid?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ingredients: Ingredient[] = draft.ingredients
|
|
148
|
+
.filter((row) => row.name.trim() !== '')
|
|
149
|
+
.map((row) => ({ name: row.name.trim(), measure: row.measure.trim() }));
|
|
150
|
+
|
|
151
|
+
const minutes = parseOptionalCount(draft.minutes, LIMITS.minutesMax);
|
|
152
|
+
const servings = parseOptionalCount(draft.servings, LIMITS.servingsMax);
|
|
153
|
+
|
|
154
|
+
const result = onsubmit({
|
|
155
|
+
name: draft.name.trim(),
|
|
156
|
+
category: draft.category.trim(),
|
|
157
|
+
area: draft.area.trim(),
|
|
158
|
+
image: draft.image.trim() || undefined,
|
|
159
|
+
steps: draft.steps.map((step) => step.trim()).filter(Boolean),
|
|
160
|
+
ingredients,
|
|
161
|
+
tags: draft.tags,
|
|
162
|
+
minutes: minutes ?? undefined,
|
|
163
|
+
servings: servings ?? undefined,
|
|
164
|
+
notes: draft.notes.trim() || undefined
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (typeof result === 'string') formError = result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const previewSteps = $derived(joinSteps(draft.steps.filter((step) => step.trim() !== '')));
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
<form class="form" onsubmit={handleSubmit} novalidate>
|
|
174
|
+
{#if submitted && problemCount > 0}
|
|
175
|
+
<div class="banner tone-error" role="alert">
|
|
176
|
+
<strong>{problemCount} {problemCount === 1 ? 'field needs' : 'fields need'} attention.</strong>
|
|
177
|
+
Fix the highlighted fields below and submit again.
|
|
178
|
+
</div>
|
|
179
|
+
{/if}
|
|
180
|
+
|
|
181
|
+
{#if formError}
|
|
182
|
+
<div class="banner tone-error" role="alert">{formError}</div>
|
|
183
|
+
{/if}
|
|
184
|
+
|
|
185
|
+
<fieldset class="card-surface">
|
|
186
|
+
<legend>The basics</legend>
|
|
187
|
+
|
|
188
|
+
<div class="stack">
|
|
189
|
+
<label class="field">
|
|
190
|
+
<span class="field-label">
|
|
191
|
+
Recipe name <span class="field-req" aria-hidden="true">*</span>
|
|
192
|
+
</span>
|
|
193
|
+
<input
|
|
194
|
+
class="input"
|
|
195
|
+
type="text"
|
|
196
|
+
bind:value={draft.name}
|
|
197
|
+
placeholder="e.g. Charred broccoli with tahini"
|
|
198
|
+
maxlength={LIMITS.nameMax + 20}
|
|
199
|
+
aria-invalid={shown.name ? 'true' : undefined}
|
|
200
|
+
aria-describedby={shown.name ? 'err-name' : undefined}
|
|
201
|
+
/>
|
|
202
|
+
{#if shown.name}<span class="field-error" id="err-name">{shown.name}</span>{/if}
|
|
203
|
+
</label>
|
|
204
|
+
|
|
205
|
+
<div class="field-grid">
|
|
206
|
+
<label class="field">
|
|
207
|
+
<span class="field-label">Category <span class="field-req" aria-hidden="true">*</span></span>
|
|
208
|
+
<select class="select" bind:value={draft.category} aria-invalid={shown.category ? 'true' : undefined}>
|
|
209
|
+
<option value="">Choose a category…</option>
|
|
210
|
+
{#each categories as category (category)}
|
|
211
|
+
<option value={category}>{category}</option>
|
|
212
|
+
{/each}
|
|
213
|
+
</select>
|
|
214
|
+
{#if shown.category}<span class="field-error">{shown.category}</span>{/if}
|
|
215
|
+
</label>
|
|
216
|
+
|
|
217
|
+
<label class="field">
|
|
218
|
+
<span class="field-label">Cuisine <span class="field-req" aria-hidden="true">*</span></span>
|
|
219
|
+
<select class="select" bind:value={draft.area} aria-invalid={shown.area ? 'true' : undefined}>
|
|
220
|
+
<option value="">Choose a cuisine…</option>
|
|
221
|
+
{#each areas as area (area)}
|
|
222
|
+
<option value={area}>{area}</option>
|
|
223
|
+
{/each}
|
|
224
|
+
</select>
|
|
225
|
+
{#if shown.area}<span class="field-error">{shown.area}</span>{/if}
|
|
226
|
+
</label>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div class="field-grid">
|
|
230
|
+
<label class="field">
|
|
231
|
+
<span class="field-label">Total time <span class="field-hint">minutes, optional</span></span>
|
|
232
|
+
<!--
|
|
233
|
+
Kept as `type="text"` with a numeric keypad: `bind:value` on a number
|
|
234
|
+
input coerces to a number, which would bypass the string-level
|
|
235
|
+
validation that reports "not a whole number" back to the user.
|
|
236
|
+
-->
|
|
237
|
+
<input
|
|
238
|
+
class="input"
|
|
239
|
+
type="text"
|
|
240
|
+
inputmode="numeric"
|
|
241
|
+
bind:value={draft.minutes}
|
|
242
|
+
placeholder="45"
|
|
243
|
+
aria-invalid={shown.minutes ? 'true' : undefined}
|
|
244
|
+
/>
|
|
245
|
+
{#if shown.minutes}<span class="field-error">{shown.minutes}</span>{/if}
|
|
246
|
+
</label>
|
|
247
|
+
|
|
248
|
+
<label class="field">
|
|
249
|
+
<span class="field-label">Servings <span class="field-hint">optional</span></span>
|
|
250
|
+
<input
|
|
251
|
+
class="input"
|
|
252
|
+
type="text"
|
|
253
|
+
inputmode="numeric"
|
|
254
|
+
bind:value={draft.servings}
|
|
255
|
+
placeholder="4"
|
|
256
|
+
aria-invalid={shown.servings ? 'true' : undefined}
|
|
257
|
+
/>
|
|
258
|
+
{#if shown.servings}<span class="field-error">{shown.servings}</span>{/if}
|
|
259
|
+
</label>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<label class="field">
|
|
263
|
+
<span class="field-label">Image URL <span class="field-hint">optional</span></span>
|
|
264
|
+
<input
|
|
265
|
+
class="input"
|
|
266
|
+
type="url"
|
|
267
|
+
bind:value={draft.image}
|
|
268
|
+
placeholder="https://…"
|
|
269
|
+
aria-invalid={shown.image ? 'true' : undefined}
|
|
270
|
+
/>
|
|
271
|
+
{#if shown.image}
|
|
272
|
+
<span class="field-error">{shown.image}</span>
|
|
273
|
+
{:else if draft.image.trim()}
|
|
274
|
+
<span class="field-hint">A preview appears in the card below once the URL loads.</span>
|
|
275
|
+
{/if}
|
|
276
|
+
</label>
|
|
277
|
+
|
|
278
|
+
<div class="field">
|
|
279
|
+
<span class="field-label">Tags <span class="field-hint">up to {LIMITS.maxTags}</span></span>
|
|
280
|
+
<!--
|
|
281
|
+
Stencil tag editor. `values` is an array, so the bridge assigns it as a
|
|
282
|
+
DOM property; changes come back as a single `rkTagsChange` event.
|
|
283
|
+
-->
|
|
284
|
+
<WebComponent
|
|
285
|
+
tag="rk-tag-input"
|
|
286
|
+
props={{ values: draft.tags, maxTags: LIMITS.maxTags, placeholder: 'e.g. weeknight, one pot', error: shown.tags }}
|
|
287
|
+
events={{ rkTagsChange: ((event: CustomEvent<string[]>) => (draft.tags = event.detail)) as (event: CustomEvent<never>) => void }}
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
</fieldset>
|
|
292
|
+
|
|
293
|
+
<fieldset class="card-surface">
|
|
294
|
+
<legend>Ingredients</legend>
|
|
295
|
+
|
|
296
|
+
{#if shown.ingredients}
|
|
297
|
+
<p class="field-error">{shown.ingredients}</p>
|
|
298
|
+
{:else}
|
|
299
|
+
<p class="field-hint">At least {LIMITS.minIngredients} ingredients. Quantities are optional but helpful.</p>
|
|
300
|
+
{/if}
|
|
301
|
+
|
|
302
|
+
<ol class="rows">
|
|
303
|
+
{#each draft.ingredients as ingredient, index (index)}
|
|
304
|
+
<li class="row-item">
|
|
305
|
+
<div class="row-fields">
|
|
306
|
+
<input
|
|
307
|
+
class="input"
|
|
308
|
+
type="text"
|
|
309
|
+
bind:value={draft.ingredients[index].name}
|
|
310
|
+
placeholder="Ingredient"
|
|
311
|
+
aria-label={`Ingredient ${index + 1} name`}
|
|
312
|
+
aria-invalid={shown.ingredientRows?.[index] ? 'true' : undefined}
|
|
313
|
+
/>
|
|
314
|
+
<input
|
|
315
|
+
class="input measure"
|
|
316
|
+
type="text"
|
|
317
|
+
bind:value={draft.ingredients[index].measure}
|
|
318
|
+
placeholder="Quantity"
|
|
319
|
+
aria-label={`Ingredient ${index + 1} quantity`}
|
|
320
|
+
/>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
<div class="row-controls">
|
|
324
|
+
<button class="icon-btn" type="button" onclick={() => moveIngredient(index, -1)} disabled={index === 0} aria-label={`Move ingredient ${index + 1} up`}>↑</button>
|
|
325
|
+
<button
|
|
326
|
+
class="icon-btn"
|
|
327
|
+
type="button"
|
|
328
|
+
onclick={() => moveIngredient(index, 1)}
|
|
329
|
+
disabled={index === draft.ingredients.length - 1}
|
|
330
|
+
aria-label={`Move ingredient ${index + 1} down`}
|
|
331
|
+
>
|
|
332
|
+
↓
|
|
333
|
+
</button>
|
|
334
|
+
<button class="icon-btn is-danger" type="button" onclick={() => removeIngredient(index)} aria-label={`Remove ingredient ${index + 1}`}>✕</button>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
{#if shown.ingredientRows?.[index]}
|
|
338
|
+
<p class="field-error row-error">{shown.ingredientRows[index]}</p>
|
|
339
|
+
{/if}
|
|
340
|
+
</li>
|
|
341
|
+
{/each}
|
|
342
|
+
</ol>
|
|
343
|
+
|
|
344
|
+
<button class="btn btn-sm" type="button" onclick={addIngredient} disabled={draft.ingredients.length >= LIMITS.maxIngredients}>
|
|
345
|
+
+ Add ingredient
|
|
346
|
+
</button>
|
|
347
|
+
</fieldset>
|
|
348
|
+
|
|
349
|
+
<fieldset class="card-surface">
|
|
350
|
+
<legend>Method</legend>
|
|
351
|
+
|
|
352
|
+
{#if shown.steps}
|
|
353
|
+
<p class="field-error">{shown.steps}</p>
|
|
354
|
+
{:else}
|
|
355
|
+
<p class="field-hint">One step per box. Pasting several lines at once splits them into separate steps.</p>
|
|
356
|
+
{/if}
|
|
357
|
+
|
|
358
|
+
<ol class="rows">
|
|
359
|
+
{#each draft.steps as step, index (index)}
|
|
360
|
+
<li class="row-item step-item">
|
|
361
|
+
<span class="step-num" aria-hidden="true">{index + 1}</span>
|
|
362
|
+
|
|
363
|
+
<textarea
|
|
364
|
+
class="textarea"
|
|
365
|
+
bind:value={draft.steps[index]}
|
|
366
|
+
rows="2"
|
|
367
|
+
placeholder="Describe this step…"
|
|
368
|
+
aria-label={`Step ${index + 1}`}
|
|
369
|
+
aria-invalid={shown.stepRows?.[index] ? 'true' : undefined}
|
|
370
|
+
onpaste={(event) => handleStepPaste(event, index)}
|
|
371
|
+
></textarea>
|
|
372
|
+
|
|
373
|
+
<div class="row-controls">
|
|
374
|
+
<button class="icon-btn" type="button" onclick={() => moveStep(index, -1)} disabled={index === 0} aria-label={`Move step ${index + 1} up`}>↑</button>
|
|
375
|
+
<button class="icon-btn" type="button" onclick={() => moveStep(index, 1)} disabled={index === draft.steps.length - 1} aria-label={`Move step ${index + 1} down`}>↓</button>
|
|
376
|
+
<button class="icon-btn is-danger" type="button" onclick={() => removeStep(index)} aria-label={`Remove step ${index + 1}`}>✕</button>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
{#if shown.stepRows?.[index]}
|
|
380
|
+
<p class="field-error row-error">{shown.stepRows[index]}</p>
|
|
381
|
+
{/if}
|
|
382
|
+
</li>
|
|
383
|
+
{/each}
|
|
384
|
+
</ol>
|
|
385
|
+
|
|
386
|
+
<button class="btn btn-sm" type="button" onclick={addStep} disabled={draft.steps.length >= LIMITS.maxSteps}>+ Add step</button>
|
|
387
|
+
|
|
388
|
+
<label class="field notes-field">
|
|
389
|
+
<span class="field-label">Notes <span class="field-hint">optional, max {LIMITS.notesMax} characters</span></span>
|
|
390
|
+
<textarea
|
|
391
|
+
class="textarea"
|
|
392
|
+
bind:value={draft.notes}
|
|
393
|
+
rows="3"
|
|
394
|
+
placeholder="Substitutions, storage, who liked it…"
|
|
395
|
+
aria-invalid={shown.notes ? 'true' : undefined}
|
|
396
|
+
></textarea>
|
|
397
|
+
{#if shown.notes}<span class="field-error">{shown.notes}</span>{/if}
|
|
398
|
+
</label>
|
|
399
|
+
</fieldset>
|
|
400
|
+
|
|
401
|
+
<div class="form-actions">
|
|
402
|
+
<button class="btn btn-primary" type="submit">{submitLabel}</button>
|
|
403
|
+
<button class="btn" type="button" onclick={oncancel}>Cancel</button>
|
|
404
|
+
|
|
405
|
+
<p class="small muted status" aria-live="polite">
|
|
406
|
+
{#if problemCount === 0}
|
|
407
|
+
Ready to save · {draft.ingredients.filter((row) => row.name.trim()).length} ingredients ·
|
|
408
|
+
{previewSteps ? draft.steps.filter((step) => step.trim()).length : 0} steps
|
|
409
|
+
{:else if submitted}
|
|
410
|
+
{problemCount} {problemCount === 1 ? 'problem' : 'problems'} remaining
|
|
411
|
+
{/if}
|
|
412
|
+
</p>
|
|
413
|
+
</div>
|
|
414
|
+
</form>
|
|
415
|
+
|
|
416
|
+
<style>
|
|
417
|
+
.form {
|
|
418
|
+
display: grid;
|
|
419
|
+
gap: 20px;
|
|
420
|
+
max-width: 820px;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
fieldset {
|
|
424
|
+
border: 1px solid var(--rk-border);
|
|
425
|
+
margin: 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
legend {
|
|
429
|
+
padding: 0 10px;
|
|
430
|
+
font-family: var(--rk-font-display);
|
|
431
|
+
font-size: 1.05rem;
|
|
432
|
+
font-weight: 650;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.banner {
|
|
436
|
+
padding: 12px 16px;
|
|
437
|
+
border-radius: var(--rk-radius);
|
|
438
|
+
font-size: 0.9rem;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.banner.tone-error {
|
|
442
|
+
background: var(--rk-danger-soft);
|
|
443
|
+
color: var(--rk-danger);
|
|
444
|
+
border: 1px solid var(--rk-danger);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.rows {
|
|
448
|
+
list-style: none;
|
|
449
|
+
margin: 14px 0;
|
|
450
|
+
padding: 0;
|
|
451
|
+
display: grid;
|
|
452
|
+
gap: 10px;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.row-item {
|
|
456
|
+
display: flex;
|
|
457
|
+
flex-wrap: wrap;
|
|
458
|
+
align-items: flex-start;
|
|
459
|
+
gap: 8px;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.row-fields {
|
|
463
|
+
display: flex;
|
|
464
|
+
gap: 8px;
|
|
465
|
+
flex: 1 1 260px;
|
|
466
|
+
min-width: 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
.row-fields .input {
|
|
470
|
+
min-width: 0;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.measure {
|
|
474
|
+
flex: 0 0 130px;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.row-controls {
|
|
478
|
+
display: flex;
|
|
479
|
+
gap: 4px;
|
|
480
|
+
flex: 0 0 auto;
|
|
481
|
+
padding-top: 2px;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.icon-btn {
|
|
485
|
+
width: 34px;
|
|
486
|
+
height: 40px;
|
|
487
|
+
padding: 0;
|
|
488
|
+
border: 1px solid var(--rk-border);
|
|
489
|
+
border-radius: var(--rk-radius-sm);
|
|
490
|
+
background: var(--rk-surface);
|
|
491
|
+
color: var(--rk-ink-2);
|
|
492
|
+
font-size: 0.9rem;
|
|
493
|
+
cursor: pointer;
|
|
494
|
+
transition:
|
|
495
|
+
background var(--rk-transition),
|
|
496
|
+
color var(--rk-transition);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.icon-btn:hover:not(:disabled) {
|
|
500
|
+
background: var(--rk-surface-2);
|
|
501
|
+
color: var(--rk-primary);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.icon-btn.is-danger:hover:not(:disabled) {
|
|
505
|
+
background: var(--rk-danger-soft);
|
|
506
|
+
color: var(--rk-danger);
|
|
507
|
+
border-color: var(--rk-danger);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.icon-btn:disabled {
|
|
511
|
+
opacity: 0.4;
|
|
512
|
+
cursor: not-allowed;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.row-error {
|
|
516
|
+
flex: 1 1 100%;
|
|
517
|
+
margin: 0;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.step-item {
|
|
521
|
+
align-items: flex-start;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.step-num {
|
|
525
|
+
flex: 0 0 auto;
|
|
526
|
+
display: grid;
|
|
527
|
+
place-items: center;
|
|
528
|
+
width: 28px;
|
|
529
|
+
height: 40px;
|
|
530
|
+
color: var(--rk-muted);
|
|
531
|
+
font-weight: 700;
|
|
532
|
+
font-variant-numeric: tabular-nums;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
.step-item .textarea {
|
|
536
|
+
flex: 1 1 240px;
|
|
537
|
+
min-width: 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
.notes-field {
|
|
541
|
+
margin-top: 22px;
|
|
542
|
+
padding-top: 18px;
|
|
543
|
+
border-top: 1px solid var(--rk-border);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.form-actions {
|
|
547
|
+
display: flex;
|
|
548
|
+
flex-wrap: wrap;
|
|
549
|
+
align-items: center;
|
|
550
|
+
gap: 10px;
|
|
551
|
+
position: sticky;
|
|
552
|
+
bottom: 0;
|
|
553
|
+
padding: 14px 0;
|
|
554
|
+
background: linear-gradient(to top, var(--rk-bg) 60%, transparent);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.status {
|
|
558
|
+
margin-left: auto;
|
|
559
|
+
}
|
|
560
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { RecipeSummary } from '$domain/recipe';
|
|
3
|
+
import RecipeCard from './RecipeCard.svelte';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
recipes: RecipeSummary[];
|
|
7
|
+
/** Renders shimmer placeholders instead of cards. */
|
|
8
|
+
loading?: boolean;
|
|
9
|
+
skeletonCount?: number;
|
|
10
|
+
/** Per-card `actions` slot content. */
|
|
11
|
+
actions?: import('svelte').Snippet<[RecipeSummary]>;
|
|
12
|
+
badges?: import('svelte').Snippet<[RecipeSummary]>;
|
|
13
|
+
/** Shown when `recipes` is empty and not loading. */
|
|
14
|
+
empty?: import('svelte').Snippet;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { recipes, loading = false, skeletonCount = 8, actions, badges, empty }: Props = $props();
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
{#if loading}
|
|
21
|
+
<div class="recipe-grid" aria-busy="true" aria-label="Loading recipes">
|
|
22
|
+
{#each Array.from({ length: skeletonCount }, (_, index) => index) as index (index)}
|
|
23
|
+
<div class="ghost card-surface">
|
|
24
|
+
<div class="skeleton ghost-media"></div>
|
|
25
|
+
<div class="skeleton ghost-line" style="width: 82%"></div>
|
|
26
|
+
<div class="skeleton ghost-line ghost-line-sm" style="width: 54%"></div>
|
|
27
|
+
</div>
|
|
28
|
+
{/each}
|
|
29
|
+
</div>
|
|
30
|
+
{:else if recipes.length === 0}
|
|
31
|
+
{@render empty?.()}
|
|
32
|
+
{:else}
|
|
33
|
+
<ul class="recipe-grid list-reset">
|
|
34
|
+
{#each recipes as recipe (recipe.id)}
|
|
35
|
+
<li><RecipeCard {recipe} {actions} {badges} /></li>
|
|
36
|
+
{/each}
|
|
37
|
+
</ul>
|
|
38
|
+
{/if}
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
.list-reset {
|
|
42
|
+
list-style: none;
|
|
43
|
+
margin: 0;
|
|
44
|
+
padding: 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.list-reset li {
|
|
48
|
+
display: flex;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.list-reset li > :global(*) {
|
|
52
|
+
flex: 1 1 auto;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.ghost {
|
|
56
|
+
padding: 0;
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
display: grid;
|
|
59
|
+
gap: 10px;
|
|
60
|
+
align-content: start;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.ghost-media {
|
|
64
|
+
aspect-ratio: 4 / 3;
|
|
65
|
+
border-radius: 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ghost-line {
|
|
69
|
+
height: 14px;
|
|
70
|
+
margin: 0 16px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.ghost-line-sm {
|
|
74
|
+
height: 10px;
|
|
75
|
+
margin-bottom: 20px;
|
|
76
|
+
}
|
|
77
|
+
</style>
|