Filipe: "beim teilen wird auch nur der link geteilt, und ich will dass
wenn ich es teile im discord der agentur, dass ein banner und ein
kompletter fertiger text fuer die creator entsteht und nicht einfach
der link kopiert wird damit die leute drauf gehen. das ist kake."
Und: "perfektionier die zwei events kategorien. ich will es noch viel
krasser und spezieller."
EIN LINK IN EINEM DISCORD-KANAL IST EINE AUFFORDERUNG. Wer ihn nicht
anklickt -- und das sind die meisten -- weiss nicht, dass es die
Kampagne gibt. Was in Discord steht, muss die Kampagne SEIN. Der
Teilen-Knopf legt deshalb einen fertigen Beitrag in die Zwischenablage:
Titel als Ueberschrift, Zeitraum, Einleitung, die Aufgaben als Liste,
die Geschenke mit Coinpreis, "Gut zu wissen" aus Rangliste, Zeitplan
und Teilnahme. Er steht zusaetzlich sichtbar unter der Kachel, mit
Zeichenzaehler und zweitem Kopierknopf -- was man nicht gesehen hat,
fuegt man blind ein.
ZWEI DINGE SIND DABEI NICHT BELIEBIG:
- Der Link steht GANZ AM ENDE. Discord setzt die Bannervorschau
UNTER die Nachricht; steht er oben, schiebt das Banner den Text
aus dem Blick.
- Der Link steht NACKT da. Spitze Klammern unterdruecken die
Vorschau -- und damit genau das Banner, um das es geht.
2000 ZEICHEN, UND NICHT EINS MEHR. Gekuerzt wird auf dem Server, in
fester Reihenfolge und an Abschnittsgrenzen: erst "Gut zu wissen",
dann die Einleitung auf zwei Saetze, dann die Geschenke. Titel,
Zeitraum, Aufgaben und Link bleiben IMMER. Gemessen gegen sieben
Laengen und einen 1500-Zeichen-Titel als Haertefall.
Gebaut wird der Beitrag aus `teilenDaten()` -- derselben Funktion, aus
der auch die offene Seite entsteht. Zwei Aufbauten fuer dasselbe Event
wuerden irgendwann Verschiedenes sagen, und niemand koennte entscheiden,
welcher recht hat.
DIE ZWEI SEITEN: ein gezeichnetes Wappen in der Farbe der Spalte, ein
Kopf mit Lichtkante und Verlauf, der beim Scrollen oben stehen bleibt,
und eine Lagezeile ("2 laufen - 1 kommt - 3 vorbei"). Gezaehlt mit
derselben Regel, die die Gruppen darunter bildet; was null ist,
erscheint nicht. Augenschonend bleibt es ueber Form und Tiefe, nicht
ueber Leuchtkraft.
NEBENBEFUND, BEHOBEN: Der Einlesekasten war durch Seitenwahl und
Bildweg auf 521 px gewachsen -- auf 412 px mehr als ein halber
Telefonbildschirm, bevor man die erste Kachel sieht. Er klappt jetzt zu
(82 px) und merkt sich die Wahl; ein Druck, und er bleibt offen.
BERICHTIGT, WEIL GEMESSEN:
- Der Bindestrich wurde entwertet: "4\-woechige", "Ahorn\-Welt". In
Discord ist "-" nur am Zeilenanfang eine Aufzaehlung.
- Das Klappschild hatte nur eine Handy-Regel und sonst kein
Aussehen -- auf 1400 px ein nackter Knopf mit 27 px statt 48.
- bild-kampagne mass elementFromPoint ausserhalb des
Sichtfensters und meldete "etwas liegt auf dem Knopf", wo nichts
lag; und r(null) liess den ganzen Lauf abstuerzen, statt die
Zusicherung rot zu machen.
- EINE GEGENPROBE WAR BLIND: DISCORD_MAX von 2000 auf 99999 zu
setzen liess die Pruefung gruen -- der Testbeitrag ist 1230
Zeichen lang, die Grenze wurde also nie geprueft. Jetzt mit einem
Beitrag, der ohne Kuerzung sicher zu lang waere.
GEPRUEFT: 190 (Route und Teilen, +8), 183 (Kachel, +13), 69 Messungen
(Bild, +2), 67 (Agentur), 43 (Bildleser), struktur, css-klassen,
zeichen, deutsche-texte, tippziele. Sechs Gegenproben: Link nur noch
Link, Link oben statt unten, Grenze weg, Lagezeile weg, Kopf klebt
nicht, Kasten wieder offen -- alle sechs werden bemerkt, alle Dateien
byte-gleich wiederhergestellt.
Co-Authored-By: Claude Opus 5 <[email protected]>
860 lines
34 KiB
JavaScript
860 lines
34 KiB
JavaScript
/* =====================================================================
|
||
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)}"`,
|
||
});
|
||
}
|
||
/* ---- DER FERTIGE BEITRAG KOMMT MIT (08.10.2026) ----------
|
||
|
||
Filipe: „beim teilen wird auch nur der link geteilt ... das
|
||
ist kake.“ Er hat recht: Ein Link ist eine Aufforderung,
|
||
und wer ihn nicht anklickt, weiß nicht, dass es die
|
||
Kampagne gibt.
|
||
|
||
GEBAUT WIRD HIER, NICHT IM BROWSER. Der Beitrag enthält
|
||
Aufgaben, Geschenke und Regeln -- dieselben Daten, die die
|
||
offene Seite zeigt, aus derselben Funktion
|
||
(`teilenDaten`). Im Browser gebaut wäre es die zweite
|
||
Fassung derselben Sache, und die läuft auseinander.
|
||
|
||
FÄLLT ER AUS, BLEIBT DER LINK. Ein Teilen, das wegen eines
|
||
Textbaufehlers gar nicht geht, wäre schlimmer als eines
|
||
ohne Text -- deshalb steht der Bau in einem eigenen
|
||
`try`. */
|
||
const basis = `${TEILEN_BASIS}/e/${schluessel}`;
|
||
let beitrag = null;
|
||
try {
|
||
const voll = eventMitNummer(e.id);
|
||
if (voll) beitrag = discordText(teilenDaten(voll, basis));
|
||
} catch (f) {
|
||
console.error("[teilen] Beitrag:", f?.message);
|
||
beitrag = null;
|
||
}
|
||
return res.json({ weg: basis, text: beitrag, 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/* DIE SPALTEN STEHEN EINMAL DA (08.10.2026).
|
||
|
||
Seit es zwei Abfragen gibt -- eine ueber den Schluessel (die
|
||
offene Seite) und eine ueber die Nummer (der Discord-Text beim
|
||
Teilen) -- waere eine zweite Liste die Sorte Abschrift, die in
|
||
diesem Haus schon einmal drei Spalten gekostet hat. Wer hier
|
||
etwas ergaenzt, ergaenzt es fuer beide.
|
||
|
||
`erstellt_von` und `haus` stehen ABSICHTLICH nicht dabei: Was
|
||
die Kampagne sagt, geht hinaus -- wer wir sind, nicht. */
|
||
const TEILEN_SPALTEN = `id, titel, text, datum, event_ende, kampagne_id,
|
||
teilen_seit, teilen_token, event_aufgaben, event_regeln,
|
||
kampagne_daten`;
|
||
|
||
/** Das Event zu einer Nummer -- fuer den Weg ueber den Teilen-Knopf. */
|
||
function eventMitNummer(id) {
|
||
try {
|
||
return db().prepare(
|
||
`SELECT ${TEILEN_SPALTEN}
|
||
FROM eintraege
|
||
WHERE id = ? AND bereich = 'agentur' AND art = 'kampagne'`)
|
||
.get(Number(id)) || null;
|
||
} catch { return 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 ${TEILEN_SPALTEN}
|
||
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);
|
||
});
|
||
|
||
/* =====================================================================
|
||
WAS VON EINEM EVENT NACH DRAUSSEN GEHT -- AN EINER STELLE
|
||
(08.10.2026)
|
||
|
||
Gerufen von ZWEI Wegen: der offenen Seite (`GET /e/:token`) und
|
||
dem Discord-Text (`POST .../teilen`). Beide zeigen dasselbe
|
||
Event; haetten sie je einen eigenen Aufbau, wuerde die Seite
|
||
irgendwann etwas anderes sagen als der Beitrag daneben -- und
|
||
niemand koennte sagen, welcher von beiden recht hat.
|
||
|
||
Die Grenze ist dieselbe wie vorher: Was die KAMPAGNE sagt, geht
|
||
hinaus; keine Hinweiszeile an DogFather, kein Steckbrief, kein
|
||
Name, keine Rolle, kein Haus.
|
||
===================================================================== */
|
||
export function teilenDaten(e, basis) {
|
||
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 hatBild = (() => {
|
||
try {
|
||
return !!db().prepare("SELECT 1 FROM dateien WHERE eintrag_id = ? LIMIT 1").get(e.id);
|
||
} catch { return false; }
|
||
})();
|
||
|
||
return {
|
||
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,
|
||
};
|
||
}
|
||
|
||
|
||
/* =====================================================================
|
||
EIN FERTIGER BEITRAG FÜR DISCORD (08.10.2026)
|
||
|
||
Filipe: „beim teilen wird auch nur der link geteilt, und ich will
|
||
dass wenn ich es teile im discord der agentur, dass ein banner und
|
||
ein kompletter fertiger text für die creator entsteht und nicht
|
||
einfach der link kopiert wird damit die leute drauf gehen. das ist
|
||
kake."
|
||
|
||
Er hat recht, und zwar aus einem Grund, den man im Workspace nicht
|
||
sieht: Ein Link in einem Discord-Kanal ist eine AUFFORDERUNG. Wer
|
||
ihn nicht anklickt — und das sind die meisten — weiß nicht, dass es
|
||
die Kampagne gibt. Was in Discord steht, muss die Kampagne SEIN.
|
||
|
||
---------------------------------------------------------------------
|
||
DER BANNER KOMMT VON SELBST, UND ZWAR ÜBER DEN LINK
|
||
|
||
Discord baut aus den Open-Graph-Angaben der offenen Seite eine
|
||
Vorschau mit großem Bild. Die steht UNTER der Nachricht. Deshalb:
|
||
|
||
- Der Link steht GANZ AM ENDE. Steht er oben, schiebt die Vorschau
|
||
den Text nach unten aus dem Blick.
|
||
- Der Link steht NACKT da, nicht in spitzen Klammern. `<…>`
|
||
unterdrückt die Vorschau — und damit genau das Banner, um das es
|
||
geht.
|
||
|
||
---------------------------------------------------------------------
|
||
2000 ZEICHEN, UND NICHT EINS MEHR
|
||
|
||
Discord nimmt pro Nachricht 2000 Zeichen. Eine Nachricht, die beim
|
||
Einfügen abgeschnitten wird, endet mitten im Wort — und niemand
|
||
merkt es, bis ein Creator nachfragt. Gekürzt wird deshalb HIER, in
|
||
fester Reihenfolge und an Abschnittsgrenzen:
|
||
|
||
1. „Gut zu wissen" fällt weg (steht vollständig auf der Seite)
|
||
2. die Einleitung wird kürzer (steht vollständig auf der Seite)
|
||
3. die Geschenke fallen weg (stehen vollständig auf der Seite)
|
||
|
||
Titel, Zeitraum, Aufgaben und der Link bleiben IMMER. Das sind die
|
||
vier Dinge, wegen denen man den Beitrag überhaupt schreibt.
|
||
|
||
---------------------------------------------------------------------
|
||
KEINE NAMEN, KEINE ROLLEN
|
||
|
||
Derselbe Satz wie bei der offenen Seite: Was die Kampagne sagt, geht
|
||
hinaus. Wer sie eingelesen hat, steht nirgends — der Beitrag kommt
|
||
vom Team, nicht von einer Person.
|
||
===================================================================== */
|
||
|
||
const DISCORD_MAX = 2000;
|
||
|
||
/** Discord-Auszeichnung in fremdem Text entwerten.
|
||
*
|
||
* EIN TITEL MIT EINEM STERN DARIN würde sonst die halbe Nachricht
|
||
* kursiv setzen. Entwertet wird nur dort, wo FREMDER Text steht
|
||
* (Titel, Aufgaben, Geschenke) -- nicht in den Überschriften, die wir
|
||
* selbst schreiben.
|
||
*
|
||
* ZWEI SORTEN ZEICHEN, UND SIE BRAUCHEN ZWEI REGELN (berichtigt
|
||
* 08.10.2026, beim ersten Blick auf den fertigen Beitrag):
|
||
*
|
||
* ÜBERALL \ * _ ~ ` | die wirken mitten im Wort
|
||
* NUR AM ANFANG # - > die wirken nur am Zeilenanfang
|
||
*
|
||
* Zuerst stand der Bindestrich in der ersten Gruppe. Heraus kam
|
||
* „4\-wöchige Wachstum Challenge" und „Ahorn\-Welt" -- die
|
||
* Rückstriche standen sichtbar im Beitrag. Ein Bindestrich mitten im
|
||
* Wort ist in Discord keine Aufzählung; ihn zu entwerten macht den
|
||
* Text kaputt, statt ihn zu retten. Dasselbe gilt für `#` und `>`.
|
||
*
|
||
* Der Rückstrich ist in Discord das Entwertungszeichen, genau wie in
|
||
* Markdown -- und steht deshalb selbst mit in der ersten Gruppe. */
|
||
function discordRoh(s) {
|
||
return String(s ?? "")
|
||
.replace(/([\\*_~`|])/g, "\\$1")
|
||
.replace(/^([#\->])/, "\\$1");
|
||
}
|
||
|
||
/** Ein fertiger Beitrag für den Agentur-Kanal.
|
||
*
|
||
* @param {object} d was `teilenDaten()` geliefert hat
|
||
* @returns {string} höchstens 2000 Zeichen, Link am Ende
|
||
*/
|
||
export function discordText(d) {
|
||
const kopf = [];
|
||
kopf.push(`# ${discordRoh(d.titel)}`);
|
||
const zeile2 = [];
|
||
if (d.zeitraum) zeile2.push(`**${discordRoh(d.zeitraum)}**`);
|
||
zeile2.push(d.vonTikTok ? "TikTok-Kampagne" : "Event der Agentur");
|
||
kopf.push(zeile2.join(" · "));
|
||
|
||
/* Die Einleitung. Sie darf lang sein -- gekürzt wird sie erst, wenn
|
||
es wirklich eng wird (siehe unten). */
|
||
const einleitung = d.text ? [``, discordRoh(d.text)] : [];
|
||
|
||
const aufgaben = (d.aufgaben || []).length
|
||
? [``, `## 🎯 ${d.aufgaben.length === 1 ? "Deine Aufgabe" : "Deine Aufgaben"}`,
|
||
...d.aufgaben.map((a) => `- ${discordRoh(a)}`)]
|
||
: [];
|
||
|
||
const gaben = (d.gaben || []).length
|
||
? [``, "## 🎁 Diese Geschenke zählen",
|
||
...d.gaben.map((g) => {
|
||
const c = coinText(g.coins);
|
||
const zusatz = c || g.zusatz;
|
||
return `- **${discordRoh(g.text)}**${zusatz ? ` · ${discordRoh(zusatz)}` : ""}`;
|
||
})]
|
||
: [];
|
||
|
||
/* „Gut zu wissen": die Punkte aus Rangliste, Zeitplan und Teilnahme
|
||
-- aber nur die kurzen. Ein ganzer Teilnahmebedingungs-Absatz in
|
||
einem Discord-Beitrag liest niemand; dafür gibt es die Seite. */
|
||
const merken = [];
|
||
for (const a of d.abschnitte || []) {
|
||
if (!/rangliste|zeitplan|teilnahme/i.test(a.art || "")) continue;
|
||
for (const z of a.zeilen || []) {
|
||
if (z.art !== "punkt" && z.art !== "text") continue;
|
||
if (z.text.length > 120) continue;
|
||
merken.push(`- ${discordRoh(z.text)}`);
|
||
if (merken.length >= 5) break;
|
||
}
|
||
if (merken.length >= 5) break;
|
||
}
|
||
const wissen = merken.length ? [``, "## ℹ️ Gut zu wissen", ...merken] : [];
|
||
|
||
/* DER LINK ZULETZT UND NACKT -- Begründung oben. */
|
||
const fuss = [``, "-# Alle Regeln, Preise und Ligen im Detail:", d.basis];
|
||
|
||
/* ZUSAMMENSETZEN UND NOTFALLS KÜRZEN. Gebaut wird von der
|
||
vollständigen Fassung abwärts, nicht von der kurzen aufwärts: So
|
||
steht immer die beste Fassung da, die noch passt. */
|
||
const bauen = (teile) => teile.flat().join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
||
|
||
let aus = bauen([kopf, einleitung, aufgaben, gaben, wissen, fuss]);
|
||
if (aus.length <= DISCORD_MAX) return aus;
|
||
|
||
aus = bauen([kopf, einleitung, aufgaben, gaben, fuss]);
|
||
if (aus.length <= DISCORD_MAX) return aus;
|
||
|
||
/* Die Einleitung auf zwei Sätze. An einer Satzgrenze, nicht mitten
|
||
im Wort -- ein abgeschnittenes Wort sieht nach einem Fehler aus. */
|
||
if (d.text) {
|
||
const saetze = String(d.text).split(/(?<=[.!?])\s+/);
|
||
const kurz = saetze.slice(0, 2).join(" ");
|
||
aus = bauen([kopf, [``, discordRoh(kurz)], aufgaben, gaben, fuss]);
|
||
if (aus.length <= DISCORD_MAX) return aus;
|
||
}
|
||
|
||
aus = bauen([kopf, aufgaben, gaben, fuss]);
|
||
if (aus.length <= DISCORD_MAX) return aus;
|
||
|
||
aus = bauen([kopf, aufgaben, fuss]);
|
||
if (aus.length <= DISCORD_MAX) return aus;
|
||
|
||
/* Selbst Titel, Aufgaben und Link passen nicht -- dann werden die
|
||
Aufgaben beschnitten. Der Link bleibt in jedem Fall: Ohne ihn ist
|
||
der Beitrag eine Sackgasse. */
|
||
const ohneAufgaben = bauen([kopf, fuss]);
|
||
const platz = DISCORD_MAX - ohneAufgaben.length - 40;
|
||
const gekuerzt = [];
|
||
let laenge = 0;
|
||
for (const a of aufgaben.slice(2)) {
|
||
if (laenge + a.length + 1 > platz) break;
|
||
gekuerzt.push(a);
|
||
laenge += a.length + 1;
|
||
}
|
||
return bauen([kopf,
|
||
gekuerzt.length ? [``, aufgaben[1], ...gekuerzt, "- …"] : [],
|
||
fuss]);
|
||
}
|
||
|
||
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 basis = `${TEILEN_BASIS}/e/${encodeURIComponent(req.params.token)}`;
|
||
res.type("text/html; charset=utf-8");
|
||
return res.send(seitePlakat(teilenDaten(e, basis)));
|
||
});
|
||
|
||
/* ---------- 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 & 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 & 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>`;
|
||
}
|