Files
dogfather-universe/workspace/assets/js/support.js
T
DogFatherGitandClaude Opus 5 9f5dbb42e3 Support: melden mit Bild, und für die Leitung ein Eingang
Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder
benutzen kann. jeder kann da probleme von der seite melden und
hinweisen. fotos mit schicken mit einem text. … so einfach wie
möglich, will nichts kompliziertes oder zu viel. kurz und knapp …
bei dogfather und der rechten hand soll die kachel anders gebaut sein
weil die sind die die sich um die probleme kümmern."

WARUM DAS NICHT DER VORHANDENE MELDEWEG IST. Es gibt ihn schon
(hilfe.html) und er sieht aehnlich aus. Drei Unterschiede machen ihn
zu etwas anderem:
  1. Er ist NICHT fuer alle -- in rechte.js steht
     `["gast","hand","admin"]`. Modis, Creator, Scouts, Manager und
     die linke Hand hatten gar keinen Weg, einen kaputten Knopf zu
     melden.
  2. Er kann keine Bilder. Bei „der Knopf tut nichts" ist ein
     Bildschirmfoto die halbe Antwort.
  3. Er ist bewusst schwer: strikter Wechsel, hoechstens drei offene
     Faelle, ein Betreff. Richtig bei einem Vorfall zwischen
     Menschen, zu viel fuer „das Datum steht falsch da".

Deshalb eigen und sehr kurz: EIN Feld, EIN Bild, fertig. Kein
Betreff, keine Kategorie, keine Dringlichkeitsstufe -- wer ein
Formular mit fuenf Feldern sieht, meldet den kleinen Fehler nicht,
und genau die kleinen erfaehrt sonst niemand.

DIE LEITUNG SIEHT ETWAS ANDERES: einen Eingang mit Zahlenband
(neu / in Arbeit / erledigt), drei Filtern und zwei Handgriffen --
„Ich kuemmere mich" und „Erledigt …". Zwei, nicht fuenf; eine
Zuweisung an eine Person und Prioritaeten waeren ein Ticketsystem
fuer zwei Menschen, die nebeneinander sitzen. Das Melde-Feld bleibt
auch fuer sie da, rutscht aber unter den Eingang.

Wer zumacht, schreibt einen Satz dazu -- der Melder sieht nur diesen
Satz, und ohne ihn weiss er nicht, ob etwas behoben wurde oder ob
niemand Zeit hatte. Beide Seiten bekommen eine Benachrichtigung.

UEBERNOMMEN STATT NEU ERFUNDEN:
  * `dateiErkennen` aus workspace-chat.js -- EXPORTIERT, nicht
    abgeschrieben. An ihr haengt die ganze Sicherheit der Uploads
    (der Typ kommt aus den ersten Bytes, nicht aus Name oder
    Content-Type), und eine zweite Fassung waere die, die beim
    naechsten Dateiformat vergessen wird.
  * Die zweigeteilte Sicht und `meldungFuer()` (gibt den Datensatz
    oder null zurueck, nie true/false) aus dem vertraulichen
    Meldeweg.
  * Der Upload-Weg (express.raw, Text im Kopf, Ordner unter
    DATEN_ORDNER, damit die Sicherungspruefung ihn findet).

DIE KACHEL BEKOMMT JEDER -- an EINER Stelle angehaengt: `bereicheFuer`
haengt sie an jede Rollenliste, statt sie in fuenf Listen
einzutragen. Genau so ist heute der Benachrichtigungs-Knopf auf 14
Seiten verschwunden. Dazu der Eintrag in rechte.js (ohne ihn
verschwindet die Kachel lautlos, `nurOffeneKacheln` filtert dagegen)
und in der Browser-Kachelliste fuer die Agentur-Rollen.

TON 44 GERECHNET, NICHT GEWAEHLT -- und dabei zweimal danebengelegen:
  * Ich habe erst einen eigenen Modus in kachel-farben.mjs gebaut.
    `tools/kachel-farbe-einzeln.mjs` gibt es aber seit dem 22.09. fuer
    genau diesen Zweck. Meine Dopplung ist wieder weg.
  * Von Hand hatte ich #bcdbff eingetragen. pruef-kachelfarben lehnte
    es ab (Buntheit 0,060, verlangt sind 0,12) -- und das vorhandene
    Werkzeug verteidigte es trotzdem, weil sein Abstand gut war. Ein
    Werkzeug, das einen regelwidrigen Zustand haelt, weil er zufaellig
    gut misst, behebt genau den Fehler nicht, fuer den es da ist.
    Behoben: Zuerst die Regeln, dann der Abstand. Ergebnis #fd6401,
    Abstand 0,0914 (engstes vorhandenes Paar: 0,0154).

