feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-18 12:41:10 +02:00
parent ee783ff50b
commit 9a5c626890
5 changed files with 668 additions and 11 deletions

View File

@@ -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 }) => {