/* ===================================================================== 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(); })();