Add CMS features with admin interface and OAuth authentication integration

- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
This commit is contained in:
2025-12-08 16:00:40 +01:00
parent 3b6cb0a3fb
commit daccc43677
16 changed files with 603 additions and 186 deletions

291
src/pages/admin.astro Normal file
View File

@ -0,0 +1,291 @@
---
const title = 'Admin';
---
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 1rem; }
h1, h2 { margin: 0.5rem 0; }
section { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
.events-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start; }
@media (max-width: 900px){ .events-row { grid-template-columns: 1fr; } }
button { margin-top: 0.5rem; padding: 0.5rem 1rem; cursor: pointer; }
.muted { color: #666; }
.btn { display: inline-block; padding: 0.5rem 1rem; background: #222; color: #fff; border-radius: 6px; text-decoration: none; }
.btn:hover { background: #444; }
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(260px,1fr)); gap: 0.75rem; }
.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; }
.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; }
.drag-handle { cursor:grab; padding:.25rem .5rem; border:1px dashed #bbb; border-radius:6px; font-size:.85rem; }
.row-buttons { display:flex; gap:.5rem; margin-top:.5rem; }
</style>
</head>
<body>
<h1>Admin</h1>
<section>
<h2>Authentifizierung</h2>
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
<div class="row">
<a id="login-link" class="btn" href="/api/auth/gitea">Mit Gitea anmelden</a>
<button id="btn-relogin">Neu anmelden</button>
<button id="btn-logout">Abmelden</button>
</div>
</section>
<section id="sec-events" style="display:none">
<h2>Events verwalten</h2>
<div class="events-row">
<div class="card">
<h3>Neues Event</h3>
<label>Titel<input id="ev-title" /></label>
<label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></label>
<label>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label>
<label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></label>
<label>Alt-Text<input id="ev-alt" placeholder="Bildbeschreibung" /></label>
<button id="btn-create-ev">Event anlegen</button>
<div id="ev-create-msg" class="muted"></div>
</div>
<div class="card" style="min-width:380px;">
<h3>Liste</h3>
<div class="toolbar">
<span class="pill" id="lbl-order">Anzeige: automatisch nach Datum (neueste zuerst)</span>
<button id="btn-toggle-reorder">Reihenfolge bearbeiten</button>
<button id="btn-save-order" style="display:none">Reihenfolge speichern</button>
<span id="order-msg" class="muted"></span>
</div>
<div id="events-list" class="grid"></div>
</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>
<button id="btn-publish">Publish</button>
<div id="pub-status" class="muted"></div>
</section>
<script>
const api = async (path, opts = {}) => {
const res = await fetch(path, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(await res.text());
const ct = res.headers.get('content-type') || '';
return ct.includes('application/json') ? res.json() : res.text();
};
async function refreshAuth() {
try {
const me = await api('/api/auth/me');
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
// UI-Bereiche für eingeloggte Nutzer einblenden
document.getElementById('sec-events').style.display = '';
document.getElementById('sec-publish').style.display = '';
// Direkt Events laden und auf Sektion fokussieren
await loadEvents();
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
} catch (e) {
const el = document.getElementById('auth-status');
el.textContent = 'Nicht angemeldet';
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
document.getElementById('sec-events').style.display = 'none';
document.getElementById('sec-publish').style.display = 'none';
}
}
// Fallback: falls der Link von Browser/Extensions blockiert wäre
const loginLink = document.getElementById('login-link');
loginLink.addEventListener('click', (e) => {
try {
// Stelle sicher, dass Navigieren erzwungen wird
window.location.assign('/api/auth/gitea');
} catch {}
});
document.getElementById('btn-relogin').addEventListener('click', async () => {
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
document.cookie = 'token=; Path=/; Max-Age=0;';
window.location.assign('/api/auth/gitea');
});
document.getElementById('btn-logout').addEventListener('click', async () => {
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
document.cookie = 'token=; Path=/; Max-Age=0;';
await refreshAuth();
});
// ========== Events & Publish ==========
async function uploadImage(file, altText) {
const fd = new FormData();
fd.append('file', file);
if (altText) fd.append('altText', altText);
fd.append('displayOrder', '0');
const res = await fetch('/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
let reorderMode = false;
let lastEvents = [];
function parseDateSafe(s){
const d = new Date(s);
return isNaN(+d) ? new Date(0) : d;
}
async function loadEvents() {
const listEl = document.getElementById('events-list');
listEl.innerHTML = '<div class="muted">Lade...</div>';
try {
const data = await api('/api/events');
listEl.innerHTML = '';
// Merken, globale Liste aktualisieren
lastEvents = (data.events || []).slice();
let renderList = lastEvents.slice();
if (!reorderMode) {
// Automatisch nach Datum sortieren (neueste zuerst)
renderList.sort((a,b) => +parseDateSafe(b.date) - +parseDateSafe(a.date));
document.getElementById('lbl-order').textContent = 'Anzeige: automatisch nach Datum (neueste zuerst)';
} else {
// Nach displayOrder aufsteigend
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
document.getElementById('lbl-order').textContent = 'Anzeige: manuelle Reihenfolge (drag & drop)';
}
renderList.forEach((ev, idx) => {
const card = document.createElement('div');
card.className = 'card';
card.setAttribute('draggable', String(reorderMode));
card.dataset.id = ev.id;
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>
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
</div>
<div class="muted">${ev.date}</div>
<div>${ev.description || ''}</div>
<div class="muted">Bild: ${ev.imageUrl || ''}</div>
<div class="row-buttons">
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
</div>`;
listEl.appendChild(card);
});
listEl.querySelectorAll('.btn-del-ev').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
if (!confirm('Event wirklich löschen?')) return;
try { await api(`/api/events/${id}`, { method: 'DELETE' }); await loadEvents(); } catch(e){ alert('Fehler: '+e.message); }
})
})
if (reorderMode) {
enableDragAndDrop(listEl);
}
} catch (e) {
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
console.error(e);
}
}
// Drag & Drop Reorder
function enableDragAndDrop(container){
let draggingEl = null;
container.querySelectorAll('.card').forEach(card => {
card.addEventListener('dragstart', (e) => {
draggingEl = card; card.classList.add('dragging');
e.dataTransfer.setData('text/plain', card.dataset.id || '');
});
card.addEventListener('dragend', () => { draggingEl = null; card.classList.remove('dragging'); });
card.addEventListener('dragover', (e) => { e.preventDefault(); });
card.addEventListener('drop', (e) => {
e.preventDefault();
const target = card;
if (!draggingEl || draggingEl === target) return;
const cards = Array.from(container.querySelectorAll('.card'));
const draggingIdx = cards.indexOf(draggingEl);
const targetIdx = cards.indexOf(target);
if (draggingIdx < targetIdx) {
target.after(draggingEl);
} else {
target.before(draggingEl);
}
});
});
}
document.getElementById('btn-create-ev').addEventListener('click', async () => {
const title = (document.getElementById('ev-title')).value.trim();
const date = (document.getElementById('ev-date')).value.trim();
const desc = (document.getElementById('ev-desc')).value.trim();
const file = /** @type {HTMLInputElement} */ (document.getElementById('ev-file')).files[0];
const alt = (document.getElementById('ev-alt')).value.trim();
const msg = document.getElementById('ev-create-msg');
msg.textContent = 'Lade Bild hoch...';
try {
let imageUrl = '';
if (file) {
const up = await uploadImage(file, alt || title);
imageUrl = up?.image?.imageUrl || '';
}
msg.textContent = 'Lege Event an...';
await api('/api/events', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, date, description: desc, imageUrl, displayOrder: 0, isPublished: true })
});
msg.textContent = 'Event erstellt';
(document.getElementById('ev-title')).value = '';
(document.getElementById('ev-date')).value = '';
(document.getElementById('ev-desc')).value = '';
(document.getElementById('ev-file')).value = '';
(document.getElementById('ev-alt')).value = '';
await loadEvents();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
document.getElementById('btn-publish').addEventListener('click', async () => {
const s = document.getElementById('pub-status');
const m = (document.getElementById('pub-msg')).value.trim() || 'Update events';
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';
} catch(e){ s.textContent = 'Fehler: '+e.message }
});
// Toggle Reorder
document.getElementById('btn-toggle-reorder').addEventListener('click', async () => {
reorderMode = !reorderMode;
document.getElementById('btn-save-order').style.display = reorderMode ? '' : 'none';
await loadEvents();
});
// Save Order
document.getElementById('btn-save-order').addEventListener('click', async () => {
const container = document.getElementById('events-list');
const cards = Array.from(container.querySelectorAll('.card'));
const orders = cards.map((c, idx) => ({ id: c.dataset.id, displayOrder: idx }));
const msg = document.getElementById('order-msg');
msg.textContent = 'Speichere Reihenfolge...';
try {
await api('/api/events/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
msg.textContent = 'Reihenfolge gespeichert';
// Bleibe in Reorder-Mode oder verlasse ihn? Hier: verlassen
reorderMode = false;
document.getElementById('btn-save-order').style.display = 'none';
await loadEvents();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
refreshAuth();
</script>
</body>
</html>