Files
dogfather-universe/server/workspace-teilen.js
T
DogFatherGitandClaude Opus 5 cadab24a5f Kampagnen-Kacheln: gegliederte Texte, Geschenke mit Preis, aufklappbar
Filipe, mit einem Bildschirmfoto des Regelblocks: "man soll die titel
in den texten besser erkennen und sehen was zu was passt. ich will
auch dass man sieht welche geschenke bei dem event zaehlen und so.
ich will alle informationen die moeglich sind wenn man den link
hochlaedt. ... auch wenn man teilt soll es einfach perfekt sein. man
soll alle infos sehen die man braucht. die kacheln von den events
soll auch nicht so lang sein, die soll man aufklappen koennen. genau
wie eine trennung fuer heute, letzte woche letzten monat und so."

DER FEHLER LAG NICHT IM TEXT, SONDERN IN SEINER ANZEIGE. Im Feld
stand die ganze Zeit eine Gliederung -- Hinweise mit "* ", eine
Ueberschrift mit Untertitel hinter einem Gedankenstrich, Absaetze
ohne Zeichen, Aufzaehlungen mit "- ". Die Kachel hat daraus mit
punkteAusText() EINE flache Liste gemacht: jede Zeile derselbe
Strich, alle gleich gross.

  gliederung()      liest die Gliederung, die schon dasteht, statt
                    den Text umzuschreiben. Die beiden Kampagnen, die
                    heute in der Datenbank liegen, sehen dadurch
                    sofort richtig aus -- ohne Umstellung.
  Geschenke         kommen aus der gelesenen LISTE, nicht aus dem
                    Text, und stehen OHNE Aufklappen auf der Kachel.
  Aufklappen        alles Uebrige hinter einem Druck: gemessen 555 px
                    weniger je Kachel bei Gipfelstuermer.
  Gruppen           je Spalte laeuft / kommt / vorbei, Vergangenes
                    nach diese Woche, dieser Monat, aelter.

NEU GELESEN (stand in allen vier gespeicherten Seiten und wurde nie
angesehen): die Rangliste mit ihren Gruppen ("Superstars" bis
"5. Liga", bei Glow Up "Team Glow"/"Team Shine"), die Beschreibung je
Missionsgruppe, der Steckbrief (Kurznummer, Markt, Sprache, Zone),
der zweite Bildbaustein, und Ueberschriften INNERHALB der Klapptexte
-- "WELCHE LIGA BIN ICH?" hat jetzt sechs eingerueckte Unterpunkte
statt zwoelf gleichrangiger Zeilen.

EVENT_TEXT_MAX 6000 -> 20000. Der Regeltext von Gipfelstuermer ist
7679 Zeichen lang; es fielen bisher jedes Mal rund 1600 Zeichen
echter Inhalt weg. Die Zahl steht jetzt an EINER Stelle.

TEILEN: Was die KAMPAGNE sagt, geht hinaus (Aufgaben, Geschenke,
Regeln) -- wer WIR sind, nicht (Name, Rolle, Haus, Kampagnennummer,
Steckbrief). Und keine Hinweiszeile: "Die Preise konnten nicht
gelesen werden -- bitte nachtragen" ist eine Notiz an DogFather und
hat auf einer Seite, die er in Discord postet, nichts zu suchen.

BERICHTIGT, WEIL NACHGEMESSEN:
  - Number(null) ist 0 und 0 ist endlich -- "Feuerwerk" bekam an drei
    Stellen "0 Coins" angeschrieben, also eine falsche Preisangabe.
  - Ein Kommentar im Leser behauptete, der Wertungszeitraum weiche
    vom Kampagnenzeitraum ab. Nachgemessen ist er in allen drei
    Seiten mit Rangliste auf die Millisekunde gleich; ich hatte zwei
    verschiedene Kampagnen verglichen.
  - bild-kampagne.mjs verlangte "jede Kachel traegt den Ton ihrer
    Seite" und war damit seit dem 07.10. rot, weil an dem Tag bewusst
    das Gegenteil entschieden wurde (Zustandston, Seitenkante). Nicht
    aufgefallen, weil nach dem Lauf nur die Zahl der MESSUNGEN notiert
    wurde, nicht die der FEHLER.
  - .kv-seite > .eintrag-karte wird .kv-seite .eintrag-karte: Die
    Karten haengen durch die Gruppen eine Ebene tiefer. Das waere der
    fuenfte Direktkind-Selektor gewesen, der nach einem Umbau still
    ausfaellt -- diesmal vorher gesehen.

