"Event des Jahres" jetzt in der Verwaltung pflegbar (Bild, Text, Link)

Nutzer-Wunsch 20.08.2026: "will ich in der admin seite das selbst
gestalten können, mit bild und text und am besten auch einen link." Auf
Rueckfrage entschieden: Titel/Text nur auf Deutsch eingeben, die anderen
4 Sprachen werden beim Speichern automatisch uebersetzt (MyMemory, 0€,
kein API-Key) -- bewusste Ausnahme von der sonst geltenden "immer echte
Uebersetzung"-Regel, klar dokumentiert und im Admin-UI selbst als Hinweis
sichtbar. de-CH bekommt denselben deutschen Text (Dialekt ist keine von
Uebersetzungs-APIs unterstuetzte Zielsprache).

Backend (server-internal):
- routes/events.js: getEventsPublic (oeffentlich, keine Session),
  saveEvent + uploadEventImage (beide hinter neuer EVENTS_MANAGE-
  Berechtigung, Owner immer erlaubt). Speichert in der bereits
  bestehenden app_settings-Tabelle (2 feste Slots) statt einer neuen
  Tabelle -- es gibt nie mehr als genau 2 Events.
- lib/translate.js: MyMemory-Anbindung mit "fail closed auf Deutsch"
  pro Sprache, falls der Dienst mal nicht antwortet.
- Bild-Upload per multer, zufaelliger Dateiname (crypto.randomUUID,
  verhindert Path-Traversal ueber den Originalnamen komplett), 5 MB
  Limit, nur jpeg/png/webp, Ablage unter /var/lib/dogfather-internal/
  uploads/events (NICHT im Git-Ordner -- uebersteht Deploys), oeffentlich
  ausgeliefert unter /uploads.
- Neue Berechtigung EVENTS_MANAGE im Katalog (Gruppe "Startseite").

Frontend:
- index.html: laedt /events-of-year beim Aufruf, ueberschreibt pro Slot
  Datum/Titel/Text/Bild/Link NUR wenn dort tatsaechlich etwas gespeichert
  ist -- bleibt der Abruf aus oder ist ein Slot leer, bleibt der bisherige
  fest eingebaute Standardinhalt (dieselben zwei echten Events) stehen.
  Kein Blocker, kein sichtbarer Fehler bei Ausfall.
- verwaltung.html: neue Sektion "🏆 Event des Jahres" (nur mit
  EVENTS_MANAGE sichtbar), 2 Karten mit Bild-Upload+Vorschau, Datum,
  Titel, Text, Link, eigenem Speichern-Knopf pro Event.

Ausfuehrlich getestet, weil server-internal wegen fehlender Visual-
Studio-Build-Tools auf dieser Windows-Maschine nicht lokal mit echtem
better-sqlite3 laufen kann: routes/events.js komplett isoliert gegen eine
Fake-DB getestet (11 Szenarien: oeffentlicher Abruf, fehlende Session,
Session ohne Recht, Owner, Rolle MIT EVENTS_MANAGE, alle Validierungen,
Bild-Upload inkl. falscher Dateityp, Abruf des hochgeladenen Bilds).
index.html per Playwright mit echtem Netzwerk-Mocking gegen zwei
Szenarien getestet (API nicht erreichbar -> Standardinhalt bleibt; API
liefert echte Daten -> nur der befuellte Slot wird ueberschrieben, der
leere bleibt Standard). Dabei einen echten CSS-Bug gefunden und behoben
(display:block auf .jahres-event-bild überschrieb die [hidden]-Regel des
Browsers, leeres Bild waere immer sichtbar gewesen). verwaltung.html per
Playwright mit gemocktem Login+API end-to-end getestet: Formular wird
korrekt vorbefuellt, Bild-Upload + Speichern senden die richtigen Daten.

