Files
kochwas/src/lib/components/RecipeEditor.svelte

343 lines
9.3 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { untrack } from 'svelte';
import { Plus } from 'lucide-svelte';
import type { Recipe, Ingredient, Step } from '$lib/types';
import ImageUploadBox from '$lib/components/ImageUploadBox.svelte';
import IngredientRow from '$lib/components/IngredientRow.svelte';
import StepList from '$lib/components/StepList.svelte';
import type { DraftIng, DraftStep } from '$lib/components/recipe-editor-types';
type Props = {
recipe: Recipe;
saving?: boolean;
onsave: (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: Ingredient[];
steps: Step[];
}) => void | Promise<void>;
oncancel: () => void;
/** Fires whenever the image was uploaded or removed — separate from save,
* because the image is its own endpoint and persists immediately. */
onimagechange?: (image_path: string | null) => void;
};
let { recipe, saving = false, onsave, oncancel, onimagechange }: Props = $props();
// Form-lokaler Zustand: Initialwerte aus dem Prop snapshotten (untrack),
// damit User-Edits nicht von prop-Updates ueberschrieben werden.
let title = $state(untrack(() => recipe.title));
let description = $state(untrack(() => recipe.description ?? ''));
let servings = $state<number | ''>(untrack(() => recipe.servings_default ?? ''));
let prepMin = $state<number | ''>(untrack(() => recipe.prep_time_min ?? ''));
let cookMin = $state<number | ''>(untrack(() => recipe.cook_time_min ?? ''));
let totalMin = $state<number | ''>(untrack(() => recipe.total_time_min ?? ''));
let ingredients = $state<DraftIng[]>(
untrack(() =>
recipe.ingredients.map((i) => ({
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
unit: i.unit ?? '',
name: i.name,
note: i.note ?? '',
section_heading: i.section_heading
}))
)
);
let steps = $state<DraftStep[]>(
untrack(() => recipe.steps.map((s) => ({ text: s.text })))
);
function addIngredient() {
ingredients = [...ingredients, { qty: '', unit: '', name: '', note: '', section_heading: null }];
}
function removeIngredient(idx: number) {
ingredients = ingredients.filter((_, i) => i !== idx);
}
function moveIngredient(idx: number, dir: -1 | 1) {
const target = idx + dir;
if (target < 0 || target >= ingredients.length) return;
const next = [...ingredients];
[next[idx], next[target]] = [next[target], next[idx]];
ingredients = next;
}
function addSection(idx: number) {
const next = [...ingredients];
next[idx] = { ...next[idx], section_heading: '' };
ingredients = next;
}
function removeSection(idx: number) {
const next = [...ingredients];
next[idx] = { ...next[idx], section_heading: null };
ingredients = next;
}
function addStep() {
steps = [...steps, { text: '' }];
}
function removeStep(idx: number) {
steps = steps.filter((_, i) => i !== idx);
}
function parseQty(raw: string): number | null {
const cleaned = raw.trim().replace(',', '.');
if (!cleaned) return null;
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
}
function toNumOrNull(v: number | ''): number | null {
return v === '' ? null : v;
}
async function save() {
const cleanedIngredients: Ingredient[] = ingredients
.filter((i) => i.name.trim())
.map((i, idx) => {
const qty = parseQty(i.qty);
const unit = i.unit.trim() || null;
const name = i.name.trim();
const note = i.note.trim() || null;
const rawParts: string[] = [];
if (qty !== null) rawParts.push(String(qty).replace('.', ','));
if (unit) rawParts.push(unit);
rawParts.push(name);
const heading = i.section_heading === null ? null : (i.section_heading.trim() || null);
return {
position: idx + 1,
quantity: qty,
unit,
name,
note,
raw_text: rawParts.join(' '),
section_heading: heading
};
});
const cleanedSteps: Step[] = steps
.filter((s) => s.text.trim())
.map((s, idx) => ({ position: idx + 1, text: s.text.trim() }));
await onsave({
title: title.trim() || recipe.title,
description: description.trim() || null,
servings_default: toNumOrNull(servings),
prep_time_min: toNumOrNull(prepMin),
cook_time_min: toNumOrNull(cookMin),
total_time_min: toNumOrNull(totalMin),
ingredients: cleanedIngredients,
steps: cleanedSteps
});
}
</script>
<div class="editor">
{#if recipe.id !== null}
<section class="block">
<h2>Bild</h2>
<ImageUploadBox
recipeId={recipe.id}
imagePath={recipe.image_path}
onchange={(p) => onimagechange?.(p)}
/>
</section>
{:else}
<section class="block info">
<p class="hint">Bild kannst du nach dem Speichern hinzufügen.</p>
</section>
{/if}
<div class="meta">
<label class="field">
<span class="lbl">Titel</span>
<input type="text" bind:value={title} placeholder="Rezeptname" />
</label>
<label class="field">
<span class="lbl">Beschreibung</span>
<textarea bind:value={description} rows="2" placeholder="Kurze Beschreibung (optional)"></textarea>
</label>
<div class="row">
<label class="field small">
<span class="lbl">Portionen</span>
<input
type="number"
min="1"
bind:value={servings}
placeholder="—"
/>
</label>
<label class="field small">
<span class="lbl">Vorb. (min)</span>
<input type="number" min="0" bind:value={prepMin} placeholder="—" />
</label>
<label class="field small">
<span class="lbl">Kochen (min)</span>
<input type="number" min="0" bind:value={cookMin} placeholder="—" />
</label>
<label class="field small">
<span class="lbl">Gesamt (min)</span>
<input type="number" min="0" bind:value={totalMin} placeholder="—" />
</label>
</div>
</div>
<section class="block">
<h2>Zutaten</h2>
<ul class="ing-list">
{#each ingredients as ing, idx (idx)}
<IngredientRow
{ing}
{idx}
total={ingredients.length}
onmove={(dir) => moveIngredient(idx, dir)}
onremove={() => removeIngredient(idx)}
onaddSection={() => addSection(idx)}
onremoveSection={() => removeSection(idx)}
/>
{/each}
</ul>
<button class="add" type="button" onclick={addIngredient}>
<Plus size={16} strokeWidth={2} />
<span>Zutat hinzufügen</span>
</button>
</section>
<section class="block">
<h2>Zubereitung</h2>
<StepList {steps} onadd={addStep} onremove={removeStep} />
</section>
<div class="foot">
<button class="btn ghost" type="button" onclick={oncancel} disabled={saving}>
Abbrechen
</button>
<button class="btn primary" type="button" onclick={save} disabled={saving}>
{saving ? 'Speichere …' : 'Speichern'}
</button>
</div>
</div>
<style>
.editor {
display: flex;
flex-direction: column;
gap: 1rem;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.75rem;
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
padding: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.lbl {
font-size: 0.8rem;
color: #666;
font-weight: 600;
}
.field input,
.field textarea {
padding: 0.55rem 0.7rem;
font-size: 0.95rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-family: inherit;
background: white;
min-height: 40px;
}
.field textarea {
resize: vertical;
}
.row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.small {
flex: 1;
min-width: 100px;
}
.block {
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
padding: 1rem;
}
.block h2 {
font-size: 1.05rem;
margin: 0 0 0.75rem;
color: #2b6a3d;
}
.block.info {
background: #f6faf7;
border: 1px dashed #cfd9d1;
}
.hint {
color: #666;
margin: 0;
font-size: 0.9rem;
}
.ing-list {
list-style: none;
padding: 0;
margin: 0 0 0.6rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 0.9rem;
border: 1px dashed #cfd9d1;
background: white;
color: #2b6a3d;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
}
.add:hover {
background: #f4f8f5;
}
.foot {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding-top: 0.5rem;
}
.btn {
padding: 0.7rem 1.25rem;
border-radius: 10px;
border: 1px solid #cfd9d1;
background: white;
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
min-height: 44px;
}
.btn.ghost {
color: #666;
}
.btn.primary {
background: #2b6a3d;
color: white;
border-color: #2b6a3d;
font-weight: 600;
}
.btn:disabled {
opacity: 0.6;
cursor: progress;
}
</style>