diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index ec263d4..6511503 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -27,14 +27,16 @@ export function initDatabase() { console.log('🔧 Initializing database...'); try { - // Check if users table exists (acts as a sentinel for initial setup) + // Nur zur Protokollierung - angelegt wird immer, siehe unten const tableCheck = sqlite .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") .get(); - if (!tableCheck) { - console.log('📝 Creating database schema...'); + console.log(tableCheck ? '📝 Checking database schema...' : '📝 Creating database schema...'); + // Laeuft bei JEDEM Start. Alle Anweisungen sind IF NOT EXISTS, und nur so + // bekommen bestehende Datenbanken spaeter ergaenzte Tabellen ueberhaupt. + { sqlite.exec(` PRAGMA foreign_keys = ON; @@ -84,6 +86,11 @@ export function initDatabase() { updated_at INTEGER DEFAULT (unixepoch()) ); + CREATE TABLE IF NOT EXISTS managed_assets ( + path TEXT PRIMARY KEY, + created_at INTEGER DEFAULT (unixepoch()) + ); + CREATE TABLE IF NOT EXISTS publish_history ( id TEXT PRIMARY KEY, user_id TEXT REFERENCES users(id), @@ -93,9 +100,7 @@ export function initDatabase() { ); `); - console.log('✅ Database schema created successfully!'); - } else { - console.log('✅ Database already initialized.'); + console.log('✅ Database schema is up to date.'); } } catch (error) { console.error('❌ Error initializing database:', error); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index a8c1532..1833c15 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -60,6 +60,18 @@ export const publishHistory = sqliteTable('publish_history', { publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`), }); +// Vom CMS selbst angelegte Dateien. +// +// Frueher wurde am Dateinamen erkannt, ob das CMS eine Datei loeschen darf. +// Seit die Namen aus Alt-Text bzw. Original-Dateiname abgeleitet werden, +// traegt der Name diese Information nicht mehr - ein hochgeladenes +// karaoke-abend.avif sieht aus wie ein handgepflegtes event_karaoke.jpg. +// Deshalb wird der Besitz hier festgehalten. +export const managedAssets = sqliteTable('managed_assets', { + path: text('path').primaryKey(), // z.B. /images/events/karaoke-abend.avif + createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`), +}); + // Banner table (for announcements like holidays, special info) export const banners = sqliteTable('banners', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), diff --git a/backend/src/routes/content.ts b/backend/src/routes/content.ts index 38a292f..ba5478a 100644 --- a/backend/src/routes/content.ts +++ b/backend/src/routes/content.ts @@ -116,7 +116,12 @@ const contentRoute: FastifyPluginAsync = async (fastify) => { return reply.code(400).send({ error: 'Only image uploads are allowed' }); } - const saved = await saveUploadedImage(file, 'content', fastify.log); + const preferredName = (file.fields?.name?.value as string | undefined) || ''; + + const saved = await saveUploadedImage(file, 'content', { + preferredName, + log: fastify.log, + }); return reply.code(201).send({ imageUrl: saved.imageUrl }); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 881078e..03b1a8b 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -112,7 +112,14 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => { return reply.code(400).send({ error: 'Only image uploads are allowed' }); } - const saved = await saveUploadedImage(file, 'events', fastify.log); + // Der Titel kommt als Formularfeld VOR der Datei, sonst ist er hier + // noch nicht geparst + const preferredName = (file.fields?.title?.value as string | undefined) || ''; + + const saved = await saveUploadedImage(file, 'events', { + preferredName, + log: fastify.log, + }); return reply.code(201).send({ imageUrl: saved.imageUrl }); diff --git a/backend/src/routes/gallery.ts b/backend/src/routes/gallery.ts index 97fd179..c0a4a02 100644 --- a/backend/src/routes/gallery.ts +++ b/backend/src/routes/gallery.ts @@ -95,7 +95,10 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { return reply.code(400).send({ error: 'Only image uploads are allowed' }); } - const saved = await saveUploadedImage(file, 'gallery', fastify.log); + const saved = await saveUploadedImage(file, 'gallery', { + preferredName: altText, + log: fastify.log, + }); // Store in DB (optional but useful) const [row] = await db.insert(galleryImages).values({ diff --git a/backend/src/routes/pdf.ts b/backend/src/routes/pdf.ts index 1ee4e21..f3913a0 100644 --- a/backend/src/routes/pdf.ts +++ b/backend/src/routes/pdf.ts @@ -1,10 +1,10 @@ import { FastifyPluginAsync } from 'fastify'; -import fs from 'fs'; -import path from 'path'; import { eq } from 'drizzle-orm'; import { db } from '../config/database.js'; import { contentSections } from '../db/schema.js'; import { AssetService } from '../services/asset.service.js'; +import { isManagedAsset, forgetManagedAsset } from '../services/managed-assets.service.js'; +import { saveUploadedPdf } from '../services/upload.service.js'; import { env } from '../config/env.js'; const assets = new AssetService(); @@ -72,15 +72,7 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => { return reply.code(400).send({ error: 'File is not a valid PDF' }); } - const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf'); - fs.mkdirSync(uploadDir, { recursive: true }); - - const stamp = Date.now().toString(36); - const rand = Math.random().toString(36).slice(2, 8); - const filename = `${stamp}-${rand}.pdf`; - fs.writeFileSync(path.join(uploadDir, filename), buffer); - - const pdfUrl = `/pdf/${filename}`; + const pdfUrl = await saveUploadedPdf(file, buffer); // URL in der Content-Section hinterlegen const [existing] = await db @@ -106,11 +98,12 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => { // Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs, // die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen - if (previousUrl && previousUrl !== pdfUrl) { + if (previousUrl && previousUrl !== pdfUrl && (await isManagedAsset(previousUrl))) { try { if (assets.deletePdf(previousUrl)) { fastify.log.info(`Removed replaced PDF ${previousUrl}`); } + await forgetManagedAsset(previousUrl); } catch (err) { fastify.log.warn({ err }, 'Could not remove replaced PDF'); } diff --git a/backend/src/routes/publish.ts b/backend/src/routes/publish.ts index 0c7d145..4406dc5 100644 --- a/backend/src/routes/publish.ts +++ b/backend/src/routes/publish.ts @@ -2,8 +2,7 @@ import { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { GitService } from '../services/git.service.js'; import { FileGeneratorService } from '../services/file-generator.service.js'; -import { AssetService } from '../services/asset.service.js'; -import { collectReferencedImageUrls } from '../services/image-refs.service.js'; +import { sweepOrphanedImages } from '../services/image-refs.service.js'; import { db } from '../config/database.js'; import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js'; import { eq } from 'drizzle-orm'; @@ -36,12 +35,8 @@ const publishRoute: FastifyPluginAsync = async (fastify) => { fastify.log.info('Git repository initialized'); - // Verwaiste Uploads entfernen, bevor committet wird. Die Referenzliste - // deckt bewusst alle Zeilen ab, auch unveroeffentlichte, und zusaetzlich - // die Bildpfade aus den Textbereichen. - const assetService = new AssetService(); - const referencedImages = await collectReferencedImageUrls(); - const removedImages = assetService.sweepOrphanedImages([...referencedImages]); + // Verwaiste Uploads entfernen, bevor committet wird + const removedImages = await sweepOrphanedImages(); if (removedImages.length > 0) { fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`); diff --git a/backend/src/services/asset.service.ts b/backend/src/services/asset.service.ts index 60ef2fa..d27e5f6 100644 --- a/backend/src/services/asset.service.ts +++ b/backend/src/services/asset.service.ts @@ -3,21 +3,16 @@ import path from 'path'; import { env } from '../config/env.js'; /** - * Verwaltet die Dateien, die das CMS in den Git-Workspace schreibt. + * Kennt die Verzeichnisse, in die das CMS schreibt, und sorgt dafuer, dass + * kein Pfad ausserhalb davon angefasst wird. * - * Grundregel: geloescht wird ausschliesslich, was das CMS selbst angelegt hat. - * Uploads bekommen den Namen -<6 zufaellige zeichen>. - * (siehe events.ts / gallery.ts). Handgepflegte Assets aus dem Repo heissen - * anders - event_karaoke.jpg, Welcome.png, Gallery1.webp - und werden dadurch - * nie angefasst, auch wenn sie in keinem Datensatz mehr vorkommen. + * Ob eine konkrete Datei geloescht werden DARF, entscheidet diese Klasse + * bewusst nicht - das steht in managed-assets.service.ts. */ -// Unterordner unterhalb von public/, in denen das CMS aufraeumen darf -const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content']; -const MANAGED_PDF_DIR = 'pdf'; - -// Namensmuster der CMS-Uploads -const GENERATED_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i; +// Unterordner unterhalb von public/ +export const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content']; +export const MANAGED_PDF_DIR = 'pdf'; export class AssetService { private publicDir: string; @@ -26,7 +21,7 @@ export class AssetService { this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public'); } - /** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb */ + /** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb. */ private resolveInPublic(url: string): string | null { if (!url || typeof url !== 'string' || !url.startsWith('/')) return null; @@ -41,31 +36,31 @@ export class AssetService { return absolute; } - /** - * Pfad einer Datei, die das CMS loeschen darf. - * Liefert null fuer fremde Pfade und fuer handgepflegte Dateien. - */ - resolveDeletable(url: string, dirs: string[]): string | null { + /** Absoluter Pfad, sofern die Datei in einem der erlaubten Ordner liegt. */ + resolveInDirs(url: string, dirs: string[]): string | null { const absolute = this.resolveInPublic(url); if (!absolute) return null; const relative = path.relative(this.publicDir, absolute); const dir = path.dirname(relative).split(path.sep).join('/'); - if (!dirs.includes(dir)) return null; - if (!GENERATED_NAME.test(path.basename(absolute))) return null; - - return absolute; + return dirs.includes(dir) ? absolute : null; } - /** Loescht ein hochgeladenes Bild. Gibt zurueck, ob wirklich etwas weg ist. */ + /** Existiert die Datei bereits? Fuer die Namensvergabe. */ + exists(url: string, dirs: string[]): boolean { + const absolute = this.resolveInDirs(url, dirs); + return absolute ? fs.existsSync(absolute) : false; + } + + /** Loescht eine Bilddatei. Die Besitzfrage muss vorher geklaert sein. */ deleteImage(url: string): boolean { - return this.unlink(this.resolveDeletable(url, MANAGED_IMAGE_DIRS)); + return this.unlink(this.resolveInDirs(url, MANAGED_IMAGE_DIRS)); } - /** Loescht ein hochgeladenes PDF. */ + /** Loescht ein PDF. Die Besitzfrage muss vorher geklaert sein. */ deletePdf(url: string): boolean { - return this.unlink(this.resolveDeletable(url, [MANAGED_PDF_DIR])); + return this.unlink(this.resolveInDirs(url, [MANAGED_PDF_DIR])); } private unlink(absolute: string | null): boolean { @@ -79,19 +74,9 @@ export class AssetService { } } - /** - * Entfernt alle hochgeladenen Bilder, die in keiner der uebergebenen URLs - * mehr vorkommen. Die Liste muss ALLE Datensaetze abdecken, auch - * unveroeffentlichte - sonst verlieren die ihr Bild. - */ - sweepOrphanedImages(referencedUrls: string[]): string[] { - const keep = new Set(); - for (const url of referencedUrls) { - const absolute = this.resolveInPublic(url); - if (absolute) keep.add(absolute); - } - - const removed: string[] = []; + /** Alle Bilddateien, die in den verwalteten Ordnern liegen, als URL-Pfade. */ + listImageFiles(): string[] { + const found: string[] = []; for (const dir of MANAGED_IMAGE_DIRS) { const absoluteDir = path.join(this.publicDir, dir); @@ -99,16 +84,11 @@ export class AssetService { for (const name of fs.readdirSync(absoluteDir)) { const absolute = path.join(absoluteDir, name); - - if (!GENERATED_NAME.test(name)) continue; - if (keep.has(absolute)) continue; if (!fs.statSync(absolute).isFile()) continue; - - fs.unlinkSync(absolute); - removed.push('/' + dir + '/' + name); + found.push(`/${dir}/${name}`); } } - return removed; + return found; } } diff --git a/backend/src/services/image-refs.service.ts b/backend/src/services/image-refs.service.ts index a4c9b9a..875e80a 100644 --- a/backend/src/services/image-refs.service.ts +++ b/backend/src/services/image-refs.service.ts @@ -1,18 +1,45 @@ import { db } from '../config/database.js'; import { events, galleryImages, contentSections } from '../db/schema.js'; import { AssetService } from './asset.service.js'; +import { isManagedAsset, forgetManagedAsset } from './managed-assets.service.js'; const assets = new AssetService(); /** - * Loescht eine Bilddatei, sofern sie von keinem Datensatz mehr benutzt wird. - * Muss NACH dem Loeschen bzw. Aktualisieren der Zeile aufgerufen werden. + * Loescht eine Bilddatei, sofern das CMS sie selbst angelegt hat und kein + * Datensatz sie mehr benutzt. Muss NACH dem Loeschen bzw. Aktualisieren der + * Zeile aufgerufen werden. */ export async function dropImageIfUnused(url: string | null | undefined): Promise { if (!url) return false; + if (!(await isManagedAsset(url))) return false; + const referenced = await collectReferencedImageUrls(); if (referenced.has(url)) return false; - return assets.deleteImage(url); + + const deleted = assets.deleteImage(url); + await forgetManagedAsset(url); + return deleted; +} + +/** + * Entfernt alle vom CMS angelegten Bilder, die nirgends mehr referenziert + * werden. Die Referenzliste deckt bewusst ALLE Zeilen ab, auch + * unveroeffentlichte - sonst verlieren die ihr Bild. + */ +export async function sweepOrphanedImages(): Promise { + const referenced = await collectReferencedImageUrls(); + const removed: string[] = []; + + for (const url of assets.listImageFiles()) { + if (referenced.has(url)) continue; + if (!(await isManagedAsset(url))) continue; + + if (assets.deleteImage(url)) removed.push(url); + await forgetManagedAsset(url); + } + + return removed; } /** diff --git a/backend/src/services/managed-assets.service.ts b/backend/src/services/managed-assets.service.ts new file mode 100644 index 0000000..717f466 --- /dev/null +++ b/backend/src/services/managed-assets.service.ts @@ -0,0 +1,54 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { managedAssets } from '../db/schema.js'; + +/** + * Fuehrt Buch darueber, welche Dateien das CMS selbst angelegt hat. + * Nur diese darf es spaeter wieder loeschen. + */ + +// Altbestand: vor der Umstellung auf sprechende Namen hiessen Uploads +// -<6 zeichen>.. Diese Dateien stehen nicht in der +// Tabelle, sollen aber weiterhin aufgeraeumt werden koennen. Handgepflegte +// Assets wie event_karaoke.jpg oder Gallery1.webp passen nicht auf das Muster. +const LEGACY_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i; + +function basename(urlPath: string): string { + return urlPath.split('/').pop() || ''; +} + +/** Merkt sich eine neu angelegte Datei. */ +export async function registerManagedAsset(urlPath: string): Promise { + await db.insert(managedAssets).values({ path: urlPath }).onConflictDoNothing(); +} + +/** Vergisst eine Datei wieder (nach dem Loeschen). */ +export async function forgetManagedAsset(urlPath: string): Promise { + await db.delete(managedAssets).where(eq(managedAssets.path, urlPath)); +} + +/** Darf das CMS diese Datei loeschen? */ +export async function isManagedAsset(urlPath: string): Promise { + if (!urlPath) return false; + + const [row] = await db + .select() + .from(managedAssets) + .where(eq(managedAssets.path, urlPath)) + .limit(1); + + if (row) return true; + + return LEGACY_NAME.test(basename(urlPath)); +} + +/** Alle vom CMS angelegten Pfade aus der Tabelle. */ +export async function listManagedAssets(): Promise { + const rows = (await db.select().from(managedAssets)) as any[]; + return rows.map((row) => row.path as string); +} + +/** Trifft der Altbestands-Namensstil zu? */ +export function hasLegacyName(urlPath: string): boolean { + return LEGACY_NAME.test(basename(urlPath)); +} diff --git a/backend/src/services/upload.service.ts b/backend/src/services/upload.service.ts index 5e404e3..5e2f1e9 100644 --- a/backend/src/services/upload.service.ts +++ b/backend/src/services/upload.service.ts @@ -1,6 +1,8 @@ import fs from 'fs'; import path from 'path'; import { env } from '../config/env.js'; +import { AssetService, MANAGED_IMAGE_DIRS, MANAGED_PDF_DIR } from './asset.service.js'; +import { registerManagedAsset } from './managed-assets.service.js'; export type UploadSubdir = 'events' | 'gallery' | 'content'; @@ -9,18 +11,64 @@ export interface SavedImage { imageUrl: string; } +const assets = new AssetService(); + /** - * Nimmt einen Multipart-Upload entgegen, rechnet ihn nach WebP herunter und - * legt ihn unter public/images/ im Git-Workspace ab. + * Macht aus "Karaoke-Abend im Gallus Pub!" -> "karaoke-abend-im-gallus-pub". + * Umlaute werden ausgeschrieben, nicht entfernt, damit aus "Getränke" nicht + * "getrnke" wird. + */ +export function slugify(value: string): string { + const slug = String(value || '') + .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') + .replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue') + .replace(/ß/g, 'ss') + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) + .replace(/-+$/, ''); + + return slug; +} + +/** Dateiname ohne Endung, wie er vor dem Upload hiess. */ +function originalBaseName(file: any): string { + const name = (file?.filename as string | undefined) || ''; + return name.replace(/\.[^.]+$/, ''); +} + +/** + * Sucht einen freien Namen. Gibt es .avif schon, wird -2, -3, ... + * angehaengt, damit zwei Events namens "Karaoke" sich nicht ueberschreiben. + */ +function findFreeName(base: string, ext: string, urlDir: string, dirs: string[]): string { + const safeBase = base || `datei-${Date.now().toString(36)}`; + + for (let attempt = 1; attempt <= 500; attempt++) { + const candidate = attempt === 1 ? `${safeBase}${ext}` : `${safeBase}-${attempt}${ext}`; + if (!assets.exists(`${urlDir}/${candidate}`, dirs)) return candidate; + } + + // Sollte nie eintreten - lieber ein haesslicher Name als eine Endlosschleife + return `${safeBase}-${Date.now().toString(36)}${ext}`; +} + +/** + * Nimmt einen Multipart-Upload entgegen, rechnet ihn auf 1600px herunter, + * wandelt nach AVIF und legt ihn unter public/images/ ab. * - * Der Dateiname folgt dem Muster -.. Daran erkennt - * der AssetService spaeter, dass er die Datei wieder loeschen darf. + * Der Name kommt aus preferredName (Event-Titel bzw. Alt-Text) und faellt + * sonst auf den urspruenglichen Dateinamen zurueck. */ export async function saveUploadedImage( file: any, subdir: UploadSubdir, - log?: { warn: (obj: any, msg: string) => void } + options: { preferredName?: string; log?: { warn: (obj: any, msg: string) => void } } = {} ): Promise { + const { preferredName, log } = options; + const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir); fs.mkdirSync(uploadDir, { recursive: true }); @@ -39,11 +87,8 @@ export async function saveUploadedImage( throw error; } - const stamp = Date.now().toString(36); - const rand = Math.random().toString(36).slice(2, 8); - let outBuffer: Buffer; - let outExt = '.webp'; + let outExt = '.avif'; try { // Sharp erst laden wenn wirklich gebraucht @@ -51,7 +96,7 @@ export async function saveUploadedImage( outBuffer = await sharp(inputBuffer) .rotate() .resize({ width: 1600, withoutEnlargement: true }) - .webp({ quality: 82 }) + .avif({ quality: 55 }) .toBuffer(); } catch (err) { log?.warn({ err }, 'Sharp processing failed, using original image'); @@ -60,8 +105,33 @@ export async function saveUploadedImage( outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase(); } - const filename = `${stamp}-${rand}${outExt}`; + const base = slugify(preferredName || '') || slugify(originalBaseName(file)); + const urlDir = `/images/${subdir}`; + const filename = findFreeName(base, outExt, urlDir, MANAGED_IMAGE_DIRS); + fs.writeFileSync(path.join(uploadDir, filename), outBuffer); - return { filename, imageUrl: `/images/${subdir}/${filename}` }; + const imageUrl = `${urlDir}/${filename}`; + await registerManagedAsset(imageUrl); + + return { filename, imageUrl }; +} + +/** + * Legt ein hochgeladenes PDF unter public/pdf ab, benannt nach dem + * urspruenglichen Dateinamen. + */ +export async function saveUploadedPdf(file: any, buffer: Buffer, preferredName?: string): Promise { + const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf'); + fs.mkdirSync(uploadDir, { recursive: true }); + + const base = slugify(preferredName || '') || slugify(originalBaseName(file)) || 'dokument'; + const filename = findFreeName(base, '.pdf', '/pdf', [MANAGED_PDF_DIR]); + + fs.writeFileSync(path.join(uploadDir, filename), buffer); + + const pdfUrl = `/pdf/${filename}`; + await registerManagedAsset(pdfUrl); + + return pdfUrl; } diff --git a/src/pages/admin.astro b/src/pages/admin.astro index d06c233..973c3e1 100644 --- a/src/pages/admin.astro +++ b/src/pages/admin.astro @@ -254,8 +254,11 @@ const title = 'Admin'; }); // ========== Events & Publish ========== - async function uploadEventImage(file) { + // Wichtig: Textfelder IMMER vor der Datei anhaengen. Der Server liest + // sie beim Empfang der Datei aus; was danach kommt, ist zu spaet. + async function uploadEventImage(file, title) { const fd = new FormData(); + fd.append('title', title || ''); fd.append('file', file); const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' }); if (!res.ok) throw new Error(await res.text()); @@ -264,9 +267,9 @@ const title = 'Admin'; async function uploadGalleryImage(file, altText) { const fd = new FormData(); - fd.append('file', file); if (altText) fd.append('altText', altText); fd.append('displayOrder', '0'); + fd.append('file', file); const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' }); if (!res.ok) throw new Error(await res.text()); return res.json(); @@ -372,7 +375,8 @@ const title = 'Admin'; try { let imageUrl = ''; if (file) { - const up = await uploadEventImage(file); + // Titel mitgeben, damit die Datei danach benannt wird + const up = await uploadEventImage(file, title); imageUrl = up?.imageUrl || ''; } msg.textContent = 'Lege Event an...'; @@ -655,8 +659,9 @@ const title = 'Admin'; } } - async function uploadContentImage(file) { + async function uploadContentImage(file, name) { const fd = new FormData(); + fd.append('name', name || ''); fd.append('file', file); const res = await fetch(API_BASE + '/api/content/upload', { method: 'POST', body: fd, credentials: 'include' }); if (!res.ok) throw new Error(await res.text()); @@ -664,10 +669,10 @@ const title = 'Admin'; } /** Lädt die Datei aus einem Input hoch, sofern eine gewählt wurde. */ - async function maybeUpload(inputId, current) { + async function maybeUpload(inputId, current, name) { const file = byId(inputId).files[0]; if (!file) return current; - const up = await uploadContentImage(file); + const up = await uploadContentImage(file, name); byId(inputId).value = ''; return up?.imageUrl || current; } @@ -710,7 +715,7 @@ const title = 'Admin'; msg.textContent = 'Lade Bild hoch...'; msg.className = 'muted'; try { - const imageUrl = await maybeUpload('wel-file', contentState.welcome.imageUrl); + const imageUrl = await maybeUpload('wel-file', contentState.welcome.imageUrl, 'willkommen'); await saveSection('welcome', { ...contentState.welcome, heading1: byId('wel-heading1').value.trim(), @@ -731,10 +736,11 @@ const title = 'Admin'; msg.textContent = 'Lade Dateien hoch...'; msg.className = 'muted'; try { - const special = await maybeUpload('dr-special-file', contentState.drinks.monthlySpecialImage); - const w1 = await maybeUpload('dr-whiskey-file1', contentState.drinks.whiskeyImage1); - const w2 = await maybeUpload('dr-whiskey-file2', contentState.drinks.whiskeyImage2); - const w3 = await maybeUpload('dr-whiskey-file3', contentState.drinks.whiskeyImage3); + const specialName = byId('dr-special-name').value.trim(); + const special = await maybeUpload('dr-special-file', contentState.drinks.monthlySpecialImage, specialName || 'monats-hit'); + const w1 = await maybeUpload('dr-whiskey-file1', contentState.drinks.whiskeyImage1, 'whiskey-1'); + const w2 = await maybeUpload('dr-whiskey-file2', contentState.drinks.whiskeyImage2, 'whiskey-2'); + const w3 = await maybeUpload('dr-whiskey-file3', contentState.drinks.whiskeyImage3, 'whiskey-3'); // PDF geht über einen eigenen Endpunkt, der die URL selbst hinterlegt const pdfFile = byId('dr-pdf-file').files[0];