WICHTIG: server-internal laeuft unter einem eigenen Systembenutzer
(dogiintern), auf den ich (claudian) bewusst KEINEN Zugriff habe -- diese
Aenderung kann ich anders als sonst nicht selbst bis auf den Server
bringen. Dogi muss den Deploy-Schritt fuer server-internal selbst
ausfuehren (git pull + npm install + Neustart des dogiintern-Dienstes).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-20 15:09:54 +02:00
co-authored by Claude Opus 5
parent 3f3ba835a9
commit f86b96a22f
8 changed files with 423 additions and 2 deletions
+160
View File
@@ -737,6 +737,27 @@
</div>
</section>
<!-- ===== "Event des Jahres" (Startseite) — 20.08.2026, Nutzer-Wunsch:
"will ich in der admin seite das selbst gestalten können, mit bild
und text und am besten auch einen link". Nur sichtbar mit
EVENTS_MANAGE-Berechtigung (Owner immer). Titel/Text werden bewusst
NUR auf Deutsch eingegeben — server-internal/lib/translate.js
übersetzt beim Speichern automatisch nach en/fr/pt (de-CH bekommt
denselben deutschen Text, siehe Kommentar dort: Dialekt lässt sich
nicht maschinell übersetzen). Zeigt IMMER genau 2 feste Slots, kein
wachsender Katalog. -->
<section class="section-tight" id="events-root" style="display:none;padding-top:0;">
<div class="container">
<h2 class="text-center" style="margin-bottom:.3rem;">🏆 Event des Jahres</h2>
<p class="small muted text-center" style="max-width:60ch;margin:0 auto 1.4rem;">
Erscheint auf der Startseite unter den 3 Welt-Kacheln. Titel/Text bitte auf Deutsch
eintippen — die anderen Sprachen werden beim Speichern automatisch übersetzt
(maschinell, daher nie ganz so gut wie eine echte Übersetzung).
</p>
<div class="grid grid-2" id="events-grid" style="gap:1.2rem;"></div>
</div>
</section>
<!-- ===== Supporter-Liste ===== -->
<section class="section-tight" id="supp-root" style="display:none;padding-top:0;">
<div class="container" id="liste-ansicht">
@@ -947,6 +968,10 @@
document.getElementById("supp-root").style.display = "";
const roleBadge = document.getElementById("role-badge");
roleBadge.innerHTML = `<span class="krone">👑</span> ${session.isOwner ? "Owner" : (session.roleName || "Eingeloggt")}`;
if (window.AdminAuth.sessionHasPermission(session, "EVENTS_MANAGE")) {
document.getElementById("events-root").style.display = "";
await ladeUndRenderEvents(session);
}
await ladeAlleSupporter();
}
@@ -956,6 +981,141 @@
document.getElementById("session-bar").style.display = "none";
document.getElementById("vw-stats-root").style.display = "none";
document.getElementById("supp-root").style.display = "none";
document.getElementById("events-root").style.display = "none";
}
/* ===== "Event des Jahres" — Verwaltung (20.08.2026) =====
Lädt den aktuellen Stand über denselben öffentlichen Endpunkt, den auch
index.html benutzt (liefert bereits alle Sprachen inkl. "de", das reicht
zum Vorausfüllen des Formulars) und baut pro Slot eine eigene Karte mit
Bild-Upload + Formular. */
const EVENT_SLOTS = [1, 2];
function eventKarteHtml(slot) {
return `
<div class="card vw-card vw-shine-card vw-noise" style="padding:1.5rem;">
<h3 style="margin-top:0;">Event ${slot}</h3>
<div style="margin-bottom:1rem;">
<img id="event-${slot}-vorschau" alt="" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;background:var(--bg-alt);display:none;" />
</div>
<div class="field">
<label for="event-${slot}-bild-input">Bild (optional, max. 5 MB, JPG/PNG/WebP)</label>
<input type="file" id="event-${slot}-bild-input" accept="image/jpeg,image/png,image/webp" />
</div>
<div class="field">
<label for="event-${slot}-datum">Datum</label>
<input type="text" id="event-${slot}-datum" maxlength="80" placeholder="z. B. 7. März 2026" />
</div>
<div class="field">
<label for="event-${slot}-titel">Titel (Deutsch)</label>
<input type="text" id="event-${slot}-titel" maxlength="120" />
</div>
<div class="field">
<label for="event-${slot}-text">Text (Deutsch)</label>
<textarea id="event-${slot}-text" maxlength="400" rows="3"></textarea>
</div>
<div class="field">
<label for="event-${slot}-link">Link (optional, muss mit https:// beginnen)</label>
<input type="url" id="event-${slot}-link" placeholder="https://…" />
</div>
<button type="button" class="btn btn-primary" id="event-${slot}-speichern" style="width:100%;">Speichern</button>
<p class="small" id="event-${slot}-status" style="margin-top:.6rem;"></p>
</div>`;
}
async function ladeUndRenderEvents(session) {
const grid = document.getElementById("events-grid");
grid.innerHTML = EVENT_SLOTS.map(eventKarteHtml).join("");
let aktuelleDaten = [null, null];
try {
const res = await fetch(`${API_BASE_URL}/events-of-year`, { cache: "no-store" });
const data = await res.json();
if (data?.ok) aktuelleDaten = data.events;
} catch {
/* Formular bleibt leer, Speichern funktioniert trotzdem — kein Blocker. */
}
EVENT_SLOTS.forEach((slot, i) => {
const eintrag = aktuelleDaten[i];
const bildInput = document.getElementById(`event-${slot}-bild-input`);
const vorschau = document.getElementById(`event-${slot}-vorschau`);
let hochgeladenesBild = eintrag?.bild || null;
if (eintrag) {
document.getElementById(`event-${slot}-datum`).value = eintrag.datum || "";
document.getElementById(`event-${slot}-titel`).value = eintrag.titel?.de || "";
document.getElementById(`event-${slot}-text`).value = eintrag.text?.de || "";
document.getElementById(`event-${slot}-link`).value = eintrag.link || "";
if (eintrag.bild) {
vorschau.src = eintrag.bild;
vorschau.style.display = "";
}
}
// Bild wird SOFORT bei Auswahl hochgeladen (nicht erst beim
// "Speichern"-Klick) — einfacher nachvollziehbar für Dogi/VanVan: die
// Vorschau zeigt exakt, was hochgeladen wurde, bevor überhaupt
// gespeichert wird.
bildInput.addEventListener("change", async () => {
const datei = bildInput.files?.[0];
if (!datei) return;
const statusEl = document.getElementById(`event-${slot}-status`);
statusEl.textContent = "Bild wird hochgeladen …";
statusEl.style.color = "";
const form = new FormData();
form.append("image", datei);
const session2 = window.AdminAuth.getSession();
try {
const res = await fetch(`${API_BASE_URL}/admin/events/upload-image`, {
method: "POST",
headers: session2?.token ? { Authorization: `Bearer ${session2.token}` } : {},
body: form,
});
const data = await res.json();
if (!data.ok) {
statusEl.textContent = data.error || "Bild-Upload fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
hochgeladenesBild = data.url;
vorschau.src = data.url;
vorschau.style.display = "";
statusEl.textContent = "Bild hochgeladen — jetzt noch auf „Speichern“ klicken.";
} catch {
statusEl.textContent = "Bild-Upload fehlgeschlagen (keine Verbindung).";
statusEl.style.color = "#ff6b6b";
}
});
document.getElementById(`event-${slot}-speichern`).addEventListener("click", async () => {
const statusEl = document.getElementById(`event-${slot}-status`);
const titel = document.getElementById(`event-${slot}-titel`).value.trim();
const text = document.getElementById(`event-${slot}-text`).value.trim();
if (!titel || !text) {
statusEl.textContent = "Titel und Text sind Pflicht.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "Wird gespeichert und übersetzt …";
statusEl.style.color = "";
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/events/save", {
slot,
datum: document.getElementById(`event-${slot}-datum`).value.trim(),
titel,
text,
link: document.getElementById(`event-${slot}-link`).value.trim(),
bild: hochgeladenesBild,
});
if (!res.ok) {
statusEl.textContent = res.error || "Speichern fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "✅ Gespeichert — auf der Startseite live.";
statusEl.style.color = "#baf7d6";
});
});
}
// Nutzer-Wunsch 04.08.2026 (6. Runde): zwei getrennte Kisten/Formulare