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
+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

+257 -4
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,10 +796,18 @@
"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
? ' 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.');
+ (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.');
}
for (const [wert, name] of Object.entries(einstellung.arten)) {
+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.';
}
})();
})();