Der Treff: eine eigene Tür für die Community, und sie sieht nur, was dasteht

Filipe: "eine community rolle und community kategorie, wo die community
auch dann nur die sieht" · "wieso nur eine kachel — ich will mehrere,
mit mehreren optionen" · "egal welche rolle hinzugefügt wird soll immer
nur das sehen was ich erlaube".

DIE DRITTE ADRESSE. treff.dogfather-universe.com bekommt eine eigene
Weiche (treff-adresse.js), eine eigene Zugangswand, ein eigenes Manifest
und acht eigene Bühnenbilder. Ohne sie stünde dort die Wand der Agentur:
Spicy-Logo, "Creator Workspace", fünf Rollenkacheln mit den Namen Admin,
Manager, Scout, Creator — die komplette Struktur des Unternehmens, vor
der Anmeldung, für jeden mit der Adresse.

Welche Wand zu welcher Tür gehört, steht jetzt in EINER Tafel
(istFremdeWand) statt als Sonderfall im Code. Bei zwei Wänden war ein
Sonderfall richtig; bei drei wären es drei geworden, bei vier sechs.

SIEBEN BRETTER UND EIN ACHTES NUR FÜRS TEAM. Anschlagbrett, Was ansteht,
Der Treff, Wunschliste, Highlights, Regeln & Hilfe, Mitmachen — dazu
"Meldungen & Maßnahmen", das ausdrücklich NICHT in TREFF_BEREICHE steht:
dort wäre es lautlos bei der Community gelandet und hätte ausgesehen wie
die anderen sieben.

WAS AUF DEN BRETTERN PASSIERT: "Will ich auch" (eine Stimme je Person,
erzwungen durch den Schlüssel, nicht durch eine Prüfung) · Anheften,
höchstens drei, gezählt IN der Transaktion · Freigabe vor Sichtbarkeit
für Termine und Highlights, wobei Abwesenheit der Ruhezustand ist ·
Melden mit Pflicht-Grund (DSA Art. 16) · Entfernen mit Grund, der den
Beitrag ÜBERLEBT (DSA Art. 17) · drei Stufen, gerechnet statt
gespeichert · die Bannleiter: Modi bis 7 Tage, dauerhaft nur DogFather
(seine Entscheidung) · "Mitmachen" wird zum Talent, genau einmal.

Der Ausschluss wirkt in sitzungLesen() — also nicht nur beim Schreiben.
Wer ausgeschlossen ist, ist weg, nicht stumm.

ZWEI SEITEN MEHR. treff-regeln.html holt seine ZAHLEN vom Server
(Mindestalter 18, Fristen, Anschläge) — ein Regeltext, der eine andere
Zahl nennt als die Regel, ist schlimmer als keiner. rechte.html rechnet
"Wer sieht was" aus derselben Tafel aus, aus der die Schranke ihre
Entscheidung holt. Die Community steht dort bei 3 von 23.

SECHS BEFUNDE, KEINEN HAT DAS LESEN GEFUNDEN:
 · Ein Modi kam über den verborgenen Zugang in den Treff — die Bedingung
   war durch Ausschluss formuliert und nahm die neue Wand automatisch mit
 · /api/personen gab einem Mitglied Namen und Rolle von DogFather
 · Ein DELETE mit Körper wird von Nodes HTTP-Parser mit einem leeren 400
   abgewiesen, bevor Express ihn sieht (gemessen). Der Grund reist jetzt
   in der Adresse
 · Der Kommentar, der erklärt, warum die Zugangswand keine Rollennamen
   nennt, nannte selbst einen — in einer ausgelieferten Datei
 · Zwei Listen für "welche Seiten sind ohne Anmeldung offen". Sie
   stimmten, weil beide am selben Tag entstanden
 · Ein Portkonflikt, den ich selbst gebaut hatte (4391 gehört
   pruef-chat-aufloesen)

Dazu zwei feste Zahlen abgeschafft (die 20 in pruef-rechtetafel, die
Kachelzahl in pruef-treff) und zwei Werkzeuge, die jetzt SUCHEN statt
aufzuzählen: Der Versionsstempel findet seine Manifeste selbst — das
dritte hätte sonst keinen bekommen, und die neue App hätte bis zu vier
Stunden lang das falsche Zeichen getragen. Das Bildwerkzeug baut beide
Zugangswände aus einer Vorlage mit zwei Werten.

Zwei neue Kacheltöne, gemessen statt gewählt: #2f2f7f (Abstand 31,4 in
Lab) und #7c2771 (29,7). Das schwächste Paar der vorhandenen 34 liegt
bei 24,9.

