feat(recipe): Bild manuell hochladen / ersetzen / entfernen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m22s

- Neuer Endpoint POST/DELETE /api/recipes/:id/image.
  * Multipart-Upload mit Feld "file".
  * Whitelist: JPG, PNG, WebP, GIF, AVIF. Max 10 MB.
  * Dedupe per SHA-256-Filename analog zu downloadImage().
- updateImagePath()-Repo-Funktion ergänzt.
- RecipeEditor: neuer Block "Bild" ganz oben. Preview + Buttons
  "Hochladen"/"Ersetzen"/"Entfernen". Upload passiert direkt beim
  Auswählen, nicht erst bei "Speichern" — das Bild ist eigene
  Ressource, Abbrechen rollt es nicht zurück (okay, da dedupliziert).
- onimagechange-Callback informiert die Detail-Ansicht, damit die
  Preview im RecipeView auch nach Abbrechen aktuell bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-18 21:39:54 +02:00
parent aaaf762564
commit 5283ab9b51
4 changed files with 256 additions and 2 deletions

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-svelte'; import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, ImageOff } from 'lucide-svelte';
import type { Recipe, Ingredient, Step } from '$lib/types'; 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 = { type Props = {
recipe: Recipe; recipe: Recipe;
@@ -16,9 +18,81 @@
steps: Step[]; steps: Step[];
}) => void | Promise<void>; }) => void | Promise<void>;
oncancel: () => 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 }: Props = $props(); 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 title = $state(recipe.title);
let description = $state(recipe.description ?? ''); let description = $state(recipe.description ?? '');
@@ -117,6 +191,52 @@
</script> </script>
<div class="editor"> <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"> <div class="meta">
<label class="field"> <label class="field">
<span class="lbl">Titel</span> <span class="lbl">Titel</span>
@@ -278,6 +398,67 @@
border-radius: 12px; border-radius: 12px;
padding: 1rem; 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 { .block h2 {
font-size: 1.05rem; font-size: 1.05rem;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;

View File

@@ -196,6 +196,17 @@ export function updateRecipeMeta(
db.prepare(`UPDATE recipe SET ${fields.join(', ')} WHERE id = ?`).run(...values, id); db.prepare(`UPDATE recipe SET ${fields.join(', ')} WHERE id = ?`).run(...values, id);
} }
export function updateImagePath(
db: Database.Database,
id: number,
filename: string | null
): void {
db.prepare('UPDATE recipe SET image_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
filename,
id
);
}
export function replaceIngredients( export function replaceIngredients(
db: Database.Database, db: Database.Database,
recipeId: number, recipeId: number,

View File

@@ -0,0 +1,61 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getDb } from '$lib/server/db';
import { getRecipeById, updateImagePath } from '$lib/server/recipes/repository';
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
const MAX_BYTES = 10 * 1024 * 1024;
const EXT_BY_MIME: Record<string, string> = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
'image/avif': '.avif'
};
function parseId(raw: string): number {
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
return id;
}
export const POST: RequestHandler = async ({ params, request }) => {
const id = parseId(params.id!);
const db = getDb();
if (!getRecipeById(db, id)) error(404, { message: 'Recipe not found' });
const form = await request.formData().catch(() => null);
const file = form?.get('file');
if (!(file instanceof File)) error(400, { message: 'Feld "file" fehlt' });
if (file.size === 0) error(400, { message: 'Leere Datei' });
if (file.size > MAX_BYTES) error(413, { message: 'Bild zu groß (max. 10 MB)' });
const mime = file.type.toLowerCase();
const ext = EXT_BY_MIME[mime];
if (!ext) error(415, { message: `Bildformat ${file.type || 'unbekannt'} nicht unterstützt` });
const buf = Buffer.from(await file.arrayBuffer());
const hash = createHash('sha256').update(buf).digest('hex');
const filename = `${hash}${ext}`;
const target = join(IMAGE_DIR, filename);
if (!existsSync(target)) {
await mkdir(IMAGE_DIR, { recursive: true });
await writeFile(target, buf);
}
updateImagePath(db, id, filename);
return json({ ok: true, image_path: filename });
};
export const DELETE: RequestHandler = ({ params }) => {
const id = parseId(params.id!);
const db = getDb();
if (!getRecipeById(db, id)) error(404, { message: 'Recipe not found' });
updateImagePath(db, id, null);
return json({ ok: true });
};

View File

@@ -379,6 +379,7 @@
{saving} {saving}
onsave={saveRecipe} onsave={saveRecipe}
oncancel={() => (editMode = false)} oncancel={() => (editMode = false)}
onimagechange={(path) => (recipeState = { ...recipeState, image_path: path })}
/> />
{:else} {:else}
<RecipeView recipe={recipeState}> <RecipeView recipe={recipeState}>