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:
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 });
|
||||
};
|
||||
Reference in New Issue
Block a user