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
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:
@@ -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 }) => {
|
||||
|
||||
@@ -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 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<RecipeView recipe={data.recipe}>
|
||||
{#if editMode}
|
||||
<RecipeEditor
|
||||
recipe={recipeState}
|
||||
{saving}
|
||||
onsave={saveRecipe}
|
||||
oncancel={() => (editMode = false)}
|
||||
/>
|
||||
{:else}
|
||||
<RecipeView recipe={recipeState}>
|
||||
{#snippet titleSlot()}
|
||||
<div class="title-row">
|
||||
{#if editingTitle}
|
||||
@@ -369,6 +419,10 @@
|
||||
<span>Bildschirm aus</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="btn" onclick={() => (editMode = true)}>
|
||||
<Pencil size={18} strokeWidth={2} />
|
||||
<span>Bearbeiten</span>
|
||||
</button>
|
||||
<button class="btn danger" onclick={deleteRecipe}>
|
||||
<Trash2 size={18} strokeWidth={2} />
|
||||
<span>Löschen</span>
|
||||
@@ -377,6 +431,7 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
</RecipeView>
|
||||
{/if}
|
||||
|
||||
<section class="comments">
|
||||
<h2>Kommentare</h2>
|
||||
|
||||
Reference in New Issue
Block a user