feat(cms): Aufraeumen von Uploads, Texte-Bearbeitung und PDF-Slot

Bilder wurden nie geloescht - weder beim Loeschen eines Datensatzes noch
beim Austauschen. Dazu waren die Textbereiche zwar im Backend vorhanden,
aber ohne Oberflaeche, und die Getraenkekarte hing hartkodiert im Markup.

Aufraeumen:
- asset.service.ts loescht ausschliesslich Dateien, die das CMS selbst
  angelegt hat (Muster <zeitstempel>-<zufall>.<ext>) und nur innerhalb der
  Upload-Ordner. Handgepflegte Assets wie event_karaoke.jpg oder Welcome.png
  bleiben unangetastet, auch wenn sie nirgends referenziert sind.
- image-refs.service.ts sammelt alle benutzten Bild-URLs, inklusive der
  Pfade aus den Textbereichen. Geloescht wird nur, was wirklich niemand
  mehr benutzt - ein von zwei Events geteiltes Bild bleibt liegen.
- Events und Gallery raeumen beim Loeschen und beim Bildwechsel mit auf.
- Der Publish entfernt zusaetzlich Verwaiste. Die Referenzliste deckt
  bewusst alle Zeilen ab, auch unveroeffentlichte, sonst verlieren die ihr
  Bild. Die Loeschungen werden mitcommittet, das Repo schrumpft also.

Texte:
- Neuer Adminbereich fuer Hero, Willkommen und Drinks ueber die schon
  vorhandenen /api/content-Endpunkte, samt Highlights-Liste und Bildern.
- Der Generator maskiert Texte jetzt. Ohne das haette ein "<" oder "&" in
  einem Feld gereicht, um den Astro-Build und damit den Deploy zu killen.

PDF:
- POST /api/pdf/drinks nimmt die Getraenkekarte entgegen, hinterlegt die
  URL in der drinks-Section und raeumt den Vorgaenger weg. Der Generator
  verlinkt sie, statt den Pfad fest im Markup zu haben.

Ausserdem:
- Vorschaubilder im Admin auf eine feste Box gezwungen. Uploads sind bis
  1600px breit und haben das Layout je nach Seitenverhaeltnis zerrissen.
- git.service.ts sichert public/pdf beim Neu-Clone mit weg, nicht nur
  public/images.
- Der Publish scheitert nicht mehr, wenn nur das Audit-Log nicht
  geschrieben werden kann - der Push ist da laengst durch.
- Upload-Logik lag dreifach kopiert vor, jetzt in upload.service.ts. Der
  Helfer prueft auch auf abgeschnittene Dateien; bisher landete bei zu
  grossen Uploads ein kaputtes Bild auf der Platte.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-08-11 11:42:59 +02:00
co-authored by Claude Opus 5
parent 2af70c238b
commit 60246f3941
12 changed files with 893 additions and 138 deletions
@@ -0,0 +1,77 @@
import { db } from '../config/database.js';
import { events, galleryImages, contentSections } from '../db/schema.js';
import { AssetService } from './asset.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.
*/
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
if (!url) return false;
const referenced = await collectReferencedImageUrls();
if (referenced.has(url)) return false;
return assets.deleteImage(url);
}
/**
* Sammelt jede Bild-URL, die irgendwo in der Datenbank vorkommt.
*
* Bewusst ueber ALLE Zeilen, nicht nur die veroeffentlichten - sonst wuerde
* ein unveroeffentlichtes Event sein Bild verlieren, sobald jemand publisht.
*
* Die Content-Sections enthalten beliebiges JSON (Welcome-Bild, Monatshit,
* Whiskey-Bilder), deshalb wird es rekursiv nach Bildpfaden durchsucht.
*/
export async function collectReferencedImageUrls(): Promise<Set<string>> {
const urls = new Set<string>();
for (const row of (await db.select().from(events)) as any[]) {
if (row.imageUrl) urls.add(row.imageUrl);
}
for (const row of (await db.select().from(galleryImages)) as any[]) {
if (row.imageUrl) urls.add(row.imageUrl);
}
for (const row of (await db.select().from(contentSections)) as any[]) {
collectFromJson(row.contentJson, urls);
}
return urls;
}
/** Alle Bildpfade aus einem beliebigen Content-JSON. */
export function extractImageUrls(value: any): Set<string> {
const out = new Set<string>();
collectFromJson(value, out);
return out;
}
function collectFromJson(value: any, out: Set<string>): void {
if (typeof value === 'string') {
if (value.startsWith('/images/')) {
out.add(value);
return;
}
// Je nach Treiber kommt das JSON als String zurueck
if (value.startsWith('{') || value.startsWith('[')) {
try {
collectFromJson(JSON.parse(value), out);
} catch {
// kein JSON - ignorieren
}
}
return;
}
if (Array.isArray(value)) {
for (const entry of value) collectFromJson(entry, out);
return;
}
if (value && typeof value === 'object') {
for (const entry of Object.values(value)) collectFromJson(entry, out);
}
}