Eine Nacht voller Auftraege von Filipe. Der Reihe nach: 1. VERTRAULICH MELDEN -- der groesste neue Teil. "es soll auch eine kategorie also eine hauptkachel geben wo die community leute sich anonym melden koennen wenn sie probleme haben ... rechte hand und dogfather sollen zugriff auf diese anonyme nachrichten haben. also anonym fuer die anderen." WICHTIG, UND ES STEHT SO AUF DER SEITE: "anonym" heisst hier VERTRAULICH. DogFather und die rechte Hand sehen den Namen -- so bestellt und auch richtig, ohne Namen kann man niemandem helfen. Anonym ist es gegenueber allen anderen: kein Modi, kein anderes Mitglied. Wer glaubt, er schreibe unerkannt, schreibt anders und fuehlt sich hinterher getaeuscht -- deshalb steht der Satz im Formular, bevor jemand tippt. Der strikte Wechsel (melden -> warten -> antworten -> warten) ist im SERVER erzwungen, nicht nur im Knopf. Ein ausgegrauter Knopf ist keine Regel. Geschlossen wird nur von der Leitung; abgeschlossene Faelle werden nach 90 Tagen samt Nachrichten geloescht -- hier stehen die empfindlichsten Texte des Hauses. Modis sind ausdruecklich AUSSEN VOR: Sehr oft geht es in diesen Meldungen um eine Moderationsentscheidung. 2. JEDE KATEGORIE LAESST SICH ZUKLAPPEN. "man soll auf dieser seite jede kategorie auf und zu klappen koennen mit einem button ... ueberall wo so eine liste entstehen kann." Das Muster gab es auf der Startseite schon; es steht jetzt als `abschnittKlappbar` in kopf.js und wird von den Brettern und dem Zeitstrahl benutzt. Die Koepfe sind echte <button> (fuer die Tastatur), der Zustand haelt in localStorage, und er haengt je Brett UND Art -- sonst waere "Regel zugeklappt" ueberall gleichzeitig zu. 3. DIE SPRUNGLEISTE SIEHT AUS WIE ETWAS ZUM ANFASSEN. Vorher unterstrichene Woerter, die aussahen wie eine Fusszeile. Jetzt Marken mit Rahmen in einem eigenen Block. 4. DIE STUNDEN SIND LILA. UMGERECHNET, NICHT NEU GEWAEHLT: Jeder der fuenf Verlaufsstopps wurde nach OKLCH zerlegt, der Farbton auf 303 Grad gedreht, zurueckgerechnet. Helligkeit und Buntheit blieben gleich -- eine frei gegriffene Palette waere heller oder bunter geworden, und die Uhr damit unruhiger. 5. DIE KARTEN "UNSERE SEITEN": babyblau und lila, mit schwebenden Logos. Beide Logos wurden zu MASKEN gerechnet (tools/logo-maske.mjs): Die PNG tragen nur Alpha, die Farbe kommt aus dem CSS. Dadurch passt dasselbe Logo in jede Kachel, egal welchen Ton sie hat -- und VanVans 378-KB- Logo wurde dabei zu 109 KB. Vier Stueck je Karte, verschieden gross, zwei Bahnen, 26 bis 41 Sekunden Umlauf. Der erste Versuch war mit 5-9 % Deckkraft praktisch unsichtbar; jetzt 10-16 %. 6. "ENTWICKLUNG" HEISST JETZT "EURE AUFGABEN". Filipe hat recht: Die Kachel fuehrt seit dem 11.09. auf den Katalog -- eine Aufgabenliste. Der Name blieb stehen, weil beim Umhaengen des Ziels niemand ihn angefasst hat. Der Name "Entwicklung" ist damit frei fuer das, was er darunter versteht (Auswertung je Person mit Verlauf und Text) -- das ist noch NICHT gebaut. DREI PRUEFUNGEN, DIE ZUFAELLIG GRUEN WAREN, sind nebenbei aufgefallen: - pruef-kachelraster wartete feste 500 ms auf eine gestaffelte Einblendung und mass vier Reihen, wo drei sind. Jetzt reducedMotion. - pruef-start-ansicht scrollte 600 px und nahm an, danach liege keine Kachel mehr unter dem Zeiger. - pruef-wege-nach-draussen zaehlte das Kachelraster ueber ALLE Gruppen statt je Gruppe. GEMESSEN: pruef-hilfe (neu) 55/0, pruef-wege-nach-draussen 64/0, pruef-jeder-hat-eine-seite 65/0, pruef-community-sicht 10/0, pruef-bereiche-lesend, pruef-steckbrief, pruef-start-ansicht, pruef-kachelraster alle gruen. Co-Authored-By: Claude Opus 5 <[email protected]>
346 lines
13 KiB
JavaScript
346 lines
13 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;
|
||
knopf.title = voll
|
||
? `Du hast ${offeneEigene} offene Meldungen. Warte auf eine Antwort, `
|
||
+ 'bevor du die nächste aufmachst.'
|
||
: '';
|
||
}
|
||
|
||
/* =====================================================================
|
||
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) {
|
||
$('antwort-stand').textContent =
|
||
j.fehler === 'nicht_dran' ? 'Die andere Seite ist gerade am Zug.'
|
||
: j.fehler === 'geschlossen' ? 'Dieser Fall ist schon abgeschlossen.'
|
||
: 'Ging nicht.';
|
||
$('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) {
|
||
const wort = prompt('Ein Schlusswort? (Kann auch leer bleiben)');
|
||
if (wort === null) return;
|
||
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) {
|
||
stand.textContent =
|
||
j.fehler === 'zu_viele_offen'
|
||
? `Du hast schon ${j.offen} offene Meldungen. Warte auf eine Antwort.`
|
||
: j.fehler === 'text_zu_kurz' ? 'Schreib bitte ein paar Sätze mehr.'
|
||
: 'Ging nicht.';
|
||
$('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);
|
||
})();
|
||
})();
|