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
+300 -11
View File
@@ -22,7 +22,21 @@ const title = 'Admin';
.card { border: 1px solid #eee; padding: 0.75rem; border-radius: 6px; }
label { display:block; margin-top: 0.5rem; }
input, textarea { width: 100%; max-width: 600px; padding: 0.5rem; margin-top: 0.25rem; }
img.thumb { max-width: 100%; height: auto; display: block; }
/* Feste Box: Uploads sind bis 1600px breit und wuerden die Liste sonst
je nach Seitenverhaeltnis beliebig hoch ziehen. */
img.thumb { width: 100%; height: 160px; object-fit: cover; display: block;
border-radius: 4px; background: #f2f2f2; margin-top: 0.5rem; }
img.thumb-sm { width: 110px; height: 110px; object-fit: cover; display: block;
border-radius: 4px; background: #f2f2f2; border: 1px solid #e5e5e5;
margin-top: 0.25rem; }
.field-row { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
.field-row > label { flex: 1 1 260px; }
.highlight-row { display: grid; grid-template-columns: 1fr 2fr auto; gap: .5rem;
align-items: end; margin-top: .5rem; }
@media (max-width: 700px){ .highlight-row { grid-template-columns: 1fr; } }
.stack > .card { margin-bottom: 1rem; }
.ok { color: #17692b; }
.err { color: #a11; }
.toolbar { display:flex; gap:.5rem; align-items:center; margin:.5rem 0; }
.pill { font-size:.85rem; padding:.25rem .5rem; border:1px solid #ddd; border-radius:999px; background:#f7f7f7; }
.dragging { opacity:.5; }
@@ -102,6 +116,72 @@ const title = 'Admin';
</div>
</section>
<section id="sec-content" style="display:none">
<h2>Texte bearbeiten</h2>
<div class="muted">Änderungen werden erst mit „Publish“ auf die Website übernommen.</div>
<div class="stack">
<div class="card">
<h3>Startseite oben (Hero)</h3>
<div class="field-row">
<label>Überschrift<input id="hero-heading" placeholder="Dein Irish Pub" /></label>
<label>Unterzeile<input id="hero-subheading" placeholder="Im Herzen von St.Gallen" /></label>
</div>
<button id="btn-save-hero">Hero speichern</button>
<span id="hero-msg" class="muted"></span>
</div>
<div class="card">
<h3>Willkommen</h3>
<div class="field-row">
<label>Überschrift Zeile 1<input id="wel-heading1" placeholder="Herzlich willkommen im" /></label>
<label>Überschrift Zeile 2<input id="wel-heading2" placeholder="Gallus Pub!" /></label>
</div>
<label>Einleitungstext<textarea id="wel-intro" rows="4"></textarea></label>
<label style="margin-top:1rem"><strong>Highlights</strong></label>
<div id="wel-highlights"></div>
<button id="btn-add-highlight" type="button">Highlight hinzufügen</button>
<label>Schlusstext<textarea id="wel-closing" rows="3"></textarea></label>
<label>Bild ersetzen<input id="wel-file" type="file" accept="image/*" /></label>
<img id="wel-preview" class="thumb-sm" alt="Aktuelles Willkommen-Bild" style="display:none" />
<button id="btn-save-welcome">Willkommen speichern</button>
<span id="wel-msg" class="muted"></span>
</div>
<div class="card">
<h3>Drinks</h3>
<label>Einleitungstext<textarea id="dr-intro" rows="3"></textarea></label>
<div class="field-row">
<label>Monats-Hit Name<input id="dr-special-name" placeholder="Mate Vodka" /></label>
<label>Monats-Hit Bild ersetzen<input id="dr-special-file" type="file" accept="image/*" /></label>
</div>
<img id="dr-special-preview" class="thumb-sm" alt="Aktuelles Monats-Hit-Bild" style="display:none" />
<label>Whiskey-Text<textarea id="dr-whiskey" rows="3"></textarea></label>
<div class="field-row">
<label>Whiskey-Bild 1<input id="dr-whiskey-file1" type="file" accept="image/*" />
<img id="dr-whiskey-preview1" class="thumb-sm" alt="Whiskey 1" style="display:none" /></label>
<label>Whiskey-Bild 2<input id="dr-whiskey-file2" type="file" accept="image/*" />
<img id="dr-whiskey-preview2" class="thumb-sm" alt="Whiskey 2" style="display:none" /></label>
<label>Whiskey-Bild 3<input id="dr-whiskey-file3" type="file" accept="image/*" />
<img id="dr-whiskey-preview3" class="thumb-sm" alt="Whiskey 3" style="display:none" /></label>
</div>
<label style="margin-top:1rem">Getränkekarte (PDF) ersetzen<input id="dr-pdf-file" type="file" accept="application/pdf" /></label>
<div id="dr-pdf-current" class="muted"></div>
<button id="btn-save-drinks">Drinks speichern</button>
<span id="dr-msg" class="muted"></span>
</div>
</div>
</section>
<section id="sec-publish" style="display:none">
<h2>Veröffentlichen</h2>
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
@@ -120,6 +200,12 @@ const title = 'Admin';
return ct.includes('application/json') ? res.json() : res.text();
};
// Inhalte landen per innerHTML in der Seite - ohne Maskierung zerlegt
// ein Anführungszeichen im Titel die Darstellung
const esc = (v) => String(v ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
async function refreshAuth() {
try {
const me = await api('/api/auth/me');
@@ -128,11 +214,13 @@ const title = 'Admin';
document.getElementById('sec-events').style.display = '';
document.getElementById('sec-gallery').style.display = '';
document.getElementById('sec-banner').style.display = '';
document.getElementById('sec-content').style.display = '';
document.getElementById('sec-publish').style.display = '';
// Direkt Events laden und auf Sektion fokussieren
await loadEvents();
await loadGallery();
await loadBanners();
await loadContent();
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
} catch (e) {
const el = document.getElementById('auth-status');
@@ -141,6 +229,7 @@ const title = 'Admin';
document.getElementById('sec-events').style.display = 'none';
document.getElementById('sec-gallery').style.display = 'none';
document.getElementById('sec-banner').style.display = 'none';
document.getElementById('sec-content').style.display = 'none';
document.getElementById('sec-publish').style.display = 'none';
}
}
@@ -218,12 +307,12 @@ const title = 'Admin';
card.dataset.displayOrder = String(ev.displayOrder ?? idx);
card.innerHTML = `
<div class="row" style="justify-content:space-between;align-items:center">
<div><strong>${ev.title}</strong></div>
<div><strong>${esc(ev.title)}</strong></div>
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
</div>
<div class="muted">${ev.date}</div>
<div>${ev.description || ''}</div>
${ev.imageUrl ? `<img src="${API_BASE}${ev.imageUrl}" alt="${ev.title}" class="thumb" style="margin-top:0.5rem;" />` : '<div class="muted">Kein Bild</div>'}
<div class="muted">${esc(ev.date)}</div>
<div>${esc(ev.description || '')}</div>
${ev.imageUrl ? `<img src="${API_BASE}${esc(ev.imageUrl)}" alt="${esc(ev.title)}" class="thumb" />` : '<div class="muted">Kein Bild</div>'}
<div class="row-buttons">
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
</div>`;
@@ -306,7 +395,10 @@ const title = 'Admin';
s.textContent = 'Veröffentliche...';
try {
const res = await api('/api/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ commitMessage: m }) });
s.textContent = res?.message || 'Veröffentlicht';
const cleaned = res?.removedImages
? ` (${res.removedImages} ungenutzte${res.removedImages === 1 ? 's Bild' : ' Bilder'} entfernt)`
: '';
s.textContent = (res?.message || 'Veröffentlicht') + cleaned;
} catch(e){ s.textContent = 'Fehler: '+e.message }
});
@@ -348,8 +440,8 @@ const title = 'Admin';
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<img src="${API_BASE}${img.imageUrl}" alt="${img.altText}" class="thumb" />
<div class="muted">${img.altText || ''}</div>
<img src="${API_BASE}${esc(img.imageUrl)}" alt="${esc(img.altText)}" class="thumb" />
<div class="muted">${esc(img.altText || '')}</div>
<div class="row-buttons">
<button data-id="${img.id}" class="btn-del-gal">Löschen</button>
</div>`;
@@ -402,9 +494,9 @@ const title = 'Admin';
card.className = 'card';
const statusText = banner.isActive ? '✓ Aktiv' : '✗ Inaktiv';
card.innerHTML = `
<div><strong>${banner.text.substring(0, 60)}${banner.text.length > 60 ? '...' : ''}</strong></div>
<div class="muted">Von: ${banner.startDate}</div>
<div class="muted">Bis: ${banner.endDate}</div>
<div><strong>${esc(banner.text.substring(0, 60))}${banner.text.length > 60 ? '...' : ''}</strong></div>
<div class="muted">Von: ${esc(banner.startDate)}</div>
<div class="muted">Bis: ${esc(banner.endDate)}</div>
<div class="pill">${statusText}</div>
<div class="row-buttons">
<button data-id="${banner.id}" class="btn-toggle-banner">${banner.isActive ? 'Deaktivieren' : 'Aktivieren'}</button>
@@ -475,6 +567,203 @@ const title = 'Admin';
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
// ========== Texte (Content-Sections) ==========
// Der geladene Stand wird gemerkt, damit beim Speichern keine Felder
// verloren gehen, die die Oberfläche gar nicht anzeigt (z.B. pdfUrl).
let contentState = { hero: {}, welcome: {}, drinks: {} };
const byId = (id) => document.getElementById(id);
function setPreview(imgId, url) {
const img = byId(imgId);
if (url) {
img.src = API_BASE + url;
img.style.display = '';
} else {
img.removeAttribute('src');
img.style.display = 'none';
}
}
function renderHighlights(highlights) {
const box = byId('wel-highlights');
box.innerHTML = '';
(highlights || []).forEach((h, idx) => {
const row = document.createElement('div');
row.className = 'highlight-row';
row.innerHTML = `
<label>Titel<input class="hl-title" value="${esc(h?.title)}" /></label>
<label>Beschreibung<input class="hl-desc" value="${esc(h?.description)}" /></label>
<button type="button" class="hl-remove" data-idx="${idx}">Entfernen</button>`;
box.appendChild(row);
});
box.querySelectorAll('.hl-remove').forEach(btn => {
btn.addEventListener('click', () => {
btn.closest('.highlight-row').remove();
});
});
}
function readHighlights() {
return Array.from(byId('wel-highlights').querySelectorAll('.highlight-row'))
.map(row => ({
title: row.querySelector('.hl-title').value.trim(),
description: row.querySelector('.hl-desc').value.trim(),
}))
.filter(h => h.title || h.description);
}
async function loadContent() {
try {
const data = await api('/api/content');
const map = {};
(data.sections || []).forEach(s => {
let c = s.content;
if (typeof c === 'string') { try { c = JSON.parse(c); } catch { c = {}; } }
map[s.section] = c || {};
});
contentState = {
hero: map.hero || {},
welcome: map.welcome || {},
drinks: map.drinks || {},
};
byId('hero-heading').value = contentState.hero.heading || '';
byId('hero-subheading').value = contentState.hero.subheading || '';
byId('wel-heading1').value = contentState.welcome.heading1 || '';
byId('wel-heading2').value = contentState.welcome.heading2 || '';
byId('wel-intro').value = contentState.welcome.introText || '';
byId('wel-closing').value = contentState.welcome.closingText || '';
renderHighlights(contentState.welcome.highlights);
setPreview('wel-preview', contentState.welcome.imageUrl);
byId('dr-intro').value = contentState.drinks.introText || '';
byId('dr-special-name').value = contentState.drinks.monthlySpecialName || '';
byId('dr-whiskey').value = contentState.drinks.whiskeyText || '';
setPreview('dr-special-preview', contentState.drinks.monthlySpecialImage);
setPreview('dr-whiskey-preview1', contentState.drinks.whiskeyImage1);
setPreview('dr-whiskey-preview2', contentState.drinks.whiskeyImage2);
setPreview('dr-whiskey-preview3', contentState.drinks.whiskeyImage3);
const pdf = contentState.drinks.pdfUrl;
byId('dr-pdf-current').innerHTML = pdf
? `Aktuell: <a href="${API_BASE}${esc(pdf)}" target="_blank" rel="noopener noreferrer">${esc(pdf.split('/').pop())}</a>`
: 'Noch keine eigene Getränkekarte hochgeladen (es gilt die im Repo hinterlegte).';
} catch (e) {
console.error(e);
}
}
async function uploadContentImage(file) {
const fd = new FormData();
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());
return res.json();
}
/** Lädt die Datei aus einem Input hoch, sofern eine gewählt wurde. */
async function maybeUpload(inputId, current) {
const file = byId(inputId).files[0];
if (!file) return current;
const up = await uploadContentImage(file);
byId(inputId).value = '';
return up?.imageUrl || current;
}
async function saveSection(name, content, msgId) {
const msg = byId(msgId);
msg.textContent = 'Speichere...';
msg.className = 'muted';
try {
await api(`/api/content/${name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentJson: content }),
});
msg.textContent = 'Gespeichert. Mit „Publish“ übernehmen.';
msg.className = 'ok';
await loadContent();
} catch (e) {
msg.textContent = 'Fehler: ' + e.message;
msg.className = 'err';
}
}
byId('btn-add-highlight').addEventListener('click', () => {
const current = readHighlights();
current.push({ title: '', description: '' });
renderHighlights(current);
});
byId('btn-save-hero').addEventListener('click', () => {
saveSection('hero', {
...contentState.hero,
heading: byId('hero-heading').value.trim(),
subheading: byId('hero-subheading').value.trim(),
}, 'hero-msg');
});
byId('btn-save-welcome').addEventListener('click', async () => {
const msg = byId('wel-msg');
msg.textContent = 'Lade Bild hoch...';
msg.className = 'muted';
try {
const imageUrl = await maybeUpload('wel-file', contentState.welcome.imageUrl);
await saveSection('welcome', {
...contentState.welcome,
heading1: byId('wel-heading1').value.trim(),
heading2: byId('wel-heading2').value.trim(),
introText: byId('wel-intro').value.trim(),
closingText: byId('wel-closing').value.trim(),
highlights: readHighlights(),
...(imageUrl ? { imageUrl } : {}),
}, 'wel-msg');
} catch (e) {
msg.textContent = 'Fehler: ' + e.message;
msg.className = 'err';
}
});
byId('btn-save-drinks').addEventListener('click', async () => {
const msg = byId('dr-msg');
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);
// PDF geht über einen eigenen Endpunkt, der die URL selbst hinterlegt
const pdfFile = byId('dr-pdf-file').files[0];
if (pdfFile) {
const fd = new FormData();
fd.append('file', pdfFile);
const res = await fetch(API_BASE + '/api/pdf/drinks', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
const up = await res.json();
contentState.drinks.pdfUrl = up?.pdfUrl || contentState.drinks.pdfUrl;
byId('dr-pdf-file').value = '';
}
await saveSection('drinks', {
...contentState.drinks,
introText: byId('dr-intro').value.trim(),
monthlySpecialName: byId('dr-special-name').value.trim(),
whiskeyText: byId('dr-whiskey').value.trim(),
...(special ? { monthlySpecialImage: special } : {}),
...(w1 ? { whiskeyImage1: w1 } : {}),
...(w2 ? { whiskeyImage2: w2 } : {}),
...(w3 ? { whiskeyImage3: w3 } : {}),
}, 'dr-msg');
} catch (e) {
msg.textContent = 'Fehler: ' + e.message;
msg.className = 'err';
}
});
refreshAuth();
</script>
</body>