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

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

View File

@@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('Startseite lädt korrekt', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Willkommen bei Mealprep' })).toBeVisible();
});

View File

@@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'e2e',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

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>

View File

@@ -2,7 +2,7 @@ import { error, redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api';
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
const VALID_EFFORTS = ['easy', 'medium', 'hard'];
export const load: PageServerLoad = async ({ fetch, params }) => {
const api = apiClient(fetch);
@@ -17,9 +17,7 @@ export const load: PageServerLoad = async ({ fetch, params }) => {
const recipe = recipeResult.data;
const allTags = tagsResult.data ?? [];
const categories = allTags
.filter((t) => t.tagType === 'category')
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
const categories = allTags.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
return {
recipe: {
@@ -50,6 +48,7 @@ export const actions: Actions = {
const serves = formData.get('serves');
const cookTimeMin = formData.get('cookTimeMin');
const effort = formData.get('effort') as string;
const heroImageUrl = (formData.get('heroImageUrl') as string) || null;
const ingredientsJson = formData.get('ingredientsJson') as string;
const stepsJson = formData.get('stepsJson') as string;
const tagIds = formData.getAll('tagIds') as string[];
@@ -76,6 +75,7 @@ export const actions: Actions = {
serves: serves ? Number(serves) || undefined : undefined,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
effort,
heroImageUrl,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
.filter((ing) => ing.name?.trim())
.map((ing, i) => ({

View File

@@ -26,7 +26,7 @@ describe('edit recipe page — load', () => {
name: 'Spaghetti Bolognese',
serves: 4,
cookTimeMin: 30,
effort: 'Easy',
effort: 'easy',
ingredients: [{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }],
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
@@ -53,7 +53,7 @@ describe('edit recipe page — load', () => {
});
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
expect(result.recipe.name).toBe('Spaghetti Bolognese');
expect(result.recipe.effort).toBe('Easy');
expect(result.recipe.effort).toBe('easy');
});
it('returns categories from tags', async () => {
@@ -82,7 +82,7 @@ describe('edit recipe page — update action', () => {
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
const base: Record<string, string | string[]> = {
name: 'Test Rezept',
effort: 'Easy',
effort: 'easy',
tagIds: ['t1'],
ingredientsJson: '[]',
stepsJson: '[]',
@@ -174,7 +174,33 @@ describe('edit recipe page — update action', () => {
} as any).catch(() => {});
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
params: { path: { id: 'r1' } },
body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' })
body: expect.objectContaining({ name: 'Test Rezept', effort: 'easy' })
}));
});
it('sends heroImageUrl in PUT body when provided', async () => {
mockPut.mockResolvedValue({ error: undefined });
const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' });
await actions.update({
request: { formData: async () => fd },
fetch: vi.fn(),
params: { id: 'r1' }
} as any).catch(() => {});
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
body: expect.objectContaining({ heroImageUrl: 'data:image/jpeg;base64,abc123' })
}));
});
it('sends null heroImageUrl when field is empty', async () => {
mockPut.mockResolvedValue({ error: undefined });
const fd = makeFormData({ heroImageUrl: '' });
await actions.update({
request: { formData: async () => fd },
fetch: vi.fn(),
params: { id: 'r1' }
} as any).catch(() => {});
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
body: expect.objectContaining({ heroImageUrl: null })
}));
});

View File

@@ -2,16 +2,14 @@ import { redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api';
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
const VALID_EFFORTS = ['easy', 'medium', 'hard'];
export const load: PageServerLoad = async ({ fetch }) => {
const api = apiClient(fetch);
const { data, error } = await api.GET('/v1/tags', {});
const allTags = error || !data ? [] : data;
const categories = allTags
.filter((t) => t.tagType === 'category')
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
const categories = allTags.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
return { recipe: null, categories };
};
@@ -23,6 +21,7 @@ export const actions: Actions = {
const serves = formData.get('serves');
const cookTimeMin = formData.get('cookTimeMin');
const effort = formData.get('effort') as string;
const heroImageUrl = (formData.get('heroImageUrl') as string) || null;
const ingredientsJson = formData.get('ingredientsJson') as string;
const stepsJson = formData.get('stepsJson') as string;
const tagIds = formData.getAll('tagIds') as string[];
@@ -48,6 +47,7 @@ export const actions: Actions = {
serves: serves ? Number(serves) || undefined : undefined,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
effort,
heroImageUrl,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
.filter((ing) => ing.name?.trim())
.map((ing, i) => ({

View File

@@ -22,8 +22,10 @@ describe('new recipe page — load', () => {
});
const mockTags = [
{ id: 't1', name: 'Pasta', tagType: 'category' },
{ id: 't2', name: 'Fleisch', tagType: 'category' }
{ id: 't1', name: 'Vegetarisch', tagType: 'dietary' },
{ id: 't2', name: 'Mediterran', tagType: 'cuisine' },
{ id: 't3', name: 'Käse', tagType: 'protein' },
{ id: 't4', name: 'Auflauf', tagType: 'other' }
];
it('fetches tags from GET /v1/tags', async () => {
@@ -32,11 +34,11 @@ describe('new recipe page — load', () => {
expect(mockGet).toHaveBeenCalledWith('/v1/tags', expect.anything());
});
it('returns categories filtered from tags', async () => {
it('returns all tags as categories regardless of tagType', async () => {
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
const result = await load({ fetch: vi.fn() } as any);
expect(result.categories).toHaveLength(2);
expect(result.categories[0].name).toBe('Pasta');
expect(result.categories).toHaveLength(4);
expect(result.categories[0].name).toBe('Vegetarisch');
});
it('returns empty categories when API fails', async () => {
@@ -58,7 +60,7 @@ describe('new recipe page — create action', () => {
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
const base: Record<string, string | string[]> = {
name: 'Test Rezept',
effort: 'Easy',
effort: 'easy',
tagIds: ['t1'],
ingredientsJson: '[]',
stepsJson: '[]',
@@ -140,7 +142,25 @@ describe('new recipe page — create action', () => {
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(
() => {}
);
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' }) }));
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'easy' }) }));
});
it('sends heroImageUrl in POST body when provided', async () => {
mockPost.mockResolvedValue({ error: undefined });
const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' });
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(() => {});
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({
body: expect.objectContaining({ heroImageUrl: 'data:image/jpeg;base64,abc123' })
}));
});
it('sends null heroImageUrl when field is empty', async () => {
mockPost.mockResolvedValue({ error: undefined });
const fd = makeFormData({ heroImageUrl: '' });
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(() => {});
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({
body: expect.objectContaining({ heroImageUrl: null })
}));
});
it('returns fail(500) when API returns error', async () => {

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow: