diff --git a/server/bild-kampagne.mjs b/server/bild-kampagne.mjs index b9329767..12500cd2 100644 --- a/server/bild-kampagne.mjs +++ b/server/bild-kampagne.mjs @@ -74,8 +74,48 @@ function anlegen(name, rolle, code) { + " VALUES (?,?,?,?,?,1,?)").run(name, rolle, hash, salt, 32768, j); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } -anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); anlegen("Luna", "creator", "CODE-CREA-0001"); + +/* ZWEI KACHELN, EINE JE SEITE DER TRENNUNG. + + Die linke traegt eine `kampagne_id` -- genau daran, und nur daran, + haengt die Trennung. Die rechte hat keine; sie ist ein Event, das + die Agentur selbst angelegt hat. + + ALLE DATEN RELATIV ZU HEUTE. Ein festes Datum waere gruen, bis der + Kalender es einholt. */ +const tagHin = (v) => { + const x = new Date(Date.now() + v * 86400_000); + const z = (n) => String(n).padStart(2, "0"); + return `${x.getFullYear()}-${z(x.getMonth() + 1)}-${z(x.getDate())}`; +}; +function event(titel, text, von, bis, auf, reg, kampagneId) { + d.prepare(`INSERT INTO eintraege + (bereich, art, titel, text, datum, event_ende, event_aufgaben, event_regeln, + dringlichkeit, status, nur_leitung, erstellt, erstellt_von, haus, kampagne_id) + VALUES ('agentur','kampagne',?,?,?,?,?,?,?,'offen',0,?,?,NULL,?)`) + .run(titel, text, von, bis, auf, reg, + bis < tagHin(7) ? "hoch" : "mittel", j, idDogi, kampagneId); +} +event("LIVE Glow Up", "Eine vierwöchige Wachstums-Challenge für deine LIVEs.", + tagHin(-2), tagHin(4), + "Deine täglichen LIVE Missionen\nDeine wöchentliche Missionen", + "KAMPAGNENZEITPLAN\n· Woche 1: 5. - 11.10.\n· Woche 2: 12. - 18.10.", + "7690927903777262344"); +event("Gipfelstürmer", "Bereit für Alpen-Vibes in deinem LIVE?", + tagHin(-9), tagHin(-1), + "1 Diamant = 1 Punkt\nDreifache Punkte für Kampagnengeschenke", + "BELOHNUNGEN\nSuperstars: Top 1 = 60.000 Diamanten", + "7691493175936699143"); +event("Spicy Media Teamabend", "Unser eigener Abend — Rückblick und Planung.", + tagHin(3), tagHin(3), + "Pünktlich um 20 Uhr da sein\nEinen Punkt für die Runde mitbringen", + "ABLAUF\n· 20:00 Rückblick\n· 21:00 Planung", null); +event("Oktober-Aktion der Agentur", "Zusätzliche Prämien für das Team.", + tagHin(1), tagHin(21), + "20 LIVE-Stunden\n50.000 Diamanten", + "PRÄMIEN\n· Platz 1: 200 €", null); d.close(); const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); @@ -337,6 +377,134 @@ await messen(412, 915, "Auf dem Handy, haeufigste Android-Breite"); await ctx.close(); } +/* ---- DIE TRENNUNG: LINKS TIKTOK, RECHTS DIE AGENTUR ---------------- */ +for (const [breite, name] of [[1400, 'Am Rechner'], [412, 'Auf dem Handy']]) { + const ctx = await browser.newContext({ + viewport: { width: breite, height: breite < 700 ? 1500 : 1300 }, + deviceScaleFactor: 2, hasTouch: breite < 700, isMobile: breite < 700, + }); + const seite = await ctx.newPage(); + await anmelden(seite, "admin", "CODE-DOGI-0001"); + await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1800); + + console.log(`\n=== Trennung, ${name} (${breite} px) ===`); + const mass = await seite.evaluate(() => { + const t = document.querySelector(".kv-trennung"); + if (!t) return { da: false }; + const r = (e) => { const b = e.getBoundingClientRect(); + return { x: Math.round(b.x), b: Math.round(b.width) }; }; + const seiten = [...t.querySelectorAll(".kv-seite")].map((s) => ({ + seite: s.dataset.seite, + titel: s.querySelector(".kv-seite__titel")?.firstChild?.textContent?.trim(), + zahl: s.querySelector(".kv-seite__zahl")?.textContent?.trim(), + ...r(s), + ton: getComputedStyle(s).getPropertyValue("--kv-ton").trim(), + karten: [...s.querySelectorAll(".eintrag-karte")].map((k) => ({ + titel: k.querySelector(".eintrag-karte__titel, h3, h2")?.textContent?.trim()?.slice(0, 28), + ton: getComputedStyle(k).getPropertyValue("--ton").trim(), + })), + })); + const kopfA = t.querySelector('.kv-seite[data-seite="tiktok"] .kv-seite__titel'); + const zahlA = t.querySelector('.kv-seite[data-seite="tiktok"] .kv-seite__zahl'); + return { + da: true, seiten, + nebeneinander: seiten.length === 2 && seiten[0].x + seiten[0].b <= seiten[1].x + 2, + abstandWortZahl: kopfA && zahlA + ? Math.round(zahlA.getBoundingClientRect().left + - kopfA.getBoundingClientRect().right) : null, + rest: [...document.querySelectorAll(".kv-rest .eintrag-karte")].length, + }; + }); + ok(mass.da, "die Trennung ist da"); + if (!mass.da) { await ctx.close(); continue; } + for (const s of mass.seiten) { + console.log(` ${String(s.seite).padEnd(8)} x=${String(s.x).padStart(4)} b=${String(s.b).padStart(4)}` + + ` ${String(s.titel).padEnd(16)} ${s.zahl} Ton ${s.ton}`); + for (const k of s.karten) console.log(` ${String(k.titel).padEnd(30)} --ton ${k.ton}`); + } + ok(mass.seiten.length === 2, + `zwei Seiten (${mass.seiten.map((s) => s.seite).join(" | ")})`); + ok(mass.seiten[0].seite === "tiktok" && mass.seiten[1].seite === "agentur", + "links TikTok, rechts die Agentur"); + ok(mass.seiten[0].ton !== mass.seiten[1].ton, + `jede Seite hat ihren eigenen Ton (${mass.seiten[0].ton} / ${mass.seiten[1].ton})`); + ok(mass.seiten.every((s) => s.karten.every((k) => k.ton === s.ton)), + "und jede Kachel traegt den Ton ihrer Seite"); + if (breite >= 820) { + ok(mass.nebeneinander, "sie stehen nebeneinander"); + ok(Math.abs(mass.seiten[0].b - mass.seiten[1].b) <= 2, + `gleich breit (${mass.seiten[0].b} / ${mass.seiten[1].b})`); + } else { + ok(!mass.nebeneinander, + "auf dem Handy untereinander, keine gequetschte Spalte"); + ok(mass.seiten.every((s) => s.b > 300), `${mass.seiten[0].b} px breit`); + } + ok(mass.abstandWortZahl !== null && mass.abstandWortZahl < 80, + `die Zahl steht neben ihrem Wort (${mass.abstandWortZahl} px)`); + ok(mass.rest === 0, `nichts Fremdes unter der Trennung (${mass.rest})`); + + /* GREIFEN DIE NEUEN REGELN WIRKLICH? (07.10.2026) + + Beim Bauen zielte eine davon auf `.ev-stand__punkt` -- eine + Klasse, die es in diesem Haus gar nicht gibt. Sie war still + wirkungslos, genau wie die drei, vor denen module.css warnt, + und waere es geblieben: Ein Bild sagt nicht, ob etwas pulsiert. + + Gemessen wird deshalb der ERRECHNETE Stil, nicht der + geschriebene. */ + const regeln = await seite.evaluate(() => { + const schild = document.querySelector(".marke-art[data-quelle]"); + const laeuft = document.querySelector('.ev-noch[data-stand="laeuft"]'); + const karte = document.querySelector('.kv-seite .eintrag-karte[data-eventkarte="ja"]'); + return { + schildTon: schild ? getComputedStyle(schild).color : null, + schildQuelle: schild ? schild.dataset.quelle : null, + /* DER PUNKT HAENGT AN ::before, NICHT AN ::after (berichtigt + 07.10.2026). Hier stand ::after -- dort ist er seit dem + Umbau auf das ev-noch-Muster nicht mehr. Gemessen kam + "rgba(0, 0, 0, 0)" zurueck, und die Bedingung /rgb/ passte + darauf: ein gruener Haken, der nichts angesehen hat. */ + schildPunkt: schild + ? getComputedStyle(schild, "::before").backgroundColor : null, + puls: laeuft + ? getComputedStyle(laeuft, "::before").animationName : "(kein laufendes Event)", + kartenGrund: karte ? getComputedStyle(karte).backgroundImage.slice(0, 40) : null, + }; + }); + console.log(` Schild ${regeln.schildQuelle} / ${regeln.schildTon}` + + ` Punkt ${regeln.schildPunkt}`); + console.log(` Puls ${regeln.puls}`); + const nach = await seite.evaluate(() => { + const s = document.querySelector(".marke-art[data-quelle]"); + const tr = document.querySelector(".kv-trennung"); + const st = getComputedStyle(s); + const na = getComputedStyle(s, "::after"); + return { padLinks: st.paddingLeft, pos: st.position, anzeige: st.display, + punktPos: na.position, punktLinks: na.left, + trennBild: getComputedStyle(tr).backgroundImage.slice(0, 90), + trennGroesse: getComputedStyle(tr).backgroundSize }; + }); + console.log(" MESS " + JSON.stringify(nach)); + ok(regeln.schildQuelle === "tiktok" || regeln.schildQuelle === "agentur", + `das Herkunftsschild steht an der Kachel (${regeln.schildQuelle})`); + ok(/^rgba?((?!0, 0, 0, 0))/.test(regeln.schildPunkt || ""), + `sein Farbpunkt wird wirklich gezeichnet (${regeln.schildPunkt})`); + /* UND ER TRAEGT DIE FARBE SEINER SEITE. Ohne diese Zeile waere der + Punkt auch dann "gezeichnet", wenn er den Ersatzton traegt -- genau + das war er beim ersten Anlauf. */ + ok(regeln.schildPunkt === "rgb(63, 189, 245)", + `und zwar das TikTok-Blau, nicht den Ersatzton (${regeln.schildPunkt})`); + ok(regeln.puls === "ev-puls", + `der Punkt eines laufenden Events pulst (${regeln.puls})`); + ok(/radial-gradient/.test(regeln.kartenGrund || ""), + `die Kachel traegt den Schein ihres Tons (${regeln.kartenGrund}…)`); + + const t2 = await seite.$(".kv-trennung"); + await t2.screenshot({ path: `server/bild-kampagne-trennung-${breite}.png` }); + await ctx.close(); +} + /* ---- Und ein Creator sieht den Kasten gar nicht --------------------- */ { const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); diff --git a/server/index.js b/server/index.js index 89d6aade..72db1f23 100644 --- a/server/index.js +++ b/server/index.js @@ -69,6 +69,7 @@ import { chatRouter, toeneNachruestenStarten } from "./workspace-chat.js"; import { anrufRouter } from "./workspace-anruf.js"; import { videoRouter, videoWacheStarten } from "./workspace-video.js"; import { kampagneRouter } from "./workspace-kampagne.js"; +import { teilenRouter } from "./workspace-teilen.js"; import { bewerbungRouter } from "./workspace-bewerbung.js"; import { leistungRouter } from "./workspace-leistung.js"; import { managerZieleRouter } from "./workspace-manager-ziele.js"; @@ -322,6 +323,25 @@ app.use(videoRouter); "kampagne" fuer eine Nummer halten -- derselbe Fehler, der beim Video drei Anlaeufe gekostet hat. */ app.use(kampagneRouter); +/* EIN EVENT NACH DRAUSSEN TEILEN (07.10.2026). + + Zwei Sorten Weg in einem Router, und das ist Absicht: + + /workspace/api/eintrag/:id/teilen angemeldet, Leitung, Agentur + /e/:token OFFEN -- Discord meldet sich + nirgends an + + Der offene Weg steht NACH `gateMiddleware` (die laesst seit dem + oeffentlichen Start ohnehin jeden durch) und VOR `express.static`, + damit /e/... nicht als Datei gesucht wird. + + Die Herkunftswache weiter oben gilt nur fuer /workspace/api -- + genau richtig: Ein GET von aussen SOLL durchgehen, sonst gaebe es + keine Vorschau. Geschrieben wird auf dem offenen Weg nichts. + + Was dort hinausgeht, steht im Kopf von workspace-teilen.js: Titel, + Zeitraum, Einleitung, Banner -- und sonst nichts. */ +app.use(teilenRouter); /* LEISTUNG und FRUEHWARNUNG (06.09.2026, Stufe 1+2 des Plans). Die Fruehwarnung liest aus `leistung` -- ohne Zahlen hat sie nichts zu melden, deshalb stehen sie beieinander. */ diff --git a/server/pruef-agentur.mjs b/server/pruef-agentur.mjs index eb1c572c..d98cb267 100644 --- a/server/pruef-agentur.mjs +++ b/server/pruef-agentur.mjs @@ -394,7 +394,7 @@ async function ruf(art, weg, keks, koerper) { const ARTEN = ["kampagne", "schulung", "anliegen", "zustaendig"]; { const { daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksDogi); - ok(daten?.einstellung?.name === "Agentur", + ok(daten?.einstellung?.name === "Kampagnen & Events", `der Bereich meldet sich als "${daten?.einstellung?.name}"`); const arten = Object.keys(daten?.einstellung?.arten || {}); ok(arten.length === 4 && ARTEN.every((a) => arten.includes(a)), @@ -628,8 +628,8 @@ melde("\n=== Ueberall, wo Bereiche vorkommen ==="); /* Die Suche. Ein Treffer ohne Ueberschrift sieht aus wie ein Fehler. */ const { daten } = await ruf("GET", "/workspace/api/suche?q=" + encodeURIComponent("Test kampagne"), keksDogi); const text = JSON.stringify(daten); - ok(/Agentur/.test(text), - "die Suche beschriftet Agentur-Treffer mit dem richtigen Namen"); + ok(/Kampagnen & Events/.test(text), + "die Suche beschriftet Treffer mit dem Namen des Bretts"); } /* ======================================================================= @@ -654,8 +654,8 @@ melde("\n=== Die Kachel ==="); ton: x.dataset.ton, pfade: x.querySelectorAll(".kachel__zeichen path").length, }))); - const a = kacheln.find((x) => x.name === "Agentur"); - ok(!!a, `die Kachel "Agentur" steht da (${kacheln.length} Kacheln insgesamt)`); + const a = kacheln.find((x) => x.name === "Kampagnen & Events"); + ok(!!a, `die Kachel "Kampagnen & Events" steht da (${kacheln.length} Kacheln insgesamt)`); ok(a?.ziel === "bereich.html?b=agentur", `sie fuehrt nach "${a?.ziel}"`); ok(Number(a?.pfade) >= 4, `ihr Zeichen hat ${a?.pfade} Pfade — es ist wirklich gezeichnet`); @@ -672,7 +672,7 @@ melde("\n=== Die Kachel ==="); await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" }); await seite.waitForTimeout(900); const titel = await seite.$eval(".titel", (e) => e.textContent.trim()).catch(() => ""); - ok(/Agentur/i.test(titel), `die Bereichsseite heisst "${titel}"`); + ok(/Kampagnen & Events/i.test(titel), `die Bereichsseite heisst "${titel}"`); /* .eintrag-karte -- der Name aus bereich.js. Beim ersten Anlauf stand hier geraten ".eintrag, .karte"; das traf nichts und meldete "0 @@ -758,6 +758,16 @@ melde("\n=== Die Kachel ==="); await seite.click("#neu-oeffnen"); await seite.waitForTimeout(400); const wahl = await seite.$$eval("#f-creator option", (o) => o.map((x) => x.textContent.trim())); + /* HIER STEHT WEITERHIN „Agentur" -- und das ist richtig + (berichtigt 07.10.2026). Beim Umbenennen des BRETTS habe ich + diese Zeile mitgenommen, ohne hinzusehen. Das Feld „Gilt für" + nennt aber das HAUS, nicht das Brett: Der Server bildet es in + workspace-bereiche.js aus `person.haus` -- „Das Rudel", + „Team Dogi" oder „Agentur". „Gilt für: Kampagnen & Events" + waere Unsinn. + + Zwei Dinge heissen aehnlich und sind verschieden; wer beim + Umbenennen beide anfasst, benennt eines falsch. */ ok(wahl.length === 1 && wahl[0] === "Agentur", `die Zuordnung bietet genau eine Wahl an: ${JSON.stringify(wahl)}`); const hinweis = await seite.$eval("#hinweis-creator", (e) => e.textContent.trim()).catch(() => ""); diff --git a/server/pruef-eventkarte.mjs b/server/pruef-eventkarte.mjs index 5b38422f..ed68a842 100644 --- a/server/pruef-eventkarte.mjs +++ b/server/pruef-eventkarte.mjs @@ -93,11 +93,16 @@ const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); Stelle gesucht. `tagLokal` ist die Stelle, an der das Haus das einmal richtig macht. */ const tag = (v) => tagLokal(v); -function evt(titel, von, bis, auf, reg) { +/* `kampagneId` entscheidet, in WELCHER Spalte die Karte landet + (07.10.2026): links die Kampagnen von TikTok, rechts die Events + der Agentur. Die Pruefung braucht beide Seiten gefuellt -- sonst + misst sie eine Trennung, bei der eine Haelfte leer ist, und + „zwei nebeneinander" waere dann zu Recht rot. */ +function evt(titel, von, bis, auf, reg, kampagneId = null) { d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, event_ende," - + " event_aufgaben, event_regeln, dringlichkeit, erstellt, erstellt_von)" - + " VALUES (?,?,'agentur','kampagne','offen',?,?,?,?,'mittel',?,?)") - .run(titel, "Beschreibung zum Test.", von, bis, auf, reg, jetzt, idDogi); + + " event_aufgaben, event_regeln, dringlichkeit, erstellt, erstellt_von, kampagne_id)" + + " VALUES (?,?,'agentur','kampagne','offen',?,?,?,?,'mittel',?,?,?)") + .run(titel, "Beschreibung zum Test.", von, bis, auf, reg, jetzt, idDogi, kampagneId); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } /* Die drei Zustaende, die eventStand kennt -- jeder einzeln, nicht als @@ -105,7 +110,8 @@ function evt(titel, von, bis, auf, reg) { einmal ohne Leerzeichen hinter dem Punkt, einmal mit. */ const laufend = evt("Oktober Event", tag(-1), tag(12), "·Jeden Tag Live gehen\n·22-Live-Stunden erreichen\n· 33k Diamanten erreichen", - "· Nur Streams auf dem eigenen Konto\n· Punkte zählen bis 23:59 am letzten Tag"); + "· Nur Streams auf dem eigenen Konto\n· Punkte zählen bis 23:59 am letzten Tag", + "7691493175936699143"); const kommend = evt("November Challenge", tag(20), tag(45), "· 30 Live-Stunden", "· Nur eigene Konten"); const vorbei = evt("September Rückblick", tag(-40), tag(-12), @@ -500,7 +506,19 @@ async function alsRolle(rolle, code, breite = 1280) { geschwister: erste.parentElement ? [...erste.parentElement.children].map((c) => c.tagName + "." + (c.className || "").split(" ")[0]) : [], - reihen: [...liste.children].map((c) => { + /* DIE KARTEN, WO IMMER SIE STEHEN (berichtigt 07.10.2026). + + Hier stand `liste.children`. Seit das Agentur-Brett in zwei + Spalten geteilt ist (links TikTok, rechts die Agentur), sind + die Kinder der Liste die SPALTEN -- die Karten haengen eine + Ebene tiefer. Gemessen kam `b: undefined` heraus, und die + Pruefung meldete einen Schaden an einer Stelle, an der + keiner war. + + `querySelectorAll` statt `children`: Die Frage ist „wo + liegen die Karten", nicht „was haengt direkt unter der + Liste". Die erste Frage ueberlebt den naechsten Umbau. */ + reihen: [...liste.querySelectorAll(".eintrag-karte")].map((c) => { const r = c.getBoundingClientRect(); return { event: c.dataset.eventkarte === "ja", @@ -526,7 +544,14 @@ async function alsRolle(rolle, code, breite = 1280) { /* DASS SIE SCHMAL SIND, HEISST NOCH NICHT, DASS ZWEI NEBENEINANDER STEHEN -- sie koennten auch untereinander in einer halben Spalte haengen. Gemessen wird deshalb ein PAAR: zwei Karten, gleiche - Hoehe, verschiedene Seite. */ + Hoehe, verschiedene Seite. + + SEIT DEM 07.10.2026 IST DAS AGENTUR-BRETT GETEILT: links die + Kampagnen von TikTok, rechts die Events der Agentur. „Zwei + nebeneinander" heisst hier also eine aus jeder Spalte -- und + genau deshalb traegt eines der Testevents oben eine + `kampagne_id`. Ohne sie staenden alle drei rechts, und diese + Zeile waere zu Recht rot. */ const paar = events.some((a, i) => events.slice(i + 1) .some((b2) => Math.abs(a.y - b2.y) < 5 && Math.abs(a.x - b2.x) > 100)); ok(paar, `zwei Eventkarten stehen wirklich nebeneinander ` diff --git a/server/pruef-kampagne.mjs b/server/pruef-kampagne.mjs index 27c45d53..8e3f5183 100644 --- a/server/pruef-kampagne.mjs +++ b/server/pruef-kampagne.mjs @@ -863,6 +863,123 @@ melde("=== 12. Team Dogi bleibt unberührt ==="); `und die Zahl der Crew-Einträge ist unverändert (${crewVorher} = ${crewNachher})`); } +/* ===================================================================== + 12b. TEILEN — eine offene Seite, und nur so viel wie nötig + ===================================================================== */ +melde(""); +melde("=== 12b. Ein Event nach draußen teilen ==="); + +{ + /* Der offene Weg laeuft ohne Anmeldung und ohne Keks -- so, wie + Discord ihn abruft. `roh()` schickt zwar einen Host-Kopf mit, + aber keinen Keks, wenn keiner uebergeben wird. */ + const offen = (pfad) => roh(pfad, { wirt: HOST }); + + /* ---- Vorher gibt es nichts ---------------------------------- */ + const vorher = await offen("/e/gibtesnichtgibtesnicht"); + ok(vorher.code === 404, `ein erfundener Schlüssel gibt 404 (${vorher.code})`); + ok(!/war mal|zurückgezogen|zurueckgezogen/i.test(vorher.text), + " und verrät nicht, ob es ihn einmal gab"); + + /* ---- Nur die Leitung darf teilen ----------------------------- */ + const alsLuna = await roh(`/workspace/api/eintrag/${kachelId}/teilen`, + { methode: "POST", keks: keksLuna, rumpf: {} }); + ok(alsLuna.code === 403, `ein Creator darf nicht teilen (${alsLuna.code})`); + + /* ---- DogFather teilt ------------------------------------------ */ + const t1 = await roh(`/workspace/api/eintrag/${kachelId}/teilen`, + { methode: "POST", keks: keksDogi, rumpf: {} }); + const j1 = JSON.parse(t1.text || "{}"); + ok(t1.code === 200 && /\/e\/[A-Za-z0-9_-]{20,}$/.test(j1.weg || ""), + `der Link entsteht: ${String(j1.weg).replace(/^https?:\/\/[^/]+/, "…")}`); + + /* DERSELBE SCHLUESSEL BEIM ZWEITEN DRUCK. Ein neuer wuerde den + Link still ins Leere laufen lassen, den jemand schon gepostet + hat. */ + const t2 = await roh(`/workspace/api/eintrag/${kachelId}/teilen`, + { methode: "POST", keks: keksDogi, rumpf: {} }); + const j2 = JSON.parse(t2.text || "{}"); + ok(j2.weg === j1.weg && j2.schon === true, + "der zweite Druck gibt denselben Link, keinen neuen"); + + const schluessel = String(j1.weg).split("/e/")[1]; + + /* ---- Die offene Seite ----------------------------------------- */ + const seiteOffen = await offen(`/e/${schluessel}`); + ok(seiteOffen.code === 200, `die Seite ist ohne Anmeldung da (${seiteOffen.code})`); + ok(/')[1] || ''); + const durchgerutscht = verboten.filter(([, wort]) => ohneStil.includes(wort)); + ok(durchgerutscht.length === 0, + `nichts Internes auf der offenen Seite` + + (durchgerutscht.length ? `: ${durchgerutscht.map((x) => x[0]).join(", ")}` : "")); + + /* GEGENPROBE: Das, was hinausgehen SOLL, steht auch wirklich da -- + sonst wäre die Zeile darüber auch bei einer leeren Seite grün. */ + ok(seiteOffen.text.includes("Gipfelstürmer") + && /04\.10\.2026/.test(seiteOffen.text), + "Gegenprobe: Titel und Zeitraum stehen sehr wohl darauf"); + + /* ---- Das Banner ------------------------------------------------ */ + const bild = await offen(`/e/${schluessel}/bild`); + ok(bild.code === 200 && /^image\//.test(String(bild.kopf["content-type"] || "")), + `das Banner kommt ohne Anmeldung (${bild.code}, ${bild.kopf["content-type"]})`); + + /* ---- Zurückziehen wirkt sofort -------------------------------- */ + const weg = await roh(`/workspace/api/eintrag/${kachelId}/teilen`, + { methode: "DELETE", keks: keksDogi }); + ok(weg.code === 200, `zurückziehen geht (${weg.code})`); + const danach = await offen(`/e/${schluessel}`); + ok(danach.code === 404, `und die Seite ist sofort weg (${danach.code})`); + const bildDanach = await offen(`/e/${schluessel}/bild`); + ok(bildDanach.code === 404, ` auch das Banner (${bildDanach.code})`); + + /* ---- Was NICHT geteilt werden darf ---------------------------- */ + const fremd = dbLesen("SELECT id FROM eintraege WHERE haus = 'crew' LIMIT 1")[0]; + const crewTeilen = await roh(`/workspace/api/eintrag/${fremd.id}/teilen`, + { methode: "POST", keks: keksDogi, rumpf: {} }); + ok(crewTeilen.code === 403, + `ein Eintrag aus dem Haus Team Dogi lässt sich nicht teilen (${crewTeilen.code})`); + const crewZ = dbLesen("SELECT teilen_token FROM eintraege WHERE id = ?", fremd.id)[0]; + ok(crewZ.teilen_token === null, + " und bekommt auch keinen Schlüssel"); + + /* DER EINDEUTIGE INDEX steht dahinter -- zwei Events mit demselben + Schlüssel wären eine Seite, die zwei Dinge zeigt. */ + const idx = dbLesen("SELECT sql FROM sqlite_master WHERE name = 'idx_eintraege_teilen'"); + ok(idx.length === 1 && /UNIQUE/i.test(idx[0].sql), + "ein eindeutiger Index sichert die Schlüssel ab"); +} + /* ===================================================================== 13. DAS PROTOKOLL ===================================================================== */ diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 5e82f865..0b33e000 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -708,13 +708,26 @@ export const BEREICHE = { }, agentur: { - name: "Agentur", + /* DER SCHLUESSEL BLEIBT `agentur` (07.10.2026). + + Filipe: „anstatt agentur soll Kampagnen & Events stehen." + Geaendert wird deshalb der NAME, nicht der Schluessel: An + `agentur` haengen 79 Zeilen in der Datenbank, der CHECK in + `eintraege.bereich`, die Adresse `bereich.html?b=agentur` + und jedes Lesezeichen. Ein Schluessel ist eine Kennung, kein + Text -- und Kennungen umzubenennen ist die Sorte Umbau, bei + der Inhalte verschwinden. */ + name: "Kampagnen & Events", /* Was dasteht, wenn nichts dasteht. Ein Bereich, der schweigt, sieht kaputt aus statt leer -- und der Satz sagt NICHT nur "leer", sondern was hier hingehoert. */ leer: "Noch kein Eintrag. Hier laufen Kampagnen, Schulungen und Anliegen zusammen, die zwischen Team und Agentur besprochen werden.", arten: { - kampagne: "Agentur-Events", + /* Beide Seiten der Trennung tragen dieselbe Art: links die + Kampagnen von TikTok, rechts die Events der Agentur. Der + Name sagt deshalb beides -- woher sie kommt, sagt die + Spalte, in der sie steht. */ + kampagne: "Kampagne oder Event", schulung: "Schulung", anliegen: "Anliegen an die Agentur", zustaendig: "Zuständigkeit", @@ -1463,6 +1476,22 @@ const SPALTEN = ` e.dringlichkeit, e.status, e.creator_id, e.erstellt, e.erstellt_von, e.geaendert, e.hook, e.format, e.saeule_id, e.geplant, e.event_ende, e.event_aufgaben, e.event_regeln, + /* WOHER DIE KACHEL KOMMT (07.10.2026). kampagne_id ist nur + gefuellt, wenn sie aus einer TikTok-Einladung eingelesen wurde -- + daran, und nur daran, haengt die Trennung in der Oberflaeche. Die + Adresse kommt mit, damit die Kachel einen Knopf zu TikTok bauen + kann, ohne noch einmal zu fragen. + + KEINE BACKTICKS IN DIESEM BLOCK: SPALTEN ist selbst ein Template. + Ein Backtick im Kommentar beendet es mitten im SQL. */ + e.kampagne_id, e.kampagne_quelle, + /* OB DIESES EVENT GETEILT IST (07.10.2026). Die Oberflaeche baut + daraus entweder "Teilen" oder "Link kopieren" plus "nicht mehr + teilen". Der Schluessel selbst geht NICHT mit: Er steht in der + Antwort auf den Knopfdruck, und wer nur die Liste liest, braucht + ihn nicht. Ein Geheimnis, das ueberall mitfaehrt, ist schwerer zu + hueten als eines, das nur dort steht, wo es gebraucht wird. */ + (e.teilen_token IS NOT NULL) AS teilen_token, e.creator_extern, /* Was DogFather dafuer tun muesste -- nur bei den Angeboten gefuellt, siehe BEREICHE.angebote. */ diff --git a/server/workspace-teilen.js b/server/workspace-teilen.js new file mode 100644 index 00000000..ee3900ad --- /dev/null +++ b/server/workspace-teilen.js @@ -0,0 +1,373 @@ +/* ===================================================================== + 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"; + +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, "&").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; +} + +/** 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( + `SELECT id, titel, text, datum, event_ende, kampagne_id, teilen_seit + 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. Ein Satz, der mitten im Wort + endet, sieht nach einem Fehler aus -- also an einer Wortgrenze. */ + const kurz = text.length > 280 + ? text.slice(0, text.lastIndexOf(" ", 277)) + " …" + : text; + const beschreibung = [zeitraum, kurz].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, + })); +}); + +/* ---------- 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; } +`; + +function seitePlakat(d) { + const bildMarke = d.bild + ? ` + ` + : ''; + return ` + + + + +${sicher(d.titel)} · Spicy Media + + + + + + + +${bildMarke} + + + + +
+${d.bild ? `` : ""} +
+ ${ + d.vonTikTok ? "TikTok" : "Agentur"} +

${sicher(d.titel)}

+ ${d.zeitraum ? `

${sicher(d.zeitraum)}

` : ""} + ${d.text ? `

${sicher(d.text)}

` : ""} +

Geteilt aus dem Creator Workspace von Spicy Media.

+
+
+ +`; +} + +function seiteLeer() { + return ` + + + + +Nicht gefunden + + + + +
+

Nicht gefunden

+

Diesen Link gibt es nicht (mehr).

+

Spicy Media

+
+ +`; +} diff --git a/server/workspace.js b/server/workspace.js index ddbdd31a..c9aec878 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -4581,6 +4581,31 @@ function umstellungen(d) { ["eintraege", "kampagne_geholt_am", "TEXT"], // ISO-Zeitstempel ["eintraege", "kampagne_zone", "TEXT"], // tz_location_name, z.B. Europe/Berlin ["eintraege", "kampagne_daten", "TEXT"], // JSON-Auszug der Rohwerte + + /* ================================================================ + 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." + + Discord baut seine Vorschau aus den Open-Graph-Angaben einer + OEFFENTLICHEN Seite -- es meldet sich nirgends an. Es braucht + also eine Seite ohne Wand. Damit die Wand trotzdem steht, + haengt alles an diesem einen Feld: + + NULL = nicht geteilt, keine oeffentliche Seite + eine Zeichenkette = genau DIESES Event ist oeffentlich + + Der Schluessel wird gewuerfelt (16 Byte), nicht hochgezaehlt: + Eine Nummer liesse sich durchprobieren, und dann waere nicht + ein Event oeffentlich, sondern alle. Er laesst sich jederzeit + zurueckziehen -- dann ist die Seite sofort weg. + + `teilen_seit` ist kein Schmuck: Wer in einem halben Jahr + aufraeumt, will sehen, was seit wann offen steht. */ + ["eintraege", "teilen_token", "TEXT"], + ["eintraege", "teilen_seit", "TEXT"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); @@ -4618,6 +4643,17 @@ function umstellungen(d) { Bedingung waere nach dem ersten davon kein zweiter mehr moeglich. Steht hier unten und nicht oben im Bauplan, weil die Spalte erst durch die Schleife darueber entsteht. */ + /* Zwei Events mit demselben Teilen-Schluessel waeren eine Seite, + die zwei Dinge zeigt -- und beim Zurueckziehen bliebe eines + offen. Derselbe Grund wie beim Kampagnenindex: Die Pruefung im + Code faengt den Normalfall, der Index den Wettlauf. */ + try { + d.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_eintraege_teilen" + + " ON eintraege (teilen_token) WHERE teilen_token IS NOT NULL"); + } catch (fehler) { + console.error("[workspace] Index idx_eintraege_teilen:", fehler?.message); + } + try { d.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_eintraege_kampagne" + " ON eintraege (kampagne_id) WHERE kampagne_id IS NOT NULL"); diff --git a/workspace/anleitung.html b/workspace/anleitung.html index e25283b9..66ca7f49 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. --> - + - - - - - - + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 96dfd354..e84dc20a 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +