Filipe: "keine kryptischen oder technischen fehlermeldungen, sondern
klare aussagen darueber, was passiert ist und wie man das problem
loesen kann."
DAS PROBLEM, NACHGEMESSEN.
Der Server antwortet im Fehlerfall mit { fehler: "..." }. Darin stehen
ZWEI verschiedene Dinge, und von aussen sehen sie gleich aus:
Maschinenkennungen (nicht_verfuegbar, nicht_gefunden, ungueltig -- 43
verschiedene) und fertige deutsche Saetze.
An 84 Stellen stand `textContent = d.fehler` -- ungefiltert. Wer beim
Hochladen einer Datei Pech hatte, las woertlich "nicht_verfuegbar" auf
dem Bildschirm und wusste nicht einmal, ob er selbst schuld war.
Gezaehlt: 474 Antworten mit "nicht_verfuegbar", 392 mit
"nicht_gefunden", 125 mit "ungueltig".
DREI AUSGAENGE, NICHT ZWEI.
`sagWas()` in workspace/assets/js/meldung.js:
bekannte Kennung -> ihr Satz
unbekannte Kennung -> der Ersatzsatz der Stelle, NIE die Kennung
(sie geht in die Konsole, wo sie jemandem
auffaellt, der sie beheben kann)
fertiger Satz -> unveraendert durch
Erkannt wird eine Kennung daran, dass sie nur aus Kleinbuchstaben,
Ziffern und Unterstrichen besteht. Ein deutscher Satz hat immer
Leerzeichen; eine Kennung nie. Die Unterscheidung ist entschieden,
nicht geraten.
UND WARUM DIE TABELLE NICHT ALTERN KANN.
Eine von Hand gepflegte Liste ist hier schon zweimal teuer geworden
(die abgeschriebene Spaltenliste, die feste Umbruchschwelle). Deshalb
rechnet pruef-meldungen.mjs die Kennungen AUS DEM SERVER aus statt sie
zu kennen -- eine neue ohne Satz macht sie rot. Man kann es nicht mehr
vergessen.
Sie prueft drei Dinge und beweist zu jedem, dass sie auch "nicht in
Ordnung" sagen kann:
1. vollstaendig -- jede Kennung hat einen Satz
2. angeschlossen -- keine Rohanzeige, und jede Seite, deren Skript
sagWas benutzt, laedt auch meldung.js
3. lesbar -- kein Satz enthaelt Kennung, Zahl oder englisches Wort
GEFUNDEN HAT SIE SOFORT EINEN ECHTEN FEHLER: leistung.html laedt seine
Skripte ohne `defer` und fiel damit durch meinen Einbau. Dort waere
sagWas nicht definiert gewesen -- aus einer Fehlermeldung waere ein
Absturz geworden, also schlimmer als vorher.
NEBENBEFUND, DER MICH FAST EINE FALSCHE MELDUNG GEKOSTET HAETTE:
"alter_offen" heisst NICHT "etwas Aelteres ist offen", sondern
"Altersbestaetigung steht noch aus" (workspace.js:4864). Geraten
haette ich es falsch uebersetzt.
GEMESSEN: pruef-meldungen 8/0, alle 46 Skripte syntaktisch in Ordnung.
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(sagWas((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(sagWas((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(sagWas((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 */ }
|
||
})();
|
||
})();
|