feat(recipes): add image upload, fix save 500, seed HelloFresh data

- Store hero image as base64 data URI in text column (V023 migration)
- Add file upload UI to RecipeForm with FileReader preview
- Remove isChildFriendly from RecipeCreateRequest (no form field)
- Fix 500 on save: effort values now lowercase, serves/cookTimeMin changed
  from primitive short to nullable Integer to survive omitted fields
- Fix empty categories panel: removed stale tagType=category filter
- Group category tags by type with German headings in recipe form
- Split SuggestionResponse.SuggestionRecipe (no image) from SlotRecipe
- Seed 11 HelloFresh recipes with ingredients, steps and tags (V101)
- Add frontend e2e scaffold, specs and dev yml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:23:28 +02:00
parent 116e400a91
commit 520dae5adf
34 changed files with 9862 additions and 84 deletions

View File

@@ -23,13 +23,30 @@
} = $props();
const effortOptions = [
{ label: 'Leicht', value: 'Easy' },
{ label: 'Mittel', value: 'Medium' },
{ label: 'Schwer', value: 'Hard' }
{ label: 'Leicht', value: 'easy' },
{ label: 'Mittel', value: 'medium' },
{ label: 'Schwer', value: 'hard' }
];
const initial = (() => $state.snapshot(recipe))();
const TAG_TYPE_LABELS: Record<string, string> = {
dietary: 'Ernährung',
cuisine: 'Küche',
protein: 'Protein',
other: 'Sonstiges'
};
const groupedCategories = $derived(
Object.entries(
categories.reduce<Record<string, typeof categories>>((acc, cat) => {
const type = cat.tagType ?? 'other';
(acc[type] ??= []).push(cat);
return acc;
}, {})
)
);
let name = $state(initial?.name ?? '');
let serves = $state<number | ''>(initial?.serves ?? '');
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
@@ -43,6 +60,17 @@
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
);
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null);
function handleImageChange(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
heroImageUrl = reader.result as string;
};
reader.readAsDataURL(file);
}
</script>
<form method="POST" {action} use:enhance>
@@ -140,6 +168,37 @@
</div>
</div>
<!-- Image -->
<div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Bild</p>
{#if heroImageUrl}
<img
src={heroImageUrl}
alt=""
class="mb-[8px] max-h-[200px] w-full rounded-[var(--radius-md)] object-cover"
/>
<button
type="button"
onclick={() => (heroImageUrl = null)}
class="mb-[8px] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
>
Bild entfernen
</button>
{/if}
<label
class="block w-full cursor-pointer rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] text-center text-[13px] text-[var(--color-text-muted)]"
>
<input
type="file"
accept="image/*"
onchange={handleImageChange}
class="sr-only"
/>
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
</label>
<input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
</div>
<!-- Ingredients -->
<div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
@@ -227,35 +286,42 @@
<div
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[20px]"
>
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
<div class="flex flex-wrap gap-[8px]">
{#each categories as cat (cat.id)}
<label
class={[
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
selectedTagIds.includes(cat.id)
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
].join(' ')}
>
<input
type="checkbox"
name="tagIds"
value={cat.id}
checked={selectedTagIds.includes(cat.id)}
onchange={(e) => {
if (e.currentTarget.checked) {
selectedTagIds = [...selectedTagIds, cat.id];
} else {
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
}
}}
class="sr-only"
/>
{cat.name}
</label>
{/each}
</div>
<p class="mb-[16px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
{#each groupedCategories as [type, tags] (type)}
<div class="mb-[16px] last:mb-0">
<p class="mb-[8px] text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
{TAG_TYPE_LABELS[type] ?? type}
</p>
<div class="flex flex-wrap gap-[8px]">
{#each tags as cat (cat.id)}
<label
class={[
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
selectedTagIds.includes(cat.id)
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
].join(' ')}
>
<input
type="checkbox"
name="tagIds"
value={cat.id}
checked={selectedTagIds.includes(cat.id)}
onchange={(e) => {
if (e.currentTarget.checked) {
selectedTagIds = [...selectedTagIds, cat.id];
} else {
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
}
}}
class="sr-only"
/>
{cat.name}
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>