GEPRUEFT: 186 (lesen, +58), 148 (Route/Teilen, +25), 158 (Kachel,
+64), 67 (Agentur, +5), 67 Messungen (Bild, +14), dazu struktur,
css-klassen, zeichen, deutsche-texte, tippziele, lesbarkeit und
haus-trennung (107, unveraendert). Sechs Gegenproben: alle sechs
Sabotagen werden bemerkt, alle Dateien byte-gleich wiederhergestellt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-07 22:39:03 +02:00

620 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
EIN EVENT NACH DRAUSSEN TEILEN (07.10.2026)
Filipe: „ich will auch eine option wo ich das teilen kann so dass ich
es z.b. in discord poste und es erscheint dan im discord mit einem
geilen banner und perfekten text. … ich will einfach dass es perfekt
läuft und klappt … und so dass die seite geschützt bleibt."
Beides zusammen ist die ganze Aufgabe dieser Datei.
---------------------------------------------------------------------
WARUM ES EINE SEITE OHNE ANMELDUNG BRAUCHT
Discord baut seine Vorschau selbst: Es ruft die Adresse ab, liest die
Open-Graph-Angaben im Kopf und zeigt Bild, Titel und Text. Es meldet
sich dabei NIRGENDS an und schickt keinen Keks mit. Eine Seite hinter
der Wand ergibt deshalb im Chat nur einen nackten Link — oder, noch
schlechter, die Vorschau der Anmeldeseite.
Dasselbe gilt für WhatsApp, Telegram, Signal und Slack. Wer teilen
will, braucht eine öffentliche Seite. Die Frage ist nicht OB, sondern
WIE WENIG.
---------------------------------------------------------------------
WIE WENIG — die fünf Regeln, an denen der Schutz hängt
1. NICHTS IST OFFEN, BIS JEMAND ES OEFFNET. Ohne `teilen_token` gibt
es keine Seite. Die Vorgabe ist zu, nicht offen.
2. DER SCHLUESSEL WIRD GEWUERFELT, NICHT GEZAEHLT. 16 Byte Zufall.
Eine laufende Nummer liesse sich durchprobieren -- und dann waere
nicht ein Event offen, sondern alle.
3. ES GEHT NUR HINAUS, WAS AUF EIN PLAKAT GEHOERT. Titel, Zeitraum,
Einleitung, Banner. KEINE Aufgaben, KEINE Regeln, KEINE Namen,
keine Rollen, keine Nummern, kein Haus. Wer den Link hat, sieht
ein Plakat -- nicht die Arbeitsfläche dahinter.
4. ZURUECKZIEHEN GEHT SOFORT. Ein Druck, und die Seite ist weg --
nicht „nach einer Weile", sondern beim nächsten Abruf.
5. SUCHMASCHINEN BLEIBEN DRAUSSEN. `noindex, nofollow` im Kopf UND
als `X-Robots-Tag` im Kopf der Antwort. Ein geteilter Link soll
im Chat landen, nicht bei Google.
---------------------------------------------------------------------
WARUM AUF DER HAUPTADRESSE UND NICHT IM WORKSPACE
`workspace.dogfather-universe.com` leitet in Caddy alles ausserhalb
von /workspace/ und /assets/ auf die Startseite um -- eine
öffentliche Seite dort hiesse, an dieser Umleitung zu drehen. Die
Hauptadresse ist ohnehin öffentlich; dort kostet es nichts.
Aufrufen:
POST /workspace/api/eintrag/:id/teilen -> { weg }
DELETE /workspace/api/eintrag/:id/teilen -> zurueckgezogen
GET /e/:token -> das Plakat (offen)
GET /e/:token/bild -> das Banner (offen)
===================================================================== */
import express from "express";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { existsSync } from "node:fs";
import {
db, protokolliere, echteIp, sitzungLesen, istLeitung,
DATEN_ORDNER } from "./workspace.js";
import { liefereDatei } from "./helfer-ausliefern.mjs";
/* DIESELBE ZERLEGUNG WIE DIE KACHEL, nicht eine zweite.
`gliederung` macht aus dem Regeltext Abschnitte, `punkteAusText`
aus den Aufgaben eine Liste. Beides hier nachzubauen hiesse: Die
geteilte Seite zeigt irgendwann etwas anderes als die Kachel,
und niemand koennte sagen, welche von beiden recht hat. */
import { gliederung } from "./kampagne-lesen.mjs";
import { punkteAusText } from "./workspace-bereiche.js";
export const teilenRouter = express.Router();
const ORDNER = join(DATEN_ORDNER, "dateien");
/* Die Adresse, unter der ein geteiltes Event steht. Sie steht hier
EINMAL -- die Oberfläche bekommt den fertigen Weg vom Server und
baut ihn nicht selbst zusammen. Zwei Fassungen derselben Adresse
laufen auseinander, und eine davon landet dann in einem Chat. */
export const TEILEN_BASIS =
process.env.TEILEN_BASIS || "https://dogfather-universe.com";
/** Ein neuer Schlüssel. 16 Byte, als base64url -- 22 Zeichen ohne
* Sonderzeichen, die in einer Adresse Ärger machen. */
const neuerSchluessel = () => randomBytes(16).toString("base64url");
const jetzt = () => new Date().toISOString();
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
next();
}
/* ---------- Der Knopf im Workspace ------------------------------------ */
teilenRouter.use("/workspace/api/eintrag", angemeldet);
/** Das Event holen und prüfen, ob es überhaupt geteilt werden darf. */
function eventFuer(req, res) {
const id = Number(req.params.id);
if (!Number.isFinite(id)) { res.status(400).json({ fehler: "keine_nummer" }); return null; }
const e = db().prepare(
"SELECT id, bereich, art, titel, teilen_token, haus FROM eintraege WHERE id = ?").get(id);
if (!e) { res.status(404).json({ fehler: "nicht_gefunden" }); return null; }
/* NUR AGENTUR-EVENTS. Ein Aushang aus dem Rudel, eine Aufgabe oder
eine vertrauliche Aufzeichnung hat auf einem Plakat nichts
verloren -- und „teilen" waere dort nicht einmal gemeint. Die
Frage wird POSITIV gestellt: Was hier nicht ausdrücklich erlaubt
ist, ist es nicht. */
if (e.bereich !== "agentur" || e.art !== "kampagne") {
res.status(403).json({
fehler: "Teilen gibt es nur für Kampagnen und Events der Agentur.",
});
return null;
}
/* Und nur, wenn es zur Agentur gehört. Ein Eintrag aus dem Haus
Team Dogi wird nicht öffentlich, auch nicht aus Versehen. */
if (e.haus === "crew") {
res.status(403).json({
fehler: "Dieses Event gehört zu Team Dogi und wird nicht geteilt.",
});
return null;
}
if (!istLeitung(req.person)) {
res.status(403).json({
fehler: "Teilen dürfen DogFather und die Manager.",
});
return null;
}
return e;
}
teilenRouter.post("/workspace/api/eintrag/:id(\\d+)/teilen", (req, res) => {
const e = eventFuer(req, res);
if (!e) return undefined;
try {
let schluessel = e.teilen_token;
if (!schluessel) {
/* Beim zweiten Druck KEIN neuer Schlüssel: Der alte Link ist
vielleicht schon in einem Chat. Einen neuen zu würfeln hiesse,
ihn stillschweigend ins Leere laufen zu lassen. */
schluessel = neuerSchluessel();
db().prepare(
"UPDATE eintraege SET teilen_token = ?, teilen_seit = ? WHERE id = ?")
.run(schluessel, jetzt(), e.id);
protokolliere("event_geteilt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `Eintrag ${e.id} "${String(e.titel).slice(0, 60)}"`,
});
}
return res.json({ weg: `${TEILEN_BASIS}/e/${schluessel}`, schon: !!e.teilen_token });
} catch (fehler) {
console.error("[teilen] anlegen:", fehler?.message);
return res.status(503).json({ fehler: "Das Teilen hat gerade nicht geklappt." });
}
});
teilenRouter.delete("/workspace/api/eintrag/:id(\\d+)/teilen", (req, res) => {
const e = eventFuer(req, res);
if (!e) return undefined;
try {
db().prepare(
"UPDATE eintraege SET teilen_token = NULL, teilen_seit = NULL WHERE id = ?")
.run(e.id);
protokolliere("event_zurueckgezogen", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `Eintrag ${e.id}`,
});
return res.json({ weg: null });
} catch (fehler) {
console.error("[teilen] zurueckziehen:", fehler?.message);
return res.status(503).json({ fehler: "Das Zurückziehen hat gerade nicht geklappt." });
}
});
/* ---------- Das Plakat, offen für alle -------------------------------- */
/** Was in HTML nicht roh stehen darf. Vier Zeichen, keine Bibliothek:
* Alles hier geht in Text- oder Attributstellen, nicht in Skript. */
const sicher = (s) => String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/** Ein Datum für Menschen: 05.10.2026 */
function tagText(iso) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ""));
return m ? `${m[3]}.${m[2]}.${m[1]}` : null;
}
/** Das Event zu einem Schlüssel -- oder null. */
function eventZu(token) {
if (!/^[A-Za-z0-9_-]{10,64}$/.test(String(token || ""))) return null;
try {
return db().prepare(
/* NEU SEIT DEM 07.10.2026: event_aufgaben, event_regeln,
kampagne_daten. Siehe die Begruendung bei `seitePlakat`,
wo die Grenze gezogen wird -- was die KAMPAGNE sagt, geht
hinaus; was ueber UNS etwas sagt, nicht. Deshalb steht
`erstellt_von` hier weiterhin nicht, und `haus` auch
nicht. */
`SELECT id, titel, text, datum, event_ende, kampagne_id, teilen_seit,
event_aufgaben, event_regeln, kampagne_daten
FROM eintraege
WHERE teilen_token = ? AND bereich = 'agentur' AND art = 'kampagne'`)
.get(String(token)) || null;
} catch { return null; }
}
/* NICHT IN SUCHMASCHINEN. Steht als Kopfzeile UND im HTML: Die
Kopfzeile gilt auch für das Banner, das gar kein HTML hat. */
function nichtIndizieren(res) {
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
}
teilenRouter.get("/e/:token/bild", (req, res) => {
nichtIndizieren(res);
const e = eventZu(req.params.token);
if (!e) return res.status(404).type("text/plain").send("nicht gefunden");
let datei;
try {
datei = db().prepare(
"SELECT name_datei, typ FROM dateien WHERE eintrag_id = ? ORDER BY id LIMIT 1")
.get(e.id);
} catch { datei = null; }
if (!datei) return res.status(404).type("text/plain").send("kein Bild");
const pfad = join(ORDNER, datei.name_datei);
if (!existsSync(pfad)) return res.status(404).type("text/plain").send("kein Bild");
if (datei.typ) res.setHeader("Content-Type", datei.typ);
/* Eine Stunde im Zwischenspeicher: Discord holt das Bild einmal je
Nachricht, und ein Banner ändert sich nicht. Länger wäre falsch --
wer zurückzieht, will nicht, dass es noch tagelang irgendwo hängt. */
res.setHeader("Cache-Control", "public, max-age=3600");
return liefereDatei(req, res, pfad);
});
teilenRouter.get("/e/:token", (req, res) => {
nichtIndizieren(res);
const e = eventZu(req.params.token);
if (!e) {
/* ZURUECKGEZOGEN SIEHT AUS WIE NIE DAGEWESEN. Ein eigener Satz
(„war mal da") würde bestätigen, dass es diesen Schlüssel gab --
und damit Durchprobieren belohnen. */
return res.status(404).type("text/html; charset=utf-8").send(seiteLeer());
}
const von = tagText(e.datum);
const bis = tagText(e.event_ende);
const zeitraum = von && bis && bis !== von ? `${von} – ${bis}` : (von || "");
const text = String(e.text || "").replace(/\s+/g, " ").trim();
/* DISCORD ZEIGT RUND 300 ZEICHEN -- UND DER ZEITRAUM STEHT MIT
DAVOR (berichtigt 07.10.2026).
Hier stand 280. Gemessen kam `og:description` auf 304
Zeichen heraus: Die Beschreibung ist `zeitraum · kurz`, und
der Zeitraum („04.10.2026 – 07.10.2026 · ") braucht allein
26 Zeichen. Wer nur den Text begrenzt, begrenzt nicht die
Angabe, die wirklich hinausgeht.
250 statt 280: 250 + 26 = 276, mit Luft fuer einen laengeren
Zeitraum. Eine Wortgrenze bleibt es -- ein Satz, der mitten
im Wort endet, sieht nach einem Fehler aus. */
const kurz = text.length > 250
? text.slice(0, text.lastIndexOf(" ", 247)) + " …"
: text;
/* ---- WAS DIE KAMPAGNE SAGT (07.10.2026) ------------------
Filipe: „auch wenn man teilt soll es einfach perfekt sein.
man soll alle infos sehen die man braucht."
Bis heute standen hier Titel, Zeitraum, Einleitung und Banner
-- also gerade so viel, dass Discord eine Vorschau bauen kann.
Wer dem Link folgte, erfuhr nicht, was zu tun ist.
DIE GRENZE LIEGT NICHT BEI DER MENGE, SONDERN BEIM GEGENSTAND:
Was die KAMPAGNE sagt, darf hinaus -- Aufgaben, Geschenke,
Regeln. Was ueber UNS etwas sagt, nicht: kein Name, keine
Rolle, kein Haken, kein Haus, keine Kampagnennummer, kein
Steckbrief. Der Grund ist nicht Geheimhaltung um ihrer selbst
willen: Wer den Link weitergibt, gibt eine EINLADUNG weiter,
und eine Einladung nennt die Sache, nicht die Einladenden.
DIE HINWEISE BLEIBEN DRINNEN, und das ist die wichtigste
Zeile hier. „Die Preise konnten nicht gelesen werden -- bitte
nachtragen" ist eine Notiz AN DOGFATHER. Auf einer Seite, die
er in Discord postet, stuende damit vor seinen Creatorn, dass
seine Kachel unfertig ist. Dasselbe gilt fuer die Fussnote
ueber einen gekuerzten Regeltext. Beides sind Hinweiszeilen,
beide fallen hier weg -- an einer Stelle, mit einer Regel.
DER STECKBRIEF BLEIBT EBENFALLS DRINNEN. Kurznummer, Markt
und Zeitzone sind Buchhaltung, keine Einladung; sie stehen
auf der Kachel, wo sie gebraucht werden. */
let auszug = null;
if (e.kampagne_daten) {
try { auszug = JSON.parse(e.kampagne_daten); } catch { auszug = null; }
}
const aufgaben = punkteAusText(e.event_aufgaben).map((p) => p.text);
const abschnitte = gliederung(e.event_regeln,
{ geschenke: auszug?.geschenke || [] })
/* Kein Hinweisabschnitt, und auch keine Hinweiszeile in einem
anderen Abschnitt. */
.filter((a) => a.art !== "hinweise")
.map((a) => ({ ...a, zeilen: a.zeilen.filter((z) => z.art !== "hinweis") }))
.filter((a, i, alle) => a.zeilen.length
|| (alle[i + 1] && alle[i + 1].stufe > a.stufe));
/* Die Geschenke werden aus den Abschnitten herausgezogen -- wie
auf der Kachel: Sie stehen oben, nicht mitten im Regeltext. */
const gabenAb = abschnitte.find((a) => a.art === "geschenke") || null;
const rest = abschnitte.filter((a) => a !== gabenAb);
const gaben = (gabenAb?.zeilen || []).filter((z) => z.art === "geschenk");
/* DIE VORSCHAU IN DISCORD SOLL AUCH OHNE EINLEITUNG ETWAS SAGEN.
Gemessen an „LIVE Glow Up": Die Kampagne hat gar keinen
Einleitungstext (der Inhalt steht in den Klapptexten) -- in der
Vorschau stand damit nur ein Zeitraum. Die ersten Aufgaben
sagen in derselben Zeile mehr als jede Umschreibung. */
const aufgabenKurz = aufgaben.slice(0, 3).join(" · ");
const beschreibung = [zeitraum, kurz || aufgabenKurz]
.filter(Boolean).join(" · ");
const basis = `${TEILEN_BASIS}/e/${encodeURIComponent(req.params.token)}`;
const hatBild = (() => {
try {
return !!db().prepare("SELECT 1 FROM dateien WHERE eintrag_id = ? LIMIT 1").get(e.id);
} catch { return false; }
})();
res.type("text/html; charset=utf-8");
return res.send(seitePlakat({
titel: String(e.titel || "Event"),
zeitraum,
text: kurz,
beschreibung,
basis,
bild: hatBild ? `${basis}/bild` : null,
vonTikTok: !!e.kampagne_id,
aufgaben,
gaben,
gabenTitel: gabenAb?.titel || null,
gabenUnter: gabenAb?.unter || null,
abschnitte: rest,
}));
});
/* ---------- Die beiden Seiten ----------------------------------------- */
const STIL = `
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
padding: 24px;
background: #070a10;
color: #e8f1fa;
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.k {
width: min(560px, 100%);
background: #0d131d;
border: 1px solid rgba(255,255,255,.1);
border-radius: 18px;
overflow: hidden;
}
.k__bild { display: block; width: 100%; height: auto; }
.k__inhalt { padding: 22px 24px 26px; }
.k__schild {
display: inline-flex; align-items: center; gap: 7px;
padding: 3px 12px; border-radius: 999px;
font-size: .72rem; font-weight: 700; letter-spacing: .14em;
text-transform: uppercase;
}
.k__schild::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: currentColor;
}
.k__schild[data-quelle="tiktok"] {
color: #3fbdf5; background: rgba(63,189,245,.15);
}
.k__schild[data-quelle="agentur"] {
color: #cace02; background: rgba(202,206,2,.15);
}
h1 { margin: 14px 0 6px; font-size: 1.6rem; line-height: 1.25; }
.k__zeit { margin: 0 0 14px; color: #9fb2c6; font-weight: 600; }
.k__text { margin: 0; color: #c5d4e3; }
.k__fuss {
margin: 20px 0 0; padding-top: 16px;
border-top: 1px solid rgba(255,255,255,.08);
color: #7f95ad; font-size: .85rem;
}
a { color: #7fd2fb; }
/* ---- Alles, was seit dem 07.10.2026 dazugekommen ist ----
Dieselbe Gliederung wie auf der Kachel, nur ohne Aufklappen:
Wer einem geteilten Link folgt, will lesen und nicht
bedienen. Augenschonend bleibt es trotzdem -- der
Unterschied laeuft ueber Groesse, Gewicht und Sperrung,
nicht ueber grelle Farben. */
.k__block {
margin: 20px 0 0; padding-top: 16px;
border-top: 1px solid rgba(255,255,255,.08);
}
.k__schild2 {
margin: 0 0 10px;
font-size: .74rem; font-weight: 700;
letter-spacing: .1em; text-transform: uppercase;
color: #9fb2c6;
}
.k__liste { margin: 0 0 8px; padding-left: 20px; color: #c5d4e3; }
.k__liste li { margin: 3px 0; font-size: .93rem; }
.k__liste:last-child { margin-bottom: 0; }
.k__woher { margin: 0 0 10px; color: #c5d4e3; font-size: .9rem; }
.k__unter { display: block; margin-top: 2px; color: #9fb2c6; font-size: .88rem; }
.k__gaben { display: flex; flex-wrap: wrap; gap: 7px; margin: 0; padding: 0; list-style: none; }
.k__gabe {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 11px; border-radius: 999px;
border: 1px solid rgba(226,193,90,.28);
background: rgba(226,193,90,.08);
font-size: .9rem; max-width: 100%;
}
.k__gname { font-weight: 600; overflow-wrap: anywhere; }
.k__coins {
padding: 1px 8px; border-radius: 999px;
background: rgba(226,193,90,.18); color: #e8d08f;
font-size: .82rem; font-weight: 700; white-space: nowrap;
}
.k__gzusatz { color: #9fb2c6; font-size: .84rem; overflow-wrap: anywhere; }
.k__abschnitt { margin: 0 0 14px; }
.k__abschnitt:last-child { margin-bottom: 0; }
.k__abschnitt[data-stufe="3"] {
margin-left: 2px; padding-left: 12px;
border-left: 2px solid rgba(255,255,255,.1);
}
.k__titel {
margin: 0 0 6px;
font-size: .8rem; font-weight: 700;
letter-spacing: .08em; text-transform: uppercase;
color: #d7e3f0;
}
.k__abschnitt[data-stufe="3"] > .k__titel {
font-size: .92rem; font-weight: 650;
letter-spacing: .01em; text-transform: none;
}
.k__absatz { margin: 0 0 7px; color: #c5d4e3; font-size: .93rem; }
.k__absatz:last-child { margin-bottom: 0; }
.k__frage { margin: 11px 0 5px; color: #e8f1fa; font-weight: 650; font-size: .93rem; }
.k__frage:first-child { margin-top: 0; }
@media (max-width: 480px) {
.k__abschnitt[data-stufe="3"] { padding-left: 9px; }
}
`;
/* ===================================================================
DIE BAUTEILE DER OFFENEN SEITE (07.10.2026)
ALLES GEHT DURCH `sicher()`. Diese Seite ist die einzige im
Haus ohne Anmeldung, und ihr Inhalt kommt aus einer FREMDEN
Quelle (einer TikTok-Seite). Ein `<script>` im Titel einer
Kampagne waere sonst ein Skript auf unserer Domain.
LEERE BLOECKE ERSCHEINEN NICHT. Eine Ueberschrift „Aufgaben"
ueber einer leeren Flaeche sieht aus, als sei das Laden
abgebrochen -- auf einer Seite, die jemand gerade in Discord
gepostet hat, ist das das Letzte, was man will.
=================================================================== */
/** Eine Zahl mit Tausenderpunkt, wie man sie liest. */
function coinText(n) {
/* `Number(null)` IST 0 -- und „0 Coins" waere eine falsche
Preisangabe. Derselbe Fehler ist mir am 07.10.2026 an einem Tag
zweimal passiert (Server und Kachel); hier steht er zum
dritten Mal und deshalb mit derselben Sicherung. */
if (n === null || n === undefined || n === "") return null;
const z = Number(n);
if (!Number.isFinite(z)) return null;
return `${z.toLocaleString("de-DE")} ${z === 1 ? "Coin" : "Coins"}`;
}
function blockAufgaben(liste) {
const a = (liste || []).filter(Boolean);
if (!a.length) return "";
return `<section class="k__block">
<h2 class="k__schild2">${a.length === 1 ? "Aufgabe" : "Aufgaben"}</h2>
<ul class="k__liste">${a.map((x) => `<li>${sicher(x)}</li>`).join("")}</ul>
</section>`;
}
function blockGaben(d) {
const g = (d.gaben || []).filter(Boolean);
if (!g.length) return "";
const kopf = d.gabenTitel
? `<p class="k__woher">${sicher(d.gabenTitel)}${
d.gabenUnter ? ` <span class="k__unter">${sicher(d.gabenUnter)}</span>` : ""}</p>`
: "";
return `<section class="k__block">
<h2 class="k__schild2">Diese Geschenke zählen</h2>
${kopf}
<ul class="k__gaben">${g.map((x) => {
const c = coinText(x.coins);
return `<li class="k__gabe"><span class="k__gname">${sicher(x.text)}</span>${
c ? `<span class="k__coins">${sicher(c)}</span>` : ""}${
x.zusatz ? `<span class="k__gzusatz">${sicher(x.zusatz)}</span>` : ""}</li>`;
}).join("")}</ul>
</section>`;
}
/** Die Zeilen eines Abschnitts in ihrer Reihenfolge.
* Aufeinanderfolgende Punkte kommen in EINE Liste; ein Absatz
* dazwischen beendet sie -- dieselbe Regel wie auf der Kachel, und
* aus demselben Grund: Bei den Teilnahmebedingungen steht zwischen
* zwei Aufzaehlungen ein Satz, der sich auf die ZWEITE bezieht. */
function zeilenHtml(zeilen) {
const teile = [];
let liste = null;
for (const z of zeilen || []) {
if (z.art === "punkt" || z.art === "geschenk") {
if (!liste) { liste = []; teile.push({ liste }); }
liste.push(z);
continue;
}
liste = null;
teile.push({ zeile: z });
}
return teile.map((s) => {
if (s.liste) {
return `<ul class="k__liste">${s.liste.map((z) => {
const c = coinText(z.coins);
return `<li>${sicher(z.text)}${c ? ` <span class="k__coins">${sicher(c)}</span>` : ""}</li>`;
}).join("")}</ul>`;
}
const klasse = s.zeile.art === "frage" ? "k__frage" : "k__absatz";
return `<p class="${klasse}">${sicher(s.zeile.text)}</p>`;
}).join("");
}
function blockRegeln(abschnitte) {
const a = (abschnitte || []).filter(Boolean);
if (!a.length) return "";
return `<section class="k__block">
<h2 class="k__schild2">Regeln &amp; Infos</h2>
${a.map((s) => `<div class="k__abschnitt" data-stufe="${s.stufe === 3 ? 3 : 2}">${
s.titel ? `<h3 class="k__titel">${sicher(s.titel)}</h3>` : ""}${
s.unter ? `<p class="k__unter">${sicher(s.unter)}</p>` : ""}${
zeilenHtml(s.zeilen)}</div>`).join("")}
</section>`;
}
function seitePlakat(d) {
const bildMarke = d.bild
? `<meta property="og:image" content="${sicher(d.bild)}" />
<meta name="twitter:card" content="summary_large_image" />`
: '<meta name="twitter:card" content="summary" />';
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${sicher(d.titel)} · Spicy Media</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
<meta name="description" content="${sicher(d.beschreibung)}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Spicy Media · Kampagnen &amp; Events" />
<meta property="og:title" content="${sicher(d.titel)}" />
<meta property="og:description" content="${sicher(d.beschreibung)}" />
<meta property="og:url" content="${sicher(d.basis)}" />
${bildMarke}
<meta name="theme-color" content="${d.vonTikTok ? "#3fbdf5" : "#cace02"}" />
<style>${STIL}</style>
</head>
<body>
<main class="k">
${d.bild ? `<img class="k__bild" src="${sicher(d.bild)}" alt="" />` : ""}
<div class="k__inhalt">
<span class="k__schild" data-quelle="${d.vonTikTok ? "tiktok" : "agentur"}">${
d.vonTikTok ? "TikTok" : "Agentur"}</span>
<h1>${sicher(d.titel)}</h1>
${d.zeitraum ? `<p class="k__zeit">${sicher(d.zeitraum)}</p>` : ""}
${d.text ? `<p class="k__text">${sicher(d.text)}</p>` : ""}
${blockAufgaben(d.aufgaben)}
${blockGaben(d)}
${blockRegeln(d.abschnitte)}
<p class="k__fuss">Geteilt aus dem Creator Workspace von Spicy Media.</p>
</div>
</main>
</body>
</html>`;
}
function seiteLeer() {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nicht gefunden</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
<style>${STIL}</style>
</head>
<body>
<main class="k"><div class="k__inhalt">
<h1>Nicht gefunden</h1>
<p class="k__text">Diesen Link gibt es nicht (mehr).</p>
<p class="k__fuss">Spicy Media</p>
</div></main>
</body>
</html>`;
}