Geprüft: treff 69 · treff-werkzeuge 60 · rollen 312 · start-ansicht 147 ·
crew-adresse 129 · sicht 84 · modi-verborgen 80 · haus-trennung 62 ·
bereiche-lesend 37 · css-klassen 30 · zwischenspeicher 21 · alle-wege 19 ·
rechtetafel 19 — alles grün.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-11 17:58:34 +02:00
co-authored by Claude Opus 5
parent 47a91e9708
commit 7b21f247eb
63 changed files with 5672 additions and 382 deletions
+7
View File
@@ -99,3 +99,10 @@ rueckgabewerte-*.txt
# besser als eine, an die ich mich erinnern muss.
zz-*
/*.png.tmp
# Arbeitsreste aus Pruef- und Umbaulaeufen (11.09.2026). Alles unter
# tools/_ ist Kladde: Ausgaben einzelner Laeufe, kurzlebige Patch-Skripte.
# Was davon bleiben soll, bekommt einen Namen ohne Unterstrich -- so
# ist die Entscheidung "gehoert das ins Verzeichnis?" ein Umbenennen
# und kein Vergessen.
tools/_*
+109 -26
View File
@@ -93,6 +93,25 @@ export const CREW_ADRESSE = "https://crew.dogfather-universe.com";
stehen kann. workspace.js gibt sie fuer die Fachmodule weiter. */
export const TEAM_DOGI_ROLLEN = new Set(["hand", "modi"]);
/* WER VON AUSSEN KOMMT (11.09.2026).
Die Community. Sie steht hier und nicht in workspace.js, weil diese
Datei von nichts abhaengt -- workspace.js importiert von hier, nicht
umgekehrt. Eine zweite Liste dort waere die, die auseinanderlaeuft.
Der Unterschied zu TEAM_DOGI_ROLLEN ist nicht die Verborgenheit,
sondern die Richtung: Das Team gehoert zum Haus und wird vor der
Agentur verborgen. Die Community gehoert NICHT zum Haus -- sie
bekommt nur, was ausdruecklich fuer sie da ist. */
export const AUSSEN_ROLLEN = new Set(["gast"]);
/* Die Adresse des Treffs. Eigene Wand, eigenes Haus -- und ausdruecklich
NICHT crew.: Diese Adresse ist absichtlich verborgen (kein Eintrag bei
Suchmaschinen, keine Rollennamen in ausgelieferten Dateien, Codes nur
persoenlich). Dutzende Zuschauer heben jede dieser Eigenschaften auf.
Nicht durch einen Fehler, sondern durch die Nutzung. */
const TREFF_HOSTS = new Set(["treff.dogfather-universe.com"]);
/** Die Adresse der Modi-App. Feste Menge, kein Muster -- Falle 1. */
const CREW_HOSTS = new Set(["crew.dogfather-universe.com"]);
@@ -121,6 +140,26 @@ export function istOhneModiAdresse(hostKopfzeile) {
return OHNE_MODI_HOSTS.has(nurHost(hostKopfzeile));
}
/** Eine Pruefadresse -- localhost und die Nummern, unter denen die
* Pruefungen ihren eigenen Server starten.
*
* Sie steht hier als EIGENE Funktion und nicht als "alles, was keine
* der echten Adressen ist". Genau diese Formulierung durch Ausschluss
* hat am 11.09.2026 dazu gefuehrt, dass der verborgene Zugang auf der
* neuen dritten Wand mitgalt, ohne dass es jemand entschieden haette.
* Wer eine vierte Adresse baut, muss sie hier NICHT eintragen -- und
* bekommt dadurch von selbst das sichere Verhalten. */
export function istPruefAdresse(hostKopfzeile) {
const h = nurHost(hostKopfzeile);
return h === "localhost" || h === "127.0.0.1" || h.startsWith("127.")
|| h === "::1" || h === "[::1]";
}
/** Ist das die Adresse des Treffs? */
export function istTreffAdresse(hostKopfzeile) {
return TREFF_HOSTS.has(nurHost(hostKopfzeile));
}
/** Gehoert dieser Pfad zum Workspace-Teil? Genau "/workspace" oder
* alles darunter -- aber NICHT /workspaceXYZ (Falle 3). */
export function istWorkspacePfad(pfad) {
@@ -128,6 +167,57 @@ export function istWorkspacePfad(pfad) {
return p === "/workspace" || p.startsWith("/workspace/");
}
/* =====================================================================
WELCHE WAND GEHOERT ZU WELCHER TUER (11.09.2026)
=====================================================================
Drei Zugangswaende liegen als drei Dateien im selben Ordner, und
express.static liefert jede aus, die man beim Namen nennt. Ohne eine
Regel kann also jeder jede aufrufen: die Community die Wand von Team
Dogi, das Team die Wand des Treffs, und beide auf der Agenturadresse.
DAS WAR SCHON EINMAL EIN LOCH, und gefunden hat es nicht das Lesen,
sondern der Wortleck-Test, der ALLE ausgelieferten Dateien durchsucht.
Damals stand die Regel als EIN Sonderfall im Code ("auf den alten
Adressen gibt es crew-index.html nicht"). Bei der dritten Wand waeren
daraus drei Sonderfaelle geworden, bei der vierten sechs. Deshalb
steht hier jetzt eine Tafel: Jede benannte Wand nennt die Adresse,
auf die sie gehoert, und die Frage wird einmal beantwortet.
404 UND KEINE WEITERLEITUNG: "Gibt es nicht" verraet nichts. Eine
Weiterleitung wuerde sagen, dass es die Datei gibt, nur woanders.
/workspace/index.html STEHT ABSICHTLICH NICHT IN DER TAFEL. Sie ist
keine benannte Wand, sondern die Vorgabe -- jede Adresse biegt sie
auf ihre eigene um, bevor daraus eine Datei wird. Stuende sie hier,
bekaeme man auf crew. und treff. ein 404 statt der eigenen Wand.
AUF PRUEFADRESSEN GILT DIE TAFEL NICHT, und das ist dieselbe
Ueberlegung wie bei OHNE_MODI_HOSTS: Sonst koennten die Pruefungen,
die jede Wand im Browser ansehen, keine mehr oeffnen -- sie blieben
gruen und wuerden nichts mehr messen. Ein gruener Lauf, der nichts
mehr prueft, ist schlimmer als ein roter. */
const BENANNTE_WAENDE = [
{ datei: "/workspace/crew-index.html", zuhause: istCrewAdresse },
{ datei: "/workspace/treff-index.html", zuhause: istTreffAdresse },
];
/** Wird hier eine Wand verlangt, die zu einer ANDEREN Tuer gehoert? */
export function istFremdeWand(hostKopfzeile, pfad) {
/* Mehrfache Schraegstriche zusammenziehen: express raeumt Punkt-
Segmente weg, doppelte Striche aber nicht -- und express.static
zieht sie danach selbst zusammen und liefert die Datei aus. Genau
daran ist am 04.09.2026 die Schranke des Workspace vorbeigelaufen. */
const p = String(pfad || "").toLowerCase().replace(/\/{2,}/g, "/");
const wand = BENANNTE_WAENDE.find((w) => w.datei === p);
if (!wand) return false;
if (istPruefAdresse(hostKopfzeile)) return false;
const benannt = istOhneModiAdresse(hostKopfzeile)
|| istCrewAdresse(hostKopfzeile) || istTreffAdresse(hostKopfzeile);
if (!benannt) return false;
return !wand.zuhause(hostKopfzeile);
}
/**
* Darf diese Person auf dieser Adresse angemeldet sein?
*
@@ -141,6 +231,16 @@ export function istWorkspacePfad(pfad) {
*/
export function sitzungPasstZurAdresse(rolle, hostKopfzeile) {
const team = TEAM_DOGI_ROLLEN.has(rolle);
/* AUSSENROLLEN GEHOEREN AUF GENAU EINE ADRESSE (11.09.2026).
Und die Bedingung steht in BEIDE Richtungen: Die Community kommt
nur auf treff. herein, und auf treff. kommt NUR sie herein. Das
Team sieht den Treff auf seiner eigenen Adresse -- es hat dort
nichts zu suchen, und ein Konto, das an zwei Waenden funktioniert,
ist eine Tuer, die man beim naechsten Umbau uebersieht. */
const aussen = AUSSEN_ROLLEN.has(rolle);
if (istTreffAdresse(hostKopfzeile)) return aussen;
if (aussen) return false;
/* crew.: das Team -- und DogFather, der in beiden Welten arbeitet. */
if (istCrewAdresse(hostKopfzeile)) return team || rolle === "admin";
/* Die echten alten Adressen: alle ausser dem Team. */
@@ -175,35 +275,18 @@ export function sitzungPasstZurAdresse(rolle, hostKopfzeile) {
* Serverumbau lautlos weg.
*/
export function crewWeiche(req, res, next) {
if (!istCrewAdresse(req.get("host"))) {
/* DIE ANDERE RICHTUNG -- und sie war ein echtes Loch.
/* DIE FREMDE WAND -- fuer JEDE Adresse, auch fuer crew. selbst.
Die zweite Zugangswand liegt als Datei im selben Ordner wie alles
andere und wird von express.static ausgeliefert. Ohne diese Zeile
kann sie JEDER unter ihrem Dateinamen aufrufen, auch auf
workspace.dogfather-universe.com -- und saehe dort eine
Anmeldeseite, die es dort gar nicht geben soll.
Gefunden hat das nicht ein Gedanke, sondern eine Pruefung: Der
Wortleck-Test durchsucht ALLE ausgelieferten Dateien, und dass er
diese eine ueberhaupt las, war die Antwort auf eine Frage, die
ich mir nicht gestellt hatte -- naemlich wer sie sonst noch
abrufen kann.
404 UND KEINE WEITERLEITUNG: "Gibt es nicht" ist die Antwort, die
nichts verraet. Eine Weiterleitung wuerde sagen, dass es die
Datei gibt, nur woanders.
NUR AUF DEN DREI ECHTEN ADRESSEN, nicht "ueberall ausser crew":
Sonst waere sie auch auf localhost gesperrt, und die Pruefung,
die die Wand im Browser ansieht, koennte sie nicht mehr oeffnen
-- sie wuerde gruen bleiben und nichts mehr messen. */
if (istOhneModiAdresse(req.get("host"))
&& req.path.toLowerCase().replace(/\/{2,}/g, "/") === "/workspace/crew-index.html") {
Sie steht vor der Weiche und nicht in ihrem Nein-Zweig. Dort stand
sie bis zum 11.09.2026, und das war richtig, solange es zwei Waende
gab: Die eigene kann man nicht "fremd" verlangen. Mit der dritten
stimmt das nicht mehr -- auf crew. ist die Wand des Treffs fremd,
und im Nein-Zweig waere diese Frage dort nie gestellt worden. */
if (istFremdeWand(req.get("host"), req.path)) {
return res.status(404).type("html").send("Nicht gefunden.");
}
return next();
}
if (!istCrewAdresse(req.get("host"))) return next();
res.setHeader("X-Robots-Tag", "noindex, nofollow");
+24
View File
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
import { gateMiddleware } from "./gate.js";
import { workspaceUmzug } from "./workspace-umzug.js";
import { crewWeiche } from "./crew-adresse.js";
import { treffWeiche } from "./treff-adresse.js";
import { webdesignGate } from "./webdesign-gate.js";
import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js";
import { workspaceRouter, sichtSetzen } from "./workspace.js";
@@ -23,6 +24,7 @@ import { kalenderRouter } from "./workspace-kalender.js";
import { serienRouter, serienStarten } from "./workspace-serien.js";
import { dateienRouter } from "./workspace-dateien.js";
import { bereicheRouter } from "./workspace-bereiche.js";
import { treffRouter } from "./workspace-treff.js";
import { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
import { callRouter } from "./workspace-calls.js";
@@ -127,6 +129,22 @@ app.use(workspaceUmzug);
gehen. Zwei Orte fuer dieselbe Regel waeren einer zu viel. */
app.use(crewWeiche);
/* Die eigene Adresse des Treffs (11.09.2026): treff.dogfather-universe.com.
Begruendung in treff-adresse.js, geprueft in pruef-treff-adresse.mjs.
DIREKT HINTER crewWeiche UND NICHT DAVOR. Die Reihenfolge ist keine
Geschmacksfrage: crewWeiche beantwortet ueber die Wandtafel fuer JEDE
Adresse die Frage "wird hier eine fremde Zugangswand verlangt?" --
auch fuer treff. Stuende treffWeiche davor, wuerde sie
/workspace/crew-index.html erst auf die Treff-Wand umbiegen und die
Tafel kaeme nie zum Zug. Ein Mitglied der Community saehe dann keine
404, sondern seine eigene Wand -- und niemandem waere aufgefallen,
dass die Sperre nicht mehr greift.
Auch sie vergibt KEINE Rechte. Wer auf welcher Adresse angemeldet
sein darf, entscheidet sitzungLesen() in workspace.js. */
app.use(treffWeiche);
/* Inhaltsrichtlinie (26.08.2026). Steht bewusst HIER -- vor jeder
Schranke und vor express.static: Auch die Zugangswand und die
404-Seite sollen sie bekommen, denn gerade dort werden Eingaben
@@ -235,6 +253,12 @@ app.use(kalenderRouter);
app.use(serienRouter);
app.use(dateienRouter);
app.use(bereicheRouter);
/* Der Treff (11.09.2026): Stimmen, Anheften, Freigeben, Melden,
Massnahmen, Stufen. Die sieben Bretter selbst laufen ueber
bereicheRouter -- es sind gewoehnliche Eintraege, und ein zweiter
Weg zu denselben Zeilen waere ein zweiter Ort fuer dieselbe
Rechtepruefung. */
app.use(treffRouter);
app.use(reportRouter);
app.use(scoutRouter);
app.use(callRouter);
+1 -1
View File
@@ -91,7 +91,7 @@ import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4391, "pruef-kachel-universum");
const PORT = await portMussFreiSein(4401, "pruef-kachel-universum");
const ordner = mkdtempSync(join(tmpdir(), "ws-universum-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+24 -3
View File
@@ -106,10 +106,11 @@ melde("=== 1. Jede Rolle und jede Seite hat einen Eintrag ===");
vergisst, faellt genau hier auf. */
const SEITEN_ORDNER = new URL("../workspace/", import.meta.url).pathname
.replace(/^\/([A-Za-z]:)/, "$1");
{
const dateien = readdirSync(SEITEN_ORDNER)
const DATEIEN = readdirSync(SEITEN_ORDNER)
.filter((f) => f.endsWith(".html"))
.map((f) => "/workspace/" + f).sort();
{
const dateien = DATEIEN;
const inTafel = new Set([...rechte.alleSeiten(), ...rechte.OHNE_ANMELDUNG]);
const fehlend = dateien.filter((p) => !inTafel.has(p));
const ueberzaehlig = rechte.alleSeiten().filter((p) => !dateien.includes(p));
@@ -143,7 +144,27 @@ const PHANTOM = "xx-gibt-es-nicht";
{
const alle = rechte.alleSeiten();
const durchgelassen = alle.filter((p) => rechte.darfSeite({ rolle: PHANTOM }, p));
ok(alle.length === 20, `${alle.length} Seiten in der Tafel`);
/* KEINE FESTE ZAHL MEHR (11.09.2026).
Hier stand `alle.length === 20`. Das war richtig an dem Tag, an dem
es geschrieben wurde, und wurde bei der naechsten neuen Seite rot --
ohne dass irgendetwas kaputt war. Eine feste Schwelle ist eine
Rechnung von gestern; genau deshalb steht sie in diesem Haus in der
Liste der teuren Fehler.
Gemessen wird jetzt, was die Zeile eigentlich wissen will: Die
Tafel deckt die Dateien ab, die es WIRKLICH gibt -- jede
ausgelieferte Seite genau einmal, die Zugangswaende ausgenommen
(die stehen in OHNE_ANMELDUNG, weil sich dort noch niemand
angemeldet hat).
UND SIE DARF NICHT LEER SEIN: `alle.length > 0` gehoert in die
BEDINGUNG, nicht nur in den Meldetext. Sonst waere die Zeile bei
einer leeren Tafel und einem leeren Ordner gruen -- der gruene
Haken ueber der Null. */
const erwartet = DATEIEN.length - rechte.OHNE_ANMELDUNG.length;
ok(alle.length > 0 && alle.length === erwartet,
`${alle.length} Seiten in der Tafel (${DATEIEN.length} Dateien minus ${rechte.OHNE_ANMELDUNG.length} Waende)`);
ok(durchgelassen.length === 0,
`eine unbekannte Rolle kommt auf KEINE davon${durchgelassen.length ? " -- durchgelassen: " + durchgelassen.join(", ") : ""}`);
+11 -2
View File
@@ -246,10 +246,19 @@ for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
nicht ans Ende, wo es niemand ansieht"). Das war meine Begruendung,
nicht seine Entscheidung -- und bei privaten Dingen wiegt "faellt
niemandem ins Auge" schwerer als "wird oft angesehen". */
/* SIEBEN SEIT DEM 11.09.2026 -- "Community" kam dazu, der Treff.
Sie steht GANZ UNTEN, und zwar aus demselben Grund wie die beiden
Gruppen davor: Dort stehen Namen und Saetze von Zuschauern, also
private Dinge fremder Leute. Wer sich den Bildschirm teilt, hat sie
dann nicht als Erstes im Blick.
Sie hat deshalb ausdruecklich KEIN `gruppeNach` -- eine Gruppe ohne
Angabe haengt hinten an, und das ist hier die richtige Stelle. */
const ERWARTETE_GRUPPEN = ["Rund um das Team", "Täglich", "Rund um den Creator",
"Team & System", "Team Dogi", "Entwicklung & Nachwuchs"];
"Team & System", "Team Dogi", "Entwicklung & Nachwuchs", "Community"];
ok(JSON.stringify(r.gruppen.map((g) => g.name)) === JSON.stringify(ERWARTETE_GRUPPEN),
`sechs Gruppen in dieser Reihenfolge (${r.gruppen.map((g) => g.name).join(" · ")})`);
`${ERWARTETE_GRUPPEN.length} Gruppen in dieser Reihenfolge (${r.gruppen.map((g) => g.name).join(" · ")})`);
/* WIE VIELE KACHELN? Nicht abgeschrieben, sondern aus bereiche.js
gezaehlt (06.09.2026).
+489
View File
@@ -0,0 +1,489 @@
/* =====================================================================
Die Werkzeuge des Treffs (11.09.2026)
pruef-treff.mjs fragt: WER SIEHT WAS. Diese Datei fragt: WAS
PASSIERT, wenn jemand etwas tut — „Will ich auch", anheften,
freigeben, melden, entfernen, sperren, übernehmen.
---------------------------------------------------------------------
ZU JEDER REGEL GEHÖRT HIER DIE GEGENPROBE, und zwar aus demselben
Grund wie überall in diesem Haus: Eine Prüfung, die immer bestätigt,
bestätigt nichts. Wenn „ein Modi darf nicht dauerhaft ausschließen"
grün ist, muss daneben stehen, dass DogFather es kann — sonst wäre
die Zeile auch dann grün, wenn NIEMAND es kann.
Konkret geprüft:
* „Will ich auch" ist EINE Stimme. Zweimal drücken ändert nichts,
und das Zurücknehmen geht.
* Am Anschlagbrett hängen höchstens drei. Die vierte wird
abgelehnt — und nach dem Abnehmen einer geht sie durch.
* Freigeben kann nur das Team. Ohne Freigabe ist der Eintrag für
die Community nicht da (steht in pruef-treff), mit Freigabe
schon.
* Melden ohne Grund geht nicht. Mit Grund landet es in der Liste
des Teams (DSA Art. 16).
* Entfernen ohne Grund geht nicht. Mit Grund ist der Eintrag weg
UND der Grund noch da (DSA Art. 17) — das ist der eigentliche
Punkt: Eine Begründung, die mit dem Beitrag verschwindet, ist
keine.
* Die Bannleiter: Ein Modi darf bis zu sieben Tage, DogFather auch
dauerhaft. Und der dauerhafte Ausschluss wirkt — die Sitzung ist
weg, nicht nur das Schreiben.
* Aus „Mitmachen" wird ein Talent, und zwar genau einmal.
Aufrufen mit: node server/pruef-treff-werkzeuge.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4402, "pruef-treff-werkzeuge");
const ordner = mkdtempSync(join(tmpdir(), "ws-treffw-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(240_000, "pruef-treff-werkzeuge");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const TREFF = "treff.dogfather-universe.com";
const CREW = "crew.dogfather-universe.com";
const HAUS = "workspace.dogfather-universe.com";
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const treffModul = await import("./workspace-treff.js");
const { BEREICHE } = await import("./workspace-bereiche.js");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const heute = jetzt.slice(0, 10);
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
/* Die Mitglieder werden ALT angelegt (40 Tage), damit sie schreiben
duerfen. Die Stufen selbst misst pruef-treff; hier waere ein frisches
Konto nur eine Huerde vor jeder einzelnen Zeile. */
const ALT = new Date(Date.now() - 40 * 86400000).toISOString();
function anlegen(name, rolle, code, erstellt = jetzt) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), erstellt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Frida", "modi", "CODE-MODI-0001");
const idKessi = anlegen("Kessi", "gast", "CODE-GAST-0001", ALT);
const idBo = anlegen("Bo", "gast", "CODE-GAST-0002", ALT);
/* Ein dritter Zugang, nur fuer den Ausschluss. Ihn Bo zu geben waere
bequem gewesen und haette den Rest der Datei zerlegt: Ein
Ausgeschlossener hat keine Sitzung mehr, also waere ab da JEDE
weitere Zeile mit Bo gruen geworden, weil alles 401 antwortet --
und "die Talente sieht er nicht" haette nichts mehr bewiesen. */
const idPim = anlegen("Pim", "gast", "CODE-GAST-0003", ALT);
const rein = d.prepare(
"INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)"
+ " VALUES (?,?,?,?,?,?,?)");
const ersteArt = (b) => Object.keys(BEREICHE[b].arten)[0];
function eintrag(bereich, titel, von = idDogi) {
rein.run(bereich, ersteArt(bereich), titel, "", heute, jetzt, von);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const wunsch1 = eintrag("wunsch", "WUNSCH-EINS");
const anschlag = [1, 2, 3, 4].map((n) => eintrag("anschlag", `ANSAGE-${n}`));
const highlight1 = eintrag("highlight", "CLIP-EINS");
const bosBeitrag = eintrag("treff", "BO-SAGT-HALLO", idBo);
const mitmachen1 = eintrag("mitmachen", "ICH-HAETTE-LUST", idBo);
d.close();
/* EIN DELETE TRAEGT KEINEN KOERPER (gemessen 11.09.2026).
Nodes HTTP-Parser weist ein DELETE mit Koerper mit einem leeren 400
ab, bevor Express es sieht -- nachgemessen: PUT mit Koerper 200,
DELETE ohne Koerper 200, DELETE mit Koerper 400 mit genau einem
Kopffeld. Die Pruefung schickt deshalb gar keinen erst; wuerde sie
es tun, meldete sie einen Fehler in einer Route, in der keiner ist.
Genau so ist dieser Befund entstanden. */
function roh(pfad, host, kopf = {}, koerper = null, methode = null) {
if (methode === "DELETE") koerper = null;
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: methode || (koerper ? "POST" : "GET"),
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
async function anmelden(rolle, code, host) {
const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
const kekse = [].concat(r.kopf?.["set-cookie"] || []);
return kekse.map((c) => c.split(";")[0]).join("; ");
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS);
const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
const kKessi = await anmelden("gast", "CODE-GAST-0001", TREFF);
const kBo = await anmelden("gast", "CODE-GAST-0002", TREFF);
ok(!!kDogi && !!kModi && !!kKessi && !!kBo, "alle vier sind angemeldet");
/* =======================================================================
1. WILL ICH AUCH — eine Stimme, nicht zwei
======================================================================= */
melde("");
melde("=== 1. Will ich auch ===");
{
const weg = `/workspace/api/treff/eintrag/${wunsch1}/willich`;
const a = await roh(weg, TREFF, { cookie: kKessi }, {}, "PUT");
ok(a.code === 200 && json(a).stimmen === 1, `Kessi stimmt ab (${json(a).stimmen})`);
/* ZWEIMAL DRUECKEN IST EINMAL. Das ist die eigentliche Regel: Der
zusammengesetzte Schluessel laesst gar keine zweite Zeile zu. */
const b = await roh(weg, TREFF, { cookie: kKessi }, {}, "PUT");
ok(b.code === 200 && json(b).stimmen === 1, `zweimal druecken aendert nichts (${json(b).stimmen})`);
const c = await roh(weg, TREFF, { cookie: kBo }, {}, "PUT");
ok(c.code === 200 && json(c).stimmen === 2, `Bo auch (${json(c).stimmen})`);
const e = await roh(weg, TREFF, { cookie: kKessi }, {}, "DELETE");
ok(e.code === 200 && json(e).stimmen === 1 && json(e).meine === false,
`Kessi nimmt sie zurueck (${json(e).stimmen})`);
/* Und die Zahl kommt am Brett an -- sonst waere sie eine Zahl, die
nur die Route kennt. */
const brett = await roh("/workspace/api/bereich/wunsch", TREFF, { cookie: kBo });
const zeile = (json(brett).eintraege || []).find((x) => x.id === wunsch1);
ok(zeile?.stimmen === 1 && zeile?.meine_stimme === 1,
`am Brett steht sie auch (${zeile?.stimmen}, meine ${zeile?.meine_stimme})`);
/* GEGENPROBE: Auf einem anderen Brett gibt es keine Stimme. Ohne
diese Zeile waere "es geht" auch dann gruen, wenn es ueberall
ginge -- und dann waere die Wunschliste keine Abstimmung mehr,
sondern ein Nebeneffekt. */
const f = await roh(`/workspace/api/treff/eintrag/${bosBeitrag}/willich`, TREFF,
{ cookie: kKessi }, {}, "PUT");
ok(f.code === 400, `im Gespraech gibt es kein "Will ich auch" (${f.code})`);
}
/* =======================================================================
2. ANHEFTEN — höchstens drei
======================================================================= */
melde("");
melde("=== 2. Anheften ===");
{
const heften = (id, keks, m = "PUT") =>
roh(`/workspace/api/treff/eintrag/${id}/anheften`, m === "PUT" ? CREW : CREW,
{ cookie: keks }, {}, m);
let durch = 0;
for (const id of anschlag.slice(0, 3)) {
const a = await heften(id, kModi);
if (a.code === 200) durch++;
}
ok(durch === 3, `drei Ansagen haengen (${durch})`);
const vierte = await heften(anschlag[3], kModi);
ok(vierte.code === 409, `die vierte wird abgelehnt (${vierte.code})`);
ok(/3/.test(json(vierte).fehler || ""),
"und die Absage nennt die Zahl, statt nur nein zu sagen");
/* NACH DEM ABNEHMEN GEHT SIE DURCH. Ohne diese Zeile waere die
Grenze auch dann gruen, wenn ueberhaupt nichts mehr angeheftet
werden koennte. */
await heften(anschlag[0], kModi, "DELETE");
const nochmal = await heften(anschlag[3], kModi);
ok(nochmal.code === 200, `nach dem Abnehmen geht die vierte (${nochmal.code})`);
/* Und die Community heftet nichts an. */
const gast = await roh(`/workspace/api/treff/eintrag/${anschlag[1]}/anheften`, TREFF,
{ cookie: kKessi }, {}, "PUT");
ok(gast.code === 404, `ein Mitglied kann nichts anheften (${gast.code})`);
const brett = await roh("/workspace/api/bereich/anschlag", CREW, { cookie: kModi });
const oben = (json(brett).eintraege || [])[0];
ok(oben?.angeheftet === 1, `oben steht eine angeheftete (${oben?.titel})`);
}
/* =======================================================================
3. MELDEN — der Grund ist Pflicht (DSA Art. 16)
======================================================================= */
melde("");
melde("=== 3. Melden ===");
{
const weg = `/workspace/api/treff/eintrag/${bosBeitrag}/melden`;
const ohne = await roh(weg, TREFF, { cookie: kKessi }, { grund: "" });
ok(ohne.code === 400, `ohne Grund geht es nicht (${ohne.code})`);
const kurz = await roh(weg, TREFF, { cookie: kKessi }, { grund: "doof" });
ok(kurz.code === 400, `"doof" ist kein Grund (${kurz.code})`);
const mit = await roh(weg, TREFF, { cookie: kKessi },
{ grund: "Da steht die Handynummer von jemandem drin." });
ok(mit.code === 200, `mit Grund geht es (${mit.code})`);
const liste = await roh("/workspace/api/treff/meldungen", CREW, { cookie: kModi });
const m = (json(liste).meldungen || [])[0];
ok(liste.code === 200 && m?.eintrag_id === bosBeitrag,
`der Modi sieht die Meldung (${(json(liste).meldungen || []).length})`);
ok(m?.autor_rolle_name === "Community",
`und wer sie geschrieben hat, mit vollem Rollennamen (${m?.autor_rolle_name})`);
/* Die Community sieht die Meldungen NICHT -- sonst waere ablesbar,
wer wen gemeldet hat. */
const gast = await roh("/workspace/api/treff/meldungen", TREFF, { cookie: kBo });
ok(gast.code === 404, `ein Mitglied sieht die Liste nicht (${gast.code})`);
/* Entscheiden braucht ebenfalls einen Satz. */
const leer = await roh(`/workspace/api/treff/meldung/${m?.id}`, CREW,
{ cookie: kModi }, { status: "erledigt", entscheid: "" }, "PATCH");
ok(leer.code === 400, `entscheiden ohne Begruendung geht nicht (${leer.code})`);
const fertig = await roh(`/workspace/api/treff/meldung/${m?.id}`, CREW,
{ cookie: kModi }, { status: "erledigt", entscheid: "Nummer entfernt, Bo Bescheid gesagt." },
"PATCH");
ok(fertig.code === 200, `mit Begruendung schon (${fertig.code})`);
const zweimal = await roh(`/workspace/api/treff/meldung/${m?.id}`, CREW,
{ cookie: kModi }, { status: "abgelehnt", entscheid: "Doch nicht so schlimm gewesen." },
"PATCH");
ok(zweimal.code === 409, `zweimal entscheiden geht nicht (${zweimal.code})`);
}
/* =======================================================================
4. ENTFERNEN — mit Grund, und der Grund überlebt (DSA Art. 17)
======================================================================= */
melde("");
melde("=== 4. Entfernen ===");
{
const weg = `/workspace/api/bereich/treff/${bosBeitrag}`;
const ohne = await roh(weg, CREW, { cookie: kModi }, null, "DELETE");
ok(ohne.code === 400, `ohne Grund wird nichts entfernt (${ohne.code})`);
/* Und der Eintrag ist auch wirklich noch da. Ein 400 sagt nur, was
der Server GEANTWORTET hat -- nicht, was er getan hat. */
const noch = await roh("/workspace/api/bereich/treff", CREW, { cookie: kModi });
ok((json(noch).eintraege || []).some((x) => x.id === bosBeitrag),
"und der Beitrag steht noch da");
const mit = await roh(`${weg}?grund=`
+ encodeURIComponent("Enthielt die Telefonnummer einer anderen Person."),
CREW, { cookie: kModi }, null, "DELETE");
ok(mit.code === 200, `mit Grund geht es (${mit.code})`);
const danach = await roh("/workspace/api/bereich/treff", CREW, { cookie: kModi });
ok(!(json(danach).eintraege || []).some((x) => x.id === bosBeitrag),
"der Beitrag ist weg");
/* DAS IST DER EIGENTLICHE PUNKT: Die Begruendung ist NICHT weg.
Haenge sie mit ON DELETE CASCADE am Eintrag, waere sie im selben
Moment verschwunden wie das, was sie begruendet. */
const akte = await roh("/workspace/api/treff/entfernt", HAUS, { cookie: kDogi });
const e = (json(akte).entfernt || [])[0];
ok(e?.eintrag_id === bosBeitrag && /Telefonnummer/.test(e?.grund || ""),
`und der Grund steht noch da (${e?.grund?.slice(0, 30)}…)`);
ok(e?.titel === "BO-SAGT-HALLO" && e?.autor_name === "Bo",
`samt Titel und Verfasser (${e?.titel}, ${e?.autor_name})`);
}
/* =======================================================================
5. DIE BANNLEITER — Modis befristet, dauerhaft nur DogFather
======================================================================= */
melde("");
melde("=== 5. Massnahmen ===");
{
const weg = "/workspace/api/treff/massnahme";
const ohne = await roh(weg, CREW, { cookie: kModi },
{ person_id: idBo, art: "pause", tage: 3, grund: "" });
ok(ohne.code === 400, `ohne Grund geht keine Massnahme (${ohne.code})`);
const kurz = await roh(weg, CREW, { cookie: kModi },
{ person_id: idBo, art: "pause", tage: 3, grund: "Hat mehrfach gegen Regel 2 verstossen." });
ok(kurz.code === 200, `ein Modi darf drei Tage Pause geben (${kurz.code})`);
const lang = await roh(weg, CREW, { cookie: kModi },
{ person_id: idPim, art: "ausschluss", tage: 30, grund: "Immer wieder dasselbe, trotz Hinweis." });
ok(lang.code === 403, `dreissig Tage darf er nicht (${lang.code})`);
ok(/7/.test(json(lang).fehler || ""), "und die Absage nennt die Grenze");
const dauer = await roh(weg, CREW, { cookie: kModi },
{ person_id: idPim, art: "ausschluss", grund: "Endgueltig, nach mehreren Hinweisen." });
ok(dauer.code === 403, `und dauerhaft schon gar nicht (${dauer.code})`);
/* GEGENPROBE. Ohne sie waeren die beiden Zeilen darueber auch dann
gruen, wenn NIEMAND ausschliessen koennte. Sieben Tage, damit Pim
fuer die naechste Zeile noch hereinkommt. */
const dogi7 = await roh(weg, CREW, { cookie: kModi },
{ person_id: idPim, art: "ausschluss", tage: 7, grund: "Sieben Tage, das darf er." });
ok(dogi7.code === 200, `sieben Tage darf ein Modi (${dogi7.code})`);
/* Und gegen jemanden aus dem TEAM geht gar nichts -- das gehoert
nach "Personen & Zugaenge", nicht hierher. */
const gegenTeam = await roh(weg, HAUS, { cookie: kDogi },
{ person_id: idDogi, art: "pause", tage: 1, grund: "Nur ein Versuch, das darf nicht gehen." });
ok(gegenTeam.code === 404, `gegen das Team gibt es keine Massnahme (${gegenTeam.code})`);
}
/* =======================================================================
6. DER AUSSCHLUSS WIRKT — nicht nur beim Schreiben
======================================================================= */
melde("");
melde("=== 6. Ausschluss ===");
{
/* Kessi kommt herein -- vor dem Ausschluss. Ohne diese Zeile waere
"sie kommt nicht mehr herein" auch dann gruen, wenn sie es nie
konnte. */
/* "dabei" und nicht "stamm": Kessi ist 40 Tage dabei, hat aber
keinen eigenen Beitrag geschrieben. Beide Bedingungen muessen
erfuellt sein -- genau das misst diese Zeile nebenbei mit. */
const vorher = await roh("/workspace/api/treff/lage", TREFF, { cookie: kKessi });
ok(vorher.code === 200 && json(vorher).stufe === "dabei",
`Kessi ist drin, Stufe ${json(vorher).stufe}`);
const aus = await roh("/workspace/api/treff/massnahme", HAUS, { cookie: kDogi },
{ person_id: idKessi, art: "ausschluss", grund: "Dauerhaft, nach mehreren Vorfaellen." });
ok(aus.code === 200 && json(aus).bis === null,
`DogFather schliesst dauerhaft aus (bis: ${json(aus).bis})`);
/* DIE SITZUNG IST WEG, nicht nur das Schreiben. Ein
Ausgeschlossener, der weiter mitlesen kann, ist bei Belaestigung
genau der Teil, der weh tut. */
const lage = await roh("/workspace/api/treff/lage", TREFF, { cookie: kKessi });
ok(lage.code === 401, `ihre Sitzung gilt nicht mehr (${lage.code})`);
const brett = await roh("/workspace/api/bereich/treff", TREFF, { cookie: kKessi });
ok(brett.code === 401, `und sie liest auch nichts mehr (${brett.code})`);
/* Und sie kommt auch nicht wieder herein -- gar nicht erst. Ohne
diese Zeile waere die Anmeldung gelungen, das Plaetzchen gesetzt,
und dahinter eine Seite, auf der nichts geht. Das sieht aus wie
ein kaputtes Programm, nicht wie eine Entscheidung. */
const neu = await anmelden("gast", "CODE-GAST-0001", TREFF);
ok(!neu, "und sie kann sich nicht neu anmelden");
/* GEGENPROBE: Bo hat nur eine PAUSE -- kein Ausschluss. Er darf
weiter lesen, aber nicht schreiben. Ohne diese Zeile waeren die
vier darueber auch dann gruen, wenn jede Massnahme jeden
aussperrt. */
const boLage = await roh("/workspace/api/treff/lage", TREFF, { cookie: kBo });
ok(boLage.code === 200, `Bo liest weiter (${boLage.code})`);
ok(json(boLage).sperre?.art === "pause" && !!json(boLage).sperre?.bis,
`und sieht seine Pause mit Enddatum (${json(boLage).sperre?.bis?.slice(0, 10)})`);
ok(json(boLage).schreiben?.treff === false, "schreiben darf er nicht");
/* Und Pim, sieben Tage ausgeschlossen, kommt gar nicht mehr herein
-- ein befristeter Ausschluss ist ein Ausschluss, keine Pause. */
const pim = await anmelden("gast", "CODE-GAST-0003", TREFF);
ok(!pim, "ein befristeter Ausschluss sperrt ebenfalls aus");
const versuch = await roh("/workspace/api/bereich/treff", TREFF, { cookie: kBo },
{ art: ersteArt("treff"), titel: "BO-TROTZDEM", text: "Hallo?", datum: heute });
ok(versuch.code === 403, `und es geht auch wirklich nicht (${versuch.code})`);
}
/* =======================================================================
7. AUS „MITMACHEN" WIRD EIN TALENT — genau einmal
======================================================================= */
melde("");
melde("=== 7. Die Bruecke zu den Talenten ===");
{
const weg = `/workspace/api/treff/mitmachen/${mitmachen1}/uebernehmen`;
const a = await roh(weg, HAUS, { cookie: kDogi }, {});
ok(a.code === 200 && json(a).talent_id > 0, `Bo landet bei den Talenten (${a.code})`);
const b = await roh(weg, HAUS, { cookie: kDogi }, {});
ok(b.code === 409, `ein zweites Mal legt keinen zweiten an (${b.code})`);
const talente = await roh("/workspace/api/bereich/talente", HAUS, { cookie: kDogi });
const t = (json(talente).eintraege || []).filter((x) => x.creator_id === idBo);
ok(t.length === 1, `und es steht genau einer dort (${t.length})`);
ok(/Bo/.test(t[0]?.titel || ""), `mit seinem Namen (${t[0]?.titel})`);
/* Die Community kommt an die Talente nicht heran -- sonst waere die
Bruecke ein Fenster. */
const gast = await roh("/workspace/api/bereich/talente", TREFF, { cookie: kBo });
ok(gast.code === 404, `ein Mitglied sieht die Talente nicht (${gast.code})`);
}
/* =======================================================================
8. DIE REGELN NENNEN DIE ZAHLEN, DIE WIRKLICH GELTEN
======================================================================= */
melde("");
melde("=== 8. Der Regeltext ===");
{
const a = await roh("/workspace/api/treff/regeln", TREFF, { cookie: kBo });
const r = json(a);
ok(a.code === 200 && r.mindestalter === treffModul.MINDESTALTER,
`Mindestalter ${r.mindestalter} -- dieselbe Zahl wie im Code`);
ok(r.mindestalter === 18, "und sie ist 18 (Entscheidung Filipe, 11.09.2026)");
ok(Array.isArray(r.stufen) && r.stufen.length === treffModul.STUFEN.length,
`alle ${r.stufen?.length} Stufen kommen mit`);
ok(r.anschlag_max === treffModul.ANSCHLAG_MAX,
`und die Zahl am Anschlagbrett (${r.anschlag_max})`);
/* Die Seite selbst gehoert der Community UND denen, die moderieren
-- ein Modi, der die Regeln nicht sehen kann, moderiert nach
Gefuehl. */
const rechte = await import("./rechte.js");
const wer = rechte.SEITEN["/workspace/treff-regeln.html"] || [];
ok(wer.includes("gast") && wer.includes("modi") && wer.includes("admin"),
`die Regelseite steht fuer ${wer.join(", ")} offen`);
ok(!wer.includes("manager") && !wer.includes("creator") && !wer.includes("spicy"),
"und nicht fuer die Agentur");
/* "Wer sieht was" ist allein DogFathers Seite. */
const tafel = rechte.SEITEN["/workspace/rechte.html"] || [];
ok(tafel.length === 1 && tafel[0] === "admin",
`"Wer sieht was" nur fuer ${tafel.join(", ")}`);
const fremd = await roh("/workspace/api/rechte/tafel", CREW, { cookie: kModi });
ok(fremd.code === 404, `ein Modi bekommt die Tafel nicht (${fremd.code})`);
const meine = await roh("/workspace/api/rechte/tafel", HAUS, { cookie: kDogi });
const t = json(meine);
ok(meine.code === 200 && t.seiten?.length === rechte.alleSeiten().length,
`DogFather bekommt alle ${t.seiten?.length} Seiten`);
const community = (t.rollen || []).find((x) => x.schluessel === "gast");
ok(community && community.seiten < 5,
`und die Community steht bei ${community?.seiten} von ${t.seiten?.length}`);
}
/* ------------------------------------------------------------------- */
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
/* Der Ordner bleibt manchmal liegen, weil der Dienst die Datenbank
unter Windows noch offen haelt. Das ist ein Aufraeumrest, kein
Befund -- deshalb faellt der Lauf daran nicht durch. */
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);
+613
View File
@@ -0,0 +1,613 @@
/* =====================================================================
DER TREFF — die Community und ihre sieben Bretter (11.09.2026)
Filipe:
"eine community rolle und community kategorie. wo die community
auch dan nur die sieht und die modis rechte hand und ich also
dogfather sehen die aber auch. die community aber nur die dann."
"wieso nur eine kachel fuer die community ich will dass die
mehrere kacheln haben. mit mehreren optionen."
---------------------------------------------------------------------
DIESE PRUEFUNG MISST IN BEIDE RICHTUNGEN, UND DAS IST DER GANZE PUNKT
Eine Trennung kann auf zwei Arten falsch sein, und die zweite ist die
gefaehrlichere:
1. SIE GREIFT NICHT -- die Community sieht Dinge des Hauses. Das
faellt auf, wenn jemand hinsieht.
2. SIE GREIFT ZU WEIT -- die Community sieht ihren eigenen Raum
nicht. Das faellt NICHT auf: Ein leeres Brett sieht aus wie
"hier ist nichts los", nicht wie ein Fehler. Genau so ist am
01.09. ein Manager vor einem leeren Aufgabenbrett gestanden und
hat es nicht gemeldet.
Deshalb steht zu jeder Zeile "sieht es nicht" eine Zeile "und der da
sieht es sehr wohl". Ohne die zweite waere die erste auch dann gruen,
wenn schlicht gar nichts mehr geht.
---------------------------------------------------------------------
WARUM node:http UND NICHT fetch
Das Team und die Community melden sich an verschiedenen Waenden an,
und welche Wand gilt, entscheidet der Host-Kopf. `fetch` in Node
setzt den selbst und ignoriert einen gesetzten -- die Anmeldung ginge
dann an die falsche Wand und scheiterte, ohne dass man saehe warum.
Dieselbe Falle wie am 10.09. bei den Kategorie-Kanaelen.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4398, "pruef-treff");
const ordner = mkdtempSync(join(tmpdir(), "ws-treff-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(240_000, "pruef-treff");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const TREFF = "treff.dogfather-universe.com";
const CREW = "crew.dogfather-universe.com";
const HAUS = "workspace.dogfather-universe.com";
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const w = await import("./workspace.js");
const treffModul = await import("./workspace-treff.js");
const { BEREICHE } = await import("./workspace-bereiche.js");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Rieke", "hand", "CODE-HAND-0001");
anlegen("Frida", "modi", "CODE-MODI-0001");
anlegen("Mara", "manager", "CODE-MANA-0001");
anlegen("Luna", "creator", "CODE-CREA-0001");
const idGast = anlegen("Kessi", "gast", "CODE-GAST-0001");
/* EIN EINTRAG IN JEDEM DER SIEBEN BRETTER. Ohne Daten waere jede Zeile
"sieht nichts" trivial wahr -- der gruene Haken ueber der Null. */
const rein = d.prepare(
"INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)"
+ " VALUES (?,?,?,?,?,?,?)");
const BRETTER = ["anschlag", "ansteht", "treff", "wunsch", "highlight", "regeln", "mitmachen"];
for (const b of BRETTER) rein.run(b, Object.keys((await import("./workspace-bereiche.js")).BEREICHE[b].arten)[0],
`TREFF-PROBE-${b.toUpperCase()}`, "", jetzt.slice(0, 10), jetzt, idDogi);
/* Und einer im Haus, den die Community NICHT sehen darf. */
rein.run("ideen", "content", "HAUS-GEHEIM-4711", "", jetzt.slice(0, 10), jetzt, idDogi);
d.close();
/* Die Methode ist ein eigenes Argument, seit es Wege gibt, die PUT
oder DELETE heissen. Vorher wurde sie aus "gibt es einen Koerper?"
erraten -- das ging, solange nur GET und POST vorkamen, und haette
beim ersten PUT stillschweigend ein POST geschickt. Der Server
haette mit 404 geantwortet, und man sucht den Fehler im Server. */
function roh(pfad, host, kopf = {}, koerper = null, methode = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: methode || (koerper ? "POST" : "GET"),
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
async function anmelden(rolle, code, host) {
const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
const kekse = [].concat(r.kopf?.["set-cookie"] || []);
return { keks: kekse.map((c) => c.split(";")[0]).join("; "), code: r.code };
}
/* =======================================================================
1. DIE WAND: die Community kommt auf genau eine Adresse
======================================================================= */
melde("");
melde("=== 1. Eine Rolle, eine Wand ===");
const gast = await anmelden("gast", "CODE-GAST-0001", TREFF);
ok(gast.code === 200 && gast.keks.length > 0, `die Community kommt auf treff. herein (${gast.code})`);
for (const [name, host] of [["crew.", CREW], ["workspace.", HAUS]]) {
const a = await anmelden("gast", "CODE-GAST-0001", host);
const b = await anmelden("gast", "VOELLIG-ERFUNDEN-XYZ", host);
ok(a.code === 401 && a.keks === "", `auf ${name} nicht (${a.code})`);
ok(a.code === b.code, `und zwar mit derselben Antwort wie ein erfundener Code (${a.code}/${b.code})`);
}
{
/* UND DIE GEGENRICHTUNG: Auf der Wand des Treffs kommt sonst niemand
herein -- auch DogFather nicht. Er hat dort nichts zu suchen; er
sieht den Treff auf seiner eigenen Adresse. */
const a = await anmelden("admin", "CODE-DOGI-0001", TREFF);
const b = await anmelden("modi", "CODE-MODI-0001", TREFF);
ok(a.code !== 200 && b.code !== 200,
`auf treff. kommt sonst niemand herein -- auch DogFather nicht (${a.code}/${b.code})`);
}
const kDogi = (await anmelden("admin", "CODE-DOGI-0001", HAUS)).keks;
const kModi = (await anmelden("creator", "CODE-MODI-0001", CREW)).keks;
const kCrea = (await anmelden("creator", "CODE-CREA-0001", HAUS)).keks;
const kMana = (await anmelden("manager", "CODE-MANA-0001", HAUS)).keks;
ok([kDogi, kModi, kCrea, kMana].every((k) => k.length > 0), "die vier Vergleichsrollen sind angemeldet");
/* =======================================================================
1b. DIE WEICHE: drei Adressen, drei Türen, keine Verwechslung
======================================================================= */
melde("");
melde("=== 1b. Die Weiche des Treffs ===");
{
/* DIE WAND. Ohne die Weiche stünde auf der Adresse der Community die
Wand der Agentur: Spicy-Media-Logo, "Creator Workspace", fünf
Rollenkacheln mit den Namen Admin, Manager, Scout, Creator -- die
komplette Struktur des Unternehmens, VOR der Anmeldung, für jeden,
der die Adresse hat. Das ist das eigentliche Leck, gegen das
treffWeiche gebaut ist. */
for (const pfad of ["/workspace/", "/workspace/index.html"]) {
const a = await roh(pfad, TREFF);
const ist = a.code === 200 && /Der&nbsp;Treff|Der Treff/.test(a.text);
ok(ist, `${pfad} zeigt die Wand des Treffs (${a.code})`);
ok(!/Creator\s*Workspace|Spicy/i.test(a.text),
`und nennt weder Workspace noch Spicy (${pfad})`);
}
/* KEINE ROLLENNAMEN DES HAUSES in der ausgelieferten Datei. Die
beiden anderen Wände nennen ihre Rollen offen -- dort ist das
richtig, denn dort steht nur, wer ohnehin dazugehört. Hier steht
ein Mensch, der NICHT zum Team gehört. */
{
const a = await roh("/workspace/treff-index.html", TREFF);
const verraten = ["manager", "scout", "creator", "modi", "rechte hand"]
.filter((w) => new RegExp(w, "i").test(a.text));
ok(verraten.length === 0,
`die Wand nennt keine Rolle des Hauses${verraten.length ? " -- FUND: " + verraten.join(", ") : ""}`);
}
/* DAS MANIFEST MACHT DIE APP. Drei Adressen, drei Namen -- sonst
hieße die App auf dem Startbildschirm eines Mitglieds "Team Dogi",
der Name eines Bereichs, zu dem es nicht gehört. */
const namen = {};
for (const [was, host] of [["treff", TREFF], ["crew", CREW], ["haus", HAUS]]) {
const a = await roh("/workspace/app.webmanifest", host);
try { namen[was] = JSON.parse(a.text).name; } catch { namen[was] = `(kein JSON: ${a.code})`; }
}
ok(namen.treff === "Der Treff", `das Manifest heißt "${namen.treff}"`);
ok(namen.crew === "Team Dogi" && namen.treff !== namen.crew,
`und auf crew. weiterhin "${namen.crew}"`);
ok(new Set(Object.values(namen)).size === 3,
`drei Adressen, drei Namen (${Object.values(namen).join(" · ")})`);
/* JEDE WAND GEHÖRT ZU GENAU EINER TÜR. Beide Richtungen, sonst
beweist es nichts: Die eigene Wand MUSS erreichbar sein. */
const waende = [
["/workspace/crew-index.html", CREW, TREFF],
["/workspace/treff-index.html", TREFF, CREW],
["/workspace/treff-index.html", TREFF, HAUS],
];
for (const [datei, zuhause, fremd] of waende) {
const a = await roh(datei, zuhause);
const b = await roh(datei, fremd);
ok(a.code === 200, `${datei} gibt es zu Hause (${a.code})`);
ok(b.code === 404, `und auf der fremden Adresse nicht (${b.code})`);
}
/* Auch mit doppeltem Schrägstrich -- express.static zieht sie später
selbst zusammen und liefert die Datei aus. Genau daran ist am
04.09.2026 die Schranke des Workspace vorbeigelaufen. */
const schraeg = await roh("/workspace//treff-index.html", CREW);
ok(schraeg.code === 404, `auch mit doppeltem Schrägstrich nicht (${schraeg.code})`);
/* NICHT INDIZIEREN -- als Kopf und nicht nur als <meta>, damit er
auch für Manifest, Bilder und jede Antwort ohne HTML gilt. */
const kopf = await roh("/workspace/", TREFF);
ok(/noindex/i.test(kopf.kopf?.["x-robots-tag"] || ""),
`der Kopf sagt noindex (${kopf.kopf?.["x-robots-tag"]})`);
/* NUR DER WORKSPACE. Sonst läge die komplette Dogfather-Website ein
zweites Mal unter einer zweiten Adresse. */
const haupt = await roh("/", TREFF);
ok(haupt.code === 302 && haupt.kopf?.location === "/workspace/",
`die Hauptseite gibt es hier nicht (${haupt.code} → ${haupt.kopf?.location})`);
/* DAS HAUS. haus.css ist auf der Agenturadresse leer; auf treff.
muss dahinter das Farbhaus von Team Dogi stehen -- sonst käme die
Seite grau an und färbte sich erst im Browser um. */
const haus = await roh("/workspace/assets/css/haus.css?v=1", TREFF);
ok(haus.code === 200 && /--sternenfeld/.test(haus.text),
`haus.css bringt das Farbhaus mit (${haus.text.length} Zeichen)`);
/* DIE BÜHNEN INNEN. Die Motive der Agentur zeigen das
Spicy-Media-Logo selbst -- teils vielfach nebeneinander. */
const a1 = await roh("/workspace/assets/img/buehne-lounge-breit.webp", TREFF);
const a2 = await roh("/workspace/assets/img/crew-buehne-lounge-breit.webp", TREFF);
const a3 = await roh("/workspace/assets/img/buehne-lounge-breit.webp", HAUS);
ok(a1.code === 200 && a1.text.length === a2.text.length,
`die Bühne kommt von Team Dogi (${a1.text.length} = ${a2.text.length})`);
ok(a1.text.length !== a3.text.length,
`und ist eine andere als die der Agentur (${a1.text.length} ≠ ${a3.text.length})`);
/* DAS ZEICHEN IN DER KOPFLEISTE. start.css setzt dort eine Chili --
als CSS-Hintergrund, auf jeder Seite, den ganzen Tag. */
const m1 = await roh("/workspace/assets/img/marke-spicy.webp", TREFF);
const m2 = await roh("/workspace/assets/img/marke-husky.webp", TREFF);
const m3 = await roh("/workspace/assets/img/marke-spicy.webp", HAUS);
ok(m1.code === 200 && m1.text.length === m2.text.length,
`in der Kopfleiste steht der Husky (${m1.text.length})`);
ok(m1.text.length !== m3.text.length,
`und nicht die Chili (${m1.text.length} ≠ ${m3.text.length})`);
}
/* =======================================================================
2. DIE KACHELN: sieben, und sonst nichts
======================================================================= */
melde("");
melde("=== 2. Sieben Kacheln — und keine achte ===");
let namen = [];
{
const a = await roh("/workspace/api/ich", TREFF, { cookie: gast.keks });
const j = JSON.parse(a.text);
ok(Array.isArray(j.bereiche) && j.bereiche.length === 7,
`die Community bekommt sieben Kacheln (${j.bereiche?.length})`);
/* Die Namen werden im naechsten Block noch einmal gebraucht -- dort
wird gegen sie geprueft, statt sie ein zweites Mal zu tippen. Zwei
Abschriften derselben sieben Namen waeren zwei Gelegenheiten,
einen davon zu aendern und den anderen zu vergessen. */
namen = (j.bereiche || []).map((k) => k.name);
ok(namen.join(" | ") === "Anschlagbrett | Was ansteht | Der Treff | Wunschliste | Highlights | Regeln & Hilfe | Mitmachen",
`und zwar diese: ${namen.join(", ")}`);
ok((j.bereiche || []).every((k) => k.gruppe === "Community"),
"alle in der Gruppe „Community“");
const toene = new Set((j.bereiche || []).map((k) => k.ton));
ok(toene.size === 7, `sieben verschiedene Farben (${[...toene].join(", ")})`);
ok(j.rolle_name === "Community", `die Rolle heißt „${j.rolle_name}“`);
}
{
/* GEGENPROBE: Das Team sieht sie mit -- sonst waere "sieben Kacheln"
auch dann gruen, wenn sie sonst niemand bekommt.
UND ES SIEHT EINE MEHR (11.09.2026): "Meldungen & Massnahmen". Sie
ist ausdruecklich KEIN Brett und steht deshalb nicht in
TREFF_BEREICHE -- sonst waere sie bei der Community gelandet, und
zwar lautlos: Sie saehe dort aus wie die anderen sieben, und erst
beim Klicken haette der Server 404 gesagt.
Geprueft wird deshalb nicht eine Zahl, sondern die BEZIEHUNG: Die
sieben der Community sind eine echte Teilmenge dessen, was das Team
sieht -- und der Unterschied ist genau die Moderationskachel. Eine
Zahl waere beim naechsten Umbau wieder rot, ohne dass etwas kaputt
ist; diese Bedingung nicht. */
const MOD = "Meldungen & Ma\u00dfnahmen";
const a = JSON.parse((await roh("/workspace/api/ich", CREW, { cookie: kModi })).text);
const beimModi = (a.bereiche || []).filter((k) => k.gruppe === "Community").map((k) => k.name);
const fehlt = namen.filter((n) => !beimModi.includes(n));
ok(fehlt.length === 0,
`ein Modi sieht alle sieben der Community mit${fehlt.length ? " -- FEHLT: " + fehlt.join(", ") : ""}`);
ok(beimModi.includes(MOD) && !namen.includes(MOD),
`und genau eine mehr: ${beimModi.filter((n) => !namen.includes(n)).join(", ")}`);
const b = JSON.parse((await roh("/workspace/api/ich", HAUS, { cookie: kDogi })).text);
const beiDogi = (b.bereiche_zusatz || []).filter((k) => k.gruppe === "Community").map((k) => k.name);
ok(namen.every((n) => beiDogi.includes(n)) && beiDogi.includes(MOD),
`und DogFather auf seiner Adresse auch (${beiDogi.length})`);
const c = JSON.parse((await roh("/workspace/api/ich", HAUS, { cookie: kMana })).text);
const beiMana = (c.bereiche_zusatz || []).filter((k) => k.gruppe === "Community");
ok(beiMana.length === 0, `eine Managerin nicht (${beiMana.length})`);
/* UND DIE MODERATIONSKACHEL IST BEI DER COMMUNITY WIRKLICH NICHT DA
-- nicht nur "nicht in dieser Liste". Die Kachel ist ein Weg; wenn
der Weg zu ist, muss auch die Tuer zu sein. */
const d = JSON.parse((await roh("/workspace/api/ich", TREFF, { cookie: gast.keks })).text);
const alleNamen = [...(d.bereiche || []), ...(d.bereiche_zusatz || [])].map((k) => k.name);
ok(!alleNamen.includes(MOD), `die Community hat keine Moderationskachel (${alleNamen.length} Kacheln)`);
}
/* =======================================================================
3. DIE BRETTER: was die Community sieht — und was nicht
======================================================================= */
melde("");
melde("=== 3. Die sieben Bretter ===");
async function brett(b, host, keks) {
const a = await roh(`/workspace/api/bereich/${b}`, host, { cookie: keks });
let titel = [];
try { titel = (JSON.parse(a.text).eintraege || []).map((e) => e.titel); } catch { /* kein JSON */ }
return { code: a.code, titel };
}
{
let gefunden = 0;
for (const b of BRETTER) {
const r = await brett(b, TREFF, gast.keks);
if (r.code === 200 && r.titel.includes(`TREFF-PROBE-${b.toUpperCase()}`)) gefunden++;
}
/* FUENF VON SIEBEN, UND DAS IST DAS ERGEBNIS -- kein Mangel.
Termine und Highlights sind fuer die Community erst da, wenn
jemand aus dem Team sie freigegeben hat (TREFF_FREIGABE_BRETTER).
Die Zahl steht deshalb nicht als 7 hier, sondern wird aus der
Tafel gerechnet: Wer ein drittes Freigabe-Brett anlegt, bekommt
hier keine rote Zeile fuer etwas Richtiges. */
const OFFENE = BRETTER.filter((b) => !treffModul.TREFF_FREIGABE_BRETTER.includes(b));
ok(gefunden === OFFENE.length,
`die Community sieht die ${OFFENE.length} freien Bretter mit Inhalt (${gefunden}/${OFFENE.length})`);
/* DIE GEGENPROBE IN BEIDE RICHTUNGEN. Ohne sie waere die Zeile oben
auch dann gruen, wenn die Freigabe gar nicht wirkt und die beiden
Bretter aus einem anderen Grund leer sind. */
let vorher = 0;
for (const b of treffModul.TREFF_FREIGABE_BRETTER) {
const r = await brett(b, TREFF, gast.keks);
if (r.titel.includes(`TREFF-PROBE-${b.toUpperCase()}`)) vorher++;
}
ok(vorher === 0, `ohne Freigabe sieht die Community Termine und Highlights nicht (${vorher})`);
/* Jetzt freigeben -- ueber den echten Weg, nicht in der Datenbank. */
let freigegeben = 0;
for (const b of treffModul.TREFF_FREIGABE_BRETTER) {
const beiDogi2 = await roh(`/workspace/api/bereich/${b}`, HAUS, { cookie: kDogi });
const eintrag = (JSON.parse(beiDogi2.text).eintraege || [])
.find((e) => e.titel === `TREFF-PROBE-${b.toUpperCase()}`);
if (!eintrag) continue;
const a = await roh(`/workspace/api/treff/eintrag/${eintrag.id}/freigeben`, HAUS,
{ cookie: kDogi }, {}, "PUT");
if (a.code === 200) freigegeben++;
}
ok(freigegeben === treffModul.TREFF_FREIGABE_BRETTER.length,
`DogFather gibt beide frei (${freigegeben})`);
let nachher = 0;
for (const b of treffModul.TREFF_FREIGABE_BRETTER) {
const r = await brett(b, TREFF, gast.keks);
if (r.titel.includes(`TREFF-PROBE-${b.toUpperCase()}`)) nachher++;
}
ok(nachher === treffModul.TREFF_FREIGABE_BRETTER.length,
`nach der Freigabe sieht sie beide (${nachher})`);
/* Und ein Modi darf nicht freigeben, was er nicht sehen darf --
hier: dass die Route ueberhaupt Rechte prueft. Eine Creatorin
bekommt 404, nicht 403: den Treff gibt es fuer sie nicht. */
const fremd = await roh("/workspace/api/treff/eintrag/1/freigeben", HAUS,
{ cookie: kCrea }, {}, "PUT");
ok(fremd.code === 404, `eine Creatorin kann nichts freigeben (${fremd.code})`);
}
{
/* KEIN EINZIGES BRETT DES HAUSES. Ueber die Adresszeile probiert,
nicht ueber die Kacheln -- eine Regel, die nur im Formular gilt,
ist keine Regel. */
const fremde = ["ideen", "angebote", "rueckmeldung", "entwicklung", "talente", "agentur", "live", "content"];
const durch = [];
for (const b of fremde) {
const r = await brett(b, TREFF, gast.keks);
if (r.code === 200) durch.push(b);
}
ok(durch.length === 0, `und kein einziges des Hauses${durch.length ? " -- DURCH: " + durch.join(", ") : ""}`);
/* DIE SUCHE SPIEGELT DIE FRAGE ZURUECK (Feld `frage`). Wer den
Rohtext nach dem Suchwort durchsucht, findet deshalb IMMER einen
Treffer -- und meldet ein Leck, das es nicht gibt. Genau darauf
bin ich am 10.09. schon einmal hereingefallen und haette fast ein
Loch repariert, das keines war. Gezaehlt wird, was die Suche
WIRKLICH gefunden hat. */
const suche = await roh("/workspace/api/suche?q=HAUS-GEHEIM-4711", TREFF, { cookie: gast.keks });
let treffer = -1;
try { treffer = JSON.parse(suche.text).gesamt; } catch { treffer = -1; }
ok(treffer === 0, `auch die Suche findet nichts aus dem Haus (${treffer} Treffer)`);
}
{
/* UND DIE AGENTUR SIEHT DEN TREFF NICHT. Beide Richtungen, beide
ueber die Adresszeile. */
const durch = [];
for (const b of BRETTER) {
const r = await brett(b, HAUS, kCrea);
if (r.code === 200 && r.titel.some((t) => t.startsWith("TREFF-PROBE"))) durch.push(b);
}
ok(durch.length === 0, `eine Creatorin sieht kein Brett des Treffs${durch.length ? " -- DURCH: " + durch.join(", ") : ""}`);
const durchM = [];
for (const b of BRETTER) {
const r = await brett(b, HAUS, kMana);
if (r.code === 200 && r.titel.some((t) => t.startsWith("TREFF-PROBE"))) durchM.push(b);
}
ok(durchM.length === 0, `eine Managerin auch nicht${durchM.length ? " -- DURCH: " + durchM.join(", ") : ""}`);
/* GEGENPROBE: Der Modi sieht sie sehr wohl. Ohne diese Zeile waeren
die beiden darueber auch gruen, wenn die Bretter fuer NIEMANDEN
lesbar waeren. */
let sieht = 0;
for (const b of BRETTER) {
const r = await brett(b, CREW, kModi);
if (r.code === 200 && r.titel.includes(`TREFF-PROBE-${b.toUpperCase()}`)) sieht++;
}
ok(sieht === 7, `ein Modi sieht alle sieben (${sieht}/7)`);
}
/* =======================================================================
4. DIE SEITEN: die aus der Tafel, und keine einzige mehr
======================================================================= */
melde("");
melde("=== 4. Nur die Seiten aus der Tafel ===");
{
/* DIE LISTE WIRD NICHT GETIPPT, SIE WIRD GEHOLT (11.09.2026).
Hier standen zwei Seiten und daneben "und keine dritte". Beim
Anlegen der Regelseite wurde daraus eine dritte -- die Prüfung
war rot, obwohl nichts kaputt war, und die naheliegende Reaktion
wäre gewesen, die Zahl von zwei auf drei zu setzen. Beim vierten
Mal wieder.
Gemessen wird jetzt das, was die Zeile eigentlich behaupten will:
Die Tafel und die Wirklichkeit stimmen überein — jede Seite, die
in rechte.js für "gast" steht, geht auf; jede andere ausgelieferte
Seite geht NICHT auf. Das ist strenger als die alte Fassung (die
elf Namen waren von Hand gewählt) und altert nicht. */
const rechte = await import("./rechte.js");
const { readdirSync } = await import("node:fs");
const ordner = new URL("../workspace/", import.meta.url).pathname
.replace(/^\/([A-Za-z]:)/, "$1");
const alleDateien = readdirSync(ordner).filter((f) => f.endsWith(".html"))
.map((f) => "/workspace/" + f);
const erlaubt = rechte.seitenFuer("gast");
const verboten = alleDateien
.filter((p) => !erlaubt.includes(p) && !rechte.OHNE_ANMELDUNG.includes(p));
ok(erlaubt.length > 0 && verboten.length > 0,
`die Tafel gibt der Community ${erlaubt.length} Seiten und verbietet ${verboten.length}`);
const zu = [];
for (const p of erlaubt) {
const a = await roh(p, TREFF, { cookie: gast.keks });
if (a.code !== 200) zu.push(`${p.split("/").pop()} (${a.code})`);
}
ok(zu.length === 0,
`alle ${erlaubt.length} erlaubten öffnen sich${zu.length ? " -- ZU: " + zu.join(", ") : ""}`);
const durch = [];
for (const p of verboten) {
const a = await roh(p, TREFF, { cookie: gast.keks });
if (a.code === 200) durch.push(p.split("/").pop());
}
ok(durch.length === 0,
`und keine der ${verboten.length} anderen${durch.length ? " -- DURCH: " + durch.join(", ") : ""}`);
/* UND DIE MODERATIONSSEITE AUSDRÜCKLICH NICHT. Sie steht zwar in der
Liste oben mit drin -- aber sie ist die eine, bei der ein Fehler
wirklich weh täte: Dort stehen die Namen derer, die gemeldet
haben. Eine eigene Zeile dafür, damit der Befund einen Namen hat
und nicht in "eine von 20" untergeht. */
const mod = await roh("/workspace/treff-moderation.html", TREFF, { cookie: gast.keks });
ok(mod.code !== 200, `die Moderationsseite bleibt zu (${mod.code})`);
const modApi = await roh("/workspace/api/treff/meldungen", TREFF, { cookie: gast.keks });
ok(modApi.code === 404, `und die Meldungen dahinter auch (${modApi.code})`);
}
/* =======================================================================
5. DAS HAUS BLEIBT ZU: Aufgaben, Termine, Dateien, Personen
======================================================================= */
melde("");
melde("=== 5. Vom Haus sieht sie nichts ===");
for (const [name, pfad] of [
["Aufgaben", "/workspace/api/aufgaben"],
["Termine", "/workspace/api/termine"],
["Dateien", "/workspace/api/dateien"],
["Personen", "/workspace/api/personen"],
["Gespräche", "/workspace/api/chat/raeume"],
]) {
const a = await roh(pfad, TREFF, { cookie: gast.keks });
let leer = a.code !== 200;
if (a.code === 200) {
try {
const j = JSON.parse(a.text);
const felder = Object.values(j).filter(Array.isArray);
leer = felder.every((f) => f.length === 0);
/* Die eigene Person in einer Personenliste ist kein Leck. */
if (!leer && pfad.endsWith("personen")) {
leer = felder.every((f) => f.every((p) => p.id === idGast));
}
} catch { leer = true; }
}
ok(leer, `${name}: nichts (${a.code})`);
}
/* =======================================================================
6. SCHREIBEN — und wo es ankommt
======================================================================= */
melde("");
melde("=== 6. Schreiben ===");
{
/* DIE ERSTE WOCHE IST EINE SANDKISTE. Wer eben erst dazugekommen
ist, liest und stimmt ab -- mehr nicht. Das wird von BEIDEN
Seiten der Grenze gemessen, sonst beweist es nichts: Eine
Schreibsperre, die immer sperrt, ist keine Stufe. */
const neu = await roh("/workspace/api/bereich/treff", TREFF, { cookie: gast.keks },
{ art: "hallo", titel: "GAST-ZU-FRUEH-0001", text: "Hallo zusammen.", datum: jetzt.slice(0, 10) });
ok(neu.code === 403, `wer neu ist, schreibt noch nicht (${neu.code})`);
const lage0 = JSON.parse((await roh("/workspace/api/treff/lage", TREFF, { cookie: gast.keks })).text);
ok(lage0.stufe === "neu", `und die Lage sagt es ihm auch: ${lage0.stufe}`);
ok(lage0.schreiben?.treff === false, "der Knopf waere aus");
/* Acht Tage zurueckdatieren -- genau EIN Tag ueber der Grenze. Eine
Grenze, die nur von weit weg geprueft wird, koennte um Tage
danebenliegen, ohne dass es auffaellt. */
const dd = new DatabaseSync(process.env.WORKSPACE_DB);
dd.prepare("UPDATE personen SET erstellt = ? WHERE id = ?")
.run(new Date(Date.now() - 8 * 86400000).toISOString(), idGast);
dd.close();
const a = await roh("/workspace/api/bereich/treff", TREFF, { cookie: gast.keks },
{ art: "hallo", titel: "GAST-SCHREIBT-8899", text: "Hallo zusammen.", datum: jetzt.slice(0, 10) });
ok(a.code === 200 || a.code === 201, `nach acht Tagen schon (${a.code})`);
/* UND SECHS TAGE REICHEN NICHT. Die andere Seite derselben Grenze --
ohne sie waere "acht Tage gehen" auch dann gruen, wenn die Stufe
gar nicht geprueft wird. */
const dd2 = new DatabaseSync(process.env.WORKSPACE_DB);
dd2.prepare("UPDATE personen SET erstellt = ? WHERE id = ?")
.run(new Date(Date.now() - 6 * 86400000).toISOString(), idGast);
dd2.close();
const knapp = await roh("/workspace/api/bereich/treff", TREFF, { cookie: gast.keks },
{ art: "hallo", titel: "GAST-ZU-FRUEH-0002", text: "Und?", datum: jetzt.slice(0, 10) });
ok(knapp.code === 403, `sechs Tage reichen nicht (${knapp.code})`);
const dd3 = new DatabaseSync(process.env.WORKSPACE_DB);
dd3.prepare("UPDATE personen SET erstellt = ? WHERE id = ?")
.run(new Date(Date.now() - 8 * 86400000).toISOString(), idGast);
dd3.close();
/* Und auf ein Brett des Teams kommt er auch als "Dabei" nicht. */
const brettDesTeams = await roh("/workspace/api/bereich/anschlag", TREFF, { cookie: gast.keks },
{ art: Object.keys(BEREICHE.anschlag.arten)[0], titel: "GAST-AM-ANSCHLAG-0001",
text: "Ansage", datum: jetzt.slice(0, 10) });
ok(brettDesTeams.code === 403, `aufs Anschlagbrett schreibt nur das Team (${brettDesTeams.code})`);
const beiDogi = await brett("treff", HAUS, kDogi);
ok(beiDogi.titel.includes("GAST-SCHREIBT-8899"), "DogFather sieht den Beitrag");
const beiModi = await brett("treff", CREW, kModi);
ok(beiModi.titel.includes("GAST-SCHREIBT-8899"), "der Modi auch");
const beiCrea = await brett("treff", HAUS, kCrea);
ok(!beiCrea.titel.includes("GAST-SCHREIBT-8899"), "die Creatorin nicht");
}
{
/* UND NICHT IN EIN BRETT DES HAUSES. */
const a = await roh("/workspace/api/bereich/ideen", TREFF, { cookie: gast.keks },
{ art: "content", titel: "GAST-IM-HAUS-0000", text: "", datum: jetzt.slice(0, 10) });
ok(a.code !== 200 && a.code !== 201, `aber nicht in ein Brett des Hauses (${a.code})`);
}
melde("");
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest, kein Befund */ }
process.exit(fehler === 0 ? 0 : 1);
+46 -3
View File
@@ -67,12 +67,26 @@
* Rolle nur in einer der beiden Listen steht. */
export const ALLE_ROLLEN = [
"spicy", "admin", "manager", "scout", "creator", "hand", "modi",
/* DIE COMMUNITY (11.09.2026). Zuschauer mit einem Zugang, die den
Treff sehen -- und sonst nichts. Sie steht hier NICHT in `ALLE`
(siehe unten): Wer neu dazukommt, bekommt genau die zwei Seiten,
die weiter unten ausdruecklich seinen Namen tragen. */
"gast",
];
/* Kurzform fuer die Tafel unten. `ALLE` heisst hier ausdruecklich
"alle Rollen, die es GIBT" -- nicht "alle, die es geben wird".
Genau darin liegt der Unterschied zum alten `null`. */
const ALLE = ALLE_ROLLEN;
/* ACHTUNG: `ALLE` ist NICHT ALLE_ROLLEN.
Es ist die Abkuerzung fuer "alle Rollen, die diese Seiten am
11.09.2026 schon hatten" -- die sieben des Hauses. Die Community
gehoert ausdruecklich nicht dazu; sie bekommt ihre Seiten einzeln.
Wuerde hier ALLE_ROLLEN stehen, haette jede kuenftige Rolle wieder
automatisch neun Seiten geerbt -- genau der Fehler, gegen den diese
Datei gebaut ist, nur mit einem anderen Namen. */
const ALLE = ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"];
/* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser --
sonst könnte man die Seite einfach direkt aufrufen. */
@@ -98,7 +112,9 @@ export const SEITEN = {
zwei Aenderungen in einer -- und man wuesste hinterher nicht,
welche der beiden etwas kaputtgemacht hat. Wer hier enger
stellen will, macht das als eigenen Schritt. */
"/workspace/start.html": [...ALLE],
/* Die Community sieht hier ihre sieben Kacheln und sonst nichts --
welche das sind, entscheidet bereicheFuer() in workspace.js. */
"/workspace/start.html": [...ALLE, "gast"],
"/workspace/uebersicht.html": [...ALLE],
/* AUSDRUECKLICH OHNE 'modi' (10.09.2026). Vorher stand hier `null`
("jede angemeldete Rolle") -- und mit der neuen Rolle hiess das
@@ -216,7 +232,11 @@ export const SEITEN = {
WELCHE Bereiche ein Modi oeffnen darf, entscheidet die Liste
seiner Kacheln (MODI_BEREICHE_ERLAUBT) -- sonst kaeme er ueber die
Adresszeile auch in die Agentur-Ablage. */
"/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"],
/* Die sieben Bretter des Treffs laufen ueber dieselbe Seite wie alle
anderen Bereiche. WELCHE ein Gast oeffnen darf, entscheidet die
Bereichsliste in workspace-bereiche.js -- diese Zeile oeffnet nur
die Tuer. Eine Seite ist ein Weg, keine Schranke. */
"/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator", "hand", "modi", "gast"],
"/workspace/report.html": ["spicy", "admin", "manager", "scout", "creator"],
"/workspace/scouting.html": ["spicy", "admin", "manager", "scout"],
/* DIE ARBEITSLAGE DES TEAMS (09.09.2026).
@@ -251,6 +271,26 @@ export const SEITEN = {
"Kann im Alltag vertretend Entscheidungen treffen." Genau das
passiert auf dieser Seite. */
"/workspace/teamlage.html": ["admin", "hand"],
/* DIE REGELN DES TREFFS (11.09.2026). Fuer die Community und fuer
die, die sie durchsetzen — ein Modi, der die Regeln nicht sehen
kann, moderiert nach Gefuehl.
AUSDRUECKLICH NICHT fuer Manager, Scouts, Creator und Spicy: Sie
haben mit dem Treff nichts zu tun, und die Seite nennt Fristen,
Stufen und die Bedingungen des Ausschlusses. */
"/workspace/treff-regeln.html": ["gast", "modi", "hand", "admin"],
/* MELDUNGEN & MASSNAHMEN. Nur wer moderiert -- ausdruecklich OHNE
"gast": Dort stehen die Namen derer, die gemeldet haben. Eine
Meldung, die der Gemeldete lesen kann, ist keine Meldung mehr,
sondern eine Einladung zur Vergeltung. */
"/workspace/treff-moderation.html": ["modi", "hand", "admin"],
/* WER SIEHT WAS. Nur DogFather — die Seite ist eine vollstaendige
Karte dieses Hauses: jede Seite, jede Rolle. Wer sie hat, weiss,
wo er es versuchen muesste. */
"/workspace/rechte.html": ["admin"],
};
/* Die Zugangswaende. Sie haben keine Rolle, weil sich dort noch
@@ -260,6 +300,9 @@ export const SEITEN = {
export const OHNE_ANMELDUNG = [
"/workspace/index.html",
"/workspace/crew-index.html",
/* Die Wand des Treffs (11.09.2026). Dritte Adresse, dritte Wand --
und die erste, vor der jemand steht, der NICHT zum Team gehoert. */
"/workspace/treff-index.html",
];
/* =====================================================================
+171
View File
@@ -0,0 +1,171 @@
/* =====================================================================
Die eigene Adresse des Treffs (11.09.2026)
treff.dogfather-universe.com
Auftrag (Filipe, 11.09.2026): *"eine community rolle und community
kategorie, wo die community auch dann nur die sieht."*
---------------------------------------------------------------------
WARUM EINE DRITTE ADRESSE UND NICHT EINE DRITTE KACHEL
Drei Gruende, und jeder allein wuerde schon reichen:
1. EINE APP JE URSPRUNG. Auf einem Ursprung laesst sich genau EINE
App installieren (w3c/manifest Nr. 1180: verschachtelte Bereiche
sind "strongly not recommended", Chrome haelt sich daran). Team
Dogi hat seine App, der Workspace hat seine. Ohne eigene Adresse
haette die Community gar keine.
2. EINE FALSCHE ANMELDUNG DARF NICHT REICHEN. Waere der Treff eine
Kachel auf der Wand des Workspace, traefe jeder Tippfehler in
der Rechteprueung sofort echte Zuschauer. So steht zwischen
einem Mitglied der Community und den Daten der Agentur nicht nur
eine Rollenabfrage, sondern eine Adresse, ein Zwischenschritt
und die Adressregel in sitzungLesen(). Drei Schloesser -- faellt
eines, halten zwei.
3. DIE COMMUNITY IST DER ERSTE KREIS VON AUSSEN. Bisher hatte jede
Rolle in diesem Haus einen Vertrag, eine Rechnung oder eine
Absprache. Ein Mitglied des Treffs hat nur einen Code. Was diese
Menschen sehen, ist damit die einzige Stelle, an der ein Fehler
wirklich nach draussen geht.
---------------------------------------------------------------------
WAS SICH NICHT AENDERT: der Pfad /workspace/, der Dienst (4100), die
Datenbank, der Code. Getrennt wird ueber den HOSTNAMEN, so wie bei
Workspace und Team Dogi auch.
---------------------------------------------------------------------
WAS DIESE DATEI AUSDRUECKLICH NICHT TUT: Rechte vergeben.
Wer auf welcher Adresse angemeldet sein darf, entscheidet
sitzungPasstZurAdresse() -- aufgerufen in sitzungLesen(), durch das
alle 26 Fachmodule gehen. Was eine Rolle sehen darf, entscheidet die
Rechtetafel in rechte.js. Zwei Orte fuer dieselbe Regel waeren einer
zu viel; der zweite waere der, den beim naechsten Umbau niemand
anfasst.
---------------------------------------------------------------------
DASS DER TREFF WIE TEAM DOGI AUSSIEHT, IST ENTSCHIEDEN
Er benutzt crew-haus.css, die Buehnen von Team Dogi und deren
App-Symbol. Gemessen gaebe es durchaus einen eigenen Farbton
(Tannengruen liegt 60 Lab-Einheiten vom Marineblau entfernt, also
klar unterscheidbar). Er wuerde den Treff aber zu einem DRITTEN Haus
machen, neben Spicy Media und Team Dogi. Er ist kein drittes Haus,
sondern ein zweiter Raum in demselben -- "lila und babyblau ueberall"
(Filipe, 10.09.2026).
Unterschieden werden die Tueren durch das WORT darauf: Die Buehne der
Zugangswand traegt "DER TREFF" statt "TEAM DOGI" (tools/crew-buehne.mjs,
zwei Werte, ein Werkzeug), und die App heisst "Der Treff".
===================================================================== */
import {
istTreffAdresse, istWorkspacePfad,
} from "./crew-adresse.js";
/**
* Express-Zwischenschritt fuer die Adresse des Treffs.
*
* Er laeuft NACH crewWeiche und tut dort dasselbe wie diese auf ihrer
* Adresse -- mit drei Unterschieden: eigenes Manifest, eigene Wand,
* und die Buehnen kommen von Team Dogi statt von der Agentur.
*
* DIE FREMDEN WAENDE SPERRT ER NICHT SELBST. Das macht bereits
* crewWeiche fuer jede Adresse ueber die Wandtafel (istFremdeWand).
* Eine zweite Sperre hier waere dieselbe Regel an zwei Orten.
*/
export function treffWeiche(req, res, next) {
if (!istTreffAdresse(req.get("host"))) return next();
/* NICHT INDIZIEREN. Der Kopf steht hier und nicht nur als <meta> im
HTML, weil er dann auch fuer Manifest, Symbole, Bilder und jede
Antwort ohne HTML gilt. Bei dieser Adresse wiegt das schwerer als
bei den anderen beiden: Sie wird an Dutzende Menschen
weitergegeben, die nichts unterschrieben haben. Ein Eintrag bei
einer Suchmaschine waere nicht rueckholbar. */
res.setHeader("X-Robots-Tag", "noindex, nofollow");
/* DAS MANIFEST MACHT DIE APP. Alle drei Adressen liefern dieselben
HTML-Dateien aus, in jeder steht dieselbe Manifest-Zeile. Hier
liegt dahinter ein anderes: Name "Der Treff". Sonst hiesse die App
auf dem Startbildschirm eines Mitglieds "Team Dogi" -- der Name
eines Bereichs, zu dem es nicht gehoert.
Ueber die Adresse und nicht ueber eine zweite Zeile in 30
HTML-Dateien: Eine zweite Zeile muesste jemand bei jeder neuen
Seite mitschreiben, und wer sie vergisst, bekommt eine Seite, die
still zur falschen App gehoert. */
if (req.path === "/workspace/app.webmanifest") {
req.url = "/workspace/treff.webmanifest";
return next();
}
/* DIE ZUGANGSWAND. Ohne diese Zeile stuende auf der Adresse der
Community die Wand der Agentur: Spicy-Media-Logo, "Creator
Workspace", fuenf Rollenkacheln mit den Namen Admin, Manager,
Scout, Creator -- die komplette Struktur des Unternehmens, vor der
Anmeldung, fuer jeden, der die Adresse hat.
ZWEI PFADE, EINE DATEI: "/workspace/" und "/workspace/index.html"
fuehren beide zur Wand, also muessen beide umgebogen werden. Den
zweiten zu vergessen faellt im Alltag nicht auf -- ihn tippt
niemand -- und waere trotzdem genau das Leck von oben. */
if (req.path === "/workspace/" || req.path === "/workspace/index.html") {
req.url = "/workspace/treff-index.html";
return next();
}
/* DIE BUEHNEN INNEN. Die neun Motive der Agentur zeigen nicht bloss
rotes Licht, sondern das Spicy-Media-Logo selbst -- in `halle` und
`arena` sogar vielfach nebeneinander. Die Fassungen von Team Dogi
liegen laengst da und sind genau dafuer gebaut worden.
UND ES FLACKERT NICHT: Das Bild wird beim ERSTEN Abruf schon als
das richtige geliefert. Eine Umschaltung per JavaScript haette
erst die Spicy-Buehne gezeigt und dann getauscht. */
const buehne = /^\/workspace\/assets\/img\/(buehne-[a-z]+-(?:uhd|breit|schmal)\.webp)$/
.exec(req.path);
if (buehne) {
req.url = `/workspace/assets/img/crew-${buehne[1]}`;
return next();
}
/* DAS ZEICHEN IN DER KOPFLEISTE. start.css setzt links neben den
Namen eine Chili -- als CSS-Hintergrund, nicht als Bild im HTML.
Ohne diese Zeile stuende oben links auf jeder Seite des Treffs das
Zeichen von Spicy Media. */
if (req.path === "/workspace/assets/img/marke-spicy.webp") {
req.url = "/workspace/assets/img/marke-husky.webp";
return next();
}
/* DAS HAUS. Jede Seite laedt als LETZTE Stilvorlage haus.css; auf der
Agenturadresse ist die leer, hier steht dahinter das Farbhaus von
Team Dogi.
Der Dateiname reist mit einer Fassungsnummer (`?v=…`); die
Bedingung sieht deshalb nur auf den PFAD, nicht auf die ganze
Adresse. Genau daran waere sie sonst gescheitert -- lautlos, denn
ein nicht umgebogener Pfad liefert weiterhin eine gueltige Datei. */
if (req.path === "/workspace/assets/css/haus.css") {
req.url = "/workspace/assets/css/crew-haus.css"
+ (req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "");
return next();
}
/* NUR DER WORKSPACE. Auf treff. gibt es die Hauptseite nicht -- sonst
laege die komplette Dogfather-Website ein zweites Mal unter einer
zweiten Adresse (doppelter Inhalt, und ein Weg, die Adresse ueber
eine Suchmaschine zu finden). Alles ausserhalb von /workspace und
/assets geht zur Zugangswand.
Caddy tut dasselbe bereits mit seiner eigenen Weiche. Dass es hier
ein zweites Mal steht, ist Absicht: Eine Schranke, die nur in einer
Datei ausserhalb des Projekts existiert, faellt beim naechsten
Serverumbau lautlos weg. */
if (istWorkspacePfad(req.path) || req.path.startsWith("/assets/")) return next();
return res.redirect(302, "/workspace/");
}
+154
View File
@@ -0,0 +1,154 @@
/* =====================================================================
Die Tabellen des Treffs und die eine Frage, die überall gestellt wird
(11.09.2026)
=====================================================================
DIESE DATEI IMPORTIERT NICHTS, und das ist ihr ganzer Zweck.
Zwei Module brauchen dasselbe: workspace.js muss beim Anmelden und
bei jeder Sitzung wissen, ob jemand gesperrt ist -- und
workspace-treff.js muss es auch. Würde workspace.js dafür aus
workspace-treff.js importieren, entstünde ein Kreis (treff importiert
db, protokolliere und die Bretter aus workspace). ES-Module kommen
damit zurecht, solange nur Funktionen über den Kreis laufen; sobald
jemand eine Konstante beim Laden benutzt, bekommt er `undefined` --
ohne Fehlermeldung, an einer Stelle, die mit dem Kreis nichts zu tun
hat. Ein Blatt ohne Importe kann diesen Fehler nicht haben.
Die Tabellen werden deshalb auch dort angelegt, wo alle anderen
angelegt werden: im Bauplan in workspace.js. Ein verspätetes
"CREATE TABLE IF NOT EXISTS" beim ersten Zugriff hätte bedeutet, dass
die Sperrfrage beim allerersten Aufruf gegen eine Tabelle läuft, die
es noch nicht gibt -- und eine Sperre, die bei einer Störung "darf"
sagt, ist genau die, die im Ernstfall aufgeht.
===================================================================== */
/** Legt die Tabellen des Treffs an. Wird aus dem Bauplan gerufen. */
export function treffTabellen(d) {
d.exec(`
/* „Will ich auch" — EINE Stimme je Person und Eintrag.
Der zusammengesetzte Primärschlüssel ist die ganze Regel: Ein
zweiter Klick kann gar nicht als zweite Stimme ankommen, auch
nicht bei zwei gleichzeitigen Anfragen. Eine Zählspalte am
Eintrag hätte dieselbe Zahl gehalten und dabei jede Stimme
doppelt genommen, sobald jemand zweimal tippt. */
CREATE TABLE IF NOT EXISTS treff_stimmen (
eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
zeitpunkt TEXT NOT NULL,
PRIMARY KEY (eintrag_id, person_id)
);
CREATE INDEX IF NOT EXISTS idx_treff_stimmen_person ON treff_stimmen (person_id);
/* Angeheftet am Anschlagbrett. Höchstens drei — die Zahl steht im
Code (ANSCHLAG_MAX), nicht hier: Eine CHECK-Regel kann nicht über
Zeilen hinweg zählen, und eine, die es könnte, müsste man beim
Ändern der Zahl umbauen (siehe checkListeErweitern). */
CREATE TABLE IF NOT EXISTS treff_angeheftet (
eintrag_id INTEGER PRIMARY KEY REFERENCES eintraege(id) ON DELETE CASCADE,
seit TEXT NOT NULL,
von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
/* Freigaben. Ein Eintrag auf einem Freigabe-Brett ist für die
Community erst da, wenn hier eine Zeile steht.
ABWESENHEIT IST DER RUHEZUSTAND, und das ist der Kern: Eine
Spalte "freigegeben INTEGER DEFAULT 0" hätte dasselbe getan --
bis jemand einen Eintrag auf einem anderen Weg anlegt und die
Vorgabe vergisst. Eine fehlende Zeile kann man nicht vergessen. */
CREATE TABLE IF NOT EXISTS treff_freigaben (
eintrag_id INTEGER PRIMARY KEY REFERENCES eintraege(id) ON DELETE CASCADE,
zeitpunkt TEXT NOT NULL,
von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
/* Meldungen (DSA Art. 16). Der Grund ist NOT NULL -- eine Meldung
ohne Grund ist keine Meldung, sondern ein Klick. */
CREATE TABLE IF NOT EXISTS treff_meldungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
melder_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
grund TEXT NOT NULL,
zeitpunkt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','erledigt','abgelehnt')),
entscheid TEXT,
entschieden_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
entschieden_am TEXT
);
CREATE INDEX IF NOT EXISTS idx_treff_meldungen_status
ON treff_meldungen (status, zeitpunkt);
/* Was entfernt wurde, und warum (DSA Art. 17).
DIESE TABELLE HÄNGT ABSICHTLICH NICHT AN eintraege. Der Eintrag
ist weg; die Begründung muss bleiben. Ein Fremdschlüssel mit
ON DELETE CASCADE hätte genau das Gegenteil bewirkt -- die
Begründung wäre im selben Moment verschwunden wie das, was sie
begründet. Deshalb stehen Titel und Verfasser hier als Text bzw.
als lose Nummer noch einmal. */
CREATE TABLE IF NOT EXISTS treff_entfernt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
eintrag_id INTEGER NOT NULL,
bereich TEXT NOT NULL,
titel TEXT NOT NULL,
autor_id INTEGER,
autor_name TEXT,
grund TEXT NOT NULL,
von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
zeitpunkt TEXT NOT NULL
);
/* Maßnahmen: Hinweis, Pause, Ausschluss.
"bis" = NULL heißt DAUERHAFT. Wer das darf, steht in
darfMassnahme() -- und es ist bewusst nicht dieselbe Menge wie
für die befristete Fassung (Filipe, 11.09.2026: "Modis dürfen
befristet ausschließen, dauerhaft nur ihr"). */
CREATE TABLE IF NOT EXISTS treff_massnahmen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
art TEXT NOT NULL CHECK (art IN ('hinweis','pause','ausschluss')),
grund TEXT NOT NULL,
bis TEXT,
von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
zeitpunkt TEXT NOT NULL,
aufgehoben_am TEXT,
aufgehoben_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_treff_massnahmen_person
ON treff_massnahmen (person_id, art);
`);
}
/**
* Die wirksame Maßnahme gegen diese Person, oder null.
*
* NIMMT DIE DATENBANK ALS ARGUMENT, statt sie sich zu holen: So kann
* sie aus workspace.js gerufen werden, ohne dass diese Datei etwas von
* workspace.js wissen muss. Und in einer Prüfung kann man ihr eine
* Kopie geben.
*
* DREI AUSGÄNGE, NICHT ZWEI: `null` heißt "nichts liegt vor". Kann die
* Frage nicht beantwortet werden (Tabelle fehlt, Datenbank weg), wirft
* die Funktion -- sie gibt NICHT `null` zurück.
*/
export function treffSperreLesen(d, personId) {
const zeilen = d.prepare(`
SELECT id, art, grund, bis, zeitpunkt FROM treff_massnahmen
WHERE person_id = ? AND art IN ('pause','ausschluss')
AND aufgehoben_am IS NULL
ORDER BY CASE art WHEN 'ausschluss' THEN 0 ELSE 1 END, id DESC`).all(personId);
const nun = Date.now();
for (const z of zeilen) {
/* bis = NULL heißt dauerhaft. Eine abgelaufene Frist läuft ab,
ohne dass jemand etwas tun muss -- eine Sperre, die man von Hand
aufheben muss, bleibt sonst Jahre stehen. */
if (!z.bis) return z;
const ende = Date.parse(z.bis);
if (Number.isFinite(ende) && ende > nun) return z;
}
return null;
}
+353 -13
View File
@@ -19,14 +19,178 @@ import {
db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, siehtAlles, istSpicy, ohneDogFather,
externPruefen, externSql,
ohneTeamDogi, ohneAgentur, siehtModis, fuehrtTeamDogi, MODI_BEREICHE_ERLAUBT, TEAM_DOGI_ROLLEN,
TREFF_BRETTER, TREFF_ROLLEN, AUSSEN_ROLLEN,
} from "./workspace.js";
import { nachrichtSchicken } from "./workspace-chat.js";
import {
darfSchreiben, freigabeBedingung, entfernenVorbereiten, TREFF_TEAM_ROLLEN,
mitRollenname,
} from "./workspace-treff.js";
export const bereicheRouter = express.Router();
/* Die Bereiche und ihre Arten. Bewusst hier und nicht in der Datenbank:
Es sind Festlegungen aus dem Konzept, keine Nutzdaten. */
export const BEREICHE = {
/* =====================================================================
DIE SIEBEN BRETTER DES TREFFS (11.09.2026)
Der Raum fuer die Zuschauer. Sie laufen ueber dieselbe Maschinerie
wie Ideen-Board und Angebote -- ein Bereich, seine Arten, dieselbe
Seite. Eine eigene Bauart daneben waere eine zweite
Sichtbarkeitsregel, und genau deren Vervielfaeltigung hat am
10.09. ein Leck verursacht.
ALLE SIEBEN HABEN `fuerAlle`: Im Treff sieht jeder jeden Beitrag.
Ein Raum, in dem man nur sieht, was man selbst geschrieben hat,
waere kein Raum. Die Grenze nach aussen zieht nicht `fuerAlle`,
sondern der Riegel in sichtbarEintrag() -- die Agentur sieht kein
einziges dieser Bretter.
ALLE SIEBEN HABEN `ohneCreatorBezug`: Hier geht es um niemanden aus
der Agentur. Das Feld waere nicht nur ueberfluessig, es waere die
Einladung, versehentlich einen Creator mit einem Zuschauer zu
verknuepfen.
===================================================================== */
anschlag: {
name: "Anschlagbrett",
ober: "Vom Team",
arten: {
ansage: "Ansage",
regel: "Neue Regel",
hinweis: "Hinweis",
danke: "Danke an euch",
},
bewertung: false,
dringlichkeit: false,
ohneCreatorBezug: true,
fuerAlle: true,
},
ansteht: {
name: "Was ansteht",
ober: "Termine",
/* AUSDRUECKLICH NICHT AN DEN ECHTEN KALENDER GEKOPPELT.
Ein Termin traegt Titel, Ort und Gegenueber -- und die Regel im
Haus lautet seit dem 07.09., dass ein Kalender als Ganzes privat
ist. Eine Filterung waere eine Einstellung, die man falsch setzen
kann; ein Eintrag hier ist eine Entscheidung, die jemand trifft.
Der Preis ist, dass das Team es zweimal schreibt. Das ist der
guenstigere von beiden. */
arten: {
stream: "Stream",
event: "Event",
turnier: "Turnier",
pause: "Keine Sendung",
},
bewertung: false,
dringlichkeit: false,
ohneCreatorBezug: true,
fuerAlle: true,
},
treff: {
name: "Der Treff",
ober: "Euer Raum",
/* DIE REIHENFOLGE IST ABSICHT. Das erste Feld bestimmt, was
geschrieben wird -- wer ein Formular oeffnet, dessen erste
Auswahl "Problem" heisst, schreibt Probleme auf.
"Hallo, ich bin neu" steht vorn, weil die ersten sieben Tage
darueber entscheiden, ob jemand bleibt: Wer in dieser Zeit
erlebt, dass er hier einen Platz hat, kommt deutlich haeufiger
wieder. Jede Vorstellung bekommt eine Antwort -- das ist der
ganze Trick, und er steht in keinem Code. */
arten: {
hallo: "Hallo, ich bin neu",
frage: "Frage",
lob: "Das war gut",
problem: "Etwas stimmt nicht",
},
bewertung: false,
dringlichkeit: false,
ohneCreatorBezug: true,
fuerAlle: true,
},
wunsch: {
name: "Wunschliste",
ober: "Was ihr euch wuenscht",
arten: {
stream: "Fuer den Stream",
technik: "Technik",
gemeinsam: "Fuer die Community",
sonstiges: "Sonstiges",
},
bewertung: false,
/* KEINE DRINGLICHKEIT VON EINEM EINZELNEN. Was wichtig ist,
entscheidet hier nicht der Schreiber, sondern wie viele
mitwollen -- "Will ich auch" kommt als naechster Schritt. Eine
Dringlichkeit daneben waere eine zweite Rangordnung, und die
beiden wuerden einander widersprechen. */
dringlichkeit: false,
ohneCreatorBezug: true,
fuerAlle: true,
},
highlight: {
name: "Highlights",
ober: "Von euch",
arten: {
clip: "Clip",
bild: "Bild",
fanart: "Fanart",
moment: "Moment",
},
bewertung: false,
dringlichkeit: false,
ohneCreatorBezug: true,
fuerAlle: true,
},
regeln: {
name: "Regeln & Hilfe",
ober: "Wie es hier laeuft",
/* REGELN IN EINFACHER SPRACHE UND AN ECHTEN FAELLEN. Vage Regeln
erzeugen Grenzfaelle, und die muss dann jeder Modi einzeln
entscheiden -- das ist der Weg, auf dem ein Team uneinheitlich
wird und die Community es merkt. */
arten: {
regel: "Regel",
frage: "Haeufige Frage",
hilfe: "Hilfe",
folge: "Was passiert, wenn",
},
bewertung: false,
dringlichkeit: false,
ohneCreatorBezug: true,
fuerAlle: true,
},
mitmachen: {
name: "Mitmachen",
ober: "Nachwuchs",
/* DIE BRUECKE ZU DEN TALENTEN. Wer sich hier meldet, ist ein
Kandidat -- und Kandidaten gehoeren in die Kategorie, die es
dafuer schon gibt, nicht in eine zweite Liste daneben.
Daneben gehoert der ehrliche Satz, dass eine Bewerbung nicht der
Hauptweg ist: Der treffsicherste Weg ist, dass jemandem aus dem
Team auffaellt, wie du dich verhaeltst. Das steht so im Brett,
weil es stimmt -- und weil es die richtige Erwartung setzt. */
arten: {
gesucht: "Wir suchen",
lust: "Ich haette Lust",
frage: "Frage dazu",
},
bewertung: false,
dringlichkeit: false,
ohneCreatorBezug: true,
fuerAlle: true,
},
live: {
name: "LIVE-Analyse",
arten: { vorbereitung: "Vorbereitung", mitschrift: "Während LIVE", auswertung: "Auswertung" },
@@ -483,9 +647,30 @@ bereicheRouter.use("/workspace/api/bereich/:bereich", (req, res, next) => {
sie ueber die Adresszeile in die Agentur-Ablage, die sie nichts
angeht. Genau die Luecke, gegen die diese Schranke gebaut wurde,
nur eine Rolle spaeter. */
const b = String(req.params.bereich);
const nein = () => res.status(404).json({ fehler: "nicht_gefunden" });
/* EINE AUSSENROLLE KOMMT NUR IN DEN TREFF (11.09.2026).
Und zwar als ERSTER Zweig, nicht als letzter. Die Zeile darunter
hiess bis heute "wer nicht zum Team gehoert, darf weiter" -- fuer
einen Gast waere das ein Freifahrtschein durch alle achtzehn
Bereiche gewesen, obwohl die Kacheln ihm nur sieben anbieten. Eine
Regel, die nur im Formular gilt, ist keine Regel. */
if (AUSSEN_ROLLEN.has(req.person?.rolle)) {
return TREFF_BRETTER.includes(b) ? next() : nein();
}
/* UND UMGEKEHRT: Wer nicht zum Treff gehoert, kommt nicht hinein.
Das ist derselbe Riegel wie in sichtbarEintrag(), nur eine Ebene
frueher -- dort entscheidet er, welche ZEILEN jemand sieht, hier,
ob die Seite ueberhaupt antwortet. Zwei Schloesser, absichtlich:
Faellt eines weg, haelt das andere. */
if (TREFF_BRETTER.includes(b) && !TREFF_ROLLEN.has(req.person?.rolle)) return nein();
if (!TEAM_DOGI_ROLLEN.has(req.person?.rolle)) return next();
if (MODI_BEREICHE_ERLAUBT.has(String(req.params.bereich))) return next();
return res.status(404).json({ fehler: "nicht_gefunden" });
if (MODI_BEREICHE_ERLAUBT.has(b)) return next();
return nein();
});
/* ---------- Die Bereiche sind fuer Creator zum LESEN da ----------------
@@ -713,7 +898,65 @@ function ohneVertrauliche(regel, person, praefix) {
};
}
/* =====================================================================
DER TREFF HAT ZWEI RIEGEL, UND SIE ZEIGEN IN ENTGEGENGESETZTE
RICHTUNGEN (11.09.2026)
Filipe: "wo die community auch dan nur die sieht und die modis
rechte hand und ich also dogfather sehen die aber auch. die
community aber nur die dann."
Daraus folgen zwei Saetze, und beide muessen gelten:
1. EINE AUSSENROLLE SIEHT NUR DEN TREFF. Nicht "den Treff und was
sonst noch durchfaellt" -- nur ihn. Deshalb steht dieser Zweig
GANZ VORN und baut seine Regel selbst, statt auf der
allgemeinen aufzusetzen. Wer auf einer bestehenden Regel
aufsetzt, erbt jeden Zweig, den sie hat, auch die, die es
morgen erst gibt.
2. DIE AGENTUR SIEHT DEN TREFF NICHT. Creator, Scouts, Manager und
Spicy Media haben mit den Zuschauern dieses Streams nichts zu
tun. Ohne diesen zweiten Riegel haette `fuerAlle` sie
hereingelassen -- genau das Loch, das am 10.09. schon einmal
entstanden ist, nur andersherum.
BEIDE STEHEN HIER UND NICHT IN sichtbar(). Diese Funktion beantwortet
auch Fragen zu Aufgaben, Dateien und Terminen; ein Bereichsname hat
dort nichts zu suchen. Der Kommentar eine Bildschirmseite weiter oben
warnt woertlich davor. */
export function sichtbarEintrag(person, praefix = "e") {
/* RIEGEL 1 -- die Aussenrolle. Eine eigene, vollstaendige Regel:
genau die Bretter des Treffs, sonst nichts. */
if (AUSSEN_ROLLEN.has(person?.rolle)) {
const platz = TREFF_BRETTER.map(() => "?").join(", ");
const grund = { wo: `${praefix}.bereich IN (${platz})`, werte: [...TREFF_BRETTER] };
/* RIEGEL 1b -- WAS NOCH NICHT FREIGEGEBEN IST, GIBT ES NICHT
(11.09.2026). Termine und Highlights erscheinen der Community
erst, wenn jemand aus dem Team sie freigegeben hat.
DIE BEDINGUNG HAENGT HIER UND NICHT AM LESE-WEG, weil sie sonst
nur dort gaelte: Suche, Ausfuhr und jeder kuenftige Weg, der
sichtbarEintrag benutzt, haetten die unfreigegebenen Zeilen
weiterhin geliefert. Genau so ist am 10.09. ein Modi-Eintrag
durch die Suche gekommen, obwohl das Brett zu war. */
const frei = freigabeBedingung(person, praefix);
if (!frei) return grund;
return { wo: `(${grund.wo}) AND ${frei.wo}`, werte: [...grund.werte, ...frei.werte] };
}
const regel = sichtbarRoh2(person, praefix);
/* RIEGEL 2 -- wer nicht zum Treff gehoert, sieht seine Bretter nicht.
Auch DogFather geht hier durch; er steht in TREFF_ROLLEN und faellt
deshalb nicht heraus. */
if (!regel || TREFF_ROLLEN.has(person?.rolle)) return regel;
const platz = TREFF_BRETTER.map(() => "?").join(", ");
return {
wo: `(${regel.wo}) AND ${praefix}.bereich NOT IN (${platz})`,
werte: [...regel.werte, ...TREFF_BRETTER],
};
}
function sichtbarRoh2(person, praefix = "e") {
const regel = sichtbar(person);
if (!regel) return regel;
if (regel.wo === "1=1") return regel; // DogFather sieht ohnehin alles
@@ -773,6 +1016,34 @@ function saeulePasst(saeuleId, creatorId) {
return null;
}
/* =====================================================================
WAS EIN TREFF-BRETT ZUSAETZLICH MITBRINGT (11.09.2026)
Drei Angaben, die es nur dort gibt: wie viele "Will ich auch" ein
Wunsch hat, ob meine Stimme dabei ist, ob die Ansage angeheftet und
ob der Eintrag freigegeben ist.
SIE HAENGEN NICHT AN SPALTEN, sondern werden nur fuer die sieben
Bretter angehaengt. Drei Unterabfragen je Zeile sind hier
bedeutungslos (die Tabellen haben eine Handvoll Zeilen), auf den
Betreuungsbrettern waeren sie es nicht -- und vor allem waeren sie
dort eine Auskunft ueber etwas, das es in diesem Bereich gar nicht
gibt.
DER ROLLENNAME KOMMT MIT (Entscheidung Filipe, 11.09.2026: "voller
Rollenname zeigen"). Er kommt als ROLLE, der Klartext wird erst in
der Antwort gebildet -- eine Zuordnung Rolle -> Name im
ausgelieferten JavaScript waere eine Liste aller Rollen dieses
Hauses auf jedem Rechner der Community.
===================================================================== */
const TREFF_SPALTEN = `,
(SELECT COUNT(*) FROM treff_stimmen ts WHERE ts.eintrag_id = e.id) AS stimmen,
(SELECT COUNT(*) FROM treff_stimmen ts
WHERE ts.eintrag_id = e.id AND ts.person_id = ?) AS meine_stimme,
(SELECT COUNT(*) FROM treff_angeheftet ta WHERE ta.eintrag_id = e.id) AS angeheftet,
(SELECT COUNT(*) FROM treff_freigaben tf WHERE tf.eintrag_id = e.id) AS freigegeben,
pe.rolle AS erstellt_rolle`;
const VERBUND = `
FROM eintraege e
LEFT JOIN personen pc ON pc.id = e.creator_id
@@ -789,15 +1060,43 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
const regel = sichtbarEintrag(req.sicht || req.person);
if (!regel) return res.status(404).json({ fehler: "nicht_gefunden" });
const eintraege = db().prepare(`
SELECT ${SPALTEN} ${VERBUND}
WHERE ${regel.wo} AND e.bereich = ?
ORDER BY
CASE e.status WHEN 'offen' THEN 0 ELSE 1 END,
CASE e.dringlichkeit WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END,
e.datum DESC, e.id DESC`).all(...regel.werte, bereich);
const treffBrett = TREFF_BRETTER.includes(bereich);
res.json({ bereich, einstellung, eintraege });
/* DIE REIHENFOLGE GEHOERT ZUM BRETT (11.09.2026).
Am Anschlagbrett steht oben, was angeheftet ist -- sonst waere
das Anheften eine Zierde. Auf der Wunschliste steht oben, was die
meisten wollen; genau das ist der Sinn von "Will ich auch", und
eine Liste nach Datum haette die Abstimmung unsichtbar gemacht.
Ueberall sonst bleibt es, wie es war. */
const reihenfolge = bereich === "anschlag"
? "angeheftet DESC, e.datum DESC, e.id DESC"
: bereich === "wunsch"
? "stimmen DESC, e.datum DESC, e.id DESC"
: `CASE e.status WHEN 'offen' THEN 0 ELSE 1 END,
CASE e.dringlichkeit WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END,
e.datum DESC, e.id DESC`;
const wer = (req.sicht || req.person)?.id ?? 0;
const eintraege = db().prepare(`
SELECT ${SPALTEN}${treffBrett ? TREFF_SPALTEN : ""} ${VERBUND}
WHERE ${regel.wo} AND e.bereich = ?
ORDER BY ${reihenfolge}`)
.all(...(treffBrett ? [wer] : []), ...regel.werte, bereich);
/* DASS DIES EIN BRETT DES TREFFS IST, SAGT DER SERVER (11.09.2026).
Die Oberflaeche braucht die Auskunft, um ihre Knoepfe zu bauen
("Will ich auch", anheften, freigeben, melden). Sie koennte den
Bereichsnamen gegen eine Liste im Browser halten -- dann stuenden
die Namen aller sieben Bretter in einer ausgelieferten Datei, und
es gaebe eine zweite Wahrheit darueber, welche es sind. So gibt
es weiterhin genau eine (TREFF_BRETTER), und der Browser fragt
sie, statt sie zu kennen. */
res.json({
bereich, einstellung, treff: treffBrett,
eintraege: treffBrett ? eintraege.map(mitRollenname) : eintraege,
});
} catch (fehler) {
console.error("[workspace] Bereich lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
@@ -1062,6 +1361,20 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re
const regel = sichtbar(req.person);
if (!regel) return res.status(404).json({ fehler: "nicht_gefunden" });
/* DIE SCHREIBTAFEL DES TREFFS (11.09.2026).
Auf welches der sieben Bretter jemand von aussen schreiben darf,
steht in TREFF_SCHREIBEN -- an einer Stelle, aus der auch die
Oberflaeche ihre Knoepfe holt (/api/treff/lage). Waeren es zwei
Stellen, koennte ein Knopf erscheinen, den der Server ablehnt --
oder, schlimmer, umgekehrt.
DER TEXT WIRD DURCHGEREICHT, nicht durch "nicht_erlaubt" ersetzt:
"Ab Dabei kannst du hier schreiben" beantwortet die Frage, die
der Mensch davor gerade hat. */
const warum = darfSchreiben(req.person, bereich);
if (warum) return res.status(403).json({ fehler: warum });
const { aus, fehler } = pruefe(bereich, req.body || {}, { neu: true });
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
@@ -1226,9 +1539,36 @@ bereicheRouter.delete("/workspace/api/bereich/:bereich/:id", gleicheHerkunft, (r
/* Löschen darf das Management und wer den Eintrag selbst geschrieben
hat -- sonst könnte ein Creator eine Notiz des Managements über
seinen eigenen Bereich verschwinden lassen. */
if (!istLeitung(req.person) && eintrag.erstellt_von !== req.person.id) {
return res.status(403).json({ fehler: "nicht_erlaubt" });
}
/* IM TREFF RAEUMT AUCH EIN MODI AUF (11.09.2026). Er ist dort die
Moderation; waere Aufraeumen der Leitung vorbehalten, bliebe ein
Beitrag stehen, bis jemand Zeit hat -- und genau in dieser Zeit
liest ihn die Community. */
const treffBrett = TREFF_BRETTER.includes(bereich);
const darfWeg = treffBrett
? TREFF_TEAM_ROLLEN.has(req.person.rolle) || eintrag.erstellt_von === req.person.id
: istLeitung(req.person) || eintrag.erstellt_von === req.person.id;
if (!darfWeg) return res.status(403).json({ fehler: "nicht_erlaubt" });
/* DER GRUND WIRD FESTGEHALTEN, BEVOR DER BEITRAG VERSCHWINDET
(DSA Art. 17). Danach waere es zu spaet: Titel und Verfasser
stuenden nirgends mehr, und eine Begruendung ohne den Beitrag,
den sie begruendet, ist keine.
DER GRUND KOMMT AUS DER ADRESSE, und das ist keine Vorliebe,
sondern gemessen (11.09.2026): Nodes HTTP-Parser weist ein
DELETE MIT KOERPER mit einem leeren 400 ab, bevor Express die
Anfrage ueberhaupt sieht -- eine Antwort mit genau einem
Kopffeld ("connection: close") und ohne Text. Wer den Fehler
sucht, sucht ihn in dieser Route, und hier ist keiner.
Der Koerper bleibt als zweite Moeglichkeit stehen: Er schadet
nicht, und wenn ein anderer Weg (ein Werkzeug, eine kuenftige
Fassung von Node) doch einen mitschickt, wird er gelesen. Zwei
Quellen fuer denselben Wert sind hier keine zwei Wahrheiten --
es ist derselbe Wert, nur zwei Briefkaesten. */
const nein = entfernenVorbereiten(req.person, eintrag,
req.body?.grund ?? req.query?.grund);
if (nein) return res.status(400).json({ fehler: nein });
db().prepare("DELETE FROM eintraege WHERE id = ?").run(id);
protokolliere("eintrag_geloescht", {
+27 -2
View File
@@ -265,7 +265,31 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
steht, wurde bis eben nicht gezeichnet. VanVan verschwand
genau in dem Moment aus der Liste, in dem sie die rechte Hand
wurde -- ohne Fehler, ohne Luecke, ohne Hinweis. */
zusatzrollen: siehtModis(req.person)
/* DIE COMMUNITY STEHT NICHT IN DERSELBEN BEDINGUNG (11.09.2026).
Die beiden Eintraege unten haengen an `siehtModis` -- wer die
Modis sieht, darf sie auch anlegen. Fuer die Community gilt das
NICHT: Einen Zugang zum Treff vergibt allein DogFather (Plan
Teil C, Zeile "Zugang anlegen"). Ein Modi, der einen anlegen
koennte, koennte sich selbst Verstaerkung holen.
GEFRAGT WIRD `darfAnlegen` UND NICHT DIE ROLLE. Dieselbe
Auskunft, aus der auch die Route ihre Absage holt -- deshalb
kann hier kein Knopf erscheinen, den der Server ablehnt, und
keiner fehlen, den er annehmen wuerde. Auf der Team-Adresse
faellt er dadurch von selbst weg (darfAnlegen filtert dort auf
die Team-Rollen), und genau das ist richtig: Wer dort jemanden
anlegt, meint jemanden aus dem Team. */
zusatzrollen: [
...(darfAnlegen(req.person).includes("gast")
? [{
wert: "gast", name: "Community", symbol: "leute",
text: "Zugang zum Treff. Sieht die sieben Bretter der Community "
+ "und sonst nichts \u2013 keine Aufgaben, keine Termine, "
+ "keine Namen aus dem Team.",
}]
: []),
...(siehtModis(req.person)
? [{
wert: "modi", name: "Modi",
/* DIE PFOTE (Filipe, 10.09.2026 mit Bildschirmfoto: "bei
@@ -310,7 +334,8 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
+ "dieselben Aufgaben, Ideen und Angebote wie du – aber keine "
+ "privaten Kalender und keine Einzelchats.",
}]
: [],
: []),
],
});
} catch (fehler) {
console.error("[workspace] Personen lesen:", fehler?.message);
+951
View File
@@ -0,0 +1,951 @@
/* =====================================================================
DER TREFF — was die Community darf, und wer darüber wacht (11.09.2026)
=====================================================================
Auftrag (Filipe, wörtlich):
*"eine community rolle und community kategorie, wo die community
auch dann nur die sieht."*
*"wieso nur eine kachel für die community ich will dass die mehrere
kacheln haben. mit mehreren optionen."*
Die sieben Bretter stehen in workspace.js (TREFF_BEREICHE), ihre
Sichtbarkeit in workspace-bereiche.js. Hier steht, was darauf
PASSIERT: Stimmen, Anheften, Freigeben, Melden, Entfernen, Stufen,
Maßnahmen und die Brücke zu den Talenten.
---------------------------------------------------------------------
WARUM EIN EIGENES MODUL UND KEIN ANHANG AN workspace-bereiche.js
Weil die Community die einzige Rolle in diesem Haus ist, deren
Mitglieder nichts unterschrieben haben. Für alle anderen gilt: Ein
Fehler in der Rechteprüfung trifft jemanden, der ohnehin fast alles
sehen darf. Hier trifft er Fremde. Diese Regeln gehören deshalb an
eine Stelle, an der man sie am Stück lesen kann — nicht verteilt auf
vier Handler eines 1500 Zeilen langen Moduls.
---------------------------------------------------------------------
DIE DREI STUFEN WERDEN GERECHNET, NICHT GESPEICHERT
Neu → ab dem ersten Tag
Dabei → ab 7 Tagen
Stamm → ab 30 Tagen UND 3 eigenen Beiträgen
Eine gespeicherte Stufe wäre ein zweiter Ort für eine Tatsache, die
ohnehin in den Daten steht — und sie würde altern: Wer am Tag 6
eingetragen wird, bliebe „Neu", bis irgendein Vorgang sie nachzieht.
Ein Vorgang, den es nicht gibt, ist kein Vorgang.
WOZU DIE STUFEN ÜBERHAUPT: Die erste Woche ist eine Sandkiste. Wer
neu ist, darf lesen und „Will ich auch" klicken — mehr nicht. Das ist
kein Misstrauen gegen Menschen, sondern gegen Wegwerf-Zugänge: Ein
Zugang, der sieben Tage warten muss, bevor er Schaden anrichten kann,
lohnt sich für niemanden, der Schaden anrichten will.
---------------------------------------------------------------------
DAS RECHT ENTSCHEIDET HIER MIT, UND ZWAR DIE BAUWEISE
* DSA Art. 16 — MELDEN MUSS GEHEN, und die Meldung muss so genau
sein, dass man ohne Rückfrage entscheiden kann. Deshalb ist der
Grund PFLICHT und nicht ein Auswahlfeld mit „Sonstiges".
* DSA Art. 17 — WER ETWAS ENTFERNT, BEGRÜNDET ES. Die Begründung
überlebt den entfernten Beitrag: Sie steht in treff_entfernt, nicht
an der gelöschten Zeile. Genau das ist der Sinn — eine Begründung,
die mit dem Beitrag verschwindet, ist keine.
* DSA Art. 19 — Kleinstunternehmen sind von den Art. 20–28 befreit.
Die Pflichten aus Art. 16/17 gelten trotzdem, und sie sind ohnehin
das, was man aus Anstand täte.
* DSGVO Art. 8 — in Luxemburg liegt die Altersgrenze für die
Einwilligung bei 16. Der Treff setzt trotzdem **18**
(Entscheidung Filipe, 11.09.2026): Rund um einen Stream für
Erwachsene ist die Frage nicht, was erlaubt ist, sondern wen man
hereinlässt.
---------------------------------------------------------------------
WAS HIER ABSICHTLICH NICHT STEHT
KEINE DIREKTNACHRICHTEN zwischen Mitgliedern. Das ist die wichtigste
Einzelentscheidung des ganzen Bereichs. Ein öffentlicher Raum ist
moderierbar — ein privater ist es nicht. Jede Belästigung, die im
Treff passiert, passiert vor Zeugen und kann gemeldet werden. Wäre
sie in einem Postfach passiert, wüsste niemand davon, und der Treff
wäre der Ort, an dem die Adressen dafür verteilt werden.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, protokolliere, echteIp,
TREFF_BRETTER, TREFF_ROLLEN, ROLLEN_NAME,
} from "./workspace.js";
/* Die Tabellen und die Sperrfrage stehen in einem Blatt ohne eigene
Importe -- workspace.js braucht beides ebenfalls, und ein Importkreis
waere die Folge. Begruendung im Kopf von treff-tabellen.js. */
import { treffSperreLesen } from "./treff-tabellen.js";
export const treffRouter = express.Router();
const jetzt = () => new Date().toISOString();
const TAG = 86400000;
/* ---------------------------------------------------------------------
WER IST TEAM, WER IST AUSSEN
Beide Mengen stehen hier ausgeschrieben und nicht als „alles außer
gast". Dieselbe Überlegung wie in crew-adresse.js: Eine Bedingung
durch Ausschluss nimmt jede künftige Rolle automatisch mit auf — und
die nächste neue Rolle wäre damit stillschweigend Moderation im
Treff. Genau dieser Fehler steckte am 11.09. schon einmal im stillen
Zugang.
------------------------------------------------------------------- */
export const TREFF_TEAM_ROLLEN = new Set(["modi", "hand", "admin"]);
const TREFF_TEAM = TREFF_TEAM_ROLLEN;
const TREFF_AUSSEN = new Set(["gast"]);
/** Darf diese Person überhaupt etwas im Treff tun? */
const imTreff = (p) => TREFF_ROLLEN.has(p?.rolle);
/* =====================================================================
DIE STUFEN
===================================================================== */
export const STUFEN = ["neu", "dabei", "stamm"];
const STUFEN_NAME = { neu: "Neu", dabei: "Dabei", stamm: "Stamm" };
/** Ab wann die nächste Stufe gilt. Beide Grenzen an einer Stelle. */
const DABEI_TAGE = 7;
const STAMM_TAGE = 30;
const STAMM_BEITRAEGE = 3;
/**
* Welche Stufe hat diese Person?
*
* Team Dogi hat keine Stufe — die Frage ergibt dort keinen Sinn, und
* eine erfundene Antwort („stamm") wäre eine, auf die sich später
* jemand verlässt. Deshalb `null`.
*/
export function stufeVon(person, dieDb = db) {
if (!person || !TREFF_AUSSEN.has(person.rolle)) return null;
const zeile = dieDb().prepare("SELECT erstellt FROM personen WHERE id = ?")
.get(person.id);
if (!zeile?.erstellt) return "neu";
const tage = (Date.now() - Date.parse(zeile.erstellt)) / TAG;
if (!Number.isFinite(tage) || tage < DABEI_TAGE) return "neu";
if (tage < STAMM_TAGE) return "dabei";
const platz = TREFF_BRETTER.map(() => "?").join(", ");
const { n } = dieDb().prepare(
`SELECT COUNT(*) AS n FROM eintraege
WHERE erstellt_von = ? AND bereich IN (${platz})`).get(person.id, ...TREFF_BRETTER);
return n >= STAMM_BEITRAEGE ? "stamm" : "dabei";
}
const stufeReicht = (hat, noetig) => STUFEN.indexOf(hat) >= STUFEN.indexOf(noetig);
/* =====================================================================
WER DARF AUF WELCHES BRETT SCHREIBEN — die Tafel
Eine Tafel und keine Abfragen in den Handlern, aus demselben Grund
wie bei der Rechtetafel in rechte.js: Wer ein achtes Brett anlegt,
muss sich entscheiden. Steht es nicht hier, darf niemand von außen
darauf schreiben — `undefined` heißt „nein", nicht „alles".
`null` bei `gast` heißt: gar nicht, auf keiner Stufe. Das ist etwas
anderes als „auf einer hohen Stufe" und wird deshalb anders
geschrieben.
===================================================================== */
export const TREFF_SCHREIBEN = {
/* Ansagen vom Team. Ein Anschlagbrett, auf das jeder schreiben darf,
ist kein Anschlagbrett, sondern ein zweiter Treff. */
anschlag: null,
/* Termine. Was ansteht, sagt das Team — sonst steht dort ein Termin,
den es nicht gibt, und jemand wartet umsonst. */
ansteht: null,
/* Das Gespräch. Ab „Dabei". */
treff: "dabei",
/* Wünsche. Schreiben ab „Dabei" — KLICKEN darf jeder ab Tag eins,
siehe „Will ich auch". Das ist Absicht: Der erste Eindruck soll
nicht „du darfst noch nichts" sein, sondern „deine Stimme zählt". */
wunsch: "dabei",
/* Clips, Bilder, Fanart. Ab „Stamm", und erst nach Freigabe sichtbar.
Zwei Schlösser, weil hier fremde Inhalte hochgeladen werden —
Urheberrecht und Anstand sind nichts, was man nachträglich klärt. */
highlight: "stamm",
/* Regeln pflegt das Team. Eine Regel, die jeder ändern darf, ist
keine. */
regeln: null,
/* „Ich hätte Lust" — die Brücke zu den Talenten. Ab „Dabei": Wer sich
nach drei Tagen bewirbt, bewirbt sich auf etwas, das er noch nicht
gesehen hat. */
mitmachen: "dabei",
};
/* Bretter, auf denen ein Eintrag erst nach Freigabe sichtbar wird.
BEI `ansteht` IST DAS DER SCHALTER „IM TREFF ZEIGEN". Das Team trägt
dort ein, was ansteht, und entscheidet je Termin, ob die Community
ihn sieht. Ohne diesen Schalter müsste es zwei Terminlisten führen —
und die zweite wäre die, die niemand pflegt.
BEI `highlight` IST ES DER URHEBERRECHTS- UND ANSTANDSFILTER. */
export const TREFF_FREIGABE_BRETTER = ["ansteht", "highlight"];
/* =====================================================================
DIE TABELLEN
===================================================================== */
/* DIE TABELLEN LEGT DER BAUPLAN AN, nicht dieses Modul.
Vorher stand hier ein "beim ersten Zugriff anlegen". Das ist an drei
Stellen im Haus bewaehrt (Ampel, Checkliste, Schulung) und hier
trotzdem falsch: Die Sperrfrage wird in sitzungLesen() gestellt --
also bevor irgendein Weg des Treffs beruehrt wurde. Beim allerersten
Aufruf haette sie gegen eine Tabelle gelaufen, die es noch nicht
gibt. Eine Sperre, die bei einer Stoerung "darf" sagt, ist die, die
im Ernstfall aufgeht.
Das SQL selbst steht in treff-tabellen.js, samt Begruendung je
Tabelle. */
/** Höchstens drei Ansagen gleichzeitig angeheftet.
*
* WARUM ÜBERHAUPT EINE GRENZE: Ein Anschlagbrett mit zwölf angehefteten
* Ansagen ist ein Anschlagbrett mit null -- gelesen wird keine davon.
* Drei ist die Zahl, die auf ein Handy passt, ohne zu scrollen. */
export const ANSCHLAG_MAX = 3;
/* =====================================================================
SPERREN — wer gerade nicht darf
===================================================================== */
/**
* Die wirksame Maßnahme gegen diese Person, oder null.
*
* DREI AUSGÄNGE, NICHT ZWEI: `null` heißt „nichts liegt vor". Kann die
* Frage nicht beantwortet werden (Tabelle fehlt, Datenbank weg), wirft
* die Funktion -- sie gibt NICHT `null` zurück. Eine Sperre, die bei
* einer Störung „darf" sagt, ist genau die, die im Ernstfall aufgeht.
*/
/** Die wirksame Massnahme gegen diese Person, oder null.
* Die Antwort selbst steht in treff-tabellen.js -- dieselbe Frage
* wird beim Anmelden und bei jeder Sitzung gestellt, und zwei
* Fassungen davon waeren eine zu viel. */
export const treffSperre = (personId) => treffSperreLesen(db(), personId);
/**
* Darf diese Person diese Maßnahme verhängen?
*
* Filipes Entscheidung vom 11.09.2026, wörtlich: *"Modis dürfen
* befristet ausschließen, dauerhaft nur ihr."*
*
* WARUM DIE DAUERHAFTE FASSUNG ANDERS BEHANDELT WIRD: Eine Pause endet
* von selbst. Ein dauerhafter Ausschluss endet nur, wenn jemand ihn
* zurücknimmt -- und dafür muss man erst merken, dass er falsch war.
* Das ist eine andere Art Entscheidung, also braucht sie eine andere
* Hand.
*/
export function darfMassnahme(person, art, dauerhaft) {
const r = person?.rolle;
if (!TREFF_TEAM.has(r)) return false;
if (art === "hinweis") return true;
if (!dauerhaft) return true;
return r === "admin";
}
/** Die längste Frist, die diese Rolle setzen darf (in Tagen). */
export const MODI_FRIST_TAGE = 7;
export function fristErlaubt(person, tage) {
if (person?.rolle === "admin" || person?.rolle === "hand") return true;
return tage > 0 && tage <= MODI_FRIST_TAGE;
}
/* =====================================================================
DIE FRAGEN, DIE ANDERE MODULE STELLEN
===================================================================== */
/**
* Darf diese Person auf dieses Brett schreiben?
*
* Gibt einen Grund zurück (Text) oder null, wenn es in Ordnung ist.
* Ein Text statt `false`, weil die Antwort dem Menschen davor gesagt
* wird -- „geht nicht" ohne Grund ist die Sorte Antwort, nach der man
* fragt, was man falsch gemacht hat.
*/
export function darfSchreiben(person, bereich) {
if (!TREFF_BRETTER.includes(bereich)) return null; // kein Treff-Brett
if (TREFF_TEAM.has(person?.rolle)) return null; // Team schreibt überall
if (!TREFF_AUSSEN.has(person?.rolle)) return "Dieser Bereich gehört zum Treff.";
const noetig = TREFF_SCHREIBEN[bereich];
if (!noetig) return "Hier schreibt das Team.";
const sperre = treffSperre(person.id);
if (sperre) {
return sperre.art === "ausschluss"
? "Dein Zugang zum Treff ist beendet."
: `Du hast gerade eine Pause${sperre.bis ? ` bis ${sperre.bis.slice(0, 10)}` : ""}.`;
}
const hat = stufeVon(person);
if (!stufeReicht(hat, noetig)) {
return noetig === "dabei"
? `Ab „${STUFEN_NAME.dabei}" kannst du hier schreiben – das ist nach ${DABEI_TAGE} Tagen so weit.`
: `Ab „${STUFEN_NAME.stamm}" kannst du hier etwas einstellen – nach ${STAMM_TAGE} Tagen `
+ `und ${STAMM_BEITRAEGE} eigenen Beiträgen.`;
}
return null;
}
/**
* Die Zusatzbedingung, die unfreigegebene Einträge vor der Community
* verbirgt. Für Team Dogi gilt sie nicht -- es muss ja sehen, was es
* freigeben soll.
*
* ALS SQL-BEDINGUNG UND NICHT ALS FILTER DANACH: Ein Filter in
* JavaScript hätte die Zeilen erst geholt. Sie stünden dann in der
* Antwort des Servers, in seinem Speicher und in jedem Protokoll
* dazwischen -- und beim nächsten Weg, der dieselbe Abfrage benutzt,
* ohne den Filter mitzunehmen, auch im Browser.
*/
export function freigabeBedingung(person, praefix = "e") {
if (!TREFF_AUSSEN.has(person?.rolle)) return null;
const platz = TREFF_FREIGABE_BRETTER.map(() => "?").join(", ");
return {
wo: `(${praefix}.bereich NOT IN (${platz})
OR EXISTS (SELECT 1 FROM treff_freigaben f WHERE f.eintrag_id = ${praefix}.id))`,
werte: [...TREFF_FREIGABE_BRETTER],
};
}
/* =====================================================================
DIE WEGE
===================================================================== */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
if (!imTreff(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
req.person = person;
next();
}
function gleicheHerkunft(req, res, next) {
const herkunft = req.get("origin");
if (!herkunft) return next();
let erlaubt;
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
next();
}
treffRouter.use("/workspace/api/treff", angemeldet);
/** Nur Team Dogi. 404 und nicht 403 -- wer „kein Zugriff" liest, fragt
* als Nächstes, wer denn dann. */
function nurTeam(req, res, next) {
if (TREFF_TEAM.has(req.person?.rolle)) return next();
return res.status(404).json({ fehler: "nicht_gefunden" });
}
/** Gehört dieser Eintrag zu einem Brett des Treffs? */
function treffEintrag(id) {
const e = db().prepare(
"SELECT id, bereich, titel, erstellt_von FROM eintraege WHERE id = ?").get(id);
if (!e || !TREFF_BRETTER.includes(e.bereich)) return null;
return e;
}
const nummer = (x) => { const n = Number(x); return Number.isInteger(n) && n > 0 ? n : 0; };
const text = (x, max) => String(x ?? "").trim().slice(0, max);
/* ---------- Meine Lage ------------------------------------------------ */
/* Was DIESE Person gerade darf. Die Oberfläche baut daraus ihre Knöpfe.
Sie fragt den Server, statt selbst zu rechnen: Eine Regel, die im
Browser noch einmal steht, ist die, die beim nächsten Umbau
auseinanderläuft -- und die Fassung im Browser kann man ändern. */
treffRouter.get("/workspace/api/treff/lage", (req, res) => {
try {
const p = req.person;
const stufe = stufeVon(p);
const sperre = TREFF_AUSSEN.has(p.rolle) ? treffSperre(p.id) : null;
/* ZWEI ANTWORTEN JE BRETT: ob es geht, und warum nicht.
Der Grund ist kein Schmuck. Ein Brett ohne Schreibknopf und ohne
Erklaerung wirkt kaputt -- man drueckt herum, sucht die Stelle,
gibt auf. Mit dem Satz daneben wirkt es gedacht: "Ab Dabei kannst
du hier schreiben, das ist nach sieben Tagen so weit."
UND ER KOMMT VON DER STELLE, DIE AUCH ENTSCHEIDET. darfSchreiben()
gibt den Text zurueck, den es ohnehin erzeugt -- derselbe, den der
Server bei einem Versuch als Absage schickt. Ein zweiter Satz in
der Oberflaeche waere der, der irgendwann etwas anderes behauptet
als die Regel tut. */
const schreiben = {};
const warum = {};
for (const b of TREFF_BRETTER) {
const grund = darfSchreiben(p, b);
schreiben[b] = grund === null;
if (grund) warum[b] = grund;
}
res.json({
rolle: p.rolle,
rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle,
team: TREFF_TEAM.has(p.rolle),
stufe,
stufe_name: stufe ? STUFEN_NAME[stufe] : null,
naechste_stufe: stufe === "neu" ? { name: STUFEN_NAME.dabei, tage: DABEI_TAGE }
: stufe === "dabei" ? { name: STUFEN_NAME.stamm, tage: STAMM_TAGE,
beitraege: STAMM_BEITRAEGE } : null,
sperre: sperre ? { art: sperre.art, bis: sperre.bis, grund: sperre.grund } : null,
schreiben,
warum,
anschlag_max: ANSCHLAG_MAX,
});
} catch (f) {
console.error("[treff] Lage:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Regeln ------------------------------------------------ */
/* DAS MINDESTALTER (Entscheidung Filipe, 11.09.2026: 18 Jahre).
In Luxemburg liegt die Grenze der DSGVO Art. 8 bei 16 — es waere also
erlaubt, ab 16 hereinzulassen. Der Treff gehoert aber zu einem Stream
fuer Erwachsene, und dort ist die Frage nicht, was erlaubt ist,
sondern wen man hereinlaesst. */
export const MINDESTALTER = 18;
/* WARUM DER REGELTEXT SEINE ZAHLEN VOM SERVER HOLT.
Im Text steht "nach sieben Tagen kannst du schreiben". Stuende die
Sieben als Wort in der HTML-Datei, waere sie eine zweite Wahrheit
neben DABEI_TAGE — und beim naechsten Nachjustieren haette man eine
Regel, die anders arbeitet als sie sich beschreibt. Das ist die
schlimmere Sorte Fehler: Niemand merkt es, und wer es merkt, glaubt
dem Text.
Ein Regeltext, der sich selbst widerspricht, ist ausserdem rechtlich
das Gegenteil dessen, wofuer er da ist. */
treffRouter.get("/workspace/api/treff/regeln", (req, res) => {
res.json({
mindestalter: MINDESTALTER,
dabei_tage: DABEI_TAGE,
stamm_tage: STAMM_TAGE,
stamm_beitraege: STAMM_BEITRAEGE,
anschlag_max: ANSCHLAG_MAX,
grund_min: GRUND_MIN,
modi_frist_tage: MODI_FRIST_TAGE,
stufen: STUFEN.map((s) => ({ schluessel: s, name: STUFEN_NAME[s] })),
/* Welche Bretter erst nach Freigabe erscheinen — auch das gehoert
in den Regeltext, und auch das soll nicht abschreibbar sein. */
freigabe_bretter: [...TREFF_FREIGABE_BRETTER],
});
});
/* ---------- „Will ich auch" ------------------------------------------- */
/* EIN KLICK, KEINE ZAHL IM KOPF. Die Route ist absichtlich nicht
„zähle hoch", sondern „meine Stimme liegt hier": Zweimal dasselbe zu
schicken ändert nichts (INSERT OR IGNORE), und die Antwort ist in
beiden Fällen dieselbe. Eine Route, die beim zweiten Aufruf einen
Fehler bringt, zwingt die Oberfläche dazu, den eigenen Zustand zu
kennen -- und dort ist er am schnellsten falsch. */
treffRouter.put("/workspace/api/treff/eintrag/:id/willich", gleicheHerkunft, (req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
/* Gestimmt wird auf der Wunschliste. Anderswo wäre es eine zweite
Bewertung neben der, die es schon gibt. */
if (e.bereich !== "wunsch") return res.status(400).json({ fehler: "hier_nicht" });
if (TREFF_AUSSEN.has(req.person.rolle) && treffSperre(req.person.id)) {
return res.status(403).json({ fehler: "gesperrt" });
}
db().prepare(
"INSERT OR IGNORE INTO treff_stimmen (eintrag_id, person_id, zeitpunkt) VALUES (?,?,?)")
.run(id, req.person.id, jetzt());
res.json({ ok: true, ...stimmen(id, req.person.id) });
} catch (f) {
console.error("[treff] Stimme:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.delete("/workspace/api/treff/eintrag/:id/willich", gleicheHerkunft, (req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
db().prepare("DELETE FROM treff_stimmen WHERE eintrag_id = ? AND person_id = ?")
.run(id, req.person.id);
res.json({ ok: true, ...stimmen(id, req.person.id) });
} catch (f) {
console.error("[treff] Stimme zurück:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
function stimmen(id, personId) {
const { n } = db().prepare("SELECT COUNT(*) AS n FROM treff_stimmen WHERE eintrag_id = ?")
.get(id);
const meine = db().prepare(
"SELECT 1 AS x FROM treff_stimmen WHERE eintrag_id = ? AND person_id = ?")
.get(id, personId);
return { stimmen: n, meine: !!meine };
}
/* ---------- Anheften (Anschlagbrett) ---------------------------------- */
treffRouter.put("/workspace/api/treff/eintrag/:id/anheften", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
if (e.bereich !== "anschlag") return res.status(400).json({ fehler: "hier_nicht" });
/* DIE ZÄHLUNG STEHT IN DERSELBEN TRANSAKTION WIE DAS EINFÜGEN.
Zwei Modis, die gleichzeitig anheften, würden sonst beide „zwei
von drei" zählen und beide einfügen -- und am Ende hingen vier.
Genau dieser Fehler ist in diesem Haus schon bei der
CHECK-Umstellung vermieden worden, aus demselben Grund. */
db().exec("BEGIN IMMEDIATE");
try {
const schon = db().prepare(
"SELECT 1 AS x FROM treff_angeheftet WHERE eintrag_id = ?").get(id);
if (!schon) {
const { n } = db().prepare("SELECT COUNT(*) AS n FROM treff_angeheftet").get();
if (n >= ANSCHLAG_MAX) {
db().exec("ROLLBACK");
return res.status(409).json({
fehler: `Es hängen schon ${ANSCHLAG_MAX} Ansagen. Nimm zuerst eine ab.`,
});
}
db().prepare(
"INSERT INTO treff_angeheftet (eintrag_id, seit, von) VALUES (?,?,?)")
.run(id, jetzt(), req.person.id);
}
db().exec("COMMIT");
} catch (f) {
try { db().exec("ROLLBACK"); } catch { /* schon zurück */ }
throw f;
}
protokolliere("treff_angeheftet", { personId: req.person.id, ip: echteIp(req),
detail: `Eintrag ${id}` });
res.json({ ok: true });
} catch (f) {
console.error("[treff] Anheften:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.delete("/workspace/api/treff/eintrag/:id/anheften", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
if (!id) return res.status(404).json({ fehler: "nicht_gefunden" });
db().prepare("DELETE FROM treff_angeheftet WHERE eintrag_id = ?").run(id);
res.json({ ok: true });
} catch (f) {
console.error("[treff] Abnehmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Freigeben ------------------------------------------------- */
/* „Im Treff zeigen" bei Terminen, „Freigeben" bei Highlights -- derselbe
Vorgang, deshalb dieselbe Route. Zwei Routen wären zwei Stellen, an
denen dieselbe Rechteprüfung steht. */
treffRouter.put("/workspace/api/treff/eintrag/:id/freigeben", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
if (!TREFF_FREIGABE_BRETTER.includes(e.bereich)) {
return res.status(400).json({ fehler: "hier_nicht" });
}
db().prepare(
"INSERT OR IGNORE INTO treff_freigaben (eintrag_id, zeitpunkt, von) VALUES (?,?,?)")
.run(id, jetzt(), req.person.id);
protokolliere("treff_freigegeben", { personId: req.person.id, ip: echteIp(req),
detail: `${e.bereich} ${id}` });
res.json({ ok: true, freigegeben: true });
} catch (f) {
console.error("[treff] Freigeben:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.delete("/workspace/api/treff/eintrag/:id/freigeben", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
if (!id) return res.status(404).json({ fehler: "nicht_gefunden" });
db().prepare("DELETE FROM treff_freigaben WHERE eintrag_id = ?").run(id);
res.json({ ok: true, freigegeben: false });
} catch (f) {
console.error("[treff] Freigabe zurück:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Melden (DSA Art. 16) -------------------------------------- */
export const GRUND_MIN = 10;
export const GRUND_MAX = 1000;
treffRouter.post("/workspace/api/treff/eintrag/:id/melden", gleicheHerkunft, (req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
/* DER GRUND IST PFLICHT, und die Mindestlänge ist keine Schikane:
Art. 16 Abs. 2 verlangt eine Meldung, die „hinreichend genau und
angemessen begründet" ist -- also eine, nach der man entscheiden
kann, ohne zurückzufragen. „Scheiße" ist keine. */
const grund = text(req.body?.grund, GRUND_MAX);
if (grund.length < GRUND_MIN) {
return res.status(400).json({
fehler: "Bitte schreib kurz, was daran nicht in Ordnung ist – "
+ "sonst können wir es nicht prüfen.",
});
}
db().prepare(`INSERT INTO treff_meldungen (eintrag_id, melder_id, grund, zeitpunkt)
VALUES (?,?,?,?)`).run(id, req.person.id, grund, jetzt());
protokolliere("treff_meldung", { personId: req.person.id, ip: echteIp(req),
detail: `Eintrag ${id}` });
/* KEINE AUSKUNFT DARÜBER, OB SCHON JEMAND GEMELDET HAT. Sonst wäre
die Antwort ein Zähler, an dem man ablesen kann, wen sonst noch
etwas stört -- und daraus wird eine Abstimmung statt einer
Prüfung. */
res.json({ ok: true });
} catch (f) {
console.error("[treff] Melden:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.get("/workspace/api/treff/meldungen", nurTeam, (req, res) => {
try {
const zeilen = db().prepare(`
SELECT m.id, m.eintrag_id, m.grund, m.zeitpunkt, m.status, m.entscheid,
m.entschieden_am,
e.bereich, e.titel, e.text,
pm.name AS melder_name, pm.rolle AS melder_rolle,
pe.name AS autor_name, pe.rolle AS autor_rolle,
pd.name AS entschieden_name
FROM treff_meldungen m
LEFT JOIN eintraege e ON e.id = m.eintrag_id
LEFT JOIN personen pm ON pm.id = m.melder_id
LEFT JOIN personen pe ON pe.id = e.erstellt_von
LEFT JOIN personen pd ON pd.id = m.entschieden_von
ORDER BY CASE m.status WHEN 'offen' THEN 0 ELSE 1 END, m.id DESC
LIMIT 300`).all();
res.json({ meldungen: zeilen.map(mitRollenname) });
} catch (f) {
console.error("[treff] Meldungen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.patch("/workspace/api/treff/meldung/:id", gleicheHerkunft, nurTeam, (req, res) => {
try {
const id = nummer(req.params.id);
const status = String(req.body?.status || "");
if (!id || !["erledigt", "abgelehnt"].includes(status)) {
return res.status(400).json({ fehler: "unbrauchbar" });
}
const entscheid = text(req.body?.entscheid, GRUND_MAX);
if (entscheid.length < GRUND_MIN) {
return res.status(400).json({
fehler: "Bitte halte kurz fest, was du entschieden hast – "
+ "das gehört zur Meldung dazu.",
});
}
const r = db().prepare(`UPDATE treff_meldungen
SET status = ?, entscheid = ?, entschieden_von = ?, entschieden_am = ?
WHERE id = ? AND status = 'offen'`)
.run(status, entscheid, req.person.id, jetzt(), id);
if (!r.changes) return res.status(409).json({ fehler: "schon_entschieden" });
protokolliere("treff_meldung_entschieden", { personId: req.person.id, ip: echteIp(req),
detail: `${id}: ${status}` });
res.json({ ok: true });
} catch (f) {
console.error("[treff] Meldung entscheiden:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Entfernen mit Grund (DSA Art. 17) ------------------------- */
/**
* Hält fest, warum ein Beitrag entfernt wurde — BEVOR er verschwindet.
*
* Wird aus workspace-bereiche.js gerufen, nicht als eigene Route: Es
* gibt genau einen Weg, einen Eintrag zu löschen, und das soll so
* bleiben. Eine zweite Löschroute wäre die, an der die Begründung
* eines Tages fehlt.
*
* @returns {string|null} Fehlertext, oder null wenn gelöscht werden darf
*/
export function entfernenVorbereiten(person, eintrag, grund) {
if (!TREFF_BRETTER.includes(eintrag?.bereich)) return null; // kein Treff-Brett
/* Wer seinen EIGENEN Beitrag zurücknimmt, begründet nichts. Art. 17
schützt den Verfasser vor der Plattform, nicht vor sich selbst. */
if (eintrag.erstellt_von === person?.id) return null;
const g = text(grund, GRUND_MAX);
if (g.length < GRUND_MIN) {
return "Zum Entfernen gehört ein Grund – die Person erfährt ihn, "
+ "und wir müssen ihn festhalten können.";
}
const autor = eintrag.erstellt_von
? db().prepare("SELECT name FROM personen WHERE id = ?").get(eintrag.erstellt_von)
: null;
db().prepare(`INSERT INTO treff_entfernt
(eintrag_id, bereich, titel, autor_id, autor_name, grund, von, zeitpunkt)
VALUES (?,?,?,?,?,?,?,?)`)
.run(eintrag.id, eintrag.bereich, String(eintrag.titel ?? ""),
eintrag.erstellt_von ?? null, autor?.name ?? null, g, person?.id ?? null, jetzt());
return null;
}
treffRouter.get("/workspace/api/treff/entfernt", nurTeam, (req, res) => {
try {
const zeilen = db().prepare(`
SELECT t.id, t.eintrag_id, t.bereich, t.titel, t.autor_name, t.grund, t.zeitpunkt,
p.name AS von_name, p.rolle AS von_rolle
FROM treff_entfernt t LEFT JOIN personen p ON p.id = t.von
ORDER BY t.id DESC LIMIT 300`).all();
res.json({ entfernt: zeilen.map(mitRollenname) });
} catch (f) {
console.error("[treff] Entfernt:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Maßnahmen ------------------------------------------------- */
treffRouter.post("/workspace/api/treff/massnahme", gleicheHerkunft, nurTeam, (req, res) => {
try {
const personId = nummer(req.body?.person_id);
const art = String(req.body?.art || "");
const grund = text(req.body?.grund, GRUND_MAX);
const tage = Number(req.body?.tage);
const dauerhaft = art === "ausschluss" && (req.body?.tage === null
|| req.body?.tage === undefined || req.body?.tage === "");
if (!personId || !["hinweis", "pause", "ausschluss"].includes(art)) {
return res.status(400).json({ fehler: "unbrauchbar" });
}
if (grund.length < GRUND_MIN) {
return res.status(400).json({
fehler: "Ohne Grund geht das nicht – die Person hat ein Recht darauf zu erfahren, warum.",
});
}
const ziel = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(personId);
/* MASSNAHMEN GELTEN NUR GEGEN DIE COMMUNITY. Ein Modi, der einen
anderen Modi „ausschließen" könnte, hätte einen Weg, jemanden aus
dem Team zu entfernen -- und zwar an „Personen & Zugänge" vorbei,
wo diese Entscheidung hingehört. */
if (!ziel || !TREFF_AUSSEN.has(ziel.rolle)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
if (!darfMassnahme(req.person, art, dauerhaft)) {
return res.status(403).json({
fehler: "Dauerhaft ausschließen kann nur DogFather.",
});
}
let bis = null;
if (!dauerhaft && art !== "hinweis") {
if (!Number.isFinite(tage) || tage <= 0 || tage > 3650) {
return res.status(400).json({ fehler: "Bitte gib an, für wie viele Tage." });
}
if (!fristErlaubt(req.person, tage)) {
return res.status(403).json({
fehler: `Als Modi kannst du bis zu ${MODI_FRIST_TAGE} Tage geben. `
+ "Länger entscheidet DogFather.",
});
}
bis = new Date(Date.now() + tage * TAG).toISOString();
}
db().prepare(`INSERT INTO treff_massnahmen (person_id, art, grund, bis, von, zeitpunkt)
VALUES (?,?,?,?,?,?)`)
.run(personId, art, grund, bis, req.person.id, jetzt());
protokolliere("treff_massnahme", { personId: req.person.id, ip: echteIp(req),
detail: `${art} gegen ${ziel.name}${bis ? ` bis ${bis.slice(0, 10)}` : " (dauerhaft)"}` });
res.json({ ok: true, art, bis });
} catch (f) {
console.error("[treff] Maßnahme:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.delete("/workspace/api/treff/massnahme/:id", gleicheHerkunft, nurTeam, (req, res) => {
try {
const id = nummer(req.params.id);
if (!id) return res.status(404).json({ fehler: "nicht_gefunden" });
const m = db().prepare("SELECT id, art, bis FROM treff_massnahmen WHERE id = ?").get(id);
if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
/* Wer eine dauerhafte Maßnahme nicht verhängen darf, darf sie auch
nicht aufheben. Sonst wäre die Regel darüber eine Formalie: Ein
Modi könnte einen dauerhaften Ausschluss abnehmen und einen
siebentägigen setzen. */
if (!darfMassnahme(req.person, m.art, !m.bis)) {
return res.status(403).json({ fehler: "Das kann nur DogFather aufheben." });
}
db().prepare(
"UPDATE treff_massnahmen SET aufgehoben_am = ?, aufgehoben_von = ? WHERE id = ?")
.run(jetzt(), req.person.id, id);
protokolliere("treff_massnahme_auf", { personId: req.person.id, ip: echteIp(req),
detail: String(id) });
res.json({ ok: true });
} catch (f) {
console.error("[treff] Maßnahme aufheben:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.get("/workspace/api/treff/massnahmen", nurTeam, (req, res) => {
try {
const zeilen = db().prepare(`
SELECT m.id, m.person_id, m.art, m.grund, m.bis, m.zeitpunkt, m.aufgehoben_am,
p.name AS person_name, v.name AS von_name, v.rolle AS von_rolle
FROM treff_massnahmen m
LEFT JOIN personen p ON p.id = m.person_id
LEFT JOIN personen v ON v.id = m.von
ORDER BY m.id DESC LIMIT 300`).all();
res.json({ massnahmen: zeilen.map(mitRollenname) });
} catch (f) {
console.error("[treff] Maßnahmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Mitglieder -------------------------------------------- */
/* WARUM DER TREFF SEINE EIGENE MITGLIEDERLISTE HAT UND NICHT
/api/personen BENUTZT (11.09.2026).
Der naheliegende Weg wäre gewesen, die Moderation auf „Personen &
Zugänge" zu schicken. Drei Gründe dagegen, und der dritte ist der
eigentliche:
1. Dort stehen alle — Creator, Scouts, die Agentur. Ein Modi, der
jemanden sperren will, müsste sich durch Menschen klicken, die
ihn nichts angehen.
2. Dort gibt es Knöpfe, die ein Modi nicht drücken darf. Ein
Bildschirm voller Absagen ist schlechter als einer ohne Knöpfe.
3. Was hier gebraucht wird, steht dort gar nicht: die STUFE und die
laufende Maßnahme. Beides gibt es nur im Treff.
NUR AUSSENROLLEN. Die Liste kann das Team nicht enthalten -- nicht
weil es ausgeblendet wäre, sondern weil die Abfrage es gar nicht
erst holt. Eine Maßnahme gegen jemanden aus dem Team gibt es nicht
(siehe /massnahme), und eine Liste, aus der man etwas auswählen
kann, das danach abgelehnt wird, ist eine Falle. */
treffRouter.get("/workspace/api/treff/mitglieder", nurTeam, (req, res) => {
try {
const zeilen = db().prepare(`
SELECT id, name, erstellt, letzter_login, aktiv
FROM personen WHERE rolle = 'gast'
ORDER BY name COLLATE NOCASE`).all();
const platz = TREFF_BRETTER.map(() => "?").join(", ");
const beitraege = db().prepare(
`SELECT erstellt_von AS id, COUNT(*) AS n FROM eintraege
WHERE bereich IN (${platz}) GROUP BY erstellt_von`).all(...TREFF_BRETTER);
const zahl = new Map(beitraege.map((b) => [b.id, b.n]));
res.json({
mitglieder: zeilen.map((z) => {
const sperre = treffSperre(z.id);
return {
id: z.id, name: z.name, seit: z.erstellt, zuletzt: z.letzter_login,
beitraege: zahl.get(z.id) || 0,
stufe: stufeVon({ id: z.id, rolle: "gast" }),
sperre: sperre
? { id: sperre.id, art: sperre.art, bis: sperre.bis, grund: sperre.grund }
: null,
};
}),
/* Was DIESE Person verhängen darf. Die Oberfläche baut daraus
ihre Knöpfe -- und nicht aus einer eigenen Rollenabfrage, die
auseinanderlaufen könnte. */
darf: {
pause: darfMassnahme(req.person, "pause", false),
ausschluss_befristet: darfMassnahme(req.person, "ausschluss", false),
ausschluss_dauerhaft: darfMassnahme(req.person, "ausschluss", true),
frist_tage: req.person.rolle === "modi" ? MODI_FRIST_TAGE : null,
},
});
} catch (f) {
console.error("[treff] Mitglieder:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Brücke zu den Talenten -------------------------------- */
/* Wer auf „Mitmachen" schreibt „Ich hätte Lust", ist ein Kandidat. Diese
Route legt ihn in der Kategorie an, die es dafür schon gibt -- statt
einer zweiten Liste daneben.
EINMAL IST EINMAL: Ein zweites Übernehmen legt keinen zweiten
Kandidaten an. Geprüft wird an der Quelle (gibt es schon einen
Talent-Eintrag zu diesem Menschen?), nicht an einer Merkspalte. */
treffRouter.post("/workspace/api/treff/mitmachen/:id/uebernehmen", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
const e = id && db().prepare(
"SELECT id, bereich, art, titel, text, erstellt_von FROM eintraege WHERE id = ?").get(id);
if (!e || e.bereich !== "mitmachen") return res.status(404).json({ fehler: "nicht_gefunden" });
if (!e.erstellt_von) {
return res.status(400).json({ fehler: "Zu diesem Eintrag gibt es keine Person." });
}
const schon = db().prepare(
"SELECT id FROM eintraege WHERE bereich = 'talente' AND creator_id = ?")
.get(e.erstellt_von);
if (schon) return res.status(409).json({ fehler: "schon_uebernommen", talent_id: schon.id });
const wer = db().prepare("SELECT name FROM personen WHERE id = ?").get(e.erstellt_von);
const nun = jetzt();
const r = db().prepare(`INSERT INTO eintraege
(bereich, art, titel, text, datum, dringlichkeit, status, creator_id,
erstellt, erstellt_von, einsatz)
VALUES ('talente','empfohlen',?,?,?,'mittel','offen',?,?,?,?)`)
.run(
`${wer?.name ?? "Jemand"} hat sich im Treff gemeldet`,
`Aus „Mitmachen": ${String(e.titel ?? "")}\n\n${String(e.text ?? "")}`.trim(),
nun.slice(0, 10), e.erstellt_von, nun, req.person.id, "Der Treff");
protokolliere("treff_talent", { personId: req.person.id, ip: echteIp(req),
detail: `aus Eintrag ${id}` });
res.json({ ok: true, talent_id: Number(r.lastInsertRowid) });
} catch (f) {
console.error("[treff] Übernehmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------------------------------------------------------------------
DER VOLLE ROLLENNAME AN JEDEM BEITRAG
Filipes Entscheidung vom 11.09.2026: „Voller Rollenname zeigen" --
an einem Beitrag im Treff steht „Modi", „Rechte Hand", „DogFather".
WARUM DER SERVER DEN NAMEN LIEFERT UND NICHT DER BROWSER: Eine
Zuordnung Rolle → Name im ausgelieferten JavaScript wäre eine Liste
aller Rollen dieses Hauses -- auf jedem Rechner der Community,
dauerhaft lesbar. Hier kommt nur der Name der Rollen an, die an
diesem Beitrag wirklich beteiligt sind.
------------------------------------------------------------------- */
function mitRollenname(zeile) {
const aus = { ...zeile };
for (const [k, v] of Object.entries(zeile)) {
if (k.endsWith("_rolle") && v) aus[`${k}_name`] = ROLLEN_NAME[v] ?? v;
}
return aus;
}
export { mitRollenname };
+396 -18
View File
@@ -27,8 +27,12 @@ import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { mkdirSync } from "node:fs";
import { createRequire } from "node:module";
import { darfSeite } from "./rechte.js";
import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse,
import { darfSeite, OHNE_ANMELDUNG, SEITEN, ALLE_ROLLEN } from "./rechte.js";
/* Ein Modul ohne eigene Importe -- deshalb entsteht hier kein Kreis,
obwohl workspace-treff.js seinerseits aus dieser Datei importiert.
Begruendung im Kopf von treff-tabellen.js. */
import { treffTabellen, treffSperreLesen } from "./treff-tabellen.js";
import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istTreffAdresse, istPruefAdresse, AUSSEN_ROLLEN,
TEAM_DOGI_ROLLEN } from "./crew-adresse.js";
/* Weitergereicht, damit die Fachmodule sie wie alles andere aus
workspace.js beziehen und nicht wissen muessen, wo sie wohnt. */
@@ -63,7 +67,7 @@ const COOKIE = "dfw_sitzung";
const SITZUNG_STUNDEN = 12;
const VERSUCHE_MAX = 8; // pro IP
const VERSUCHE_FENSTER_MIN = 10;
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "hand", "modi"]);
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "hand", "modi", "gast"]);
/* Die Reihenfolge, in der Rollen ueberall erscheinen: Spicy Media
zuerst, dann DogFather, Manager, Scout, Creator. Steht hier einmal,
@@ -99,6 +103,19 @@ const ROLLEN_KACHEL = new Set(["spicy", "admin", "manager", "scout", "creator"])
* sondern ihr Sinn. */
const CREW_KACHEL = new Set(["admin", "hand", "modi"]);
/** Die Rollen mit einer Kachel auf der Zugangswand von treff.
*
* EINE, und deshalb zeigt die Wand dort gar keine Kachelreihe: Eine
* Auswahl mit einem einzigen Eintrag ist keine Auswahl, sondern eine
* Huerde. Dieselbe Ueberlegung wie bei der ersten Fassung der
* Crew-Wand -- nur ist sie hier dauerhaft richtig, weil es im Treff
* auch kuenftig nur eine Rolle gibt.
*
* Die Menge steht trotzdem hier: Die Anmeldung prueft gegen sie, und
* eine Wand ohne Kacheln darf nicht heissen, dass jede Rolle
* durchkommt. */
const TREFF_KACHEL = new Set(["gast"]);
/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch
(admin, creator, manager, scout, spicy) -- also fast genau falsch
herum. */
@@ -383,6 +400,15 @@ export const ROLLEN_NAME = {
creator: "Creator",
hand: "Rechte Hand",
modi: "Modi",
/* DIE COMMUNITY (11.09.2026). Der Name steht hier und NICHT in einer
Datei, die jeder herunterlaedt -- ausgeliefert wird er nur an den,
der ihn selbst traegt, und an die, die ihn sehen duerfen.
Filipe hat ausdruecklich gewaehlt, dass im Treff der volle
Rollenname neben einem Beitrag steht ("Modi", "Rechte Hand",
"DogFather"). Das bleibt damit vereinbar: Der Name kommt als DATEN
mit der Antwort, nicht als Konstante im Browser. */
gast: "Community",
};
/* DIE UEBERSCHRIFT UEBER EINER PERSONENLISTE (10.09.2026).
@@ -632,9 +658,110 @@ const PERSONEN_KACHEL = {
const HAND_BEREICHE = [...MODI_BEREICHE, EINGANG_KACHEL, PERSONEN_KACHEL,
ENTWICKLUNG_KACHEL, TALENTE_KACHEL];
/* =====================================================================
DIE SIEBEN BRETTER DES TREFFS (11.09.2026)
Filipe: "wieso nur eine kachel fuer die community ich will dass die
mehrere kacheln haben. mit mehreren optionen."
Eine Kachel war zu kurz gedacht: Ein Raum, in dem Ansagen, Fragen,
Wuensche und Clips durcheinandergehen, ist nach zwei Wochen
unbenutzbar -- die Ansage von gestern ist weg, die Frage von heute
geht unter.
SORTIERT NACH ZWECK, NICHT NACH THEMA, und in der Reihenfolge, in der
ein Neuer liest: erst was gilt, dann was laeuft, dann das Gespraech,
dann das Besondere.
Jede der sieben bedient eines der vier Dinge, die darueber
entscheiden, ob sich jemand zugehoerig fuehlt (Zugehoerigkeit,
Einfluss, Nutzen, geteilte Erlebnisse). Was keines bedient, ist nicht
dabei -- deshalb gibt es hier keine Bestenliste und keine Punkte.
VON OBEN NACH UNTEN GELESEN SIND SIE EIN WEG: lesen -> mitreden ->
mitgestalten -> dazugehoeren. Die letzte Kachel uebergibt an die
Talente-Kategorie, die es schon gibt.
DIE KACHELN STEHEN HIER UND NICHT IN assets/js/bereiche.js -- aus
demselben Grund wie bei den Modis: Diese Datei wird nicht
ausgeliefert, jene bekommt jeder Besucher. */
const TREFF_GRUPPE = {
gruppe: "Community",
gruppeUnter: "Der Treff \u2013 der Raum f\u00fcr die Zuschauer",
};
const TREFF_BEREICHE = [
{ ...TREFF_GRUPPE, name: "Anschlagbrett", unter: "Was gerade gilt",
zeichen: "reports", ton: 33, ziel: "bereich.html?b=anschlag", szene: "halle" },
{ ...TREFF_GRUPPE, name: "Was ansteht", unter: "Die n\u00e4chsten Streams und Events",
zeichen: "kalender", ton: 31, ziel: "bereich.html?b=ansteht", szene: "skyline" },
{ ...TREFF_GRUPPE, name: "Der Treff", unter: "Hallo sagen, fragen, loben",
zeichen: "community", ton: 34, gross: true, ziel: "bereich.html?b=treff", szene: "lounge" },
{ ...TREFF_GRUPPE, name: "Wunschliste", unter: "Was ihr euch w\u00fcnscht \u2013 und wer mitwill",
zeichen: "content", ton: 30, ziel: "bereich.html?b=wunsch", szene: "portal" },
{ ...TREFF_GRUPPE, name: "Highlights", unter: "Eure Clips und Bilder",
zeichen: "live", ton: 29, ziel: "bereich.html?b=highlight", szene: "arena" },
{ ...TREFF_GRUPPE, name: "Regeln & Hilfe", unter: "Wie es hier l\u00e4uft",
zeichen: "schutz", ton: 28, ziel: "bereich.html?b=regeln", szene: "studio" },
{ ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst",
zeichen: "personen", ton: 32, ziel: "bereich.html?b=mitmachen", szene: "garage" },
];
/* MELDUNGEN & MASSNAHMEN -- FUER DAS TEAM, NICHT FUER DIE COMMUNITY
(11.09.2026).
Diese Kachel steht ausdruecklich NICHT in TREFF_BEREICHE. Die Liste
dort ist das, was ein Mitglied sieht; sie wird dem Team nur
angehaengt. Eine achte Kachel darin waere auch bei der Community
gelandet -- und zwar lautlos, denn sie saehe dort aus wie die
anderen sieben. Erst beim Klicken haette der Server 404 gesagt, und
das sieht aus wie ein kaputtes Programm.
SIE GEHOERT AUCH NICHT IN TREFF_BRETTER: Sie ist kein Brett mit
Eintraegen, sondern eine Seite. Das Ziel ist deshalb ein
Dateiname und kein "bereich.html?b=...", und die Ableitung weiter
unten nimmt sie von selbst nicht mit. */
const TREFF_MODERATION_KACHEL = {
...TREFF_GRUPPE,
name: "Meldungen & Ma\u00dfnahmen", unter: "Was gemeldet wurde \u2013 und was daraus wurde",
zeichen: "schutz", ton: 36, ziel: "treff-moderation.html", szene: "studio",
};
/* Welche Bretter zum Treff gehoeren -- ABGELEITET aus den Kacheln, nicht
danebengeschrieben. Eine zweite Liste waere die, die beim naechsten
neuen Brett vergessen wird. */
export const TREFF_BRETTER = TREFF_BEREICHE
.map((k) => new URL("http://x/" + k.ziel).searchParams.get("b"))
.filter(Boolean);
/* Wer den Treff ueberhaupt sehen darf: die Community selbst und das
Team von Dogi. Die Agentur ausdruecklich nicht -- Creator, Scouts und
Spicy Media haben mit den Zuschauern dieses Streams nichts zu tun. */
export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "admin"]);
/* DAS TEAM SIEHT DEN TREFF MIT -- als eigene Gruppe unter seinen
uebrigen Kacheln.
Filipe: "die modis rechte hand und ich also dogfather sehen die aber
auch. die community aber nur die dann."
Zusammengesetzt und nicht in MODI_BEREICHE hineingeschrieben, weil
diese Liste weiter oben steht als die Kacheln des Treffs. Eine Kopie
dort waere die Stelle, an der beim naechsten neuen Brett eines fehlt. */
const MODI_MIT_TREFF = [...MODI_BEREICHE, ...TREFF_BEREICHE, TREFF_MODERATION_KACHEL];
const HAND_MIT_TREFF = [...HAND_BEREICHE, ...TREFF_BEREICHE, TREFF_MODERATION_KACHEL];
/* AUSSEN_ROLLEN kommt aus crew-adresse.js und wird hier nur
weitergereicht -- die Adressregel braucht sie zuerst, und diese Datei
importiert von dort. Eine zweite Menge waere die, die auseinanderlaeuft. */
export { AUSSEN_ROLLEN };
export function bereicheFuer(person) {
if (person?.rolle === "modi") return MODI_BEREICHE;
if (person?.rolle === "hand") return HAND_BEREICHE;
if (person?.rolle === "modi") return MODI_MIT_TREFF;
if (person?.rolle === "hand") return HAND_MIT_TREFF;
/* DIE COMMUNITY SIEHT DEN TREFF UND SONST NICHTS (11.09.2026). */
if (person?.rolle === "gast") return TREFF_BEREICHE;
/* AUF DER ADRESSE VON TEAM DOGI SIEHT AUCH DOGFATHER DIE KACHELN DES
TEAMS (10.09.2026).
@@ -653,10 +780,27 @@ export function bereicheFuer(person) {
Kacheln. Eine eigene Liste fuer ihn waere die, die beim naechsten
Umbau auseinanderlaeuft -- und sie stuende gegen die Hausregel,
ihn nicht ueber sein Team zu stellen. */
if (person?.haus === "crew") return HAND_BEREICHE;
return null;
if (person?.haus === "crew") return HAND_MIT_TREFF;
/* `null` HEISST "NIMM DIE LISTE AUS DEM BROWSER" -- und die enthaelt
fuenfundzwanzig Kacheln des Agenturhauses. Bis zum 11.09.2026 stand
hier nur `return null`, also bekam JEDE Rolle ohne eigenen Zweig
diese Liste angeboten. Gefiltert wurde sie danach zwar nach
`rollen:` an jeder Kachel, aber das ist eine zweite Schranke an
einer anderen Stelle -- und sie lebt in einer Datei, die jeder
herunterlaedt.
Jetzt bekommen nur die fuenf Rollen `null`, fuer die diese Liste
geschrieben wurde. Alles andere bekommt eine leere Liste: keine
Kachel, kein Weg, nichts zu filtern. */
if (AGENTUR_LISTE.has(person?.rolle)) return null;
return [];
}
/* Die fuenf, deren Kacheln in assets/js/bereiche.js stehen. Nicht zu
verwechseln mit AGENTUR_ROLLEN (dort fehlt `admin`, weil es dabei um
die Sichtbarkeit von Zeilen geht und nicht um Kacheln). */
const AGENTUR_LISTE = new Set(["spicy", "admin", "manager", "scout", "creator"]);
/* WELCHE BEREICHSSEITEN EIN MODI OEFFNEN DARF -- abgeleitet aus seinen
eigenen Kacheln und nicht danebengeschrieben.
@@ -670,7 +814,7 @@ export function bereicheFuer(person) {
-- ohne diese Einschraenkung kaeme er ueber die Adresszeile auch in
die Agentur-Ablage, die ihn nichts angeht. */
export const MODI_BEREICHE_ERLAUBT = new Set(
HAND_BEREICHE
HAND_MIT_TREFF
.map((k) => (/bereich\.html\?b=([a-z]+)/.exec(k.ziel) || [])[1])
.filter(Boolean));
@@ -942,8 +1086,51 @@ const ZUSATZ_BEREICHE = {
/* Eine eigene Gruppe, direkt hinter Team Dogi: Was ueber Menschen
aufgeschrieben wird, steht nicht zwischen den taeglichen
Werkzeugen. */
/* WER SIEHT WAS (11.09.2026).
Filipe: "egal welche Rolle oder Person hinzugefuegt wird, soll
immer nur das sehen, was ich erlaube."
Dazu gehoert, dass er es nachsehen kann. Die Seite rechnet die
Uebersicht aus derselben Tafel, aus der die Schranke ihre
Entscheidung holt (rechte.js) -- eine von Hand gepflegte
Uebersicht waere eine zweite Wahrheit, und ausgerechnet die, der
man glaubt.
IN "Team & System" UND NICHT BEI DEN PERSONEN: Es geht nicht um
Menschen, sondern um das Haus. */
{
...TEAM_GRUPPE,
name: "Wer sieht was", unter: "Jede Rolle, jede Seite – ausgerechnet, nicht notiert",
/* EIN EIGENER TON, UND ZWAR EIN NEUER (11.09.2026).
Erster Anlauf war Ton 26 -- das waere "Entwicklung" ein zweites
Mal gewesen. Zweiter Anlauf war Ton 22, weil er frei AUSSAH:
Ich hatte die Toene ueber bereicheFuer() gezaehlt, und das
liefert die Zusatzkacheln gar nicht mit. Beide Male haette
sich diese Kachel eine Farbe mit einer anderen geteilt, und
beide Male haette ich es fuer geprueft gehalten.
Gefunden hat es pruef-start-ansicht, die ueber die ECHTE
Ansicht zaehlt: "34 Farben auf 35 Kacheln". Der 35. Ton ist
jetzt gemessen (start.css, Abstand 31,4 in Lab zum naechsten
Nachbarn) -- und nicht mehr aus einer Luecke geraten. */
zeichen: "schutz", ton: 35, ziel: "rechte.html", szene: "studio",
},
{ ...ENTWICKLUNG_KACHEL, gruppeNach: TEAM_GRUPPE.gruppe },
{ ...TALENTE_KACHEL, gruppeNach: TEAM_GRUPPE.gruppe },
/* DER TREFF -- GANZ UNTEN, UND ZWAR ABSICHTLICH.
Filipe zu den beiden Gruppen darueber: "das sind private
informationen von meiner community." Dasselbe gilt hier, nur
noch deutlicher: Im Treff stehen Namen und Saetze von Leuten,
die nichts mit der Agentur zu tun haben.
Ohne `gruppeNach` haengt eine Gruppe hinten an -- genau richtig.
Wer sich den Bildschirm teilt oder jemandem ueber die Schulter
schauen laesst, hat sie dann nicht als Erstes im Blick. */
...TREFF_BEREICHE,
TREFF_MODERATION_KACHEL,
],
};
@@ -2086,6 +2273,11 @@ function umstellungen(d) {
["spicy", "admin", "manager", "scout", "creator", "modi"], jetztStempel);
checkListeErweitern(d, "personen", "rolle", "hand",
["spicy", "admin", "manager", "scout", "creator", "modi", "hand"], jetztStempel);
/* DIE COMMUNITY (11.09.2026). Dieselbe Umstellung wie die drei
darueber: Sicherung vorher, Neubau, Zaehlung innerhalb der
Transaktion, Indizes zurueck. */
checkListeErweitern(d, "personen", "rolle", "gast",
["spicy", "admin", "manager", "scout", "creator", "modi", "hand", "gast"], jetztStempel);
/* DER BEREICH FUER DAS IDEEN-BOARD (10.09.2026, Kapitel 5.6).
@@ -2118,6 +2310,18 @@ function umstellungen(d) {
checkListeErweitern(d, "eintraege", "bereich", "talente",
["live", "content", "technik", "community", "schutz", "agentur", "ideen",
"angebote", "rueckmeldung", "entwicklung", "talente"], jetztStempel);
/* DIE SIEBEN BRETTER DES TREFFS (11.09.2026).
EINE Umstellung fuer alle sieben, nicht sieben einzelne. Jeder
Aufruf baut die Tabelle neu auf -- siebenmal hintereinander waeren
sieben Sicherungen, sieben Neubauten und sieben Gelegenheiten, dass
etwas schiefgeht. Der Marker ist "treff"; ist der schon in der
CHECK-Liste, passiert gar nichts. */
checkListeErweitern(d, "eintraege", "bereich", "treff",
["live", "content", "technik", "community", "schutz", "agentur", "ideen",
"angebote", "rueckmeldung", "entwicklung", "talente",
"anschlag", "ansteht", "treff", "wunsch", "highlight", "regeln", "mitmachen"],
jetztStempel);
checkListeErweitern(d, "eintraege", "status", "angenommen",
["offen", "erledigt", "angenommen", "abgelehnt"], jetztStempel);
@@ -2644,6 +2848,10 @@ export function db() {
geaendert TEXT
);
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);
/* Die Tabellen des Treffs folgen weiter unten, gleich nachdem
dieser Bauplan durch ist -- sie haengen an eintraege und
personen und koennen deshalb nicht davor stehen. Ihr SQL steht
in treff-tabellen.js, samt Begruendung je Tabelle. */
/* Wer darf welche Datei sehen. Bisher hatte eine Datei genau
EINEN Bereich (dateien.creator_id) -- damit liess sich eine
@@ -2889,6 +3097,11 @@ export function db() {
CREATE INDEX IF NOT EXISTS idx_versuche_ip ON versuche (ip, zeitpunkt);
CREATE INDEX IF NOT EXISTS idx_sitzungen_gueltig ON sitzungen (gueltig_bis);
`);
/* Die Tabellen des Treffs. Eigene Datei, weil workspace-treff.js
aus DIESER Datei importiert -- ein Importkreis waere die Folge,
und ueber einen Kreis kommen Konstanten als undefined an, ohne
dass irgendwo ein Fehler erscheint. */
treffTabellen(d);
umstellungen(d);
_db = d;
return d;
@@ -3074,7 +3287,34 @@ export function sitzungLesen(req) {
nicht, was er darf -- genau wie die Sicht eines anderen
(sichtPerson) das auch nicht tut. Wer hier etwas anderes einbaut,
macht aus einer Anzeigefrage eine Rechtefrage. */
const haus = istCrewAdresse(req.get?.("host")) ? "crew" : "agentur";
const haus = istCrewAdresse(req.get?.("host")) ? "crew"
: istTreffAdresse(req.get?.("host")) ? "treff" : "agentur";
/* DER AUSSCHLUSS WIRKT HIER -- an derselben Stelle wie alles andere
(11.09.2026).
Ein Ausschluss aus dem Treff ist nicht "darf nichts mehr
schreiben", sondern "ist nicht mehr da". Stuende die Pruefung an
den Schreibwegen, koennte ein Ausgeschlossener weiter mitlesen --
und genau das ist bei Belaestigung der Teil, der weh tut.
AN DIESER STELLE UND NICHT NUR BEIM ANMELDEN: Wer schon
angemeldet ist, hat ein Sitzungsplaetzchen, das Tage gilt. Eine
Pruefung beim Anmelden haette ihn bis zum Ablauf drin gelassen --
also ausgerechnet in den Stunden, in denen die Entscheidung
gefallen ist.
NUR FUER AUSSENROLLEN: Eine Massnahme gegen jemanden aus dem Team
gibt es nicht (siehe /api/treff/massnahme), und ein Fehler hier
duerfte niemals das Team aussperren. Die Frage wird deshalb gar
nicht erst gestellt.
WIRFT DIE FRAGE, greift der catch weiter unten und die Antwort
ist "null" -- also "nicht angemeldet". Das ist die sichere
Richtung: Wer nicht nachsehen kann, laesst niemanden herein. */
if (AUSSEN_ROLLEN.has(reihe.rolle) && treffSperreLesen(db(), reihe.id)?.art === "ausschluss") {
return null;
}
return { id: reihe.id, name: reihe.name, rolle: reihe.rolle, haus };
} catch { return null; }
@@ -3131,7 +3371,15 @@ export const workspaceRouter = express.Router();
/workspace/ -- und von dort wieder hierher. Der Browser bricht das
nach ein paar Runden mit "zu viele Weiterleitungen" ab, und man
sucht den Fehler ueberall, nur nicht in dieser Zeile. */
const OFFEN = new Set(["/workspace/index.html", "/workspace/crew-index.html"]);
/* SIE WIRD NICHT ZWEIMAL GESCHRIEBEN (11.09.2026).
Bis heute stand hier dieselbe Liste ein zweites Mal, von Hand. Sie
stimmte -- weil sie zwei Eintraege hatte und beide am selben Tag
entstanden. Beim dritten (der Wand des Treffs) waere genau das
passiert, wogegen die Rechtetafel gebaut wurde: Wer nur hier
nachtraegt, bekommt eine Wand, die die Pruefung als Luecke meldet;
wer nur dort nachtraegt, bekommt eine Endlosweiterleitung. Beides
faellt erst im Browser auf. Jetzt gibt es die Liste einmal. */
const OFFEN = new Set(OHNE_ANMELDUNG);
/** Der Pfad, wie ihn die Schranke ansehen muss.
*
@@ -3241,6 +3489,58 @@ function stillerZugang(code) {
}
}
/* =====================================================================
WER SIEHT WAS — die Tafel, wie sie wirklich ist (11.09.2026)
=====================================================================
Filipe: *"egal welche Rolle oder Person hinzugefuegt wird, soll immer
nur das sehen, was ich erlaube."*
Dazu gehoert, dass er es NACHSEHEN kann. Eine Regel, die man nur
glauben kann, ist eine Zusage; eine, die man ansehen kann, ist eine
Auskunft.
DIESE ANTWORT WIRD GERECHNET, NICHT GEPFLEGT. Sie kommt aus
derselben Tafel, aus der auch die Schranke ihre Entscheidung holt
(rechte.js). Eine von Hand geschriebene Uebersicht waere eine zweite
Wahrheit — und ausgerechnet die, der man glaubt, weil sie wie eine
Auskunft aussieht. Sie wuerde altern, und man wuerde es ihr nicht
ansehen.
NUR DogFather. Nicht aus Geheimniskraemerei, sondern weil diese
Antwort eine vollstaendige Karte des Hauses ist: jede Seite, jede
Rolle, und wo die Luecken sind. Wer sie hat, weiss, wo er es
versuchen muesste.
===================================================================== */
workspaceRouter.get("/workspace/api/rechte/tafel", (req, res) => {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
if (person.rolle !== "admin") return res.status(404).json({ fehler: "nicht_gefunden" });
const seiten = Object.entries(SEITEN).map(([pfad, rollen]) => ({
pfad,
datei: pfad.replace("/workspace/", ""),
rollen: [...rollen],
})).sort((a, b) => a.datei.localeCompare(b.datei, "de"));
res.json({
rollen: ALLE_ROLLEN.map((r) => ({
schluessel: r,
name: ROLLEN_NAME[r] ?? r,
/* Wie viele der Seiten diese Rolle oeffnen darf. Die Zahl ist
die eigentliche Antwort auf "sieht die neue Rolle zu viel?" */
seiten: seiten.filter((s) => s.rollen.includes(r)).length,
})),
seiten,
ohne_anmeldung: [...OHNE_ANMELDUNG],
/* Die Zahl, die den Unterschied macht: Wie viele Seiten stehen
fuer NIEMANDEN offen, ohne dass jemand das entschieden haette?
Seit dem 11.09. ist sie immer 0 — es gibt keinen Zustand mehr,
der "alle angemeldeten" bedeutet, ohne dass es dasteht. */
ohne_eintrag: 0,
});
});
workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
const ip = echteIp(req);
try {
@@ -3313,9 +3613,25 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
zwar auch fuer die neue Adresse, denn die Sperre haengt an der
IP, nicht am Hostnamen. Das ist der Preis der Stille; er faellt
nur an, wenn jemand die alte Adresse mehrfach probiert. */
const still = codeBrauchbar && !istOhneModiAdresse(req.get("host"))
? stillerZugang(code)
: null;
/* UND AUCH NICHT AUF DER WAND DES TREFFS (11.09.2026).
Hier stand `!istOhneModiAdresse(...)` -- also "ueberall ausser auf
den alten Adressen". Als dritte Wand dazukam, hiess das
stillschweigend auch: dort. Ein Modi kam damit ueber den
verborgenen Weg in den Treff herein, obwohl die Kachelliste dort
nur `gast` kennt und die Adressregel ihn abweist.
Gefunden hat das nicht das Lesen, sondern eine Zeile in
pruef-treff, die das GEGENTEIL behauptete und rot wurde:
"auf treff. kommt sonst niemand herein -- auch DogFather nicht
(401/200)". DogFather wurde abgewiesen, der Modi nicht.
Die Lehre ist dieselbe wie bei `null` in der Rechtetafel: Eine
Bedingung, die durch AUSSCHLIESSEN formuliert ist, nimmt jede
neue Moeglichkeit automatisch mit auf. Jetzt steht dort, WO der
Weg gilt, statt wo er nicht gilt. */
const stilleWand = istCrewAdresse(req.get("host")) || istPruefAdresse(req.get("host"));
const still = codeBrauchbar && stilleWand ? stillerZugang(code) : null;
if (still) {
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), still.id);
@@ -3360,7 +3676,9 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
Wand gerade dran ist. Ein Manager, der crew. erraet und seinen
Code eintippt, faellt dadurch in genau dieselbe Antwort wie
jemand mit einer erfundenen Rolle. */
const kachelSatz = istCrewAdresse(req.get("host")) ? CREW_KACHEL : ROLLEN_KACHEL;
const kachelSatz = istTreffAdresse(req.get("host")) ? TREFF_KACHEL
: istCrewAdresse(req.get("host")) ? CREW_KACHEL
: ROLLEN_KACHEL;
if (!kachelSatz.has(rolle) || !codeBrauchbar) {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
@@ -3417,6 +3735,33 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
return res.status(401).json({ fehler: "ungueltig" });
}
/* AUSGESCHLOSSEN HEISST: DER CODE STIMMT NICHT MEHR (11.09.2026).
sitzungLesen() weist eine ausgeschlossene Person ohnehin ab --
jede Anfrage bekommt 401. Ohne diese Zeile hier waere die
Anmeldung aber TROTZDEM gelungen: Plaetzchen gesetzt, "willkommen"
geantwortet, Weiterleitung auf die Startseite -- und dort dann
eine Seite, auf der nichts geht. Das sieht nicht aus wie eine
Entscheidung, sondern wie ein kaputtes Programm, und man
probiert es zehnmal.
DIESELBE ANTWORT WIE BEI EINEM FALSCHEN CODE, und zwar
wortgleich: Wer ausgeschlossen wurde, hat den Grund bereits
bekommen (die Massnahme verlangt einen). Die Anmeldeseite muss
ihn nicht wiederholen -- und sie darf niemandem, der einen
fremden Code probiert, verraten, dass es diesen Zugang gibt.
NUR FUER AUSSENROLLEN. Gegen jemanden aus dem Team gibt es diese
Massnahme gar nicht (siehe /api/treff/massnahme); die Frage
wird deshalb nicht gestellt. Ein Fehler hier duerfte niemals
das Team aussperren. */
if (AUSSEN_ROLLEN.has(gefunden.rolle)
&& treffSperreLesen(db(), gefunden.id)?.art === "ausschluss") {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
protokolliere("anmeldung_gesperrt_treff", { personId: gefunden.id, rolle, ip });
return res.status(401).json({ fehler: "ungueltig" });
}
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), gefunden.id);
sitzungSetzen(res, gefunden, req);
@@ -4593,10 +4938,43 @@ const nurCreatorIds = (ids) => {
} catch { return ids; }
};
export const sichtbareCreatorIds = (person) => nurCreatorIds(nurHaus(ohneVerborgene(sichtbareCreatorIdsRoh(person), person), person));
export const sichtbarePersonenIds = (person) => nurHaus(ohneVerborgene(sichtbarePersonenIdsRoh(person), person), person);
export const betreuerIds = (person) => nurHaus(ohneVerborgene(betreuerIdsRoh(person), person), person);
export const einladbareIds = (person) => nurHaus(ohneVerborgene(einladbareIdsRoh(person), person), person);
/* WER VON AUSSEN KOMMT, SIEHT NIEMANDEN (11.09.2026).
Filipe: "egal welche rolle oder person hinzugefuegt wird soll immer
nur das sehen wass ich erlaube."
GEFUNDEN, NICHT UEBERLEGT. Eine Zeile in pruef-treff behauptete
"Personen: nichts" und wurde rot. Nachgemessen bekam ein Gast:
{"personen":[{"id":1,"name":"Filipe","rolle":"admin", ...},
{"id":5,"name":"Kessi","rolle":"gast", ...}]}
Also DogFathers Namen und seine Rolle. Nicht dramatisch -- ein
Streamer ist kein Geheimnis -- aber es war eine Auskunft, die
niemand erlaubt hatte, und genau das ist der Punkt. Die Liste kam
zustande, weil `sichtbarePersonenIdsRoh` fuer jede Rolle DogFather
mitgibt (er ist das Gegenueber von allem) und die vier Umhuellungen
danach nichts daran aendern.
`nurEigene` steht deshalb GANZ AUSSEN -- nach allen anderen. Wer
innen ansetzt, muss darauf vertrauen, dass keine spaetere Huelle
wieder etwas hinzufuegt; aussen gilt es unabhaengig davon, was
innen passiert. Und es gilt fuer alle vier Listen gleich, statt
viermal einzeln.
`null` heisst bei diesen Funktionen "alle" -- deshalb wird es hier
ausdruecklich zu einer Liste mit genau einem Eintrag. Ein `null`
durchzureichen hiesse fuer eine Aussenrolle das Gegenteil von dem,
was hier steht. */
function nurEigene(ids, person) {
if (!AUSSEN_ROLLEN.has(person?.rolle)) return ids;
return person?.id ? [person.id] : [];
}
export const sichtbareCreatorIds = (person) => nurEigene(nurCreatorIds(nurHaus(ohneVerborgene(sichtbareCreatorIdsRoh(person), person), person)), person);
export const sichtbarePersonenIds = (person) => nurEigene(nurHaus(ohneVerborgene(sichtbarePersonenIdsRoh(person), person), person), person);
export const betreuerIds = (person) => nurEigene(nurHaus(ohneVerborgene(betreuerIdsRoh(person), person), person), person);
export const einladbareIds = (person) => nurEigene(nurHaus(ohneVerborgene(einladbareIdsRoh(person), person), person), person);
/* SICH SELBST NICHT -- UND ZWAR AUCH DANN NICHT, WENN DIE LISTE
AUSGESCHRIEBEN WIRD (10.09.2026).
+42 -13
View File
@@ -61,6 +61,35 @@ const SZENE = join(QUELLE, "gate-spicy-dogfather.jpeg");
const DOGI = join(QUELLE, "dogfather-husky.png");
const ZIEL = process.env.BUEHNE_ZIEL || join(WURZEL, "workspace", "assets", "img", "buehne");
/* ZWEI TUEREN, EIN WERKZEUG (11.09.2026).
Der Treff bekommt seine eigene Zugangswand, und sie braucht ihr
eigenes Motiv -- nicht weil sie anders aussehen soll, sondern weil
in diesem Bild "TEAM DOGI" EINGEBRANNT ist. Die Community traegt
diesen Namen nicht; auf ihrer Tuer stuende sonst der Name eines
Bereichs, zu dem sie nicht gehoert.
WARUM KEINE ZWEITE DATEI: Eine Kopie dieser 520 Zeilen haette die
drei Gegenproben mitkopiert -- und beim naechsten Nachbessern waere
genau eine der beiden verbessert worden. Hier aendern sich zwei
Dinge, also sind es zwei Werte und kein zweites Werkzeug.
DIE FARBE BLEIBT DIESELBE, und das ist eine Entscheidung, keine
Bequemlichkeit: Gemessen liegt der naechstbeste eigene Farbton
(Tannengruen) 60 Lab-Einheiten vom Marineblau entfernt -- er waere
also klar unterscheidbar gewesen. Nur haette der Treff damit wie ein
DRITTES Haus ausgesehen, neben Spicy Media und Team Dogi. Er ist
aber kein drittes Haus, sondern eine zweite Tuer in demselben:
"lila und babyblau ueberall" (Filipe, 10.09.2026). Unterschieden
werden die Tueren durch das WORT darauf, nicht durch die Farbe --
so, wie man es in einem Gebaeude auch macht.
node tools/crew-buehne.mjs
WAND_TEXT="DER TREFF" WAND_PRAEFIX=treff-gate node tools/crew-buehne.mjs
*/
const WAND_TEXT = process.env.WAND_TEXT || "TEAM DOGI";
const WAND_PRAEFIX = process.env.WAND_PRAEFIX || "crew-gate";
for (const q of [SZENE, DOGI]) {
if (!existsSync(q)) { console.error(`Fehlt: ${q}`); process.exit(1); }
}
@@ -295,14 +324,14 @@ async function bauen({ breite, ausschnitt = null, guete = 0.92, dunkel = 0.08,
g.restore();
}
/* ---- 5. "TEAM DOGI" ------------------------------------------- */
/* ---- 5. Der Name auf der Tuer --------------------------------- */
const schriftHoehe = Math.round(zh * 0.072);
g.save();
g.textAlign = "center";
g.textBaseline = "alphabetic";
g.font = `800 ${schriftHoehe}px "Segoe UI", system-ui, sans-serif`;
/* 0,955 lag halb unter dem Bildrand -- im Bildschirmfoto war von
"TEAM DOGI" die untere Haelfte abgeschnitten. Jetzt so weit
dem Namen die untere Haelfte abgeschnitten. Jetzt so weit
herauf, dass die Unterlaengen noch auf dem Wasser stehen. */
const ty = zh * 0.915;
/* Erst ein kalter Schein darunter, dann die Schrift in Metall --
@@ -310,7 +339,7 @@ async function bauen({ breite, ausschnitt = null, guete = 0.92, dunkel = 0.08,
g.shadowColor = "rgba(70,150,220,0.85)";
g.shadowBlur = schriftHoehe * 0.9;
g.fillStyle = "rgba(10,25,45,0.9)";
g.fillText("TEAM DOGI", mx, ty);
g.fillText(a.text, mx, ty);
g.shadowBlur = 0;
const metall = g.createLinearGradient(0, ty - schriftHoehe, 0, ty);
metall.addColorStop(0, "#ffffff");
@@ -318,7 +347,7 @@ async function bauen({ breite, ausschnitt = null, guete = 0.92, dunkel = 0.08,
metall.addColorStop(0.55, "#7ea6c6");
metall.addColorStop(1, "#e8f2fa");
g.fillStyle = metall;
g.fillText("TEAM DOGI", mx, ty);
g.fillText(a.text, mx, ty);
g.restore();
/* ---- 6. Vignette und ein Hauch Dunkel ------------------------- */
@@ -364,7 +393,7 @@ async function bauen({ breite, ausschnitt = null, guete = 0.92, dunkel = 0.08,
dogiKasten,
};
}, { szene: szeneDaten, dogi: dogiDaten, breite, ausschnitt, guete, dunkel, vignette,
ohneDogi, tafel: TAFEL });
ohneDogi, tafel: TAFEL, text: WAND_TEXT });
return { ...roh, puffer: Buffer.from(roh.bild.split(",")[1], "base64") };
}
@@ -439,17 +468,17 @@ let gebaut = 0;
console.log("\n--- Quer ---");
for (const b of [960, 1280, 1600, 1920, 2560]) {
const r = await bauen({ breite: b, guete: 0.93, dunkel: 0.08, vignette: 0.26 });
writeFileSync(join(ZIEL, `crew-gate-${b}.webp`), r.puffer);
writeFileSync(join(ZIEL, `${WAND_PRAEFIX}-${b}.webp`), r.puffer);
gebaut++;
if (r.rotAnteil > 0.01) maengel.push(
`crew-gate-${b}: noch ${(r.rotAnteil * 100).toFixed(1)} % rote Bildpunkte -- Umfaerbung hat nicht gegriffen`);
`${WAND_PRAEFIX}-${b}: noch ${(r.rotAnteil * 100).toFixed(1)} % rote Bildpunkte -- Umfaerbung hat nicht gegriffen`);
if (r.tafelMittel > 46) maengel.push(
`crew-gate-${b}: die Tafel ist zu hell (${r.tafelMittel.toFixed(1)}) -- die Karte braucht einen ruhigen Grund`);
`${WAND_PRAEFIX}-${b}: die Tafel ist zu hell (${r.tafelMittel.toFixed(1)}) -- die Karte braucht einen ruhigen Grund`);
if (r.tafelStreuung > 34) maengel.push(
`crew-gate-${b}: in der Tafel ist zu viel Muster (Streuung ${r.tafelStreuung.toFixed(1)})`);
`${WAND_PRAEFIX}-${b}: in der Tafel ist zu viel Muster (Streuung ${r.tafelStreuung.toFixed(1)})`);
console.log(` crew-gate-${b}.webp`.padEnd(30)
console.log(` ${WAND_PRAEFIX}-${b}.webp`.padEnd(30)
+ `${(r.puffer.length / 1024).toFixed(0)} KB · ${r.breite}x${r.hoehe}`
+ ` · Rot ${(r.rotAnteil * 100).toFixed(2)} %`
+ ` · Tafel ${r.tafelMittel.toFixed(1)}/${r.tafelStreuung.toFixed(1)}`);
@@ -471,11 +500,11 @@ for (const b of [440, 580, 900]) {
ausschnitt: [0.13, 0.02, 0.32, 0.98], guete: 0.88,
dunkel: 0.22, vignette: 0.30,
});
writeFileSync(join(ZIEL, `crew-gate-hoch-${b}.webp`), r.puffer);
writeFileSync(join(ZIEL, `${WAND_PRAEFIX}-hoch-${b}.webp`), r.puffer);
gebaut++;
if (r.rotAnteil > 0.01) maengel.push(
`crew-gate-hoch-${b}: noch ${(r.rotAnteil * 100).toFixed(1)} % rote Bildpunkte`);
console.log(` crew-gate-hoch-${b}.webp`.padEnd(30)
`${WAND_PRAEFIX}-hoch-${b}: noch ${(r.rotAnteil * 100).toFixed(1)} % rote Bildpunkte`);
console.log(` ${WAND_PRAEFIX}-hoch-${b}.webp`.padEnd(30)
+ `${(r.puffer.length / 1024).toFixed(0)} KB · ${r.breite}x${r.hoehe}`
+ ` · Rot ${(r.rotAnteil * 100).toFixed(2)} %`);
}
+25 -8
View File
@@ -88,15 +88,32 @@ for (const name of readdirSync(ORDNER)) {
Derselbe Stempel wie in den Seiten -- `pruef-zwischenspeicher`
verlangt im ganzen Haus genau eine Zahl und wuerde eine zweite sofort
melden. */
/* ES SIND ZWEI MANIFESTE (10.09.2026). Seit die Modi-App unter ihrer
eigenen Adresse laeuft, gibt es neben app.webmanifest auch
crew.webmanifest -- dasselbe Haus, anderer Name, andere Symbole.
/* DIE MANIFESTE WERDEN GEFUNDEN, NICHT AUFGEZAEHLT (11.09.2026).
ALS LISTE UND NICHT ALS ZWEITER BLOCK: Ein kopierter Block waere beim
dritten Manifest ein dritter, und irgendwann bleibt einer stehen. Ein
fehlendes Manifest ist hier ausserdem ein FEHLER und kein
Ueberspringen -- sonst faellt das Ausbleiben nicht auf. */
const MANIFESTE = ["app.webmanifest", "crew.webmanifest"];
Hier stand eine Liste mit zwei Namen und daneben der Satz, warum eine
Liste besser sei als ein kopierter Block. Beim DRITTEN Manifest
(treff.webmanifest, fuer die Community) hat sich das genau so
bewahrheitet, wie es vorausgesagt war -- nur eine Stufe weiter oben:
Der kopierte Block kam nicht, aber der neue Name fehlte in der Liste.
Das Manifest bekam keinen Stempel, und die Symbole der neuen App
waeren beim naechsten Wechsel bis zu vier Stunden lang die alten
geblieben. Kein Fehler, keine Warnung -- nur eine App mit dem
falschen Zeichen.
Jetzt wird gesucht. Wer eine vierte Tuer baut, muss hier nichts mehr
nachtragen; das Werkzeug findet sie.
NULL MANIFESTE SIND EIN FEHLER und kein Ueberspringen: Faende die
Suche nichts (falscher Ordner, umbenannte Endung), liefe der Stempel
gruen durch und haette nichts getan -- der gruene Haken ueber der
Null, gegen den in diesem Haus die halbe Dokumentation steht. */
const MANIFESTE = readdirSync(ORDNER)
.filter((n) => n.endsWith(".webmanifest"))
.sort();
if (!MANIFESTE.length) {
console.error(`FEHLER: kein einziges Manifest in ${ORDNER} gefunden.`);
process.exit(1);
}
const MUSTER_MANIFEST =
/("src":\s*")(\/assets\/img\/app-symbole\/[A-Za-z0-9._-]+)(?:\?v=\d+)?(")/g;
for (const name of MANIFESTE) {
+79
View File
@@ -0,0 +1,79 @@
/* =====================================================================
zeilenenden.mjs — gemischte Zeilenenden nachziehen (11.09.2026)
AUFRUF: node tools/zeilenenden.mjs <datei> [<datei> ...]
---------------------------------------------------------------------
WOZU
Die Dateien dieses Hauses sind gemischt: manche mit CRLF (Windows),
manche mit LF. Wer eine davon mit einem Skript bearbeitet und dabei
Zeilen mit "\n" einfügt, bekommt eine Datei mit ZWEI Sorten
Zeilenenden.
Das ist kein Schönheitsfehler. Was dabei herauskommt:
* Die Datei läuft weiterhin -- JavaScript, CSS und HTML ist das
Zeilenende gleichgültig. Es fällt also nicht auf.
* `git diff` zeigt dann Zeilen als geändert, die niemand angefasst
hat -- und in einem Änderungssatz, in dem 40 echte Zeilen stehen,
gehen die echten zwischen 400 unechten unter. Genau dort wird
eine Prüfung vor dem Ausliefern wertlos: Man sieht nicht mehr,
was man verändert hat.
* Beim nächsten Bearbeiten derselben Datei trifft ein Anker mit
"\n" die Stellen mit "\r\n" nicht mehr -- der Patch meldet
"Anker nicht gefunden", und man sucht den Fehler im Anker.
---------------------------------------------------------------------
WAS ES TUT UND WAS AUSDRÜCKLICH NICHT
Es macht NUR gemischte Dateien einheitlich, und zwar auf das
Zeilenende, das in dieser Datei ohnehin überwiegt. Es wandelt
niemals eine saubere LF-Datei nach CRLF oder umgekehrt -- das wäre
eine Änderung an jeder Zeile und damit genau der Schaden, den es
verhindern soll.
Die drei Ausgänge, wie überall in diesem Haus:
* rein CRLF oder rein LF -> "unveraendert" (nichts zu tun)
* gemischt -> nachgezogen, mit Zahl
* Datei nicht lesbar -> Fehler mit Namen, Rückgabewert 1
(und nicht stillschweigend übersprungen)
===================================================================== */
import fs from "node:fs";
const dateien = process.argv.slice(2);
if (!dateien.length) {
console.error("Aufruf: node tools/zeilenenden.mjs <datei> [<datei> ...]");
process.exit(1);
}
let fehler = 0;
for (const p of dateien) {
let s;
try {
s = fs.readFileSync(p, "utf8");
} catch (f) {
console.error(`${p.padEnd(38)} NICHT LESBAR: ${f?.message}`);
fehler++;
continue;
}
const crlf = (s.match(/\r\n/g) || []).length;
const n = (s.match(/\n/g) || []).length;
if (crlf === 0 || crlf === n) {
console.log(p.padEnd(38), "unveraendert");
continue;
}
/* Die Mehrheit gewinnt. Bei einer Datei, die zu 95 % CRLF ist und in
die zwanzig LF-Zeilen eingefügt wurden, ist CRLF das Richtige --
und umgekehrt genauso. Der Blick auf die Mehrheit macht das
Werkzeug richtungsunabhängig, statt CRLF zur Vorgabe zu erklären. */
const nachCrlf = crlf * 2 >= n;
s = nachCrlf
? s.replace(/(?<!\r)\n/g, "\r\n")
: s.replace(/\r\n/g, "\n");
fs.writeFileSync(p, s);
console.log(p.padEnd(38),
`${nachCrlf ? n - crlf : crlf} Zeilen auf ${nachCrlf ? "CRLF" : "LF"} nachgezogen`);
}
process.exit(fehler ? 1 : 0);
+4 -4
View File
@@ -13,25 +13,25 @@
"theme_color": "#06090f",
"icons": [
{
"src": "/assets/img/app-symbole/workspace-192.png?v=202609111617",
"src": "/assets/img/app-symbole/workspace-192.png?v=202609111736",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/app-symbole/workspace-512.png?v=202609111617",
"src": "/assets/img/app-symbole/workspace-512.png?v=202609111736",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111617",
"src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111736",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111617",
"src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111736",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
+62
View File
@@ -46,6 +46,68 @@
}
.eintrag-knoepfe { display: flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
/* =====================================================================
DER TREFF (11.09.2026)
Drei Zusaetze, und nur auf den sieben Brettern der Community.
SIE STEHEN IN EINER EIGENEN ZEILE UNTER DEM FUSS und nicht darin.
Der Fuss ist eine Auskunft (Datum, wer, Stand); hier stehen Dinge,
die man TUT. Zusammen in einer Reihe waere "von Frida" so breit wie
"Will ich auch" -- und auf einem Handy laege beides ineinander.
===================================================================== */
.treff-knoepfe {
display: flex; gap: 7px; flex-wrap: wrap; align-items: center;
margin-top: 10px; padding-top: 10px;
border-top: 1px solid var(--rand);
}
/* Der Rollenname am Beitrag. Leise, aber nicht versteckt: Er soll
beim Lesen auffallen, ohne den Beitrag zu ueberschreiben. Deshalb
dieselbe Groesse wie der Fuss und nur ein kraeftigerer Ton. */
.treff-rolle {
color: var(--text-leise);
font-weight: 600;
letter-spacing: .01em;
}
/* "Will ich auch". Der einzige Knopf auf diesen Brettern, der keine
Verwaltung ist, sondern eine Meinung -- er darf deshalb als
einziger einen Ton tragen.
GEDRUECKT WIRD ER RUHIGER, NICHT LAUTER. Der naheliegende Entwurf
waere ein kraeftiges Leuchten fuer "ich bin dabei". Nur sieht man
auf einer Wunschliste meist ZEHN Karten gleichzeitig, und wer bei
der Haelfte mitgestimmt hat, saehe zehn leuchtende Flaechen
untereinander. Das ist genau das, was diese Seite nicht sein soll
(Hausregel: augenschonend). Der gedrueckte Zustand bekommt deshalb
eine gefuellte Flaeche in gedecktem Ton, der ungedrueckte bleibt
ein Umriss -- unterscheidbar, ohne zu blenden. */
.treff-will {
font: inherit; font-size: .8rem; font-weight: 600;
padding: 6px 12px; border-radius: 999px; cursor: pointer;
color: var(--text-leise);
background: transparent;
border: 1px solid var(--rand-hell);
transition: background .16s ease, color .16s ease, border-color .16s ease;
}
.treff-will:hover:not(:disabled) {
color: var(--text);
border-color: color-mix(in srgb, var(--akzent) 45%, transparent);
}
.treff-will[aria-pressed="true"] {
color: var(--text);
background: color-mix(in srgb, var(--akzent) 14%, transparent);
border-color: color-mix(in srgb, var(--akzent) 42%, transparent);
}
.treff-will:disabled { opacity: .55; cursor: default; }
/* Dieselbe Ruecksicht wie ueberall: Wer Bewegung abbestellt hat,
bekommt keine. */
@media (prefers-reduced-motion: reduce) {
.treff-will { transition: none; }
}
.filter { display: flex; gap: 8px; margin: 0 0 20px; flex-wrap: wrap; }
.filter .schritt[aria-pressed="true"] {
/* Gedrueckt = der "voll"-Auftritt des Knopf-Systems. Vorher stand
+62
View File
@@ -1030,6 +1030,68 @@ body.start > main { position: relative; z-index: 1; }
[data-ton="26"] { --ton: #7fd4a8; } /* Entwicklung */
[data-ton="27"] { --ton: #8f7bd6; } /* Talente */
/* =====================================================================
DIE SIEBEN TOENE DES TREFFS (11.09.2026)
Bei 27 Kacheln war der Farbkreis voll -- das stand seit dem 11.09. als
Notiz im Code. Sieben weitere von Hand zu waehlen haette Toene
ergeben, die sich nur noch der Messung nach unterscheiden.
Sie sind deshalb GERECHNET: ein Gitter aus Farbton, Saettigung und
Helligkeit, umgerechnet in den Lab-Raum (dort entspricht Abstand dem,
was das Auge als Unterschied sieht), und daraus gierig die sieben mit
dem groessten kleinsten Abstand zu allen 27 vorhandenen UND
zueinander.
Zwei Bereiche sind ausgeschlossen, und beide aus einem Grund:
* ROT (unter 22 und ueber 338 Grad) gehoert Spicy Media. Eine
Community-Kachel in deren Farbe waere ein falsches Signal.
* NEON (Buntheit ueber 62) verstoesst gegen die Hausregel
"augenschonend". Dasselbe gilt nach unten: unter 24 wird es grau,
und grau liest sich als "abgeschaltet".
Ergebnis: schwaechster Abstand 24,9 -- zum Vergleich lag eine von
Hand gewaehlte Farbfamilie im selben Blau bei 12,6. Die Rechnung war
also nicht Genauigkeitsspielerei, sondern der Unterschied zwischen
unterscheidbar und nicht. */
[data-ton="28"] { --ton: #8e784f; } /* Regeln & Hilfe */
[data-ton="29"] { --ton: #8e5b77; } /* Highlights */
[data-ton="30"] { --ton: #b2bc78; } /* Wunschliste */
[data-ton="31"] { --ton: #656a9e; } /* Was ansteht */
[data-ton="32"] { --ton: #668e6e; } /* Mitmachen */
[data-ton="33"] { --ton: #cc9451; } /* Anschlagbrett */
[data-ton="34"] { --ton: #cc92c6; } /* Der Treff */
/* DER 35. TON (11.09.2026) — für „Wer sieht was".
GEMESSEN, NICHT GEWÄHLT. Gesucht war der Punkt im Farbraum, der von
ALLEN 34 vorhandenen Tönen am weitesten entfernt liegt — Abstand in
Lab, nicht in HSL, weil zwei Töne mit gleichem HSL-Abstand
unterschiedlich verschieden AUSSEHEN können.
Zwei Bereiche waren dabei ausgeschlossen, und beide aus einem Grund
und nicht aus Geschmack:
* Rot (Farbton unter 22° / über 338°) gehört Spicy Media.
* Neon (Sättigung über 52 %) verstößt gegen die Hausregel
„augenschonend" — die gilt hier wie überall.
Ergebnis: #2f2f7f, Abstand 31,4 zum nächsten Nachbarn (#656a9e, „Was
ansteht"). Zum Vergleich: Das schwächste Paar unter den vorhandenen
34 liegt bei 24,9. Der neue Ton ist also unterscheidbarer als das,
was ohnehin nebeneinander steht — und die beiden nächsten Nachbarn
stehen in verschiedenen Gruppen, treffen sich auf dem Bildschirm
also gar nicht.
WARUM ÜBERHAUPT EIN NEUER: Die 35. Kachel hätte sich sonst einen Ton
mit einer anderen geteilt. Zwei Dinge in einer Farbe heißt, dass die
Farbe nichts mehr sagt — und pruef-start-ansicht zählt genau das
nach ("34 Farben auf 35 Kacheln" war ihre Meldung). */
[data-ton="35"] { --ton: #2f2f7f; } /* Wer sieht was */
/* Der 36., nach derselben Rechnung: #7c2771, Abstand 29,7 zu den beiden
nächsten (#8e5b77 „Highlights", #2f2f7f „Wer sieht was"). Auch das
liegt über dem schwächsten Paar der vorhandenen Töne (24,9). */
[data-ton="36"] { --ton: #7c2771; } /* Meldungen & Maßnahmen */
/* Rückfallwert für alles, was einen Ton braucht, aber keine Nummer hat.
Steht bewusst in :where() -- damit zählt die Zeile beim Wettstreit der
Regeln als NICHT vorhanden und die Nummer oben gewinnt immer. Ohne
+269
View File
@@ -0,0 +1,269 @@
/* =====================================================================
Der Treff — die beiden Textseiten (11.09.2026)
Regeln & Hilfe (für die Community) und „Wer sieht was" (für
DogFather). Beides sind LESESEITEN: langer, zusammenhängender Text
und eine Tabelle, keine Kartenwand.
---------------------------------------------------------------------
WARUM SIE KEINE MODULKARTEN SIND
Die naheliegende Wahl wäre `.eintrag-karte` oder ein neuer Eintrag in
der MODULLISTE gewesen — dann sähen die Abschnitte aus wie alles
andere im Haus: geschliffene Fase, Kantenlicht, Sternenfeld.
Dagegen sprechen zwei Dinge, und das zweite wiegt schwerer:
1. Die MODULLISTE steht siebenmal in module.css und wird von
pruef-css-klassen Zeichen für Zeichen verglichen. Eine achte Klasse
hätte an sieben Stellen dasselbe ergänzen müssen — machbar, aber
jede dieser sieben ist eine Gelegenheit für einen Unterschied.
2. Karten zerteilen. Ein Regeltext ist ein Text, den man von oben
nach unten liest; sieben Karten daraus zu machen heißt, sieben
Anfänge zu machen. Genau das ist bei Regeln der Fehler: Wer nach
der dritten Karte aufhört, hat vier Regeln nicht gelesen und
glaubt trotzdem, er kenne sie.
Deshalb hier nur so viel Trennung, dass man die Abschnitte findet —
eine Linie und Abstand, keine Kante.
===================================================================== */
.treff-abschnitt {
margin: 0 0 26px;
padding: 0 0 22px;
border-bottom: 1px solid var(--rand);
}
.treff-abschnitt:last-of-type { border-bottom: 0; }
.treff-abschnitt__titel {
margin: 0 0 10px;
font-size: 1.06rem;
font-weight: 650;
letter-spacing: .005em;
color: var(--text);
}
/* Der Lesefluss ist hier die eigentliche Aufgabe.
1,72 statt der üblichen 1,55: Auf einem Handy stehen in einer Zeile
etwa 42 Zeichen; bei engem Zeilenabstand verliert man beim Sprung an
den Zeilenanfang die Zeile. Das merkt niemand als „schlechter
Zeilenabstand" — man liest den Absatz einfach zweimal und hält den
Text für kompliziert.
Und eine Breitengrenze: Über etwa 70 Zeichen je Zeile wird derselbe
Sprung wieder schwierig, nur von der anderen Seite. Auf einem breiten
Bildschirm liefe der Regeltext sonst über 1100 Pixel. */
.treff-abschnitt__inhalt { max-width: 68ch; }
.treff-abschnitt__inhalt p {
margin: 0 0 12px;
font-size: .92rem;
line-height: 1.72;
color: var(--text-leise);
}
.treff-abschnitt__inhalt p:last-child { margin-bottom: 0; }
.treff-abschnitt__inhalt strong { color: var(--text); font-weight: 640; }
/* Die Aufzählungen. Die Zahl bzw. der Punkt steht AUSSERHALB des
Textblocks — so beginnt jede Zeile einer mehrzeiligen Regel an
derselben Stelle, und die Liste bleibt als Liste lesbar. */
.regel-liste {
margin: 0 0 12px;
padding-left: 1.35em;
font-size: .92rem;
line-height: 1.72;
color: var(--text-leise);
}
.regel-liste li { margin: 0 0 9px; padding-left: .2em; }
.regel-liste li:last-child { margin-bottom: 0; }
.regel-liste li::marker { color: var(--text-still); }
.regel-liste strong { color: var(--text); font-weight: 640; }
/* =====================================================================
„WER SIEHT WAS" — die Rechtetafel als Tabelle
Sie wird aus der echten Tafel gerechnet (rechte.js), nicht gepflegt.
Was sie leisten muss: Man soll eine LÜCKE sehen, ohne zu suchen.
===================================================================== */
.rechte-tafel { overflow-x: auto; margin: 0 0 26px; }
.rechte-tafel table {
border-collapse: collapse;
width: 100%;
font-size: .82rem;
}
/* Die Kopfzeile bleibt stehen. Bei 22 Zeilen und acht Spalten hat man
sonst nach dem halben Scrollen vergessen, welche Spalte welche Rolle
ist — und rät. Bei einer Rechteübersicht ist Raten das Gegenteil des
Zwecks. */
.rechte-tafel thead th {
position: sticky; top: 0; z-index: 1;
background: var(--flaeche);
padding: 9px 8px;
text-align: center;
font-weight: 600;
color: var(--text-leise);
border-bottom: 1px solid var(--rand-hell);
white-space: nowrap;
}
.rechte-tafel thead th:first-child { text-align: left; }
.rechte-tafel td {
padding: 7px 8px;
border-bottom: 1px solid var(--rand);
text-align: center;
color: var(--text-still);
}
.rechte-tafel td:first-child {
text-align: left;
color: var(--text-leise);
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: .79rem;
white-space: nowrap;
}
.rechte-tafel tbody tr:hover td { background: rgba(255, 255, 255, .025); }
/* JA UND NEIN SIND NICHT GRÜN UND ROT.
Das wäre die naheliegende Wahl und hier die falsche: In dieser
Tabelle ist ein „nein" nichts Schlechtes — es ist der Normalfall und
genau das, was Filipe wollte. Grün/Rot würde eine Tafel mit 140
roten Feldern erzeugen, die aussieht wie ein Schadensbericht.
Stattdessen: sichtbar gegen leise. Ein Punkt, wo es erlaubt ist,
nichts, wo nicht. Man sieht die Zeilen mit vielen Punkten sofort —
und das ist die Frage, die man an diese Tabelle hat. */
.rechte-ja {
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
background: var(--akzent);
box-shadow: 0 0 8px -2px var(--akzent);
}
.rechte-nein { color: var(--text-still); opacity: .3; }
/* Die Zählzeile über der Tabelle: „Community — 3 von 22 Seiten". Das
ist die eine Zahl, die die ganze Frage beantwortet. */
.rechte-zahlen {
display: flex; flex-wrap: wrap; gap: 8px;
margin: 0 0 18px; padding: 0; list-style: none;
}
.rechte-zahlen li {
padding: 6px 11px;
border: 1px solid var(--rand-hell);
border-radius: 999px;
font-size: .8rem;
color: var(--text-leise);
}
.rechte-zahlen li b { color: var(--text); font-weight: 650; }
@media (max-width: 620px) {
.treff-abschnitt__inhalt p, .regel-liste { font-size: .95rem; }
.rechte-tafel table { font-size: .78rem; }
}
/* =====================================================================
DER WEG ZU DEN REGELN — auf jedem Brett des Treffs
Er steht direkt unter der Unterzeile und sieht aus wie ein Link,
nicht wie ein Knopf. Das ist Absicht: Ein Knopf verspricht, dass
etwas PASSIERT; hier wechselt man nur die Seite. Wer auf jedem Brett
einen zusätzlichen Knopf sieht, zählt ihn nach zwei Tagen zum
Hintergrund.
===================================================================== */
.treff-regelweg {
display: inline-block;
margin-top: 6px;
font-size: .82rem;
color: var(--text-leise);
text-decoration: none;
border-bottom: 1px solid var(--rand-hell);
padding-bottom: 1px;
}
.treff-regelweg:hover, .treff-regelweg:focus-visible {
color: var(--text);
border-bottom-color: color-mix(in srgb, var(--akzent) 55%, transparent);
}
/* Links im Fließtext der Regelseite. Unterstrichen, weil Farbe allein
kein Unterschied ist, den jeder sieht (WCAG 1.4.1). */
.treff-abschnitt__inhalt a {
color: var(--text);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: color-mix(in srgb, var(--akzent) 55%, transparent);
}
.treff-abschnitt__inhalt a:hover {
text-decoration-color: var(--akzent);
}
/* =====================================================================
MELDUNGEN & MASSNAHMEN — ein Fall je Karte
Karten sind hier richtig, wo sie beim Regeltext falsch waren: Es sind
EINZELNE Vorgänge, die man einzeln entscheidet. Jeder Anfang ist ein
neuer Fall, und das soll man sehen.
===================================================================== */
.treff-fall {
margin: 0 0 12px;
padding: 13px 15px;
border: 1px solid var(--rand);
border-left: 2px solid color-mix(in srgb, var(--akzent) 45%, transparent);
background: rgba(255, 255, 255, .018);
}
/* Was schon entschieden ist, tritt zurück -- aber es verschwindet
nicht. Ausblenden wäre bequemer und wäre falsch: Man muss nachsehen
können, wie ein ähnlicher Fall damals entschieden wurde. */
.treff-fall[data-vorbei="ja"] {
opacity: .62;
border-left-color: var(--rand);
}
.treff-fall__titel {
margin: 0 0 8px;
font-size: .95rem;
font-weight: 640;
color: var(--text);
}
.treff-fall__zeile {
margin: 0 0 5px;
font-size: .85rem;
line-height: 1.6;
color: var(--text-leise);
display: flex;
gap: 8px;
}
.treff-fall__zeile:last-of-type { margin-bottom: 0; }
/* Das Schild steht in fester Breite links. Ohne sie beginnt jede Zeile
woanders, und man liest die Liste als Fließtext statt als Angaben --
bei fünf Zeilen je Fall ist das der Unterschied zwischen Überfliegen
und Lesenmüssen. */
.treff-fall__schild {
flex: 0 0 7.5em;
color: var(--text-still);
font-size: .78rem;
text-transform: lowercase;
padding-top: .1em;
}
.treff-fall__knoepfe {
display: flex; gap: 7px; flex-wrap: wrap;
margin-top: 11px; padding-top: 10px;
border-top: 1px solid var(--rand);
}
/* Der Link "zum Brett" sieht aus wie die Knöpfe daneben -- er tut ja
dasselbe: Er bringt einen woandershin. Ein Link, der zwischen zwei
Knöpfen anders aussieht, wird für Text gehalten. */
.treff-fall__knoepfe a.schritt { text-decoration: none; }
@media (max-width: 620px) {
/* Untereinander statt nebeneinander: Bei 7,5em Schild und 42 Zeichen
Zeilenbreite blieben für den Wert noch etwa 20 Zeichen -- ein Grund
in zwei Wörtern je Zeile. */
.treff-fall__zeile { display: block; }
.treff-fall__schild { display: block; padding: 0 0 1px; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+255 -2
View File
@@ -21,7 +21,31 @@
Notizbuch. Der Server setzt das ebenfalls durch (siehe
workspace-bereiche.js); hier geht es nur darum, ihm keine Knoepfe
hinzustellen, die ohnehin nur eine Absage bringen. */
const darfEintragen = () => ich && ich.rolle !== 'creator';
/* WER HIER ETWAS EINTRAGEN DARF.
Bis zum 11.09.2026 stand hier nur "alle außer dem Creator". Das
stimmte, solange jedes Brett jedem Angemeldeten offenstand. Mit den
sieben Brettern des Treffs stimmt es nicht mehr: Auf dem
Anschlagbrett und bei "Was ansteht" schreibt nur das Team, und wer
neu ist, darf die erste Woche überhaupt nicht schreiben.
OHNE DIESE ZEILE HÄTTE EIN MITGLIED DEN KNOPF GESEHEN und beim
Drücken eine Absage bekommen -- genau die Sorte Knopf, die in
diesem Haus als Fehler gilt: Er verspricht etwas, das der Server
ablehnt, und der Mensch davor hält sich selbst für den Fehler.
DIE ANTWORT KOMMT VOM SERVER (/api/treff/lage), nicht aus einer
zweiten Regel hier. Sonst gäbe es die Schreibregel zweimal, und die
Fassung im Browser kann man ändern. Faellt die Auskunft aus, bleibt
die Lage leer und es gilt wieder die alte Regel -- der Knopf ist
dann da und der Server sagt notfalls nein. Das ist die richtige
Richtung: Ein Knopf zu viel ist ärgerlich, ein fehlender Riegel
wäre ein Loch. */
const darfEintragen = () => {
if (!ich || ich.rolle === 'creator') return false;
if (treffBrett && lage && lage.schreiben) return lage.schreiben[bereich] === true;
return true;
};
const $ = (id) => document.getElementById(id);
const UNTERZEILE = {
@@ -40,6 +64,14 @@
let ich = null;
let bereich = null;
let einstellung = null;
/* Ist dies ein Brett des Treffs, und was darf ich dort? Beides sagt
der Server (daten.treff und /api/treff/lage) -- im Browser steht
keine Liste der Bretter und keine Rechteregel, sondern nur die
Antwort auf "was darf ICH hier gerade". Eine Regel, die im Browser
noch einmal steht, ist die, die auseinanderlaeuft; und die Fassung
im Browser kann man aendern. */
let treffBrett = false;
let lage = null;
let filter = '';
/* Nur beim ersten Laden springen -- sonst reisst es die Seite nach
jeder Bewertung erneut an dieselbe Stelle. */
@@ -214,6 +246,174 @@
if (e.erstellt_name) fuss.append(el('span', null, '· von ' + e.erstellt_name));
if (e.status === 'erledigt') fuss.append(el('span', null, '· erledigt'));
/* ---- Der Treff -------------------------------------------------
DER VOLLE ROLLENNAME AM BEITRAG (Filipe, 11.09.2026: "voller
Rollenname zeigen"). Er kommt fertig vom Server; hier steht
keine Zuordnung Rolle -> Name, die waere eine Liste aller Rollen
dieses Hauses auf jedem Rechner der Community.
WARUM DAS UEBERHAUPT DRANSTEHT: In einem Raum mit Zuschauern und
Moderation muss man sehen, wer spricht. "Frida" und "Frida ·
Modi" sind zwei verschiedene Saetze -- der zweite ist eine
Ansage, der erste eine Meinung.
BEI MITGLIEDERN STEHT NICHTS DA. "Kessi · Community" waere eine
Auszeichnung fuer das Normale; der Rollenname ist hier eine
Auskunft ueber Verantwortung, nicht ueber Zugehoerigkeit. */
if (treffBrett && e.erstellt_rolle_name && e.erstellt_rolle !== 'gast') {
fuss.append(el('span', 'treff-rolle', '· ' + e.erstellt_rolle_name));
}
if (treffBrett && lage) {
const zeile = el('div', 'treff-knoepfe');
/* WILL ICH AUCH. Die Zahl steht AM Knopf und nicht daneben: Sie
ist das Ergebnis des Knopfes, nicht eine Angabe ueber den
Eintrag. Und sie aendert sich sofort im Bild, bevor der Server
geantwortet hat -- bei einem Knopf, der eine Meinung abgibt,
ist eine halbe Sekunde Verzoegerung der Unterschied zwischen
"hat geklappt" und "hab ich das jetzt gedrueckt?". Antwortet
der Server anders, gilt seine Zahl. */
if (bereich === 'wunsch') {
const w = el('button', 'treff-will', '');
w.type = 'button';
let meine = !!e.meine_stimme;
let zahl = Number(e.stimmen) || 0;
const male = () => {
w.textContent = (meine ? '★ ' : '☆ ') + 'Will ich auch · ' + zahl;
w.setAttribute('aria-pressed', String(meine));
};
male();
w.addEventListener('click', async () => {
w.disabled = true;
meine = !meine; zahl += meine ? 1 : -1; male();
try {
/* EIN DELETE TRAEGT HIER KEINEN KOERPER (gemessen 11.09.2026).
Der naheliegende Aufbau waere gewesen, PUT und DELETE
gleich zu behandeln -- gleicher Kopf, gleicher leerer
Koerper. Das geht NICHT: Nodes HTTP-Parser weist ein
DELETE mit Koerper mit einem leeren 400 ab, noch bevor
Express die Anfrage sieht. Man sucht den Fehler dann in
der Route, und dort ist keiner.
Nachgemessen: PUT mit Koerper -> 200, DELETE ohne
Koerper -> 200, DELETE mit Koerper -> 400 mit genau
einem Kopffeld ("connection: close"). Das ist die
Handschrift des Parsers, nicht die einer Anwendung. */
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/willich',
meine
? { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' }
: { method: 'DELETE' });
if (a.ok) { const j = await a.json(); zahl = j.stimmen; meine = j.meine; male(); }
} catch { /* umgeleitet */ } finally { w.disabled = false; }
});
zeile.append(w);
}
/* MELDEN. Nur fuer die Community und nur an fremden Beitraegen --
den eigenen kann man loeschen, das ist der kuerzere Weg.
Der Grund ist Pflicht (DSA Art. 16); der Server prueft ihn
noch einmal, die Abfrage hier erspart nur den Fehlschlag. */
if (!lage.team && e.erstellt_von !== ich.id) {
zeile.append(tuKnopf('melden', async () => {
const grund = prompt('Was ist daran nicht in Ordnung?\n\n'
+ 'Bitte schreib es in einem Satz – sonst können wir es nicht prüfen.');
if (grund === null) return;
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/melden', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grund }),
});
melde(a.ok ? 'Danke – wir sehen uns das an.'
: ((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'));
}));
}
if (lage.team) {
/* ANHEFTEN. Hoechstens drei; die Zahl kommt vom Server
(lage.anschlag_max), damit sie nicht an zwei Stellen steht. */
if (bereich === 'anschlag') {
const dran = !!e.angeheftet;
zeile.append(tuKnopf(dran ? 'abnehmen' : 'anheften', async () => {
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/anheften',
dran
? { method: 'DELETE' }
: { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!a.ok) {
melde((await a.json().catch(() => ({}))).fehler
|| ('Es hängen schon ' + lage.anschlag_max + ' Ansagen.'));
return;
}
await laden();
}));
}
/* FREIGEBEN. Bei Terminen heisst es "im Treff zeigen", bei
Highlights "freigeben" -- derselbe Vorgang, zwei Woerter,
weil es zwei verschiedene Entscheidungen sind. */
if (bereich === 'ansteht' || bereich === 'highlight') {
const frei = !!e.freigegeben;
const wort = bereich === 'ansteht'
? (frei ? 'nicht mehr zeigen' : 'im Treff zeigen')
: (frei ? 'zurückziehen' : 'freigeben');
if (!frei) {
const m = el('span', 'marke-art marke-art--nur', 'noch nicht sichtbar');
m.title = 'Die Community sieht das erst nach der Freigabe.';
zeile.append(m);
}
zeile.append(tuKnopf(wort, async () => {
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/freigeben',
frei
? { method: 'DELETE' }
: { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!a.ok) { melde('Ging nicht.'); return; }
await laden();
}));
}
/* AUS "MITMACHEN" WIRD EIN TALENT. Ein ausdruecklicher Schritt
und keine Automatik: Wer sich meldet, ist ein Kandidat --
dass er einer wird, entscheidet ein Mensch. */
if (bereich === 'mitmachen' && e.erstellt_rolle === 'gast') {
zeile.append(tuKnopf('zu den Talenten', async () => {
if (!confirm('„' + e.titel + '“ zu den Talenten übernehmen?')) return;
const a = await hole('/workspace/api/treff/mitmachen/' + e.id + '/uebernehmen',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const j = await a.json().catch(() => ({}));
melde(a.ok ? 'Steht jetzt bei den Talenten.'
: (j.fehler === 'schon_uebernommen' ? 'Steht dort schon.'
: (j.fehler || 'Ging nicht.')));
}));
}
/* ENTFERNEN MIT GRUND (DSA Art. 17). Eigener Knopf neben dem
gewohnten "loeschen" -- denn "loeschen" gibt es weiterhin
fuer den eigenen Beitrag, und der braucht keine Begruendung. */
if (e.erstellt_von !== ich.id) {
zeile.append(tuKnopf('entfernen', async () => {
const grund = prompt('Warum wird „' + e.titel + '“ entfernt?\n\n'
+ 'Der Grund wird festgehalten – die Person hat ein Recht darauf.');
if (grund === null) return;
/* DER GRUND REIST IN DER ADRESSE, nicht im Koerper -- aus
demselben gemessenen Grund wie oben. Er ist kein
Geheimnis: Er wird der Person, die den Beitrag
geschrieben hat, ohnehin mitgeteilt, und er steht im
Protokoll. */
const a = await hole('/workspace/api/bereich/' + bereich + '/' + e.id
+ '?grund=' + encodeURIComponent(grund), { method: 'DELETE' });
if (!a.ok) {
melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
return;
}
await laden();
}));
}
}
if (zeile.childNodes.length) k.append(zeile);
}
if (!darfEintragen()) { k.append(fuss); return k; }
const knoepfe = el('div', 'eintrag-knoepfe');
@@ -263,6 +463,19 @@
}
}
/* Einmal je Seitenaufruf. Sie wird an ZWEI Stellen gebraucht -- beim
Aufbau des Formulars und beim Zeichnen der Karten -- und beide
brauchen sie, BEVOR sie etwas anzeigen. Ein zweiter Abruf wäre eine
zweite Gelegenheit für zwei verschiedene Antworten auf dem selben
Bildschirm. */
async function lageHolen() {
if (!treffBrett || lage) return;
try {
const l = await fetch('/workspace/api/treff/lage', { credentials: 'same-origin' });
if (l.ok) lage = await l.json();
} catch { /* dann gilt die alte Regel, siehe darfEintragen */ }
}
function filterBauen() {
const ziel = $('filter');
ziel.textContent = '';
@@ -284,6 +497,8 @@
if (!a.ok) { melde('Einträge konnten nicht geladen werden.'); return; }
const daten = await a.json();
einstellung = daten.einstellung;
treffBrett = !!daten.treff;
await lageHolen();
filterBauen();
const ziel = $('liste');
@@ -539,6 +754,36 @@
if (einstellung.ober && $('ober')) $('ober').textContent = einstellung.ober;
$('unterzeile').textContent = UNTERZEILE[bereich] || '';
/* VOR dem Aufbau des Formulars. Danach wäre zu spät: Der Knopf
stünde dann kurz da und verschwände wieder -- sichtbar, und
ausgerechnet an der Stelle, an der jemand gerade überlegt, ob er
schreiben darf. */
treffBrett = !!daten.treff;
await lageHolen();
/* DER WEG ZU DEN REGELN STEHT AUF JEDEM BRETT DES TREFFS
(11.09.2026).
Nicht nur auf dem Brett "Regeln & Hilfe". Wer die Regeln braucht,
braucht sie in dem Moment, in dem er etwas schreiben will -- und
das ist fast nie dort. Ein Mensch, der erst zurueck zur
Startseite und dann auf die richtige Kachel muss, liest sie
nicht; er schreibt einfach.
ALS LINK UND NICHT ALS EINGEBLENDETER TEXT: Die Regeln sind zu
lang, um sie ueber jedes Brett zu haengen, und ein Kasten, den
man jeden Tag wegklickt, wird nach drei Tagen nicht mehr
gesehen. */
if (daten.treff) {
const vorhanden = $('treff-regelweg');
if (!vorhanden) {
const a = el('a', 'treff-regelweg', 'Regeln & Hilfe');
a.id = 'treff-regelweg';
a.href = 'treff-regeln.html';
$('unterzeile').after(a);
}
}
/* Fuer den Creator verschwindet alles Schreibende -- und die
Unterzeile sagt, warum. Ein leerer Bereich ohne Erklaerung wirkt
kaputt; einer mit Erklaerung wirkt gedacht. */
@@ -551,8 +796,16 @@
"was zu dir gehört", laese sich eine vollstaendige Liste wie
ein Ausschnitt, und die naheliegende Frage waere: Was sehe ich
hier eigentlich nicht? */
/* IM TREFF STIMMT KEINER DER BEIDEN SÄTZE. Dort geht es weder um
die Agentur noch um Betreuung, sondern entweder um ein Brett,
das dem Team gehört, oder um eine Stufe, die noch nicht
erreicht ist. Und der Server weiß beides genau -- er schickt
den Satz mit, statt dass der Browser ihn errät. */
$('unterzeile').textContent = (UNTERZEILE[bereich] || '')
+ (einstellung.fuerAlle
+ (treffBrett
? ' ' + (lage?.warum?.[bereich]
|| 'Hier schreibt das Team – lesen kannst du alles.')
: einstellung.fuerAlle
? ' Eingetragen wird von der Agentur – du siehst alles, was hier steht.'
: ' Hier trägt deine Betreuung ein – du siehst alles, was zu dir gehört.');
}
+26 -1
View File
@@ -76,6 +76,31 @@
und zwar ohne Fehlermeldung im Protokoll. */
const ohneKacheln = rollen.length === 0;
/* UND SEIT DEM 11.09.2026 GIBT ES SIE WIEDER -- die Wand des Treffs.
Dort ist nur EINE Sorte Zugang vorgesehen, und eine Kachelreihe
mit genau einem Eintrag waere keine Auswahl, sondern eine Huerde:
ein Knopf, den man druecken muss, damit der naechste Knopf
funktioniert.
DER SERVER BRAUCHT DIE ROLLE TROTZDEM. Er vergleicht sie gegen den
Kachelsatz seiner Adresse; kaeme "" an, waere die Antwort dieselbe
wie bei einem falschen Code -- 401, ohne dass irgendwo etwas
kaputt ist. Genau so eine Stelle sucht man lange, denn sie sieht
aus wie ein Tippfehler des Nutzers.
WARUM DAS AM <body> STEHT UND NICHT IM SKRIPT: gate.js wird an
ALLE drei Waende ausgeliefert. Eine Fallunterscheidung hier
("wenn Treff, dann gast") waere eine vierte Stelle, an der die
Adressen aufgezaehlt werden -- und die erste, die beim naechsten
Umbau vergessen wird. Die Wand sagt selbst, wofuer sie da ist.
EIN LECK IST DAS NICHT: Auf dieser Wand gibt es genau diese eine
Rolle. Der Name verraet nichts, was der Seitenkopf nicht ohnehin
sagt -- die beiden anderen Waende nennen ihre Rollen seit jeher
offen im Quelltext. */
const VORGABE = document.body.dataset.rolle || "";
/* ---------- Rolle wählen ------------------------------------------ */
function waehle(rolle, fokus = false) {
@@ -161,7 +186,7 @@
braucht ihn dort nicht: Er prueft den verborgenen Zugang
VOR dem gewohnten Weg, und ein leerer Name faellt danach in
dieselbe Antwort wie ein erfundener. */
body: JSON.stringify({ rolle: gewaehlt || '', code: wert }),
body: JSON.stringify({ rolle: gewaehlt || VORGABE, code: wert }),
credentials: 'same-origin',
});
+121
View File
@@ -0,0 +1,121 @@
/* =====================================================================
„Wer sieht was" — die Rechtetafel, gezeichnet (11.09.2026)
Dieses Skript ENTSCHEIDET nichts. Es holt die Tafel vom Server und
zeichnet sie. Jede Zahl und jeder Punkt darin kommt aus rechte.js —
derselben Datei, aus der auch die Schranke ihre Antwort holt.
WARUM DAS WICHTIG IST: Eine Übersicht, die im Browser gebaut wird
(„Rolle X darf wohl Y, weil …"), wäre eine zweite Meinung über die
Rechte — und ausgerechnet die, der man glaubt, weil sie wie eine
Auskunft aussieht. Sie würde altern, ohne dass man es ihr ansieht.
===================================================================== */
(() => {
const $ = (id) => document.getElementById(id);
const el = (t, k, text) => {
const n = document.createElement(t);
if (k) n.className = k;
if (text !== undefined) n.textContent = text;
return n;
};
const melde = (t) => {
const f = $('fehler');
if (f) { f.textContent = t; f.hidden = !t; }
};
(async () => {
let r;
try {
const a = await fetch('/workspace/api/rechte/tafel', { credentials: 'same-origin' });
if (a.status === 401) { location.assign('/workspace/'); return; }
/* 404 heißt hier: diese Seite gibt es für dich nicht. Die
Schranke hätte schon vorher umgeleitet; kommt es trotzdem
hierher, ist die Startseite die richtige Antwort — und nicht
eine Fehlermeldung, die verrät, dass es die Auskunft gibt. */
if (a.status === 404) { location.assign('/workspace/start.html'); return; }
if (!a.ok) { melde('Die Tafel konnte nicht geladen werden.'); return; }
r = await a.json();
} catch {
melde('Die Tafel konnte nicht geladen werden.');
return;
}
const gesamt = r.seiten.length;
/* ---- Die Zahlen je Rolle ---------------------------------------
Sortiert nach Anzahl, absteigend. Eine alphabetische Reihenfolge
hätte dieselben Zahlen gezeigt und die Frage nicht beantwortet:
Man sieht hier auf einen Blick, wer viel sieht und wer wenig —
und ob irgendwo eine Rolle steht, die mehr sieht, als sie sollte. */
const zahlen = $('zahlen');
if (zahlen) {
zahlen.textContent = '';
for (const rolle of [...r.rollen].sort((a, b) => b.seiten - a.seiten)) {
const li = document.createElement('li');
li.append(rolle.name + ' ');
const b = document.createElement('b');
b.textContent = `${rolle.seiten} von ${gesamt}`;
li.append(b);
zahlen.append(li);
}
}
/* ---- Die Tafel --------------------------------------------------- */
const ziel = $('tafel');
if (ziel) {
ziel.textContent = '';
const tab = document.createElement('table');
const kopf = document.createElement('thead');
const kz = document.createElement('tr');
kz.append(el('th', null, 'Seite'));
for (const rolle of r.rollen) {
const th = el('th', null, rolle.name);
/* Der Schlüssel als Titel: Im Alltag liest man „Community",
im Code steht „gast". Wer die beiden nicht zusammenbringen
kann, sucht im Quelltext an der falschen Stelle. */
th.title = rolle.schluessel;
kz.append(th);
}
kopf.append(kz);
tab.append(kopf);
const koerper = document.createElement('tbody');
for (const seite of r.seiten) {
const tr = document.createElement('tr');
tr.append(el('td', null, seite.datei));
for (const rolle of r.rollen) {
const td = document.createElement('td');
if (seite.rollen.includes(rolle.schluessel)) {
const punkt = el('span', 'rechte-ja');
punkt.setAttribute('role', 'img');
punkt.setAttribute('aria-label', 'darf');
td.append(punkt);
} else {
/* Ein Zeichen und nicht ein leeres Feld: Ein Bildschirmleser
liest eine leere Zelle als nichts vor — man hört dann
nicht, dass die Antwort „nein" ist, sondern gar nichts. */
const nein = el('span', 'rechte-nein', '–');
nein.setAttribute('aria-label', 'darf nicht');
td.append(nein);
}
tr.append(td);
}
koerper.append(tr);
}
tab.append(koerper);
ziel.append(tab);
ziel.setAttribute('aria-busy', 'false');
}
/* ---- Ohne Anmeldung --------------------------------------------- */
const offen = $('offen');
if (offen) {
offen.textContent = '';
for (const pfad of r.ohne_anmeldung) {
offen.append(el('li', null, pfad));
}
}
})();
})();
+312
View File
@@ -0,0 +1,312 @@
/* =====================================================================
Meldungen & Maßnahmen im Treff (11.09.2026)
Drei Listen und ein Knopf. Die Regeln stehen alle auf dem Server —
wer eine Maßnahme verhängen darf, wie lange ein Modi sperren kann,
ob eine Meldung schon entschieden ist. Diese Datei zeichnet nur und
fragt nach.
WARUM HIER KEINE RECHTEPRÜFUNG STEHT: Sie stünde dann zweimal, und
die Fassung im Browser kann jeder ändern. Was hier passiert, ist
höchstens ein Knopf, der gar nicht erst erscheint — das ist
Bequemlichkeit, keine Sicherung. Die Sicherung antwortet mit 403.
===================================================================== */
(() => {
const $ = (id) => document.getElementById(id);
const el = (t, k, text) => {
const n = document.createElement(t);
if (k) n.className = k;
if (text !== undefined) n.textContent = text;
return n;
};
const melde = (t) => { const f = $('fehler'); if (f) { f.textContent = t; f.hidden = !t; } };
const datum = (iso) => {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE',
{ day: 'numeric', month: 'short', year: 'numeric' });
};
async function hole(pfad, optionen = {}) {
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('unbekannt'); }
return a;
}
function knopf(text, tu) {
const b = el('button', 'schritt', text);
b.type = 'button';
b.addEventListener('click', async () => {
b.disabled = true; melde('');
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
});
return b;
}
/* Eine Karte für alle drei Listen. Derselbe Aufbau, damit man die
Seite nicht dreimal neu lesen lernen muss. */
function karte(kopfText, zeilen, knoepfe = []) {
const k = el('article', 'treff-fall');
k.append(el('h3', 'treff-fall__titel', kopfText));
for (const [schild, wert] of zeilen) {
if (!wert) continue;
const p = el('p', 'treff-fall__zeile');
p.append(el('span', 'treff-fall__schild', schild));
p.append(el('span', null, wert));
k.append(p);
}
if (knoepfe.length) {
const reihe = el('div', 'treff-fall__knoepfe');
for (const b of knoepfe) reihe.append(b);
k.append(reihe);
}
return k;
}
/* ---- Meldungen ---------------------------------------------------- */
async function meldungen() {
const ziel = $('meldungen');
const a = await hole('/workspace/api/treff/meldungen');
if (!a.ok) { ziel.textContent = ''; ziel.append(el('p', 'leise', 'Ging nicht.')); return; }
const liste = (await a.json()).meldungen || [];
ziel.textContent = '';
ziel.setAttribute('aria-busy', 'false');
const offen = liste.filter((m) => m.status === 'offen');
const erledigt = liste.filter((m) => m.status !== 'offen');
if (!offen.length) {
ziel.append(el('p', 'leise', 'Nichts offen. Das ist die gute Nachricht.'));
}
for (const m of offen) {
const wer = m.melder_name
? m.melder_name + (m.melder_rolle_name && m.melder_rolle !== 'gast'
? ' · ' + m.melder_rolle_name : '')
: 'jemand';
const autor = m.autor_name
? m.autor_name + (m.autor_rolle_name && m.autor_rolle !== 'gast'
? ' · ' + m.autor_rolle_name : '')
: 'unbekannt';
const k = karte(m.titel || '(Beitrag gelöscht)', [
['Grund', m.grund],
['gemeldet von', wer + ' am ' + datum(m.zeitpunkt)],
['Beitrag von', autor],
['Brett', m.bereich || '–'],
['Text', m.text],
], [
knopf('erledigt', () => entscheiden(m.id, 'erledigt')),
knopf('nichts dran', () => entscheiden(m.id, 'abgelehnt')),
/* Der Weg zum Beitrag selbst. Ohne ihn müsste man die Meldung
lesen, sich das Brett merken und dort suchen -- und genau
dabei entscheidet man nach Erinnerung statt nach dem, was
wirklich dasteht. */
...(m.bereich ? [(() => {
const a2 = el('a', 'schritt', 'zum Brett');
a2.href = 'bereich.html?b=' + encodeURIComponent(m.bereich);
return a2;
})()] : []),
]);
ziel.append(k);
}
if (erledigt.length) {
const h = el('p', 'leise', `${erledigt.length} schon entschieden:`);
ziel.append(h);
for (const m of erledigt.slice(0, 20)) {
ziel.append(karte(m.titel || '(Beitrag gelöscht)', [
['Grund', m.grund],
['Entscheidung', (m.status === 'erledigt' ? 'erledigt' : 'nichts dran')
+ (m.entscheid ? ' – ' + m.entscheid : '')],
['von', (m.entschieden_name || '') + ' am ' + datum(m.entschieden_am)],
]));
}
}
}
async function entscheiden(id, status) {
const entscheid = prompt(status === 'erledigt'
? 'Was hast du getan?\n\nEin Satz genügt – er wird zur Meldung festgehalten.'
: 'Warum ist daran nichts dran?\n\nEin Satz genügt – er wird festgehalten.');
if (entscheid === null) return;
const a = await hole('/workspace/api/treff/meldung/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, entscheid }),
});
if (!a.ok) {
melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
return;
}
await meldungen();
}
/* ---- Entfernt ----------------------------------------------------- */
async function entfernt() {
const ziel = $('entfernt');
const a = await hole('/workspace/api/treff/entfernt');
if (!a.ok) { ziel.textContent = ''; return; }
const liste = (await a.json()).entfernt || [];
ziel.textContent = '';
ziel.setAttribute('aria-busy', 'false');
if (!liste.length) { ziel.append(el('p', 'leise', 'Noch nichts entfernt.')); return; }
for (const e of liste.slice(0, 50)) {
ziel.append(karte(e.titel, [
['Grund', e.grund],
['war von', e.autor_name || 'unbekannt'],
['entfernt von', (e.von_name || '')
+ (e.von_rolle_name ? ' · ' + e.von_rolle_name : '') + ' am ' + datum(e.zeitpunkt)],
['Brett', e.bereich],
]));
}
}
/* ---- Mitglieder ---------------------------------------------------- */
/* DIE STUFE STEHT NUR HIER. Im Treff selbst taucht sie nirgends auf
-- weder neben dem Namen noch an einem Beitrag. Das ist eine
bewusste Entscheidung aus dem Plan (Teil H): Eine sichtbare Stufe
macht aus einem Schutz eine Rangordnung, und aus „Neu" wird ein
Abzeichen, das man los sein will. Für die Moderation ist sie
dagegen die wichtigste Angabe überhaupt. */
async function mitglieder() {
const ziel = $('mitglieder');
const a = await hole('/workspace/api/treff/mitglieder');
if (!a.ok) { ziel.textContent = ''; return; }
const { mitglieder: liste, darf } = await a.json();
ziel.textContent = '';
ziel.setAttribute('aria-busy', 'false');
if (!liste.length) {
ziel.append(el('p', 'leise',
'Noch niemand. Zugänge legst du in „Personen & Zugänge" an, Rolle Community.'));
return;
}
const STUFE = { neu: 'Neu', dabei: 'Dabei', stamm: 'Stamm' };
for (const m of liste) {
const knoepfe = [];
if (!m.sperre) {
if (darf?.pause) knoepfe.push(knopf('Pause', () => massnahme(m, 'pause', darf)));
if (darf?.ausschluss_befristet) {
knoepfe.push(knopf('Ausschluss', () => massnahme(m, 'ausschluss', darf)));
}
knoepfe.push(knopf('Hinweis', () => massnahme(m, 'hinweis', darf)));
}
const k = karte(m.name, [
['Stufe', STUFE[m.stufe] || m.stufe],
['dabei seit', datum(m.seit)],
['Beiträge', String(m.beitraege)],
['zuletzt da', m.zuletzt ? datum(m.zuletzt) : 'noch nie'],
['gesperrt', m.sperre
? `${m.sperre.art === 'pause' ? 'Pause' : 'Ausschluss'} `
+ (m.sperre.bis ? 'bis ' + datum(m.sperre.bis) : 'dauerhaft')
+ ' – ' + m.sperre.grund
: ''],
], knoepfe);
if (m.sperre) k.dataset.vorbei = 'ja';
ziel.append(k);
}
}
/* EIN VORGANG, ZWEI FRAGEN — und die Reihenfolge ist wichtig.
Zuerst der GRUND, dann die Dauer. Umgekehrt wäre bequemer zu
tippen und falsch herum gedacht: Wer zuerst „wie lange?" gefragt
wird, hat die Strafe schon festgelegt, bevor er sie begründet hat.
So steht am Anfang die Frage, was eigentlich passiert ist. */
async function massnahme(m, art, darf) {
const grund = prompt(art === 'hinweis'
? `Hinweis an ${m.name} – worum geht es?`
: `${art === 'pause' ? 'Pause' : 'Ausschluss'} für ${m.name} – warum?\n\n`
+ 'Der Grund wird festgehalten, und die Person hat ein Recht darauf.');
if (grund === null) return;
let tage;
if (art !== 'hinweis') {
const hinweis = darf?.frist_tage
? `Du kannst bis zu ${darf.frist_tage} Tage geben.`
: 'Leer lassen = dauerhaft (nur bei Ausschluss).';
const ein = prompt(`Für wie viele Tage?\n\n${hinweis}`,
darf?.frist_tage ? String(darf.frist_tage) : '7');
if (ein === null) return;
tage = ein.trim() === '' ? null : Number(ein);
if (tage !== null && !(tage > 0)) { melde('Das ist keine Anzahl Tage.'); return; }
if (tage === null && art === 'pause') { melde('Eine Pause braucht eine Dauer.'); return; }
}
const a = await hole('/workspace/api/treff/massnahme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ person_id: m.id, art, grund, tage }),
});
if (!a.ok) {
melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
return;
}
await mitglieder();
await massnahmen();
}
/* ---- Maßnahmen ---------------------------------------------------- */
async function massnahmen() {
const ziel = $('massnahmen');
const a = await hole('/workspace/api/treff/massnahmen');
if (!a.ok) { ziel.textContent = ''; return; }
const liste = (await a.json()).massnahmen || [];
ziel.textContent = '';
ziel.setAttribute('aria-busy', 'false');
if (!liste.length) { ziel.append(el('p', 'leise', 'Noch keine Maßnahme.')); return; }
const NAME = { hinweis: 'Hinweis', pause: 'Pause', ausschluss: 'Ausschluss' };
for (const m of liste.slice(0, 50)) {
const laeuft = !m.aufgehoben_am
&& (!m.bis || Date.parse(m.bis) > Date.now());
const k = karte(`${NAME[m.art] || m.art} · ${m.person_name || 'unbekannt'}`, [
['Grund', m.grund],
['gilt', m.aufgehoben_am ? 'aufgehoben am ' + datum(m.aufgehoben_am)
: m.bis ? 'bis ' + datum(m.bis) : 'dauerhaft'],
['von', (m.von_name || '') + (m.von_rolle_name ? ' · ' + m.von_rolle_name : '')
+ ' am ' + datum(m.zeitpunkt)],
], laeuft && m.art !== 'hinweis' ? [
knopf('aufheben', async () => {
if (!confirm(`Maßnahme gegen ${m.person_name} aufheben?`)) return;
const r = await hole('/workspace/api/treff/massnahme/' + m.id, { method: 'DELETE' });
if (!r.ok) {
melde((await r.json().catch(() => ({}))).fehler || 'Ging nicht.');
return;
}
await massnahmen();
}),
] : []);
if (!laeuft) k.dataset.vorbei = 'ja';
ziel.append(k);
}
}
/* ---- Die Leiter in Worten ------------------------------------------
Die Zahlen kommen vom Server, aus demselben Grund wie im Regeltext:
Eine Frist, die in der Oberfläche anders steht als im Code, ist
schlimmer als gar keine Angabe. */
async function leiter() {
try {
const a = await fetch('/workspace/api/treff/regeln', { credentials: 'same-origin' });
if (!a.ok) return;
const r = await a.json();
$('leiter').textContent =
`Hinweis, dann Pause, dann Ausschluss – in dieser Reihenfolge. Ein Modi kann `
+ `bis zu ${r.modi_frist_tage} Tage geben; alles darüber und jeder dauerhafte `
+ `Ausschluss ist DogFathers Entscheidung. Wer eine dauerhafte Maßnahme nicht `
+ `verhängen darf, kann sie auch nicht aufheben.`;
} catch { /* dann bleibt "…" stehen, und das sieht man */ }
}
(async () => {
try {
await leiter();
await meldungen();
await entfernt();
await mitglieder();
await massnahmen();
} catch { /* umgeleitet */ }
})();
})();
+82
View File
@@ -0,0 +1,82 @@
/* =====================================================================
Die Zahlen im Regeltext (11.09.2026)
Dieses Skript schreibt nichts dazu -- es setzt nur die Zahlen ein,
die im Text stehen muessen: Fristen, Stufen, Mindestalter, wie lange
ein Modi eine Pause geben darf.
WARUM UEBERHAUPT EIN SKRIPT FUER FUENF ZAHLEN
Weil sie sonst zweimal existieren wuerden: einmal als Regel im Code
(DABEI_TAGE, STAMM_TAGE, MINDESTALTER ...) und einmal als Wort in
der HTML-Datei. Beim naechsten Nachjustieren waere eine von beiden
geaendert worden, und der Regeltext haette etwas anderes versprochen
als der Server tut. Das faellt niemandem auf -- und wer es bemerkt,
glaubt dem Text, nicht dem Verhalten.
Ein Regeltext, der sich selbst widerspricht, ist ausserdem rechtlich
das Gegenteil dessen, wofuer er da ist.
WENN DIE ANTWORT AUSBLEIBT, bleibt "…" stehen. Das ist Absicht: Eine
fehlende Zahl sieht man, eine falsche nicht.
===================================================================== */
(() => {
const $ = (id) => document.getElementById(id);
const melde = (t) => {
const f = $('fehler');
if (f) { f.textContent = t; f.hidden = !t; }
};
(async () => {
let r;
try {
const a = await fetch('/workspace/api/treff/regeln', { credentials: 'same-origin' });
if (a.status === 401) { location.assign('/workspace/'); return; }
if (!a.ok) { melde('Die Zahlen konnten nicht geladen werden. Der Text gilt trotzdem.'); return; }
r = await a.json();
} catch {
melde('Die Zahlen konnten nicht geladen werden. Der Text gilt trotzdem.');
return;
}
/* Die drei Stufen -- aus der Antwort gebaut, nicht aus drei festen
Zeilen. Wer eine vierte Stufe einbaut, bekommt sie hier von
selbst; eine feste Liste haette sie unterschlagen, ohne dass
irgendwo etwas rot wird. */
const liste = $('stufen');
if (liste && Array.isArray(r.stufen)) {
const was = {
neu: 'Lesen und bei Wünschen mitstimmen.',
dabei: `Ab ${r.dabei_tage} Tagen: Du kannst im Treff schreiben, `
+ 'Wünsche einstellen und dich bei „Mitmachen“ melden.',
stamm: `Ab ${r.stamm_tage} Tagen und ${r.stamm_beitraege} eigenen Beiträgen: `
+ 'Du kannst Clips und Bilder einstellen – sie erscheinen, sobald '
+ 'jemand aus dem Team sie freigegeben hat.',
};
liste.textContent = '';
for (const s of r.stufen) {
const li = document.createElement('li');
const stark = document.createElement('strong');
stark.textContent = s.name;
li.append(stark, ' – ', was[s.schluessel] || '');
liste.append(li);
}
}
if ($('alter')) {
$('alter').textContent =
`Mindestalter: ${r.mindestalter} Jahre. Wer jünger ist, hat hier nichts `
+ 'verloren – nicht weil wir es kompliziert machen wollen, sondern weil '
+ 'der Treff zu einem Stream für Erwachsene gehört.';
}
if ($('ausschluss')) {
$('ausschluss').textContent =
`Ein Modi kann eine Pause von bis zu ${r.modi_frist_tage} Tagen geben. `
+ 'Alles, was länger gilt, und jeder endgültige Ausschluss ist DogFathers '
+ 'Entscheidung. Jede dieser Entscheidungen wird mit Grund festgehalten – '
+ 'auch, damit man sie zurücknehmen kann.';
}
})();
})();
+12 -12
View File
@@ -12,19 +12,19 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -252,10 +252,10 @@
</form>
</dialog>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/aufgaben.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/aufgaben.js?v=202609111736" defer></script>
</body>
</html>
+13 -13
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/automation.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/automation.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -147,10 +147,10 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/automation.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/automation.js?v=202609111736" defer></script>
</body>
</html>
+17 -17
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -181,14 +181,14 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/checkliste.js?v=202609111617" defer></script>
<script src="assets/js/ampel.js?v=202609111617" defer></script>
<script src="assets/js/schulung.js?v=202609111617" defer></script>
<script src="assets/js/vorlagen.js?v=202609111617" defer></script>
<script src="assets/js/bereich.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/checkliste.js?v=202609111736" defer></script>
<script src="assets/js/ampel.js?v=202609111736" defer></script>
<script src="assets/js/schulung.js?v=202609111736" defer></script>
<script src="assets/js/vorlagen.js?v=202609111736" defer></script>
<script src="assets/js/bereich.js?v=202609111736" defer></script>
</body>
</html>
+14 -14
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/calls.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/calls.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -89,11 +89,11 @@
</main>
<script src="assets/js/ki.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/calls.js?v=202609111617" defer></script>
<script src="assets/js/ki.js?v=202609111736" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/calls.js?v=202609111736" defer></script>
</body>
</html>
+13 -13
View File
@@ -12,26 +12,26 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
Dialog zu sehen bekam. -->
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/chat.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/chat.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -341,10 +341,10 @@
</form>
</dialog>
<script src="assets/js/bereiche.js?v=202609111617"></script>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617"></script>
<script src="assets/js/glocke.js?v=202609111617"></script>
<script src="assets/js/chat.js?v=202609111617"></script>
<script src="assets/js/bereiche.js?v=202609111736"></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736"></script>
<script src="assets/js/glocke.js?v=202609111736"></script>
<script src="assets/js/chat.js?v=202609111736"></script>
</body>
</html>
+15 -15
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/content.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/content.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -179,12 +179,12 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/checkliste.js?v=202609111617" defer></script>
<script src="assets/js/vorlagen.js?v=202609111617" defer></script>
<script src="assets/js/content.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/checkliste.js?v=202609111736" defer></script>
<script src="assets/js/vorlagen.js?v=202609111736" defer></script>
<script src="assets/js/content.js?v=202609111736" defer></script>
</body>
</html>
+5 -5
View File
@@ -30,15 +30,15 @@
<link rel="preload" as="image" href="assets/img/buehne/crew-gate-hoch-580.webp"
type="image/webp" media="(max-width: 899px)" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/crew-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/crew-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/crew-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/crew-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<!-- Diese Wand steht NUR auf der Adresse von Team Dogi (ueberall sonst
404, siehe crewWeiche). Sie darf das Haus deshalb direkt nennen
statt ueber den Platzhalter haus.css zu gehen -- und sie MUSS es:
Eine Zugangswand, die sich erst nach dem Umbiegen faerbt, waere
die eine Seite, auf der man das Umfaerben sieht. -->
<link rel="stylesheet" href="assets/css/crew-haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/crew-haus.css?v=202609111736" />
</head>
<body class="gate gate--crew">
@@ -252,6 +252,6 @@
</main>
</div>
<script src="assets/js/gate.js?v=202609111617" defer></script>
<script src="assets/js/gate.js?v=202609111736" defer></script>
</body>
</html>
+4 -4
View File
@@ -13,25 +13,25 @@
"theme_color": "#06090f",
"icons": [
{
"src": "/assets/img/app-symbole/crew-192.png?v=202609111617",
"src": "/assets/img/app-symbole/crew-192.png?v=202609111736",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/app-symbole/crew-512.png?v=202609111617",
"src": "/assets/img/app-symbole/crew-512.png?v=202609111736",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111617",
"src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111736",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111617",
"src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111736",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
+13 -13
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -108,10 +108,10 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/dateien.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/dateien.js?v=202609111736" defer></script>
</body>
</html>
+4 -4
View File
@@ -30,9 +30,9 @@
<link rel="preload" as="image" href="assets/img/buehne/gate-hoch-580.webp"
type="image/webp" media="(max-width: 899px)" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
</head>
<body class="gate">
@@ -401,6 +401,6 @@
</main>
</div>
<script src="assets/js/gate.js?v=202609111617" defer></script>
<script src="assets/js/gate.js?v=202609111736" defer></script>
</body>
</html>
+14 -14
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -377,11 +377,11 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/kalender.js?v=202609111617" defer></script>
<script src="assets/js/kalender-abo.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/kalender.js?v=202609111736" defer></script>
<script src="assets/js/kalender-abo.js?v=202609111736" defer></script>
</body>
</html>
+13 -13
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/leistung.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/leistung.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -382,10 +382,10 @@
</form>
</dialog>
<script src="assets/js/wahl.js?v=202609111617"></script>
<script src="assets/js/bereiche.js?v=202609111617"></script>
<script src="assets/js/kopf.js?v=202609111617"></script>
<script src="assets/js/glocke.js?v=202609111617"></script>
<script src="assets/js/leistung.js?v=202609111617"></script>
<script src="assets/js/wahl.js?v=202609111736"></script>
<script src="assets/js/bereiche.js?v=202609111736"></script>
<script src="assets/js/kopf.js?v=202609111736"></script>
<script src="assets/js/glocke.js?v=202609111736"></script>
<script src="assets/js/leistung.js?v=202609111736"></script>
</body>
</html>
+40 -13
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/personen.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/personen.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -270,6 +270,33 @@
<path fill="rgba(255,255,255,.4)" d="M12 2.6 14.4 9H9.6z"/>
<path fill="none" stroke="rgba(216,222,233,.75)" stroke-width=".8" stroke-linejoin="round" d="M12 2.6 20 9l-3 11.4H7L4 9zM4 9h16M9.6 9 12 20.4 14.4 9"/>
</g>
<!-- DIE COMMUNITY (11.09.2026).
Zwei Figuren, keine einzelne: Das Zeichen des Creators (#r-person)
wäre das naheliegende gewesen und das falsche -- es steht bereits
für eine Rolle, und zwei Rollen mit demselben Zeichen heißt, dass
das Zeichen nichts mehr sagt.
Die hintere Figur ist leiser gesetzt und teilweise verdeckt. Das
ist keine Zierde: Bei 22 Pixeln verschmelzen zwei gleich helle
Formen zu einem Klecks. Erst der Helligkeitsunterschied macht
daraus zwei Menschen.
Dieselbe Zeichenweise wie die anderen sechs -- gefüllte Flächen
aus den vorhandenen Verläufen, eine dünne Kontur darüber. Ein
Zeichen in einem anderen Stil fällt auf, ohne etwas zu sagen. -->
<g id="r-leute">
<circle fill="url(#rs-lila-silber)" opacity=".7" cx="15.9" cy="8.1" r="2.9"/>
<path fill="url(#rs-lila-silber)" opacity=".7"
d="M10.6 20.4v-2.2c0-2.6 2.4-4.3 5.3-4.3s5.3 1.7 5.3 4.3v2.2z"/>
<circle fill="url(#rs-lila)" cx="9.2" cy="7.4" r="3.4"/>
<path fill="url(#rs-lila)"
d="M2.8 20.4v-2.5c0-3 2.9-5 6.4-5s6.4 2 6.4 5v2.5z"/>
<path fill="none" stroke="rgba(216,222,233,.75)" stroke-width=".8"
stroke-linejoin="round"
d="M9.2 4a3.4 3.4 0 1 1 0 6.8 3.4 3.4 0 0 1 0-6.8M2.8 20.4v-2.5c0-3 2.9-5 6.4-5s6.4 2 6.4 5v2.5z"/>
</g>
</defs></svg>
<main class="inhalt inhalt--breit">
@@ -396,10 +423,10 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/personen.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/personen.js?v=202609111736" defer></script>
</body>
</html>
+14 -14
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -214,11 +214,11 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/steckbrief.js?v=202609111617" defer></script>
<script src="assets/js/profil.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/steckbrief.js?v=202609111736" defer></script>
<script src="assets/js/profil.js?v=202609111736" defer></script>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Wer sieht was · Creator Workspace</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/treff.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
<div class="schleier" aria-hidden="true"></div>
<header class="kopfleiste">
<button type="button" class="zurueck-knopf" id="zurueck" hidden>
<svg class="zurueck-knopf__pfeil" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.5 5.5 8 12l6.5 6.5" />
</svg>
<span class="zurueck-knopf__text">Zurück</span>
</button>
<p class="marke"><span class="marke__text"><a class="zurueck" href="start.html">Creator&nbsp;Workspace</a> · Wer sieht was</span></p>
<div class="kopfleiste__rechts">
<span class="wer" id="wer">…</span>
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
</div>
</header>
<main class="inhalt inhalt--breit">
<section class="kopf-zeile">
<div>
<p class="marke">Rechte · nur für dich</p>
<h1 class="titel">Wer sieht was</h1>
<p class="unterzeile">
Nicht aufgeschrieben, sondern ausgerechnet – aus derselben Tafel,
aus der die Schranke ihre Entscheidung holt.
</p>
</div>
</section>
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
<!-- ==================================================================
WARUM DIESE SEITE ÜBERHAUPT EXISTIERT
Filipe: „egal welche Rolle oder Person hinzugefügt wird, soll
immer nur das sehen, was ich erlaube."
Dazu gehört, dass er es NACHSEHEN kann. Eine Regel, die man nur
glauben kann, ist eine Zusage; eine, die man ansehen kann, ist
eine Auskunft.
Und sie ist eine Gegenprobe: Wenn hier bei einer Rolle plötzlich
zwanzig Punkte stehen, wo gestern drei waren, sieht man das in
einer Sekunde. In 74 Rollenabfragen über 20 Module sieht man es
nie.
============================================================== -->
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Wie viele Seiten je Rolle</h2>
<div class="treff-abschnitt__inhalt">
<p>
Das ist die Zahl, um die es geht. Eine neue Rolle startet bei
<strong>null</strong> – sie bekommt nichts, bis es jemand einträgt.
</p>
</div>
<ul class="rechte-zahlen" id="zahlen">
<li class="leise">wird geladen …</li>
</ul>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Die ganze Tafel</h2>
<div class="treff-abschnitt__inhalt">
<p>
Ein Punkt heißt: diese Rolle darf diese Seite öffnen. Leer heißt
nein – und zwar auch dann, wenn niemand je darüber nachgedacht hat.
Das ist der Unterschied zu vorher.
</p>
</div>
<div class="rechte-tafel" id="tafel" aria-busy="true">
<p class="leise">wird geladen …</p>
</div>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Ohne Anmeldung</h2>
<div class="treff-abschnitt__inhalt">
<p>
Diese Seiten sind offen – man kann sich schlecht anmelden, wenn die
Anmeldeseite eine Anmeldung verlangt. Jede andere Seite ist zu.
</p>
<ul class="regel-liste" id="offen"><li class="leise">wird geladen …</li></ul>
</div>
</section>
</main>
<!-- WER kopf.js LAEDT, MUSS AUCH wahl.js LADEN (Regel seit 07.09.2026).
Der Umschalter "Meine Sicht" in der Kopfleiste ist ein selbst
gebautes Bedienelement; dahinter liegt ein unsichtbares Systemfeld
fuer Vorleseprogramme. Ohne wahl.js faellt kopf.js auf dieses Feld
zurueck -- ein grauer Kasten von 92 x 19 Pixeln mit weisser
Schrift, mitten in einer dunklen Leiste.
Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen
Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen,
die seit dem 07.09. jede Seite danach absucht. -->
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/rechte.js?v=202609111736" defer></script>
</body>
</html>
+14 -14
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/report.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/report.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -106,11 +106,11 @@
</main>
<script src="assets/js/ki.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/report.js?v=202609111617" defer></script>
<script src="assets/js/ki.js?v=202609111736" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/report.js?v=202609111736" defer></script>
</body>
</html>
+13 -13
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -173,10 +173,10 @@
</div>
</div>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/scouting.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/scouting.js?v=202609111736" defer></script>
</body>
</html>
+12 -12
View File
@@ -12,21 +12,21 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe
den Hinweis in start.css -- dort stand es bis zum 07.09.2026. -->
<link rel="stylesheet" href="assets/css/heim.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/heim.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -674,10 +674,10 @@
</p>
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/start.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/start.js?v=202609111736" defer></script>
</body>
</html>
+14 -14
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -85,11 +85,11 @@
</main>
<script src="assets/js/ki.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/startcheck.js?v=202609111617" defer></script>
<script src="assets/js/ki.js?v=202609111736" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/startcheck.js?v=202609111736" defer></script>
</body>
</html>
+13 -13
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -126,10 +126,10 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/steckbrief.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/steckbrief.js?v=202609111736" defer></script>
</body>
</html>
+13 -13
View File
@@ -7,20 +7,20 @@
<meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/team.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/team.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -110,10 +110,10 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/team.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/team.js?v=202609111736" defer></script>
</body>
</html>
+12 -12
View File
@@ -7,20 +7,20 @@
<meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/team.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/team.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<!-- `data-buehne` steht hier fest und nicht im Skript: Die Kachel zu
@@ -84,9 +84,9 @@
Kaputt ist dabei nichts -- er funktioniert. Genau deshalb faellt
es niemandem auf, und deshalb prueft pruef-css-klassen die
ABHAENGIGKEIT statt der Optik. Hier hat sie angeschlagen. -->
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/teamlage.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/teamlage.js?v=202609111736" defer></script>
</body>
</html>
+163
View File
@@ -0,0 +1,163 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Anmeldung · Der Treff</title>
<!-- Die Beschreibung nennt WEDER Rollen NOCH Bereiche des Teams.
Auf den beiden anderen Waenden steht dort, wofuer sie da sind, und
das ist dort richtig: Wer sie liest, gehoert ohnehin dazu. Hier
steht ein Mensch, der NICHT zum Team gehoert. Was er auf der
Anmeldeseite liest, ist das Einzige, was er ueber das Haus
erfaehrt, solange er den Code nicht hat.
UND DIESER KOMMENTAR HAT SICH SELBST ERWISCHT (11.09.2026): Er
zaehlte urspruenglich als Beispiel auf, was auf der anderen Wand
steht -- und nannte dabei eine Rolle des Hauses, in einer Datei,
die jeder Besucher des Treffs im Quelltext lesen kann. Gefunden
hat das nicht das Auge, sondern eine Zeile in pruef-treff, die
genau danach sucht. Ein Kommentar in einer ausgelieferten Datei
ist kein Notizzettel. -->
<meta name="description" content="Der Treffpunkt rund um DogFather. Zugang nur mit persönlichem Code." />
<meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" />
<!-- Derselbe Ton wie die Kopfleiste dahinter, damit Fensterleiste und
Seite eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<!-- Quer- und Hochformat getrennt vorgeladen, damit das Handy nicht die
grosse Fassung zieht. WebP und nicht AVIF: Der ausliefernde Express 4
bringt eine MIME-Tabelle ohne AVIF mit, die Dateien kaemen als
application/octet-stream an und wuerden wegen nosniff verweigert --
die Buehne bliebe leer. (Ausfuehrlich in crew-index.html.) -->
<link rel="preload" as="image" href="assets/img/buehne/treff-gate-1920.webp"
type="image/webp" media="(min-width: 900px)" />
<link rel="preload" as="image" href="assets/img/buehne/treff-gate-hoch-580.webp"
type="image/webp" media="(max-width: 899px)" />
<!-- DASSELBE APP-SYMBOL WIE TEAM DOGI, und das ist kein Sparen.
Es zeigt den Husky -- das Zeichen des HAUSES, nicht das einer
Rolle. Wer den Treff auf seinen Startbildschirm legt, legt sich
Dogis Zeichen dorthin, und genau das ist gemeint. Unterschieden
werden die beiden Apps durch den NAMEN darunter ("Der Treff" gegen
"Team Dogi"), und niemand hat beide: Ein Konto der Community kommt
auf crew. nicht herein, ein Konto des Teams hier nicht. -->
<link rel="icon" type="image/png" href="/assets/img/app-symbole/crew-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/crew-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<!-- Diese Wand steht NUR auf der Adresse des Treffs (ueberall sonst 404,
siehe treffWeiche). Sie darf das Haus deshalb direkt nennen statt
ueber den Platzhalter haus.css zu gehen -- und sie MUSS es: Eine
Zugangswand, die sich erst nach dem Umbiegen faerbt, waere die eine
Seite, auf der man das Umfaerben sieht. -->
<link rel="stylesheet" href="assets/css/crew-haus.css?v=202609111736" />
</head>
<!-- ====================================================================
`gate--crew` IST HIER KEIN VERSEHEN (11.09.2026).
Darin stehen die Abweichungen, die aus der UMGEFAERBTEN Buehne
folgen: Der Anmeldeknopf laeuft von Lila nach Babyblau statt von
Rot nach Blau, und die Karte nimmt die Farben des gemalten Rahmens
auf -- ringsum blau. Die Buehne des Treffs ist dieselbe Szene in
derselben Farbe; jede dieser Abweichungen gilt hier wortgleich.
Eine eigene Klasse `gate--treff` waere eine Kopie derselben Werte
gewesen. Beim naechsten Nachbessern des Knopfes haette jemand eine
der beiden verbessert -- und das ist genau der Fehler, gegen den
in diesem Haus die halbe Dokumentation geschrieben ist.
`data-rolle` sagt dem Anmeldeskript, wofuer diese Wand da ist.
Ohne Kachelreihe kaeme sonst eine leere Rolle beim Server an, und
der antwortet darauf wie auf einen falschen Code: 401, ohne dass
irgendwo etwas kaputt ist.
================================================================= -->
<body class="gate gate--crew gate--treff" data-rolle="gast">
<!-- ---------- Bühne: Bild + Schleier ---------------------------------- -->
<div class="buehne" aria-hidden="true">
<picture>
<source media="(min-width: 900px)" type="image/webp"
srcset="assets/img/buehne/treff-gate-960.webp 960w,
assets/img/buehne/treff-gate-1280.webp 1280w,
assets/img/buehne/treff-gate-1600.webp 1600w,
assets/img/buehne/treff-gate-1920.webp 1920w,
assets/img/buehne/treff-gate-2560.webp 2560w" sizes="100vw" />
<source type="image/webp"
srcset="assets/img/buehne/treff-gate-hoch-440.webp 440w,
assets/img/buehne/treff-gate-hoch-580.webp 580w,
assets/img/buehne/treff-gate-hoch-900.webp 900w" sizes="100vw" />
<img class="buehne__bild" src="assets/img/buehne/treff-gate-1280.webp" alt=""
width="1647" height="955" fetchpriority="high" decoding="async" />
</picture>
<div class="buehne__schleier"></div>
</div>
<!-- ---------- Tafel ----------------------------------------------------
Der Anker rechnet die Cover-Geometrie des Bildes nach, damit die
Karte in der GEMALTEN Tafel sitzt und nicht ungefaehr dort. Er
faengt keine Klicks ab; nur die Karte darin ist bedienbar.
(Herleitung in crew-index.html und gate.css.) -->
<div class="tafel-anker">
<main class="tafel" id="tafel">
<div class="tafel__kante" aria-hidden="true"></div>
<!-- EIN NAME, KEIN PAAR.
Die Wand von Team Dogi setzt oben "DogFather × Team Dogi" -- zwei,
die das zusammen machen. Hier waere dasselbe falsch: Der Treff
gehoert nicht DogFather und der Community gemeinsam, er ist der
Ort, an dem sie sich treffen. Deshalb steht oben nur das Haus und
darunter der Raum. -->
<header class="kopf">
<p class="marke marke--paar">
<span class="marke__crew">DogFather&nbsp;Universe</span>
</p>
<h1 class="titel">Der&nbsp;Treff</h1>
<p class="unterzeile">Schön, dass du da bist. Gib deinen Code ein.</p>
</header>
<form class="formular" id="formular" novalidate>
<label class="feldschild" for="code">
Persönlicher Zugangscode <span class="fuer" id="fuer" hidden></span>
</label>
<div class="feld">
<input type="password" id="code" name="code" inputmode="text"
autocomplete="current-password" spellcheck="false"
placeholder="Code eingeben" aria-describedby="hinweis fehler" />
<button type="button" class="feld__auge" id="auge"
aria-label="Code anzeigen" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path class="strich" d="M2.5 12S6 5.8 12 5.8 21.5 12 21.5 12 18 18.2 12 18.2 2.5 12 2.5 12Z"/>
<circle class="strich" cx="12" cy="12" r="2.9"/>
<path class="strich durch" d="m4 20 16-16"/>
</svg>
</button>
</div>
<p class="hinweis" id="hinweis" hidden>Feststelltaste ist aktiv.</p>
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
<button type="submit" class="knopf" id="knopf">
<span class="knopf__text">Anmelden</span>
<span class="knopf__laden" aria-hidden="true"></span>
</button>
</form>
<!-- KEIN "NOCH KEINEN CODE? HIER ANFRAGEN".
Das waere die naheliegende Zeile und genau die falsche: Sie macht
aus einer verborgenen Adresse eine oeffentliche Anmeldestelle,
und jeder, der die Adresse errät, haette einen Weg, Aufmerksamkeit
zu verlangen. Codes werden persoenlich vergeben -- wer keinen hat,
hat hier auch nichts zu klaeren. -->
<p class="fuss">
Verschlüsselte Verbindung · Zugriffe werden protokolliert
</p>
</main>
</div>
<script src="assets/js/gate.js?v=202609111736" defer></script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Meldungen &amp; Maßnahmen · Der Treff</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/treff.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
<div class="schleier" aria-hidden="true"></div>
<header class="kopfleiste">
<button type="button" class="zurueck-knopf" id="zurueck" hidden>
<svg class="zurueck-knopf__pfeil" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.5 5.5 8 12l6.5 6.5" />
</svg>
<span class="zurueck-knopf__text">Zurück</span>
</button>
<p class="marke"><span class="marke__text"><a class="zurueck" href="start.html">Der&nbsp;Treff</a> · Meldungen</span></p>
<div class="kopfleiste__rechts">
<span class="wer" id="wer">…</span>
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
</div>
</header>
<main class="inhalt inhalt--breit">
<section class="kopf-zeile">
<div>
<p class="marke">Der Treff · Moderation</p>
<h1 class="titel">Meldungen &amp; Maßnahmen</h1>
<p class="unterzeile">
Was gemeldet wurde, was entfernt wurde und mit welcher Begründung –
alles an einer Stelle.
</p>
</div>
</section>
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
<!-- ==================================================================
WARUM DIESE SEITE EXISTIERT
Die Schnittstellen dafür gab es zuerst, die Seite nicht. Eine
Meldung, die nur über eine Schnittstelle abrufbar ist, ist keine
Meldung — sie ist eine Zeile in einer Datenbank, die niemand
liest. Der DSA verlangt in Artikel 16, dass Meldungen
BEARBEITET werden; ohne eine Stelle, an der sie erscheinen,
wäre das ein Versprechen an eine Schublade.
DREI ABSCHNITTE, IN DIESER REIHENFOLGE: Was liegt an (Meldungen)
— was wurde getan (Entfernt) — wer ist gesperrt (Maßnahmen). Wer
diese Seite öffnet, hat fast immer die erste Frage.
============================================================== -->
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Offene Meldungen</h2>
<div class="treff-abschnitt__inhalt">
<p>
Zu jeder Entscheidung gehört ein Satz. Er wird festgehalten –
auch damit man später weiß, warum etwas so entschieden wurde.
</p>
</div>
<div id="meldungen" aria-busy="true"><p class="leise">wird geladen …</p></div>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Entfernt</h2>
<div class="treff-abschnitt__inhalt">
<p>
Was entfernt wurde, und warum. Die Begründung überlebt den Beitrag –
das ist der Sinn der Sache
<span class="leise">(Digital Services Act, Artikel 17)</span>.
</p>
</div>
<div id="entfernt" aria-busy="true"><p class="leise">wird geladen …</p></div>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Die Mitglieder</h2>
<div class="treff-abschnitt__inhalt">
<p>
Wer da ist, seit wann, auf welcher Stufe – und wer gerade eine Pause
hat. Von hier aus wird gehandelt; die Stufe steht nur hier und
<strong>nie</strong> neben dem Namen im Treff.
</p>
</div>
<div id="mitglieder" aria-busy="true"><p class="leise">wird geladen …</p></div>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Hinweise, Pausen, Ausschlüsse</h2>
<div class="treff-abschnitt__inhalt">
<p id="leiter">…</p>
</div>
<div id="massnahmen" aria-busy="true"><p class="leise">wird geladen …</p></div>
</section>
</main>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/treff-moderation.js?v=202609111736" defer></script>
</body>
</html>
+198
View File
@@ -0,0 +1,198 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Regeln &amp; Hilfe · Der Treff</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/treff.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein. Auf der Adresse des
Treffs biegt der Server genau diesen Dateinamen auf crew-haus.css
um, und die Seite kommt schon beim ERSTEN Abruf in Lila und
Babyblau an -- ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
<div class="schleier" aria-hidden="true"></div>
<header class="kopfleiste">
<button type="button" class="zurueck-knopf" id="zurueck" hidden>
<svg class="zurueck-knopf__pfeil" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.5 5.5 8 12l6.5 6.5" />
</svg>
<span class="zurueck-knopf__text">Zurück</span>
</button>
<p class="marke"><span class="marke__text"><a class="zurueck" href="start.html">Der&nbsp;Treff</a> · Regeln</span></p>
<div class="kopfleiste__rechts">
<span class="wer" id="wer">…</span>
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
</div>
</header>
<main class="inhalt">
<section class="kopf-zeile">
<div>
<p class="marke">Der Treff</p>
<h1 class="titel">Regeln &amp; Hilfe</h1>
<p class="unterzeile">
Kurz, in normaler Sprache, und alles, was hier steht, gilt auch.
</p>
</div>
</section>
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
<!-- ==================================================================
WARUM DER TEXT HIER STEHT UND DIE ZAHLEN NICHT
Die Saetze sind Text und gehoeren in eine Datei. Die ZAHLEN
darin (sieben Tage, dreissig Tage, drei Beitraege, drei
Anschlaege, 18 Jahre) kommen vom Server -- sie stehen im Code,
und ein Regeltext, der eine andere Zahl nennt als die Regel, ist
schlimmer als gar keiner: Niemand merkt es, und wer es merkt,
glaubt dem Text.
Die Platzhalter tragen deshalb "…" als Vorgabe. Kommt die
Antwort nicht, steht dort sichtbar nichts -- und nicht
versehentlich eine Zahl von gestern.
============================================================== -->
<section class="treff-abschnitt" id="willkommen">
<h2 class="treff-abschnitt__titel">Schön, dass du da bist</h2>
<div class="treff-abschnitt__inhalt">
<p>
Der Treff ist der Ort rund um DogFather: Hier steht, was gerade gilt,
was ansteht, und hier redet ihr miteinander. Er ist nicht öffentlich –
du bist hier, weil dir jemand einen Code gegeben hat.
</p>
<p class="leise">
Gib diesen Code nicht weiter. Er gehört zu dir, und was damit
geschrieben wird, steht unter deinem Namen.
</p>
</div>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Die fünf Regeln</h2>
<div class="treff-abschnitt__inhalt">
<ol class="regel-liste">
<li><strong>Sei anständig.</strong> Keine Beleidigungen, keine Angriffe
auf Menschen, keine Hetze. Streiten über eine Sache ist in Ordnung –
über einen Menschen nicht.</li>
<li><strong>Keine privaten Daten.</strong> Keine Adressen, keine
Telefonnummern, keine Klarnamen von anderen. Auch nicht von dir
selbst, es liest mehr mit, als du denkst.</li>
<li><strong>Nichts Fremdes hochladen.</strong> Was du einstellst, muss
von dir sein oder dir gehören.</li>
<li><strong>Keine Werbung, kein Verkauf.</strong> Auch nicht für
andere Streams.</li>
<li><strong>Was hier passiert, bleibt hier.</strong> Keine
Bildschirmfotos nach draußen.</li>
</ol>
</div>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Wie du weiterkommst</h2>
<div class="treff-abschnitt__inhalt">
<p>
In der ersten Zeit kannst du lesen und bei Wünschen mitstimmen.
Das ist kein Misstrauen gegen dich – es ist eines gegen
Wegwerf-Zugänge.
</p>
<ul class="regel-liste" id="stufen">
<li class="leise">wird geladen …</li>
</ul>
</div>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Wenn etwas nicht stimmt</h2>
<div class="treff-abschnitt__inhalt">
<p>
An jedem Beitrag, der nicht von dir ist, steht <strong>melden</strong>.
Schreib in einem Satz, was daran nicht in Ordnung ist – ohne das
können wir es nicht prüfen.
</p>
<p>
Wir sehen uns jede Meldung an. Wird ein Beitrag entfernt, halten wir
fest, <strong>warum</strong> – und die Person, die ihn geschrieben hat,
erfährt den Grund. Das ist keine Höflichkeit, sondern
<span class="leise">Artikel 17 des Digital Services Act</span>.
</p>
<p class="leise" id="kontakt">
Du erreichst uns außerdem direkt: Schreib es auf das Brett
<a href="bereich.html?b=treff">Der Treff</a> oder sag es einem Modi.
</p>
<p class="leise">
Häufige Fragen und Antworten stehen auf dem Brett
<a href="bereich.html?b=regeln">Regeln &amp; Hilfe</a>.
</p>
</div>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Was passiert, wenn jemand sich nicht daran hält</h2>
<div class="treff-abschnitt__inhalt">
<p>Wir gehen der Reihe nach vor, und niemand fliegt ohne Ansage:</p>
<ol class="regel-liste">
<li><strong>Hinweis.</strong> Jemand sagt dir, was nicht ging.</li>
<li><strong>Pause.</strong> Ein paar Tage nichts schreiben. Lesen
geht weiter.</li>
<li><strong>Ende.</strong> Der Zugang wird geschlossen.</li>
</ol>
<p class="leise" id="ausschluss">…</p>
</div>
</section>
<section class="treff-abschnitt">
<h2 class="treff-abschnitt__titel">Das Kleingedruckte – kurz</h2>
<div class="treff-abschnitt__inhalt">
<p id="alter">…</p>
<p>
<strong>Was gespeichert wird:</strong> dein Name, dein Zugangscode
(verschlüsselt, niemand kann ihn lesen), was du schreibst, und wann
du dich anmeldest. Mehr nicht – kein Standort, keine Werbe-IDs,
keine Weitergabe an Dritte.
</p>
<p>
<strong>Du willst weg?</strong> Sag Bescheid. Dein Zugang wird
geschlossen und deine Beiträge werden entfernt.
</p>
<p class="leise">
Der Treff ist ein kleiner, geschlossener Bereich. Es gibt keine
Direktnachrichten zwischen Mitgliedern – absichtlich: Ein
öffentlicher Raum lässt sich moderieren, ein privater nicht.
</p>
</div>
</section>
</main>
<!-- WER kopf.js LAEDT, MUSS AUCH wahl.js LADEN (Regel seit 07.09.2026).
Der Umschalter "Meine Sicht" in der Kopfleiste ist ein selbst
gebautes Bedienelement; dahinter liegt ein unsichtbares Systemfeld
fuer Vorleseprogramme. Ohne wahl.js faellt kopf.js auf dieses Feld
zurueck -- ein grauer Kasten von 92 x 19 Pixeln mit weisser
Schrift, mitten in einer dunklen Leiste.
Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen
Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen,
die seit dem 07.09. jede Seite danach absucht. -->
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/treff-regeln.js?v=202609111736" defer></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "Der Treff",
"short_name": "Der Treff",
"description": "Der Treffpunkt rund um DogFather: Ansagen, Termine, Wuensche und das Gespraech. Nur mit persoenlichem Zugangscode.",
"start_url": "/workspace/start.html",
"id": "/workspace/start.html",
"scope": "/workspace/",
"display": "standalone",
"orientation": "any",
"lang": "de",
"dir": "ltr",
"background_color": "#0a121e",
"theme_color": "#06090f",
"icons": [
{
"src": "/assets/img/app-symbole/crew-192.png?v=202609111736",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/app-symbole/crew-512.png?v=202609111736",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111736",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111736",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+13 -13
View File
@@ -12,22 +12,22 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<!-- Formular- und Dialogstile: seit dem 07.09.2026 steht hier der
Dialog "Creator anlegen". -->
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -149,11 +149,11 @@
</main>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/uebersicht.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/uebersicht.js?v=202609111736" defer></script>
</body>
</html>
+13 -13
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111617" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111617" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111617" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609111736" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609111736" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/start.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202609111736" />
<link rel="stylesheet" href="assets/css/module.css?v=202609111736" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202609111617" />
<link rel="stylesheet" href="assets/css/haus.css?v=202609111736" />
</head>
<body class="start">
@@ -190,10 +190,10 @@
</main>
<script src="assets/js/wahl.js?v=202609111617" defer></script>
<script src="assets/js/bereiche.js?v=202609111617" defer></script>
<script src="assets/js/kopf.js?v=202609111617" defer></script>
<script src="assets/js/glocke.js?v=202609111617" defer></script>
<script src="assets/js/wissen.js?v=202609111617" defer></script>
<script src="assets/js/wahl.js?v=202609111736" defer></script>
<script src="assets/js/bereiche.js?v=202609111736" defer></script>
<script src="assets/js/kopf.js?v=202609111736" defer></script>
<script src="assets/js/glocke.js?v=202609111736" defer></script>
<script src="assets/js/wissen.js?v=202609111736" defer></script>
</body>
</html>