From 9a5c626890df8469d3979b1676681677315eeb3f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:41:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipe):=20Edit-Modus=20f=C3=BCr=20Zutaten?= =?UTF-8?q?,=20Schritte=20und=20Meta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon) in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um. Im Editor: - Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als inline-Inputs. - Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende. - Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen". - Mengen akzeptieren Komma- oder Punkt-Dezimalen. - Empty-Items werden beim Speichern automatisch aussortiert. Backend: - Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients, replaceSteps — letztere in einer Transaction mit delete+insert und FTS-Refresh. - PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description, servings_default, servings_unit, prep_time_min, cook_time_min, total_time_min, cuisine, category, ingredients[], steps[]. Vorher nur title/hidden_from_recent; diese beiden bleiben als Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen. - Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen). Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl. FTS-Update), replaceSteps. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/components/RecipeEditor.svelte | 403 ++++++++++++++++++++ src/lib/server/recipes/repository.ts | 76 ++++ src/routes/api/recipes/[id]/+server.ts | 84 +++- src/routes/recipes/[id]/+page.svelte | 57 ++- tests/integration/recipe-repository.test.ts | 59 ++- 5 files changed, 668 insertions(+), 11 deletions(-) create mode 100644 src/lib/components/RecipeEditor.svelte diff --git a/src/lib/components/RecipeEditor.svelte b/src/lib/components/RecipeEditor.svelte new file mode 100644 index 0000000..54167fe --- /dev/null +++ b/src/lib/components/RecipeEditor.svelte @@ -0,0 +1,403 @@ + + +
+
+ + +
+ + + + +
+
+ +
+

Zutaten

