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]>
3241 lines
146 KiB
JavaScript
3241 lines
146 KiB
JavaScript
/* ===================================================================
|
||
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;
|
||
/* FAELLT DIE LAGE AUS, HEISST DAS NICHT "DARF ALLES"
|
||
(berichtigt 19.09.2026). `lageHolen()` schluckt jeden Fehler;
|
||
war `lage` null, fiel die Pruefung hier durch auf `true` --
|
||
fuer einen Zuschauer also von "darf auf keinem Brett" auf
|
||
"darf ueberall". Der Knopf erschien, das Formular ging auf,
|
||
man tippte -- und der Server lehnte mit 403 ab. Ein Formular,
|
||
das der Server ablehnt, laesst den Menschen davor sich selbst
|
||
fuer den Fehler halten. */
|
||
if (treffBrett && !lage) 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: 'Rudel',
|
||
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);
|
||
/* ---- DIE SIEBEN COMMUNITY-BRETTER (18.09.2026) -----------------
|
||
|
||
Sie hatten hier keinen Eintrag. Ein Mitglied sah deshalb nur:
|
||
|
||
— TERMINE
|
||
Was ansteht
|
||
Hier schreibt das Team.
|
||
|
||
Die Oberzeile ist eine Kategorie, kein Satz; darunter stand
|
||
ausschliesslich, OB man schreiben darf. Was das Brett IST und was
|
||
man damit tut, stand nirgends -- auf drei Brettern sogar
|
||
wortgleich dasselbe („Ab „Dabei" kannst du hier schreiben").
|
||
|
||
Jeder Satz sagt deshalb zwei Dinge: wofuer das Brett da ist und
|
||
was man hier konkret tut. Der Hinweis auf das Schreibrecht haengt
|
||
sich dahinter -- in dieser Reihenfolge, weil „was ist das hier"
|
||
vor „darf ich mitmachen" kommt. */
|
||
const UNTERZEILE = {
|
||
treff: 'Der offene Raum: Hallo sagen, fragen, loben – und sagen, wenn etwas nicht stimmt.',
|
||
anschlag: 'Was gerade gilt, kurz und an einer Stelle. Mehr als ein paar Sachen hängen hier nie.',
|
||
ansteht: 'Die nächsten Streams, Events und Turniere – mit Countdown, damit du nichts verpasst.',
|
||
wunsch: 'Was ihr euch wünscht. Drück „Will ich auch“ – danach wird sortiert, was am meisten gewollt ist.',
|
||
highlight: 'Eure Clips, Bilder und Fanart. Der Knopf führt zu TikTok – dort zählt das Liken wirklich.',
|
||
regeln: 'Wie es hier läuft – zum Nachschlagen, nicht zum Auswendiglernen. Oben steht, wo was steht.',
|
||
mitmachen: 'Wie du dazugehören kannst: was gerade gesucht wird und wie du dich meldest.',
|
||
|
||
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;
|
||
/* DIE ACCOUNTS, so wie der Server sie schickt (20.09.2026).
|
||
Auf Modulebene, weil zwei Stellen sie brauchen: die Galerie (drei
|
||
Spalten in DIESER Reihenfolge) und das Formular (Auswahlliste).
|
||
Beide aus derselben Auskunft -- eine zweite Liste im Browser waere
|
||
die, die beim vierten Account vergessen wird. */
|
||
let kanaeleDa = [];
|
||
/* 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;
|
||
/* Darf ich den Kalender oeffnen? Kommt vom Server (kalender_offen).
|
||
Vorgabe `true`: Antwortet ein aelterer Server das Feld noch nicht,
|
||
bleibt es wie bisher -- ein fehlender Link waere fuer das Team ein
|
||
Rueckschritt, ein ueberzaehliger fuer die Community nur der alte
|
||
Zustand. */
|
||
let kalenderOffen = true;
|
||
let lage = null;
|
||
/* WELCHE BRETTER NACH ART GEGLIEDERT WERDEN.
|
||
Stand frueher mitten in der Zeichenfunktion -- dort konnte
|
||
`filterBauen` sie nicht sehen, und deshalb baute die Filterreihe
|
||
auf genau diesen Brettern eine ZWEITE Leiste mit denselben
|
||
Woertern wie die Sprungmarken darunter. */
|
||
const NACH_ART = {
|
||
regeln: ['regel', 'folge', 'hilfe', 'frage'],
|
||
mitmachen: ['gesucht', 'lust', 'frage'],
|
||
};
|
||
|
||
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 = '';
|
||
|
||
/* =====================================================================
|
||
DIE HIGHLIGHTS NACH ZEIT (20.09.2026)
|
||
|
||
Filipe: "die videos will ich auch kategorisiert haben nach woche,
|
||
monat, jahr und ganz oben aktuell also heute."
|
||
|
||
WARUM DAS NOETIG WURDE: Highlights sammeln sich. Nach einem halben
|
||
Jahr steht das Beste vom Mai zwischen dem von gestern, und die
|
||
Galerie beantwortet die Frage, die man wirklich hat ("was ist
|
||
GERADE gut gelaufen"), gar nicht mehr.
|
||
|
||
DIE ZEITRAEUME SIND KALENDERZEITRAEUME, KEINE RUECKBLICKE. "Diese
|
||
Woche" heisst seit Montag, nicht "die letzten sieben Tage". Wer am
|
||
Dienstag "diese Woche" fragt, meint Montag und Dienstag -- nicht
|
||
den Mittwoch davor. Ein rollendes Fenster waere leichter zu rechnen
|
||
und beantwortet eine andere Frage.
|
||
|
||
UND SIE STAPELN SICH NICHT. Man waehlt EINEN -- "heute" ist auch
|
||
in "diese Woche" enthalten, und zwei sich enthaltende Filter
|
||
gleichzeitig anzubieten erzeugt nur die Frage, was ihre
|
||
Kombination bedeutet. */
|
||
let zeitFilter = '';
|
||
|
||
const ZEIT_WORT = {
|
||
heute: 'Aktuell', woche: 'Diese Woche',
|
||
monat: 'Dieser Monat', jahr: 'Dieses Jahr',
|
||
};
|
||
|
||
/** Ab wann zaehlt dieser Zeitraum? Gibt einen Zeitstempel in
|
||
* Millisekunden -- oder 0 fuer "alles". */
|
||
function zeitAb(was) {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
if (was === 'heute') return d.getTime();
|
||
if (was === 'woche') {
|
||
/* Montag als Wochenanfang. `getDay()` liefert Sonntag = 0 --
|
||
ohne die Korrektur faenge die Woche am Sonntag an, und in
|
||
Deutschland steht der am Ende. */
|
||
const wt = (d.getDay() + 6) % 7;
|
||
d.setDate(d.getDate() - wt);
|
||
return d.getTime();
|
||
}
|
||
if (was === 'monat') { d.setDate(1); return d.getTime(); }
|
||
if (was === 'jahr') { d.setMonth(0, 1); return d.getTime(); }
|
||
return 0;
|
||
}
|
||
|
||
/** Wann ist dieser Eintrag entstanden? `erstellt` ist die Wahrheit --
|
||
* `datum` ist ein Feld, das jemand von Hand setzen kann, und ein
|
||
* Highlight vom Mai mit dem Datum von heute gehoert nicht unter
|
||
* "Aktuell". */
|
||
const entstandAm = (e) => {
|
||
const t = Date.parse(e.erstellt || e.datum || '');
|
||
return Number.isNaN(t) ? 0 : t;
|
||
};
|
||
/* 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) };
|
||
}
|
||
|
||
/* Der Abruf steht seit dem 22.09.2026 in assets/js/holen.js.
|
||
Er stand ACHTMAL da, Zeichen fuer Zeichen gleich -- und als
|
||
die Regel darin sich als falsch herausstellte (jeder 404
|
||
warf auf die Startseite, auch bei einer blossen Handlung),
|
||
haette man sie achtmal aendern muessen. */
|
||
const hole = (pfad, optionen) => window.holen(pfad, optionen);
|
||
|
||
/** Ein Bild auswaehlen und an den Beitrag haengen.
|
||
*
|
||
* DAS DATEIFELD WIRD JEDES MAL NEU GEBAUT und danach weggeworfen.
|
||
* Ein dauerhaftes im HTML waere ein Feld fuer vierzig Karten --
|
||
* und beim zweiten Mal dieselbe Datei, weil der Browser den Wert
|
||
* behaelt, wenn man denselben Namen erneut waehlt.
|
||
*
|
||
* DIE VORSCHAU GIBT ES HIER NICHT. Beim Support ist sie wichtig
|
||
* (man schreibt einen Text dazu und will sehen, was man mitgibt);
|
||
* hier geht das Bild sofort raus und steht eine Sekunde spaeter
|
||
* als Streifen an der Karte. Eine Vorschau dazwischen waere ein
|
||
* Schritt, den niemand braucht. */
|
||
function bildWaehlen(e) {
|
||
const feld = document.createElement('input');
|
||
feld.type = 'file';
|
||
feld.accept = 'image/png,image/jpeg,image/webp,image/gif';
|
||
feld.addEventListener('change', async () => {
|
||
const datei = feld.files?.[0];
|
||
if (!datei) return;
|
||
/* Die Grenze steht hier nur als Freundlichkeit -- abgelehnt
|
||
wird am Server. Sie erspart es, zwoelf Megabyte erst
|
||
hochzuladen, um danach eine Absage zu lesen. */
|
||
if (datei.size > 12 * 1024 * 1024) {
|
||
melde('Das Bild ist größer als 12 MB – bitte ein kleineres.');
|
||
return;
|
||
}
|
||
melde('Bild wird hochgeladen …');
|
||
try {
|
||
const a = await hole(
|
||
`/workspace/api/bereich/${bereich}/${e.id}/bild`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'x-name': encodeURIComponent(datei.name || 'Bild') },
|
||
body: datei,
|
||
});
|
||
if (!a.ok) {
|
||
melde(sagWas((await a.json().catch(() => ({}))).fehler,
|
||
'Das Bild ließ sich nicht anhängen.'));
|
||
return;
|
||
}
|
||
melde('Bild ist dran.');
|
||
await laden();
|
||
} catch { /* umgeleitet */ }
|
||
});
|
||
feld.click();
|
||
}
|
||
|
||
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 --------------------------------------------------- */
|
||
|
||
/** Sagt unter dem Datumsfeld, ob dieser Tag im Kalender landet.
|
||
*
|
||
* DIESELBE REGEL WIE AUF DEM SERVER -- aber bewusst nur die eine
|
||
* Haelfte, die der Browser ueberhaupt beantworten kann: Liegt der
|
||
* Tag in der Zukunft? Ob am Ende `geplant`, `uhrzeit` oder
|
||
* `event_ende` gesetzt sind, entscheidet der Server, und die Karte
|
||
* zeigt danach sein Ergebnis. Der Hinweis hier ist eine Vorschau,
|
||
* keine zweite Wahrheit -- deshalb steht er im Konjunktiv-Ton
|
||
* („steht damit im Kalender") und nicht als Zusage. */
|
||
function kalenderhinweisSetzen() {
|
||
const feld = $('f-datum');
|
||
const ziel = $('hinweis-datum');
|
||
if (!feld || !ziel) return;
|
||
const wert = feld.value;
|
||
/* Denselben Rueckfall wie an den zwei Stellen darueber: `heuteLokal`
|
||
kommt aus kopf.js, und wenn das noch nicht geladen ist, waere ein
|
||
direkter Aufruf ein Absturz mitten im Formularaufbau. */
|
||
const heute = window.heuteLokal ? window.heuteLokal()
|
||
: new Date().toISOString().slice(0, 10);
|
||
if (!wert) { ziel.textContent = ''; return; }
|
||
if (wert > heute) {
|
||
ziel.textContent = 'Liegt in der Zukunft – steht damit im Kalender.';
|
||
ziel.dataset.imkalender = 'ja';
|
||
} else if ($('f-uhrzeit') && !$('feld-uhrzeit').hidden && $('f-uhrzeit').value) {
|
||
ziel.textContent = 'Mit Uhrzeit – steht damit im Kalender.';
|
||
ziel.dataset.imkalender = 'ja';
|
||
} else {
|
||
ziel.textContent = wert === heute
|
||
? 'Heute – das ist der Tag der Notiz, kein Termin.'
|
||
: 'Liegt in der Vergangenheit – kein Termin.';
|
||
delete ziel.dataset.imkalender;
|
||
}
|
||
}
|
||
|
||
/** Setzt den Ton der Kachel, aus der dieses Brett kommt.
|
||
*
|
||
* Sucht in beiden Kachellisten, die der Browser ohnehin hat. Findet
|
||
* sich nichts, bleibt es beim Standardton -- eine Seite ohne Farbe
|
||
* ist unauffaellig, eine Seite mit der FALSCHEN Farbe fuehrt in die
|
||
* Irre. */
|
||
function tonSetzen(schluessel) {
|
||
const ziel = 'bereich.html?b=' + schluessel;
|
||
let ton = null;
|
||
|
||
/* 1. Die Kacheln vom Server (Treff, Modis, rechte Hand). */
|
||
for (const k of (ich?.bereiche || [])) {
|
||
if (k?.ziel === ziel && k.ton) { ton = k.ton; break; }
|
||
}
|
||
/* 2. Die Kacheln der Agentur, die im Browser stehen. */
|
||
if (!ton) {
|
||
for (const g of (window.Bereiche?.GRUPPEN || [])) {
|
||
for (const k of (g.bereiche || [])) {
|
||
if (k?.ziel === ziel && k.ton) { ton = k.ton; break; }
|
||
}
|
||
if (ton) break;
|
||
}
|
||
}
|
||
/* 3. Und die Kacheln, die einer Rolle zusaetzlich angehaengt
|
||
werden (bereiche_zusatz) -- dort steht der Treff fuer
|
||
DogFather. */
|
||
if (!ton) {
|
||
for (const k of (ich?.bereiche_zusatz || [])) {
|
||
if (k?.ziel === ziel && k.ton) { ton = k.ton; break; }
|
||
}
|
||
}
|
||
|
||
if (ton) document.body.dataset.ton = String(ton);
|
||
}
|
||
|
||
function karte(e) {
|
||
const k = el('article', 'eintrag-karte');
|
||
k.dataset.dringlich = e.dringlichkeit;
|
||
k.dataset.status = e.status;
|
||
/* DIE ART GEHOERT AN DIE KARTE (22.09.2026).
|
||
|
||
Am Anschlagbrett traegt sie die Farbe: Ansage, Neue Regel,
|
||
Hinweis, Danke an euch. Sie stand bisher nur im Text der Karte
|
||
-- eine Auskunft, die man lesen muss, statt einer, die man
|
||
sieht.
|
||
|
||
Das Merkmal steht an JEDER Karte und nicht nur am
|
||
Anschlagbrett: Es ist eine Tatsache ueber den Eintrag, keine
|
||
Anzeige. Wer sie auf einem anderen Brett braucht, findet sie
|
||
vor -- und muss nicht erst diese Zeile aendern. */
|
||
if (e.art) k.dataset.art = e.art;
|
||
|
||
/* DER GANZE KASTEN IST DER WEG ZUM VIDEO (19.09.2026).
|
||
|
||
Filipe: „die community soll sofort auf das video geschickt werden
|
||
sobald sie auf die kachel drücken, also die ganze kacheln will
|
||
ich egal wo man drauf drückt, foto text.... egal wo sobald man
|
||
drauf drückt weiter geleitet".
|
||
|
||
Er hat recht, und der Grund ist handfest: Auf dem Handy ist der
|
||
Knopf „Auf TikTok ansehen" ein Ziel von rund 150 × 44 px in
|
||
einer Karte von 350 × 600. Wer das Bild antippt — die größte und
|
||
einladendste Fläche — erwartet, dass etwas passiert, und es
|
||
passiert nichts. Das fühlt sich an wie eine kaputte Seite.
|
||
|
||
NUR DORT, WO ES AUCH EIN VIDEO GIBT. Auf den anderen Brettern
|
||
bleibt die Karte eine Karte: Eine Fläche, die manchmal klickt und
|
||
manchmal nicht, ist schlimmer als eine, die es nie tut.
|
||
|
||
UND NUR, SOLANGE DAS VIDEO NOCH DA IST. Ist es bei TikTok
|
||
gelöscht (`quelle_fehlt_seit`, Stufe 5), führt der Weg auf eine
|
||
Fehlerseite — und wer darauf tippt, hält UNS für kaputt. Dann
|
||
bleibt die Karte still, und der Hinweis „Bei TikTok nicht mehr
|
||
da" steht weiterhin da, wo er hingehört. */
|
||
const videoWeg = (e.quelle_url && !e.quelle_fehlt_seit) ? String(e.quelle_url) : '';
|
||
|
||
/* ---- UND DER KLICK HAENGT GLEICH HIER DRAN ---------------------
|
||
|
||
ER STAND ZUERST GANZ UNTEN, VOR `return k` -- und war fuer die
|
||
Community wirkungslos. Diese Funktion hat DREI Rueckgabepunkte,
|
||
und der erste davon lautet:
|
||
|
||
if (!darfEintragen()) { k.append(fuss); return k; }
|
||
|
||
Wer nicht schreiben darf, bekommt die Karte also frueher zurueck
|
||
-- und genau das sind die Mitglieder, fuer die Filipe es
|
||
ueberhaupt wollte. Beim Team funktionierte es, bei der Community
|
||
nicht: der unangenehmste Fehler ueberhaupt, weil er beim
|
||
Selbsttest unsichtbar ist.
|
||
|
||
Deshalb steht es jetzt dort, wo `videoWeg` entsteht: „Diese
|
||
Karte ist ein Weg" ist eine Eigenschaft der Daten, kein
|
||
Nachtrag am Ende. Damit gilt sie fuer jeden Rueckgabeweg, auch
|
||
fuer den naechsten, den jemand einbaut.
|
||
|
||
WARUM KEIN <a> UM ALLES: In der Karte stecken schon Links (der
|
||
TikTok-Knopf, „Daraus wurde", die Bilder) und Knoepfe (loeschen,
|
||
melden, zur Aufgabe). Ein Link in einem Link ist ungueltiges
|
||
HTML -- der Browser beendet den aeusseren still, und danach ist
|
||
ein Teil der Karte tot, ohne Fehlermeldung.
|
||
|
||
WARUM DIE KARTE NICHT FOKUSSIERBAR WIRD: Der Knopf „Auf TikTok
|
||
ansehen" ist bereits der Tastaturweg. Ein zweiter Halt mit
|
||
demselben Ziel saehe fuer jemanden, der sich durchtabbt, nach
|
||
einem Fehler aus. Die Flaeche ist eine Bequemlichkeit fuer den
|
||
Daumen, nicht der eigentliche Weg. */
|
||
if (videoWeg) {
|
||
k.dataset.klickbar = 'ja';
|
||
const eigeneBedeutung = (ziel) =>
|
||
!!ziel.closest('a, button, input, textarea, select, label, [role="button"]');
|
||
k.addEventListener('click', (ev) => {
|
||
if (ev.button !== 0) return;
|
||
if (eigeneBedeutung(ev.target)) return;
|
||
/* Wer Text markiert, will lesen und nicht weg -- der haeufigste
|
||
Aerger an klickbaren Karten ueberhaupt. */
|
||
const auswahl = window.getSelection?.();
|
||
if (auswahl && !auswahl.isCollapsed && String(auswahl).trim()) return;
|
||
window.open(videoWeg, '_blank', 'noopener,noreferrer');
|
||
});
|
||
/* Mittlere Maustaste: dieselbe Erwartung wie bei einem Link. */
|
||
k.addEventListener('auxclick', (ev) => {
|
||
if (ev.button !== 1) return;
|
||
if (eigeneBedeutung(ev.target)) return;
|
||
ev.preventDefault();
|
||
window.open(videoWeg, '_blank', 'noopener,noreferrer');
|
||
});
|
||
}
|
||
|
||
const kopf = el('div', 'eintrag-kopf');
|
||
const marke = el('span', 'marke-art', einstellung.arten[e.art] || e.art);
|
||
/* Die Art als Merkmal am Element -- daran haengt der Ton (siehe
|
||
tools/art-farben.mjs). Kein Klassenname je Art: Der waere eine
|
||
zweite Liste, die mit der ersten auseinanderlaeuft. */
|
||
if (e.art) marke.dataset.art = e.art;
|
||
kopf.append(marke);
|
||
/* 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 (!await frageNach({
|
||
titel: `„${e.titel}“ als Nachricht schicken?`,
|
||
was: 'Die Person bekommt Art, Titel und Text im Chat – einmalig.',
|
||
bleibt: 'Der Eintrag bleibt hier stehen. Zurückholen lässt sich '
|
||
+ 'die Nachricht nicht, sobald sie unterwegs ist.',
|
||
ja: 'Schicken',
|
||
})) 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(sagWas((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');
|
||
/* GIBT ES EIN VIDEO, FÜHRT AUCH DAS BILD DORTHIN (19.09.2026).
|
||
|
||
Vorher öffnete das Bild sich selbst in voller Größe. Bei
|
||
einem Clip ist das Bild aber das Standbild des Videos — wer
|
||
darauf tippt, will das Video sehen und nicht das Standbild
|
||
größer. Genau das meinte Filipe mit „foto".
|
||
|
||
Ohne Video bleibt es beim Vollbild: Auf den anderen Brettern
|
||
ist ein Bild wirklich nur ein Bild. */
|
||
a.href = videoWeg || `/workspace/api/dateien/${b.id}/bild`;
|
||
a.target = '_blank';
|
||
a.rel = videoWeg ? 'noopener noreferrer' : '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.
|
||
|
||
UND DAS ZIEL GEHOERT DAZU (19.09.2026): Seit das Bild zum
|
||
Video führt, wäre „Bild zu ..." eine Falschauskunft für jeden,
|
||
der die Seite vorlesen lässt -- er würde ein Bild erwarten
|
||
und in einer fremden App landen. */
|
||
bild.alt = e.titel ? `Bild zu „${e.titel}“` : (b.name || 'Bild');
|
||
if (videoWeg) {
|
||
a.setAttribute('aria-label',
|
||
e.titel ? `Video ansehen: ${e.titel}` : 'Video ansehen');
|
||
a.title = 'Öffnet das Video in einem neuen Fenster';
|
||
}
|
||
/* 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);
|
||
}
|
||
/* IST DAS VIDEO BEI TIKTOK NOCH DA? (Stufe 5, 18.09.2026)
|
||
|
||
Wird es dort geloescht oder auf privat gestellt, bleibt der
|
||
Eintrag hier stehen. Der Knopf fuehrt dann auf eine
|
||
Fehlerseite -- und wer darauf tippt, haelt UNS fuer kaputt.
|
||
|
||
Der Knopf verschwindet trotzdem nicht: Ein Eintrag, bei dem
|
||
wortlos etwas fehlt, wirft die Frage auf, ob man sich
|
||
taeuscht. Es steht jetzt einfach da. Das Cover bleibt
|
||
ohnehin -- es liegt bei uns, nicht bei TikTok. */
|
||
if (e.quelle_fehlt_seit) {
|
||
const f = el('span', 'videozeile__weg videozeile__fort',
|
||
'Bei TikTok nicht mehr da');
|
||
f.title = 'Seit ' + (e.quelle_fehlt_seit || '').slice(0, 10)
|
||
+ ' antwortet TikTok zu diesem Video nicht mehr.';
|
||
z.append(f);
|
||
} else {
|
||
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'));
|
||
/* ==== NICHT DENSELBEN TEXT ZWEIMAL (24.09.2026) ==============
|
||
|
||
Hier stand immer "Brett: voller Titel der Quelle". Bei einem
|
||
Eintrag, der AUS einem anderen entstanden ist, traegt er
|
||
meistens denselben Titel -- und dann stand auf Filipes
|
||
Bildschirmfoto ein langer Satz als Ueberschrift und darunter
|
||
derselbe lange Satz noch einmal.
|
||
|
||
Die Zeile soll sagen, WOHER etwas kam. Wenn der Titel schon
|
||
darueber steht, ist der Brettname die ganze Auskunft; der
|
||
Rest ist Abschrift.
|
||
|
||
Verglichen werden die ersten vierzig Zeichen und nicht der
|
||
ganze Text: Beim Uebernehmen wird manchmal ein Wort
|
||
angehaengt oder ein Doppelpunkt gesetzt, und zwei Titel, die
|
||
sich nur darin unterscheiden, sind fuer den Leser derselbe. */
|
||
const brett = BRETT_WORT[e.aus.bereich] || e.aus.bereich;
|
||
const eigen = String(e.titel || '').trim();
|
||
const quelle = String(e.aus.titel || '').trim();
|
||
const anfang = (t) => t.slice(0, 40).toLowerCase();
|
||
const dasselbe = !!quelle && !!eigen
|
||
&& (quelle === eigen || anfang(quelle) === anfang(eigen));
|
||
/* Und auch ein ANDERER Titel wird nicht zum Absatz: Diese Zeile
|
||
ist ein Verweis, kein Zitat. Geschnitten wird an einer
|
||
Wortgrenze -- mitten im Wort abzubrechen liest sich wie ein
|
||
Fehler. */
|
||
const kurz = (t) => {
|
||
if (t.length <= 60) return t;
|
||
const schnitt = t.slice(0, 60);
|
||
const luecke = schnitt.lastIndexOf(' ');
|
||
return (luecke > 30 ? schnitt.slice(0, luecke) : schnitt) + ' …';
|
||
};
|
||
const a = el('a', 'kette__weg',
|
||
dasselbe ? brett : `${brett}: ${kurz(quelle)}`);
|
||
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) {
|
||
const t = el('p', 'eintrag-text');
|
||
t.append(mitFett(e.text));
|
||
k.append(t);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/* ---- STEHT IM KALENDER (17.09.2026) ------------------------------
|
||
|
||
Der Kalender zeigt seit heute, was auf den Brettern steht und
|
||
einen Termin hat. Die Verbindung war einseitig: vom Kalender
|
||
aufs Brett ja, zurueck nicht. Am Kartenfuss stand bloss ein
|
||
Datum -- und „24.09.2026" sieht aus wie „17.09.2026", obwohl das
|
||
eine ein Termin ist und das andere der Tag, an dem jemand
|
||
getippt hat.
|
||
|
||
OB ER DRINSTEHT, SAGT DER SERVER (`e.termin`). Hier wird es
|
||
nicht nachgerechnet: Eine zweite Fassung der Regel behauptet
|
||
irgendwann etwas, das der Kalender nicht zeigt -- und eine
|
||
Anzeige, die etwas Falsches sagt, ist schlimmer als keine.
|
||
|
||
ES IST EIN LINK, keine Marke. Wer liest „steht im Kalender",
|
||
will als Naechstes nachsehen, was noch an dem Tag ist. */
|
||
if (e.termin) {
|
||
/* LINK NUR, WENN DER KALENDER FUER MICH OFFEN IST (19.09.2026).
|
||
|
||
Fuer die Community ist er es nicht -- `kalender.html` steht in
|
||
der Rechtetafel auf ALLE, und ALLE ist ausdruecklich ohne
|
||
"gast". Bis heute stand hier trotzdem ein Link, und ein Klick
|
||
landete wortlos auf der Startseite: sichtbar, beschriftet,
|
||
vielversprechend -- und eine Sackgasse.
|
||
|
||
GEFRAGT WIRD DER SERVER (`kalender_offen`), nicht die Rolle.
|
||
Eine Rollenabfrage hier waere eine vierte Fassung derselben
|
||
Regel; die vierte ist die, die beim naechsten Umbau etwas
|
||
durchlaesst.
|
||
|
||
DIE AUSKUNFT BLEIBT in jedem Fall stehen -- "Im Kalender am
|
||
24.09." ist auch ohne Weg dorthin richtig und nuetzlich. Es
|
||
faellt nur der Klick weg, der nirgendwohin fuehrt. */
|
||
const zeile = el(kalenderOffen ? 'a' : 'span', 'im-kalender');
|
||
zeile.dataset.grund = e.termin.grund;
|
||
if (kalenderOffen) zeile.href = 'kalender.html';
|
||
zeile.append(el('span', 'im-kalender__zeichen', '\u{1F5D3}'));
|
||
zeile.append(el('span', 'im-kalender__text',
|
||
(e.termin.grund === 'zugesagt' ? 'Zugesagt für ' : 'Im Kalender am ')
|
||
+ datum(e.termin.tag)
|
||
+ (e.uhrzeit ? ' um ' + e.uhrzeit : '')));
|
||
k.append(zeile);
|
||
}
|
||
|
||
const fuss = el('div', 'eintrag-fuss');
|
||
/* Beim Event steht der Zeitraum schon oben -- das Datum hier waere
|
||
dieselbe Zahl ein zweites Mal. Und steht der Zettel im Kalender,
|
||
steht der Tag ebenfalls schon da; dann waere das Erfassungsdatum
|
||
hier eine ZWEITE Zahl neben einer wichtigeren -- und die
|
||
verwirrt mehr, als sie sagt. */
|
||
if (!stand && !e.termin) 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));
|
||
|
||
/* ---- 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));
|
||
}
|
||
|
||
/* „erledigt" ANS ENDE, nicht zwischen Name und Rolle (18.09.2026).
|
||
|
||
Vorher stand dort „von Filipe · erledigt · DogFather" -- der
|
||
Rollenname gehört aber zum Namen davor, und „erledigt" ist eine
|
||
Aussage über den Eintrag, nicht über die Person. Dazwischen
|
||
gelesen wirkt es, als sei jemand erledigt. */
|
||
if (e.status === 'erledigt') fuss.append(el('span', null, '· erledigt'));
|
||
|
||
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. */
|
||
/* NICHT AN BEITRAEGEN DES TEAMS (18.09.2026).
|
||
|
||
Unter jeder Regel stand „melden" -- auch unter „Achtzehn plus
|
||
bleibt draussen". Man meldet keine Regel: Sie ist der Text des
|
||
Teams selbst, und ein Knopf darunter legt nahe, das Team
|
||
moderiere sich gegenseitig. Auf „Regeln & Hilfe" standen so
|
||
siebzehn Melden-Knoepfe, von denen keiner einen Sinn hatte.
|
||
|
||
Wer mit dem Team reden will, findet den Weg unter „Hilfe" --
|
||
dort steht, wie man DogFather und die Moderation erreicht.
|
||
|
||
IM ZWEIFEL BLEIBT DER KNOPF: Fehlt die Rollenangabe (alte
|
||
Eintraege), gilt der Beitrag nicht als Team-Beitrag. Lieber
|
||
ein Knopf zu viel als ein fehlender Meldeweg. */
|
||
const vomTeam = !!e.erstellt_rolle && e.erstellt_rolle !== 'gast';
|
||
if (!lage.team && e.erstellt_von !== ich.id && !vomTeam) {
|
||
zeile.append(tuKnopf('melden', async () => {
|
||
const a0 = await frageNach({
|
||
titel: 'Diesen Beitrag melden?',
|
||
was: 'Das Team sieht sich den Beitrag an. Du bleibst dabei ungenannt.',
|
||
bleibt: 'Der Beitrag bleibt vorerst stehen – gemeldet heißt noch '
|
||
+ 'nicht entfernt.',
|
||
ja: 'Melden',
|
||
grund: 'Was ist daran nicht in Ordnung?', grundPflicht: true,
|
||
platzhalter: 'Bitte in einem Satz – sonst können wir es nicht prüfen.',
|
||
});
|
||
if (!a0) return;
|
||
const grund = a0.grund;
|
||
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.'
|
||
: (sagWas((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(sagWas((await a.json().catch(() => ({}))).fehler,
|
||
'Es hängen schon ' + lage.anschlag_max + ' Ansagen.'));
|
||
return;
|
||
}
|
||
await laden();
|
||
}));
|
||
}
|
||
|
||
/* FREIGEBEN. Bei Terminen heisst es "im Rudel 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 Rudel 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 (!await frageNach({
|
||
titel: '„' + e.titel + '“ zu den Talenten übernehmen?',
|
||
was: 'Die Person erscheint danach in der Talente-Liste.',
|
||
bleibt: 'Der Beitrag hier bleibt stehen. Übernehmen heißt noch '
|
||
+ 'keine Zusage – es entscheidet weiterhin ein Mensch.',
|
||
ja: 'Ü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.'
|
||
: (sagWas(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 a1 = await frageNach({
|
||
titel: '„' + e.titel + '“ entfernen?',
|
||
was: 'Der Beitrag verschwindet vom Brett.',
|
||
bleibt: 'Der Grund wird festgehalten und der Person mitgeteilt – '
|
||
+ 'sie hat ein Recht darauf (DSA Art. 17).',
|
||
endgueltig: true, ja: 'Entfernen',
|
||
grund: 'Warum wird er entfernt?', grundPflicht: true,
|
||
/* WIE LANG, SAGT DER SERVER (24.09.2026). Der Dialog
|
||
liess bisher drei Zeichen durch, der Server verlangt
|
||
zehn -- wer "spam" tippte, kam durch die Nachfrage und
|
||
bekam danach eine Absage. `grund_min` steht seit dem
|
||
11.09. in /api/treff/lage und wurde nie benutzt; eine
|
||
10 hier waere die zweite Fassung derselben Zahl. */
|
||
grundMin: lage?.grund_min || 10,
|
||
platzhalter: 'z. B. Beleidigende Sprache gegenüber einem anderen Mitglied.',
|
||
});
|
||
if (!a1) return;
|
||
const grund = a1.grund;
|
||
/* 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(sagWas((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 -------------
|
||
"Das Rudel" 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(sagWas((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();
|
||
}));
|
||
}
|
||
|
||
/* ---- VIDEO DAZU (Stufe 7, 18.09.2026) --------------------------
|
||
|
||
„Ein Video aus einem Wunsch zeigt den Namen." Der Knopf steht
|
||
AN DEM WUNSCH -- aus demselben Grund wie die Kettenknoepfe
|
||
darueber: Wer oben im Formular einen Link einfuegt, denkt nicht
|
||
an den Wunsch von letzter Woche. Hier denkt er an nichts
|
||
anderes.
|
||
|
||
Er verschwindet, sobald aus dem Wunsch ein Highlight MIT Video
|
||
geworden ist. Steht dort schon ein Highlight ohne Video, bleibt
|
||
er -- der Server haengt das Video dann an den vorhandenen
|
||
Eintrag, statt ein zweites anzulegen. */
|
||
if (VIDEO_AUS.includes(bereich)
|
||
&& !(e.wurde || []).some((w) => w.bereich === 'highlight' && w.hat_video)) {
|
||
knoepfe.append(tuKnopf('Video dazu einfügen', () => {
|
||
/* Schon offen? Dann wieder zu. Ein Knopf, der beim zweiten
|
||
Druecken ein zweites Feld baut, ist ein Fehler, den man
|
||
erst sieht, wenn drei davon dastehen. */
|
||
const alt = k.querySelector('.videodazu');
|
||
if (alt) { alt.remove(); return; }
|
||
/* DIREKT UNTER DIE KNOPFREIHE, nicht ans Ende der Karte.
|
||
Zuerst stand hier `k.append` -- und das Bildschirmfoto zeigte
|
||
das Feld UNTER der Fusszeile („18.09.2026 - von Lena"),
|
||
getrennt von dem Knopf, den man gerade gedrueckt hat. Eine
|
||
Zahl haette das nie gemeldet: Das Feld war da, es reagierte,
|
||
alle Pruefungen gruen. */
|
||
knoepfe.insertAdjacentElement('afterend', videoDazuFeld(e));
|
||
}));
|
||
}
|
||
|
||
/* 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.'
|
||
: (sagWas(j.fehler, 'Ging nicht.')));
|
||
return;
|
||
}
|
||
melde('Steht jetzt im Aufgabenbrett – bei dir.');
|
||
await laden();
|
||
}));
|
||
}
|
||
|
||
/* ==== EIN BILD ANHAENGEN (24.09.2026) =========================
|
||
|
||
Filipe: "mach das man da bitte screenshots oder kurzschnitte
|
||
von den live reinposten kann. nach dem selben prinzip wie bei
|
||
den anderen nebendran."
|
||
|
||
DER KNOPF STEHT AN DER KARTE, nicht im Anlege-Formular. Das ist
|
||
der Unterschied zu "beim Schreiben ein Bild mitschicken": Ein
|
||
Bildschirmfoto faellt einem meist spaeter ein -- beim
|
||
Nachschauen, beim Erklaeren, wenn jemand fragt. Wer es nur beim
|
||
Anlegen mitgeben koennte, muesste den Beitrag loeschen und neu
|
||
schreiben.
|
||
|
||
ER ERSCHEINT NUR, WENN NOCH PLATZ IST. Drei Bilder je Beitrag
|
||
(der Server setzt die Grenze durch); beim vierten waere der
|
||
Knopf einer, der eine Absage bringt. */
|
||
if (darfEintragen() && (e.bilder || []).length < 3) {
|
||
knoepfe.append(tuKnopf('Bild anhängen', () => bildWaehlen(e)));
|
||
}
|
||
|
||
if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) {
|
||
knoepfe.append(tuKnopf('löschen', async () => {
|
||
/* EIN FREMDER BEITRAG AUF EINEM TREFF-BRETT BRAUCHT EINEN
|
||
GRUND (24.09.2026, DSA Art. 17).
|
||
|
||
Hier stand ein Löschen ohne Grund -- und der Server lehnt
|
||
das seit dem 11.09. ab. Gemessen an einem echten Fall:
|
||
DogFather wollte drei übernommene Vorschläge wegnehmen, die
|
||
eine Modi angelegt hatte. Der Knopf daneben ("entfernen")
|
||
konnte es, dieser antwortete mit "Löschen hat nicht
|
||
geklappt." -- ohne zu sagen, woran es lag. Zwei Knöpfe
|
||
nebeneinander, einer geht, einer nicht, und die Meldung
|
||
erklärt nichts.
|
||
|
||
`treffBrett` kommt vom Server (daten.treff), nicht aus einer
|
||
Liste hier -- eine abgeschriebene Liste der Bretter wäre die
|
||
zweite Wahrheit, die beim nächsten neuen Brett ausläuft. */
|
||
const brauchtGrund = treffBrett && e.erstellt_von !== ich.id;
|
||
const antwort = await frageNach({
|
||
titel: `„${e.titel}“ löschen?`,
|
||
was: 'Der Eintrag verschwindet vom Brett – für alle.',
|
||
bleibt: brauchtGrund
|
||
? 'Nachrichten, die dazu geschrieben wurden, gehen mit. Der Grund '
|
||
+ 'wird festgehalten und der Person mitgeteilt – sie hat ein '
|
||
+ 'Recht darauf (DSA Art. 17).'
|
||
: 'Nachrichten, die dazu geschrieben wurden, gehen mit.',
|
||
endgueltig: true, ja: 'Löschen',
|
||
...(brauchtGrund ? {
|
||
grund: 'Warum wird er gelöscht?', grundPflicht: true,
|
||
grundMin: lage?.grund_min || 10,
|
||
platzhalter: 'z. B. Gehört nicht auf dieses Brett.',
|
||
} : {}),
|
||
});
|
||
if (!antwort) return;
|
||
const grund = brauchtGrund ? (antwort?.grund || '') : '';
|
||
const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`
|
||
+ (grund ? '?grund=' + encodeURIComponent(grund) : ''), { method: 'DELETE' });
|
||
if (!a.ok) {
|
||
/* SAGEN, WORAN ES LAG. "Löschen hat nicht geklappt" schickt
|
||
einen auf die Suche; die Antwort des Servers steht da und
|
||
wurde nur weggeworfen. */
|
||
melde(sagWas((await a.json().catch(() => ({}))).fehler,
|
||
'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(); });
|
||
} else if (was.startsWith('eintrag-')) {
|
||
/* EIN EINZELNER ZETTEL (17.09.2026).
|
||
|
||
Der Kalender zeigt seit heute, was auf den Brettern steht und
|
||
einen Termin hat. Ein Klick darauf fuehrte bisher auf das
|
||
BRETT -- und dort steht der gesuchte Zettel irgendwo zwischen
|
||
zwanzig anderen. Wer ihn dann von Hand sucht, hat vom Link
|
||
nichts gehabt.
|
||
|
||
BENUTZT WIRD DER VORHANDENE WEG (`?zeigen=`, kopf.js) und
|
||
keine zweite Mechanik: Sonst springt die eine Seite hart, die
|
||
andere weich, und niemand traut dem Weg mehr -- das steht
|
||
woertlich in der Begruendung dieses Helfers. */
|
||
const k = karten.get(Number(was.slice(8)));
|
||
window.zielMarkieren?.(k ? [k] : [], 'diesen einen Eintrag',
|
||
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 */ }
|
||
}
|
||
|
||
/* ---- FETTSCHRIFT IM TEXT (18.09.2026) --------------------------
|
||
|
||
Im Startkatalog steht an zehn Stellen `**Hinweis**`, `**Pause**`,
|
||
`**DogFather**` -- gemeint als Hervorhebung, angezeigt als
|
||
Sternchen. Auf „Regeln & Hilfe" stand damit woertlich:
|
||
|
||
Drei Stufen: **Hinweis** (jemand aus dem Team sagt dir, was
|
||
nicht ging), **Pause** ..., **Ausschluss**.
|
||
|
||
Das sind ausgerechnet die drei Stufen, um die es geht -- und sie
|
||
sahen aus wie ein Tippfehler.
|
||
|
||
KEIN innerHTML, AUCH NICHT HIER. Der Text kommt aus der Datenbank,
|
||
und dort schreiben Menschen hinein. Gebaut wird deshalb aus
|
||
einzelnen Knoten: Was nicht zwischen zwei Sternchenpaaren steht,
|
||
wird Text und kann nichts anderes werden. Ein `<img onerror=…>`
|
||
bleibt ein Wort.
|
||
|
||
Ein einzelnes Sternchen bleibt ein Sternchen: `3 * 4` ist keine
|
||
Hervorhebung, und wer das anders faende, haette eine Regel, die
|
||
beim Rechnen zuschlaegt. */
|
||
/* AUS EINER QUELLE FUER DAS GANZE HAUS (21.09.2026).
|
||
|
||
Hier stand eine eigene kleine Fassung, die nur `**fett**` kannte
|
||
-- und sie war die EINZIGE Gestaltung im Haus. Im Chat gab es
|
||
nichts, bei den Aufgaben nichts.
|
||
|
||
Filipe: "ich will dass die leute auch immer die art von text
|
||
aussuchen können, fettgedrückt, unterstrichen und so alles ...
|
||
das überall."
|
||
|
||
Jetzt kommt sie aus textform.js -- dieselbe, die auch der Chat
|
||
benutzt. Vier Zeichen statt einem, und wer sie nicht kennt,
|
||
bekommt eine Leiste ueber dem Schreibfeld.
|
||
|
||
ALTES BLEIBT LESBAR: `**fett**` funktioniert unveraendert weiter,
|
||
es ist dort das erste der vier Zeichen. Nichts, was jemand schon
|
||
geschrieben hat, sieht ab heute anders aus.
|
||
|
||
DER RUECKFALL IST ABSICHT: Faellt textform.js aus (alte Fassung im
|
||
Zwischenspeicher), steht der Text roh da -- mit Sternchen, aber
|
||
vollstaendig. Ein leerer Eintrag waere schlimmer als ein
|
||
ungestalteter. */
|
||
function mitFett(text) {
|
||
if (window.Textform) return window.Textform.zeichne(text);
|
||
const stueck = document.createDocumentFragment();
|
||
stueck.append(document.createTextNode(String(text == null ? '' : text)));
|
||
return stueck;
|
||
}
|
||
|
||
/* ---- STARTHILFE BEIM SCHREIBEN (18.09.2026) ---------------------
|
||
|
||
Filipe: „ich will auch dass du fertige und beispiele machst fuer
|
||
die community damit die auch start hilfe haben ueberall."
|
||
|
||
Wer zum ersten Mal schreibt, steht vor einem leeren Feld. Die
|
||
Vorlagen geben einen Anfang -- mit LUECKEN, die nur der
|
||
Schreibende fuellen kann. Eine Vorlage, die man absenden KANN,
|
||
wird abgesendet; dann steht dort zehnmal derselbe Satz.
|
||
|
||
SIE VERSCHWINDEN, SOBALD JEMAND TIPPT. Hilfe, die stehen bleibt,
|
||
wenn man sie nicht mehr braucht, ist keine Hilfe mehr, sondern
|
||
etwas, das man wegklickt -- und beim naechsten Mal gleich
|
||
ignoriert. */
|
||
/* HEISST `starthilfe` UND NICHT `vorlagen`.
|
||
|
||
`vorlagenZeigen` und `#vorlagen` gab es hier schon: die Checkliste
|
||
fuer Creator auf live/community/technik. Meine gleichnamige
|
||
Funktion wurde davon ueberschrieben -- die spaetere Deklaration
|
||
gewinnt --, und im HTML standen zwei Elemente mit derselben
|
||
Kennung. Die Starthilfe erschien deshalb NIE, ohne eine einzige
|
||
Fehlermeldung. Aufgefallen ist es nur, weil ich nachgesehen habe,
|
||
ob die Knoepfe wirklich dastehen. */
|
||
let starthilfeGeholt = '';
|
||
|
||
async function starthilfeZeigen() {
|
||
const kasten = $('starthilfe');
|
||
const reihe = $('starthilfe-reihe');
|
||
if (!kasten || !reihe) return;
|
||
if (starthilfeGeholt === bereich) return;
|
||
starthilfeGeholt = bereich;
|
||
reihe.textContent = '';
|
||
kasten.hidden = true;
|
||
|
||
let liste = [];
|
||
try {
|
||
const a = await hole(`/workspace/api/bereich/${bereich}/vorlagen`);
|
||
if (!a.ok) return;
|
||
liste = (await a.json())?.vorlagen || [];
|
||
} catch { return; }
|
||
if (!liste.length) return;
|
||
|
||
for (const v of liste) {
|
||
const b = el('button', 'starthilfe__beispiel', einstellung?.arten?.[v.art] || v.art);
|
||
b.type = 'button';
|
||
/* Der volle Text als Titel-Attribut: Wer wissen will, was ihn
|
||
erwartet, muss nicht erst klicken. */
|
||
b.title = `${v.titel}\n\n${v.text}`;
|
||
b.addEventListener('click', () => {
|
||
const art = $('f-art');
|
||
if (art && [...art.options].some((o) => o.value === v.art)) {
|
||
art.value = v.art;
|
||
art.dispatchEvent(new Event('change'));
|
||
}
|
||
$('f-titel').value = v.titel;
|
||
$('f-text').value = v.text;
|
||
kasten.hidden = true;
|
||
/* Der Fokus wandert in die Ueberschrift und markiert sie: Der
|
||
erste Handgriff ist, den Platzhalter zu ersetzen. */
|
||
$('f-titel').focus();
|
||
$('f-titel').select();
|
||
});
|
||
reihe.append(b);
|
||
}
|
||
kasten.hidden = false;
|
||
|
||
/* Sobald jemand selbst schreibt, sind die Beispiele erledigt. */
|
||
for (const id of ['f-titel', 'f-text']) {
|
||
$(id)?.addEventListener('input', () => { kasten.hidden = true; }, { once: true });
|
||
}
|
||
}
|
||
|
||
function filterBauen(eintraege = []) {
|
||
const ziel = $('filter');
|
||
ziel.textContent = '';
|
||
|
||
/* ---- EIN NACHSCHLAGEWERK BRAUCHT KEINEN FILTER (18.09.2026) ----
|
||
|
||
Auf „Regeln & Hilfe" standen zwei Knopfreihen direkt
|
||
untereinander -- „Regel · Häufige Frage · Hilfe · Was passiert,
|
||
wenn" und darunter dieselben vier Wörter noch einmal. Die eine
|
||
filterte, die andere sprang zur Überschrift. Von außen sahen sie
|
||
gleich aus, und niemand konnte erraten, was der Unterschied ist.
|
||
|
||
Dort, wo nach Art gegliedert wird, ist der Filter der
|
||
überflüssige von beiden: Die Gliederung zeigt ohnehin schon
|
||
alles, sortiert und mit Überschriften. Die Sprungmarken bleiben
|
||
-- sie sind das Inhaltsverzeichnis. */
|
||
if (NACH_ART[bereich]) { filter = ''; return; }
|
||
|
||
/* ---- UND AUF EINEM LEEREN BRETT GAR KEINEN (18.09.2026) --------
|
||
|
||
Am ersten Tag steht auf jedem Brett ein Satz wie „Noch hat
|
||
niemand Hallo gesagt. Sei der Erste — ein Satz reicht." Darueber
|
||
standen fuenf Filterknoepfe: „Alle · Frage · Das war gut · …"
|
||
Filter fuer nichts.
|
||
|
||
Das ist die schlechteste Stelle fuer Bedienelemente, die ins
|
||
Leere fuehren: Wer zum ersten Mal hier ist, soll den Satz lesen
|
||
und schreiben -- nicht ueberlegen, was die sechs Knoepfe tun.
|
||
|
||
`!filter` ist wieder der wichtige Teil: Ist die Liste leer, WEIL
|
||
gefiltert wird, muss die Reihe stehen bleiben. Sonst kaeme man
|
||
nie wieder zu „Alle" zurueck. */
|
||
if (!eintraege.length && !filter) return;
|
||
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'));
|
||
|
||
/* „NUR OFFENE" NUR, WENN ES ETWAS ZU FILTERN GIBT (18.09.2026).
|
||
|
||
Fuenf der sieben Community-Bretter kennen gar keine Zustaende --
|
||
ein Clip ist nicht „offen" oder „erledigt". Der Knopf stand
|
||
trotzdem ueberall und tat dort nichts. Ein Knopf, der nichts
|
||
tut, kostet das Vertrauen in den naechsten: Man drueckt ihn,
|
||
nichts passiert, und beim uebernaechsten fragt man sich, ob man
|
||
etwas falsch macht.
|
||
|
||
Dieselbe Regel wie bei der Kanalzeile: Sie erscheint nur, wenn
|
||
es mehr als eine Moeglichkeit gibt.
|
||
|
||
`|| filter === 'offen'` ist kein Beiwerk: Ist der Filter aktiv,
|
||
sieht man gerade KEINE erledigten mehr -- der Knopf wuerde unter
|
||
der eigenen Hand verschwinden, und man kaeme nicht zurueck. */
|
||
const gibtErledigte = eintraege.some((e) => e.status && e.status !== 'offen');
|
||
if (gibtErledigte || filter === 'offen') 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. */
|
||
/* ---- DIE ZEITLEISTE, NUR AUF DEN HIGHLIGHTS (20.09.2026) -------
|
||
|
||
Filipe: "die videos will ich auch kategorisiert haben nach
|
||
woche, monat, jahr und ganz oben aktuell also heute."
|
||
|
||
SIE STEHT VOR der Kanal-Abbruchstelle darunter. Dort steigt die
|
||
Funktion aus, wenn es weniger als zwei Kanaele gibt -- stuende
|
||
die Zeitleiste dahinter, waere sie auf einem Brett mit nur
|
||
einem Account nicht da. Und genau dann braucht man sie:
|
||
Ein Kanal, viele Monate.
|
||
|
||
JEDER KNOPF TRAEGT SEINE ZAHL. Ohne sie klickt man auf
|
||
"Aktuell" und landet auf einer leeren Seite, ohne zu wissen, ob
|
||
der Filter greift oder nichts passiert ist. Mit ihr sieht man
|
||
vorher, dass heute nichts dazugekommen ist -- und klickt gar
|
||
nicht erst. */
|
||
if (bereich === 'highlight' && eintraege.length) {
|
||
const zeile = el('div', 'filter__zeiten');
|
||
zeile.setAttribute('role', 'group');
|
||
zeile.setAttribute('aria-label', 'Nach Zeitraum filtern');
|
||
const zknopf = (wert, text) => {
|
||
const b = el('button', 'schritt filter__zeit', text);
|
||
b.type = 'button';
|
||
if (wert) b.dataset.zeit = wert;
|
||
b.setAttribute('aria-pressed', String(zeitFilter === wert));
|
||
const ab = zeitAb(wert);
|
||
const zahl = wert
|
||
? eintraege.filter((e) => entstandAm(e) >= ab).length
|
||
: eintraege.length;
|
||
b.append(el('span', 'filter__zeit-zahl', String(zahl)));
|
||
/* Ein Zeitraum ohne einen einzigen Eintrag laesst sich nicht
|
||
waehlen. Er bleibt aber STEHEN, mit seiner Null -- ihn
|
||
auszublenden hiesse, die Leiste wechselt ihre Form, sobald
|
||
jemand etwas einstellt, und man sucht den Knopf, den man
|
||
gestern noch hatte. */
|
||
if (!zahl && wert) { b.disabled = true; b.title = 'Dafür gibt es nichts'; }
|
||
b.addEventListener('click', async () => {
|
||
/* Zweiter Klick hebt auf -- wie beim Kanalfilter daneben.
|
||
Zwei Filter nebeneinander, die sich verschieden bedienen
|
||
lassen, sind einer zu viel. */
|
||
zeitFilter = (zeitFilter === wert) ? '' : wert;
|
||
await laden();
|
||
});
|
||
return b;
|
||
};
|
||
zeile.append(zknopf('heute', ZEIT_WORT.heute));
|
||
zeile.append(zknopf('woche', ZEIT_WORT.woche));
|
||
zeile.append(zknopf('monat', ZEIT_WORT.monat));
|
||
zeile.append(zknopf('jahr', ZEIT_WORT.jahr));
|
||
zeile.append(zknopf('', 'Alles'));
|
||
ziel.append(zeile);
|
||
}
|
||
|
||
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. */
|
||
/* Aus welchen Brettern heraus ein Video eingefuegt werden kann.
|
||
„mitmachen" und „regeln" stehen bewusst nicht dabei: Dort geht es
|
||
nicht um Inhalte, aus denen ein Clip wird. */
|
||
const VIDEO_AUS = ['wunsch', 'ansteht'];
|
||
|
||
/** Das Eingabefeld, das direkt an der Karte aufgeht. */
|
||
function videoDazuFeld(e) {
|
||
const kasten = el('div', 'videodazu');
|
||
const feld = el('input', 'videodazu__feld');
|
||
feld.type = 'url';
|
||
feld.placeholder = 'TikTok-Link einfügen';
|
||
/* Ohne Beschriftung waere das Feld fuer eine Vorleseansage ein
|
||
Kaestchen ohne Bedeutung. Sichtbar ist der Knopftext daneben. */
|
||
feld.setAttribute('aria-label', 'TikTok-Link zu diesem Eintrag');
|
||
const senden = el('button', 'schritt videodazu__senden', 'Übernehmen');
|
||
senden.type = 'button';
|
||
const sagt = el('p', 'videodazu__sagt');
|
||
|
||
senden.addEventListener('click', async () => {
|
||
const url = feld.value.trim();
|
||
if (!url) { feld.focus(); return; }
|
||
senden.disabled = true;
|
||
sagt.textContent = 'Wird geholt …';
|
||
try {
|
||
const a = await hole('/workspace/api/bereich/highlight/video', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url, aus: e.id }),
|
||
});
|
||
const r = await a.json().catch(() => ({}));
|
||
if (!a.ok) {
|
||
/* Der Grund des Servers, nicht „Ging nicht" -- er weiss, ob
|
||
der Account fremd war oder die Adresse keine ist. */
|
||
sagt.textContent = sagWas(r.fehler, 'Ging nicht.');
|
||
senden.disabled = false;
|
||
return;
|
||
}
|
||
await laden();
|
||
} finally { senden.disabled = false; }
|
||
});
|
||
feld.addEventListener('keydown', (ev) => {
|
||
if (ev.key === 'Enter') { ev.preventDefault(); senden.click(); }
|
||
});
|
||
|
||
kasten.append(feld, senden, sagt);
|
||
/* Der Fokus wandert ins Feld: Wer den Knopf gedrueckt hat, will
|
||
tippen, nicht erst noch zielen. */
|
||
setTimeout(() => feld.focus(), 0);
|
||
return kasten;
|
||
}
|
||
|
||
function videoFeldZeigen() {
|
||
const ziel = $('videoholen');
|
||
if (!ziel) return;
|
||
ziel.hidden = !(bereich === 'highlight' && darfEintragen());
|
||
|
||
/* DIE ABLAGE WAEHLEN (22.09.2026).
|
||
Gefuellt wird aus derselben Liste wie das normale Formular
|
||
(`kaneleDa` kommt vom Server) -- eine zweite Aufzaehlung hier
|
||
waere die, die beim naechsten Kanal vergessen wird.
|
||
|
||
"Aus dem Link" bleibt die Vorgabe. Der Server erkennt den
|
||
Account ohnehin; die Wahl ist fuer die Faelle, in denen das
|
||
Erkannte nicht das Gewuenschte ist -- ein Ausschnitt vom
|
||
Hauptkanal gehoert oft unter "Clips". */
|
||
const wohin = $('video-kanal');
|
||
if (wohin && !ziel.hidden
|
||
&& wohin.dataset.stand !== String(kanaeleDa.length)) {
|
||
wohin.dataset.stand = String(kanaeleDa.length);
|
||
wohin.textContent = '';
|
||
const auto = document.createElement('option');
|
||
auto.value = '';
|
||
auto.textContent = 'Aus dem Link erkennen';
|
||
wohin.append(auto);
|
||
for (const k of kanaeleDa) {
|
||
const o = document.createElement('option');
|
||
o.value = k.wert;
|
||
o.textContent = k.name;
|
||
wohin.append(o);
|
||
}
|
||
}
|
||
}
|
||
|
||
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, kanal: $('video-kanal')?.value || '' }),
|
||
});
|
||
const r = await a.json().catch(() => ({}));
|
||
if (!a.ok) {
|
||
melde(sagWas(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. */
|
||
/* WO IM VORRAT WIR GERADE STEHEN (20.09.2026).
|
||
|
||
Filipe: "fertige vorschlaege ... will ich dass man auch immer
|
||
wieder neuladen kann, so dass wenn man alle benutzt hat man neue
|
||
erstellen kann oder wenn keiner uns gefaellt wir komplett neue
|
||
erstellen koennen automatisch."
|
||
|
||
Bisher kamen ALLE auf einmal -- damit gab es nichts zu wechseln.
|
||
Jetzt zeigt der Server ein Fenster, und dieser Merker sagt ihm, wo
|
||
es anfangen soll. Er steht ausserhalb der Funktion, weil `laden()`
|
||
sie neu aufruft: Waere er drin, spraenge die Liste nach jedem
|
||
Uebernehmen zurueck an den Anfang.
|
||
|
||
KEINE EIGENE FENSTERGROESSE HIER. Weitergezaehlt wird um das, was
|
||
tatsaechlich kam (`vorschlaege.length`) -- eine zweite 4 an dieser
|
||
Stelle waere eine Zahl, die beim naechsten Anfassen auseinanderlaeuft. */
|
||
let katalogAb = 0;
|
||
|
||
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?ab=${katalogAb}`,
|
||
{ 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'} `
|
||
+ (daten.mehr_da
|
||
? 'vorbereitet. Hier stehen die nächsten – übernimm, was passt.'
|
||
: 'vorbereitet. Übernehmen, was passt – ändern kannst du danach alles.')));
|
||
|
||
/* EIN VORSCHLAG IST EIN VORSCHLAG (21.09.2026).
|
||
|
||
Filipe: „immer wenn es um was geht was ich machen soll ... dass
|
||
ich immer am ende entscheide ob ich was mache oder nicht. ich
|
||
kann immer annehmen oder ablehnen."
|
||
|
||
WARUM DAS HIER STEHT UND NICHT AUF JEDER KARTE: Auf den Karten
|
||
stehen Fragen wie „Was soll DogFather im nächsten Stream
|
||
unbedingt machen?". Wer so gefragt wird, schreibt etwas hin und
|
||
rechnet damit. Passiert es nicht, sieht es aus wie ein
|
||
gebrochenes Versprechen — obwohl nie eines gegeben wurde. Der
|
||
Satz steht deshalb ÜBER den Karten, wo man ihn liest, bevor man
|
||
die erste antippt.
|
||
|
||
Viermal derselbe Satz nebeneinander wäre das Gegenteil: Eine
|
||
Zeile, die überall steht, liest niemand mehr.
|
||
|
||
DER TEXT KOMMT VOM SERVER. Er steht dort an derselben Liste wie
|
||
die Vorschläge selbst — eine Abschrift hier wäre die zweite
|
||
Fassung, und die erste, die veraltet. */
|
||
if (daten.hinweis) {
|
||
kopf.append(el('p', 'startkatalog__entscheidung', daten.hinweis));
|
||
}
|
||
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);
|
||
|
||
/* ---- ANDERE ZEIGEN ------------------------------------------------
|
||
Nur wenn es ueberhaupt andere GIBT. Ein Knopf, der dieselben vier
|
||
noch einmal bringt, ist ein Knopf, der nichts tut -- und der
|
||
Verdacht, dass die Seite haengt, ist schlimmer als ein fehlender
|
||
Knopf. Deshalb entscheidet der Server (`mehr_da`) und nicht eine
|
||
Rechnung hier.
|
||
|
||
DER VORRAT ENDET NICHT: Der Server rechnet `ab` mit Rest, nach dem
|
||
letzten kommt wieder der erste. Es gibt also keinen Zustand
|
||
"durchgeklickt, jetzt ist leer". */
|
||
if (daten.mehr_da) {
|
||
const andere = el('button', 'schritt startkatalog__andere',
|
||
'Andere Vorschläge zeigen');
|
||
andere.type = 'button';
|
||
andere.setAttribute('aria-label',
|
||
'Andere Vorschläge zeigen – die nächsten aus dem Vorrat');
|
||
andere.addEventListener('click', () => {
|
||
/* Um das weiterzaehlen, was wirklich kam. Bei drei gezeigten
|
||
Karten um drei -- sonst uebersprungen wir eine. */
|
||
katalogAb += Math.max(1, daten.vorschlaege.length);
|
||
startkatalogZeigen();
|
||
});
|
||
kopf.append(andere);
|
||
}
|
||
|
||
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) {
|
||
/* Auch hier -- sonst sieht das Team im Vorschlag Sternchen und
|
||
auf dem Brett danach Fettschrift. Zwei Darstellungen
|
||
desselben Textes sind eine zu viel. */
|
||
const vt = el('p', 'startkatalog__text');
|
||
vt.append(mitFett(v.text));
|
||
k.append(vt);
|
||
}
|
||
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;
|
||
kanaeleDa = Array.isArray(daten.kanaele) ? daten.kanaele : [];
|
||
treffBrett = !!daten.treff;
|
||
kalenderOffen = daten.kalender_offen !== false;
|
||
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. */
|
||
/* SEIT DEN KANALSPALTEN LIEGT DAS RASTER EINE EBENE TIEFER
|
||
(20.09.2026). `#liste[data-galerie]` ist selbst ein Raster --
|
||
der Spaltenkasten wurde darin zu EINER Zelle von 376 px, und
|
||
die drei Accounts standen untereinander statt nebeneinander.
|
||
Gemessen, nicht vermutet: Elternbreite 1160, Kind 376.
|
||
Die Galerie gehoert jetzt in die einzelne Spalte (siehe
|
||
`.kanalspalte__inhalt[data-galerie]`). */
|
||
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;
|
||
/* DER ZEITFILTER GILT NUR FUER HIGHLIGHTS. Auf den anderen
|
||
Brettern steht er gar nicht erst da -- eine Regel, die
|
||
unsichtbar mitfiltert, ist ein Brett, das Dinge verschluckt. */
|
||
if (bereich === 'highlight' && zeitFilter
|
||
&& entstandAm(e) < zeitAb(zeitFilter)) 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();
|
||
|
||
/* ==== NICHT ALLES AUF EINMAL (23.09.2026) =====================
|
||
|
||
Filipe: "mach die ganze seite noch viel uebersichtlicher und
|
||
ohne so dass es extrem lange listen gibt mit der zeit."
|
||
|
||
Das "mit der zeit" ist der Kern. Heute liegen neun Highlights
|
||
da und alles passt; in einem Jahr sind es dreihundert, und
|
||
dann ist jede Spalte eine Rolle ohne Ende. Der Fehler faellt
|
||
erst auf, wenn er schon laestig ist.
|
||
|
||
AN EINER STELLE FUER ALLE DREI FORMEN. Die Seite legt Karten
|
||
an drei Stellen in einen Kasten -- Kanalspalten, Abschnitte
|
||
nach Art, Zeitstrahl. Dreimal dasselbe hinzuschreiben hiesse,
|
||
dass beim naechsten Umbau zwei davon nachgezogen werden und
|
||
eine vergessen wird.
|
||
|
||
ZWOELF, WEIL ZWEI NEBENEINANDER PASSEN: sechs Reihen, genug um
|
||
zu sehen was zuletzt los war, ohne bis zum Anfang der Zeit zu
|
||
scrollen. Eine Zahl ist hier unvermeidlich; sie steht deshalb
|
||
EINMAL da und traegt ihren Grund.
|
||
|
||
ES VERSCHWINDET NICHTS. Der Knopf sagt, wie viele noch kommen,
|
||
und holt sie auf einen Druck. Die Zahlen in den Koepfen zaehlen
|
||
weiter ALLE -- sonst stimmten sie nicht mehr mit dem ueberein,
|
||
was der Abschnitt enthaelt.
|
||
|
||
SORTIERT IST SCHON: Der Server liefert nach Datum absteigend
|
||
(e.datum DESC, e.id DESC). "Die ersten zwoelf" sind also die
|
||
neuesten zwoelf, nicht die erstbesten. */
|
||
const AUF_EINMAL = 12;
|
||
|
||
/* ==== NACH ALTER GEGLIEDERT (24.09.2026) ======================
|
||
|
||
Filipe: "ich will da unterkategorien die man auf und zu
|
||
klappen kann, so wie heute gestern, diese woche, diesen
|
||
monate, über ein monat, über 6 monate, über ein jahr."
|
||
|
||
Das ist die bessere Antwort auf die langen Listen als die
|
||
Zahl von gestern. Zwoelf ist willkuerlich; eine Gliederung
|
||
nach Alter beantwortet die Frage, die man an so eine Liste
|
||
wirklich stellt -- "was ist neu?". Und was alt ist, steht
|
||
zugeklappt da: sichtbar, dass es existiert, ohne den Weg zu
|
||
verstellen.
|
||
|
||
DIE ERSTEN VIER STUFEN SIND KALENDERBEZOGEN, die letzten drei
|
||
altersbezogen. Das klingt uneinheitlich und ist genau richtig:
|
||
"Diese Woche" heisst seit Montag, nicht "in den letzten
|
||
sieben Tagen" -- am Dienstag ist der Freitag davor nicht
|
||
"diese Woche". Fuer alles aeltere zaehlt dagegen der Abstand,
|
||
weil "Mai" niemandem sagt, wie lange das her ist.
|
||
|
||
EINE STUFE OHNE EINTRAEGE ERSCHEINT NICHT. Sieben leere
|
||
Ueberschriften waeren schlimmer als eine lange Liste. */
|
||
const ZEITSTUFEN = [
|
||
{ schluessel: 'heute', name: 'Heute' },
|
||
{ schluessel: 'gestern', name: 'Gestern' },
|
||
{ schluessel: 'woche', name: 'Diese Woche' },
|
||
{ schluessel: 'monat', name: 'Dieser Monat' },
|
||
{ schluessel: 'aelter', name: 'Über einen Monat' },
|
||
{ schluessel: 'halb', name: 'Über sechs Monate' },
|
||
{ schluessel: 'jahr', name: 'Über ein Jahr' },
|
||
];
|
||
|
||
/** In welche Stufe faellt dieser Eintrag?
|
||
*
|
||
* GERECHNET WIRD IN ORTSZEIT, nicht in UTC. Ein Eintrag von
|
||
* gestern 23:40 waere in UTC schon heute -- und stuende dann
|
||
* unter "Heute", waehrend das Datum daneben gestern sagt.
|
||
* window.heuteLokal() ist die Stelle im Haus, die das richtig
|
||
* macht. */
|
||
function stufeVon(e) {
|
||
const roh = String(e?.datum || '').slice(0, 10);
|
||
if (!roh) return 'jahr';
|
||
const heute = window.heuteLokal
|
||
? window.heuteLokal() : new Date().toISOString().slice(0, 10);
|
||
if (roh === heute) return 'heute';
|
||
|
||
const tag = new Date(heute + 'T00:00:00');
|
||
const dann = new Date(roh + 'T00:00:00');
|
||
if (Number.isNaN(dann.getTime())) return 'jahr';
|
||
|
||
const gestern = new Date(tag); gestern.setDate(tag.getDate() - 1);
|
||
if (dann.getTime() === gestern.getTime()) return 'gestern';
|
||
|
||
/* Montag als Wochenanfang -- getDay() liefert Sonntag = 0, und
|
||
in Deutschland steht der am Ende. Dieselbe Rechnung wie in
|
||
zeitAb() weiter oben. */
|
||
const montag = new Date(tag);
|
||
montag.setDate(tag.getDate() - ((tag.getDay() + 6) % 7));
|
||
if (dann >= montag) return 'woche';
|
||
|
||
const ersterDesMonats = new Date(tag.getFullYear(), tag.getMonth(), 1);
|
||
if (dann >= ersterDesMonats) return 'monat';
|
||
|
||
const halbesJahr = new Date(tag); halbesJahr.setMonth(tag.getMonth() - 6);
|
||
const einJahr = new Date(tag); einJahr.setFullYear(tag.getFullYear() - 1);
|
||
if (dann < einJahr) return 'jahr';
|
||
if (dann < halbesJahr) return 'halb';
|
||
return 'aelter';
|
||
}
|
||
|
||
/** Die Karten eines Fachs in einen Kasten legen -- gegliedert
|
||
* nach Alter, jede Gruppe auf- und zuklappbar.
|
||
*
|
||
* "merker" unterscheidet die Gruppen zweier Spalten: Ohne ihn
|
||
* teilten sich "Heute bei DogFather" und "Heute bei HasiDog"
|
||
* denselben Zustand, und wer die eine zuklappt, klappt die
|
||
* andere mit. */
|
||
function kartenLegen(kasten, fach, merker) {
|
||
/* HIER STAND EINE SCHWELLE -- UND SIE WAR FALSCH (24.09.2026).
|
||
|
||
"if (fach.length <= AUF_EINMAL) { stapelLegen(...); return; }"
|
||
Begruendung damals: "Bei wenigen Eintraegen waere eine
|
||
Gliederung Aufwand ohne Nutzen: sieben Ueberschriften fuer
|
||
fuenf Karten."
|
||
|
||
Das klang vernuenftig und war trotzdem falsch, aus zwei
|
||
Gruenden. Erstens hat Filipe die Unterkategorien verlangt,
|
||
nicht eine Bedingung dafuer -- eine eigene Einschraenkung
|
||
an eine Ansage zu haengen ist keine Sorgfalt, sondern eine
|
||
Entscheidung, die mir nicht zusteht. Zweitens stimmte die
|
||
Begruendung gar nicht: Leere Stufen erscheinen ohnehin
|
||
nicht (siehe "continue" weiter unten), also gibt es bei
|
||
fuenf Karten nie sieben Ueberschriften, sondern hoechstens
|
||
so viele, wie es wirklich verschiedene Alter gibt. Meist
|
||
eine, manchmal zwei.
|
||
|
||
Gemerkt hat es Filipe, nicht ich: Seine Spalten haben 3, 1,
|
||
1 und 4 Eintraege -- die Schwelle lag bei 13. Er hat die
|
||
Gliederung also nie zu sehen bekommen. */
|
||
const faecher = new Map(ZEITSTUFEN.map((s) => [s.schluessel, []]));
|
||
for (const e of fach) {
|
||
const s = stufeVon(e);
|
||
(faecher.get(s) || faecher.get('jahr')).push(e);
|
||
}
|
||
|
||
let erste = true;
|
||
for (const stufe of ZEITSTUFEN) {
|
||
const drin = faecher.get(stufe.schluessel) || [];
|
||
if (!drin.length) continue; /* leere Stufe: gar nicht erst */
|
||
|
||
const kopf = el('button', 'zeitgruppe__kopf');
|
||
kopf.type = 'button';
|
||
kopf.append(el('span', 'zeitgruppe__wort', stufe.name));
|
||
kopf.append(el('span', 'zeitgruppe__zahl', String(drin.length)));
|
||
if (window.klappPfeil) kopf.append(window.klappPfeil());
|
||
kasten.append(kopf);
|
||
|
||
const innen = el('div', 'zeitgruppe__inhalt');
|
||
innen.dataset.galerie = 'ja';
|
||
stapelLegen(innen, drin);
|
||
kasten.append(innen);
|
||
|
||
/* DIE NEUESTE GRUPPE STEHT OFFEN, die uebrigen zu. Wer die
|
||
Seite aufmacht, will sehen was neu ist -- und nicht erst
|
||
etwas aufklappen muessen. Alles Aeltere ist sichtbar
|
||
VORHANDEN, ohne den Weg zu verstellen.
|
||
|
||
Die eigene Wahl gewinnt: abschnittKlappbar merkt sich je
|
||
Gruppe, was zuletzt eingestellt war. */
|
||
/* Der vierte Wert ist ZU-Vorgabe, kein Objekt -- nachgesehen
|
||
in kopf.js, nicht geraten. Die erste Gruppe steht offen
|
||
(zu = false), alle weiteren zu. */
|
||
window.abschnittKlappbar?.(kopf, innen,
|
||
'zeit:' + merker + ':' + stufe.schluessel, !erste);
|
||
erste = false;
|
||
}
|
||
}
|
||
|
||
/** Ein Stapel Karten mit der Grenze bei zwoelf -- der Rest auf
|
||
* einen Druck. Gilt jetzt JE GRUPPE: Auch "Über ein Jahr" kann
|
||
* dreihundert Eintraege haben, und dann hilft die Gliederung
|
||
* allein nicht. */
|
||
function stapelLegen(kasten, fach) {
|
||
for (const e of fach.slice(0, AUF_EINMAL)) {
|
||
const k = karte(e);
|
||
karten.set(e.id, k);
|
||
kasten.append(k);
|
||
}
|
||
const rest = fach.slice(AUF_EINMAL);
|
||
if (!rest.length) return;
|
||
const mehr = el('button', 'mehr-zeigen',
|
||
rest.length === 1 ? 'Einen älteren zeigen'
|
||
: rest.length + ' ältere zeigen');
|
||
mehr.type = 'button';
|
||
mehr.addEventListener('click', () => {
|
||
for (const e of rest) {
|
||
const k = karte(e);
|
||
karten.set(e.id, k);
|
||
kasten.insertBefore(k, mehr);
|
||
}
|
||
/* Der Knopf hat seine Arbeit getan. Stehen zu lassen hiesse,
|
||
einen anzubieten, der nichts mehr tut. */
|
||
mehr.remove();
|
||
});
|
||
kasten.append(mehr);
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE HIGHLIGHTS: DREI KANAELE NEBENEINANDER (20.09.2026)
|
||
|
||
Filipe: "die highlights sollen in 3 kategorien sein
|
||
nebeneinander, dogfather, hasidog und dogfather clips. und die
|
||
liste soll man auch zu und auf klappen koennen."
|
||
|
||
WARUM DAS MEHR IST ALS EINE SORTIERUNG: Die drei Accounts haben
|
||
eigene Zuschauer und einen eigenen Ton -- das steht so an
|
||
KANAELE im Server. Eine gemeinsame Galerie behauptet, sie
|
||
waeren ein Topf. Wer fuer Clips schneidet, sucht dann in jeder
|
||
Reihe nach den Kacheln, die ihn angehen.
|
||
|
||
DIE REIHENFOLGE KOMMT VOM SERVER, nicht aus einer Liste hier.
|
||
Der Browser hatte dafuer bis heute `KANAL_WORT` -- dieselben
|
||
drei Namen in anderer Reihenfolge, ohne Handles. Beim vierten
|
||
Account waere genau diese Kopie die vergessene.
|
||
|
||
EINE VIERTE SPALTE GIBT ES NUR, WENN SIE GEBRAUCHT WIRD:
|
||
Bilder und Fanart ohne TikTok-Link gehoeren zu keinem Kanal.
|
||
Sie verschwinden zu lassen waere der schlimmere Fehler -- ein
|
||
Eintrag, den man angelegt hat und nirgends wiederfindet.
|
||
|
||
ZUKLAPPEN ueber dieselben zwei Hilfen wie ueberall
|
||
(`abschnittKlappbar`, `klappPfeil`), mit einem Merker je Kanal.
|
||
Ein zweiter Klappmechanismus waere eine zweite Sache zum
|
||
Lernen und eine zweite zum Kaputtgehen.
|
||
===================================================================== */
|
||
if (bereich === 'highlight') {
|
||
const kanaele = kanaeleDa.length
|
||
? kanaeleDa
|
||
/* Faellt die Auskunft aus (alte Auslieferung, fremde Rolle),
|
||
wird aus den Eintraegen selbst abgeleitet -- lieber die
|
||
Namen ohne Reihenfolge als eine leere Seite. */
|
||
: [...new Set(sichtbar.map((e) => e.quelle_kanal).filter(Boolean))]
|
||
.map((w) => ({ wert: w, name: KANAL_WORT[w] || w }));
|
||
|
||
const faecher = new Map(kanaele.map((k) => [k.wert, []]));
|
||
const ohne = [];
|
||
for (const e of sichtbar) {
|
||
if (e.quelle_kanal && faecher.has(e.quelle_kanal)) faecher.get(e.quelle_kanal).push(e);
|
||
else ohne.push(e);
|
||
}
|
||
|
||
const spalten = el('div', 'kanalspalten');
|
||
const bauen = (schluessel, name, handle, fach, satz) => {
|
||
const sp = el('section', 'kanalspalte');
|
||
sp.dataset.kanal = schluessel || 'ohne';
|
||
const kopf = el('button', 'kanalspalte__kopf');
|
||
kopf.type = 'button';
|
||
const wort = el('span', 'kanalspalte__wort', name);
|
||
if (handle) wort.append(el('span', 'kanalspalte__handle', handle));
|
||
kopf.append(wort);
|
||
kopf.append(el('span', 'kanalspalte__zahl', String(fach.length)));
|
||
if (window.klappPfeil) kopf.append(window.klappPfeil());
|
||
sp.append(kopf);
|
||
|
||
const kasten = el('div', 'kanalspalte__inhalt');
|
||
/* EIN SATZ, DER SAGT, WAS ZU TUN IST. Nur wo es etwas zu tun
|
||
gibt -- eine Erklaerung an jeder Spalte waere Fuellwerk. */
|
||
if (satz) {
|
||
const hinweis = el('p', 'kanalspalte__satz', satz);
|
||
kasten.append(hinweis);
|
||
}
|
||
/* Die Galerie-Form kommt hierher -- in der Spalte, nicht um
|
||
sie herum. In einer schmalen Spalte ergibt das eine Kachel
|
||
je Reihe, bei viel Platz zwei. Gerechnet wird nichts. */
|
||
kasten.dataset.galerie = 'ja';
|
||
if (!fach.length) {
|
||
/* Eine leere Spalte sagt, dass sie leer IST -- sonst sieht
|
||
sie aus, als haette die Seite nicht fertig geladen. */
|
||
kasten.append(el('p', 'leise kanalspalte__leer',
|
||
zeitFilter
|
||
? `Für ${ZEIT_WORT[zeitFilter].toLowerCase()} steht hier nichts.`
|
||
: 'Hier steht noch nichts.'));
|
||
}
|
||
kartenLegen(kasten, fach, bereich + ':kanal:' + (schluessel || 'ohne'));
|
||
sp.append(kasten);
|
||
window.abschnittKlappbar?.(kopf, kasten, `highlight:kanal:${schluessel || 'ohne'}`);
|
||
spalten.append(sp);
|
||
};
|
||
|
||
for (const k of kanaele) bauen(k.wert, k.name, k.handle, faecher.get(k.wert) || []);
|
||
|
||
/* ==== DIE VIERTE SPALTE HEISST NACH IHREM INHALT (23.09.2026)
|
||
|
||
Filipe: "der ohne account ist total sinnlos, mach was
|
||
anderes draus."
|
||
|
||
Er hat recht, und zwar aus zwei Gruenden. Erstens sagte der
|
||
Name nur, was FEHLT -- nicht, was dort liegt und schon gar
|
||
nicht, was zu tun ist. Zweitens lagen dort zwei
|
||
verschiedene Sachen:
|
||
|
||
ein CLIP ohne Account ist eine Luecke. Clips kommen von
|
||
einem Kanal; steht keiner dabei, wurde der Link nicht
|
||
erkannt oder nicht mitgegeben. Das gehoert einsortiert.
|
||
|
||
ein BILD oder FANART ohne Account ist keine Luecke. Es
|
||
gibt keinen TikTok-Beitrag dazu, und das ist in Ordnung.
|
||
|
||
Beides unter derselben Ueberschrift zu sammeln, heisst: Wer
|
||
die Zahl sieht, weiss nicht, ob er etwas tun muss. Genau das
|
||
macht sie "sinnlos".
|
||
|
||
Also entscheidet der Inhalt ueber Namen und Satz. Getrennt
|
||
wird NICHT in zwei Spalten -- das waere eine mehr, und
|
||
Filipe hat im selben Satz um weniger gebeten. */
|
||
if (ohne.length) {
|
||
const clips = ohne.filter((e) => e.art === 'clip').length;
|
||
bauen('', clips ? 'Noch einzusortieren' : 'Ohne TikTok-Quelle', null, ohne,
|
||
clips
|
||
? (clips === 1
|
||
? 'Ein Clip hier hat keinen Account. Öffne ihn und trag ihn nach – '
|
||
+ 'dann steht er bei den anderen seines Kanals.'
|
||
: `${clips} Clips hier haben keinen Account. Öffne sie und trag ihn `
|
||
+ 'nach – dann stehen sie bei den anderen ihres Kanals.')
|
||
: 'Eigene Bilder und Fanart gehören zu keinem TikTok-Account. '
|
||
+ 'Hier ist nichts zu tun.');
|
||
}
|
||
|
||
ziel.append(spalten);
|
||
sprungZeigen(ziel, karten);
|
||
return;
|
||
}
|
||
|
||
/* ---- 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". */
|
||
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');
|
||
/* „Springe zu" davor: Ohne diese zwei Wörter sieht eine Reihe
|
||
von Wörtern aus wie eine Reihe von Filtern -- genau die
|
||
Verwechslung, die hier behoben wird. Mit ihnen ist es ein
|
||
Inhaltsverzeichnis, und das ist es auch. */
|
||
marken.append(el('span', 'brettmarken__wort', 'Springe zu'));
|
||
for (const a of faecher.keys()) {
|
||
const anzahl = faecher.get(a).length;
|
||
if (!anzahl) continue;
|
||
const m = el('a', 'brettmarken__weg', einstellung?.arten?.[a] || a);
|
||
/* Die Anzahl gehört dazu: Sie sagt, ob sich der Sprung lohnt
|
||
-- und sie verrät, dass hier nicht gefiltert wird. */
|
||
m.append(el('span', 'brettmarken__zahl', String(anzahl)));
|
||
m.href = `#abschnitt-${a}`;
|
||
marken.append(m);
|
||
}
|
||
if (marken.childNodes.length > 2) ziel.append(marken);
|
||
|
||
for (const a of faecher.keys()) {
|
||
const fach = faecher.get(a);
|
||
if (!fach.length) continue;
|
||
/* JEDER ABSCHNITT LAESST SICH ZUKLAPPEN (19.09.2026).
|
||
|
||
Filipe: "man soll auf dieser seite jede kategorie auf und
|
||
zu klappen koennen mit einem button."
|
||
|
||
Der Kopf ist jetzt ein echter <button> und kein <h2> mit
|
||
Klickzuhoerer -- sonst waere er fuer die Tastatur nicht
|
||
erreichbar. Die Ueberschrift steckt darin, damit
|
||
Vorleseprogramme die Gliederung behalten.
|
||
|
||
Die Sprungmarke (#abschnitt-…) bleibt am Kopf: Wer aus dem
|
||
Inhaltsverzeichnis herspringt, landet weiterhin richtig --
|
||
auch wenn der Abschnitt gerade zu ist. */
|
||
const kopf2 = el('button', 'brettabschnitt');
|
||
kopf2.type = 'button';
|
||
kopf2.id = `abschnitt-${a}`;
|
||
kopf2.append(el('span', 'brettabschnitt__wort',
|
||
einstellung?.arten?.[a] || a));
|
||
kopf2.append(el('span', 'brettabschnitt__zahl', String(fach.length)));
|
||
if (window.klappPfeil) kopf2.append(window.klappPfeil());
|
||
ziel.append(kopf2);
|
||
|
||
const kasten = el('div', 'brettabschnitt__inhalt');
|
||
kartenLegen(kasten, fach, bereich + ':art:' + a);
|
||
ziel.append(kasten);
|
||
/* Der Merker haengt am BRETT und an der Art -- sonst waere
|
||
"Regel zugeklappt" auf allen Brettern gleichzeitig zu. */
|
||
window.abschnittKlappbar?.(kopf2, kasten, `${bereich}:${a}`);
|
||
}
|
||
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;
|
||
});
|
||
/* Auch hier zuklappbar -- Filipe: "ueberall wo so eine
|
||
liste entstehen kann". Vorbei Gewesenes steht dabei von
|
||
sich aus zu: Es ist Nachschlagewerk, nicht das, weswegen
|
||
jemand die Seite oeffnet. */
|
||
const kopf = el('button', 'zeitstrahl__schild');
|
||
kopf.type = 'button';
|
||
kopf.append(el('span', 'zeitstrahl__wort', ABSCHNITT_NAME[r]));
|
||
kopf.append(el('span', 'zeitstrahl__zahl', String(fach.length)));
|
||
if (window.klappPfeil) kopf.append(window.klappPfeil());
|
||
ziel.append(kopf);
|
||
const kasten = el('div', 'zeitstrahl__inhalt');
|
||
kartenLegen(kasten, fach, bereich + ':zeit:' + r);
|
||
ziel.append(kasten);
|
||
window.abschnittKlappbar?.(kopf, kasten, `ansteht:${r}`, r === 'vorbei');
|
||
}
|
||
} else if (bereich === 'anschlag') {
|
||
/* =================================================================
|
||
DAS ANSCHLAGBRETT: WAS GERADE HAENGT, UND WAS ABGEHAENGT IST
|
||
=================================================================
|
||
|
||
Filipe: „die sachen sollen in der seite vom anschlagbrett
|
||
immer automatisch nach 24h von da verschwinden. weil alles
|
||
hat seine kategorie und was nicht ist auch nicht um ewig auf
|
||
der seite zu bleiben fertig."
|
||
|
||
WER ENTSCHEIDET, IST DER SERVER. `e.abgehaengt` kommt mit der
|
||
Antwort; hier wird nur einsortiert. Die Stunden im Browser
|
||
nachzurechnen waere eine zweite Fassung derselben Regel --
|
||
und eine davon waere irgendwann aelter als die andere.
|
||
|
||
ABGEHAENGT HEISST ZUGEKLAPPT, NICHT GELOESCHT. Er sagt „von
|
||
DA verschwinden": von der Seite. Wer eine Ansage geschrieben
|
||
hat, soll sie wiederfinden; sie rueckwirkend verschwinden zu
|
||
lassen waere etwas anderes als sie abzuhaengen. Die Zeile
|
||
darunter ist leise und zu -- auf dem Brett selbst steht sie
|
||
damit nicht mehr.
|
||
================================================================= */
|
||
const haengt = sichtbar.filter((e) => !e.abgehaengt);
|
||
const ab = sichtbar.filter((e) => e.abgehaengt);
|
||
|
||
for (const e of haengt) {
|
||
const k = karte(e);
|
||
karten.set(e.id, k);
|
||
ziel.append(k);
|
||
}
|
||
/* NICHTS HAENGT MEHR, ABER ES GAB ETWAS: Dann steht der
|
||
Leersatz des Bretts nicht da (die Liste ist ja nicht leer),
|
||
und man saehe eine Seite, auf der nichts ist und nichts
|
||
erklaert wird. */
|
||
if (!haengt.length && ab.length) {
|
||
ziel.append(el('p', 'leer-hinweis',
|
||
'Gerade hängt nichts. Was hier stand, ist abgehängt – es gilt nicht mehr.'));
|
||
}
|
||
|
||
if (ab.length) {
|
||
const kopf = el('h2', 'anschlag-alt__kopf');
|
||
kopf.append(el('span', 'anschlag-alt__wort', 'Abgehängt'));
|
||
kopf.append(el('span', 'anschlag-alt__zahl', String(ab.length)));
|
||
const kasten = el('div', 'anschlag-alt');
|
||
for (const e of ab) {
|
||
const k = karte(e);
|
||
k.dataset.abgehaengt = 'ja';
|
||
karten.set(e.id, k);
|
||
kasten.append(k);
|
||
}
|
||
ziel.append(kopf);
|
||
ziel.append(kasten);
|
||
/* ZU, SOLANGE NIEMAND SIE AUFMACHT -- derselbe Klappmechanismus
|
||
wie bei „Was ansteht". Ein eigener waere ein zweiter, der
|
||
sich anders verhaelt. */
|
||
window.abschnittKlappbar?.(kopf, kasten, 'anschlag:alt', true);
|
||
}
|
||
} 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';
|
||
/* WAS DAS DATUM BEWIRKT, STEHT DARUNTER (17.09.2026).
|
||
|
||
Das Feld ist mit HEUTE vorbelegt und heisst schlicht „Datum".
|
||
Damit war die Kalenderfunktion zwar vorhanden, aber unauffindbar:
|
||
Niemand konnte wissen, dass ein Tag in der Zukunft den Zettel in
|
||
den Kalender bringt. Eine Funktion, die niemand findet, ist
|
||
keine.
|
||
|
||
Der Satz aendert sich MIT DER EINGABE, statt allgemein zu
|
||
bleiben -- ein fester Hinweis („kann im Kalender erscheinen")
|
||
beantwortet die Frage nicht, die man gerade hat: „Steht der
|
||
jetzt drin oder nicht?" */
|
||
kalenderhinweisSetzen();
|
||
$('schild-titel').textContent = event ? 'Titel des Events' : 'Überschrift';
|
||
/* textContent wuerde das <span class="leise"> 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) starthilfeZeigen();
|
||
if (auf) $('f-titel').focus();
|
||
};
|
||
$('neu-oeffnen').addEventListener('click', () => {
|
||
$('f-datum').value = window.heuteLokal();
|
||
kalenderhinweisSetzen();
|
||
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 <select> -- ohne dieses Ereignis merkte das
|
||
Formular vom Artwechsel nichts. */
|
||
$('f-art').addEventListener('change', formularAnpassen);
|
||
/* DER HINWEIS ZIEHT MIT. Ohne diese beiden Zeilen stuende er auf dem
|
||
Stand vom Oeffnen des Formulars -- also dauerhaft auf „Heute", auch
|
||
nachdem jemand den 24. eingetragen hat. Ein Hinweis, der beim Tippen
|
||
stehenbleibt, sagt nach dem ersten Tastendruck etwas Falsches, und
|
||
das ist schlechter als gar keiner. */
|
||
$('f-datum')?.addEventListener('change', kalenderhinweisSetzen);
|
||
$('f-datum')?.addEventListener('input', kalenderhinweisSetzen);
|
||
$('f-uhrzeit')?.addEventListener('change', kalenderhinweisSetzen);
|
||
$('f-uhrzeit')?.addEventListener('input', kalenderhinweisSetzen);
|
||
|
||
$('neu').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
$('neu-fehler').textContent = '';
|
||
const knopf = $('neu-speichern');
|
||
const daten = {
|
||
art: $('f-art').value,
|
||
titel: $('f-titel').value,
|
||
text: $('f-text').value,
|
||
datum: $('f-datum').value,
|
||
};
|
||
if ($('feld-uhrzeit') && !$('feld-uhrzeit').hidden) {
|
||
daten.uhrzeit = $('f-uhrzeit').value || '';
|
||
}
|
||
const event = istEventArt(daten.art);
|
||
if (einstellung.dringlichkeit && !event) daten.dringlichkeit = $('f-dringlich').value;
|
||
if (einstellung.vertraulich) daten.nur_leitung = $('f-vertraulich').checked ? 1 : 0;
|
||
/* Verborgen heisst auch: nichts mitschicken. Ein Wert, der in einem
|
||
unsichtbaren Feld stehenbleibt, wandert sonst beim naechsten
|
||
Speichern mit -- und niemand sieht, woher er kommt. */
|
||
if (einstellung.einsatzFeld && $('f-einsatz')) daten.einsatz = $('f-einsatz').value.trim();
|
||
/* Nur, wenn das Feld auch sichtbar ist -- ein Wert aus einem
|
||
verborgenen Feld wandert sonst beim naechsten Speichern mit, und
|
||
niemand sieht, woher er kommt. Dieselbe Regel wie eine Zeile
|
||
darueber. */
|
||
if ($('feld-kanal') && !$('feld-kanal').hidden) {
|
||
daten.quelle_kanal = $('f-kanal').value || null;
|
||
}
|
||
if (einstellung.bewertung) daten.bewertung = $('f-bewertung').value || null;
|
||
if (event) {
|
||
daten.event_ende = $('f-ende').value || null;
|
||
daten.event_aufgaben = $('f-aufgaben').value || null;
|
||
daten.event_regeln = $('f-regeln').value || null;
|
||
}
|
||
/* Im Agentur-Bereich gehoert ein Eintrag allen -- es gibt nichts
|
||
zuzuordnen, also wird auch nichts mitgeschickt. Der Server setzt
|
||
beides ohnehin auf leer (ohneCreatorBezug); hier zu schweigen
|
||
spart die stille Annahme, das Formular haette etwas zu sagen. */
|
||
if (LEITUNG.has(ich.rolle) && !einstellung.ohneCreatorBezug) {
|
||
daten.creator_id = $('f-creator').value || null;
|
||
daten.creator_extern = $('f-creator').dataset.extern || null;
|
||
}
|
||
|
||
knopf.disabled = true;
|
||
try {
|
||
const a = await hole(`/workspace/api/bereich/${bereich}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(daten),
|
||
});
|
||
if (!a.ok) {
|
||
$('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
|
||
return;
|
||
}
|
||
$('neu').reset(); umschalten(false);
|
||
await laden();
|
||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||
});
|
||
|
||
/* ---------- Start -------------------------------------------------------- */
|
||
|
||
(async () => {
|
||
bereich = new URLSearchParams(location.search).get('b') || '';
|
||
/* WELCHES BRETT DAS IST, STEHT AM KOERPER (22.09.2026).
|
||
|
||
Die Stilvorlage braucht es, um dem Anschlagbrett eine eigene
|
||
Sprache zu geben. Sie darueber zu setzen statt ueber eine
|
||
zusaetzliche Klasse an jeder Karte heisst: EINE Stelle, und sie
|
||
stimmt auch fuer alles, was morgen dazukommt. */
|
||
if (/^[a-z]+$/.test(bereich)) document.body.dataset.bereich = bereich;
|
||
|
||
/* Wird eine Bewertung gesetzt, baut sich die Liste neu auf. Der
|
||
Zuhoerer haengt am Dokument und wird genau einmal gesetzt --
|
||
laden() laeuft mehrfach, und zwei Zuhoerer wuerden die Liste
|
||
zweimal neu bauen. */
|
||
document.addEventListener('ampel-geaendert', () => laden());
|
||
if (!/^[a-z]+$/.test(bereich)) { location.assign('/workspace/start.html'); return; }
|
||
|
||
try {
|
||
const a = await hole('/workspace/api/ich');
|
||
if (!a.ok) { location.assign('/workspace/'); return; }
|
||
ich = await a.json();
|
||
} catch {
|
||
/* NICHT MEHR STUMM (20.09.2026). Hier stand `catch { return; }`
|
||
-- und damit blieb die Seite bei jedem Netzaussetzer fuer
|
||
immer auf "wird geladen …" stehen. Gemessen auf 13 von 21
|
||
Seiten. `ladefehler()` sagt, was los ist, und bietet einen
|
||
Knopf an, der es noch einmal versucht. */
|
||
window.ladefehler?.();
|
||
return;
|
||
}
|
||
window.werZeigen?.(ich);
|
||
|
||
let daten;
|
||
try {
|
||
const a = await hole(`/workspace/api/bereich/${bereich}`);
|
||
if (!a.ok) { location.assign('/workspace/start.html'); return; }
|
||
daten = await a.json();
|
||
} catch { return; }
|
||
|
||
einstellung = daten.einstellung;
|
||
kanaeleDa = Array.isArray(daten.kanaele) ? daten.kanaele : [];
|
||
document.title = einstellung.name + ' · Creator Workspace';
|
||
$('marke-bereich').textContent = einstellung.name;
|
||
|
||
/* ---- DIE SEITE TRÄGT DIE FARBE IHRER KACHEL (19.09.2026) -------
|
||
|
||
Filipe: „die kacheln sollen auch in jeder seite die farben der
|
||
kategorie haben. ich will dass alles perfekt angepasst ist."
|
||
|
||
Vorher war jede Bereichsseite gleich blau, egal aus welcher
|
||
Kachel man kam. Die Kachel hat einen Ton, die Seite dahinter
|
||
nicht — beim Klick verlor man die einzige Farbe, an der man sich
|
||
orientieren konnte.
|
||
|
||
KEINE DRITTE LISTE. Der Ton steht bereits an zwei Stellen, und
|
||
der Browser hat beide vorliegen:
|
||
`ich.bereiche` die Kacheln vom Server (Treff, Modis)
|
||
`window.Bereiche.GRUPPEN` die Kacheln der Agentur
|
||
Hier wird nur gesucht, nicht noch einmal aufgeschrieben — eine
|
||
eigene Zuordnung wäre die, die beim nächsten neuen Brett
|
||
vergessen wird.
|
||
|
||
Gefunden wird über das ZIEL, nicht über den Namen: Der Name
|
||
ändert sich (heute erst wieder von „Entwicklung" zu „Eure
|
||
Aufgaben"), das Ziel bleibt. */
|
||
tonSetzen(bereich);
|
||
$('titel').textContent = einstellung.name;
|
||
/* Das Wort ueber dem Titel. Es steht fest in bereich.html
|
||
("Betreuung") und stimmte fuer jede Akte -- nicht aber fuer eine
|
||
Sammelstelle, die niemandem gehoert.
|
||
|
||
SEIT DEM 20.09.2026 SCHICKT DER SERVER SIE IMMER: entweder das
|
||
eigene `ober` des Bretts oder, wenn es keines hat, die GRUPPE
|
||
der Kachel, ueber die dieser Mensch hergekommen ist. Sechs
|
||
Bretter hatten keines -- ueber dreien davon stand deshalb
|
||
dauerhaft „Betreuung", auch fuer einen Modi, der niemanden
|
||
betreut.
|
||
|
||
Bleibt sie leer, verschwindet die Zeile ganz. Eine leere Zeile,
|
||
die Platz einnimmt, sieht aus wie ein Fehler. */
|
||
const oberZeile = $('ober');
|
||
if (oberZeile) {
|
||
oberZeile.textContent = einstellung.ober || '';
|
||
oberZeile.hidden = !einstellung.ober;
|
||
|
||
/* WER ENTSCHEIDET (21.09.2026).
|
||
|
||
Steht auf jedem Brett, für das es Vorschläge gibt — und damit
|
||
genau dort, wo Fragen wie „Was soll DogFather im nächsten
|
||
Stream unbedingt machen?" veröffentlicht werden.
|
||
|
||
WARUM DAS NICHT NUR HÖFLICH IST: Wer so gefragt wird, schreibt
|
||
etwas hin und rechnet damit. Passiert es dann nicht, sieht es
|
||
aus wie ein gebrochenes Versprechen — obwohl nie eines gegeben
|
||
wurde. Der Satz verhindert die Enttäuschung vorher, statt sie
|
||
hinterher zu erklären.
|
||
|
||
Er kommt vom Server, aus derselben Quelle wie der Satz über
|
||
den Vorschlagskarten. Zwei Fassungen desselben Satzes wären
|
||
eine zu viel. */
|
||
const entscheidZeile = document.getElementById('brett-entscheidung');
|
||
if (entscheidZeile) {
|
||
entscheidZeile.textContent = einstellung.entscheidung || '';
|
||
entscheidZeile.hidden = !einstellung.entscheidung;
|
||
}
|
||
}
|
||
$('unterzeile').textContent = UNTERZEILE[bereich] || '';
|
||
|
||
/* VOR dem Aufbau des Formulars. Danach wäre zu spät: Der Knopf
|
||
stünde dann kurz da und verschwände wieder -- sichtbar, und
|
||
ausgerechnet an der Stelle, an der jemand gerade überlegt, ob er
|
||
schreiben darf. */
|
||
treffBrett = !!daten.treff;
|
||
kalenderOffen = daten.kalender_offen !== false;
|
||
await lageHolen();
|
||
|
||
/* DER WEG ZU DEN REGELN STEHT AUF JEDEM BRETT DES TREFFS
|
||
(11.09.2026).
|
||
|
||
Nicht nur auf dem Brett "Regeln & Hilfe". Wer die Regeln braucht,
|
||
braucht sie in dem Moment, in dem er etwas schreiben will -- und
|
||
das ist fast nie dort. Ein Mensch, der erst zurueck zur
|
||
Startseite und dann auf die richtige Kachel muss, liest sie
|
||
nicht; er schreibt einfach.
|
||
|
||
ALS LINK UND NICHT ALS EINGEBLENDETER TEXT: Die Regeln sind zu
|
||
lang, um sie ueber jedes Brett zu haengen, und ein Kasten, den
|
||
man jeden Tag wegklickt, wird nach drei Tagen nicht mehr
|
||
gesehen. */
|
||
if (daten.treff) {
|
||
const vorhanden = $('treff-regelweg');
|
||
if (!vorhanden) {
|
||
/* „Die Regeln" und nicht „Regeln & Hilfe": So heisst auch das
|
||
BRETT, auf dem dieser Link steht -- und ein Link, der den
|
||
Namen der Seite traegt, auf der man schon ist, sieht aus wie
|
||
ein Fehler. Seit dem 18.09.2026 heisst die Seite dahinter
|
||
„Die Regeln"; hier steht derselbe Name. */
|
||
const a = el('a', 'treff-regelweg', 'Die Regeln');
|
||
a.id = 'treff-regelweg';
|
||
a.href = 'treff-regeln.html';
|
||
$('unterzeile').after(a);
|
||
}
|
||
}
|
||
|
||
/* Fuer den Creator verschwindet alles Schreibende -- und die
|
||
Unterzeile sagt, warum. Ein leerer Bereich ohne Erklaerung wirkt
|
||
kaputt; einer mit Erklaerung wirkt gedacht. */
|
||
if (!darfEintragen()) {
|
||
$('neu-oeffnen').hidden = true;
|
||
$('neu').hidden = true;
|
||
/* Zwei verschiedene Saetze, weil es zwei verschiedene Wahrheiten
|
||
sind. In den Betreuungsbereichen sieht ein Creator, was ZU IHM
|
||
gehoert -- in der Agentur sieht er ALLES. Stuende hier weiter
|
||
"was zu dir gehört", laese sich eine vollstaendige Liste wie
|
||
ein Ausschnitt, und die naheliegende Frage waere: Was sehe ich
|
||
hier eigentlich nicht? */
|
||
/* IM TREFF STIMMT KEINER DER BEIDEN SÄTZE. Dort geht es weder um
|
||
die Agentur noch um Betreuung, sondern entweder um ein Brett,
|
||
das dem Team gehört, oder um eine Stufe, die noch nicht
|
||
erreicht ist. Und der Server weiß beides genau -- er schickt
|
||
den Satz mit, statt dass der Browser ihn errät. */
|
||
$('unterzeile').textContent = (UNTERZEILE[bereich] || '')
|
||
+ (treffBrett
|
||
? ' ' + (lage?.warum?.[bereich]
|
||
|| 'Hier schreibt das Team – lesen kannst du alles.')
|
||
: einstellung.fuerAlle
|
||
? ' Eingetragen wird von der Agentur – du siehst alles, was hier steht.'
|
||
: ' Hier trägt deine Betreuung ein – du siehst alles, was zu dir gehört.');
|
||
}
|
||
|
||
for (const [wert, name] of Object.entries(einstellung.arten)) {
|
||
const o = document.createElement('option');
|
||
o.value = wert; o.textContent = name;
|
||
$('f-art').append(o);
|
||
}
|
||
$('feld-dringlich').hidden = !einstellung.dringlichkeit;
|
||
$('feld-vertraulich').hidden = !einstellung.vertraulich;
|
||
/* =================================================================
|
||
ZWEI ZAHLEN, ANDERE NAMEN (10.09.2026)
|
||
|
||
Bei den Angeboten heisst `bewertung` NUTZEN und `dringlichkeit`
|
||
AUFWAND. Die Felder gibt es laengst -- sie mit neuen Namen zu
|
||
belegen ist besser, als zwei weitere Spalten anzulegen, die
|
||
dasselbe koennen. Aber ein Feld, ueber dem "Dringlichkeit" steht
|
||
und das den Aufwand meint, ist falsch beschriftet, und das faellt
|
||
beim Ausfuellen niemandem auf -- erst beim Lesen.
|
||
|
||
Die Namen kommen aus den Bereichsangaben; steht dort keiner,
|
||
bleibt es wie bisher. */
|
||
if (einstellung.dringlichkeitName && $('dringlich-schild')) {
|
||
$('dringlich-schild').textContent = einstellung.dringlichkeitName;
|
||
}
|
||
if (einstellung.bewertungName && $('bewertung-schild')) {
|
||
$('bewertung-schild').textContent = `${einstellung.bewertungName} (1–5)`;
|
||
}
|
||
/* ---- ZU WELCHEM ACCOUNT? (20.09.2026) -------------------------
|
||
|
||
Filipe: "und wenn man was hinzufuegt soll man auch den account
|
||
auswaehlen koennen."
|
||
|
||
Nur auf den Highlights und nur, wenn der Server die Kanaele
|
||
ueberhaupt mitgeschickt hat. Eine leere Auswahlliste waere eine
|
||
Frage ohne moegliche Antwort.
|
||
|
||
DIE LISTE KOMMT VOM SERVER, nicht aus KANAL_WORT hier. Beim
|
||
vierten Account ist die Kopie hier die vergessene -- das steht
|
||
so schon am Kanal-Begriff selbst. */
|
||
const kanalFeld = $('feld-kanal'), kanalWahl = $('f-kanal');
|
||
if (kanalFeld && kanalWahl) {
|
||
const liste = kanaeleDa;
|
||
kanalFeld.hidden = !(bereich === 'highlight' && liste.length);
|
||
if (!kanalFeld.hidden && kanalWahl.dataset.stand !== String(liste.length)) {
|
||
kanalWahl.dataset.stand = String(liste.length);
|
||
kanalWahl.textContent = '';
|
||
/* LEER ZUERST UND ALS VORGABE. Ein Pflichtfeld erzwaenge hier
|
||
einen falschen Kanal, und ein falscher Eintrag ist
|
||
schlechter als ein leerer. */
|
||
const leer = document.createElement('option');
|
||
leer.value = ''; leer.textContent = 'Noch offen';
|
||
kanalWahl.append(leer);
|
||
for (const k of liste) {
|
||
const o = document.createElement('option');
|
||
o.value = k.wert;
|
||
o.textContent = k.handle ? `${k.name} (${k.handle})` : k.name;
|
||
kanalWahl.append(o);
|
||
}
|
||
}
|
||
if ($('hinweis-kanal')) {
|
||
$('hinweis-kanal').textContent = 'Bei einem TikTok-Link erkennt die '
|
||
+ 'Seite den Account selbst – das hier ist für Bilder und Momente ohne Link.';
|
||
}
|
||
}
|
||
|
||
/* Das Einsatz-Feld nur, wo der Bereich es kennt. */
|
||
if ($('feld-einsatz')) $('feld-einsatz').hidden = !einstellung.einsatzFeld;
|
||
if (einstellung.einsatzName && $('einsatz-schild')) {
|
||
$('einsatz-schild').textContent = einstellung.einsatzName;
|
||
}
|
||
$('feld-bewertung').hidden = !einstellung.bewertung;
|
||
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
$('feld-creator').hidden = false;
|
||
|
||
/* AGENTUR: EINE EINZIGE WAHL, UND DIE HEISST "AGENTUR" (07.09.2026).
|
||
|
||
Hier stand dieselbe Creator-Liste wie ueberall -- und sie war
|
||
eine Falle. Wer ein Event fuer alle anlegt und dabei aus
|
||
Gewohnheit einen Namen anklickt, hat es damit UNSICHTBAR
|
||
gemacht: fuer alle anderen Creator, ohne Warnung, ohne dass es
|
||
der Liste danach anzusehen waere.
|
||
|
||
Kein Feld auszublenden waere die halbe Loesung gewesen: Dann
|
||
fragt man sich, wem der Eintrag gehoert. Eine Liste mit genau
|
||
einer Wahl beantwortet die Frage, statt sie wegzunehmen.
|
||
|
||
Die Personenwahl (freier Name, Suchfeld) wird hier bewusst
|
||
NICHT aufgesetzt -- sie erlaubt getippte Namen, und genau das
|
||
soll es hier nicht geben. */
|
||
if (einstellung.ohneCreatorBezug) {
|
||
/* DAS WORT KOMMT VOM SERVER (11.09.2026).
|
||
|
||
Hier stand fest "Agentur" -- richtig auf der Agenturadresse,
|
||
falsch auf jeder anderen. Filipe, mit Bildschirmfoto: "auf
|
||
dieser seite soll nichts stehen von agentur, diese seite hier
|
||
ist nur für mich meine modis und meine community."
|
||
|
||
Der Server weiss, welches Brett es ist und in welchem Haus
|
||
die Person steht; er schickt das Wort mit (daten.gehoert).
|
||
Faellt die Angabe aus, bleibt das Feld LEER statt falsch --
|
||
ein fehlendes Wort sieht man, ein falsches nicht. */
|
||
const gehoert = daten?.gehoert || '';
|
||
$('schild-creator').textContent = 'Gilt für';
|
||
$('f-creator').textContent = '';
|
||
const o = document.createElement('option');
|
||
o.value = ''; o.textContent = gehoert || '—';
|
||
$('f-creator').append(o);
|
||
const hinweis = $('hinweis-creator');
|
||
hinweis.textContent = gehoert
|
||
? `Gehört allen hier – wer ${gehoert} sieht, sieht auch diesen Eintrag.`
|
||
: '';
|
||
hinweis.hidden = !gehoert;
|
||
} else {
|
||
try {
|
||
const { personen } = await (await hole('/workspace/api/personen')).json();
|
||
for (const p of personen.filter((p) => p.rolle === 'creator')) {
|
||
const o = document.createElement('option');
|
||
o.value = String(p.id); o.textContent = p.name;
|
||
/* Bild ans <option> -- wahl.js zeichnet daraus das Gesicht. */
|
||
o.dataset.person = 'ja';
|
||
if (p.bild) o.dataset.bild = p.bild;
|
||
$('f-creator').append(o);
|
||
}
|
||
} catch { /* Zuordnung bleibt leer */ }
|
||
window.personenwahl?.('f-creator', { hinweis: "Kein Konto im Workspace – der Eintrag gehört dann zu keiner Person und taucht in personenbezogenen Auswertungen nicht auf. Sichtbar bleibt er für dich." });
|
||
}
|
||
}
|
||
formularAnpassen();
|
||
|
||
/* Kommt jemand vom Bericht mit "nur die offenen", wird der Filter
|
||
VOR dem ersten Laden gesetzt -- sonst baut die Seite alles auf,
|
||
um es gleich wieder wegzuwerfen, und das flackert sichtbar. */
|
||
if (window.zielSchluessel?.() === 'offen') filter = 'offen';
|
||
|
||
await laden();
|
||
|
||
/* DIE LEISTE FUER DIE TEXTGESTALTUNG (21.09.2026). Nach `laden()`,
|
||
weil das Formular davor noch nicht zusammengesetzt ist -- die
|
||
Felder haengen am Bereich, und der steht erst danach fest. */
|
||
if (window.Textform) {
|
||
for (const id of ['f-text', 'f-aufgaben', 'f-regeln']) {
|
||
const feld = document.getElementById(id);
|
||
if (feld) window.Textform.leiste(feld);
|
||
}
|
||
}
|
||
})();
|
||
})();
|