Files
dogfather-universe/workspace/assets/js/hilfe.js
T
DogFatherGitandClaude Opus 5 f62ade3573 Vertraulicher Meldeweg, klappbare Abschnitte, lila Stunden, Logos
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]>
2026-09-19 02:04:03 +02:00

346 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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);
})();
})();