"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
+17
View File
@@ -1629,6 +1629,23 @@ header.site-header {
.jahres-event-card h3 { margin: 0 0 .4rem; font-size: 1.15rem; }
.jahres-event-card p { margin: 0 0 .6rem; color: var(--text-muted); font-size: .93rem; line-height: 1.55; }
.jahres-event-mehr { color: var(--accent); font-weight: 600; font-size: .88rem; }
/* Optionales Admin-Bild pro Event (20.08.2026, "Event des Jahres" jetzt in
der Verwaltung pflegbar) — bleibt per [hidden] unsichtbar, solange kein
Bild gesetzt ist, damit die textbasierten Standard-Karten unverändert
aussehen. Bugfix (per Playwright-Test gefunden): "display:block" allein
hätte dieselbe Spezifität wie die eingebaute [hidden]-Regel des Browsers
und würde sie im Zweifel überschreiben (leerer Bild-Rahmen wäre auch OHNE
gesetztes Bild sichtbar gewesen) — deshalb hier zusätzlich explizit
:not([hidden]) statt nur der Klasse allein. */
.jahres-event-bild[hidden] { display: none; }
.jahres-event-bild:not([hidden]) {
display: block;
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: calc(var(--radius) - 4px);
margin-bottom: .9rem;
}
/* ---------- Cards ---------- */
.card {
+53 -2
View File
@@ -91,13 +91,15 @@
<span class="tag" data-i18n="idx_jahres_events_tag">🏆 Event des Jahres</span>
</div>
<div class="jahres-events-grid">
<a class="jahres-event-card" href="zeitreise.html">
<a class="jahres-event-card" href="zeitreise.html" id="jahres-event-1">
<img class="jahres-event-bild" alt="" hidden />
<span class="jahres-event-datum" data-i18n="idx_jahres_event1_datum">7. März 2026</span>
<h3 data-i18n="idx_jahres_event1_h3">Der 28-Stunden-Stream</h3>
<p data-i18n="idx_jahres_event1_p">DogFather stellt sich einer besonderen Herausforderung und streamt 28 Stunden am Stück.</p>
<span class="jahres-event-mehr" data-i18n="idx_jahres_events_mehr">Zur Zeitreise →</span>
</a>
<a class="jahres-event-card" href="zeitreise.html">
<a class="jahres-event-card" href="zeitreise.html" id="jahres-event-2">
<img class="jahres-event-bild" alt="" hidden />
<span class="jahres-event-datum" data-i18n="idx_jahres_event2_datum">27. Juli bis 2. August 2026</span>
<h3 data-i18n="idx_jahres_event2_h3">Der Creator Cup</h3>
<p data-i18n="idx_jahres_event2_p">Gemeinsam mit Cidem organisiert DogFather den Creator Cup für die Streamer von Spicy Media.</p>
@@ -355,6 +357,55 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
/* "Event des Jahres": seit 20.08.2026 in der Verwaltung pflegbar (Bild,
Text, Link je Slot, siehe verwaltung.html + server-internal/routes/
events.js). Lädt beim Seitenaufruf, was Dogi/VanVan zuletzt gespeichert
haben — bleibt der Fetch aus (noch nie gespeichert, Dienst nicht
erreichbar) oder ist ein Slot noch leer, bleibt bewusst der fest
eingebaute Standardinhalt aus dem HTML stehen (dieselben zwei echten
Events wie bisher) statt einer kaputten/leeren Kachel. */
function wendeJahresEventAn(slot, daten) {
if (!daten) return; // kein gespeicherter Eintrag -> Standardinhalt im HTML bleibt
const karte = document.getElementById(`jahres-event-${slot}`);
if (!karte) return;
const feld = (x) => (window.dogiFeld ? window.dogiFeld(x) : x || "");
const bild = karte.querySelector(".jahres-event-bild");
if (daten.bild) {
bild.src = daten.bild;
bild.alt = feld(daten.titel);
bild.hidden = false;
}
karte.querySelector(".jahres-event-datum").textContent = daten.datum || "";
karte.querySelector("h3").textContent = feld(daten.titel);
karte.querySelector("p").textContent = feld(daten.text);
if (daten.link) karte.href = daten.link;
}
if (API_BASE_URL) {
let letzteJahresEvents = null;
const ladeJahresEvents = () =>
fetch(`${API_BASE_URL}/events-of-year`, { cache: "no-store" })
.then((res) => res.json())
.then((data) => {
if (!data?.ok) return;
letzteJahresEvents = data.events;
wendeJahresEventAn(1, data.events[0]);
wendeJahresEventAn(2, data.events[1]);
})
.catch(() => {
/* Standardinhalt aus dem HTML bleibt stehen -- kein sichtbarer Fehler nötig. */
});
ladeJahresEvents();
// Bei Sprachwechsel mit den zuletzt geladenen Daten neu rendern (kein
// erneuter Server-Aufruf nötig) — Titel/Text stehen ja bereits in allen
// Sprachen im geladenen Objekt.
document.addEventListener("dogi-sprache-geaendert", () => {
if (!letzteJahresEvents) return;
wendeJahresEventAn(1, letzteJahresEvents[0]);
wendeJahresEventAn(2, letzteJahresEvents[1]);
});
}
// Stats: Team-Größe aus den Datenobjekten berechnen
const teamCount = (window.MODIS || []).length + (window.SCOUTS || []).length;
const statTeam = document.getElementById("stat-team");
+38
View File
@@ -27,6 +27,9 @@ import "dotenv/config";
import express from "express";
import cors from "cors";
import cron from "node-cron";
import multer from "multer";
import { mkdirSync } from "node:fs";
import { extname } from "node:path";
import { initDb } from "./db.js";
import { json } from "./lib/http.js";
@@ -60,6 +63,7 @@ import {
import {
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
} from "./routes/supporter-admin.js";
import { getEventsPublic, saveEvent, uploadEventImage } from "./routes/events.js";
initDb();
@@ -68,6 +72,27 @@ const app = express();
app.set("trust proxy", 1);
app.disable("x-powered-by"); // verrät sonst unnötig den eingesetzten Technik-Stack
/* Bild-Uploads für admin-gepflegte Inhalte (aktuell: "Event des Jahres",
20.08.2026). Liegt bewusst NEBEN der Datenbank unter /var/lib/... statt im
Git-Repo-Ordner — Uploads sollen einen `git pull`/Deploy unbeschadet
überstehen und nicht versehentlich mit eingecheckt werden. */
const UPLOADS_BASIS_DIR = process.env.UPLOADS_DIR || "/var/lib/dogfather-internal/uploads";
const EVENTS_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/events`;
mkdirSync(EVENTS_UPLOADS_DIR, { recursive: true });
const ERLAUBTE_BILD_TYPEN = new Set(["image/jpeg", "image/png", "image/webp"]);
const bildUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, EVENTS_UPLOADS_DIR),
// Zufälliger Dateiname (crypto.randomUUID) statt des Originalnamens —
// verhindert Pfad-/Namenskollisionen und Path-Traversal-Versuche über
// einen manipulierten Dateinamen komplett, ganz ohne Validierung nötig.
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
});
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
@@ -76,6 +101,10 @@ app.use((req, res, next) => {
});
app.use(cors());
// Bild-Uploads oeffentlich ausliefern (index.html zeigt "Event des Jahres"-Bilder
// JEDER Besucherin/JEDEM Besucher, ohne Login) — nur lesend, Schreiben bleibt
// ausschliesslich ueber /admin/events/upload-image mit EVENTS_MANAGE-Berechtigung.
app.use("/uploads", express.static(UPLOADS_BASIS_DIR));
app.use(
express.json({
limit: "2mb",
@@ -124,6 +153,7 @@ app.get("/health", (req, res) => json(res, { ok: true, service: "dogfather-unive
app.post("/submit", wrap(submitApplication));
app.get("/live-status", wrap(handleLiveStatusGet));
app.post("/auth/login", wrap(login));
app.get("/events-of-year", wrap(getEventsPublic));
app.post("/supporter/register", wrap(registerSupporter));
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
@@ -156,6 +186,10 @@ app.post("/admin/notes/delete", requireTeamSession, withSession(deleteNote));
app.post("/admin/drafts/list", requireTeamSession, withSession(listDrafts));
app.post("/admin/drafts/save", requireTeamSession, withSession(saveDraft));
// Startseite: "Event des Jahres" (20.08.2026)
app.post("/admin/events/save", requireTeamSession, withSession(saveEvent));
app.post("/admin/events/upload-image", requireTeamSession, bildUpload.single("image"), withSession(uploadEventImage));
app.post("/admin/roles/list", requireTeamSession, withSession(listRoles));
app.post("/admin/roles/create", requireTeamSession, withSession(createRole));
app.post("/admin/roles/update", requireTeamSession, withSession(updateRole));
@@ -212,6 +246,10 @@ app.use((err, req, res, next) => {
if (err?.type === "entity.too.large") {
return json(res, { ok: false, error: "Die Anfrage ist zu groß." }, 413);
}
if (err instanceof multer.MulterError) {
const nachricht = err.code === "LIMIT_FILE_SIZE" ? "Das Bild ist zu groß (maximal 5 MB)." : "Bild-Upload fehlgeschlagen.";
return json(res, { ok: false, error: nachricht }, 400);
}
console.error(`[fehler] ${req.method} ${req.originalUrl}:`, err?.stack || err);
if (res.headersSent) return;
return json(res, { ok: false, error: "Interner Fehler." }, 500);
+3
View File
@@ -45,6 +45,8 @@ export const PERMISSIONS = {
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
EVENTS_MANAGE: "Startseite: \"Event des Jahres\" verwalten",
};
export const PERMISSION_GROUPS = [
@@ -55,6 +57,7 @@ export const PERMISSION_GROUPS = [
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
{ label: "Startseite", keys: ["EVENTS_MANAGE"] },
];
export const OWNER_ONLY_ACTIONS = [
+58
View File
@@ -0,0 +1,58 @@
/* =====================================================================
lib/translate.js — automatische Übersetzung für admin-gepflegte Texte
(aktuell nur "Event des Jahres" auf der Startseite, 20.08.2026).
Bewusste Entscheidung (Nutzer-Rückfrage 20.08.2026): Dogi tippt Titel/Text
nur auf Deutsch in die Verwaltung ein, die anderen Sprachen entstehen
automatisch beim Speichern — NICHT die sonst auf der ganzen Seite geltende
Regel "immer echte, geprüfte Übersetzungen", sondern eine bewusste
Ausnahme für diesen einen admin-gepflegten Bereich. Maschinen-Übersetzung
ist NIE so gut wie eine echte — für schnell änderbare Startseiten-Teaser
akzeptabel, für Rechtstexte/feste Seiteninhalte weiterhin NICHT verwenden.
de-CH bekommt bewusst NICHT automatisch übersetzt: der Website-Dialekt
(Solothurn/Zürich-nahe Schreibweise, siehe main.js-Kommentar zu I18N_CORE)
ist keine von Übersetzungs-APIs unterstützte Zielsprache — eine
automatische "Übersetzung" nach de-CH würde bestenfalls Standarddeutsch
zurückgeben, das wäre nicht ehrlich als "Dialekt" beschriftet. de-CH
bekommt stattdessen exakt denselben Text wie de.
MyMemory (https://mymemory.translated.net) statt Google/DeepL: völlig
kostenlos, kein API-Key/Anmeldung nötig (passt zu "Kosten möglichst 0€"),
für kurze Teaser-Texte ausreichende Qualität. Absichtlich KEIN lokal
gehosteter Übersetzungsdienst (z.B. LibreTranslate) — bei der seltenen
Nutzung (2 kurze Texte, gelegentlich geändert) wäre ein dauerhaft
laufender zusätzlicher Dienst auf dem ohnehin schon mehrfach belegten
Server unverhältnismäßig viel Aufwand für den tatsächlichen Bedarf. */
const SPRACHEN_ZIEL = ["en", "fr", "pt"];
async function uebersetzeEinzeln(text, ziel) {
if (!text || !text.trim()) return "";
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=de|${ziel}`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`Übersetzungsdienst antwortete mit ${res.status}`);
const data = await res.json();
const uebersetzt = data?.responseData?.translatedText;
if (!uebersetzt || typeof uebersetzt !== "string") throw new Error("Keine Übersetzung erhalten.");
return uebersetzt;
}
/** Übersetzt einen deutschen Text nach en/fr/pt, de-CH = 1:1 Kopie von de.
Bei Fehlern (Dienst nicht erreichbar etc.) fällt die jeweilige Sprache
auf den deutschen Text zurück, statt die ganze Speicherung scheitern zu
lassen — "fail closed" mit sichtbarem Ergebnis statt kaputter Anzeige. */
export async function uebersetzeAlleSprachen(textDe) {
const ergebnis = { de: textDe || "", "de-CH": textDe || "" };
await Promise.all(
SPRACHEN_ZIEL.map(async (ziel) => {
try {
ergebnis[ziel] = await uebersetzeEinzeln(textDe, ziel);
} catch (err) {
console.error(`[translate] ${ziel} fehlgeschlagen, falle auf Deutsch zurück:`, err?.message || err);
ergebnis[ziel] = textDe || "";
}
})
);
return ergebnis;
}
+1
View File
@@ -14,6 +14,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3"
}
}
+93
View File
@@ -0,0 +1,93 @@
/* =====================================================================
routes/events.js — "Event des Jahres" auf der Startseite (index.html),
admin-pflegbar aus der Verwaltung heraus (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"). Zeigt IMMER genau 2 feste Slots
(1 und 2) — kein wachsender Katalog, deshalb genügt die bereits
bestehende app_settings-Tabelle (key/value) statt einer eigenen Tabelle.
Öffentlicher Lesezugriff (getEventsPublic) ohne Session, damit
index.html die Inhalte für jede Besucherin/jeden Besucher laden kann —
Schreibzugriff nur mit EVENTS_MANAGE-Berechtigung (Owner immer erlaubt,
siehe can()). ===================================================================== */
import { db } from "../db.js";
import { hasPermission } from "../lib/permissions.js";
import { json } from "../lib/http.js";
import { logAction } from "../lib/audit.js";
import { uebersetzeAlleSprachen } from "../lib/translate.js";
function can(session, key) {
if (!session) return false;
if (session.isOwner) return true;
return hasPermission(session.permissions, session.overrides, key);
}
function forbidden(res) {
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
}
function settingsKey(slot) {
return `event_des_jahres_${slot}`;
}
function ladeSlot(slot) {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(settingsKey(slot));
if (!row) return null;
try {
return JSON.parse(row.value);
} catch {
return null;
}
}
/** Öffentlich, keine Session: liefert beide Slots (oder null, wenn noch nie
gespeichert -- index.html behält dann seinen eingebauten Standardinhalt). */
export async function getEventsPublic(req, res) {
return json(res, { ok: true, events: [ladeSlot(1), ladeSlot(2)] });
}
/** NUR Bild-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware). */
export async function uploadEventImage(req, res, session) {
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
const url = `/uploads/events/${req.file.filename}`;
logAction(session.actor, "events.image_uploaded", req.file.filename, null);
return json(res, { ok: true, url });
}
export async function saveEvent(req, res, session) {
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
const body = req.body || {};
const slot = Number(body.slot);
if (slot !== 1 && slot !== 2) return json(res, { ok: false, error: "Ungültiger Slot (nur 1 oder 2)." }, 400);
const datum = String(body.datum || "").trim().slice(0, 80);
const titelDe = String(body.titel || "").trim().slice(0, 120);
const textDe = String(body.text || "").trim().slice(0, 400);
const link = String(body.link || "").trim().slice(0, 300) || null;
const bild = String(body.bild || "").trim().slice(0, 300) || null;
if (!titelDe || !textDe) {
return json(res, { ok: false, error: "Titel und Text sind Pflicht." }, 400);
}
// Nur echte http(s)-Links zulassen (kein javascript:-Link etc.)
if (link && !/^https?:\/\//i.test(link)) {
return json(res, { ok: false, error: "Link muss mit http:// oder https:// beginnen." }, 400);
}
const [titel, text] = await Promise.all([uebersetzeAlleSprachen(titelDe), uebersetzeAlleSprachen(textDe)]);
const eintrag = {
datum, titel, text, link, bild,
aktualisiertVon: session.actor,
aktualisiertAm: new Date().toISOString(),
};
db.prepare(
`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
).run(settingsKey(slot), JSON.stringify(eintrag), eintrag.aktualisiertAm);
logAction(session.actor, "events.saved", String(slot), { titel: titelDe });
return json(res, { ok: true, event: eintrag });
}
+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