/* ===================================================================== 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 */ } })(); })();