Siebter Durchgang durch die Team-Dogi-Seiten aus Benutzersicht. Alles am
Code belegt, die Groessenangaben nachgerechnet.
=== DIE ANMELDUNG ===
WOHER BEKOMMT MAN EINEN CODE? Stand nirgends. Vier Kacheln, ein Feld,
ein Knopf -- wer keinen Code hatte, fand keinen einzigen Satz dazu. Fuer
die Community besonders teuer: Sie ist die einzige Rolle, die von
aussen kommt und niemanden im Haus kennt.
"BITTE KURZ WARTEN" SIND BIS ZU ZEHN MINUTEN (VERSUCHE_FENSTER_MIN).
Wer nach zwanzig Sekunden nachsieht und dieselbe Meldung liest, haelt
die Seite fuer kaputt. Jetzt steht die Zahl da.
EIN RICHTIGER CODE AN DER FALSCHEN KACHEL fiel in dieselbe Absage wie
ein erfundener -- der Server sucht nur unter der gewaehlten Rolle. Wer
aus der Community kommt und auf "Modi" tippt (das klingt ja danach),
tippt seinen richtigen Code dreimal Buchstabe fuer Buchstabe. Ab dem
zweiten Fehlversuch kommt jetzt ein Hinweis auf die Auswahl -- ohne zu
sagen, welche die richtige waere.
=== DER ANRUF: VIER FEHLER, EINE ERFAHRUNG ===
DAS FREIZEICHEN LIEF UNENDLICH. Es gab keinen Zeitgeber, der aufhoert.
Wer niemanden erreichte, sass vor einem piependen Kasten mit "02:47"
darauf, als telefoniere er laengst. Der Server schickt den Verfallszeit-
punkt sogar mit (`laeuft_bis`) -- benutzt hat ihn nie jemand.
DER KLINGELKASTEN VERSCHWAND NACH 46 SEKUNDEN, waehrend der Server 120
gibt. Wer beim Klingeln das Handy aus der Tasche holt und in Sekunde 50
hinsieht, fand nichts mehr -- kein Kasten, keine Knoepfe -- waehrend der
Anrufer noch siebzig Sekunden Freizeichen hoerte. Die 120 Sekunden sind
im Server ausdruecklich damit begruendet, dass man Zeit zum Entsperren
braucht; eine Zeile im Browser hat diese Entscheidung aufgehoben.
DAS FREIZEICHEN PIEPTE WEITER, waehrend daneben "Keine Verbindung."
stand. Ton sagte "es klingelt noch", Text sagte "vorbei".
DIE UHR ZAEHLTE AB DEM WAEHLEN. Wer 30 Sekunden klingeln liess und 12
Sekunden sprach, las im Kasten "00:42" und danach im Chat "Anruf . 12
Sekunden". Der Server rechnet es richtig; der Kasten hat es wieder
kaputtgemacht.
Dazu: Die Mikrofon-Absage verwies auf "das Schloss-Symbol neben der
Adresse" -- in der installierten App gibt es weder Adresszeile noch
Schloss. Das Haus kennt diesen Fehler und hat ihn beim Weg zur
Anruf-Probe schon behoben; hier stand er noch.
Und die Anruf-Probe: Ihr einziger Punkt ohne Handlungsanweisung war
"Antwort 503." -- auf einer Seite, die ausdruecklich fuer jemanden
gebaut ist, der nicht technisch ist. Ihr Rueckweg fuehrte ausserdem
immer in den Chat, auch wenn man von der Startseite kam.
=== DER TREFF ===
EIN EINZEILER MACHTE SECHS ERKLAERTEXTE UNSICHTBAR. bewerben.js las
`g.unter`, der Server schickt `g.text` -- also immer undefined. Damit
fehlten ALLE SECHS Gruppeneinleitungen des Fragebogens ("Kein Test mit
richtigen Antworten"), und die CSS-Regel dafuer lief ins Leere. Uebrig
blieben sechs nackte Ueberschriften ueber fuenfzehn Fragen.
DIE EINZIGE "SO LAEUFT DAS HIER"-SEITE WAR FAST UNERREICHBAR. Die
Kachel "Regeln & Hilfe" zeigte auf ein Brett, das am ersten Tag leer
ist. Die Regeln, die es wirklich gibt (Begruessung, fuenf Regeln, drei
Stufen, Mindestalter), stehen auf treff-regeln.html -- erreichbar ueber
genau einen kleinen Textlink auf einer Brettseite. Die Kachel zeigt
jetzt dorthin.
FIEL /api/treff/lage AUS, versprach die Seite einem Zuschauer
Schreibrechte, die der Server ablehnt: Die Pruefung fiel bei `lage ===
null` auf `true` durch -- von "darf auf keinem Brett" auf "darf
ueberall". Formular auf, getippt, 403.
Dazu: hilfe.js hatte eine eigene kleine Fehlertabelle fuer zwei
Kennungen und endete sonst bei "Ging nicht." -- ausgerechnet die Seite,
auf der jemand mit einem Problem sitzt, hatte die inhaltsleerste Absage
im Haus. Und der gesperrte "Neue Meldung"-Knopf erklaerte sich nur im
Maus-Tooltip; die Community kommt von TikTok, also vom Handy.
=== DIE STARTSEITE ===
DIE COMMUNITY BEKAM EIN WORT. Unter dem Titel steht bei jeder Rolle ein
Satz -- beim Community-Mitglied stand "Community". Woertlich derselbe
Mangel, den der Kommentar bei MODI_ROLLENTEXT beschreibt und fuer den
Modi behebt.
DER RING MELDETE 100 %, bevor man irgendetwas getan hat. Am ersten Tag,
ohne eine einzige Aufgabe, stand das groesste Element der Seite auf
"100 % -- HEUTE NICHTS OFFEN". Gelesen wird zuerst die Zahl, und 100 %
heisst fuer jeden "fertig", nicht "leer".
UND BEI EINER STOERUNG BLIEB "wird geladen" FUER IMMER STEHEN -- der
fruehe `return` liess den Platzhalter unberuehrt.
=== DIE ZUGANGSVERWALTUNG ===
Der Code-Kasten sagte "jetzt weitergeben" und gab die Haelfte nicht
mit, die man weitergeben muss: WO sich die Person anmeldet. DogFather
ruft den Code durchs Zimmer, der andere fragt "und wo?". Die Adresse
kommt jetzt vom Server (anmeldeAdresseFuer, abgeleitet aus derselben
Weiche), dazu ein Knopf "Code und Adresse kopieren".
Er verschwieg ausserdem den Rettungsweg: "danach nicht mehr abrufbar"
stimmt fuer DIESEN Code -- man kann jederzeit einen neuen vergeben. Wer
das nicht weiss, glaubt, er habe einen Menschen angelegt, der nie
hereinkommt. Und das Fenster liess sich wortlos schliessen, waehrend
der Code offen stand.
=== OPTIK: NACHGERECHNET, NICHT VERMUTET ===
DIE GLOCKE STAND NICHT IN DER LISTE. Sie ist ein echtes <button> und
wurde von der 44-px-Regel erfasst, waehrend ihre vier Nachbarn durch
einen staerkeren Selektor auf 36 px gehen. Zwischen 401 und 560 px --
also bei 412 px (haeufigste Android-Breite) und 430 px (iPhone Pro Max)
-- stand eine 44 px hohe runde Pille zwischen fuenf 36-px-Quadraten.
Der 400er-Block listet sie korrekt; genau dieses Band fiel durch beide
Rechnungen. Dasselbe Muster wie am 06.09.
DIE 9,6-px-REPARATUR IM KALENDER WAR SEIT WOCHEN WIRKUNGSLOS.
kalender.css wird NACH start.css geladen, beide Selektoren sind gleich
stark -- also gewann `.6rem` auf jeder Breite unter 760 px. Genau der
Wert, den start.css als behobenen Fehler protokolliert.
Und die Hebung selbst unterschritt ihre eigene Grenze: Die Regel, die
zu kleine Schrift hochziehen soll, zog `.k-pille` auf 11,2 px -- 0,3 px
unter die 11,5, die 33 Zeilen darueber aufgestellt werden.
Weitere nachgerechnete Groessen: Spaltenkopf und KW-Spalte im Kalender
(10,56 px), Kalenderwoche am Balken (9,92 px), Wecker-Knopf (30 px --
der kleinste im Haus, 14 unter der Vorgabe), Chat-Zurueck auf dem
Tablet (34x34, 40 % weniger Flaeche als noetig, und der einzige Weg
zurueck in die Liste), Emoji-Knoepfe (34 px bei 2 px Abstand -- wer
danebentippt, verschickt ein anderes Zeichen, und das ist sofort
abgeschickt).
IM WINDOWS-KONTRASTMODUS HATTE DIE STARTSEITE KEINE UEBERSCHRIFT.
`.ztitel` und der Team-Dogi-Schriftzug sind mit `background-clip: text`
gesetzt; faellt der Verlauf weg, bleibt durchsichtiger Text. Beide
haben jetzt den Rueckfall, den gate.css schon lange hat.
UND MEIN EIGENES STREIFENRASTER rechnete die Spaltenzahl aus der Breite
statt aus den Daten (`auto-fit`): Bei 390 px passten sechs, alles
darueber fiel in eine zweite, unbeschriftete Zeile. Das `overflow-x`
daneben konnte nie greifen -- ein auto-fit-Raster wird nie breiter als
sein Kasten. Der Kommentar beschrieb ein Verhalten, das es nicht gab.
GEMESSEN: pruef-meldungen 8/0, pruef-css-klassen ALLES IN ORDNUNG,
pruef-rechtetafel 19/0, alle Module laden, keine doppelten
Kachelnamen/-ziele/-farben in allen vier Rollen.
Co-Authored-By: Claude Opus 5 <[email protected]>
2344 lines
105 KiB
JavaScript
2344 lines
105 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: 'Treff',
|
||
wunsch: 'Wunschliste', highlight: 'Highlights', regeln: 'Regeln',
|
||
mitmachen: 'Mitmachen',
|
||
};
|
||
|
||
/* WELCHER WEG VON WO AUS SINN ERGIBT. Bewusst wenige: Ein Menü mit
|
||
sieben Zielen an jeder Karte waere ein Menü, kein Angebot. Was
|
||
hier steht, ist der Weg, den eine Sache im echten Betrieb nimmt. */
|
||
const KETTE_ZIELE = {
|
||
wunsch: [
|
||
{ nach: 'ansteht', wort: 'Daraus einen Termin machen' },
|
||
{ nach: 'highlight', wort: 'Daraus ein Highlight machen' },
|
||
],
|
||
ansteht: [{ nach: 'highlight', wort: 'Daraus ein Highlight machen' }],
|
||
treff: [{ nach: 'wunsch', wort: 'Daraus einen Wunsch machen' }],
|
||
highlight: [{ nach: 'anschlag', wort: 'Am Anschlagbrett zeigen' }],
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
/* ---- 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;
|
||
/* 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 = '';
|
||
/* Nur beim ersten Laden springen -- sonst reisst es die Seite nach
|
||
jeder Bewertung erneut an dieselbe Stelle. */
|
||
let gesprungen = false;
|
||
|
||
const el = (tag, klasse, text) => {
|
||
const k = document.createElement(tag);
|
||
if (klasse) k.className = klasse;
|
||
if (text !== undefined) k.textContent = text;
|
||
return k;
|
||
};
|
||
const melde = (t) => { $('fehler').textContent = t || ''; };
|
||
const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : '—');
|
||
|
||
/* ---------- Agentur-Events ---------------------------------------------
|
||
Ein Event ist kein Eintrag mit Ueberschrift und Text, sondern eine
|
||
Aktion mit Zeitraum, Aufgaben und Regeln. Welche Art das ist, sagt
|
||
der Server (die Arten kommen aus workspace-bereiche.js) -- die
|
||
Kennung steht hier trotzdem woertlich, weil das Formular je Art
|
||
anders aussieht und es dafuer keine allgemeine Regel gibt. Sie
|
||
steht an EINER Stelle, damit sie sich nicht verteilt. */
|
||
const EVENT_BEREICH = 'agentur';
|
||
const EVENT_ART = 'kampagne';
|
||
const istEventArt = (art) => bereich === EVENT_BEREICH && art === EVENT_ART;
|
||
|
||
/* =====================================================================
|
||
"WANN IST DER NAECHSTE STREAM?" (17.09.2026)
|
||
|
||
Filipe wollte fuer den Community-Bereich "eine richtige
|
||
perfektion". Bei "Was ansteht" ist das keine Frage der Optik: Es
|
||
gibt genau EINE Frage, die ein Zuschauer hier hat, und sie stand
|
||
bisher in Zeile vier einer Liste.
|
||
|
||
BEIM BAUEN KAM EINE LUECKE HERAUS, die im Plan nicht stand: Ein
|
||
Eintrag hatte nur einen TAG, keine Uhrzeit -- die Pruefung im
|
||
Server lehnte eine Zeitangabe ausdruecklich ab. Ein Countdown "in
|
||
3 Std 12 Min" war damit gar nicht moeglich. Die Spalte `uhrzeit`
|
||
ist deshalb heute dazugekommen; sie ist OPTIONAL, denn viele
|
||
Termine haben keine Uhrzeit, und ein Pflichtfeld haette dafuer
|
||
eine erfundene erzwungen.
|
||
|
||
OHNE UHRZEIT ZAEHLT ER IN TAGEN. "Morgen" ist eine ehrliche
|
||
Antwort; "morgen um 00:00" waere eine erfundene. */
|
||
|
||
/* Der Zeitpunkt eines Eintrags als Millisekunden -- Ortszeit.
|
||
`new Date("2026-09-17T20:00:00")` OHNE Z ist Ortszeit, und genau
|
||
das ist gemeint: Der Stream faengt um 20 Uhr HIER an. */
|
||
function zeitpunktVon(e) {
|
||
const tag = String(e?.datum || '').slice(0, 10);
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(tag)) return null;
|
||
const zeit = /^\d{2}:\d{2}$/.test(String(e.uhrzeit || '')) ? e.uhrzeit : null;
|
||
const d = new Date(`${tag}T${zeit || '00:00'}:00`);
|
||
return Number.isNaN(d.getTime()) ? null : { ms: d.getTime(), tag, zeit };
|
||
}
|
||
|
||
/* Wie lange noch? Gibt Worte zurueck, keine Zahl -- "in 3 Std 12 Min"
|
||
liest sich in einem Zug, "0.134 Tage" nicht. */
|
||
function nochText(e, jetztMs = Date.now()) {
|
||
const z = zeitpunktVon(e);
|
||
if (!z) return null;
|
||
const heute = window.heuteLokal ? window.heuteLokal() : new Date().toISOString().slice(0, 10);
|
||
const tageHin = Math.round(
|
||
(new Date(`${z.tag}T00:00:00`).getTime() - new Date(`${heute}T00:00:00`).getTime()) / 86400000);
|
||
|
||
if (tageHin < 0) return { wann: 'vorbei', stufe: 'vorbei' };
|
||
/* MIT UHRZEIT UND HEUTE ODER MORGEN: in Stunden. Weiter entfernt
|
||
sagt eine Stundenzahl nichts mehr -- "in 73 Std" muss man erst
|
||
umrechnen, "in 3 Tagen" nicht. */
|
||
if (z.zeit && tageHin <= 1) {
|
||
const min = Math.round((z.ms - jetztMs) / 60000);
|
||
if (min < 0) return { wann: tageHin === 0 ? 'läuft' : 'vorbei', stufe: tageHin === 0 ? 'jetzt' : 'vorbei' };
|
||
if (min < 60) return { wann: `in ${min} Min`, stufe: 'jetzt' };
|
||
const std = Math.floor(min / 60);
|
||
const rest = min % 60;
|
||
return { wann: `in ${std} Std${rest ? ' ' + rest + ' Min' : ''}`, stufe: 'bald' };
|
||
}
|
||
if (tageHin === 0) return { wann: 'heute', stufe: 'jetzt' };
|
||
if (tageHin === 1) return { wann: 'morgen', stufe: 'bald' };
|
||
if (tageHin <= 7) return { wann: `in ${tageHin} Tagen`, stufe: 'woche' };
|
||
return { wann: datum(z.tag), stufe: 'spaeter' };
|
||
}
|
||
|
||
/* In welchen Abschnitt des Zeitstrahls gehoert der Eintrag? */
|
||
function abschnittVon(e) {
|
||
const n = nochText(e);
|
||
if (!n) return 'ohne';
|
||
if (n.stufe === 'vorbei') return 'vorbei';
|
||
if (n.stufe === 'jetzt' || n.stufe === 'bald') return 'heute';
|
||
if (n.stufe === 'woche') return 'woche';
|
||
return 'spaeter';
|
||
}
|
||
const ABSCHNITT_NAME = {
|
||
heute: 'Heute und morgen', woche: 'Diese Woche',
|
||
spaeter: 'Später', ohne: 'Ohne Datum', vorbei: 'Vorbei',
|
||
};
|
||
|
||
/* Der Kasten ganz oben. */
|
||
function countdownZeigen(eintraege) {
|
||
const ziel = $('countdown');
|
||
if (!ziel) return;
|
||
if (bereich !== 'ansteht') { ziel.hidden = true; return; }
|
||
ziel.hidden = false;
|
||
ziel.textContent = '';
|
||
|
||
const kommend = (eintraege || [])
|
||
.map((e) => ({ e, n: nochText(e), z: zeitpunktVon(e) }))
|
||
.filter((x) => x.n && x.n.stufe !== 'vorbei')
|
||
.sort((a, b) => a.z.ms - b.z.ms);
|
||
|
||
if (!kommend.length) {
|
||
/* EIN SATZ STATT EINER LEEREN FLAECHE. Ein Bereich, der nichts
|
||
sagt, wenn nichts da ist, sieht kaputt aus statt leer. */
|
||
ziel.dataset.leer = 'ja';
|
||
ziel.append(el('p', 'countdown__leer',
|
||
'Noch kein Termin eingetragen. Der nächste Stream steht hier, sobald er feststeht.'));
|
||
return;
|
||
}
|
||
delete ziel.dataset.leer;
|
||
const { e, n } = kommend[0];
|
||
ziel.dataset.stufe = n.stufe;
|
||
ziel.append(el('p', 'countdown__schild', 'Als Nächstes'));
|
||
ziel.append(el('p', 'countdown__wann', n.wann));
|
||
ziel.append(el('p', 'countdown__titel', e.titel || 'Termin'));
|
||
const wann = e.uhrzeit
|
||
? `${datum(String(e.datum).slice(0, 10))} um ${e.uhrzeit} Uhr`
|
||
: datum(String(e.datum).slice(0, 10));
|
||
ziel.append(el('p', 'countdown__datum', wann));
|
||
if (kommend.length > 1) {
|
||
ziel.append(el('p', 'countdown__weitere',
|
||
`${kommend.length - 1} weitere${kommend.length === 2 ? 'r Termin' : ' Termine'} danach`));
|
||
}
|
||
}
|
||
|
||
/* Laeuft das Event noch, kommt es erst, ist es vorbei?
|
||
Der Vergleich laeuft auf den ISO-Zeichenketten, nicht auf Date --
|
||
"2026-09-12" < "2026-09-30" ist als Text schon richtig, und ein
|
||
Date() haette hier eine Zeitzone erfunden, die niemand gemeint hat.
|
||
Ohne Ende gilt der Beginntag als der ganze Zeitraum. */
|
||
function eventStand(e) {
|
||
const heute = window.heuteLokal ? window.heuteLokal() : new Date().toISOString().slice(0, 10);
|
||
const von = (e.datum || '').slice(0, 10);
|
||
const bis = (e.event_ende || von).slice(0, 10);
|
||
if (!von) return null;
|
||
if (heute < von) return { schluessel: 'kommt', text: 'startet ' + datum(von) };
|
||
if (heute > bis) return { schluessel: 'vorbei', text: 'vorbei seit ' + datum(bis) };
|
||
return { schluessel: 'laeuft', text: von === bis ? 'heute' : 'läuft bis ' + datum(bis) };
|
||
}
|
||
|
||
async function hole(pfad, optionen = {}) {
|
||
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
|
||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||
if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('unbekannt'); }
|
||
return a;
|
||
}
|
||
|
||
function tuKnopf(text, tu) {
|
||
const b = el('button', 'schritt', text);
|
||
b.type = 'button';
|
||
b.addEventListener('click', async () => {
|
||
b.disabled = true; melde('');
|
||
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
|
||
});
|
||
return b;
|
||
}
|
||
|
||
/* ---------- Zeichnen --------------------------------------------------- */
|
||
|
||
/** 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;
|
||
|
||
/* 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 (!confirm(`„${e.titel}“ als Nachricht schicken?\n\n`
|
||
+ 'Die Person bekommt Art, Titel und Text im Chat – einmalig.')) return;
|
||
b.disabled = true;
|
||
const a = await hole(`/workspace/api/bereich/entwicklung/${e.id}/senden`,
|
||
{ method: 'POST', headers: { 'Content-Type': 'application/json' } });
|
||
if (!a.ok) {
|
||
melde(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'));
|
||
const a = el('a', 'kette__weg',
|
||
`${BRETT_WORT[e.aus.bereich] || e.aus.bereich}: ${e.aus.titel}`);
|
||
a.href = `/workspace/bereich.html?b=${encodeURIComponent(e.aus.bereich)}`;
|
||
z.append(a);
|
||
if (e.aus.von_name) z.append(el('span', 'kette__wer', '· von ' + e.aus.von_name));
|
||
k.append(z);
|
||
}
|
||
if (Array.isArray(e.wurde) && e.wurde.length) {
|
||
const z = el('p', 'kette kette--hin');
|
||
z.append(el('span', 'kette__wort', 'Daraus wurde'));
|
||
for (const w of e.wurde) {
|
||
const a = el('a', 'kette__weg',
|
||
`${BRETT_WORT[w.bereich] || w.bereich}: ${w.titel}`);
|
||
a.href = `/workspace/bereich.html?b=${encodeURIComponent(w.bereich)}`;
|
||
z.append(a);
|
||
}
|
||
k.append(z);
|
||
}
|
||
|
||
/* DER RANGBALKEN. Eine Wunschliste ist eine Rangfolge, keine Liste
|
||
-- und eine Rangfolge sieht man erst, wenn der Abstand sichtbar
|
||
ist. Die Zahl allein beantwortet "wie viele", der Balken "wie
|
||
viel mehr als die anderen". */
|
||
if (bereich === 'wunsch' && maxStimmen > 0) {
|
||
const n = Number(e.stimmen) || 0;
|
||
const balken = el('div', 'wunsch-rang');
|
||
const fuell = el('div', 'wunsch-rang__fuellung');
|
||
fuell.style.width = `${Math.round((n / maxStimmen) * 100)}%`;
|
||
if (n === maxStimmen && n > 0) balken.dataset.spitze = 'ja';
|
||
balken.append(fuell);
|
||
/* Fuer Vorleseprogramme eine SPRACHE, kein Balken. */
|
||
balken.setAttribute('role', 'img');
|
||
balken.setAttribute('aria-label',
|
||
n === 0 ? 'Noch niemand will das auch'
|
||
: `${n} ${n === 1 ? 'Person will' : 'Personen wollen'} das auch`
|
||
+ (n === maxStimmen ? ', das ist der meistgewünschte' : ''));
|
||
k.append(balken);
|
||
}
|
||
|
||
/* ---- Agentur-Event: Zeitraum, Aufgaben, Regeln -------------------
|
||
Die Reihenfolge ist nicht beliebig. Wer auf ein Event schaut,
|
||
fragt in genau dieser Folge: Laeuft das ueberhaupt noch? Was
|
||
muss ich tun? Was gilt dabei? Steht der Zeitraum unten im Fuss
|
||
-- da, wo bei allen anderen Karten das Datum steht --, liest man
|
||
erst die Aufgaben und danach, dass es seit einer Woche vorbei
|
||
ist. */
|
||
const stand = istEventArt(e.art) ? eventStand(e) : null;
|
||
if (stand) {
|
||
k.dataset.event = stand.schluessel;
|
||
const zeile = el('p', 'event-zeitraum');
|
||
zeile.dataset.stand = stand.schluessel;
|
||
zeile.append(el('span', 'event-zeitraum__marke', stand.text));
|
||
zeile.append(el('span', 'event-zeitraum__spanne',
|
||
e.event_ende && e.event_ende !== e.datum
|
||
? `${datum(e.datum)} – ${datum(e.event_ende)}`
|
||
: datum(e.datum)));
|
||
k.append(zeile);
|
||
}
|
||
|
||
if (e.text) {
|
||
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 grund = prompt('Was ist daran nicht in Ordnung?\n\n'
|
||
+ 'Bitte schreib es in einem Satz – sonst können wir es nicht prüfen.');
|
||
if (grund === null) return;
|
||
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/melden', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ grund }),
|
||
});
|
||
melde(a.ok ? 'Danke – wir sehen uns das an.'
|
||
: (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 Treff zeigen", bei
|
||
Highlights "freigeben" -- derselbe Vorgang, zwei Woerter,
|
||
weil es zwei verschiedene Entscheidungen sind. */
|
||
if (bereich === 'ansteht' || bereich === 'highlight') {
|
||
const frei = !!e.freigegeben;
|
||
const wort = bereich === 'ansteht'
|
||
? (frei ? 'nicht mehr zeigen' : 'im Treff zeigen')
|
||
: (frei ? 'zurückziehen' : 'freigeben');
|
||
if (!frei) {
|
||
const m = el('span', 'marke-art marke-art--nur', 'noch nicht sichtbar');
|
||
m.title = 'Die Community sieht das erst nach der Freigabe.';
|
||
zeile.append(m);
|
||
}
|
||
zeile.append(tuKnopf(wort, async () => {
|
||
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/freigeben',
|
||
frei
|
||
? { method: 'DELETE' }
|
||
: { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||
if (!a.ok) { melde('Ging nicht.'); return; }
|
||
await laden();
|
||
}));
|
||
}
|
||
|
||
/* AUS "MITMACHEN" WIRD EIN TALENT. Ein ausdruecklicher Schritt
|
||
und keine Automatik: Wer sich meldet, ist ein Kandidat --
|
||
dass er einer wird, entscheidet ein Mensch. */
|
||
if (bereich === 'mitmachen' && e.erstellt_rolle === 'gast') {
|
||
zeile.append(tuKnopf('zu den Talenten', async () => {
|
||
if (!confirm('„' + e.titel + '“ zu den Talenten übernehmen?')) return;
|
||
const a = await hole('/workspace/api/treff/mitmachen/' + e.id + '/uebernehmen',
|
||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||
const j = await a.json().catch(() => ({}));
|
||
melde(a.ok ? 'Steht jetzt bei den Talenten.'
|
||
: (j.fehler === 'schon_uebernommen' ? 'Steht dort schon.'
|
||
: (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 grund = prompt('Warum wird „' + e.titel + '“ entfernt?\n\n'
|
||
+ 'Der Grund wird festgehalten – die Person hat ein Recht darauf.');
|
||
if (grund === null) return;
|
||
/* DER GRUND REIST IN DER ADRESSE, nicht im Koerper -- aus
|
||
demselben gemessenen Grund wie oben. Er ist kein
|
||
Geheimnis: Er wird der Person, die den Beitrag
|
||
geschrieben hat, ohnehin mitgeteilt, und er steht im
|
||
Protokoll. */
|
||
const a = await hole('/workspace/api/bereich/' + bereich + '/' + e.id
|
||
+ '?grund=' + encodeURIComponent(grund), { method: 'DELETE' });
|
||
if (!a.ok) {
|
||
melde(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 -------------
|
||
"Der Treff" war eine Liste aus Karten. Der einzige Bereich, in
|
||
dem Menschen MITEINANDER reden sollen, zwang sie, nebeneinander
|
||
zu reden: Jeder schrieb eine eigene Karte, niemand antwortete
|
||
jemandem.
|
||
|
||
ANTWORTEN STEHEN EINGERUECKT UNTER IHREM BEITRAG, aelteste
|
||
zuerst -- so liest man ein Gespraech. Und nur EINE Stufe
|
||
eingerueckt: Auf 390 px ist bei der zweiten Schluss, dann steht
|
||
der Text in einer Spalte von vier Woertern. */
|
||
if (Array.isArray(e.antworten)) {
|
||
const strang = el('div', 'antworten');
|
||
for (const a of e.antworten) {
|
||
const z = el('div', 'antwort');
|
||
const kopf2 = el('p', 'antwort__kopf');
|
||
kopf2.append(el('span', 'antwort__wer', a.von_name || 'jemand'));
|
||
if (a.von_rolle_name) {
|
||
kopf2.append(el('span', 'antwort__rolle', a.von_rolle_name));
|
||
}
|
||
kopf2.append(el('span', 'antwort__wann', datum(String(a.erstellt).slice(0, 10))));
|
||
z.append(kopf2);
|
||
z.append(el('p', 'antwort__text', a.text));
|
||
/* Die eigene immer, fremde nur das Team -- was der Server
|
||
genauso entscheidet. Ein Knopf, den der Server ablehnt,
|
||
waere schlimmer als keiner. */
|
||
if (a.meine || darfEintragen()) {
|
||
const w = el('button', 'antwort__weg', 'löschen');
|
||
w.type = 'button';
|
||
w.addEventListener('click', async () => {
|
||
w.disabled = true;
|
||
const r = await hole(`/workspace/api/antwort/${a.id}`, { method: 'DELETE' });
|
||
if (!r.ok) { melde('Ging nicht.'); w.disabled = false; return; }
|
||
await laden();
|
||
});
|
||
z.append(w);
|
||
}
|
||
strang.append(z);
|
||
}
|
||
|
||
/* DAS FELD STEHT IMMER DA, nicht hinter einem "Antworten"-Knopf.
|
||
Ein Gespraech faengt nicht an, wenn man erst einen Knopf
|
||
suchen muss -- und ein leeres Feld ist die deutlichste
|
||
Einladung, die es gibt.
|
||
|
||
ABER NUR FUER DEN, DER AUCH DARF. Der Server entscheidet das
|
||
mit derselben Regel (`darfSchreiben`); ein Feld, dessen
|
||
Absenden der Server ablehnt, ist schlimmer als keins -- man
|
||
tippt umsonst und bekommt eine Absage fuer etwas, das man
|
||
angeboten bekommen hat. */
|
||
if (darfEintragen()) {
|
||
const form = el('form', 'antwort-neu');
|
||
const feld = el('input', 'antwort-neu__feld');
|
||
feld.type = 'text';
|
||
feld.maxLength = 1200;
|
||
feld.placeholder = e.antworten.length ? 'Antworten …' : 'Als Erster antworten …';
|
||
feld.setAttribute('aria-label', `Antwort auf „${e.titel}“`);
|
||
const ab = el('button', 'antwort-neu__ab', 'Senden');
|
||
ab.type = 'submit';
|
||
form.append(feld, ab);
|
||
form.addEventListener('submit', async (ev) => {
|
||
ev.preventDefault();
|
||
const text = feld.value.trim();
|
||
if (!text) return;
|
||
ab.disabled = true;
|
||
const r = await hole(`/workspace/api/bereich/${bereich}/${e.id}/antwort`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text }),
|
||
});
|
||
if (!r.ok) {
|
||
melde(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();
|
||
}));
|
||
}
|
||
|
||
if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) {
|
||
knoepfe.append(tuKnopf('löschen', async () => {
|
||
if (!confirm(`„${e.titel}" wirklich löschen?`)) return;
|
||
const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, { method: 'DELETE' });
|
||
if (!a.ok) { melde('Löschen hat nicht geklappt.'); return; }
|
||
await laden();
|
||
}));
|
||
}
|
||
fuss.append(knoepfe);
|
||
k.append(fuss);
|
||
|
||
return k;
|
||
}
|
||
|
||
/* Sprung vom Bericht (01.09.2026). "dringend" ist kein Filter der
|
||
Seite -- die Dringlichkeit steht als data-dringlich an der Karte,
|
||
und genau die wird hervorgehoben. "offen" dagegen IST ein Filter,
|
||
der wurde schon vor dem ersten Laden gesetzt. */
|
||
function sprungZeigen(ziel, karten) {
|
||
if (gesprungen) return;
|
||
gesprungen = true;
|
||
const was = window.zielSchluessel?.();
|
||
if (was === 'dringend') {
|
||
/* OFFEN und hoch -- genau so zaehlt der Bericht (workspace-reports.js:
|
||
"status = 'offen' AND dringlichkeit = 'hoch'"). Ohne das
|
||
status-Merkmal hebt die Seite auch erledigte dringende Eintraege
|
||
hervor: Man klickt auf "2" und bekommt drei markiert. Gefunden
|
||
von pruef-sprung.mjs -- mit blossem Auge waere das nie
|
||
aufgefallen, beide Zahlen sehen plausibel aus. */
|
||
window.zielMarkieren?.(
|
||
ziel.querySelectorAll('.eintrag-karte[data-dringlich="hoch"][data-status="offen"]'),
|
||
'Einträge, die offen und als dringend eingestuft sind');
|
||
} else if (was === 'offen') {
|
||
window.zielMarkieren?.([...karten.values()], 'Einträge, die noch offen sind',
|
||
async () => { filter = ''; await laden(); });
|
||
} 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. */
|
||
function mitFett(text) {
|
||
const stueck = document.createDocumentFragment();
|
||
const teile = String(text).split(/\*\*(.+?)\*\*/s);
|
||
/* split mit einer Gruppe liefert abwechselnd: normal, fett,
|
||
normal, fett … Der ungerade Platz ist immer der Inhalt der
|
||
Klammern. */
|
||
teile.forEach((t, i) => {
|
||
if (!t) return;
|
||
if (i % 2 === 1) {
|
||
const b = document.createElement('strong');
|
||
b.textContent = t;
|
||
stueck.append(b);
|
||
} else {
|
||
stueck.append(document.createTextNode(t));
|
||
}
|
||
});
|
||
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. */
|
||
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());
|
||
}
|
||
|
||
function videoFeldBedienen() {
|
||
const form = $('video-form');
|
||
if (!form || form.dataset.bereit === 'ja') return;
|
||
form.dataset.bereit = 'ja';
|
||
form.addEventListener('submit', async (ev) => {
|
||
ev.preventDefault();
|
||
const feld = $('video-url');
|
||
const knopf = $('video-holen');
|
||
const url = feld.value.trim();
|
||
if (!url) return;
|
||
knopf.disabled = true;
|
||
/* "Wird geholt" statt eines stillen Wartens: Der Server fragt
|
||
TikTok, und das dauert einen Moment. Ein Knopf, der nichts tut
|
||
und nichts sagt, wird ein zweites Mal gedrueckt. */
|
||
const hinweis = $('video-hinweis');
|
||
const alterText = hinweis?.textContent || '';
|
||
if (hinweis) hinweis.textContent = 'Wird geholt …';
|
||
try {
|
||
const a = await hole(`/workspace/api/bereich/${bereich}/video`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url }),
|
||
});
|
||
const r = await a.json().catch(() => ({}));
|
||
if (!a.ok) {
|
||
melde(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. */
|
||
async function startkatalogZeigen() {
|
||
const ziel = $('startkatalog');
|
||
if (!ziel) return;
|
||
if (!darfEintragen()) { ziel.hidden = true; return; }
|
||
let daten = null;
|
||
try {
|
||
const a = await fetch(`/workspace/api/bereich/${bereich}/start`,
|
||
{ credentials: 'same-origin' });
|
||
if (!a.ok) { ziel.hidden = true; return; }
|
||
daten = await a.json();
|
||
} catch { ziel.hidden = true; return; }
|
||
if (!daten?.offen) { ziel.hidden = true; return; }
|
||
|
||
ziel.hidden = false;
|
||
ziel.textContent = '';
|
||
const kopf = el('div', 'startkatalog__kopf');
|
||
kopf.append(el('h2', 'startkatalog__titel', 'Fertige Vorschläge'));
|
||
kopf.append(el('p', 'startkatalog__unter',
|
||
`${daten.offen} ${daten.offen === 1 ? 'Vorschlag ist' : 'Vorschläge sind'} `
|
||
+ 'vorbereitet. Übernehmen, was passt – ändern kannst du danach alles.'));
|
||
const alle = el('button', 'schritt startkatalog__alle',
|
||
`Alle ${daten.offen} übernehmen`);
|
||
alle.type = 'button';
|
||
alle.addEventListener('click', async () => {
|
||
alle.disabled = true;
|
||
const a = await hole(`/workspace/api/bereich/${bereich}/start`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
||
});
|
||
if (!a.ok) { melde('Ging nicht.'); alle.disabled = false; return; }
|
||
await laden();
|
||
});
|
||
kopf.append(alle);
|
||
ziel.append(kopf);
|
||
|
||
const liste = el('div', 'startkatalog__liste');
|
||
for (const v of daten.vorschlaege) {
|
||
if (v.schon) continue;
|
||
const k = el('div', 'startkatalog__karte');
|
||
k.append(el('span', 'marke-art', einstellung?.arten?.[v.art] || v.art));
|
||
k.append(el('h3', 'startkatalog__name', v.titel));
|
||
if (v.text) {
|
||
/* 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;
|
||
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. */
|
||
if (bereich === 'highlight') ziel.dataset.galerie = 'ja';
|
||
else delete ziel.dataset.galerie;
|
||
/* DAS ANSCHLAGBRETT IST EIN SCHWARZES BRETT: wenige, GROSSE
|
||
Aushaenge. Ein Aushang zwischen dreissig anderen ist kein
|
||
Aushang -- die Wirkung entsteht durch die Groesse und die
|
||
Begrenzung. */
|
||
if (bereich === 'anschlag') ziel.dataset.aushang = 'ja';
|
||
else delete ziel.dataset.aushang;
|
||
|
||
const sichtbar = daten.eintraege.filter((e) => {
|
||
/* DER KANAL ZUERST und als eigene Bedingung: Er schließt den
|
||
Art-Filter nicht aus, er kommt dazu. „Nur offene Clips von
|
||
HasiDog" ist eine sinnvolle Frage. */
|
||
if (kanalFilter && e.quelle_kanal !== kanalFilter) return false;
|
||
if (!filter) return true;
|
||
if (filter === 'offen') return e.status === 'offen';
|
||
return e.art === filter;
|
||
});
|
||
/* DER COUNTDOWN steht ueber der Liste und rechnet mit ALLEN
|
||
Eintraegen, nicht mit der gefilterten Auswahl: Wer auf "nur
|
||
offene" stellt, will trotzdem wissen, wann der naechste Stream
|
||
ist. */
|
||
countdownZeigen(daten.eintraege);
|
||
/* NACH der Liste, nicht davor: Der Katalog verschwindet, sobald
|
||
alles uebernommen ist -- und das haengt an dem, was gerade
|
||
geladen wurde. */
|
||
startkatalogZeigen();
|
||
videoFeldZeigen();
|
||
videoFeldBedienen();
|
||
maxStimmen = (daten.eintraege || [])
|
||
.reduce((n, e) => Math.max(n, Number(e.stimmen) || 0), 0);
|
||
|
||
if (!sichtbar.length) {
|
||
/* DER SATZ GEHOERT ZUM BEREICH (17.09.2026). "Noch kein Eintrag
|
||
in diesem Bereich" stand auf allen acht gleich -- und drei
|
||
Bildschirmfotos davon nebeneinander sahen aus wie ein defektes
|
||
System. Jeder Bereich sagt jetzt in seinen eigenen Worten,
|
||
was hier hingehoert. */
|
||
ziel.append(el('p', 'leer-hinweis', daten.eintraege.length
|
||
? 'Zu dieser Auswahl gibt es nichts.'
|
||
: (einstellung?.leer || 'Noch kein Eintrag in diesem Bereich.')));
|
||
/* AUCH HIER springen -- und das ist kein Schoenheitsfehler,
|
||
sondern der wichtigere Fall: Wer auf "3 dringende Punkte"
|
||
klickt und auf einer leeren Seite landet, glaubt, er sei
|
||
falsch abgebogen. Die Leiste sagt ihm, dass er richtig ist und
|
||
die Sache erledigt. */
|
||
sprungZeigen(ziel, new Map());
|
||
return;
|
||
}
|
||
const karten = new Map();
|
||
|
||
/* ---- STUFE 7: JEDES BRETT BEKOMMT SEINE FORM ------------------
|
||
"Regeln & Hilfe" ist ein NACHSCHLAGEWERK, kein Feed: Man
|
||
schlaegt es im Streitfall auf und will finden, nicht scrollen.
|
||
Also nach Art gruppiert, mit Ueberschriften -- und ganz oben
|
||
Sprungmarken.
|
||
|
||
"Mitmachen" ist ein WEG: erst die drei Schritte, dann die
|
||
Gebiete, dann die Fragen. Eine Liste beantwortet "was gibt
|
||
es"; die Frage lautet aber "was tue ich als Erstes". */
|
||
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');
|
||
for (const e of fach) {
|
||
const k = karte(e);
|
||
karten.set(e.id, k);
|
||
kasten.append(k);
|
||
}
|
||
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');
|
||
for (const e of fach) {
|
||
const k = karte(e);
|
||
karten.set(e.id, k);
|
||
kasten.append(k);
|
||
}
|
||
ziel.append(kasten);
|
||
window.abschnittKlappbar?.(kopf, kasten, `ansteht:${r}`, r === 'vorbei');
|
||
}
|
||
} 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();
|
||
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') || '';
|
||
|
||
/* 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 { 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;
|
||
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. Bringt ein Bereich ein
|
||
eigenes mit, gilt seines; sonst bleibt es wie bisher. */
|
||
if (einstellung.ober && $('ober')) $('ober').textContent = einstellung.ober;
|
||
$('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)`;
|
||
}
|
||
/* 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();
|
||
})();
|
||
})();
|