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

631 lines
16 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, ImageOff } from 'lucide-svelte';
import type { Recipe, Ingredient, Step } from '$lib/types';
import { alertAction, confirmAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
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();
let imagePath = $state<string | null>(recipe.image_path);
let uploading = $state(false);
let fileInput: HTMLInputElement | null = $state(null);
const imageSrc = $derived(
imagePath === null
? null
: /^https?:\/\//i.test(imagePath)
? imagePath
: `/images/${imagePath}`
);
async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!requireOnline('Der Bild-Upload')) return;
uploading = true;
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`/api/recipes/${recipe.id}/image`, {
method: 'POST',
body: fd
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
await alertAction({
title: 'Upload fehlgeschlagen',
message: body.message ?? `HTTP ${res.status}`
});
return;
}
const body = await res.json();
imagePath = body.image_path;
onimagechange?.(imagePath);
} finally {
uploading = false;
}
}
async function removeImage() {
if (imagePath === null) return;
const ok = await confirmAction({
title: 'Bild entfernen?',
message: 'Das Rezept wird danach ohne Titelbild angezeigt.',
confirmLabel: 'Entfernen',
destructive: true
});
if (!ok) return;
if (!requireOnline('Das Entfernen')) return;
uploading = true;
try {
const res = await fetch(`/api/recipes/${recipe.id}/image`, { method: 'DELETE' });
if (!res.ok) {
await alertAction({
title: 'Entfernen fehlgeschlagen',
message: `HTTP ${res.status}`
});
return;
}
imagePath = null;
onimagechange?.(null);
} finally {
uploading = false;
}
}
let title = $state(recipe.title);
let description = $state(recipe.description ?? '');
let servings = $state<number | ''>(recipe.servings_default ?? '');
let prepMin = $state<number | ''>(recipe.prep_time_min ?? '');
let cookMin = $state<number | ''>(recipe.cook_time_min ?? '');
let totalMin = $state<number | ''>(recipe.total_time_min ?? '');
type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
};
type DraftStep = { text: string };
let ingredients = $state<DraftIng[]>(
recipe.ingredients.map((i) => ({
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
unit: i.unit ?? '',
name: i.name,
note: i.note ?? ''
}))
);
let steps = $state<DraftStep[]>(
recipe.steps.map((s) => ({ text: s.text }))
);
function addIngredient() {
ingredients = [...ingredients, { qty: '', unit: '', name: '', note: '' }];
}
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 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);
return {
position: idx + 1,
quantity: qty,
unit,
name,
note,
raw_text: rawParts.join(' ')
};
});
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">
<section class="block image-block">
<h2>Bild</h2>
<div class="image-row">
<div class="image-preview" class:empty={!imageSrc}>
{#if imageSrc}
<img src={imageSrc} alt="" />
{:else}
<span class="placeholder">Kein Bild</span>
{/if}
</div>
<div class="image-actions">
<button
class="btn"
type="button"
onclick={() => fileInput?.click()}
disabled={uploading}
>
<ImagePlus size={16} strokeWidth={2} />
<span>{imagePath ? 'Bild ersetzen' : 'Bild hochladen'}</span>
</button>
{#if imagePath}
<button
class="btn ghost"
type="button"
onclick={removeImage}
disabled={uploading}
>
<ImageOff size={16} strokeWidth={2} />
<span>Entfernen</span>
</button>
{/if}
{#if uploading}
<span class="upload-status">Lade …</span>
{/if}
</div>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
class="file-input"
onchange={onFileChosen}
/>
</div>
<p class="image-hint">Max. 10 MB. JPG, PNG, WebP, GIF oder AVIF.</p>
</section>
<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)}
<li class="ing-row">
<div class="move">
<button
class="move-btn"
type="button"
aria-label="Zutat nach oben"
disabled={idx === 0}
onclick={() => moveIngredient(idx, -1)}
>
<ChevronUp size={14} strokeWidth={2.5} />
</button>
<button
class="move-btn"
type="button"
aria-label="Zutat nach unten"
disabled={idx === ingredients.length - 1}
onclick={() => moveIngredient(idx, 1)}
>
<ChevronDown size={14} strokeWidth={2.5} />
</button>
</div>
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
<button class="del" type="button" aria-label="Zutat entfernen" onclick={() => removeIngredient(idx)}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
{/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>
<ol class="step-list">
{#each steps as step, idx (idx)}
<li class="step-row">
<span class="num">{idx + 1}</span>
<textarea
bind:value={step.text}
rows="3"
placeholder="Schritt beschreiben …"
></textarea>
<button class="del" type="button" aria-label="Schritt entfernen" onclick={() => removeStep(idx)}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
{/each}
</ol>
<button class="add" type="button" onclick={addStep}>
<Plus size={16} strokeWidth={2} />
<span>Schritt hinzufügen</span>
</button>
</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;
}
.image-row {
display: flex;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.image-preview {
width: 160px;
aspect-ratio: 16 / 10;
border-radius: 10px;
overflow: hidden;
background: #eef3ef;
border: 1px solid #e4eae7;
flex-shrink: 0;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-preview.empty {
display: grid;
place-items: center;
color: #999;
font-size: 0.85rem;
}
.image-preview .placeholder {
padding: 0 0.5rem;
text-align: center;
}
.image-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.image-actions .btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.85rem;
min-height: 40px;
font-size: 0.9rem;
}
.upload-status {
color: #666;
font-size: 0.9rem;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.image-hint {
margin: 0.6rem 0 0;
color: #888;
font-size: 0.8rem;
}
.block h2 {
font-size: 1.05rem;
margin: 0 0 0.75rem;
color: #2b6a3d;
}
.ing-list,
.step-list {
list-style: none;
padding: 0;
margin: 0 0 0.6rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ing-row {
display: grid;
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
gap: 0.35rem;
align-items: center;
}
.move {
display: flex;
flex-direction: column;
gap: 2px;
}
.move-btn {
width: 28px;
height: 20px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 6px;
cursor: pointer;
color: #555;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.move-btn:hover:not(:disabled) {
background: #f4f8f5;
}
.move-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ing-row input {
padding: 0.5rem 0.55rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.9rem;
min-height: 38px;
font-family: inherit;
min-width: 0;
}
.step-row {
display: grid;
grid-template-columns: 32px 1fr 40px;
gap: 0.5rem;
align-items: start;
}
.num {
width: 32px;
height: 32px;
background: #2b6a3d;
color: white;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 600;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.step-row textarea {
padding: 0.55rem 0.7rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.95rem;
font-family: inherit;
resize: vertical;
min-height: 70px;
}
.del {
width: 40px;
height: 40px;
border: 1px solid #f1b4b4;
background: white;
color: #c53030;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.del:hover {
background: #fdf3f3;
}
.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;
}
@media (max-width: 560px) {
.ing-row {
grid-template-columns: 28px 70px 1fr 40px;
grid-template-areas:
'move qty name del'
'move unit unit del'
'note note note note';
}
.ing-row .move {
grid-area: move;
}
.ing-row .qty {
grid-area: qty;
}
.ing-row .unit {
grid-area: unit;
}
.ing-row .name {
grid-area: name;
}
.ing-row .note {
grid-area: note;
}
.ing-row .del {
grid-area: del;
}
}
</style>