Files
dogfather-universe/workspace/assets/js/material.js
T
DogFatherGitandClaude Opus 5 95c4599baa Anfuehrungszeichen: 199 falsche Schlusszeichen im sichtbaren Text
Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.

Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.

WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE

Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus

    „<a href="https://…                  ein kaputtes Attribut
    /^["'„»\s]+|["'“«.\s]+$/              einen kaputten Ausdruck
    "… nichts „mal " + "eben …"          eine kaputte Zeichenkette

DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE

  < > = dazwischen        -> HTML-Marke oder Attribut
  endet auf Leerzeichen   -> die Zeichenkette hoert hier auf, der
                             Satz geht in der naechsten Zeile weiter.
                             Ein deutsches Schlusszeichen steht NIE
                             hinter einem Leerzeichen.
  Rueckstrich mittendrin  -> regulaerer Ausdruck
  ${ ohne }               -> mitten in einem Ausdruck

Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.

Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.

Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.

KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.

DIE WACHE DAZU

pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.

GEPRUEFT

  pruef-struktur   59 -> 68 Pruefungen, 0 Fehler
  node --check auf allen geaenderten JS-Dateien
  nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben

  gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
  reaktion 421, support 63, content 45, terminregel 35, treff 85

  Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
  ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
  Ausgabe, keine einzige ein Vergleich mit dem Seitentext.

GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.

Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:56:15 +02:00

621 lines
26 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.
/* =====================================================================
material.js — Bilder und Videos zum Posten, jedes nur einmal
=====================================================================
Auftrag vom 22.09.2026.
DIESE DATEI KENNT KEINEN ROLLENNAMEN. Ob jemand einstellen darf und
ob er sieht, wer sich etwas genommen hat, sagt der Server
(`darf_einstellen`, `sieht_wer`). Stünde es hier, stünde es in einer
Datei, die jeder herunterlädt — und wäre am Tag der nächsten
Änderung falsch.
DER WICHTIGSTE TEIL IST DAS NEHMEN, und es sind zwei Schritte:
erst festschreiben (`/nehmen`), dann herunterladen. Wer zu spät
kommt, bekommt 409 und sieht sofort, dass es weg ist — statt eine
Datei zu laden, die schon jemand anders postet.
===================================================================== */
(() => {
'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;
};
const melde = (t) => { const f = $('fehler'); if (f) f.textContent = t || ''; };
let stand = null;
/* Eine Größe, die ein Mensch lesen kann. */
function groesse(bytes) {
const mb = bytes / (1024 * 1024);
if (mb >= 1) return `${mb.toFixed(1).replace('.', ',')} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
function datum(roh) {
try {
const d = new Date(roh);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
} catch { return ''; }
}
/* EIN PUNKT, ABER KEIN ZWEITER. Eine deutsche Datumsangabe endet
selbst auf einen Punkt ("22.09."). Ein angehängter Satzpunkt
ergibt dann "am 22.09.." — auf dem Bildschirm sieht das nach
Tippfehler aus. Gefunden beim ersten Blick mit echten Augen,
nicht in einer Messung: Kein Prüfprogramm hätte danach gefragt. */
const punkt = (t) => (/[.!?]$/.test(t) ? t : t + '.');
/* ===================================================================
DIE VIER ZUSTÄNDE (22.09.2026)
-------------------------------------------------------------------
Filipe: „mach kategorien welche sind benutzt welche nicht welche
sind noch offen, welche laufen von wan bis wann."
Gerechnet wird der Zustand auf dem SERVER (`zustandVon`), nicht
hier. Zwei Rechnungen für dieselbe Frage laufen auseinander — und
spätestens um Mitternacht zeigte die Seite einen anderen Zustand
als die Datenbank.
Die Reihenfolge hier ist die, in der man sie liest: erst was jetzt
geht, dann was kommt, dann was vorbei ist, dann was weg ist.
=================================================================== */
const ZUSTAENDE = [
{ wert: '', wort: 'Alle' },
{ wert: 'frei', wort: 'Jetzt frei' },
{ wert: 'spaeter', wort: 'Kommt noch' },
{ wert: 'abgelaufen', wort: 'Abgelaufen' },
{ wert: 'benutzt', wort: 'Schon benutzt' },
];
const ZUSTAND_WORT = Object.fromEntries(
ZUSTAENDE.filter((z) => z.wert).map((z) => [z.wert, z.wort]));
/* Welche Kategorie gerade gewählt ist. Steht außerhalb von `laden()`:
Wäre sie drin, spränge die Liste nach jedem Nehmen zurück auf
„Alle" — und man müsste jedes Mal neu suchen, wo man war. */
let katFilter = '';
function tagText(iso) {
if (!iso) return '';
const t = String(iso).slice(0, 10).split('-');
return t.length === 3 ? `${t[2]}.${t[1]}.${t[0]}` : String(iso);
}
/* „ab 01.10.2026", „bis 07.10.2026" oder „01.10. – 07.10.2026".
Ein Zeitraum, bei dem eine Seite fehlt, ist trotzdem einer — und
„ab 01.10." sagt mehr als ein Gedankenstrich ins Leere. */
function zeitraum(m) {
if (m.gilt_ab && m.gilt_bis) return `${tagText(m.gilt_ab)} – ${tagText(m.gilt_bis)}`;
if (m.gilt_ab) return `ab ${tagText(m.gilt_ab)}`;
if (m.gilt_bis) return `bis ${tagText(m.gilt_bis)}`;
return '';
}
/* ===================================================================
BEARBEITEN — ein Formular IN der Karte, kein Kasten davor
-------------------------------------------------------------------
Erster Entwurf stellte drei Nachfragen hintereinander: Text, dann
„gilt ab", dann „gilt bis". Das ist dreimal Lesen und dreimal
Bestätigen für eine Sache, die auf eine Karte passt — und wer beim
dritten abbricht, hat die ersten zwei umsonst getippt.
Zweiter Fund beim Messen der echten Schnittstelle: `frageNach`
kennt kein vorbelegtes Textfeld. Eine Bearbeitung, die den
bisherigen Text nicht zeigt, ist keine Bearbeitung, sondern ein
Neuschreiben.
Also hier: Die Karte klappt auf, drei Felder stehen mit ihren
heutigen Werten darin, ein Knopf speichert. Abbrechen schließt
wieder — es wurde nichts angefasst, deshalb braucht es auch keine
Rückfrage.
=================================================================== */
function bearbeiten(m) {
const karteEl = document.querySelector(`.m-karte[data-id="${m.id}"]`);
if (!karteEl || karteEl.querySelector('.m-bearb')) return;
const f = el('form', 'm-bearb');
const zeile = (schild, art, wert, id) => {
const d = el('div', 'm-bearb__feld');
const l = el('label', 'feld-schild', schild);
l.htmlFor = id;
const i = art === 'text'
? el('textarea') : el('input');
i.id = id;
if (art !== 'text') i.type = art;
i.value = wert || '';
if (art === 'text') { i.rows = 2; i.maxLength = 600; }
d.append(l, i);
f.append(d);
return i;
};
const tText = zeile('Wofür ist es?', 'text', m.text, `b-text-${m.id}`);
const tAb = zeile('Gilt ab (freiwillig)', 'date', m.gilt_ab, `b-ab-${m.id}`);
const tBis = zeile('Gilt bis (freiwillig)', 'date', m.gilt_bis, `b-bis-${m.id}`);
const hinweis = el('p', 'm-bearb__hinweis',
'Die Datei selbst bleibt, wie sie ist. Wer ein anderes Bild will, '
+ 'stellt ein neues Stück bereit.');
const fehlerZeile = el('p', 'm-bearb__fehler');
fehlerZeile.setAttribute('role', 'alert');
const knoepfe = el('p', 'm-bearb__knoepfe');
const speichern = el('button', 'knopf knopf--klein', 'Speichern');
speichern.type = 'submit';
const ab = el('button', 'knopf-still', 'Abbrechen');
ab.type = 'button';
ab.addEventListener('click', () => f.remove());
knoepfe.append(speichern, ab);
f.append(hinweis, fehlerZeile, knoepfe);
f.addEventListener('submit', async (ev) => {
ev.preventDefault();
/* VORHER PRUEFEN, WAS DER SERVER OHNEHIN PRUEFT. Nicht statt
seiner — er bleibt die Regel. Aber ein Fenster, das rückwärts
läuft, hier schon zu sagen, spart den Weg hin und zurück. */
if (tAb.value && tBis.value && tAb.value > tBis.value) {
fehlerZeile.textContent = 'Das Ende liegt vor dem Anfang. Dreh die zwei Daten um.';
tBis.focus();
return;
}
speichern.disabled = true;
try {
const a = await fetch(`/workspace/api/material/${m.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: tText.value, gilt_ab: tAb.value, gilt_bis: tBis.value,
}),
});
const r = await a.json().catch(() => ({}));
if (!a.ok) {
fehlerZeile.textContent = window.sagWas?.(r.fehler, 'Ging nicht.') || 'Ging nicht.';
speichern.disabled = false;
return;
}
await laden();
} catch {
fehlerZeile.textContent = 'Das hat gerade nicht geklappt. Versuch es gleich noch einmal.';
speichern.disabled = false;
}
});
karteEl.querySelector('.m-karte__koerper')?.append(f);
tText.focus();
}
async function entfernen(m) {
/* `frageNach` liefert bei einer reinen Rückfrage `true`, nicht
`{ ok: true }` — das Objekt kommt nur, wenn ein Feld dabei ist.
Der erste Entwurf prüfte auf `erg?.ok`; damit wäre „Entfernen"
ein Knopf gewesen, der nichts tut, und niemand hätte den Grund
gesehen. Gemessen an der Quelle, nicht angenommen. */
const erg = await window.frageNach?.({
titel: 'Wirklich entfernen?',
was: `„${m.name}“ verschwindet aus der Liste, und die Datei wird gelöscht.`,
bleibt: 'Was schon jemand genommen hat, ist davon nicht betroffen — '
+ 'das liegt längst auf seinem Gerät.',
endgueltig: true,
ja: 'Entfernen', nein: 'Doch nicht',
});
if (!erg) return;
try {
const a = await fetch(`/workspace/api/material/${m.id}`, { method: 'DELETE' });
const r = await a.json().catch(() => ({}));
if (!a.ok) { melde(window.sagWas?.(r.fehler, 'Ging nicht.') || 'Ging nicht.'); return; }
melde('');
await laden();
} catch { melde('Das hat gerade nicht geklappt. Versuch es gleich noch einmal.'); }
}
/* ===================================================================
DIE KATEGORIEN
-------------------------------------------------------------------
Die Zahl neben jedem Wort kommt aus den echten Daten. Eine Zahl im
HTML wäre die, die beim nächsten Stück falsch dasteht.
EINE KATEGORIE OHNE INHALT WIRD NICHT VERSTECKT, sondern grau: Wer
„Abgelaufen" sucht und den Knopf nicht findet, weiß nicht, ob es
nichts gibt oder ob er ihn übersieht. Eine 0 ist eine Antwort.
=================================================================== */
function katLeisteBauen(alle) {
const ziel = $('m-kat');
if (!ziel) return;
ziel.replaceChildren();
for (const z of ZUSTAENDE) {
const n = z.wert ? alle.filter((m) => m.zustand === z.wert).length : alle.length;
const b = el('button', 'schritt m-kat__knopf');
b.type = 'button';
b.dataset.wert = z.wert;
b.setAttribute('aria-pressed', String(katFilter === z.wert));
if (!n) b.dataset.leer = 'ja';
/* Das Wort als blosser Text, nicht in einer eigenen Huelle: Die
Huelle haette einen Klassennamen ohne eine einzige Regel
dahinter -- und niemand weiss beim naechsten Umbau, ob er sie
anfassen darf. Anonymer Text in einer Flex-Reihe ist selbst
ein Flex-Kind, `gap` wirkt also trotzdem. */
b.append(document.createTextNode(z.wort));
b.append(el('span', 'm-kat__zahl', String(n)));
b.addEventListener('click', () => {
/* NOCH EINMAL AUF DASSELBE HEISST „ALLE". Sonst gibt es keinen
Weg zurueck ausser dem Knopf ganz links -- und wer zweimal
tippt, erwartet das Gegenteil, nicht nichts.
„Alle" selbst bleibt dabei „Alle": `'' === ''` waere sonst
ein Klick, der den Zustand nicht aendert und trotzdem neu
zeichnet. */
katFilter = (z.wert && katFilter === z.wert) ? '' : z.wert;
zeichnen();
});
ziel.append(b);
}
}
/* ===================================================================
EINE KARTE
=================================================================== */
function karte(m) {
const k = el('article', 'm-karte');
k.dataset.id = String(m.id);
if (m.genommen) k.dataset.genommen = 'ja';
/* DIE VORSCHAU IST DIE HAUPTSACHE. Wer entscheiden soll, ob er
etwas postet, muss es sehen -- ein Dateiname reicht dafür nicht.
SIE LÄDT ERST BEIM SICHTBARWERDEN (`loading="lazy"`): Bei
dreißig Bildern wären das sonst dreißig Abrufe auf einmal, und
am Handy im Funkloch sieht man eine Minute lang nichts. */
const schau = el('div', 'm-karte__schau');
if (m.genommen) {
/* Was weg ist, wird nicht mehr ausgeliefert -- der Server gibt
die Datei nur dem, der sie genommen hat. Statt eines kaputten
Bildes steht hier ein ehrliches Feld. */
schau.append(el('span', 'm-karte__weg-zeichen', m.art === 'video' ? '▣' : '▤'));
} else if (m.art === 'bild') {
const bild = el('img', 'm-karte__bild');
bild.src = `/workspace/api/material/${m.id}/vorschau`;
bild.alt = '';
bild.loading = 'lazy';
bild.decoding = 'async';
schau.append(bild);
} else {
const v = el('span', 'm-karte__art', 'Video');
schau.append(v);
}
k.append(schau);
const koerper = el('div', 'm-karte__koerper');
koerper.append(el('p', 'm-karte__name', m.name));
if (m.text) koerper.append(el('p', 'm-karte__text', m.text));
const fuss = el('p', 'm-karte__fuss');
fuss.append(el('span', 'm-karte__marke', m.art === 'video' ? 'Video' : 'Bild'));
fuss.append(el('span', 'm-karte__leise', groesse(m.groesse)));
fuss.append(el('span', 'm-karte__leise', `von ${m.von}`));
koerper.append(fuss);
/* VON WANN BIS WANN (22.09.2026).
Steht nur da, WENN es ein Fenster gibt. Eine Zeile "ohne
Zeitraum" auf jeder zweiten Karte waere Rauschen -- und Rauschen
liest niemand, auch nicht die eine Zeile, auf die es ankommt. */
if (m.gilt_ab || m.gilt_bis) {
const z = el('p', 'm-karte__zeit');
z.dataset.zustand = m.zustand;
z.append(el('span', 'm-karte__zeit-marke', ZUSTAND_WORT[m.zustand] || ''));
z.append(document.createTextNode(' ' + zeitraum(m)));
koerper.append(z);
}
/* ---- Der Zustand ---- */
const meldung = el('p', 'm-karte__meldung');
meldung.hidden = true;
if (m.genommen) {
/* WER ES HAT, STEHT NUR FÜR ZWEI DA. Der Server schickt das Feld
den anderen gar nicht erst mit -- diese Zeile prüft deshalb
nicht auf eine Rolle, sondern darauf, ob die Angabe da ist. */
if (m.von_mir_genommen) {
koerper.append(el('p', 'm-karte__stand m-karte__stand--meins',
'Du hast es genommen.'));
const a = el('a', 'knopf knopf--klein', 'Noch einmal laden');
a.href = `/workspace/api/material/${m.id}/datei`;
a.setAttribute('download', m.name);
koerper.append(a);
} else if (m.genommen_von) {
koerper.append(el('p', 'm-karte__stand', punkt(
`${m.genommen_von} hat es genommen${m.genommen_am ? ' am ' + datum(m.genommen_am) : ''}`)));
} else {
koerper.append(el('p', 'm-karte__stand', 'Schon benutzt.'));
}
} else {
const knopf = el('button', 'knopf knopf--klein', 'Nehmen und laden');
knopf.type = 'button';
knopf.addEventListener('click', () => nehmen(m, knopf, meldung));
koerper.append(knopf);
}
/* BEARBEITEN UND ENTFERNEN (22.09.2026).
Filipe: "will ich dass die rechte und linke hand es auch
bearbeiten und loeschen koennen. dogfather auch fals es ein
fehler gab."
OB JEMAND DARF, SAGT DER SERVER (`m.darf_ich`) -- hier wird es
nicht nachgerechnet. Zwei Rechnungen fuer dieselbe Frage laufen
auseinander, und die im Browser ist die, der man ohnehin nicht
trauen darf.
Ein vergebenes Stueck bleibt unangetastet: Wer es genommen hat,
hat sich auf diesen Text verlassen. */
if (m.darf_ich && !m.genommen) {
const reihe = el('p', 'm-karte__verwalten');
const bearb = el('button', 'knopf-still', 'Bearbeiten');
bearb.type = 'button';
bearb.addEventListener('click', () => bearbeiten(m));
const weg = el('button', 'knopf-still knopf-still--warn', 'Entfernen');
weg.type = 'button';
weg.addEventListener('click', () => entfernen(m));
reihe.append(bearb, weg);
koerper.append(reihe);
}
koerper.append(meldung);
k.append(koerper);
return k;
}
/* ===================================================================
NEHMEN — erst festschreiben, dann laden
=================================================================== */
async function nehmen(m, knopf, meldung) {
knopf.disabled = true;
knopf.textContent = 'einen Moment …';
try {
const a = await fetch(`/workspace/api/material/${m.id}/nehmen`, { method: 'POST' });
if (a.status === 409) {
/* DER WICHTIGE FALL: Jemand war schneller. Kein Fehler, keine
rote Zeile -- eine Auskunft. Und die Liste wird neu geholt,
damit man sofort sieht, was noch da ist. */
const j = await a.json().catch(() => ({}));
meldung.textContent = j.wer
? `${j.wer} war ein paar Sekunden schneller.`
: 'Da war jemand schneller — das ist schon vergeben.';
meldung.hidden = false;
knopf.remove();
setTimeout(laden, 1400);
return;
}
if (!a.ok) {
const j = await a.json().catch(() => ({}));
meldung.textContent = window.sagWas?.(j.fehler, 'Ging gerade nicht.')
|| 'Ging gerade nicht.';
meldung.hidden = false;
knopf.disabled = false;
knopf.textContent = 'Nehmen und laden';
return;
}
/* ES GEHÖRT JETZT IHM. Der Download wird ausgelöst, ohne die
Seite zu verlassen -- ein `location.assign` hätte die Liste
weggeworfen, und danach wüsste niemand mehr, was er gerade
geholt hat. */
const weg = (await a.json().catch(() => ({}))).weg
|| `/workspace/api/material/${m.id}/datei`;
const link = document.createElement('a');
link.href = weg;
link.download = m.name;
document.body.append(link);
link.click();
link.remove();
meldung.textContent = 'Gehört dir. Der Download läuft.';
meldung.hidden = false;
knopf.remove();
setTimeout(laden, 1200);
} catch {
meldung.textContent = 'Das Netz war gerade weg. Versuch es noch einmal.';
meldung.hidden = false;
knopf.disabled = false;
knopf.textContent = 'Nehmen und laden';
}
}
/* ===================================================================
EINSTELLEN
=================================================================== */
function einstellenAufbauen() {
const knopf = $('m-hoch');
if (!knopf) return;
knopf.addEventListener('click', async () => {
const feld = $('m-datei');
const datei = feld?.files?.[0];
const stand2 = $('m-stand');
if (!datei) {
stand2.textContent = 'Such erst ein Bild oder Video aus.';
return;
}
if (stand?.max_bytes && datei.size > stand.max_bytes) {
stand2.textContent = `Das ist zu groß (${groesse(datei.size)}). `
+ `Mehr als ${groesse(stand.max_bytes)} geht nicht.`;
return;
}
/* DAS FENSTER WIRD VOR DEM HOCHLADEN GEPRÜFT, NICHT DANACH.
Der Server lehnt ein verkehrtes Fenster ohnehin ab — aber
erst, nachdem 60 MB durch das Handynetz gewandert sind. Wer
das erlebt, lädt zweimal hoch für einen Zahlendreher. */
const abFeld = $('m-ab');
const bisFeld = $('m-bis');
if (abFeld?.value && bisFeld?.value && abFeld.value > bisFeld.value) {
stand2.textContent = window.sagWas?.('fenster_verkehrt', '')
|| 'Das Ende liegt vor dem Anfang.';
bisFeld.focus();
return;
}
knopf.disabled = true;
/* ÜBER DEN GEMEINSAMEN HOCHLADER -- er zeigt den Fortschritt und
lässt sich abbrechen. Genau dafür wurde er am 21.09. gebaut;
eine eigene Fassung hier wäre die zweite, die es nicht kann.
DIE SCHNITTSTELLE IST NACHGELESEN, NICHT GERATEN: `hochladen()`
nimmt (Pfad, Daten, {kopf, beiFortschritt}) und liefert
{fertig, abbrechen}; `fertig` löst mit {ok, status, daten} auf.
Mein erster Entwurf hatte sie sich ausgedacht. */
const anzeige = window.hochladeAnzeige?.($('m-fortschritt'), datei.name,
() => lauf?.abbrechen());
const lauf = window.hochladen?.('/workspace/api/material', datei, {
kopf: {
'Content-Type': datei.type || 'application/octet-stream',
'X-Datei-Name': encodeURIComponent(datei.name || ''),
'X-Text': encodeURIComponent(($('m-text')?.value || '').slice(0, 600)),
/* Leer ist erlaubt und heißt „gilt immer" — der Server macht
aus einer leeren Kopfzeile `null`, nicht einen Fehler. */
'X-Gilt-Ab': abFeld?.value || '',
'X-Gilt-Bis': bisFeld?.value || '',
},
beiFortschritt: (anteil, gesendet, gesamt) => anzeige?.fortschritt(anteil, gesendet, gesamt),
beiVerarbeitung: () => anzeige?.verarbeiten(),
});
try {
const ergebnis = lauf
? await lauf.fertig
: { ok: false, netz: true };
anzeige?.weg();
if (ergebnis.abgebrochen) {
stand2.textContent = 'Abgebrochen. Es wurde nichts bereitgestellt.';
return;
}
if (!ergebnis.ok) {
stand2.textContent = ergebnis.netz
? 'Das Netz war gerade weg. Versuch es noch einmal.'
: (window.sagWas?.(ergebnis.daten?.fehler, 'Ging nicht.') || 'Ging nicht.');
return;
}
stand2.textContent = 'Steht jetzt bereit.';
feld.value = '';
const t = $('m-text'); if (t) t.value = '';
if (abFeld) abFeld.value = '';
if (bisFeld) bisFeld.value = '';
await laden();
} finally {
knopf.disabled = false;
}
});
}
/* ===================================================================
LADEN
=================================================================== */
/* ===================================================================
ZEICHNEN — getrennt vom Holen (22.09.2026)
-------------------------------------------------------------------
Bis heute war beides eine Funktion. Mit den Kategorien geht das
nicht mehr: Ein Klick auf „Abgelaufen" darf nicht die ganze Liste
neu vom Server holen -- das wäre ein Abruf für eine Frage, deren
Antwort schon im Browser liegt, und am Handy im Funkloch ein
Zustand, in dem man nichts sieht.
=================================================================== */
/* WARUM OBEN NICHTS STEHT, HAT DREI GRÜNDE — und der dritte fehlte.
Wer „Schon benutzt" antippt, bekommt oben immer eine leere Liste,
weil benutzte Stücke unten stehen. „Ist gerade nichts" wäre dort
schlicht gelogen und schickte jemanden los, der schon am Ziel ist. */
function leerSatz(untenAnzahl) {
if (katFilter && untenAnzahl) {
return `Unter „${ZUSTAND_WORT[katFilter] || katFilter}“ steht nichts zum `
+ 'Mitnehmen — was dazu passt, findest du weiter unten.';
}
if (katFilter) {
return `In „${ZUSTAND_WORT[katFilter] || katFilter}“ ist gerade nichts. `
+ 'Tipp oben auf „Alle“, um wieder alles zu sehen.';
}
return stand.darf_einstellen
? 'Gerade nichts frei. Stell oben etwas bereit — dann können die anderen es sich holen.'
: 'Gerade ist nichts frei. Schau später noch einmal rein — hier kommt regelmäßig Neues dazu.';
}
function zeichnen() {
if (!stand) return;
$('neu-block').hidden = !stand.darf_einstellen;
const alle = stand.material || [];
katLeisteBauen(alle);
/* DER FILTER GILT FÜR BEIDE ABSCHNITTE — sonst ist er keiner.
Im ersten Entwurf filterte nur die obere Liste. „Schon benutzt"
anzutippen ergab damit oben zwangsläufig nichts (ein benutztes
Stück steht nie zum Mitnehmen) und unten unverändert alles: Der
Knopf sah aus, als täte er nichts, und die Zahl daneben passte
zu keiner der beiden Listen. */
const passt = (m) => !katFilter || m.zustand === katFilter;
const frei = alle.filter((m) => !m.genommen && passt(m));
const weg = alle.filter((m) => m.genommen && passt(m));
const zielFrei = $('frei');
zielFrei.replaceChildren();
zielFrei.setAttribute('aria-busy', 'false');
if (!frei.length) {
/* KEIN LEERER BILDSCHIRM. Ob das gut oder schlecht ist, hängt
davon ab, wer liest -- deshalb drei Sätze statt einem. Der
dritte ist neu: Eine leere Liste nach einem Klick auf eine
Kategorie ist etwas anderes als eine leere Liste. */
zielFrei.append(el('p', 'm-leer', leerSatz(weg.length)));
}
for (const m of frei) zielFrei.append(karte(m));
/* ZWEI ZAHLEN FÜR FAST DASSELBE, DIE SICH WIDERSPRECHEN.
Auf dem ersten Bildschirmfoto stand oben „4 Stück zum
Mitnehmen" und eine Zeile darunter „Jetzt frei 2". Beide
stimmten — die eine zählt alles Freie, die andere nur das, was
heute schon gilt —, aber wer sie zum ersten Mal sieht, hält
eine davon für falsch und traut ab da keiner mehr.
Also stehen sie jetzt in EINEM Satz und erklären sich
gegenseitig. Und nur dann, wenn sie sich unterscheiden: Steht
„4, davon 4 sofort", ist das keine Auskunft, sondern Lärm. */
const sofort = frei.filter((m) => m.zustand === 'frei').length;
$('frei-satz').textContent = !frei.length ? ''
: katFilter
? `${frei.length} in „${ZUSTAND_WORT[katFilter] || katFilter}“. `
+ 'Wer es nimmt, hat es — danach ist es für alle anderen weg.'
: `${frei.length} Stück zum Mitnehmen`
+ (sofort === frei.length ? '. ' : `, davon ${sofort} sofort. `)
+ 'Wer es nimmt, hat es — danach ist es für alle anderen weg.';
$('weg-block').hidden = !weg.length;
const zielWeg = $('weg');
zielWeg.replaceChildren();
for (const m of weg) zielWeg.append(karte(m));
if (weg.length) {
$('weg-satz').textContent = stand.sieht_wer
? `${weg.length} schon vergeben — mit Namen, damit du weißt, wer was postet.`
: `${weg.length} schon vergeben. Damit nichts zweimal gepostet wird.`;
}
$('laedt').hidden = true;
$('haupt')?.removeAttribute('aria-busy');
melde('');
}
async function laden() {
try {
const a = await fetch('/workspace/api/material');
if (!a.ok) {
window.ladefehler?.({ nochmal: laden });
return;
}
stand = await a.json();
zeichnen();
} catch {
window.ladefehler?.({ nochmal: laden });
}
}
einstellenAufbauen();
laden();
})();