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
+
+
+
+
+
+ Zubereitung
+
+ {#each steps as step, idx (idx)}
+ -
+ {idx + 1}
+
+
+
+ {/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']); + }); });