VanVan im Support, Meldung #11, VIERMAL gemeldet: „Hier im Supportbereich kann man immer nur ein Bild hinzufuegen bei einer Meldung. 2-3 waeren besser." Und in der zweiten Runde der Satz, auf den es ankommt: „wenn man es nacheinander versucht hinzuzufuegen wird das Bild immer nur ersetzt." EINE TABELLE STATT NEUER SPALTEN `support_bilder` haelt ab jetzt JEDES Supportbild -- das der Meldung (`runde_nr` NULL) und das einer Antwort (`runde_nr` = Runde). Die Alternative waere `bild2_datei`, `bild3_datei` gewesen, und beim vierten Bild wieder. Eine Zeile je Bild kennt keine Obergrenze im Schema; die Grenze steht an EINER Stelle im Code (`BILDER_MAX = 3`) und kommt von dort in die Oberflaeche, statt dort ein zweites Mal zu stehen. DIE ACHT VORHANDENEN BILDER WANDERN MIT. Ohne diesen Schritt haette die neue Tabelle ab heute recht und die alten Bilder waeren unsichtbar -- ohne Fehler, ohne rote Zeile, nur acht leere Karten. Der Umzug steht NACH der Spaltennachruestung: Er liest `urteil_bild_datei`, und die gibt es in einer bestehenden Datenbank erst, nachdem sie ergaenzt wurde. Stuende er davor, scheiterte er genau dort, wo es darauf ankommt -- live, waehrend lokal alles gruen bliebe, weil jede Pruefung ihre Datenbank frisch anlegt. DREI BILDER IN EINER ANFRAGE `x-bilder: 20481,15320` sagt, wo zu schneiden ist, der Rumpf ist die Aneinanderreihung. `multipart/form-data` haette einen Zerleger gebraucht, den dieses Haus nicht hat; drei Anfragen nacheinander haetten den Zustand „Meldung da, Bild zwei laedt noch" erzeugt -- genau den, gegen den die Kommentare an dieser Route schon vorher argumentieren. Die Summe muss auf das Byte stimmen, und jedes Stueck wird einzeln an seinen ersten Bytes erkannt: Wer falsch schneidet, bekommt eine Absage, kein verfaelschtes Bild. Ohne den Kopf gilt der ganze Rumpf als ein Bild -- derselbe Satz mit einer Laenge, damit eine Seite aus dem Zwischenspeicher weiterlaeuft. EINE ROUTE STATT DREI. `/:id/bild` und `/:id/runde/:nr/bild` sind weg; es gibt `/:id/bild/:bid`. Wohin ein Bild gehoert, steht in seiner Zeile -- der Weg muss es nicht wiederholen. Die Meldungsnummer bleibt trotzdem im Pfad: Sie ist die Sichtbarkeitsfrage, und beides muss zusammenpassen (gemessen). ZWEI FUNDE, DIE DIE PRUEFUNG GEMACHT HAT UND NICHT ICH 1. UEBER DIE SEITE KAM GAR KEIN BILD MEHR AN. Beim Melden stand kein `Content-Type`. Das ging gut, solange der Rumpf eine einzelne Datei war -- ein `File` bringt seinen Typ mit. Ein `Blob` aus mehreren hat keinen, `fetch` schickt die Zeile dann gar nicht, `express.raw` fuehlt sich nicht zustaendig, und der Server bekam einen leeren Rumpf. Die Meldung waere durchgegangen, der Text angekommen, die Bilder weg -- ohne Fehlermeldung. Alle Pruefungen am Server waren dabei gruen; gefunden hat es erst der echte Browser. 2. DAS KREUZ DES DRITTEN BILDES LAG AUF DEM ZWEITEN. Der Entfernen-Knopf ist 44 px breit und absolut gesetzt, der Kasten aber nur so breit wie sein Bild. Bei einem schmalen Bild ragt er darueber hinaus -- wer „das zweite weg" antippt, loescht das dritte. `min-width`/`min-height` loesen das an der Ursache: Ein Kasten ist nie schmaler als der Knopf in ihm. WAS ICH FALSCH ANGENOMMEN HATTE: Ich hatte eingebaut, dass ein Nachtrag in derselben Runde die Bilder ersetzt. Die Pruefung dazu wurde rot -- zu Recht: Eine zweite Antwort in derselben Runde kann es nicht geben, die erste verlaesst den Stand „wartet". Der Code waere nie gelaufen und damit nie pruefbar gewesen. Er ist weg; an seiner Stelle steht der Beweis, dass er nicht fehlt. DREI WEITERE ROTE ZEILEN, DIE NICHT ZU DIESEM UMBAU GEHOERTEN * `manager-ziele.js` hatte einen ZWEITEN Notnagel (`frageNach ? … : confirm(…)`). `nachfrage.js` hat denselben laengst, und zwar mit dem vollstaendigen Text; der hiesige war der kuerzere und haette gewonnen. Zwei Antworten auf dieselbe Frage -- gemeldet von `pruef-nachfrage`. * Zwei Mittelpunkte in `reaktion.css` standen woertlich im `content`. Sie liegen im Latin-1-Block, wo `pruef-zeichen` die Truemmer einer verunglueckten Kodierung sucht. Jetzt als Escape -- im Browser nachgemessen, es steht Zeichen fuer Zeichen dasselbe da. * Das Aufraeumen nach 90 Tagen loeschte nur das EINE Bild der Meldung; die Bilder aus den Antwortrunden blieben ohne Zeile auf der Platte liegen. Die Liste kommt jetzt aus einer Abfrage statt aus einer Spalte und kann deshalb nicht wieder unvollstaendig sein. GEPRUEFT pruef-support 78 -> 104 ok darunter: der Umzug der alten Bilder auf einer eigenen Wegwerf-Datenbank -- zweimal und dreimal gestartet, nichts verdoppelt, Datum von damals erhalten pruef-support-bilder NEU, 36 ok (echter Browser) dreimal nacheinander waehlen ergibt drei, das vierte wird mit einem Satz abgelehnt, dasselbe zaehlt nicht doppelt, einzeln entfernen laesst die anderen stehen, alle drei laden wirklich (naturalWidth), Kreuze 44x44 und keines verdeckt (mit Gegenprobe per Deckel), nichts ragt auf 390 px heraus pruef-nachfrage 69 -> 74 ok pruef-struktur 102 ok, 413 Routen (vorher 414: zwei weg, eine neu) pruef-zeichen 7 ok (vorher 1 Fehler) pruef-aufbewahrung 45 ok pruef-manager-ziele 216 ok pruef-ports 10 ok · pruef-portnummern 41 ok (die neue Pruefdatei verschiebt die abgeleiteten Nummern) NUR DAS AGENTURHAUS IST BETROFFEN. Die Supportseite liegt unter `/workspace`; am Crew-Haus aendert sich keine Zeile. Co-Authored-By: Claude Opus 5 <[email protected]>
808 lines
33 KiB
JavaScript
808 lines
33 KiB
JavaScript
/* =====================================================================
|
||
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';
|
||
/* ==== BIS ZU DREI BILDER (02.10.2026) ==========================
|
||
|
||
Hier stand `let gewaehltesBild = null` — eine Variable für genau
|
||
ein Bild, und jede neue Auswahl überschrieb sie. Das war VanVans
|
||
zweite Meldung dazu, wörtlich: „wenn man es nacheinander versucht
|
||
hinzuzufügen wird das Bild immer nur ersetzt."
|
||
|
||
JETZT EINE LISTE, und `bilderMax` kommt vom Server. Eine feste 3
|
||
an dieser Stelle wäre die Zahl, die beim Ändern vergessen wird —
|
||
dasselbe Muster wie die 380 px vom 06.09. */
|
||
let gewaehlteBilder = [];
|
||
let bilderMax = 3;
|
||
|
||
/* 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',
|
||
/* ==== DER VIERTE STAND (25.09.2026) ==========================
|
||
|
||
Filipe: „damit die bescheid wissen und dan anklicken koennen,
|
||
es funktioniert, oder noch nicht."
|
||
|
||
Das Wort sagt, WER DRAN IST -- nicht, was passiert ist. „Wartet
|
||
auf Antwort" waere aus Sicht der Leitung richtig und aus Sicht
|
||
des Melders falsch: Er wartet nicht, er ist gefragt. */
|
||
wartet: 'geht es wieder?',
|
||
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
|
||
=================================================================== */
|
||
|
||
/** Die Auswahl neu zeichnen — Name, Knöpfe, Vorschaubilder.
|
||
*
|
||
* JEDES BILD HAT SEIN EIGENES KREUZ. Wer versehentlich das falsche
|
||
* dritte Bild erwischt, soll nicht alle drei neu wählen müssen;
|
||
* genau dort hört man sonst auf und schickt eben nur eines.
|
||
*
|
||
* DIE ADRESSEN WERDEN WIEDER FREIGEGEBEN (`revokeObjectURL`).
|
||
* Ohne das hält jede Vorschau ihr Bild bis zum Neuladen der Seite
|
||
* im Arbeitsspeicher fest — bei drei Handyfotos sind das schnell
|
||
* fünfzehn Megabyte, die niemand mehr braucht. */
|
||
function bilderZeigen() {
|
||
const name = $('bild-name');
|
||
const weg = $('bild-weg');
|
||
const vorschau = $('vorschau');
|
||
|
||
/* Erst die alten Adressen zurückgeben, dann neu bauen. */
|
||
for (const img of vorschau.querySelectorAll('img[data-url]')) {
|
||
URL.revokeObjectURL(img.dataset.url);
|
||
}
|
||
vorschau.textContent = '';
|
||
|
||
if (!gewaehlteBilder.length) {
|
||
name.textContent = '';
|
||
weg.hidden = true;
|
||
vorschau.hidden = true;
|
||
return;
|
||
}
|
||
|
||
const kb = gewaehlteBilder.reduce((s, d) => s + d.size, 0) / 1024;
|
||
name.textContent = gewaehlteBilder.length === 1
|
||
? `${gewaehlteBilder[0].name} · ${Math.round(kb)} KB`
|
||
: `${gewaehlteBilder.length} von ${bilderMax} Bildern · ${Math.round(kb)} KB`;
|
||
weg.hidden = false;
|
||
|
||
for (const [i, datei] of gewaehlteBilder.entries()) {
|
||
const kasten = el('div', 's-vorschau__eins');
|
||
/* 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 = el('img');
|
||
img.dataset.url = url;
|
||
img.src = url;
|
||
img.alt = `Vorschau von ${datei.name}`;
|
||
kasten.append(img);
|
||
|
||
const raus = el('button', 's-vorschau__weg', '✕');
|
||
raus.type = 'button';
|
||
raus.title = `${datei.name} wieder entfernen`;
|
||
raus.setAttribute('aria-label', `${datei.name} wieder entfernen`);
|
||
raus.addEventListener('click', () => {
|
||
gewaehlteBilder.splice(i, 1);
|
||
bilderZeigen();
|
||
});
|
||
kasten.append(raus);
|
||
vorschau.append(kasten);
|
||
}
|
||
vorschau.hidden = false;
|
||
}
|
||
|
||
/** Neu gewählte Dateien DAZULEGEN statt ersetzen.
|
||
*
|
||
* ==== VANVANS ZWEITE MELDUNG (02.10.2026) =======================
|
||
*
|
||
* „wenn man es nacheinander versucht hinzuzufügen wird das Bild
|
||
* immer nur ersetzt."
|
||
*
|
||
* `multiple` am Feld allein löst das NICHT: Auch mit `multiple`
|
||
* ersetzt jede neue Auswahl die `files`-Liste vollständig. Auf dem
|
||
* Handy wählt man aber selten drei Bilder in einem Rutsch — man
|
||
* tippt dreimal. Deshalb wird hier angehängt.
|
||
*
|
||
* DOPPELTE WERDEN NICHT ZWEIMAL GENOMMEN. Wer das Feld zweimal
|
||
* öffnet und zweimal dasselbe Foto antippt, hat eines gemeint.
|
||
* Erkannt an Name, Größe und Änderungszeit — zwei verschiedene
|
||
* Bildschirmfotos stimmen in allen dreien nicht überein. */
|
||
function bilderDazu(neue) {
|
||
let abgelehnt = '';
|
||
for (const datei of neue) {
|
||
if (gewaehlteBilder.length >= bilderMax) {
|
||
abgelehnt = `Mehr als ${bilderMax} Bilder gehen nicht – nimm die wichtigsten.`;
|
||
break;
|
||
}
|
||
/* 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) {
|
||
abgelehnt = `„${datei.name}“ ist größer als 12 MB – bitte ein kleineres.`;
|
||
continue;
|
||
}
|
||
const schon = gewaehlteBilder.some((d) => d.name === datei.name
|
||
&& d.size === datei.size && d.lastModified === datei.lastModified);
|
||
if (schon) continue;
|
||
gewaehlteBilder.push(datei);
|
||
}
|
||
melde('melden-fehler', abgelehnt);
|
||
bilderZeigen();
|
||
}
|
||
|
||
function meldenEinrichten() {
|
||
$('bild-waehlen').addEventListener('click', () => $('bild').click());
|
||
$('bild-weg').addEventListener('click', () => {
|
||
$('bild').value = '';
|
||
gewaehlteBilder = [];
|
||
bilderZeigen();
|
||
});
|
||
$('bild').addEventListener('change', (e) => {
|
||
bilderDazu([...(e.target.files || [])]);
|
||
/* DAS FELD WIRD GELEERT, die Liste nicht. Sonst meldet der
|
||
Browser beim zweiten Antippen DESSELBEN Bildes kein
|
||
`change` — er sieht keine Änderung — und es sähe aus, als
|
||
wäre der Knopf kaputt. */
|
||
e.target.value = '';
|
||
});
|
||
|
||
$('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, die Bilder im Rumpf — derselbe Weg
|
||
wie bei Chat-Anhängen. Ein Weg für beides, damit es keinen
|
||
Zustand „Meldung da, Bild fehlt noch" gibt.
|
||
Was `x-bilder` bedeutet, steht in workspace-support.js bei
|
||
`rumpfZerlegen`. */
|
||
const kopf = {
|
||
'x-text': encodeURIComponent(text),
|
||
'x-seite': location.pathname,
|
||
/* ==== DIESE ZEILE IST PFLICHT (02.10.2026) ==============
|
||
|
||
Hier stand gar kein Typ. Das ging gut, solange der Rumpf
|
||
eine EINZELNE Datei war: Ein `File` bringt seinen Typ
|
||
mit, und `fetch` setzt daraus den Kopf. Ein `Blob` aus
|
||
mehreren Dateien hat KEINEN Typ -- dann schickt `fetch`
|
||
die Zeile gar nicht, `express.raw` fuehlt sich mit
|
||
seinem Sternchen-Muster nicht zustaendig (ohne Kopf
|
||
passt kein Muster), und der Server bekommt einen leeren
|
||
Rumpf.
|
||
|
||
(Das Muster steht hier absichtlich NICHT woertlich da:
|
||
Ein Sternchen mit Schraegstrich beendet diesen
|
||
Kommentar. Genau daran ist der erste Versuch dieser
|
||
Zeile gescheitert -- die Seite lud danach gar nicht
|
||
mehr, und die Pruefung hat es in derselben Minute
|
||
gemeldet.)
|
||
|
||
FOLGE WAERE GEWESEN: Die Meldung geht durch, der Text
|
||
kommt an, die Bilder sind weg -- ohne Fehler, ohne
|
||
Meldung. Genau das, was VanVan gemeldet hat, nur
|
||
schlimmer. Die Pruefungen am Server waren dabei gruen;
|
||
gefunden hat es erst `pruef-support-bilder` im echten
|
||
Browser. */
|
||
'Content-Type': 'application/octet-stream',
|
||
};
|
||
if (gewaehlteBilder.length) {
|
||
kopf['x-bilder'] = gewaehlteBilder.map((d) => d.size).join(',');
|
||
}
|
||
const a = await hole('/workspace/api/support', {
|
||
method: 'POST',
|
||
headers: kopf,
|
||
body: new Blob(gewaehlteBilder),
|
||
});
|
||
if (!a.ok) {
|
||
melde('melden-fehler', await fehlerText(a, 'Das hat nicht geklappt.'));
|
||
return;
|
||
}
|
||
$('text').value = '';
|
||
$('bild').value = '';
|
||
gewaehlteBilder = [];
|
||
bilderZeigen();
|
||
$('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
|
||
=================================================================== */
|
||
|
||
/** Die Bilder einer Meldung oder einer Antwortrunde anzeigen.
|
||
*
|
||
* ==== EINE FUNKTION FÜR BEIDE STELLEN (02.10.2026) ==============
|
||
*
|
||
* Hier stand `bildAnhaengen(karte, m)` für das eine Bild der
|
||
* Meldung, und weiter unten noch einmal fast derselbe Block für
|
||
* das Bild einer Runde. Mit bis zu drei Bildern wären daraus zwei
|
||
* Schleifen geworden, die dasselbe tun und beim nächsten Mal nur
|
||
* an einer Stelle verbessert werden.
|
||
*
|
||
* Der Server schickt an beiden Stellen dieselbe Form (`bilder`:
|
||
* Liste aus `{ weg, breite, hoehe }`), deshalb genügt eine
|
||
* Funktion und ein Alternativtext, der sagt, wo man ist. */
|
||
function bilderAnhaengen(wohin, bilder, wortDazu) {
|
||
const liste = Array.isArray(bilder) ? bilder : [];
|
||
if (!liste.length) return;
|
||
const kasten = el('div', 's-karte__bild');
|
||
if (liste.length > 1) kasten.dataset.viele = 'ja';
|
||
for (const [i, b] of liste.entries()) {
|
||
const a = el('a');
|
||
a.href = b.weg;
|
||
a.target = '_blank';
|
||
a.rel = 'noopener noreferrer';
|
||
a.title = 'In voller Größe ansehen';
|
||
const img = el('img');
|
||
img.src = b.weg;
|
||
img.alt = liste.length > 1
|
||
? `${wortDazu} – Bild ${i + 1} von ${liste.length}`
|
||
: wortDazu;
|
||
img.loading = 'lazy';
|
||
img.decoding = 'async';
|
||
/* 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 (b.breite > 0 && b.hoehe > 0) { img.width = b.breite; img.height = b.hoehe; }
|
||
a.append(img);
|
||
kasten.append(a);
|
||
}
|
||
wohin.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));
|
||
bilderAnhaengen(k, m.bilder, 'Bild zur Meldung');
|
||
|
||
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);
|
||
}
|
||
|
||
/* ==== DER VERLAUF STATT NUR DER LETZTEN ANTWORT (25.09.2026) ==
|
||
|
||
Filipe: „mit dem gedanken das manchmal sachen mehrmal nicht
|
||
sofort perfekt sein werden."
|
||
|
||
Hier stand nur `m.antwort` -- die LETZTE. Bei Runde drei war
|
||
damit nicht mehr zu sehen, was beim ersten Mal versucht wurde,
|
||
und genau das loest einen wiederkehrenden Fehler.
|
||
|
||
DIE RUNDEN STEHEN UNTEREINANDER, jede mit dem, was die Leitung
|
||
gesagt hat, und dem, was der Melder daraufhin geantwortet hat.
|
||
Gibt es noch keine Runde (die Meldung ist frisch), steht wie
|
||
bisher die blosse Antwort da -- Meldungen von vor diesem Umbau
|
||
haben keinen Verlauf, und ein leerer Kasten waere schlechter
|
||
als der alte Satz. */
|
||
const verlauf = Array.isArray(m.verlauf) ? m.verlauf : [];
|
||
if (verlauf.length) {
|
||
const liste = el('div', 's-verlauf');
|
||
for (const r of verlauf) {
|
||
const runde = el('div', 's-runde');
|
||
const kopfR = el('div', 's-runde__kopf');
|
||
kopfR.append(el('span', 's-runde__nr', 'Runde ' + r.nr));
|
||
if (r.von) kopfR.append(el('span', 's-runde__von', r.von));
|
||
runde.append(kopfR);
|
||
runde.append(el('p', 's-runde__antwort', r.antwort));
|
||
if (r.urteil) {
|
||
const u = el('p', 's-runde__urteil');
|
||
u.dataset.urteil = r.urteil;
|
||
u.append(el('span', 's-runde__urteil-wort',
|
||
r.urteil === 'geht' ? 'Geht wieder' : 'Ging noch nicht'));
|
||
if (r.urteil_text) u.append(document.createTextNode(' · ' + r.urteil_text));
|
||
runde.append(u);
|
||
}
|
||
/* ==== DAS BILD ZUR ANTWORT (02.10.2026) ==================
|
||
|
||
Es steht BEI DER RUNDE, zu der es gehört, nicht oben bei
|
||
der Meldung. Nach drei Runden ist sonst nicht mehr zu
|
||
sehen, welches Bild zu welchem Versuch gehörte -- und
|
||
genau diese Frage löst einen wiederkehrenden Fehler.
|
||
|
||
ANKLICKBAR UND IN EIGENEM FENSTER: dieselbe Behandlung wie
|
||
die Bilder der Meldung weiter oben. Ein Bildschirmfoto in
|
||
Briefmarkengröße beantwortet keine Frage.
|
||
|
||
SEIT DEM 02.10. DIESELBE FUNKTION wie oben, und bis zu
|
||
drei Bilder. Hier stand ein eigener, fast gleicher Block —
|
||
zwei Stellen, an denen dasselbe passiert, sind eine zu
|
||
viel. */
|
||
bilderAnhaengen(runde, r.bilder, 'Bildschirmfoto zur Antwort in Runde ' + r.nr);
|
||
liste.append(runde);
|
||
}
|
||
k.append(liste);
|
||
} else 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);
|
||
}
|
||
|
||
/* ==== DER MELDER IST GEFRAGT (25.09.2026) ====================
|
||
|
||
Filipe: „und erst wenn es funktioniert gedrueckt wird, will ich
|
||
dass alles richtig fertig ist."
|
||
|
||
`darf_bestaetigen` KOMMT VOM SERVER -- er weiss, wessen Meldung
|
||
es ist, und diese Datei bekommt jeder, der die Seite oeffnet.
|
||
Ein Rollenvergleich im Browser waere die zweite Wahrheit, die in
|
||
diesem Haus diese Woche viermal veraltet ist. */
|
||
if (m.darf_bestaetigen) k.append(knoepfeFuerMelder(m));
|
||
else if (leitung && m.stand !== 'erledigt') k.append(knoepfeFuerLeitung(m));
|
||
return k;
|
||
}
|
||
|
||
/** Die zwei Knoepfe des Melders.
|
||
*
|
||
* „GEHT WIEDER" ZUERST und ohne Rueckfrage: Es ist der haeufige und
|
||
* der harmlose Fall -- wer bestaetigt, dass etwas geht, soll dafuer
|
||
* kein Fenster wegklicken muessen.
|
||
*
|
||
* „NOCH NICHT" FRAGT NACH. Ohne ein Wort dazu faengt die Suche von
|
||
* vorn an, und die naechste Runde waere dieselbe wie die letzte.
|
||
* Der Server verlangt es ohnehin; hier wird es gefragt, statt eine
|
||
* Absage zu kassieren.
|
||
*/
|
||
function knoepfeFuerMelder(m) {
|
||
const reihe = el('div', 's-karte__tun');
|
||
|
||
const geht = el('button', 'schritt s-tun--geht', 'Geht wieder ✓');
|
||
geht.type = 'button';
|
||
geht.addEventListener('click', () => rueckmelden(m, true, ''));
|
||
reihe.append(geht);
|
||
|
||
const nicht = el('button', 'schritt', 'Noch nicht …');
|
||
nicht.type = 'button';
|
||
nicht.addEventListener('click', async () => {
|
||
const antwort = await window.frageNach?.({
|
||
titel: 'Geht es noch nicht?',
|
||
was: 'Die Meldung geht zurück an DogFather und die rechte Hand.',
|
||
bleibt: 'Alles bisher Geschriebene bleibt stehen – ihr fangt nicht von vorn an.',
|
||
ja: 'Zurückgeben',
|
||
grund: 'Was geht noch nicht?',
|
||
grundPflicht: true,
|
||
grundMin: 3,
|
||
platzhalter: 'z. B. Der Knopf geht jetzt, aber das Datum stimmt immer noch nicht.',
|
||
/* ==== EIN BILD DARF MIT (02.10.2026) ====================
|
||
|
||
VanVan: „… damit man nochmal ein Bild anhängen kann, wenn
|
||
das Problem noch besteht oder sich durch die Änderung ein
|
||
neues Problem ergeben hat."
|
||
|
||
Ihr zweiter Halbsatz ist der Grund: Beim Melden war das
|
||
Bild vom ERSTEN Problem. Taucht durch die Änderung ein
|
||
neues auf, hilft das alte Bild niemandem.
|
||
|
||
NIE PFLICHT. Wer keines hat, antwortet ohne -- ein
|
||
Bildschirmfoto ist Hilfe, keine Hürde.
|
||
|
||
SEIT DEM 02.10. BIS ZU DREI, genau wie beim Melden.
|
||
VanVan, Meldung #11: „2-3 wären besser." Dass es beim
|
||
Melden drei sein dürfen und bei der Antwort eines, wäre
|
||
dieselbe Frage mit zwei Antworten. */
|
||
bild: 'Bildschirmfotos dazu (freiwillig)',
|
||
bildMax: bilderMax,
|
||
});
|
||
if (!antwort) return;
|
||
await rueckmelden(m, false, antwort.grund || '', antwort.bilder || []);
|
||
});
|
||
reihe.append(nicht);
|
||
return reihe;
|
||
}
|
||
|
||
/** Die Antwort des Melders -- mit Bild oder ohne.
|
||
*
|
||
* DERSELBE WEG WIE BEIM MELDEN (02.10.2026): Text und Urteil
|
||
* reisen im Kopf, das Bild im Rumpf. Ein zweiter Weg nur für den
|
||
* Anhang hätte einen Zustand dazwischen -- eine Antwort, die schon
|
||
* zählt, während das Bild noch lädt.
|
||
*
|
||
* OHNE BILD IST DER RUMPF LEER, und das ist zulässig. Dann steht
|
||
* im Kopf alles, was es zu sagen gibt.
|
||
*/
|
||
async function rueckmelden(m, geht, text, bilder) {
|
||
melde('eingang-fehler', '');
|
||
const anhang = Array.isArray(bilder) ? bilder : (bilder ? [bilder] : []);
|
||
try {
|
||
const kopf = {
|
||
/* Der Typ sagt, was im RUMPF liegt. Bei mehreren Bildern ist
|
||
das kein einzelnes Bildformat mehr, sondern eine Folge --
|
||
`application/octet-stream` ist dafür die ehrlichste
|
||
Auskunft, und der Server erkennt jedes Stück ohnehin an
|
||
seinen ersten Bytes statt an dieser Zeile. */
|
||
'Content-Type': 'application/octet-stream',
|
||
'x-geht': geht ? 'ja' : 'nein',
|
||
/* Prozentkodiert, weil ein Kopf keine Umlaute trägt -- und
|
||
„Grüße" in einer Begründung ist der Normalfall, nicht die
|
||
Ausnahme. Genauso macht es das Melden. */
|
||
'x-text': encodeURIComponent(text || ''),
|
||
};
|
||
if (anhang.length) kopf['x-bilder'] = anhang.map((d) => d.size).join(',');
|
||
const a = await hole(`/workspace/api/support/${m.id}/rueckmeldung`, {
|
||
method: 'POST',
|
||
headers: kopf,
|
||
body: new Blob(anhang),
|
||
});
|
||
/* DURCH DIESELBE UEBERSETZUNG WIE ALLES ANDERE (25.09.2026,
|
||
gefunden von pruef-meldungen). Hier stand `f.fehler` roh --
|
||
schickte der Server einmal eine Kennung statt eines Satzes
|
||
(`nicht_gefunden`), stuende das mit Unterstrich auf dem
|
||
Bildschirm. `fehlerText` gibt es in dieser Datei seit jeher;
|
||
die zwei anderen Stellen benutzen es, diese neue nicht.
|
||
Genau so entsteht eine Ausnahme: nicht aus Absicht, sondern
|
||
weil man die Hausregel beim Neuschreiben nicht danebenliegen
|
||
hatte. */
|
||
if (!a.ok) {
|
||
melde('eingang-fehler', await fehlerText(a, 'Das ging gerade nicht.'));
|
||
return;
|
||
}
|
||
await laden();
|
||
} catch { /* hole() hat schon gemeldet */ }
|
||
}
|
||
|
||
/** 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);
|
||
}
|
||
|
||
/* ==== WER IST DRAN? (25.09.2026) =============================
|
||
|
||
Liegt die Meldung beim Melder, ist die Leitung NICHT dran --
|
||
und ein Knopf „Behoben – nachfragen ..." waere dort schlicht
|
||
falsch: Sie hat ja schon nachgefragt. Gefunden hat das kein
|
||
Test, sondern ein Bildschirmfoto der fertigen Seite.
|
||
|
||
DER HINWEIS STEHT VOR DEM KNOPF, nicht statt seiner: Manchmal
|
||
faellt einem hinterher noch etwas ein, und dann soll man es
|
||
schicken koennen. Der Knopf heisst dann aber, was er tut. */
|
||
let hinweis = null;
|
||
if (m.stand === 'wartet') {
|
||
hinweis = el('p', 's-karte__wartet');
|
||
hinweis.append(el('span', 's-karte__wartet-punkt'));
|
||
hinweis.append(document.createTextNode(
|
||
m.von ? `Liegt bei ${m.von} – sie oder er sagt, ob es wieder geht.`
|
||
: 'Liegt beim Melder – er sagt, ob es wieder geht.'));
|
||
}
|
||
|
||
/* DER KNOPF HEISST ANDERS, WEIL ER ETWAS ANDERES TUT (25.09.2026).
|
||
|
||
„Erledigt" hiess: zu. Jetzt geht die Meldung damit zuerst an den
|
||
MELDER, und erst sein Druck schliesst sie. Ein Knopf, der
|
||
„Erledigt" sagt und in Wahrheit eine Frage stellt, waere genau
|
||
die Sorte Beschriftung, die man einmal glaubt und danach nie
|
||
wieder. */
|
||
const fertig = el('button', 'schritt',
|
||
m.stand === 'wartet' ? 'Noch etwas nachschicken …' : 'Behoben – nachfragen …');
|
||
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 nachtrag = m.stand === 'wartet';
|
||
const antwort = await window.frageNach?.({
|
||
titel: nachtrag ? 'Noch etwas nachschicken?' : 'Behoben – beim Melder nachfragen?',
|
||
was: nachtrag
|
||
? 'Dein neuer Satz ERSETZT den alten in dieser Runde – der Melder sieht nur ihn.'
|
||
: 'Er bekommt deinen Satz und zwei Knöpfe: „Geht wieder“ oder „Noch nicht“.',
|
||
bleibt: nachtrag
|
||
? 'Die Meldung bleibt bei ihm liegen. Der Verlauf davor bleibt stehen.'
|
||
: 'Erledigt ist sie erst, wenn ER bestätigt. Sagt er „noch nicht“, '
|
||
+ 'kommt sie zurück – mit allem, was ihr bisher geschrieben habt.',
|
||
ja: nachtrag ? 'Nachschicken' : 'Nachfragen',
|
||
grund: 'Was ist daraus geworden?',
|
||
grundPflicht: true,
|
||
grundMin: 3,
|
||
platzhalter: nachtrag
|
||
? 'z. B. Behoben, der Knopf geht wieder – und das Datum stimmt jetzt auch.'
|
||
: 'z. B. Behoben, der Knopf geht wieder.',
|
||
});
|
||
if (!antwort) return;
|
||
await aendern(m, 'erledigt', antwort.grund || '');
|
||
});
|
||
reihe.append(fertig);
|
||
|
||
/* EIN FRAGMENT, WEIL `reihe.before(...)` HIER NICHTS TUT.
|
||
Die Reihe haengt in diesem Moment noch an keinem Elternknoten --
|
||
sie wird erst vom Aufrufer eingesetzt --, und `before()` auf
|
||
einem heimatlosen Element ist wirkungslos. Ohne Fehlermeldung:
|
||
Der Hinweis wurde gebaut, stand nirgends, und das Bild sah aus
|
||
wie vorher. Aufgefallen beim Nachsehen am Bildschirmfoto, nicht
|
||
im Code. */
|
||
if (!hinweis) return reihe;
|
||
const beides = document.createDocumentFragment();
|
||
beides.append(hinweis, reihe);
|
||
return beides;
|
||
}
|
||
|
||
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 [
|
||
/* DER VIERTE STAND BEKOMMT SEINE EIGENE ZAHL (25.09.2026).
|
||
|
||
Ohne sie zaehlte „wartet" nirgends mit -- die drei Kaesten
|
||
oben ergaeben nicht mehr die Gesamtzahl, und Meldungen, die
|
||
auf eine Bestaetigung warten, waeren unsichtbar. Genau die
|
||
Sorte Zustand, die man beim Zaehlen vergisst. */
|
||
['neu', 'neu'], ['in_arbeit', 'in Arbeit'],
|
||
['wartet', 'wartet auf Melder'], ['erledigt', 'erledigt'],
|
||
]) {
|
||
const kasten = el('div', 's-bilanz__feld');
|
||
kasten.dataset.stand = stand;
|
||
/* OB DAS FELD LEER IST, steht als Merkmal daran -- das Stilblatt
|
||
faerbt die "neu"-Null damit nicht mehr rot. Gerechnet wird es
|
||
hier und nicht im CSS: Dort gibt es keine Zahlen, nur Text,
|
||
und ":empty" traefe eine "0" nicht. */
|
||
const n = zahl(stand);
|
||
if (!n) kasten.dataset.leer = 'ja';
|
||
kasten.append(el('span', 's-bilanz__zahl', String(n)));
|
||
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 : [];
|
||
/* WIE VIELE BILDER GEHEN, SAGT DER SERVER (02.10.2026). Steht
|
||
es nicht in der Antwort (eine Seite aus dem Zwischenspeicher
|
||
trifft auf einen neuen Server oder umgekehrt), bleibt der
|
||
Vorgabewert — eine fehlende Zahl darf die Auswahl nicht auf
|
||
null setzen. */
|
||
if (Number.isInteger(r.bilder_max) && r.bilder_max > 0) bilderMax = r.bilder_max;
|
||
|
||
/* 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();
|
||
})();
|