Aus dem Plan im Vault. Diese Stufe steht dort bewusst VOR Galerie und
Gespraech: Mehr Sichtbarkeit heisst mehr Angriffsflaeche, und ein
Bereich, der waechst und keine Bremse hat, waechst genau einmal.
DER TREFF HATTE SCHON MEHR, ALS MEIN PLAN ANNAHM
Stufen (WER schreiben darf: neu, dabei, stamm) und Massnahmen (wer
NICHT MEHR darf: Hinweis, Pause, Ausschluss). Gefehlt hat die Frage
dazwischen: WIE SCHNELL. Ein Stammgast konnte in einer Minute vierzig
Beitraege absetzen -- aus Aerger, aus Versehen oder per Skript.
DIE STILLE SPERRE IST VERWORFEN -- nach einer Messung, nicht nach
einem Gefuehl. `gast` ist eine Rolle mit Zugangscode, es gibt KEINE
Selbstanmeldung. Wer ausgeschlossen wird, kann sich nicht neu
anmelden. Damit loest ein Shadowban ein Problem, das dieses System
nicht hat; er bliebe ein Werkzeug, das Menschen taeuscht, ohne etwas
zu verhindern. Steht jetzt in §9 des Plans: was wir NICHT bauen.
DIE BREMSE: 10 Beitraege je Stunde und Person, nur auf den
Community-Brettern. Keine neue Tabelle -- die Antwort steht schon in
`eintraege`, eine Zaehlung ueber die letzte Stunde genuegt. Eine eigene
Tabelle waere ein zweiter Ort fuer dieselbe Wahrheit und muesste
zusaetzlich aufgeraeumt werden.
Die Absage sagt, WANN es weitergeht ("In 37 Minuten"). Ohne Zeitangabe
probiert jemand im Minutentakt weiter -- genau die Last, die man
verhindern wollte.
MEIN ERSTER ENTWURF BREMSTE DIE FALSCHE ARBEIT: Er zaehlte ALLE
Eintraege. Eine Creatorin mit zehn Content-Ideen haette danach im Treff
nichts mehr schreiben koennen. Eine Bremse, die das richtige Verhalten
bestraft, wird abgeschaltet -- und ist ab da wirkungslos.
DIE TEAM-SICHT: "Was hereinkommt", ganz oben auf der Moderationsseite,
vor den offenen Meldungen. Eine Meldung setzt voraus, dass jemand etwas
GESEHEN hat -- und wer acht Bretter durchklicken muss, tut das nicht
achtmal am Tag. Eine Zeile je Beitrag statt einer Karte: Hier
ueberfliegt man, man liest nicht. Der Inhalt steht auf dem Brett; eine
Vorschau waere eine zweite Stelle, an der derselbe Beitrag steht.
NEU: pruef-bremse (16 Pruefungen)
Die Grenze wird aus dem Code GELESEN, nicht abgeschrieben -- sonst
misst die Pruefung beim naechsten Anpassen etwas anderes als der
Server tut. Dazu die drei Fragen: Greift sie? Trifft sie die richtige
Arbeit (Content bleibt offen, das zweite Treff-Brett nicht)? Und laesst
sie den normalen Fall in Ruhe? Gegenprobe: Beitraege auf vorgestern
zurueckdatiert -- danach muss es sofort wieder gehen.
pruef-treff 66, pruef-wunschliste 30, pruef-countdown 22,
pruef-bereiche-lesend, pruef-css-klassen: gruen.
Co-Authored-By: Claude Opus 5 <[email protected]>
371 lines
15 KiB
JavaScript
371 lines
15 KiB
JavaScript
/* =====================================================================
|
||
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 das Team 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 ---------------------------------------------------- */
|
||
/* WAS HEREINKOMMT -- alle Community-Bretter in einer Liste.
|
||
Nicht der Inhalt, nur: was, wo, von wem, wann. Wer mehr will,
|
||
klickt auf das Brett. Eine Vorschau des Textes hier waere eine
|
||
zweite Stelle, an der derselbe Beitrag steht. */
|
||
const BRETT_NAME = {
|
||
anschlag: 'Anschlagbrett', ansteht: 'Was ansteht', treff: 'Der Treff',
|
||
wunsch: 'Wunschliste', highlight: 'Highlights', regeln: 'Regeln & Hilfe',
|
||
mitmachen: 'Mitmachen',
|
||
};
|
||
|
||
async function zulaufLaden() {
|
||
const ziel = document.getElementById('zulauf');
|
||
if (!ziel) return;
|
||
const a = await hole('/workspace/api/treff/zulauf');
|
||
if (!a.ok) { ziel.textContent = ''; ziel.append(el('p', 'leise', 'Ging nicht.')); return; }
|
||
const { zulauf } = await a.json();
|
||
ziel.textContent = '';
|
||
ziel.setAttribute('aria-busy', 'false');
|
||
if (!zulauf.length) {
|
||
/* Ein Satz statt einer leeren Flaeche -- und einer, der nicht wie
|
||
ein Fehler klingt. */
|
||
ziel.append(el('p', 'leer-hinweis', 'Noch nichts hereingekommen.'));
|
||
return;
|
||
}
|
||
const liste = el('ul', 'zulauf');
|
||
for (const e of zulauf) {
|
||
const z = el('li', 'zulauf__zeile');
|
||
const brett = el('span', 'zulauf__brett', BRETT_NAME[e.bereich] || e.bereich);
|
||
brett.dataset.brett = e.bereich;
|
||
z.append(brett);
|
||
const a2 = el('a', 'zulauf__titel', e.titel || '(ohne Titel)');
|
||
a2.href = `/workspace/bereich.html?b=${encodeURIComponent(e.bereich)}#e${e.id}`;
|
||
z.append(a2);
|
||
z.append(el('span', 'zulauf__wer',
|
||
(e.von_name || 'unbekannt') + (e.von_rolle_name ? ' · ' + e.von_rolle_name : '')));
|
||
z.append(el('span', 'zulauf__wann', zeitKurz(e.erstellt)));
|
||
liste.append(z);
|
||
}
|
||
ziel.append(liste);
|
||
}
|
||
|
||
/* "vor 3 Min" statt eines Zeitstempels: Bei einer Moderationsliste
|
||
ist die Frage "wie frisch", nicht "wann genau". */
|
||
function zeitKurz(iso) {
|
||
const ms = Date.now() - Date.parse(iso);
|
||
if (!Number.isFinite(ms)) return '';
|
||
const min = Math.floor(ms / 60000);
|
||
if (min < 1) return 'gerade eben';
|
||
if (min < 60) return `vor ${min} Min`;
|
||
const std = Math.floor(min / 60);
|
||
if (std < 24) return `vor ${std} Std`;
|
||
const tage = Math.floor(std / 24);
|
||
return tage === 1 ? 'gestern' : `vor ${tage} Tagen`;
|
||
}
|
||
|
||
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 ${r.rolle_name} kann `
|
||
+ `bis zu ${r.pause_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();
|
||
/* ZUERST DER ZULAUF: Er steht oben auf der Seite, und eine Seite,
|
||
die von unten nach oben fuellt, wackelt beim Aufbau. */
|
||
await zulaufLaden();
|
||
await meldungen();
|
||
await entfernt();
|
||
await mitglieder();
|
||
await massnahmen();
|
||
} catch { /* umgeleitet */ }
|
||
})();
|
||
})();
|