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:
@@ -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) => ({
|
||||
|
||||
@@ -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 })
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
7
frontend/src/routes/+layout.svelte
Normal file
7
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
Reference in New Issue
Block a user