Filipe: "Es darf vor allem keine Stellen geben, an denen ein Benutzer
etwas falsch machen kann, nur weil die Seite es nicht verstaendlich
genug erklaert."
Gemessen: 30 Stellen in 16 Dateien benutzten confirm() oder prompt().
Das Haus hatte die richtige Bauweise laengst -- einen <dialog>, in
aufgaben.html sogar ausfuehrlich begruendet -- aber sie stand IN EINER
SEITE. Wer anderswo etwas loeschen liess, hatte sie nicht.
confirm('Wirklich loeschen?') stellt die falsche Frage: Es fragt, ob
man sicher ist, und nennt nicht, WAS passiert, was BLEIBT und ob es
ZURUECK geht. Jetzt beantwortet jeder der 41 Dialoge alle drei.
Neu: workspace/assets/js/nachfrage.js -- window.frageNach() mit
Pflichtgrund, Zahlenfeld, einzeiliger Eingabe und Abtippsicherung.
Drei Ausgaenge: <dialog> / confirm()-Notnagel fuer Safari vor 15.4 /
Abbruch (Esc, Klick daneben, "Doch nicht" -- immer false).
DREIMAL DERSELBE FALLSTRICK, dreimal nachgemessen statt vermutet:
.dialog stand in aufgaben.css und leistung.css -> auf dateien.html
waere der Dialog ein weisser Systemkasten gewesen. 14 Regeln
klammergenau nach module.css verschoben (Klammern gezaehlt, nicht
per Muster geschnitten -- heute frueh hat ein nicht-gieriges
Muster schon einmal CSS zerrissen).
Das Formular trug .neu neu--blank -- und .neu gibt seine Abstaende
nur in aufgaben.css. Gemessen: padding 0px, und die Felder
verloren ihre height:44px. Jetzt steht alles unter
.nachfrage__form in module.css; der Dialog borgt nichts mehr.
Die erste Fassung der Pruefung zaehlte nachfrage.js SELBST als
Nutzer -- damit war jede Seite trivialerweise "Nutzer" und die
Pruefung gruen ohne Inhalt. Jetzt ausdruecklich ausgenommen.
ZWEI FUNDE NEBENBEI:
hilfeAufraeumen() wird im Betrieb NIE aufgerufen. Der Kommentar
behauptete "wird beim Start aufgerufen (siehe index.js)" -- das
war nie wahr; einziger Aufrufer ist die eigene Pruefung. Folge:
geschlossene vertrauliche Faelle bleiben unbegrenzt stehen. NICHT
eingeschaltet (das loescht echte Daten und ist Filipes
Entscheidung), sondern der Kommentar richtiggestellt.
Einen Hilfe-Fall zu schliessen ist endgueltig -- es gibt keine
Route, die ihn wieder oeffnet. Vorher stand darueber nur die
Frage nach einem Schlusswort. Jetzt sagt der Dialog es.
pruef-struktur hat meine eigene Pruefung von heute Nachmittag
erwischt: Sie bildete ihr Datum aus UTC. Beim Beheben erst
heuteLokal(datum) genommen -- die Funktion nimmt gar kein Argument
und haette still "heute" statt "+3 Tage" geliefert. Jetzt tagLokal(3),
nachgerechnet: Abstand 3 Tage.
Am Bildschirm angesehen (Rechner 1280, Handy 390): passt rein, Esc
ergibt false, Fokus liegt auf dem harmlosen Knopf, Knoepfe 44px auf
Touch. Der Platzhalter im Abtippfeld zeigte den erwarteten Namen --
das sah aus wie ein schon ausgefuelltes Feld, entfernt.
Neu: server/pruef-nachfrage.mjs -- 17/0, mit sechs Gegenproben und
beiden Richtungen (wer fragt, laedt die Datei; wer nie fragt, laedt
sie nicht -- sonst truege die Anmeldewand 4,8 KB fuer nichts).
pruef-meldungen 8/0, pruef-css-klassen gruen, pruef-struktur gruen,
pruef-leistung gruen.
388 lines
15 KiB
JavaScript
388 lines
15 KiB
JavaScript
/* =====================================================================
|
||
VERTRAULICH MELDEN (19.09.2026)
|
||
|
||
Eine Seite, zwei Rollen: Ein Mitglied sieht seine eigenen Meldungen,
|
||
DogFather und die rechte Hand sehen alle. WELCHE das sind, entscheidet
|
||
der Server — diese Datei zeigt nur, was sie bekommt.
|
||
|
||
DIE REGEL "IMMER ABWECHSELND" STEHT NICHT HIER. Sie steht im Server
|
||
(`ich_bin_dran`), und diese Seite fragt danach. Zwei Stellen mit
|
||
derselben Regel wären zwei Stellen, die auseinanderlaufen — und die
|
||
im Browser wäre die, an der man sie umgehen kann.
|
||
|
||
KEIN innerHTML mit fremdem Text. Alles, was jemand geschrieben hat,
|
||
geht durch `textContent`. Hier landen Texte aus einem Streit —
|
||
ausgerechnet die will man nicht als HTML ausführen.
|
||
===================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const el = (was, klasse, text) => {
|
||
const e = document.createElement(was);
|
||
if (klasse) e.className = klasse;
|
||
if (text !== undefined) e.textContent = text;
|
||
return e;
|
||
};
|
||
|
||
let leitung = false;
|
||
let offeneEigene = 0;
|
||
let offenMax = 3;
|
||
|
||
/* ---- Wörter für die Zustände ----------------------------------------
|
||
|
||
Sie stehen hier als Anzeige-Texte, nicht als Regel. Der Server
|
||
schickt den Schlüssel; was daraus auf dem Bildschirm wird, ist eine
|
||
Frage der Sprache und darf sich ändern, ohne dass jemand die Logik
|
||
anfasst. */
|
||
const STAND_WORT = {
|
||
wartet_auf_antwort: { kurz: 'wartet auf Antwort', wer: 'leitung' },
|
||
antwort_da: { kurz: 'Antwort da', wer: 'melder' },
|
||
geschlossen: { kurz: 'abgeschlossen', wer: 'niemand' },
|
||
};
|
||
|
||
function melde(text) {
|
||
const f = $('fehler');
|
||
if (!f) return;
|
||
f.textContent = text || '';
|
||
f.hidden = !text;
|
||
}
|
||
|
||
async function hole(pfad, einstellungen) {
|
||
const a = await fetch(pfad, { credentials: 'same-origin', ...einstellungen });
|
||
if (a.status === 401) { location.href = 'index.html'; throw new Error('weg'); }
|
||
return a;
|
||
}
|
||
|
||
function datum(roh) {
|
||
if (!roh) return '';
|
||
const d = new Date(roh);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
return d.toLocaleString('de-DE',
|
||
{ day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE LISTE
|
||
===================================================================== */
|
||
async function listeLaden() {
|
||
const ziel = $('liste');
|
||
try {
|
||
const a = await hole('/workspace/api/hilfe');
|
||
if (!a.ok) { melde('Die Liste ließ sich nicht laden.'); return; }
|
||
const d = await a.json();
|
||
leitung = !!d.leitung;
|
||
offeneEigene = d.offen_eigene ?? 0;
|
||
offenMax = d.offen_max ?? 3;
|
||
|
||
/* Die Seite heißt für beide anders — und das ist kein Schmuck:
|
||
"Deine Meldungen" wäre für DogFather schlicht falsch. */
|
||
$('seiten-titel').textContent = leitung ? 'Vertrauliche Meldungen' : 'Vertraulich melden';
|
||
$('listen-titel').textContent = leitung ? 'Alle Meldungen' : 'Deine Meldungen';
|
||
$('neu-block').hidden = leitung;
|
||
|
||
if (leitung) {
|
||
$('seiten-unter').textContent =
|
||
'Was die Community vertraulich gemeldet hat. Du siehst den Namen; sonst niemand.';
|
||
$('vertrauen-text').textContent =
|
||
'Diese Meldungen sehen nur du und die rechte Hand. Weder Modis noch '
|
||
+ 'andere Mitglieder haben Zugriff — auch nicht auf die Namen.';
|
||
}
|
||
|
||
const faelle = d.faelle || [];
|
||
$('listen-zahl').textContent = faelle.length
|
||
? `${faelle.length}` : '';
|
||
|
||
ziel.textContent = '';
|
||
ziel.removeAttribute('aria-busy');
|
||
|
||
if (!faelle.length) {
|
||
const leer = el('p', 'hilfe-leer', leitung
|
||
? 'Nichts gemeldet. Das ist die beste Nachricht auf dieser Seite.'
|
||
: 'Du hast noch nichts gemeldet. Gut so – und wenn doch mal etwas ist, '
|
||
+ 'ist der Weg hier.');
|
||
ziel.append(leer);
|
||
knopfStand();
|
||
return;
|
||
}
|
||
|
||
for (const f of faelle) ziel.append(zeileBauen(f));
|
||
knopfStand();
|
||
} catch (fehler) {
|
||
if (String(fehler.message) !== 'weg') melde('Keine Verbindung.');
|
||
}
|
||
}
|
||
|
||
function zeileBauen(f) {
|
||
const k = el('article', 'fall-zeile');
|
||
k.dataset.stand = f.stand;
|
||
k.tabIndex = 0;
|
||
k.setAttribute('role', 'button');
|
||
|
||
const oben = el('div', 'fall-zeile__oben');
|
||
const marke = el('span', 'fall-marke', STAND_WORT[f.stand]?.kurz || f.stand);
|
||
marke.dataset.stand = f.stand;
|
||
oben.append(marke);
|
||
oben.append(el('h3', 'fall-zeile__titel', f.betreff));
|
||
k.append(oben);
|
||
|
||
const unten = el('p', 'fall-zeile__unten');
|
||
/* Der Name nur, wenn der Server ihn geschickt hat — er kommt gar
|
||
nicht erst mit, wenn jemand anderes fragt. */
|
||
if (f.melder) unten.append(el('span', 'fall-zeile__wer', f.melder));
|
||
unten.append(el('span', 'fall-zeile__zeit', datum(f.geaendert)));
|
||
unten.append(el('span', 'fall-zeile__anzahl',
|
||
f.nachrichten === 1 ? '1 Nachricht' : `${f.nachrichten} Nachrichten`));
|
||
k.append(unten);
|
||
|
||
const auf = () => fallOeffnen(f.id);
|
||
k.addEventListener('click', auf);
|
||
k.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); auf(); }
|
||
});
|
||
return k;
|
||
}
|
||
|
||
/** Darf noch eine Meldung aufgemacht werden? */
|
||
function knopfStand() {
|
||
const knopf = $('neu-auf');
|
||
if (!knopf || leitung) return;
|
||
const voll = offeneEigene >= offenMax;
|
||
knopf.disabled = voll;
|
||
/* DER GRUND STAND NUR IM `title` (geändert 19.09.2026).
|
||
|
||
Auf dem Handy gibt es kein Überfahren mit der Maus -- und die
|
||
Community kommt von TikTok, also vom Handy. Ein grauer Knopf
|
||
ohne Text sieht kaputt aus; wer ein akutes Problem hat, hält das
|
||
für eine Panne und geht wieder.
|
||
|
||
Ein Vorleseprogramm liest einen gesperrten Knopf ohnehin nicht
|
||
vor. Der Satz muss also sichtbar daneben stehen, nicht darin. */
|
||
const satz = voll
|
||
? `Du hast ${offeneEigene} offene Meldungen. Sobald eine davon `
|
||
+ 'beantwortet ist, kannst du eine neue aufmachen.'
|
||
: '';
|
||
knopf.title = satz;
|
||
let hinweis = document.getElementById('neu-gesperrt');
|
||
if (voll && !hinweis) {
|
||
hinweis = document.createElement('p');
|
||
hinweis.id = 'neu-gesperrt';
|
||
hinweis.className = 'h-gesperrt';
|
||
knopf.insertAdjacentElement('afterend', hinweis);
|
||
}
|
||
if (hinweis) { hinweis.textContent = satz; hinweis.hidden = !voll; }
|
||
}
|
||
|
||
/* =====================================================================
|
||
EIN FALL
|
||
===================================================================== */
|
||
async function fallOeffnen(id) {
|
||
try {
|
||
const a = await hole('/workspace/api/hilfe/' + id);
|
||
if (a.status === 404) { melde('Diese Meldung gibt es nicht (mehr).'); return; }
|
||
if (!a.ok) { melde('Der Fall ließ sich nicht öffnen.'); return; }
|
||
const d = await a.json();
|
||
fallZeigen(d);
|
||
/* Die Adresse merkt sich den Fall — damit eine Benachrichtigung
|
||
direkt hierher führen kann und ein Neuladen nicht zur Liste
|
||
zurückwirft. */
|
||
history.replaceState(null, '', 'hilfe.html?fall=' + id);
|
||
} catch (fehler) {
|
||
if (String(fehler.message) !== 'weg') melde('Keine Verbindung.');
|
||
}
|
||
}
|
||
|
||
function fallZeigen(d) {
|
||
const f = d.fall;
|
||
$('fall-block').hidden = false;
|
||
$('fall-titel').textContent = f.betreff;
|
||
|
||
const kopf = $('fall-kopf');
|
||
kopf.textContent = '';
|
||
const marke = el('span', 'fall-marke', STAND_WORT[f.stand]?.kurz || f.stand);
|
||
marke.dataset.stand = f.stand;
|
||
kopf.append(marke);
|
||
if (f.melder) kopf.append(el('span', 'fall-kopf__wer', 'von ' + f.melder));
|
||
kopf.append(el('span', 'fall-kopf__zeit', 'seit ' + datum(f.erstellt)));
|
||
|
||
const ziel = $('verlauf');
|
||
ziel.textContent = '';
|
||
for (const n of d.nachrichten || []) {
|
||
const b = el('div', 'blase');
|
||
b.dataset.seite = n.von_leitung ? 'leitung' : 'melder';
|
||
b.append(el('p', 'blase__wer', n.von_leitung ? 'DogFather / rechte Hand' : (f.melder || 'Du')));
|
||
/* Zeilenumbrüche erhalten, ohne HTML zuzulassen: white-space im
|
||
CSS, Text über textContent. */
|
||
b.append(el('p', 'blase__text', n.text));
|
||
const fuss = el('p', 'blase__fuss', datum(n.erstellt));
|
||
if (!n.von_leitung && n.gelesen) fuss.textContent += ' · gelesen';
|
||
b.append(fuss);
|
||
ziel.append(b);
|
||
}
|
||
|
||
const form = $('antwort-form');
|
||
const warten = $('fall-warten');
|
||
const zuKnopf = $('fall-schliessen');
|
||
|
||
zuKnopf.hidden = !(d.leitung && f.stand !== 'geschlossen');
|
||
zuKnopf.onclick = () => fallSchliessen(f.id);
|
||
|
||
if (f.stand === 'geschlossen') {
|
||
form.hidden = true;
|
||
warten.hidden = false;
|
||
warten.textContent = 'Dieser Fall ist abgeschlossen'
|
||
+ (f.geschlossen_am ? ' (' + datum(f.geschlossen_am) + ')' : '') + '.';
|
||
} else if (d.ich_bin_dran) {
|
||
form.hidden = false;
|
||
warten.hidden = true;
|
||
$('antwort-schild').textContent = d.leitung ? 'Deine Antwort' : 'Deine Antwort';
|
||
form.onsubmit = (e) => { e.preventDefault(); antwortSenden(f.id); };
|
||
} else {
|
||
form.hidden = true;
|
||
warten.hidden = false;
|
||
/* DER SATZ SAGT, WER DRAN IST. "Bitte warten" allein lässt offen,
|
||
worauf — und ob überhaupt noch etwas kommt. */
|
||
warten.textContent = d.leitung
|
||
? 'Die andere Seite ist am Zug. Sobald sie antwortet, kannst du wieder schreiben.'
|
||
: 'Deine Meldung ist angekommen. Sobald eine Antwort da ist, siehst du sie hier '
|
||
+ '– und bekommst eine Benachrichtigung, wenn du sie erlaubt hast.';
|
||
}
|
||
|
||
$('fall-block').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
async function antwortSenden(id) {
|
||
const feld = $('antwort-text');
|
||
const text = feld.value.trim();
|
||
if (text.length < 2) { $('antwort-stand').textContent = 'Da fehlt noch etwas.'; return; }
|
||
$('antwort-senden').disabled = true;
|
||
$('antwort-stand').textContent = 'wird gesendet …';
|
||
try {
|
||
const a = await hole('/workspace/api/hilfe/' + id + '/antwort', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text }),
|
||
});
|
||
const j = await a.json().catch(() => ({}));
|
||
if (!a.ok) {
|
||
/* ÜBER sagWas (19.09.2026). Hier stand eine eigene kleine
|
||
Tabelle für zwei Kennungen -- alles andere endete bei „Ging
|
||
nicht.". Ausgerechnet auf der Seite, auf der jemand mit einem
|
||
Problem sitzt, war das die inhaltsleerste Absage im Haus.
|
||
|
||
Die Sätze für `nicht_dran` und `geschlossen` stehen jetzt in
|
||
meldung.js, zusammen mit allen anderen. Zwei Tabellen
|
||
nebeneinander laufen beim nächsten Nachschärfen auseinander. */
|
||
$('antwort-stand').textContent =
|
||
sagWas(j.fehler, 'Die Antwort ließ sich nicht abschicken. Dein Text steht noch da.');
|
||
$('antwort-senden').disabled = false;
|
||
return;
|
||
}
|
||
feld.value = '';
|
||
$('antwort-stand').textContent = 'gesendet';
|
||
await fallOeffnen(id);
|
||
await listeLaden();
|
||
} catch { $('antwort-stand').textContent = 'Keine Verbindung.'; }
|
||
$('antwort-senden').disabled = false;
|
||
}
|
||
|
||
async function fallSchliessen(id) {
|
||
/* DER EINZIGE WEG IST VORWAERTS. Gemessen am 19.09.2026: Es gibt
|
||
keine Route, die einen geschlossenen Fall wieder oeffnet -- weder
|
||
fuer die Leitung noch fuer die meldende Person. Ein Klick hier
|
||
ist also endgueltig, und vorher stand darueber nur die Frage
|
||
nach einem Schlusswort. Wer sie las, konnte nicht ahnen, dass
|
||
damit der Fall zugeht. */
|
||
const antwort = await frageNach({
|
||
titel: 'Diesen Fall abschließen?',
|
||
was: 'Der Fall gilt danach als erledigt. Es kann niemand mehr '
|
||
+ 'antworten – auch die Person nicht, die ihn gemeldet hat.',
|
||
bleibt: 'Der ganze Verlauf bleibt lesbar. Wieder aufmachen lässt '
|
||
+ 'sich der Fall aber nicht – dafür müsste er neu gemeldet werden.',
|
||
endgueltig: true,
|
||
ja: 'Abschließen',
|
||
grund: 'Ein Schlusswort für die Person? (darf leer bleiben)',
|
||
platzhalter: 'z. B. Wir haben mit beiden gesprochen, es ist geklärt.',
|
||
});
|
||
if (!antwort) return;
|
||
const wort = antwort.grund;
|
||
try {
|
||
const a = await hole('/workspace/api/hilfe/' + id + '/schliessen', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: wort }),
|
||
});
|
||
if (!a.ok) { melde('Ließ sich nicht abschließen.'); return; }
|
||
await fallOeffnen(id);
|
||
await listeLaden();
|
||
} catch { melde('Keine Verbindung.'); }
|
||
}
|
||
|
||
/* =====================================================================
|
||
NEUE MELDUNG
|
||
===================================================================== */
|
||
$('neu-auf')?.addEventListener('click', () => {
|
||
$('neu-form').hidden = false;
|
||
$('neu-auf').hidden = true;
|
||
$('neu-betreff').focus();
|
||
});
|
||
$('neu-zu')?.addEventListener('click', () => {
|
||
$('neu-form').hidden = true;
|
||
$('neu-auf').hidden = false;
|
||
$('neu-stand').textContent = '';
|
||
});
|
||
|
||
$('neu-form')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const betreff = $('neu-betreff').value.trim();
|
||
const text = $('neu-text').value.trim();
|
||
const stand = $('neu-stand');
|
||
if (betreff.length < 3) { stand.textContent = 'Eine kurze Überschrift fehlt noch.'; return; }
|
||
if (text.length < 10) { stand.textContent = 'Schreib bitte ein paar Sätze mehr.'; return; }
|
||
|
||
$('neu-senden').disabled = true;
|
||
stand.textContent = 'wird gesendet …';
|
||
try {
|
||
const a = await hole('/workspace/api/hilfe', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ betreff, text }),
|
||
});
|
||
const j = await a.json().catch(() => ({}));
|
||
if (!a.ok) {
|
||
/* Die Zahl der offenen Meldungen kennt nur der Server -- der
|
||
eine Fall bleibt deshalb hier. Alles andere über sagWas. */
|
||
stand.textContent = j.fehler === 'zu_viele_offen'
|
||
? `Du hast schon ${j.offen} offene Meldungen. Warte auf eine Antwort, `
|
||
+ 'bevor du die nächste aufmachst.'
|
||
: sagWas(j.fehler, 'Die Meldung ließ sich nicht abschicken. Dein Text steht noch da.');
|
||
$('neu-senden').disabled = false;
|
||
return;
|
||
}
|
||
$('neu-betreff').value = '';
|
||
$('neu-text').value = '';
|
||
$('neu-form').hidden = true;
|
||
$('neu-auf').hidden = false;
|
||
stand.textContent = '';
|
||
await listeLaden();
|
||
if (j.id) await fallOeffnen(j.id);
|
||
} catch { stand.textContent = 'Keine Verbindung.'; }
|
||
$('neu-senden').disabled = false;
|
||
});
|
||
|
||
$('fall-zurueck')?.addEventListener('click', () => {
|
||
$('fall-block').hidden = true;
|
||
history.replaceState(null, '', 'hilfe.html');
|
||
document.querySelector('.hilfe-block')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
|
||
/* ---- Los ------------------------------------------------------------ */
|
||
(async () => {
|
||
await listeLaden();
|
||
/* Kommt jemand über eine Benachrichtigung, steht der Fall in der
|
||
Adresse — dann direkt aufmachen, statt ihn suchen zu lassen. */
|
||
const fall = new URLSearchParams(location.search).get('fall');
|
||
if (fall && /^\d+$/.test(fall)) await fallOeffnen(fall);
|
||
})();
|
||
})();
|