Nutzer-Wunsch 20.08.2026: "wuensch mir nur noch bissl farbe und sehr wichtig ist dass ich auch spezielle live events da auch eintragen kann in der verwaltungsseite so dass da eine zweite uhr erscheint wo die zeit dann fuer dieses spezielle event laeuft." - Neue Verwaltungs-Sektion "Spezielles Live-Event": Titel (Deutsch, wird automatisch uebersetzt), echtes Datum+Uhrzeit, optionaler Link, Aktiv- Schalter -- nur mit Titel + Zukunftsdatum aktivierbar. - Zweite Uhr auf streamplan.html (Magenta/Violett statt Cyan/Gold, direkt neben der bestehenden), nur sichtbar wenn ein Event aktiviert ist und das Zieldatum noch nicht vorbei ist. Echter Countdown inkl. Tage (z.B. "2T 03:14:59"), "Jetzt"-Zeiger zeigt wie bei Uhr 1 die tatsaechliche Uhrzeit, der magentafarbene Fixpunkt markiert die Tageszeit des Events. Zieldatum wird als UTC gespeichert -- jede besuchende Person sieht den exakt richtigen Countdown in ihrer eigenen Zeitzone. - "Bissl Farbe": bunter Farbverlauf (Cyan/Violett/Gold) auf dem aeusseren Ring statt reinem Grauton, kraeftigerer Sweep-Farbschein. - Backend: neue routes/special-event.js (getSpecialEventPublic nur bei aktiv+zukuenftig, getSpecialEventAdmin fuer Entwuerfe, saveSpecialEvent mit Validierung), 13 automatisierte Tests gegen eine Fake-DB bestanden. - Bugfix unterwegs gefunden (Playwright-Screenshot, wiederholtes Muster auf dieser Seite): .stream-radar-wrap blieb trotz [hidden]-Attribut sichtbar (display:block ueberschreibt die eingebaute [hidden]-Regel bei gleicher Spezifitaet) -- explizite Regel ergaenzt, per Playwright erneut verifiziert (display:none bestaetigt). Backend-Teil (server-internal/) noch ohne Deploy-Zugriff -- Dogi muss ihn manuell auf dogiintern ausrollen, sonst bleibt die zweite Uhr unsichtbar. Co-Authored-By: Claude Opus 5 <[email protected]>
98 lines
4.0 KiB
JavaScript
98 lines
4.0 KiB
JavaScript
/* =====================================================================
|
|
routes/special-event.js — "Spezielles Live-Event" für streamplan.html
|
|
(20.08.2026, Nutzer-Wunsch: "sehr wichtig ist dass ich auch spezielle
|
|
live events da auch eintragen kann in der verwaltungsseite so dass da
|
|
eine zweite uhr erscheint wo die zeit dann für dieses spezielle event
|
|
läuft"). Anders als "Event des Jahres" (2 feste Slots) gibt es hier
|
|
GENAU EIN Event mit einem exakten Zieldatum+Uhrzeit statt nur einem
|
|
freien Datumstext — daraus lässt sich ein echter Countdown berechnen.
|
|
Läuft auf derselben app_settings-Tabelle (key/value), kein Schema-Umbau
|
|
nötig. Gleiche Rechte-/Freigabe-Logik wie "Event des Jahres"
|
|
(EVENTS_MANAGE, Owner immer erlaubt, öffentlich lesbar, aber nur wenn
|
|
aktiviert UND das Zieldatum noch in der Zukunft liegt). ===================================================================== */
|
|
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";
|
|
|
|
const SETTINGS_KEY = "special_live_event";
|
|
|
|
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 laden() {
|
|
const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(SETTINGS_KEY);
|
|
if (!row) return null;
|
|
try {
|
|
return JSON.parse(row.value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Öffentlich, keine Session: nur wenn aktiviert UND das Zieldatum noch
|
|
nicht vorbei ist (ein abgelaufenes Event soll nicht ewig als "Countdown
|
|
bei 00:00:00" hängen bleiben, sondern einfach verschwinden). */
|
|
export async function getSpecialEventPublic(req, res) {
|
|
const eintrag = laden();
|
|
const istGueltig = eintrag && eintrag.aktiv && new Date(eintrag.zieldatum) > new Date();
|
|
return json(res, { ok: true, event: istGueltig ? eintrag : null });
|
|
}
|
|
|
|
/** Für die Verwaltung: liefert den rohen Stand (auch inaktiv/abgelaufen),
|
|
damit das Formular beim Öffnen alles vorausfüllen kann. */
|
|
export async function getSpecialEventAdmin(req, res, session) {
|
|
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
|
return json(res, { ok: true, event: laden() });
|
|
}
|
|
|
|
export async function saveSpecialEvent(req, res, session) {
|
|
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
|
const body = req.body || {};
|
|
|
|
const titelDe = String(body.titel || "").trim().slice(0, 120);
|
|
const zieldatumRoh = String(body.zieldatum || "").trim();
|
|
const link = String(body.link || "").trim().slice(0, 300) || null;
|
|
const aktiv = !!body.aktiv;
|
|
|
|
if (!titelDe) return json(res, { ok: false, error: "Titel ist Pflicht." }, 400);
|
|
|
|
const zieldatum = new Date(zieldatumRoh);
|
|
if (!zieldatumRoh || Number.isNaN(zieldatum.getTime())) {
|
|
return json(res, { ok: false, error: "Bitte ein gültiges Datum + Uhrzeit angeben." }, 400);
|
|
}
|
|
if (link && !/^https?:\/\//i.test(link)) {
|
|
return json(res, { ok: false, error: "Link muss mit http:// oder https:// beginnen." }, 400);
|
|
}
|
|
if (aktiv && zieldatum <= new Date()) {
|
|
return json(res, { ok: false, error: "Das Zieldatum liegt in der Vergangenheit — zum Aktivieren muss es in der Zukunft liegen." }, 400);
|
|
}
|
|
|
|
const titel = await uebersetzeAlleSprachen(titelDe);
|
|
|
|
const eintrag = {
|
|
titel,
|
|
zieldatum: zieldatum.toISOString(),
|
|
link,
|
|
aktiv,
|
|
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(SETTINGS_KEY, JSON.stringify(eintrag), eintrag.aktualisiertAm);
|
|
|
|
logAction(session.actor, "special_event.saved", null, { titel: titelDe, zieldatum: eintrag.zieldatum, aktiv });
|
|
return json(res, { ok: true, event: eintrag });
|
|
}
|