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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -1,22 +1,51 @@
<script lang="ts">
interface Warning {
title: string;
explanation: string;
interface WarningItem {
dayShort: string;
recipeName: string;
slotId: number;
}
let { warnings }: { warnings: Warning[] } = $props();
interface ActionWarning {
title: string;
items: WarningItem[];
}
let { warnings, weekStart }: { warnings: ActionWarning[]; weekStart: string } = $props();
</script>
{#each warnings as warning}
{#each warnings as warning (warning.title)}
<div
data-testid="warning-card"
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] px-4 py-3"
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] overflow-hidden"
>
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
{warning.title}
</p>
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{warning.explanation}
</p>
<!-- Header row -->
<div class="px-4 py-2.5 border-b border-[var(--yellow-light)]">
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
{warning.title}
</p>
</div>
<!-- Item rows -->
{#each warning.items as item (item.slotId)}
<div class="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-[var(--yellow-light)] last:border-b-0">
<!-- Left: day label + recipe name -->
<div class="flex items-center gap-2 min-w-0">
<span class="font-[var(--font-sans)] text-[11px] font-medium text-[var(--yellow-text)] w-6 flex-shrink-0">
{item.dayShort}
</span>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
{item.recipeName}
</span>
</div>
<!-- Right: swap link -->
<a
href="/planner?week={weekStart}&swap={item.slotId}"
class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--yellow-text)] flex-shrink-0 hover:underline"
>
Tauschen →
</a>
</div>
{/each}
</div>
{/each}

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>