DREI MESSFEHLER VON MIR, die wie schwere Befunde aussahen: `fetch`
verwirft den Host-Kopf (die Community kommt nur auf crew. herein),
die Community bestaetigt ihr Alter (`alter_ok`), und die Startroute
heisst /api/ich. Alle drei stehen in pruef-treff.mjs richtig.

Ein ECHTER Fehler kam dazu: support.html lud bereiche.js nicht, und
kopf.js stuerzte mit „Cannot read properties of undefined (reading
'LEITUNG')" ab -- sichtbar nur in der Browserkonsole. Und
pruef-css-klassen fand sofort, dass auch wahl.js fehlte.

Gemessen: pruef-support 45 Pruefungen, 0 Fehler (alle neun Rollen
kommen herein, jede sieht die Kachel, als Bild getarntes HTML wird
abgelehnt, eine fremde Meldung bleibt fremd, erledigt bleibt
erledigt). Dazu 24 Messungen am Bild in drei Groessen. Gruen:
pruef-kachelfarben (22), pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 01:32:55 +02:00

434 lines
16 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.
/* =====================================================================
SUPPORT (24.09.2026)
Filipe: „jeder kann da probleme von der seite melden und hinweisen.
fotos mit schicken mit einem text. … so einfach wie möglich, will
nichts kompliziertes oder zu viel. kurz und knapp."
EINE SEITE, ZWEI ANSICHTEN. Wer meldet, sieht ein Feld und seine
eigenen Meldungen. DogFather und die rechte Hand sehen den Eingang:
alles, nach Stand sortiert, mit Bild und zwei Knöpfen.
WELCHE ANSICHT GILT, ENTSCHEIDET DER SERVER. Er schickt `leitung`
in der Antwort — diese Datei fragt nicht nach der Rolle. Eine
Rollenprüfung im Browser wäre eine zweite Stelle mit derselben
Regel, und es wäre die, an der man sie umgehen kann.
KEIN innerHTML MIT FREMDEM TEXT. Alles, was jemand geschrieben hat,
geht durch `textContent`. Hier landet Text aus einem Fehlerbericht —
und genau dort steht irgendwann ein `<script>`, weil jemand es
ausprobiert.
===================================================================== */
(() => {
'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 meldungen = [];
let filter = 'offen';
let gewaehltesBild = null;
/* Anzeige-Wörter, keine Regel. Der Server schickt den Schlüssel; was
daraus auf dem Bildschirm wird, ist eine Frage der Sprache. */
const STAND_WORT = {
neu: 'neu',
in_arbeit: 'wird bearbeitet',
erledigt: 'erledigt',
};
function melde(id, text) {
const f = $(id);
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;
}
/** Was der Server als Fehler geschickt hat — oder ein Ersatz.
*
* Die Antwort wegzuwerfen und „Ging nicht" zu schreiben schickt
* einen auf die Suche nach etwas, das der Server längst gesagt hat. */
async function fehlerText(a, ersatz) {
try {
const j = await a.json();
return typeof j?.fehler === 'string' && j.fehler ? j.fehler : ersatz;
} catch { return ersatz; }
}
/** „vor 3 Tagen" statt eines Zeitstempels.
*
* Bei einer Meldung ist die Frage „wie lange liegt das schon?",
* nicht „wann genau war das?". Das genaue Datum bleibt als Titel. */
function herZeit(iso) {
if (!iso) return '';
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms)) return '';
const min = Math.floor(ms / 60000);
if (min < 2) 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);
if (tage === 1) return 'gestern';
if (tage < 31) return `vor ${tage} Tagen`;
const monate = Math.floor(tage / 30.44);
return monate < 12 ? `vor ${monate} Mon.` : 'vor über einem Jahr';
}
function genau(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const z = (n) => String(n).padStart(2, '0');
return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()} `
+ `${z(d.getHours())}:${z(d.getMinutes())}`;
}
/* ===================================================================
MELDEN
=================================================================== */
function bildZeigen(datei) {
gewaehltesBild = datei || null;
const name = $('bild-name');
const weg = $('bild-weg');
const vorschau = $('vorschau');
if (!datei) {
name.textContent = '';
weg.hidden = true;
vorschau.hidden = true;
$('vorschau-bild').removeAttribute('src');
return;
}
name.textContent = `${datei.name} · ${Math.round(datei.size / 1024)} KB`;
weg.hidden = false;
/* DIE VORSCHAU KOMMT AUS DER DATEI SELBST, nicht vom Server: Sie
ist noch gar nicht hochgeladen. `URL.createObjectURL` zeigt das
Bild aus dem Arbeitsspeicher — wer sieht, was er anhängt, hängt
nicht versehentlich das falsche an. */
const url = URL.createObjectURL(datei);
const img = $('vorschau-bild');
img.onload = () => URL.revokeObjectURL(url);
img.src = url;
vorschau.hidden = false;
}
function meldenEinrichten() {
$('bild-waehlen').addEventListener('click', () => $('bild').click());
$('bild-weg').addEventListener('click', () => {
$('bild').value = '';
bildZeigen(null);
});
$('bild').addEventListener('change', (e) => {
const datei = e.target.files?.[0] || null;
if (!datei) { bildZeigen(null); return; }
/* DIE GRENZE STEHT HIER NUR ALS FREUNDLICHKEIT. Abgelehnt wird
am Server; dieser Hinweis erspart es, zwölf Megabyte erst
hochzuladen, um danach eine Absage zu lesen. */
if (datei.size > 12 * 1024 * 1024) {
melde('melden-fehler', 'Das Bild ist größer als 12 MB – bitte ein kleineres.');
$('bild').value = '';
bildZeigen(null);
return;
}
melde('melden-fehler', '');
bildZeigen(datei);
});
$('melden').addEventListener('submit', async (e) => {
e.preventDefault();
const text = $('text').value.trim();
if (text.length < 5) {
melde('melden-fehler', 'Schreib bitte kurz, was los ist – ein Satz reicht.');
$('text').focus();
return;
}
const knopf = $('senden');
knopf.disabled = true;
melde('melden-fehler', '');
$('melden-hinweis').textContent = 'wird geschickt …';
try {
/* DER TEXT REIST IM KOPF, das Bild im Rumpf — derselbe Weg wie
bei Chat-Anhängen. Ein Weg für beides, damit es keinen
Zustand „Meldung da, Bild fehlt noch" gibt. */
const kopf = { 'x-text': encodeURIComponent(text), 'x-seite': location.pathname };
const a = await hole('/workspace/api/support', {
method: 'POST',
headers: kopf,
body: gewaehltesBild || new Blob([]),
});
if (!a.ok) {
melde('melden-fehler', await fehlerText(a, 'Das hat nicht geklappt.'));
return;
}
$('text').value = '';
$('bild').value = '';
bildZeigen(null);
$('melden-hinweis').textContent = 'Danke – ist angekommen.';
setTimeout(() => { $('melden-hinweis').textContent = ''; }, 6000);
await laden();
} catch { /* umgeleitet */ } finally {
knopf.disabled = false;
if ($('melden-hinweis').textContent === 'wird geschickt …') {
$('melden-hinweis').textContent = '';
}
}
});
}
/* ===================================================================
EINE MELDUNG ALS KARTE
=================================================================== */
function bildAnhaengen(karte, m) {
if (!m.bild) return;
const kasten = el('div', 's-karte__bild');
const a = el('a');
a.href = m.bild;
a.target = '_blank';
a.rel = 'noopener';
a.title = 'In voller Größe ansehen';
const img = document.createElement('img');
img.src = m.bild;
img.alt = 'Bild zur Meldung';
img.loading = 'lazy';
/* BREITE UND HÖHE STEHEN DRAN, wenn der Server sie kennt: Dann
reserviert der Browser den Platz, bevor das Bild da ist, und die
Liste springt beim Laden nicht. */
if (m.bild_breite > 0 && m.bild_hoehe > 0) {
img.width = m.bild_breite;
img.height = m.bild_hoehe;
}
a.append(img);
kasten.append(a);
karte.append(kasten);
}
function karteBauen(m) {
const k = el('article', 's-karte');
k.dataset.stand = m.stand;
k.id = `meldung-${m.id}`;
const kopf = el('div', 's-karte__kopf');
const marke = el('span', 's-marke', STAND_WORT[m.stand] || m.stand);
marke.dataset.stand = m.stand;
kopf.append(marke);
if (leitung && m.von) {
kopf.append(el('span', 's-karte__von', m.von));
}
const zeit = el('span', 's-karte__zeit', herZeit(m.erstellt));
zeit.title = genau(m.erstellt);
kopf.append(zeit);
k.append(kopf);
/* `textContent` mit erhaltenen Zeilenumbrüchen (das CSS setzt
`white-space: pre-wrap`). Kein innerHTML, siehe der Kopf. */
k.append(el('p', 's-karte__text', m.text));
bildAnhaengen(k, m);
if (leitung && m.seite) {
const wo = el('p', 's-karte__wo', 'gemeldet auf ' + m.seite);
if (m.geraet) wo.title = m.geraet;
k.append(wo);
}
if (m.antwort) {
const ant = el('div', 's-antwort');
ant.append(el('span', 's-antwort__schild', 'Antwort'));
ant.append(el('p', 's-antwort__text', m.antwort));
k.append(ant);
}
if (leitung && m.stand !== 'erledigt') k.append(knoepfeFuerLeitung(m));
return k;
}
/** Die zwei Handgriffe der Leitung.
*
* ZWEI, NICHT FÜNF. „Ich kümmere mich" und „erledigt". Eine
* Zuweisung an eine bestimmte Person, Prioritäten, Kategorien — das
* wäre ein Ticketsystem für zwei Menschen, die nebeneinander
* sitzen. */
function knoepfeFuerLeitung(m) {
const reihe = el('div', 's-karte__tun');
if (m.stand === 'neu') {
const dran = el('button', 'schritt', 'Ich kümmere mich');
dran.type = 'button';
dran.addEventListener('click', () => aendern(m, 'in_arbeit', ''));
reihe.append(dran);
}
const fertig = el('button', 'schritt', 'Erledigt …');
fertig.type = 'button';
fertig.addEventListener('click', async () => {
/* WER ZUMACHT, SAGT WARUM. Der Melder sieht nur diesen Satz —
ohne ihn weiß er nicht, ob etwas behoben wurde oder ob
niemand Zeit hatte. Der Server verlangt ihn ohnehin; hier
wird er gefragt, statt eine Absage zu kassieren. */
const antwort = await window.frageNach?.({
titel: 'Meldung erledigen?',
was: 'Sie verschwindet aus dem offenen Eingang.',
bleibt: 'Der Melder bekommt eine Nachricht mit deinem Satz – '
+ 'das ist alles, was er davon sieht.',
ja: 'Erledigt',
grund: 'Was ist daraus geworden?',
grundPflicht: true,
grundMin: 3,
platzhalter: 'z. B. Behoben, der Knopf geht wieder.',
});
if (!antwort) return;
await aendern(m, 'erledigt', antwort.grund || '');
});
reihe.append(fertig);
return reihe;
}
async function aendern(m, stand, antwort) {
melde('eingang-fehler', '');
try {
const a = await hole(`/workspace/api/support/${m.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stand, antwort }),
});
if (!a.ok) {
melde('eingang-fehler', await fehlerText(a, 'Das hat nicht geklappt.'));
return;
}
await laden();
} catch { /* umgeleitet */ }
}
/* ===================================================================
ZEICHNEN
=================================================================== */
function bilanzZeichnen() {
const ziel = $('bilanz');
ziel.textContent = '';
const zahl = (stand) => meldungen.filter((m) => m.stand === stand).length;
for (const [stand, wort] of [
['neu', 'neu'], ['in_arbeit', 'in Arbeit'], ['erledigt', 'erledigt'],
]) {
const kasten = el('div', 's-bilanz__feld');
kasten.dataset.stand = stand;
kasten.append(el('span', 's-bilanz__zahl', String(zahl(stand))));
kasten.append(el('span', 's-bilanz__wort', wort));
ziel.append(kasten);
}
}
function filterZeichnen() {
const ziel = $('filter');
ziel.textContent = '';
const zaehl = (welche) => welche === 'alle'
? meldungen.length
: welche === 'offen'
? meldungen.filter((m) => m.stand !== 'erledigt').length
: meldungen.filter((m) => m.stand === welche).length;
for (const [wert, wort] of [
['offen', 'Offen'], ['erledigt', 'Erledigt'], ['alle', 'Alle'],
]) {
const k = el('button', 's-filter__knopf');
k.type = 'button';
k.dataset.wert = wert;
k.setAttribute('aria-pressed', String(filter === wert));
k.append(el('span', null, wort));
k.append(el('span', 's-filter__zahl', String(zaehl(wert))));
k.addEventListener('click', () => { filter = wert; listeZeichnen(); });
ziel.append(k);
}
}
function listeZeichnen() {
if (leitung) {
filterZeichnen();
const ziel = $('eingang-liste');
ziel.textContent = '';
const drin = meldungen.filter((m) => filter === 'alle' ? true
: filter === 'offen' ? m.stand !== 'erledigt' : m.stand === filter);
if (!drin.length) {
/* EIN LEERER EINGANG IST EINE GUTE NACHRICHT und soll auch so
aussehen — nicht wie eine Seite, die nicht fertig geladen
hat. */
ziel.append(el('p', 's-leer', filter === 'offen'
? 'Nichts offen. Alles abgearbeitet.'
: 'Hier steht nichts.'));
return;
}
for (const m of drin) ziel.append(karteBauen(m));
return;
}
const block = $('meine-block');
const ziel = $('meine-liste');
ziel.textContent = '';
$('meine-zahl').textContent = String(meldungen.length);
block.hidden = meldungen.length === 0;
for (const m of meldungen) ziel.append(karteBauen(m));
}
async function laden() {
try {
const a = await hole('/workspace/api/support');
if (!a.ok) { melde('eingang-fehler', 'Die Meldungen ließen sich nicht laden.'); return; }
const r = await a.json();
leitung = r.leitung === true;
meldungen = Array.isArray(r.meldungen) ? r.meldungen : [];
/* DIE SEITE STELLT SICH AUF DEN UM, DER SIE ÖFFNET. */
$('eingang-block').hidden = !leitung;
$('meine-block').hidden = leitung || meldungen.length === 0;
if (leitung) {
$('seiten-titel').textContent = 'Support';
$('seiten-unter').textContent =
'Was aus dem Team und der Community gemeldet wurde. '
+ 'Das Älteste steht oben – was am längsten wartet, gehört nach vorn.';
/* MELDEN KANN DIE LEITUNG AUCH. Filipe findet selbst Fehler,
und ein Weg, der für ihn nicht gilt, ist ein zweiter Weg,
den er dann anders benutzt. Das Feld rutscht nur nach
unten: Wer den Eingang aufmacht, will zuerst sehen, was
liegt. */
/* WIRKLICH VERSCHIEBEN, nicht nur eine Klasse setzen.
`order` wirkt nur in einem Flex- oder Rasterbehaelter, und
`.inhalt` ist keiner -- nachgemessen in start.css. Ein
`append` an denselben Elternteil bewegt den Kasten ans
Ende, ohne ihn neu zu bauen: Der Text im Feld bleibt
stehen, falls gerade jemand tippt. */
const kasten = $('melden-block');
kasten.classList.add('s-melden--unten');
kasten.parentElement?.append(kasten);
bilanzZeichnen();
}
listeZeichnen();
/* AUS EINER BENACHRICHTIGUNG KOMMT MAN MIT ?meldung=… — dann
gehört der Blick genau dorthin und nicht an den Seitenanfang. */
const wohin = new URLSearchParams(location.search).get('meldung');
if (wohin) {
const karte = document.getElementById(`meldung-${Number(wohin)}`);
if (karte) {
karte.scrollIntoView({ behavior: window.sanft?.() || 'auto', block: 'center' });
karte.dataset.hervor = 'ja';
}
}
} catch { /* umgeleitet */ }
}
meldenEinrichten();
laden();
})();