diff --git a/server/bild-kampagne.mjs b/server/bild-kampagne.mjs
index a481cbad..2401a7d7 100644
--- a/server/bild-kampagne.mjs
+++ b/server/bild-kampagne.mjs
@@ -155,6 +155,30 @@ const browser = await pw.chromium.launch();
const fehlerAufSeite = [];
+/** Den Einlesekasten aufmachen, falls er zu ist.
+ *
+ * Seit dem 08.10.2026 steht er von sich aus zu -- er war auf
+ * 412 px auf 521 px gewachsen, mehr als ein halber
+ * Telefonbildschirm vor der ersten Kachel. Jede Stelle, die
+ * seinen INHALT braucht, muss ihn also erst oeffnen.
+ *
+ * AN EINER STELLE, NICHT DREIMAL ABGESCHRIEBEN: Beim ersten
+ * Versuch stand das Oeffnen nur oben bei der Messung, und die
+ * zwei `fill()` weiter unten liefen in eine 30-Sekunden-Frist --
+ * mit einer Meldung ueber ein Textfeld, das es laut Fehlertext
+ * sehr wohl gab. Das ist die unangenehme Sorte Fehlschlag: Er
+ * sagt die Wahrheit und fuehrt trotzdem in die Irre. */
+const kastenAufmachen = async (seite) => {
+ const schild = await seite.$("#kampagne-schild");
+ if (!schild) return false;
+ const zu = await seite.evaluate(() =>
+ document.getElementById("kampagne-form")?.hidden === true);
+ if (!zu) return true;
+ await schild.click();
+ await seite.waitForTimeout(250);
+ return true;
+};
+
const anmelden = async (seite, rolle, code) => {
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
@@ -214,16 +238,47 @@ async function messen(breite, hoehe, name) {
return { rolle: j.rolle, darf: j.darf_kampagne_einlesen, wirt: location.host };
});
console.log(" /api/ich: " + JSON.stringify(ichSagt));
+
+ /* ERST AUFKLAPPEN (08.10.2026).
+
+ Der Kasten steht seit heute zu -- er war auf 412 px auf 521 px
+ gewachsen, mehr als ein halber Telefonbildschirm vor der ersten
+ Kachel. Gemessen werden soll trotzdem der INHALT: Textfeld,
+ Knopf, Kontrast, Tippziele. Zugeklappt haben die alle die Hoehe
+ null, und die Messung meldete einen Schaden, wo keiner ist.
+
+ DASS ER ZUGEHT, MISST pruef-eventkarte (Abschnitt 4c) -- dort
+ gehoert es hin, zusammen mit dem Rest der Bedienung. Hier geht
+ es um das Aussehen des offenen Kastens. */
+ await kastenAufmachen(seite);
+
const mass = await seite.evaluate(() => {
const k = document.getElementById("kampagneholen");
if (!k || k.hidden) return { da: false };
- const r = (e) => { const b = e.getBoundingClientRect();
+ /* EIN FEHLENDES ELEMENT GIBT NULLEN, KEINEN ABSTURZ
+ (berichtigt 08.10.2026). Vorher warf `r(null)` mitten in der
+ Messung, und der ganze Lauf endete mit „unbehandelter
+ Fehler" -- ohne zu sagen, WELCHES Element fehlte. Nullen
+ machen die Zusicherung darunter rot und nennen dabei den
+ Namen. Ein Fehlschlag ist rot und steht da; ein Absturz
+ sieht aus wie ein kaputtes Werkzeug. */
+ const r = (e) => {
+ if (!e) return { x: 0, y: 0, b: 0, h: 0, fehlt: true };
+ const b = e.getBoundingClientRect();
return { x: Math.round(b.x), y: Math.round(b.y),
- b: Math.round(b.width), h: Math.round(b.height) }; };
+ b: Math.round(b.width), h: Math.round(b.height) };
+ };
const feld = document.getElementById("kampagne-text");
const knopf = document.getElementById("kampagne-holen");
const sagt = document.getElementById("kampagne-sagt");
- const titel = k.querySelector(".kampagneholen__titel");
+ /* DIE UEBERSCHRIFT IST SEIT DEM 08.10.2026 DAS KLAPPSCHILD.
+ `.kampagneholen__titel` gibt es nicht mehr -- der `
` ist
+ ein `` geworden, weil man ihn jetzt druecken kann.
+ Die Messung sucht deshalb das Schild; faellt auch das weg,
+ gibt `r()` keinen Fehler mehr, sondern Nullen, und die
+ Zusicherungen unten werden rot statt den Lauf abzubrechen. */
+ const titel = k.querySelector(".kampagneholen__schildwort")
+ || k.querySelector(".kampagneholen__schild");
const st = getComputedStyle(sagt);
return {
da: true,
@@ -237,6 +292,19 @@ async function messen(breite, hoehe, name) {
/* Ueberdeckt etwas den Knopf? Nicht gerechnet, sondern gefragt:
Was liegt an seinem Mittelpunkt wirklich obenauf? */
obenAufKnopf: (() => {
+ /* ERST IN DEN BLICK HOLEN (berichtigt 08.10.2026).
+
+ `elementFromPoint` fragt das SICHTFENSTER, nicht die
+ Seite: Was darunter liegt, gibt `null`. Seit der Kasten
+ die Seitenwahl und den Bildweg traegt, ist er auf 412 px
+ hoeher als das Fenster -- und die Pruefung meldete
+ „nichts liegt auf dem Knopf“ als Schaden, obwohl der
+ Knopf nur weiter unten stand.
+
+ Die Frage bleibt dieselbe und bleibt wichtig: Am
+ 06.09.2026 lag der Sicht-Umschalter quer ueber dem
+ Chat-Knopf. Nur muss man dafuer hinsehen, wo er ist. */
+ knopf.scrollIntoView({ block: "center" });
const b = knopf.getBoundingClientRect();
const e = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
return e ? (e.id || e.className || e.tagName) : null;
@@ -259,6 +327,27 @@ async function messen(breite, hoehe, name) {
diesem Haus bei 40 und wurden genau deshalb gezogen. */
ok(mass.knopf.h >= 44, `der Knopf ist ${mass.knopf.h} px hoch (mindestens 44)`);
ok(mass.feld.b > 120, `das Textfeld ist ${mass.feld.b} px breit -- kein Schlitz`);
+ /* WIE VIEL PLATZ FRISST DER KASTEN? (08.10.2026)
+
+ Er ist seit dem 07.10. um die Seitenwahl und seit dem 08.10.
+ um den Bildweg gewachsen -- auf 412 px gemessen 539 px, also
+ mehr als ein halber Telefonbildschirm, bevor man die erste
+ Kachel sieht. Die Grenze steht hier, damit das naechste
+ Stueck nicht unbemerkt dazukommt: Eine feste Zahl, die
+ mitwaechst, ist keine Grenze (CLAUDE.md, 06.09.2026). */
+ /* WIE VIEL PLATZ FRISST DER OFFENE KASTEN?
+
+ Er ist seit dem 07.10. um die Seitenwahl und seit dem 08.10. um
+ den Bildweg gewachsen -- auf 412 px gemessen 521 px. Seitdem
+ steht er zu (siehe oben); AUFGEKLAPPT darf er trotzdem nicht
+ beliebig wachsen, sonst ist das Aufklappen ein Sprung ins
+ Nichts. Die Grenze steht hier, damit das naechste Stueck nicht
+ unbemerkt dazukommt: Eine feste Zahl, die mitwaechst, ist keine
+ Grenze (CLAUDE.md, 06.09.2026). */
+ const grenze = breite <= 560 ? 560 : 420;
+ ok(mass.kasten.h <= grenze,
+ `der offene Kasten bleibt bei ${mass.kasten.h} px `
+ + `(hoechstens ${grenze} auf ${breite} px)`);
ok(mass.feld.h >= 72, `und ${mass.feld.h} px hoch, also mehrzeilig`);
/* NICHTS LIEGT AUF DEM KNOPF. Am 06.09.2026 lag der Sicht-Umschalter
@@ -348,6 +437,7 @@ await messen(412, 915, "Auf dem Handy, haeufigste Android-Breite");
await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
await seite.waitForTimeout(1400);
+ await kastenAufmachen(seite);
await seite.fill("#kampagne-text",
"Dein Creator Network lädt dich ein: https://www.tiktok.com/t/ZSbqPsFa8/");
await seite.$eval("#kampagne-holen", (b) => b.click());
@@ -395,6 +485,7 @@ await messen(412, 915, "Auf dem Handy, haeufigste Android-Breite");
}), { status: 502, headers: { "Content-Type": "application/json" } });
};
});
+ await kastenAufmachen(seite);
await seite.fill("#kampagne-text", "https://www.tiktok.com/t/ZSbqPsFa8/");
await seite.$eval("#kampagne-holen", (b) => b.click());
await seite.waitForFunction(
diff --git a/server/pruef-eventkarte.mjs b/server/pruef-eventkarte.mjs
index 229f2410..1359a1ab 100644
--- a/server/pruef-eventkarte.mjs
+++ b/server/pruef-eventkarte.mjs
@@ -1423,6 +1423,127 @@ melde("\n=== 4c. Die Seitenwahl ===");
ok(links.includes("Oktober Event") && links.includes("Glow Up Struktur"),
`ohne Wahl entscheidet weiterhin die Herkunft (links: ${JSON.stringify(links)})`);
+ /* ---- DIE ZWEI SEITEN, KRASSER (08.10.2026) ----------------
+
+ Filipe: „perfektionier die zwei events kategorien. ich will es
+ noch viel krasser und spezieller.“
+
+ Gemessen wird, was dabei herauskommen SOLL, und zwar so, dass ein
+ leerer Kasten nicht durchrutscht: Jede Zusicherung trägt die Zahl
+ der untersuchten Dinge in ihrer Bedingung. */
+ const seiten = await seite.evaluate(() => {
+ const lies = (w) => {
+ const s = getComputedStyle(w);
+ return {
+ breite: Math.round(w.getBoundingClientRect().width),
+ hoehe: Math.round(w.getBoundingClientRect().height),
+ rand: s.borderTopWidth,
+ grund: s.backgroundImage.slice(0, 40),
+ klebt: s.position,
+ };
+ };
+ return [...document.querySelectorAll(".kv-seite")].map((s) => {
+ const w = s.querySelector(".kv-seite__wappen");
+ const k = s.querySelector(".kv-seite__kopf");
+ return {
+ seite: s.dataset.seite,
+ ton: getComputedStyle(s).getPropertyValue("--kv-ton").trim(),
+ wappen: w ? lies(w) : null,
+ kopf: k ? lies(k) : null,
+ lage: [...s.querySelectorAll(".kv-seite__lageteil")].map((x) => ({
+ stand: x.dataset.stand || null,
+ wort: x.textContent.trim(),
+ farbe: getComputedStyle(x).color,
+ })),
+ };
+ });
+ });
+ melde(" -- Seiten: " + JSON.stringify(seiten));
+
+ ok(seiten.length === 2, `zwei Seiten (${seiten.length})`);
+
+ /* ---- Das Wappen ------------------------------------------- */
+ const wappen = seiten.map((s) => s.wappen).filter(Boolean);
+ ok(wappen.length === 2, `jede Seite trägt ein Wappen (${wappen.length})`);
+ /* ES WIRD WIRKLICH GEZEICHNET. Ein Element mit 0 px ist kein
+ Abzeichen, und ein Verlauf, der nicht ankommt, auch
+ nicht — genau dieser Fehlgriff (Merkmal gesetzt, nichts zu sehen)
+ hat am 07.10.2026 einen Farbpunkt 10 statt 22 px breit gemacht. */
+ ok(wappen.every((w) => w.breite >= 24 && w.hoehe >= 24),
+ `und es ist groß genug: ${JSON.stringify(wappen.map((w) => `${w.breite}x${w.hoehe}`))}`);
+ ok(wappen.every((w) => /gradient/.test(w.grund)),
+ "und wirklich gezeichnet, nicht nur vorhanden");
+
+ /* ---- Der Kopf klebt ---------------------------------------- */
+ /* Bei fünf Gruppen und einem Dutzend Kacheln je Spalte ist „auf
+ welcher Seite bin ich gerade“ eine echte Frage. */
+ ok(seiten.every((s) => s.kopf && s.kopf.klebt === "sticky"),
+ `der Spaltenkopf bleibt beim Scrollen oben (${
+ JSON.stringify(seiten.map((s) => s.kopf?.klebt))})`);
+
+ /* ---- Die Lage der Seite ------------------------------------ */
+ const alleTeile = seiten.flatMap((s) => s.lage);
+ ok(alleTeile.length >= 2,
+ `die Spalten sagen, was los ist (${alleTeile.length} Angaben: ${
+ JSON.stringify(alleTeile.map((x) => x.wort))})`);
+ /* KEINE NULL. „0 laufen“ ist keine Auskunft, sondern Füllmaterial —
+ und drei Nullen lassen eine leere Spalte voll aussehen. */
+ ok(!alleTeile.some((x) => /^0 /.test(x.wort)),
+ "und nennen nie eine Null");
+ /* DIE ZAHLEN STIMMEN MIT DEN GRUPPEN DARUNTER ÜBEREIN. Eine zweite
+ Zählung könnte etwas anderes sagen als die Überschriften zwei
+ Zentimeter weiter unten. */
+ const summeLage = alleTeile
+ .map((x) => Number((x.wort.match(/^(\d+)/) || [])[1] || 0))
+ .reduce((a, b) => a + b, 0);
+ ok(summeLage === 5,
+ `und zählen alle 5 Events (Summe ${summeLage})`);
+ /* Verschiedene Zustände, verschiedene Töne — sonst wäre die
+ Einfärbung Zierde ohne Aussage. */
+ const toene = new Set(alleTeile.filter((x) => x.stand).map((x) => x.farbe));
+ ok(toene.size >= 2,
+ `und verschiedene Zustände sehen verschieden aus (${toene.size} Töne)`);
+
+ /* ---- DER KASTEN STEHT ZU, UND DAS IST DER ZWECK (08.10.2026) --
+
+ Gemessen auf 412 px war er auf 521 px gewachsen -- mehr als ein
+ halber Telefonbildschirm, bevor man die erste Kachel sieht. Er
+ ist ein Werkzeug, nicht der Inhalt der Seite.
+
+ ZUERST WIRD GEMESSEN, DASS ER ZU IST, dann wird er
+ aufgemacht. Ohne den ersten Teil waere „er laesst sich
+ aufklappen" auch bei einem Kasten gruen, der nie zugeht. */
+ const zuerstZu = await seite.evaluate(() => {
+ const form = document.getElementById("kampagne-form");
+ const schild = document.getElementById("kampagne-schild");
+ return {
+ formVerborgen: form?.hidden === true,
+ gedrueckt: schild?.getAttribute("aria-expanded"),
+ schildHoehe: schild ? Math.round(schild.getBoundingClientRect().height) : null,
+ kastenHoehe: Math.round(
+ document.getElementById("kampagneholen")?.getBoundingClientRect().height ?? 0),
+ wort: schild?.querySelector(".kampagneholen__schildwort")?.textContent.trim() || null,
+ };
+ });
+ melde(" -- Kasten zu: " + JSON.stringify(zuerstZu));
+ ok(zuerstZu.formVerborgen === true && zuerstZu.gedrueckt === "false",
+ `der Einlesekasten steht von sich aus zu (hidden=${zuerstZu.formVerborgen}, `
+ + `aria-expanded=${zuerstZu.gedrueckt})`);
+ /* UND ER BRAUCHT DANN FAST KEINEN PLATZ. Die Zahl steht in der
+ Bedingung, nicht nur im Meldetext: Ein zugeklappter Kasten, der
+ trotzdem 500 px hoch ist, waere kein zugeklappter Kasten. */
+ ok(zuerstZu.kastenHoehe <= 110,
+ `und nimmt zugeklappt nur ${zuerstZu.kastenHoehe} px (hoechstens 110)`);
+ /* 44 px Tippziel -- dieselbe Vorgabe wie ueberall im Haus. */
+ ok(zuerstZu.schildHoehe >= 44,
+ `sein Schild ist ${zuerstZu.schildHoehe} px hoch (mindestens 44)`);
+ ok(/Kampagne aus einer Einladung/.test(zuerstZu.wort || ""),
+ `und sagt, was dahinter steckt: ${JSON.stringify(zuerstZu.wort)}`);
+
+ /* EIN DRUCK MACHT IHN AUF. */
+ await seite.click("#kampagne-schild");
+ await seite.waitForTimeout(250);
+
/* ---- Die Auswahl im Kasten -------------------------------- */
const wahl = await seite.evaluate(() => {
const f = document.querySelector(".kampagneholen__wahl");
diff --git a/server/pruef-kampagne.mjs b/server/pruef-kampagne.mjs
index 9501c8b9..b05db74d 100644
--- a/server/pruef-kampagne.mjs
+++ b/server/pruef-kampagne.mjs
@@ -1115,6 +1115,131 @@ melde("=== 12b. Ein Event nach draußen teilen ===");
ok(t1.code === 200 && /\/e\/[A-Za-z0-9_-]{20,}$/.test(j1.weg || ""),
`der Link entsteht: ${String(j1.weg).replace(/^https?:\/\/[^/]+/, "…")}`);
+ /* ==== UND 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.“
+
+ Gemessen wird nicht „es kommt ein Text“, sondern dass der Text
+ die vier Dinge trägt, wegen denen man ihn schreibt: Titel,
+ Zeitraum, Aufgaben, Link — und dass Discord ihn annimmt. */
+ const beitrag = String(j1.text || "");
+ ok(beitrag.length > 200,
+ `der fertige Beitrag kommt mit (${beitrag.length} Zeichen)`);
+
+ /* DISCORD NIMMT 2000 ZEICHEN JE NACHRICHT. Eine längere wird
+ beim Einfügen abgeschnitten — mitten im Wort, und niemand
+ merkt es, bis ein Creator nachfragt. */
+ ok(beitrag.length <= 2000,
+ `und bleibt unter der Discord-Grenze (${beitrag.length} von 2000)`);
+
+ ok(beitrag.includes("Gipfelstürmer"), "der Titel steht drin");
+ ok(/04\.10\.2026/.test(beitrag), "der Zeitraum steht drin");
+ ok(beitrag.includes("1 Diamant = 1 Punkt"),
+ "die Aufgaben stehen drin — genau das, was beim Link fehlte");
+ ok(/Ahorn-Welt/.test(beitrag) && /12\.000 Coins/.test(beitrag),
+ "die Geschenke mit Coinpreis ebenso");
+
+ /* DER LINK STEHT GANZ AM ENDE UND NACKT.
+ Am Ende, weil Discord die Vorschau UNTER die Nachricht setzt --
+ steht er oben, schiebt das Banner den Text aus dem Blick.
+ Nackt, weil spitze Klammern die Vorschau unterdrücken: dann
+ gäbe es genau das Banner nicht, um das es geht. */
+ ok(beitrag.trimEnd().endsWith(j1.weg),
+ `der Link steht ganz am Ende (…${beitrag.trimEnd().slice(-34)})`);
+ ok(!beitrag.includes(`<${j1.weg}>`),
+ " und nackt, nicht in spitzen Klammern — sonst kein Banner");
+
+ /* DISCORD-AUSZEICHNUNG: Überschriften und Aufzählungen, damit es
+ in Discord wie ein Aushang aussieht und nicht wie eine
+ Textwand. */
+ ok(/^# /m.test(beitrag) && /^## /m.test(beitrag),
+ "Überschriften sind gesetzt (# und ##)");
+ ok(/^- /m.test(beitrag), "und die Aufgaben als Aufzählung");
+
+ /* EIN BINDESTRICH MITTEN IM WORT WIRD NICHT ENTWERTET. Beim
+ ersten Blick auf den Beitrag stand dort „Ahorn\\-Welt“ und
+ „4\\-wöchige“ — die Rückstriche sichtbar im Text. In Discord
+ ist `-` nur am Zeilenanfang eine Aufzählung. */
+ ok(!/\w\\-\w/.test(beitrag),
+ "kein Rückstrich mitten im Wort");
+
+ /* ---- DIE GRENZE WIRD AN EINEM LANGEN BEITRAG GEMESSEN -------
+
+ GEFUNDEN DURCH DIE GEGENPROBE (08.10.2026): Als Sabotage wurde
+ `DISCORD_MAX` von 2000 auf 99999 gesetzt -- und die Pruefung
+ blieb GRUEN. Der Beitrag von „Gipfelstuermer" ist 1230 Zeichen
+ lang; ob die Grenze bei 2000 oder bei 99999 steht, aendert daran
+ nichts. `beitrag.length <= 2000` hat also nie die GRENZE
+ geprueft, sondern nur diesen einen Beitrag.
+
+ Genau die Sorte gruener Haken, die nichts misst (CLAUDE.md,
+ 01.09.2026). Geprueft wird deshalb mit einem Beitrag, der ohne
+ Kuerzung sicher zu lang WAERE -- und daneben, dass das
+ Wesentliche trotzdem stehen bleibt. Ein Beitrag, der die Grenze
+ einhaelt, indem er alles wegwirft, waere auch keine Loesung. */
+ {
+ const { discordText } = await import("./workspace-teilen.js");
+ const satz = "Gipfelstürmer ist eine TikTok LIVE Kampagne, in welcher "
+ + "Creator*innen Punkte sammeln können, wenn sie Diamanten sammeln. ";
+ const lang = {
+ titel: "Ein sehr langer Kampagnentitel zum Messen",
+ zeitraum: "04.10.2026 – 07.10.2026",
+ text: satz.repeat(40),
+ basis: "https://beispiel.de/e/abc",
+ vonTikTok: true,
+ aufgaben: Array.from({ length: 12 }, (x, i) =>
+ `Aufgabe ${i + 1} mit einem recht ausführlichen Text dahinter`),
+ gaben: Array.from({ length: 8 }, (x, i) =>
+ ({ text: `Geschenk ${i + 1}`, coins: (i + 1) * 999, zusatz: null })),
+ abschnitte: [{ art: "rangliste", titel: "RANGLISTE", zeilen: [
+ { art: "punkt", text: "Gewertet wird vom 04.10. bis zum 07.10." }] }],
+ };
+ /* ERST BELEGEN, DASS DER FALL UEBERHAUPT EINTRITT. Ohne diese
+ Zeile misst die naechste wieder nichts. */
+ const ungekuerzt = satz.repeat(40).length;
+ ok(ungekuerzt > 2000,
+ `der Probetext allein ist ${ungekuerzt} Zeichen lang -- er MUSS gekürzt werden`);
+ const kurz = discordText(lang);
+ ok(kurz.length <= 2000,
+ `und der Beitrag bleibt bei ${kurz.length} Zeichen`);
+ ok(kurz.length < ungekuerzt,
+ " es wurde also wirklich gekürzt, nicht nur gemessen");
+ /* WAS IMMER STEHEN BLEIBT: Titel, Zeitraum, Aufgaben, Link.
+ Das sind die vier Dinge, wegen denen man den Beitrag
+ schreibt. */
+ ok(kurz.includes("Ein sehr langer Kampagnentitel"), " der Titel bleibt");
+ ok(/04\.10\.2026/.test(kurz), " der Zeitraum bleibt");
+ ok(/^- Aufgabe 1 /m.test(kurz), " die Aufgaben bleiben");
+ ok(kurz.trimEnd().endsWith("https://beispiel.de/e/abc"),
+ " und der Link steht am Ende");
+
+ /* DER AEUSSERSTE FALL: ein Titel, der allein fast die Grenze
+ sprengt. Auch dann muss der Link dastehen -- ohne ihn ist der
+ Beitrag eine Sackgasse. */
+ const riesig = discordText({ ...lang, titel: "A".repeat(1500) });
+ ok(riesig.length <= 2000 && riesig.trimEnd().endsWith("https://beispiel.de/e/abc"),
+ `selbst bei einem 1500-Zeichen-Titel: ${riesig.length} Zeichen, Link am Ende`);
+ }
+
+ /* UND NICHTS ÜBER UNS. Dieselbe Grenze wie auf der offenen
+ Seite: Was die Kampagne sagt, geht hinaus — wer wir sind,
+ nicht. Der Beitrag landet in einem Kanal, den auch Fremde
+ lesen können. */
+ const verbotenImBeitrag = [
+ ["ein Name", "Dogfather"], ["eine Rolle", "admin"],
+ ["die Kampagnennummer", ID_GIPFEL],
+ ["ein Hinweis an DogFather", "bitte nachtragen"],
+ ["ein Feldname", "erstellt_von"],
+ ].filter(([, w]) => beitrag.includes(w));
+ ok(verbotenImBeitrag.length === 0,
+ "nichts Internes im Beitrag"
+ + (verbotenImBeitrag.length
+ ? `: ${verbotenImBeitrag.map((x) => x[0]).join(", ")}` : ""));
+
/* DERSELBE SCHLUESSEL BEIM ZWEITEN DRUCK. Ein neuer wuerde den
Link still ins Leere laufen lassen, den jemand schon gepostet
hat. */
@@ -1123,6 +1248,11 @@ melde("=== 12b. Ein Event nach draußen teilen ===");
const j2 = JSON.parse(t2.text || "{}");
ok(j2.weg === j1.weg && j2.schon === true,
"der zweite Druck gibt denselben Link, keinen neuen");
+ /* UND DEN BEITRAG GLEICH MIT. Wer ihn ein zweites Mal in einen
+ anderen Kanal stellen will, soll nicht erst zurückziehen und
+ neu teilen müssen. */
+ ok(String(j2.text || "") === beitrag,
+ `und denselben Beitrag (${String(j2.text || "").length} Zeichen)`);
const schluessel = String(j1.weg).split("/e/")[1];
diff --git a/server/workspace-teilen.js b/server/workspace-teilen.js
index 396ada8d..55413579 100644
--- a/server/workspace-teilen.js
+++ b/server/workspace-teilen.js
@@ -157,7 +157,33 @@ teilenRouter.post("/workspace/api/eintrag/:id(\\d+)/teilen", (req, res) => {
detail: `Eintrag ${e.id} "${String(e.titel).slice(0, 60)}"`,
});
}
- return res.json({ weg: `${TEILEN_BASIS}/e/${schluessel}`, schon: !!e.teilen_token });
+ /* ---- 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." });
@@ -196,6 +222,31 @@ function tagText(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;
@@ -207,8 +258,7 @@ function eventZu(token) {
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
+ `SELECT ${TEILEN_SPALTEN}
FROM eintraege
WHERE teilen_token = ? AND bereich = 'agentur' AND art = 'kampagne'`)
.get(String(token)) || null;
@@ -242,15 +292,21 @@ teilenRouter.get("/e/:token/bild", (req, res) => {
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());
- }
+/* =====================================================================
+ 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 || "");
@@ -326,15 +382,13 @@ teilenRouter.get("/e/:token", (req, res) => {
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({
+ return {
titel: String(e.titel || "Event"),
zeitraum,
text: kurz,
@@ -347,7 +401,193 @@ teilenRouter.get("/e/:token", (req, res) => {
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 ----------------------------------------- */
diff --git a/workspace/anleitung.html b/workspace/anleitung.html
index 72f2ada6..5187e34b 100644
--- a/workspace/anleitung.html
+++ b/workspace/anleitung.html
@@ -7,17 +7,17 @@
-
-
-
-
-
+
+
+
+
+
-
-
+
+
@@ -289,16 +289,16 @@
Und anleitung.js braucht es doppelt: Symbol, Farbe, Name und
Adresse jeder Karte kommen von dort, nicht vom Server. -->
-
+
-
-
-
-
-
-
+
+
+
+
+
+