/* =================================================================== Eine Ansicht für fünf Bereiche (LIVE, Content, Technik, Community, Schutz). Welcher gemeint ist, steht in der Adresse: ?b=live Welche Arten es gibt und ob Bewertung oder Dringlichkeit dazugehören, sagt der Server in der Antwort -- diese Datei kennt die Bereiche nicht auswendig. Ein neuer Bereich braucht hier also keine Zeile. =================================================================== */ (() => { 'use strict'; /* DogFather und Manager haben dieselben Rechte -- die Unterscheidung macht nur die Personenverwaltung, und die prueft der Server. */ /* Die Leitungsliste steht in bereiche.js -- eine Antwort statt neun. Sie stand hier frueher selbst, ohne 'spicy', und genau das war der Fehler, den Filipe am 07.09.2026 gefunden hat. */ const LEITUNG = window.Bereiche.LEITUNG; /* Wer hier eintraegt: DogFather, Manager und Scout. Der Creator liest nur mit -- diese Bereiche sind die Betreuungsakte, nicht sein Notizbuch. Der Server setzt das ebenfalls durch (siehe workspace-bereiche.js); hier geht es nur darum, ihm keine Knoepfe hinzustellen, die ohnehin nur eine Absage bringen. */ /* WER HIER ETWAS EINTRAGEN DARF. Bis zum 11.09.2026 stand hier nur "alle außer dem Creator". Das stimmte, solange jedes Brett jedem Angemeldeten offenstand. Mit den sieben Brettern des Treffs stimmt es nicht mehr: Auf dem Anschlagbrett und bei "Was ansteht" schreibt nur das Team, und wer neu ist, darf die erste Woche überhaupt nicht schreiben. OHNE DIESE ZEILE HÄTTE EIN MITGLIED DEN KNOPF GESEHEN und beim Drücken eine Absage bekommen -- genau die Sorte Knopf, die in diesem Haus als Fehler gilt: Er verspricht etwas, das der Server ablehnt, und der Mensch davor hält sich selbst für den Fehler. DIE ANTWORT KOMMT VOM SERVER (/api/treff/lage), nicht aus einer zweiten Regel hier. Sonst gäbe es die Schreibregel zweimal, und die Fassung im Browser kann man ändern. Faellt die Auskunft aus, bleibt die Lage leer und es gilt wieder die alte Regel -- der Knopf ist dann da und der Server sagt notfalls nein. Das ist die richtige Richtung: Ein Knopf zu viel ist ärgerlich, ein fehlender Riegel wäre ein Loch. */ const darfEintragen = () => { if (!ich || ich.rolle === 'creator') return false; if (treffBrett && lage && lage.schreiben) return lage.schreiben[bereich] === true; return true; }; /* DIE HOECHSTE STIMMENZAHL DER LISTE. Sie ist der Massstab fuer den Balken: Ein Wunsch mit 12 Stimmen neben einem mit 14 sieht sonst genauso aus wie einer mit 1 neben einem mit 40. Der Balken zeigt den ABSTAND, nicht die absolute Zahl -- die steht ohnehin daneben. */ let maxStimmen = 0; /* Wie ein Brett in einem Satz heisst. Kurz, weil es mitten in einer Zeile steht: „Aus Wunschliste: …" liest sich, „Aus dem Bereich Wunschliste (wunsch): …" nicht. */ /* Wie ein Kanal auf der Karte heisst. Kurz -- er steht neben einem Knopf, nicht in einer Ueberschrift. */ const KANAL_WORT = { dogfather: 'DogFather', clips: 'DogFather Clips', hasidog: 'HasiDog', }; const BRETT_WORT = { anschlag: 'Anschlagbrett', ansteht: 'Was ansteht', treff: 'Treff', wunsch: 'Wunschliste', highlight: 'Highlights', regeln: 'Regeln', mitmachen: 'Mitmachen', }; /* WELCHER WEG VON WO AUS SINN ERGIBT. Bewusst wenige: Ein Menü mit sieben Zielen an jeder Karte waere ein Menü, kein Angebot. Was hier steht, ist der Weg, den eine Sache im echten Betrieb nimmt. */ const KETTE_ZIELE = { wunsch: [ { nach: 'ansteht', wort: 'Daraus einen Termin machen' }, { nach: 'highlight', wort: 'Daraus ein Highlight machen' }, ], ansteht: [{ nach: 'highlight', wort: 'Daraus ein Highlight machen' }], treff: [{ nach: 'wunsch', wort: 'Daraus einen Wunsch machen' }], highlight: [{ nach: 'anschlag', wort: 'Am Anschlagbrett zeigen' }], }; const $ = (id) => document.getElementById(id); const UNTERZEILE = { live: 'Vorbereitung, Mitschrift und Auswertung – LIVE systematisch verbessern.', content: 'Ideen, Produktion und Veröffentlichtes an einer Stelle statt in Chats.', technik: 'Setups, Fehlerbilder und Lösungen pro Creator sauber dokumentiert.', community: 'Moderation, Aktionen und Konflikte – ruhig statt Drama.', schutz: 'Richtlinien, Vorfälle und was daraus gelernt wurde.', /* Der Satz nennt die Trennung beim Namen, die das Konzept fordert: Betreuung und Umsetzung sind unsere Seite, offizielle Wege sind Agenturseite. Solange das nur im Kopf steht, wird es bei jedem Streitfall neu verhandelt. */ agentur: 'Kampagnen, Schulungen und der offizielle Weg zur Agentur – mit Stand und Antwort.', }; let ich = null; let bereich = null; let einstellung = null; /* Ist dies ein Brett des Treffs, und was darf ich dort? Beides sagt der Server (daten.treff und /api/treff/lage) -- im Browser steht keine Liste der Bretter und keine Rechteregel, sondern nur die Antwort auf "was darf ICH hier gerade". Eine Regel, die im Browser noch einmal steht, ist die, die auseinanderlaeuft; und die Fassung im Browser kann man aendern. */ let treffBrett = false; let lage = null; let filter = ''; /* WELCHER KANAL -- eine ZWEITE Dimension neben `filter`, nicht derselbe Wert. Ein gemeinsamer Wert wäre einfacher zu bauen und falsch zu benutzen: Man könnte dann „nur offene" ODER „nur HasiDog" fragen, aber nie beides. Und genau das will man, sobald aus drei Kanälen Videos nebeneinander liegen. */ let kanalFilter = ''; /* Nur beim ersten Laden springen -- sonst reisst es die Seite nach jeder Bewertung erneut an dieselbe Stelle. */ let gesprungen = false; const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; if (text !== undefined) k.textContent = text; return k; }; const melde = (t) => { $('fehler').textContent = t || ''; }; const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : '—'); /* ---------- Agentur-Events --------------------------------------------- Ein Event ist kein Eintrag mit Ueberschrift und Text, sondern eine Aktion mit Zeitraum, Aufgaben und Regeln. Welche Art das ist, sagt der Server (die Arten kommen aus workspace-bereiche.js) -- die Kennung steht hier trotzdem woertlich, weil das Formular je Art anders aussieht und es dafuer keine allgemeine Regel gibt. Sie steht an EINER Stelle, damit sie sich nicht verteilt. */ const EVENT_BEREICH = 'agentur'; const EVENT_ART = 'kampagne'; const istEventArt = (art) => bereich === EVENT_BEREICH && art === EVENT_ART; /* ===================================================================== "WANN IST DER NAECHSTE STREAM?" (17.09.2026) Filipe wollte fuer den Community-Bereich "eine richtige perfektion". Bei "Was ansteht" ist das keine Frage der Optik: Es gibt genau EINE Frage, die ein Zuschauer hier hat, und sie stand bisher in Zeile vier einer Liste. BEIM BAUEN KAM EINE LUECKE HERAUS, die im Plan nicht stand: Ein Eintrag hatte nur einen TAG, keine Uhrzeit -- die Pruefung im Server lehnte eine Zeitangabe ausdruecklich ab. Ein Countdown "in 3 Std 12 Min" war damit gar nicht moeglich. Die Spalte `uhrzeit` ist deshalb heute dazugekommen; sie ist OPTIONAL, denn viele Termine haben keine Uhrzeit, und ein Pflichtfeld haette dafuer eine erfundene erzwungen. OHNE UHRZEIT ZAEHLT ER IN TAGEN. "Morgen" ist eine ehrliche Antwort; "morgen um 00:00" waere eine erfundene. */ /* Der Zeitpunkt eines Eintrags als Millisekunden -- Ortszeit. `new Date("2026-09-17T20:00:00")` OHNE Z ist Ortszeit, und genau das ist gemeint: Der Stream faengt um 20 Uhr HIER an. */ function zeitpunktVon(e) { const tag = String(e?.datum || '').slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(tag)) return null; const zeit = /^\d{2}:\d{2}$/.test(String(e.uhrzeit || '')) ? e.uhrzeit : null; const d = new Date(`${tag}T${zeit || '00:00'}:00`); return Number.isNaN(d.getTime()) ? null : { ms: d.getTime(), tag, zeit }; } /* Wie lange noch? Gibt Worte zurueck, keine Zahl -- "in 3 Std 12 Min" liest sich in einem Zug, "0.134 Tage" nicht. */ function nochText(e, jetztMs = Date.now()) { const z = zeitpunktVon(e); if (!z) return null; const heute = window.heuteLokal ? window.heuteLokal() : new Date().toISOString().slice(0, 10); const tageHin = Math.round( (new Date(`${z.tag}T00:00:00`).getTime() - new Date(`${heute}T00:00:00`).getTime()) / 86400000); if (tageHin < 0) return { wann: 'vorbei', stufe: 'vorbei' }; /* MIT UHRZEIT UND HEUTE ODER MORGEN: in Stunden. Weiter entfernt sagt eine Stundenzahl nichts mehr -- "in 73 Std" muss man erst umrechnen, "in 3 Tagen" nicht. */ if (z.zeit && tageHin <= 1) { const min = Math.round((z.ms - jetztMs) / 60000); if (min < 0) return { wann: tageHin === 0 ? 'läuft' : 'vorbei', stufe: tageHin === 0 ? 'jetzt' : 'vorbei' }; if (min < 60) return { wann: `in ${min} Min`, stufe: 'jetzt' }; const std = Math.floor(min / 60); const rest = min % 60; return { wann: `in ${std} Std${rest ? ' ' + rest + ' Min' : ''}`, stufe: 'bald' }; } if (tageHin === 0) return { wann: 'heute', stufe: 'jetzt' }; if (tageHin === 1) return { wann: 'morgen', stufe: 'bald' }; if (tageHin <= 7) return { wann: `in ${tageHin} Tagen`, stufe: 'woche' }; return { wann: datum(z.tag), stufe: 'spaeter' }; } /* In welchen Abschnitt des Zeitstrahls gehoert der Eintrag? */ function abschnittVon(e) { const n = nochText(e); if (!n) return 'ohne'; if (n.stufe === 'vorbei') return 'vorbei'; if (n.stufe === 'jetzt' || n.stufe === 'bald') return 'heute'; if (n.stufe === 'woche') return 'woche'; return 'spaeter'; } const ABSCHNITT_NAME = { heute: 'Heute und morgen', woche: 'Diese Woche', spaeter: 'Später', ohne: 'Ohne Datum', vorbei: 'Vorbei', }; /* Der Kasten ganz oben. */ function countdownZeigen(eintraege) { const ziel = $('countdown'); if (!ziel) return; if (bereich !== 'ansteht') { ziel.hidden = true; return; } ziel.hidden = false; ziel.textContent = ''; const kommend = (eintraege || []) .map((e) => ({ e, n: nochText(e), z: zeitpunktVon(e) })) .filter((x) => x.n && x.n.stufe !== 'vorbei') .sort((a, b) => a.z.ms - b.z.ms); if (!kommend.length) { /* EIN SATZ STATT EINER LEEREN FLAECHE. Ein Bereich, der nichts sagt, wenn nichts da ist, sieht kaputt aus statt leer. */ ziel.dataset.leer = 'ja'; ziel.append(el('p', 'countdown__leer', 'Noch kein Termin eingetragen. Der nächste Stream steht hier, sobald er feststeht.')); return; } delete ziel.dataset.leer; const { e, n } = kommend[0]; ziel.dataset.stufe = n.stufe; ziel.append(el('p', 'countdown__schild', 'Als Nächstes')); ziel.append(el('p', 'countdown__wann', n.wann)); ziel.append(el('p', 'countdown__titel', e.titel || 'Termin')); const wann = e.uhrzeit ? `${datum(String(e.datum).slice(0, 10))} um ${e.uhrzeit} Uhr` : datum(String(e.datum).slice(0, 10)); ziel.append(el('p', 'countdown__datum', wann)); if (kommend.length > 1) { ziel.append(el('p', 'countdown__weitere', `${kommend.length - 1} weitere${kommend.length === 2 ? 'r Termin' : ' Termine'} danach`)); } } /* Laeuft das Event noch, kommt es erst, ist es vorbei? Der Vergleich laeuft auf den ISO-Zeichenketten, nicht auf Date -- "2026-09-12" < "2026-09-30" ist als Text schon richtig, und ein Date() haette hier eine Zeitzone erfunden, die niemand gemeint hat. Ohne Ende gilt der Beginntag als der ganze Zeitraum. */ function eventStand(e) { const heute = window.heuteLokal ? window.heuteLokal() : new Date().toISOString().slice(0, 10); const von = (e.datum || '').slice(0, 10); const bis = (e.event_ende || von).slice(0, 10); if (!von) return null; if (heute < von) return { schluessel: 'kommt', text: 'startet ' + datum(von) }; if (heute > bis) return { schluessel: 'vorbei', text: 'vorbei seit ' + datum(bis) }; return { schluessel: 'laeuft', text: von === bis ? 'heute' : 'läuft bis ' + datum(bis) }; } async function hole(pfad, optionen = {}) { const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('unbekannt'); } return a; } function tuKnopf(text, tu) { const b = el('button', 'schritt', text); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; melde(''); try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; } }); return b; } /* ---------- Zeichnen --------------------------------------------------- */ function karte(e) { const k = el('article', 'eintrag-karte'); k.dataset.dringlich = e.dringlichkeit; k.dataset.status = e.status; const kopf = el('div', 'eintrag-kopf'); kopf.append(el('span', 'marke-art', einstellung.arten[e.art] || e.art)); /* DIE VERTRAULICHE ZEILE SAGT ES SELBST (10.09.2026). Sie kommt ohnehin nur bei DogFather und beim Schreiber an -- der Server laesst sie sonst gar nicht heraus. Der Hinweis ist also keine Sperre, sondern eine Auskunft: Ohne ihn saehe die eigene Zeile aus wie jede andere, und beim naechsten Mal schriebe man offen, was man vertraulich gemeint hat. */ /* AN DIE PERSON SCHICKEN (11.09.2026). Nur im Entwicklungs-Bereich, und nur dort, wo ein Eintrag überhaupt zu jemandem gehört. Er ist die einzige Brücke aus einer Aufzeichnung, die die Person selbst nicht sieht -- und deshalb ein ausdrücklicher Schritt und keine Automatik. WURDE SCHON GESCHICKT? Dann steht dort ein Satz statt eines Knopfs. Ein Knopf, der beim zweiten Drücken eine Absage bringt, ist eine Falle; und zweimal dasselbe Lob ist kein doppeltes Lob, sondern ein Versehen, das jeder bemerkt. */ if (bereich === 'entwicklung' && e.creator_id) { if (e.gesendet_am) { const hin = el('span', 'marke-art marke-art--weg', `geschickt ${new Date(e.gesendet_am).toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })}`); hin.title = 'Diese Person hat den Eintrag als Nachricht bekommen.'; kopf.append(hin); } else { const b = el('button', 'eintrag-schicken', 'An die Person schicken'); b.type = 'button'; b.title = 'Schickt Art, Titel und Text als Nachricht in den Chat.'; b.addEventListener('click', async () => { if (!confirm(`„${e.titel}“ als Nachricht schicken?\n\n` + 'Die Person bekommt Art, Titel und Text im Chat – einmalig.')) return; b.disabled = true; const a = await hole(`/workspace/api/bereich/entwicklung/${e.id}/senden`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); b.disabled = false; return; } await laden(); }); kopf.append(b); } } if (e.nur_leitung) { const v = el('span', 'marke-art marke-art--nur', 'nur DogFather'); v.title = 'Das liest ausser dir nur DogFather.'; kopf.append(v); } kopf.append(el('h3', 'eintrag-titel', e.titel)); /* DER ZUSTAND EINES WUNSCHES steht im Kopf, nicht im Kleingedruckten (17.09.2026). Er ist das, weswegen jemand wiederkommt: Ein Wunsch, bei dem nie etwas passiert, ist ein Briefkasten ohne Postbote. */ if (einstellung?.zustaende && e.status) { const name = einstellung.zustaende[e.status] || e.status; const wann = e.geplant ? ` · ${datum(String(e.geplant).slice(0, 10))}` : ''; const z = el('span', 'wunsch-stand', name + wann); z.dataset.stand = e.status; kopf.append(z); } if (einstellung.bewertung && e.bewertung) { const b = el('span', 'bewertung'); b.setAttribute('aria-label', `Bewertung ${e.bewertung} von 5`); for (let i = 1; i <= 5; i++) { const punkt = el('span', 'bewertung__punkt'); if (i <= e.bewertung) punkt.dataset.voll = 'ja'; b.append(punkt); } kopf.append(b); } /* DAS BILD STEHT OBEN, VOR DEM TITEL (17.09.2026) -- aus dem Plan im Vault, Stufe 5. "Highlights -- eure Clips und Bilder" zeigte bisher Textkarten mit einem Anhang darunter. Ein Bild als Anhang unter einer Ueberschrift ist kein Highlight; dieser Bereich lebt vom SEHEN. Der Text wird damit zur Bildunterschrift, nicht umgekehrt. ANSPRINGBAR UND MIT NAMEN: Ein Bild ohne `alt` ist fuer einen Teil der Zuschauer gar nicht da, und eine Galerie aus nicht-anspringbaren Kacheln ist keine Galerie. Deshalb ein echter Verweis mit Beschriftung -- das kann die Tastatur, ohne eine Zeile JavaScript. */ if (Array.isArray(e.bilder) && e.bilder.length) { const bg = el('div', 'bildstreifen'); if (e.bilder.length > 1) bg.dataset.mehrere = 'ja'; for (const b of e.bilder) { const a = el('a', 'bildstreifen__feld'); a.href = `/workspace/api/dateien/${b.id}/bild`; a.target = '_blank'; a.rel = 'noopener'; const bild = el('img', 'bildstreifen__bild'); bild.src = `/workspace/api/dateien/${b.id}/bild`; bild.loading = 'lazy'; bild.decoding = 'async'; /* Der Titel des Beitrags ist die beste Beschreibung, die es hier gibt -- ein Dateiname wie "IMG_2831.jpg" sagt niemandem etwas. */ bild.alt = e.titel ? `Bild zu „${e.titel}“` : (b.name || 'Bild'); /* Ist es doch kein Bild (oder die Datei fehlt), verschwindet der Rahmen, statt ein kaputtes Symbol zu zeigen. */ bild.addEventListener('error', () => { a.remove(); if (!bg.childNodes.length) bg.remove(); }); a.append(bild); bg.append(a); } k.append(bg); } k.append(kopf); /* ---- DAS VIDEO (17.09.2026), Stufe A des Video-Plans ---------- Der Knopf ist die Antwort auf "und dann liken": Er fuehrt dorthin, wo das Liken zaehlt. Ein Herz bei uns, das bei TikTok nichts bewirkt, waere eine Attrappe. `rel="noopener"` ist Pflicht: Ohne das kann die geoeffnete Seite ueber `window.opener` auf unsere zugreifen. */ if (e.quelle_url) { const z = el('div', 'videozeile'); if (e.quelle_kanal) { const k = el('span', 'videozeile__kanal', KANAL_WORT[e.quelle_kanal] || e.quelle_kanal); k.dataset.kanal = e.quelle_kanal; z.append(k); } const a = el('a', 'schritt videozeile__weg', 'Auf TikTok ansehen →'); a.href = e.quelle_url; a.target = '_blank'; a.rel = 'noopener noreferrer'; z.append(a); k.append(z); } /* ---- DIE KETTE (17.09.2026), Stufe 8 des Plans ----------------- Woher kam das hier, und was ist daraus geworden? Beides steht auf der Karte, nicht in einem Detail: Der Zusammenhang ist die Aussage. Ein Clip, unter dem steht „Euer Wunsch — von Lena", ist etwas anderes als ein Clip. */ if (e.aus) { const z = el('p', 'kette kette--her'); z.append(el('span', 'kette__wort', 'Aus')); const a = el('a', 'kette__weg', `${BRETT_WORT[e.aus.bereich] || e.aus.bereich}: ${e.aus.titel}`); a.href = `/workspace/bereich.html?b=${encodeURIComponent(e.aus.bereich)}`; z.append(a); if (e.aus.von_name) z.append(el('span', 'kette__wer', '· von ' + e.aus.von_name)); k.append(z); } if (Array.isArray(e.wurde) && e.wurde.length) { const z = el('p', 'kette kette--hin'); z.append(el('span', 'kette__wort', 'Daraus wurde')); for (const w of e.wurde) { const a = el('a', 'kette__weg', `${BRETT_WORT[w.bereich] || w.bereich}: ${w.titel}`); a.href = `/workspace/bereich.html?b=${encodeURIComponent(w.bereich)}`; z.append(a); } k.append(z); } /* DER RANGBALKEN. Eine Wunschliste ist eine Rangfolge, keine Liste -- und eine Rangfolge sieht man erst, wenn der Abstand sichtbar ist. Die Zahl allein beantwortet "wie viele", der Balken "wie viel mehr als die anderen". */ if (bereich === 'wunsch' && maxStimmen > 0) { const n = Number(e.stimmen) || 0; const balken = el('div', 'wunsch-rang'); const fuell = el('div', 'wunsch-rang__fuellung'); fuell.style.width = `${Math.round((n / maxStimmen) * 100)}%`; if (n === maxStimmen && n > 0) balken.dataset.spitze = 'ja'; balken.append(fuell); /* Fuer Vorleseprogramme eine SPRACHE, kein Balken. */ balken.setAttribute('role', 'img'); balken.setAttribute('aria-label', n === 0 ? 'Noch niemand will das auch' : `${n} ${n === 1 ? 'Person will' : 'Personen wollen'} das auch` + (n === maxStimmen ? ', das ist der meistgewünschte' : '')); k.append(balken); } /* ---- Agentur-Event: Zeitraum, Aufgaben, Regeln ------------------- Die Reihenfolge ist nicht beliebig. Wer auf ein Event schaut, fragt in genau dieser Folge: Laeuft das ueberhaupt noch? Was muss ich tun? Was gilt dabei? Steht der Zeitraum unten im Fuss -- da, wo bei allen anderen Karten das Datum steht --, liest man erst die Aufgaben und danach, dass es seit einer Woche vorbei ist. */ const stand = istEventArt(e.art) ? eventStand(e) : null; if (stand) { k.dataset.event = stand.schluessel; const zeile = el('p', 'event-zeitraum'); zeile.dataset.stand = stand.schluessel; zeile.append(el('span', 'event-zeitraum__marke', stand.text)); zeile.append(el('span', 'event-zeitraum__spanne', e.event_ende && e.event_ende !== e.datum ? `${datum(e.datum)} – ${datum(e.event_ende)}` : datum(e.datum))); k.append(zeile); } if (e.text) k.append(el('p', 'eintrag-text', e.text)); if (stand) { for (const [feld, name] of [['event_aufgaben', 'Aufgaben'], ['event_regeln', 'Regeln']]) { if (!e[feld]) continue; const block = el('div', 'event-block'); block.append(el('h4', 'event-block__schild', name)); /* Zeilenumbrueche bleiben stehen (white-space: pre-line im CSS). Eine Aufzaehlung, die zu einem Absatz zusammenlaeuft, ist genau das, was diese beiden Felder verhindern sollen. */ block.append(el('p', 'event-block__text', e[feld])); k.append(block); } } const fuss = el('div', 'eintrag-fuss'); /* Beim Event steht der Zeitraum schon oben -- das Datum hier waere dieselbe Zahl ein zweites Mal. */ if (!stand) fuss.append(el('span', null, datum(e.datum))); if (LEITUNG.has(ich.rolle) && e.creator_name) fuss.append(el('span', null, '· ' + e.creator_name)); if (e.erstellt_name) fuss.append(el('span', null, '· von ' + e.erstellt_name)); if (e.status === 'erledigt') fuss.append(el('span', null, '· erledigt')); /* ---- Der Treff ------------------------------------------------- DER VOLLE ROLLENNAME AM BEITRAG (Filipe, 11.09.2026: "voller Rollenname zeigen"). Er kommt fertig vom Server; hier steht keine Zuordnung Rolle -> Name, die waere eine Liste aller Rollen dieses Hauses auf jedem Rechner der Community. WARUM DAS UEBERHAUPT DRANSTEHT: In einem Raum mit Zuschauern und Moderation muss man sehen, wer spricht. "Frida" und "Frida · jemand aus dem Team" sind zwei verschiedene Saetze -- der zweite ist eine Ansage, der erste eine Meinung. BEI MITGLIEDERN STEHT NICHTS DA. "Kessi · Community" waere eine Auszeichnung fuer das Normale; der Rollenname ist hier eine Auskunft ueber Verantwortung, nicht ueber Zugehoerigkeit. */ if (treffBrett && e.erstellt_rolle_name && e.erstellt_rolle !== 'gast') { fuss.append(el('span', 'treff-rolle', '· ' + e.erstellt_rolle_name)); } if (treffBrett && lage) { const zeile = el('div', 'treff-knoepfe'); /* WILL ICH AUCH. Die Zahl steht AM Knopf und nicht daneben: Sie ist das Ergebnis des Knopfes, nicht eine Angabe ueber den Eintrag. Und sie aendert sich sofort im Bild, bevor der Server geantwortet hat -- bei einem Knopf, der eine Meinung abgibt, ist eine halbe Sekunde Verzoegerung der Unterschied zwischen "hat geklappt" und "hab ich das jetzt gedrueckt?". Antwortet der Server anders, gilt seine Zahl. */ if (bereich === 'wunsch') { const w = el('button', 'treff-will', ''); w.type = 'button'; let meine = !!e.meine_stimme; let zahl = Number(e.stimmen) || 0; const male = () => { w.textContent = (meine ? '★ ' : '☆ ') + 'Will ich auch · ' + zahl; w.setAttribute('aria-pressed', String(meine)); }; male(); w.addEventListener('click', async () => { w.disabled = true; meine = !meine; zahl += meine ? 1 : -1; male(); try { /* EIN DELETE TRAEGT HIER KEINEN KOERPER (gemessen 11.09.2026). Der naheliegende Aufbau waere gewesen, PUT und DELETE gleich zu behandeln -- gleicher Kopf, gleicher leerer Koerper. Das geht NICHT: Nodes HTTP-Parser weist ein DELETE mit Koerper mit einem leeren 400 ab, noch bevor Express die Anfrage sieht. Man sucht den Fehler dann in der Route, und dort ist keiner. Nachgemessen: PUT mit Koerper -> 200, DELETE ohne Koerper -> 200, DELETE mit Koerper -> 400 mit genau einem Kopffeld ("connection: close"). Das ist die Handschrift des Parsers, nicht die einer Anwendung. */ const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/willich', meine ? { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' } : { method: 'DELETE' }); if (a.ok) { const j = await a.json(); zahl = j.stimmen; meine = j.meine; male(); } } catch { /* umgeleitet */ } finally { w.disabled = false; } }); zeile.append(w); } /* MELDEN. Nur fuer die Community und nur an fremden Beitraegen -- den eigenen kann man loeschen, das ist der kuerzere Weg. Der Grund ist Pflicht (DSA Art. 16); der Server prueft ihn noch einmal, die Abfrage hier erspart nur den Fehlschlag. */ if (!lage.team && e.erstellt_von !== ich.id) { zeile.append(tuKnopf('melden', async () => { const grund = prompt('Was ist daran nicht in Ordnung?\n\n' + 'Bitte schreib es in einem Satz – sonst können wir es nicht prüfen.'); if (grund === null) return; const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/melden', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grund }), }); melde(a.ok ? 'Danke – wir sehen uns das an.' : ((await a.json().catch(() => ({}))).fehler || 'Ging nicht.')); })); } if (lage.team) { /* ANHEFTEN. Hoechstens drei; die Zahl kommt vom Server (lage.anschlag_max), damit sie nicht an zwei Stellen steht. */ if (bereich === 'anschlag') { const dran = !!e.angeheftet; zeile.append(tuKnopf(dran ? 'abnehmen' : 'anheften', async () => { const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/anheften', dran ? { method: 'DELETE' } : { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' }); if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || ('Es hängen schon ' + lage.anschlag_max + ' Ansagen.')); return; } await laden(); })); } /* FREIGEBEN. Bei Terminen heisst es "im Treff zeigen", bei Highlights "freigeben" -- derselbe Vorgang, zwei Woerter, weil es zwei verschiedene Entscheidungen sind. */ if (bereich === 'ansteht' || bereich === 'highlight') { const frei = !!e.freigegeben; const wort = bereich === 'ansteht' ? (frei ? 'nicht mehr zeigen' : 'im Treff zeigen') : (frei ? 'zurückziehen' : 'freigeben'); if (!frei) { const m = el('span', 'marke-art marke-art--nur', 'noch nicht sichtbar'); m.title = 'Die Community sieht das erst nach der Freigabe.'; zeile.append(m); } zeile.append(tuKnopf(wort, async () => { const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/freigeben', frei ? { method: 'DELETE' } : { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' }); if (!a.ok) { melde('Ging nicht.'); return; } await laden(); })); } /* AUS "MITMACHEN" WIRD EIN TALENT. Ein ausdruecklicher Schritt und keine Automatik: Wer sich meldet, ist ein Kandidat -- dass er einer wird, entscheidet ein Mensch. */ if (bereich === 'mitmachen' && e.erstellt_rolle === 'gast') { zeile.append(tuKnopf('zu den Talenten', async () => { if (!confirm('„' + e.titel + '“ zu den Talenten übernehmen?')) return; const a = await hole('/workspace/api/treff/mitmachen/' + e.id + '/uebernehmen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); const j = await a.json().catch(() => ({})); melde(a.ok ? 'Steht jetzt bei den Talenten.' : (j.fehler === 'schon_uebernommen' ? 'Steht dort schon.' : (j.fehler || 'Ging nicht.'))); })); } /* ENTFERNEN MIT GRUND (DSA Art. 17). Eigener Knopf neben dem gewohnten "loeschen" -- denn "loeschen" gibt es weiterhin fuer den eigenen Beitrag, und der braucht keine Begruendung. */ if (e.erstellt_von !== ich.id) { zeile.append(tuKnopf('entfernen', async () => { const grund = prompt('Warum wird „' + e.titel + '“ entfernt?\n\n' + 'Der Grund wird festgehalten – die Person hat ein Recht darauf.'); if (grund === null) return; /* DER GRUND REIST IN DER ADRESSE, nicht im Koerper -- aus demselben gemessenen Grund wie oben. Er ist kein Geheimnis: Er wird der Person, die den Beitrag geschrieben hat, ohnehin mitgeteilt, und er steht im Protokoll. */ const a = await hole('/workspace/api/bereich/' + bereich + '/' + e.id + '?grund=' + encodeURIComponent(grund), { method: 'DELETE' }); if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } await laden(); })); } } if (zeile.childNodes.length) k.append(zeile); } /* ---- DAS GESPRAECH (17.09.2026), Stufe 6 des Plans ------------- "Der Treff" war eine Liste aus Karten. Der einzige Bereich, in dem Menschen MITEINANDER reden sollen, zwang sie, nebeneinander zu reden: Jeder schrieb eine eigene Karte, niemand antwortete jemandem. ANTWORTEN STEHEN EINGERUECKT UNTER IHREM BEITRAG, aelteste zuerst -- so liest man ein Gespraech. Und nur EINE Stufe eingerueckt: Auf 390 px ist bei der zweiten Schluss, dann steht der Text in einer Spalte von vier Woertern. */ if (Array.isArray(e.antworten)) { const strang = el('div', 'antworten'); for (const a of e.antworten) { const z = el('div', 'antwort'); const kopf2 = el('p', 'antwort__kopf'); kopf2.append(el('span', 'antwort__wer', a.von_name || 'jemand')); if (a.von_rolle_name) { kopf2.append(el('span', 'antwort__rolle', a.von_rolle_name)); } kopf2.append(el('span', 'antwort__wann', datum(String(a.erstellt).slice(0, 10)))); z.append(kopf2); z.append(el('p', 'antwort__text', a.text)); /* Die eigene immer, fremde nur das Team -- was der Server genauso entscheidet. Ein Knopf, den der Server ablehnt, waere schlimmer als keiner. */ if (a.meine || darfEintragen()) { const w = el('button', 'antwort__weg', 'löschen'); w.type = 'button'; w.addEventListener('click', async () => { w.disabled = true; const r = await hole(`/workspace/api/antwort/${a.id}`, { method: 'DELETE' }); if (!r.ok) { melde('Ging nicht.'); w.disabled = false; return; } await laden(); }); z.append(w); } strang.append(z); } /* DAS FELD STEHT IMMER DA, nicht hinter einem "Antworten"-Knopf. Ein Gespraech faengt nicht an, wenn man erst einen Knopf suchen muss -- und ein leeres Feld ist die deutlichste Einladung, die es gibt. ABER NUR FUER DEN, DER AUCH DARF. Der Server entscheidet das mit derselben Regel (`darfSchreiben`); ein Feld, dessen Absenden der Server ablehnt, ist schlimmer als keins -- man tippt umsonst und bekommt eine Absage fuer etwas, das man angeboten bekommen hat. */ if (darfEintragen()) { const form = el('form', 'antwort-neu'); const feld = el('input', 'antwort-neu__feld'); feld.type = 'text'; feld.maxLength = 1200; feld.placeholder = e.antworten.length ? 'Antworten …' : 'Als Erster antworten …'; feld.setAttribute('aria-label', `Antwort auf „${e.titel}“`); const ab = el('button', 'antwort-neu__ab', 'Senden'); ab.type = 'submit'; form.append(feld, ab); form.addEventListener('submit', async (ev) => { ev.preventDefault(); const text = feld.value.trim(); if (!text) return; ab.disabled = true; const r = await hole(`/workspace/api/bereich/${bereich}/${e.id}/antwort`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }); if (!r.ok) { melde((await r.json().catch(() => ({}))).fehler || 'Ging nicht.'); ab.disabled = false; return; } feld.value = ''; await laden(); }); strang.append(form); } k.append(strang); } if (!darfEintragen()) { k.append(fuss); return k; } const knoepfe = el('div', 'eintrag-knoepfe'); /* ---- DER WEG WEITER --------------------------------------------- Die Handlung, nicht ein Auswahlfeld: Wer ein Formular ausfuellt, denkt nicht an den Wunsch von letzter Woche. Hier steht der Knopf an dem Wunsch, um den es geht. Schon gegangene Wege fallen weg -- ein Knopf, der beim zweiten Druecken nichts tut, ist eine Sackgasse. */ for (const ziel of (KETTE_ZIELE[bereich] || [])) { if ((e.wurde || []).some((w) => w.bereich === ziel.nach)) continue; knoepfe.append(tuKnopf(ziel.wort, async () => { const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}/daraus`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nach: ziel.nach }), }); if (!a.ok) { melde('Ging nicht.'); return; } await laden(); })); } /* VIER ZUSTAENDE STATT EINES UMSCHALTERS (17.09.2026). "erledigt / wieder offen" reicht fuer einen Vorfall. Ein Wunsch braucht mehr: Er wird angenommen, bekommt einen Tag, wird gemacht -- oder eben nicht. "Diesmal nicht" gehoert ausdruecklich dazu: Ein Nein ist eine Antwort, Schweigen ist keine. */ if (einstellung?.zustaende) { const reihe = el('div', 'wunsch-wahl'); reihe.setAttribute('role', 'group'); reihe.setAttribute('aria-label', 'Zustand setzen'); for (const [wert, name] of Object.entries(einstellung.zustaende)) { const b = el('button', 'wunsch-wahl__knopf', name); b.type = 'button'; b.dataset.stand = wert; b.setAttribute('aria-pressed', String(e.status === wert)); if (e.status === wert) b.disabled = true; b.addEventListener('click', async () => { b.disabled = true; const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: wert }), }); if (!a.ok) { melde('Ging nicht.'); b.disabled = false; return; } await laden(); }); reihe.append(b); } knoepfe.append(reihe); k.append(knoepfe); k.append(fuss); return k; } knoepfe.append(tuKnopf(e.status === 'offen' ? 'erledigt ▶' : '◀ wieder offen', async () => { const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: e.status === 'offen' ? 'erledigt' : 'offen' }), }); if (!a.ok) { melde('Ändern hat nicht geklappt.'); return; } await laden(); })); /* AUS EINER IDEE WIRD ARBEIT (15.09.2026, Stufe 9). HIER und nicht in der Treff-Knopfreihe: Die gehoert ganz zum Treff (`if (treffBrett && lage)`), und dort waere der Knopf genau am falschen Ort -- an den Beitraegen der Community, und an keinem Arbeitsbrett. Zweimal hintereinander dort gelandet; gefunden hat es beide Male die Pruefung im Browser mit "null Knoepfe auf dem Ideenbrett", nicht das Lesen. Welche Bretter Uebernahmen kennen, entscheidet der Server. Eine zweite Liste hier waere die, die veraltet. IST ES SCHON GESCHEHEN, steht statt des Knopfes der Verweis. Ein Knopf, der beim zweiten Druecken "gibt es schon" sagt, laedt zum zweiten Druecken ein. */ const schon = uebernahmen[e.id]; if (schon) { const v = el('a', 'eintrag__aufgabe', 'Aufgabe · ' + schon.status); v.href = 'aufgaben.html#a' + schon.aufgabe_id; v.title = 'Aus diesem Eintrag ist eine Aufgabe geworden.'; knoepfe.append(v); } else if (uebernahmeMoeglich) { knoepfe.append(tuKnopf('zur Aufgabe', async () => { const a = await hole('/workspace/api/eintrag/' + e.id + '/zur-aufgabe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); const j = await a.json().catch(() => ({})); if (!a.ok) { melde(j.fehler === 'schon_uebernommen' ? 'Daraus gibt es schon eine Aufgabe.' : (j.fehler || 'Ging nicht.')); return; } melde('Steht jetzt im Aufgabenbrett – bei dir.'); await laden(); })); } if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) { knoepfe.append(tuKnopf('löschen', async () => { if (!confirm(`„${e.titel}" wirklich löschen?`)) return; const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, { method: 'DELETE' }); if (!a.ok) { melde('Löschen hat nicht geklappt.'); return; } await laden(); })); } fuss.append(knoepfe); k.append(fuss); return k; } /* Sprung vom Bericht (01.09.2026). "dringend" ist kein Filter der Seite -- die Dringlichkeit steht als data-dringlich an der Karte, und genau die wird hervorgehoben. "offen" dagegen IST ein Filter, der wurde schon vor dem ersten Laden gesetzt. */ function sprungZeigen(ziel, karten) { if (gesprungen) return; gesprungen = true; const was = window.zielSchluessel?.(); if (was === 'dringend') { /* OFFEN und hoch -- genau so zaehlt der Bericht (workspace-reports.js: "status = 'offen' AND dringlichkeit = 'hoch'"). Ohne das status-Merkmal hebt die Seite auch erledigte dringende Eintraege hervor: Man klickt auf "2" und bekommt drei markiert. Gefunden von pruef-sprung.mjs -- mit blossem Auge waere das nie aufgefallen, beide Zahlen sehen plausibel aus. */ window.zielMarkieren?.( ziel.querySelectorAll('.eintrag-karte[data-dringlich="hoch"][data-status="offen"]'), 'Einträge, die offen und als dringend eingestuft sind'); } else if (was === 'offen') { window.zielMarkieren?.([...karten.values()], 'Einträge, die noch offen sind', async () => { filter = ''; await laden(); }); } } /* Einmal je Seitenaufruf. Sie wird an ZWEI Stellen gebraucht -- beim Aufbau des Formulars und beim Zeichnen der Karten -- und beide brauchen sie, BEVOR sie etwas anzeigen. Ein zweiter Abruf wäre eine zweite Gelegenheit für zwei verschiedene Antworten auf dem selben Bildschirm. */ async function lageHolen() { if (!treffBrett || lage) return; try { const l = await fetch('/workspace/api/treff/lage', { credentials: 'same-origin' }); if (l.ok) lage = await l.json(); } catch { /* dann gilt die alte Regel, siehe darfEintragen */ } } function filterBauen(eintraege = []) { const ziel = $('filter'); ziel.textContent = ''; const knopf = (wert, text) => { const b = el('button', 'schritt', text); b.type = 'button'; b.setAttribute('aria-pressed', String(filter === wert)); b.addEventListener('click', async () => { filter = wert; await laden(); }); return b; }; ziel.append(knopf('', 'Alle')); ziel.append(knopf('offen', 'Nur offene')); for (const [wert, name] of Object.entries(einstellung.arten)) ziel.append(knopf(wert, name)); /* ---- UND DIE KANALZEILE, wenn hier Videos liegen ---------------- SIE ERSCHEINT NUR, WENN ES ETWAS ZU FILTERN GIBT. Die Kanäle werden aus den vorhandenen Einträgen abgeleitet, nicht aufgezählt: Auf einem Brett ohne Videos steht keine leere Leiste, und auf einem mit nur einem Kanal steht kein Filter, der nichts tut. Ein Knopf, der immer dasselbe zeigt, macht misstrauisch. ERST AB ZWEI KANÄLEN. Bei einem gibt es nichts zu wählen. */ const kanaele = [...new Set(eintraege.map((e) => e.quelle_kanal).filter(Boolean))]; if (kanaele.length < 2) { kanalFilter = ''; return; } const zweite = el('div', 'filter__kanaele'); zweite.setAttribute('role', 'group'); zweite.setAttribute('aria-label', 'Nach Kanal filtern'); const kanalKnopf = (wert, text, zahl) => { const b = el('button', 'schritt filter__kanal', text); b.type = 'button'; if (wert) b.dataset.kanal = wert; b.setAttribute('aria-pressed', String(kanalFilter === wert)); if (zahl !== null) b.append(el('span', 'filter__kanal-zahl', String(zahl))); b.addEventListener('click', async () => { /* Ein zweiter Klick auf denselben Kanal hebt ihn auf -- sonst sucht man den Weg zurück zu „Alle Kanäle" und findet ihn nicht, weil man nicht mehr weiß, dass man gefiltert hat. */ kanalFilter = (kanalFilter === wert) ? '' : wert; await laden(); }); return b; }; zweite.append(kanalKnopf('', 'Alle Kanäle', null)); /* IN DER REIHENFOLGE VON KANAL_WORT, nicht in der, in der die Einträge zufällig kommen. Sonst springen die Knöpfe, sobald jemand ein Video einfügt. */ for (const wert of Object.keys(KANAL_WORT)) { if (!kanaele.includes(wert)) continue; zweite.append(kanalKnopf(wert, KANAL_WORT[wert], eintraege.filter((e) => e.quelle_kanal === wert).length)); } ziel.append(zweite); } /* WAS AUS DEN EINTRAEGEN GEWORDEN IST -- einmal fuer das ganze Brett. Bei zwanzig Ideen waeren einzelne Abfragen zwanzig Anfragen, und die Liste stuende zwanzigmal kurz falsch da, bevor sie stimmt. */ let uebernahmen = {}; let uebernahmeMoeglich = false; async function uebernahmenLaden() { try { const a = await fetch(`/workspace/api/bereich/${encodeURIComponent(bereich)}/uebernahmen`, { credentials: 'same-origin' }); if (!a.ok) { uebernahmen = {}; uebernahmeMoeglich = false; return; } const d = await a.json(); uebernahmen = d.uebernahmen || {}; /* OB DER KNOPF HINGEHOERT, ENTSCHEIDET DER SERVER. Eine zweite Liste der Bretter hier im Browser waere die, die veraltet -- derselbe Fehler wie bei der Rollenreihenfolge am 11.09. */ uebernahmeMoeglich = d.moeglich === true; } catch { uebernahmen = {}; uebernahmeMoeglich = false; } } /* ---- EIN VIDEO EINLESEN --------------------------------------- Link einfuegen, den Rest macht der Server. Nur bei den Highlights: Dort gehoeren Clips hin, und ein Feld an jedem anderen Brett waere eine Einladung, sie ueberall hinzuschreiben. */ function videoFeldZeigen() { const ziel = $('videoholen'); if (!ziel) return; ziel.hidden = !(bereich === 'highlight' && darfEintragen()); } function videoFeldBedienen() { const form = $('video-form'); if (!form || form.dataset.bereit === 'ja') return; form.dataset.bereit = 'ja'; form.addEventListener('submit', async (ev) => { ev.preventDefault(); const feld = $('video-url'); const knopf = $('video-holen'); const url = feld.value.trim(); if (!url) return; knopf.disabled = true; /* "Wird geholt" statt eines stillen Wartens: Der Server fragt TikTok, und das dauert einen Moment. Ein Knopf, der nichts tut und nichts sagt, wird ein zweites Mal gedrueckt. */ const hinweis = $('video-hinweis'); const alterText = hinweis?.textContent || ''; if (hinweis) hinweis.textContent = 'Wird geholt …'; try { const a = await hole(`/workspace/api/bereich/${bereich}/video`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }); const r = await a.json().catch(() => ({})); if (!a.ok) { melde(r.fehler || 'Ging nicht.'); if (hinweis) hinweis.textContent = alterText; knopf.disabled = false; return; } feld.value = ''; if (hinweis) hinweis.textContent = r.schon ? 'Das Video war schon da.' : alterText; await laden(); } finally { knopf.disabled = false; } }); } /* ---- DER STARTKATALOG ------------------------------------------------ Fertige Texte, die jemand aus dem Team uebernimmt. Nicht automatisch: Ein Text, den niemand gelesen hat, stuende sonst als Ansage des Teams auf einer oeffentlichen Seite. */ async function startkatalogZeigen() { const ziel = $('startkatalog'); if (!ziel) return; if (!darfEintragen()) { ziel.hidden = true; return; } let daten = null; try { const a = await fetch(`/workspace/api/bereich/${bereich}/start`, { credentials: 'same-origin' }); if (!a.ok) { ziel.hidden = true; return; } daten = await a.json(); } catch { ziel.hidden = true; return; } if (!daten?.offen) { ziel.hidden = true; return; } ziel.hidden = false; ziel.textContent = ''; const kopf = el('div', 'startkatalog__kopf'); kopf.append(el('h2', 'startkatalog__titel', 'Fertige Vorschläge')); kopf.append(el('p', 'startkatalog__unter', `${daten.offen} ${daten.offen === 1 ? 'Vorschlag ist' : 'Vorschläge sind'} ` + 'vorbereitet. Übernehmen, was passt – ändern kannst du danach alles.')); const alle = el('button', 'schritt startkatalog__alle', `Alle ${daten.offen} übernehmen`); alle.type = 'button'; alle.addEventListener('click', async () => { alle.disabled = true; const a = await hole(`/workspace/api/bereich/${bereich}/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); if (!a.ok) { melde('Ging nicht.'); alle.disabled = false; return; } await laden(); }); kopf.append(alle); ziel.append(kopf); const liste = el('div', 'startkatalog__liste'); for (const v of daten.vorschlaege) { if (v.schon) continue; const k = el('div', 'startkatalog__karte'); k.append(el('span', 'marke-art', einstellung?.arten?.[v.art] || v.art)); k.append(el('h3', 'startkatalog__name', v.titel)); if (v.text) k.append(el('p', 'startkatalog__text', v.text)); const b = el('button', 'schritt', 'Übernehmen'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; const a = await hole(`/workspace/api/bereich/${bereich}/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nr: v.nr }), }); if (!a.ok) { melde('Ging nicht.'); b.disabled = false; return; } await laden(); }); k.append(b); liste.append(k); } ziel.append(liste); } /* ===================================================================== DER NAECHSTE SCHRITT (17.09.2026) Der Server schickt entweder einen fertigen Block oder nichts. Diese Funktion weiss nicht, worum es geht -- und genau das ist der Punkt: Die Frage "darf diese Person?" haengt an der Rolle, und diese Datei kann ohne Anmeldung aus dem offenen Netz gelesen werden. Beim naechsten Bereich, der so einen Schritt braucht, ist hier nichts zu tun. ===================================================================== */ function weiterZeigen(w) { const kasten = $('weiter'); if (!kasten) return; kasten.textContent = ''; if (!w || !w.ziel) { kasten.hidden = true; return; } kasten.hidden = false; kasten.dataset.neu = 'ja'; const text = el('div'); text.append(el('p', 'bw-tor__wort', w.titel || '')); if (w.text) text.append(el('p', 'leise', w.text)); const hin = el('a', 'knopf', w.knopf || 'Weiter'); hin.href = w.ziel; kasten.append(text, hin); } async function laden() { try { await uebernahmenLaden(); const a = await hole(`/workspace/api/bereich/${bereich}`); if (!a.ok) { melde('Einträge konnten nicht geladen werden.'); return; } const daten = await a.json(); einstellung = daten.einstellung; treffBrett = !!daten.treff; weiterZeigen(daten.weiter); await lageHolen(); filterBauen(daten.eintraege || []); const ziel = $('liste'); ziel.textContent = ''; ziel.setAttribute('aria-busy', 'false'); /* HIGHLIGHTS SIND EINE GALERIE, keine Spalte aus Karten. Das Raster steht hier und nicht fest im HTML, weil dieselbe Seite acht Bretter bedient -- ein Raster in der Datei waere fuer sieben davon falsch. */ if (bereich === 'highlight') ziel.dataset.galerie = 'ja'; else delete ziel.dataset.galerie; /* DAS ANSCHLAGBRETT IST EIN SCHWARZES BRETT: wenige, GROSSE Aushaenge. Ein Aushang zwischen dreissig anderen ist kein Aushang -- die Wirkung entsteht durch die Groesse und die Begrenzung. */ if (bereich === 'anschlag') ziel.dataset.aushang = 'ja'; else delete ziel.dataset.aushang; const sichtbar = daten.eintraege.filter((e) => { /* DER KANAL ZUERST und als eigene Bedingung: Er schließt den Art-Filter nicht aus, er kommt dazu. „Nur offene Clips von HasiDog" ist eine sinnvolle Frage. */ if (kanalFilter && e.quelle_kanal !== kanalFilter) return false; if (!filter) return true; if (filter === 'offen') return e.status === 'offen'; return e.art === filter; }); /* DER COUNTDOWN steht ueber der Liste und rechnet mit ALLEN Eintraegen, nicht mit der gefilterten Auswahl: Wer auf "nur offene" stellt, will trotzdem wissen, wann der naechste Stream ist. */ countdownZeigen(daten.eintraege); /* NACH der Liste, nicht davor: Der Katalog verschwindet, sobald alles uebernommen ist -- und das haengt an dem, was gerade geladen wurde. */ startkatalogZeigen(); videoFeldZeigen(); videoFeldBedienen(); maxStimmen = (daten.eintraege || []) .reduce((n, e) => Math.max(n, Number(e.stimmen) || 0), 0); if (!sichtbar.length) { /* DER SATZ GEHOERT ZUM BEREICH (17.09.2026). "Noch kein Eintrag in diesem Bereich" stand auf allen acht gleich -- und drei Bildschirmfotos davon nebeneinander sahen aus wie ein defektes System. Jeder Bereich sagt jetzt in seinen eigenen Worten, was hier hingehoert. */ ziel.append(el('p', 'leer-hinweis', daten.eintraege.length ? 'Zu dieser Auswahl gibt es nichts.' : (einstellung?.leer || 'Noch kein Eintrag in diesem Bereich.'))); /* AUCH HIER springen -- und das ist kein Schoenheitsfehler, sondern der wichtigere Fall: Wer auf "3 dringende Punkte" klickt und auf einer leeren Seite landet, glaubt, er sei falsch abgebogen. Die Leiste sagt ihm, dass er richtig ist und die Sache erledigt. */ sprungZeigen(ziel, new Map()); return; } const karten = new Map(); /* ---- STUFE 7: JEDES BRETT BEKOMMT SEINE FORM ------------------ "Regeln & Hilfe" ist ein NACHSCHLAGEWERK, kein Feed: Man schlaegt es im Streitfall auf und will finden, nicht scrollen. Also nach Art gruppiert, mit Ueberschriften -- und ganz oben Sprungmarken. "Mitmachen" ist ein WEG: erst die drei Schritte, dann die Gebiete, dann die Fragen. Eine Liste beantwortet "was gibt es"; die Frage lautet aber "was tue ich als Erstes". */ const NACH_ART = { regeln: ['regel', 'folge', 'hilfe', 'frage'], mitmachen: ['gesucht', 'lust', 'frage'], }; if (NACH_ART[bereich] && !filter) { const reihe = NACH_ART[bereich]; const faecher = new Map(reihe.map((a) => [a, []])); for (const e of sichtbar) { if (!faecher.has(e.art)) faecher.set(e.art, []); faecher.get(e.art).push(e); } /* DIE SPRUNGMARKEN ZUERST. Sie sind der Unterschied zwischen einem Dokument und einer langen Seite. */ const marken = el('nav', 'brettmarken'); marken.setAttribute('aria-label', 'Zu einem Abschnitt springen'); for (const a of faecher.keys()) { if (!faecher.get(a).length) continue; const m = el('a', 'brettmarken__weg', einstellung?.arten?.[a] || a); m.href = `#abschnitt-${a}`; marken.append(m); } if (marken.childNodes.length > 1) ziel.append(marken); for (const a of faecher.keys()) { const fach = faecher.get(a); if (!fach.length) continue; const kopf2 = el('h2', 'brettabschnitt'); kopf2.id = `abschnitt-${a}`; kopf2.textContent = einstellung?.arten?.[a] || a; kopf2.append(el('span', 'brettabschnitt__zahl', String(fach.length))); ziel.append(kopf2); for (const e of fach) { const k = karte(e); karten.set(e.id, k); ziel.append(k); } } sprungZeigen(ziel, karten); return; } if (bereich === 'ansteht') { /* ALS ZEITSTRAHL, NICHT ALS LISTE (17.09.2026). Eine Liste beantwortet "was gibt es". Hier lautet die Frage aber "was kommt WANN" -- und die beantwortet erst eine Reihenfolge mit Abschnitten. Vorbei Gewesenes steht unten, nicht dazwischen: Ein abgelaufener Termin mitten zwischen kommenden liest sich wie ein Fehler. */ const REIHE = ['heute', 'woche', 'spaeter', 'ohne', 'vorbei']; const faecher = new Map(REIHE.map((r) => [r, []])); for (const e of sichtbar) faecher.get(abschnittVon(e)).push(e); for (const r of REIHE) { const fach = faecher.get(r); if (!fach.length) continue; /* Innerhalb eines Abschnitts nach Zeitpunkt, das Vergangene umgekehrt -- das zuletzt Gewesene zuerst. */ fach.sort((a, b) => { const za = zeitpunktVon(a)?.ms ?? 0; const zb = zeitpunktVon(b)?.ms ?? 0; return r === 'vorbei' ? zb - za : za - zb; }); const kopf = el('h2', 'zeitstrahl__schild', ABSCHNITT_NAME[r]); kopf.append(el('span', 'zeitstrahl__zahl', String(fach.length))); ziel.append(kopf); for (const e of fach) { const k = karte(e); karten.set(e.id, k); ziel.append(k); } } } else { for (const e of sichtbar) { const k = karte(e); karten.set(e.id, k); ziel.append(k); } } sprungZeigen(ziel, karten); /* Bewertung und Rueckmeldungen. In EINER Abfrage fuer die ganze Liste -- zwanzig Eintraege einzeln zu fragen waeren zwanzig Anfragen, und die Seite ruckelte sichtbar beim Aufbau. Nur in den Bereichen, in denen eine Beurteilung ueberhaupt Sinn ergibt: LIVE, Community und Technik. Bei Content sind es eigene Ideen des Creators -- die bewertet niemand mit einer Ampel. */ /* "agentur" ist seit dem 06.09.2026 dabei, und aus demselben Grund wie die anderen vier: Ein Anliegen an die Agentur hat einen STAND -- eingereicht, in Klärung, beantwortet. Genau dafür ist die Ampel da, samt der Nachrichten daran. Ohne sie wäre der "offizielle Weg" wieder nur eine Liste, in der etwas steht und niemand weiß, wie es ausgegangen ist. */ if (["live", "community", "technik", "schutz", "agentur"].includes(bereich) && window.ampelAnhaengen && karten.size) { const stand = await window.ampelnHolen([...karten.keys()]); for (const [id, k] of karten) { window.ampelAnhaengen(k, id, { ampel: stand.ampeln?.[id] || null, anzahl: stand.anzahlNachrichten?.[id] || 0, }); } } } catch { /* umgeleitet */ } finally { /* IM finally, nicht dahinter. laden() steigt oben frueh aus, wenn die Liste leer ist -- und dann wurden diese beiden Bloecke nie gebaut. Also ausgerechnet auf der leeren Seite, fuer die sie gedacht sind. Die Browserpruefung hat es sofort gemeldet ("eine Unterweisung: 0"). */ await vorlagenZeigen(); await schulungZeigen(); } } /* Unterweisungen -- nur im Bereich Schutz & Regeln. Dort gehoeren Richtlinien hin, und dort sucht sie auch jemand. */ async function schulungZeigen() { const behaelter = $('schulung'); if (!behaelter || bereich !== "schutz" || !window.schulungBlock) return; await window.schulungBlock(behaelter); } /* Fertige Punkte zum Uebernehmen -- derzeit nur fuer die LIVE-Analyse. Sie stehen ueber der Liste: Wer noch nichts hat, soll nicht erst an einem leeren Kasten vorbeiscrollen. Welcher Abschnitt gezeigt wird, richtet sich nach dem gewaehlten Filter. Ohne Filter die Vorbereitung -- das ist der Teil, ohne den die anderen beiden gar nicht erst sinnvoll sind. */ /* Die FESTE Liste -- nicht mehr Vorschlaege zum Uebernehmen. Der Unterschied ist grundlegend: Eine Liste, die man sich erst holen muss, ist keine Checkliste. Und zwei Creator haetten unterschiedliche Listen gehabt, je nachdem wer sich was geholt hat -- was einen Vergleich unmoeglich macht, und um Vergleich geht es bei einer Betreuung. Jetzt stehen dieselben Punkte fuer alle fest da, gruppiert. Was sich unterscheidet, ist der Stand je Creator. */ async function vorlagenZeigen() { const behaelter = $('vorlagen'); if (!behaelter || !window.checkliste) return; if (!['live', 'community', 'technik'].includes(bereich)) return; await window.checkliste(behaelter, bereich); } /* Fuer wen wird uebernommen? Ein Creator immer fuer sich (der Server setzt das ohnehin), ein Betreuer fuer den gewaehlten Creator. */ function creatorWahl() { const w = document.getElementById('f-creator'); return w && w.value ? Number(w.value) : null; } /* ---------- Neuer Eintrag ---------------------------------------------- */ /* Das Formular sieht bei einem Agentur-Event anders aus als bei allem anderen -- nicht "zusaetzlich", sondern anders: Aus "Überschrift" wird der Titel des Events, aus "Text" die Beschreibung, aus dem Datum ein Zeitraum, und Dringlichkeit hat hier nichts zu suchen (ein Event ist nicht dringend, es hat einen Termin). Umgeschaltet wird bei JEDEM Artwechsel und beim Oeffnen. Nur beim Oeffnen zu schalten waere der bequeme Fehler: Man oeffnet mit "Schulung", waehlt "Agentur-Events" und fuellt ein Formular aus, das noch nach der alten Art fragt. */ function formularAnpassen() { const event = istEventArt($('f-art').value); /* DIE UHRZEIT NUR DORT, WO TERMINE STEHEN (17.09.2026). Ein Anschlag oder ein Wunsch hat keine Uhrzeit -- ein Feld, das da ist und nie gebraucht wird, macht das Formular laenger und die Entscheidung schwerer. */ const uhrFeld = $('feld-uhrzeit'); if (uhrFeld) { uhrFeld.hidden = bereich !== 'ansteht'; if (uhrFeld.hidden && $('f-uhrzeit')) $('f-uhrzeit').value = ''; } $('feld-ende').hidden = !event; $('feld-aufgaben').hidden = !event; $('feld-regeln').hidden = !event; if (einstellung?.dringlichkeit) $('feld-dringlich').hidden = event; if (einstellung?.vertraulich) $('feld-vertraulich').hidden = event; $('schild-datum').textContent = event ? 'Von' : 'Datum'; $('schild-titel').textContent = event ? 'Titel des Events' : 'Überschrift'; /* textContent wuerde das mit erschlagen -- deshalb nur der erste Textknoten, das kleine "(optional)" bleibt stehen. */ const textSchild = $('schild-text').firstChild; if (textSchild) textSchild.nodeValue = event ? 'Beschreibung ' : 'Text '; /* Leeren, was gerade unsichtbar wird. Sonst reicht ein Wechsel von "Agentur-Events" auf "Schulung", und das Ende des Zeitraums faehrt unsichtbar mit -- der Server nimmt es an, weil es zum Bereich gehoert, und an der Schulung stuende ein Enddatum, das niemand eingetragen hat und niemand sieht. */ if (!event) { $('f-ende').value = ''; $('f-aufgaben').value = ''; $('f-regeln').value = ''; } } const umschalten = (auf) => { /* Auch hier die Regel, nicht nur einmal beim Aufbau: Diese Funktion ist die einzige Stelle, die das Formular je wieder sichtbar macht. Sperrt man nur beim Laden, holt der naechste Aufruf den Knopf zurueck -- und niemand faellt es auf, weil es der Alltagsweg nicht ist. */ if (!darfEintragen()) { $('neu').hidden = true; $('neu-oeffnen').hidden = true; return; } $('neu').hidden = !auf; $('neu-oeffnen').hidden = auf; if (auf) $('f-titel').focus(); }; $('neu-oeffnen').addEventListener('click', () => { $('f-datum').value = window.heuteLokal(); formularAnpassen(); umschalten(true); }); $('neu-abbrechen').addEventListener('click', () => { $('neu').reset(); $('neu-fehler').textContent = ''; formularAnpassen(); umschalten(false); }); /* "change" und nicht "input": Die Auswahl wird ueber wahl.js zu einer Knopfliste umgebaut, und die meldet ihre Wahl als change-Ereignis am urspruenglichen