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:
+300
-11
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user