+
    + {#each ingredients as ing, idx (idx)} +
  • + + + + + + +
  • + {/each} +
+ +
+ +
+

Zubereitung

+
    + {#each steps as step, idx (idx)} +
  1. + {idx + 1} + + +
  2. + {/each} +
+ +
+ +
+ + +
+
+ + diff --git a/src/lib/server/recipes/repository.ts b/src/lib/server/recipes/repository.ts index b029015..0ef5643 100644 --- a/src/lib/server/recipes/repository.ts +++ b/src/lib/server/recipes/repository.ts @@ -155,3 +155,79 @@ export function getRecipeIdBySourceUrl( export function deleteRecipe(db: Database.Database, id: number): void { db.prepare('DELETE FROM recipe WHERE id = ?').run(id); } + +export type RecipeMetaPatch = { + title?: string; + description?: string | null; + servings_default?: number | null; + servings_unit?: string | null; + prep_time_min?: number | null; + cook_time_min?: number | null; + total_time_min?: number | null; + cuisine?: string | null; + category?: string | null; +}; + +export function updateRecipeMeta( + db: Database.Database, + id: number, + patch: RecipeMetaPatch +): void { + const fields: string[] = []; + const values: unknown[] = []; + for (const key of [ + 'title', + 'description', + 'servings_default', + 'servings_unit', + 'prep_time_min', + 'cook_time_min', + 'total_time_min', + 'cuisine', + 'category' + ] as const) { + if (patch[key] !== undefined) { + fields.push(`${key} = ?`); + values.push(patch[key]); + } + } + if (fields.length === 0) return; + fields.push('updated_at = CURRENT_TIMESTAMP'); + db.prepare(`UPDATE recipe SET ${fields.join(', ')} WHERE id = ?`).run(...values, id); +} + +export function replaceIngredients( + db: Database.Database, + recipeId: number, + ingredients: Ingredient[] +): void { + const tx = db.transaction(() => { + db.prepare('DELETE FROM ingredient WHERE recipe_id = ?').run(recipeId); + const ins = db.prepare( + `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + for (const ing of ingredients) { + ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text); + } + refreshFts(db, recipeId); + }); + tx(); +} + +export function replaceSteps( + db: Database.Database, + recipeId: number, + steps: Step[] +): void { + const tx = db.transaction(() => { + db.prepare('DELETE FROM step WHERE recipe_id = ?').run(recipeId); + const ins = db.prepare( + 'INSERT INTO step(recipe_id, position, text) VALUES (?, ?, ?)' + ); + for (const step of steps) { + ins.run(recipeId, step.position, step.text); + } + }); + tx(); +} diff --git a/src/routes/api/recipes/[id]/+server.ts b/src/routes/api/recipes/[id]/+server.ts index f4a027e..95773e2 100644 --- a/src/routes/api/recipes/[id]/+server.ts +++ b/src/routes/api/recipes/[id]/+server.ts @@ -2,7 +2,13 @@ import type { RequestHandler } from './$types'; import { json, error } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; -import { deleteRecipe, getRecipeById } from '$lib/server/recipes/repository'; +import { + deleteRecipe, + getRecipeById, + replaceIngredients, + replaceSteps, + updateRecipeMeta +} from '$lib/server/recipes/repository'; import { listComments, listCookingLog, @@ -11,14 +17,36 @@ import { setRecipeHiddenFromRecent } from '$lib/server/recipes/actions'; +const IngredientSchema = z.object({ + position: z.number().int().nonnegative(), + quantity: z.number().nullable(), + unit: z.string().max(30).nullable(), + name: z.string().min(1).max(200), + note: z.string().max(300).nullable(), + raw_text: z.string().max(500) +}); + +const StepSchema = z.object({ + position: z.number().int().positive(), + text: z.string().min(1).max(4000) +}); + const PatchSchema = z .object({ title: z.string().min(1).max(200).optional(), + description: z.string().max(2000).nullable().optional(), + servings_default: z.number().int().positive().nullable().optional(), + servings_unit: z.string().max(30).nullable().optional(), + prep_time_min: z.number().int().nonnegative().nullable().optional(), + cook_time_min: z.number().int().nonnegative().nullable().optional(), + total_time_min: z.number().int().nonnegative().nullable().optional(), + cuisine: z.string().max(60).nullable().optional(), + category: z.string().max(60).nullable().optional(), + ingredients: z.array(IngredientSchema).optional(), + steps: z.array(StepSchema).optional(), hidden_from_recent: z.boolean().optional() }) - .refine((v) => v.title !== undefined || v.hidden_from_recent !== undefined, { - message: 'Need title or hidden_from_recent' - }); + .refine((v) => Object.keys(v).length > 0, { message: 'Empty patch' }); function parseId(raw: string): number { const id = Number(raw); @@ -45,13 +73,51 @@ export const PATCH: RequestHandler = async ({ params, request }) => { const parsed = PatchSchema.safeParse(body); if (!parsed.success) error(400, { message: 'Invalid body' }); const db = getDb(); - if (parsed.data.title !== undefined) { - renameRecipe(db, id, parsed.data.title); + const p = parsed.data; + // Spezielle Kurz-Updates (bleiben als Sonderfall, weil sie FTS triggern + // bzw. andere Tabellen mitpflegen). + if (p.title !== undefined && Object.keys(p).length === 1) { + renameRecipe(db, id, p.title); + return json({ ok: true }); } - if (parsed.data.hidden_from_recent !== undefined) { - setRecipeHiddenFromRecent(db, id, parsed.data.hidden_from_recent); + if (p.hidden_from_recent !== undefined && Object.keys(p).length === 1) { + setRecipeHiddenFromRecent(db, id, p.hidden_from_recent); + return json({ ok: true }); } - return json({ ok: true }); + // Voller Edit-Modus-Patch. + const hasMeta = + p.title !== undefined || + p.description !== undefined || + p.servings_default !== undefined || + p.servings_unit !== undefined || + p.prep_time_min !== undefined || + p.cook_time_min !== undefined || + p.total_time_min !== undefined || + p.cuisine !== undefined || + p.category !== undefined; + if (hasMeta) { + updateRecipeMeta(db, id, { + title: p.title, + description: p.description, + servings_default: p.servings_default, + servings_unit: p.servings_unit, + prep_time_min: p.prep_time_min, + cook_time_min: p.cook_time_min, + total_time_min: p.total_time_min, + cuisine: p.cuisine, + category: p.category + }); + } + if (p.ingredients !== undefined) { + replaceIngredients(db, id, p.ingredients); + } + if (p.steps !== undefined) { + replaceSteps(db, id, p.steps); + } + if (p.hidden_from_recent !== undefined) { + setRecipeHiddenFromRecent(db, id, p.hidden_from_recent); + } + return json({ ok: true, recipe: getRecipeById(db, id) }); }; export const DELETE: RequestHandler = async ({ params }) => { diff --git a/src/routes/recipes/[id]/+page.svelte b/src/routes/recipes/[id]/+page.svelte index 793a447..e47d15c 100644 --- a/src/routes/recipes/[id]/+page.svelte +++ b/src/routes/recipes/[id]/+page.svelte @@ -14,6 +14,7 @@ LightbulbOff } from 'lucide-svelte'; import RecipeView from '$lib/components/RecipeView.svelte'; + import RecipeEditor from '$lib/components/RecipeEditor.svelte'; import StarRating from '$lib/components/StarRating.svelte'; import { profileStore } from '$lib/client/profile.svelte'; import { wishlistStore } from '$lib/client/wishlist.svelte'; @@ -35,6 +36,46 @@ let titleDraft = $state(''); let titleInput: HTMLInputElement | null = $state(null); + let editMode = $state(false); + let saving = $state(false); + let recipeState = $state(data.recipe); + + async function saveRecipe(patch: { + title: string; + description: string | null; + servings_default: number | null; + prep_time_min: number | null; + cook_time_min: number | null; + total_time_min: number | null; + ingredients: typeof data.recipe.ingredients; + steps: typeof data.recipe.steps; + }) { + saving = true; + try { + const res = await fetch(`/api/recipes/${data.recipe.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch) + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + await alertAction({ + title: 'Speichern fehlgeschlagen', + message: body.message ?? `HTTP ${res.status}` + }); + return; + } + const body = await res.json(); + if (body.recipe) { + recipeState = body.recipe; + title = body.recipe.title; + } + editMode = false; + } finally { + saving = false; + } + } + $effect(() => { ratings = [...data.ratings]; comments = [...data.comments]; @@ -42,6 +83,7 @@ favoriteProfileIds = [...data.favorite_profile_ids]; wishlistProfileIds = [...data.wishlist_profile_ids]; title = data.recipe.title; + recipeState = data.recipe; }); const myRating = $derived( @@ -290,7 +332,15 @@ }); - +{#if editMode} + (editMode = false)} + /> +{:else} + {#snippet titleSlot()}
{#if editingTitle} @@ -369,6 +419,10 @@ Bildschirm aus {/if} +
{/snippet}
+{/if}

Kommentare

diff --git a/tests/integration/recipe-repository.test.ts b/tests/integration/recipe-repository.test.ts index 620da32..6cd0ebd 100644 --- a/tests/integration/recipe-repository.test.ts +++ b/tests/integration/recipe-repository.test.ts @@ -7,7 +7,10 @@ import { insertRecipe, getRecipeById, getRecipeIdBySourceUrl, - deleteRecipe + deleteRecipe, + updateRecipeMeta, + replaceIngredients, + replaceSteps } from '../../src/lib/server/recipes/repository'; import { extractRecipeFromHtml } from '../../src/lib/server/parsers/json-ld-recipe'; import type { Recipe } from '../../src/lib/types'; @@ -97,4 +100,58 @@ describe('recipe repository', () => { deleteRecipe(db, id); expect(getRecipeById(db, id)).toBeNull(); }); + + it('updateRecipeMeta patches only supplied fields', () => { + const db = openInMemoryForTest(); + const id = insertRecipe(db, baseRecipe({ title: 'A', prep_time_min: 10 })); + updateRecipeMeta(db, id, { description: 'neu', prep_time_min: 15 }); + const loaded = getRecipeById(db, id); + expect(loaded?.title).toBe('A'); // unverändert + expect(loaded?.description).toBe('neu'); + expect(loaded?.prep_time_min).toBe(15); + }); + + it('replaceIngredients swaps full list and rebuilds FTS', () => { + const db = openInMemoryForTest(); + const id = insertRecipe( + db, + baseRecipe({ + title: 'Pasta', + ingredients: [ + { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta' } + ] + }) + ); + replaceIngredients(db, id, [ + { position: 1, quantity: 500, unit: 'g', name: 'Nudeln', note: null, raw_text: '500 g Nudeln' }, + { position: 2, quantity: 2, unit: null, name: 'Eier', note: null, raw_text: '2 Eier' } + ]); + const loaded = getRecipeById(db, id); + expect(loaded?.ingredients.length).toBe(2); + expect(loaded?.ingredients[0].name).toBe('Nudeln'); + // FTS index should reflect new ingredient + const hit = db + .prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'nudeln'") + .all(); + expect(hit.length).toBe(1); + }); + + it('replaceSteps swaps full list', () => { + const db = openInMemoryForTest(); + const id = insertRecipe( + db, + baseRecipe({ + title: 'S', + steps: [ + { position: 1, text: 'Alt' } + ] + }) + ); + replaceSteps(db, id, [ + { position: 1, text: 'Erst' }, + { position: 2, text: 'Dann' } + ]); + const loaded = getRecipeById(db, id); + expect(loaded?.steps.map((s) => s.text)).toEqual(['Erst', 'Dann']); + }); });