"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:
@@ -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
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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