"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:
+160
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user