feat(recipe): Bild manuell hochladen / ersetzen / entfernen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m22s
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:
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
61
src/routes/api/recipes/[id]/image/+server.ts
Normal file
61
src/routes/api/recipes/[id]/image/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user