Files
dogfather-universe/workspace/assets/js/kopf.js
T
DogFatherGitandClaude Opus 5 2acb6bc020 Das Maus-Licht ist zurueck, und die Begruessung ist keine Kachel mehr
DAS LICHT, DAS DEM ZEIGER FOLGT -- MEIN EIGENER FEHLER VON HEUTE FRUEH

Es war nicht geloescht. In module.css stand seit heute

    .kachel > * { position: relative; z-index: 1; }

damit der Inhalt ueber Raster und Kantenlicht liegt. Das Lichtelement
ist aber ein direktes KIND jeder Karte -- ihm wurde damit sein
`position: absolute` genommen. Aus einer Flaeche ueber der ganzen Karte
wurde ein leerer Inline-Span ohne Ausdehnung. Im Browser gemessen:
`display: inline`, obwohl in start.css `absolute` steht.

Merksatz dazu im Code: Eine Regel auf `> *` trifft auch das, was gar
kein Inhalt ist.

Zweiter, aelterer Fehler beim selben Thema, den erst die Pruefung
gefunden hat: Beim Wechsel von einer Kachel direkt auf die naechste
ging das Licht GANZ aus. `pointermove` der neuen Karte meldet einen
Bildaufbau an, `pointerout` der alten kommt danach und hat ihn
geloescht -- obwohl er gar nicht ihr gehoerte. Jetzt wird nur noch der
EIGENE Bildaufbau entwertet.

DIE BEGRUESSUNG FLIEGT AUS DER REIHE

Filipe: "diese hauptkachel muss komplett aus der rolle fliegen im
gegenzug zu den anderen ... AUCH MIT DER UHR RECHTS; WIE IN DER
BUISNESS HUB SEITE VON VANVAN."

Nachgesehen statt geraten: Auf VanVans Business-Hub gibt es keine Uhr.
Gemeint ist das `gate-medaillon` der Anmeldeseite -- ein runder
Kegelverlauf, der wie gebuerstetes Metall aussieht, gefasst in zwei
eingelassenen Ringen. Diese Bauart steht jetzt hier, weitergetrieben.

Die Begruessung ist keine Kachel mehr, sondern eine KONSOLE, und sie
unterscheidet sich in der FORM, nicht im Lack:

  * Sie ist BREITER ALS DIE SEITE -- sie tritt links und rechts ueber
    die Spalte hinaus, in der alle Kacheln stehen.
  * Sie ist ein ACHTECK. Die Module haben EINE abgeschnittene Ecke,
    sie hat VIER.
  * Sie hat eine METALLFASSUNG, laengs gebuerstet, mit je einer warmen
    und einer kuehlen Spiegelung.

Die Uhr ist von 124 auf 164 px gewachsen und hat eine echte Luenette:
10 px deckendes Metall, zwoelf eingravierte Stundenmarken, sechzig
feine Minutenstriche, Glaskuppe.

VIER FEHLER AUF DEM WEG DAHIN, ALLE IM BILDSCHIRMFOTO GESEHEN

  1. HALBDURCHSICHTIGES METALL ist kein Metall, sondern graues Glas.
     Stand gleichzeitig an Konsole und Uhr.
  2. KEGELVERLAUF AUF EINEM BREITEN BALKEN bewirkt nichts: Die ganze
     Oberkante liegt in wenigen Grad. Rund -> conic, lang -> linear.
     Die Verlaufsart muss zur FORM passen, nicht zum Material.
  3. DIE SKALA DER UHR WAR NIE SICHTBAR, seit es sie gibt. Ihre Maske
     rechnete Prozente auf die weiteste ECKE (116 px) statt auf den
     Radius (82 px) -- der Ring lag komplett ausserhalb der Uhr.
     `closest-side` behebt es. Eine unsichtbare Verzierung sieht aus
     wie gar keine, nicht wie ein Fehler.
  4. `body.start .willkommen` in start.css hat die neue Konsole
     ueberschrieben -- nicht ueber die Ladereihenfolge, sondern ueber
     die SPEZIFITAET (0,2,1 gegen 0,1,0). Derselbe Fehler wie mit
     `:where()` heute Frueh, nur andersherum: damals zu schwach
     geschrieben, hier zu stark stehen gelassen.

Der Ueberstand haengt an der Polsterung der Inhaltsspalte
(`min(34px, 3.6vw)`) statt an einer festen Zahl -- eine feste haette
auf dem Handy 17 px aus dem Bildschirm geragt.

UND EINE PRUEFUNG, DIE UNTER DEN BILDRAND GEZIELT HAT

pruef-start-ansicht meldete zwei Fehler am Licht. Das Licht war in
Ordnung: Die hoehere Konsole hatte Kachel 3 auf y = 1134 geschoben,
bei einem 1200 px hohen Fenster lag ihre Mitte unter dem Rand. Sie
rollt jetzt hin, misst danach neu -- und die Zahl der wirklich
gemessenen Kacheln steht in der Bedingung. 140 statt 137 Pruefungen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 13:03:16 +02:00

1192 lines
50 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===================================================================
Gemeinsames Verhalten der Kopfleiste: Zurück-Knopf, Abmelden, Suche.
Liegt bewusst in einer eigenen Datei. Vorher stand das Abmelden in
jeder Seitendatei noch einmal -- fünfmal derselbe Block, fünfmal eine
Stelle zum Vergessen, wenn sich etwas ändert.
=================================================================== */
(() => {
'use strict';
const START = '/workspace/start.html';
/* ---------- Wer angemeldet ist -------------------------------------------
Vorher stand in der Kopfleiste "Dogfather · DogFather". Name und
Rolle lasen sich gleich, und es sah nach einem Fehler aus. Jetzt drei
unterscheidbare Dinge: ein Zeichen mit dem Anfangsbuchstaben, der
Name, und die Rolle als eigene Marke in ihrer Farbe.
Steht hier und nicht in jeder Seitendatei -- jede von ihnen setzte
bisher dieselbe Zeile selbst zusammen, vierzehnmal. */
window.werZeigen = function (ich) {
const ziel = document.getElementById('wer');
if (!ziel || !ich) return;
ziel.textContent = '';
ziel.dataset.rolle = ich.rolle || '';
/* Das eigene Bild, wenn es eines gibt -- sonst der Anfangsbuchstabe.
Der Buchstabe bleibt als Unterlage LIEGEN und wird nicht ersetzt:
Laedt das Bild nicht (Verbindung weg, Datei geloescht), steht dort
weiterhin etwas Sinnvolles statt eines kaputten Bildsymbols. */
const zeichen = document.createElement('span');
zeichen.className = 'wer__zeichen';
zeichen.setAttribute('aria-hidden', 'true');
zeichen.textContent = (ich.name || '?').trim().charAt(0).toUpperCase();
if (ich.bild) {
const b = document.createElement('img');
b.className = 'wer__bild';
b.src = ich.bild;
b.alt = '';
b.addEventListener('error', () => b.remove());
zeichen.append(b);
}
const name = document.createElement('span');
name.className = 'wer__name';
name.textContent = ich.name || '';
const rolle = document.createElement('span');
rolle.className = 'wer__rolle';
rolle.textContent = ich.rolle_name || ich.rolle || '';
ziel.append(zeichen, name, rolle);
/* Vorleseprogramme bekommen einen Satz statt drei Bruchstücke. */
ziel.setAttribute('aria-label',
`Angemeldet als ${ich.name} in der Rolle ${ich.rolle_name || ich.rolle}`);
teilenKnopf(ich);
};
/* ---------- Teilen ---------------------------------------------------
Wunsch Filipe (07.09.2026): "einen button oben in der leiste ... zum
teilen der seite, auch nur die scouts, manager und dogfather sollen
diesen button sehen und benutzen können."
WAS GETEILT WIRD, IST DIE ADRESSE DES WORKSPACE -- nicht die Seite,
auf der man gerade steht. Das ist kein Detail: Wer einen Link auf
`bereich.html?b=schutz` weitergibt, schickt jemanden auf eine Seite,
die er entweder gar nicht sehen darf oder die ohne Zusammenhang
verwirrt. Geteilt wird der Eingang.
WARUM NUR DIE LEITUNG. Ein Creator hat niemanden, dem er den
Workspace geben würde -- er ist der Eingeladene, nicht der
Einladende. Ein Knopf, den man hat und nie braucht, ist Ballast;
einer, den man drückt und der nichts Sinnvolles tut, ist schlimmer.
Durchgesetzt wird das an der Rolle aus /api/ich, also an derselben
Angabe, mit der der Server auch alles andere entscheidet.
ZWEI WEGE, EIN KNOPF. Wo es die Teilen-Funktion des Geräts gibt
(Handy, neuere Rechner), wird sie benutzt -- das ist der Weg, den
Filipe kennt. Wo nicht, landet die Adresse in der Zwischenablage
und der Knopf sagt "Kopiert". Kein Dialog, kein zweiter Klick.
Und wo auch das nicht geht (alte Browser, kein sicherer Kontext),
erscheint der Knopf gar nicht erst -- ein dritter Ausgang statt
einer Schaltfläche, die nichts tut. */
/* Gleiche Menge, gleiche Begruendung wie beim Abbrechen: Leitung
plus Scouts, abgeleitet statt abgeschrieben. */
const DARF_TEILEN = new Set([...window.Bereiche.LEITUNG, 'scout']);
function teilenKnopf(ich) {
const leiste = document.querySelector('.kopfleiste__rechts');
if (!leiste || document.getElementById('teilen')) return;
if (!DARF_TEILEN.has(ich.rolle)) return;
const kannTeilen = typeof navigator.share === 'function';
const kannKopieren = !!navigator.clipboard?.writeText;
if (!kannTeilen && !kannKopieren) return;
const k = document.createElement('button');
k.type = 'button';
k.id = 'teilen';
k.className = 'teilen';
k.title = 'Adresse des Workspace weitergeben';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'teilen__zeichen');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
/* Drei Knoten, zwei Linien -- das Zeichen für "weitergeben". Als
Pfade und nicht als Schriftzeichen: Ein Emoji sieht auf jedem
Gerät anders aus und lässt sich nicht einfärben. */
svg.innerHTML =
'<circle cx="18" cy="5" r="2.6"/><circle cx="6" cy="12" r="2.6"/>'
+ '<circle cx="18" cy="19" r="2.6"/>'
+ '<path d="M8.3 10.8 15.7 6.6M8.3 13.2l7.4 4.2"/>';
k.append(svg);
const text = document.createElement('span');
text.className = 'teilen__text';
text.textContent = 'Teilen';
k.append(text);
const ZIEL = location.origin + '/workspace/';
let laeuft = false;
k.addEventListener('click', async () => {
if (laeuft) return;
laeuft = true;
try {
if (kannTeilen) {
await navigator.share({
title: 'Creator Workspace',
text: 'Der Creator Workspace von DogFather Universe',
url: ZIEL,
});
} else {
await navigator.clipboard.writeText(ZIEL);
const vorher = text.textContent;
text.textContent = 'Kopiert';
k.dataset.fertig = 'ja';
setTimeout(() => { text.textContent = vorher; delete k.dataset.fertig; }, 2200);
}
} catch (f) {
/* Ein Abbruch durch die Nutzerin ist KEIN Fehler und darf nicht
wie einer aussehen -- sie hat den Dialog absichtlich
geschlossen. Alles andere wird gemeldet. */
if (f?.name !== 'AbortError') {
text.textContent = 'Ging nicht';
setTimeout(() => { text.textContent = 'Teilen'; }, 2200);
}
} finally { laeuft = false; }
});
/* Ganz links in der rechten Gruppe: Teilen ist eine Nebenhandlung
und darf dem Abmelden nicht den Platz nehmen. */
leiste.prepend(k);
}
/* ---------- Auf- und Zuklappen langer Listen -------------------------
Lange Protokoll-Listen sind Nachschlagewerk, kein Startbild: Sie
stehen zugeklappt da und zeigen nur die neuesten Zeilen.
Steht hier, weil es an mehreren Stellen gebraucht wird -- und weil
genau so schon dreimal Regeln in einer Seitendatei gelandet sind,
die eine andere Seite gar nicht laedt.
`klappe(liste, knopf, text)` erwartet: die Liste (deren direkte
Kinder die Zeilen sind), den Knopf und das Textfeld darin. Das
Ausblenden macht CSS ueber [data-klapp="zu"] -- ohne Neuaufbau und
ohne zweite Abfrage. */
/* Zwei Sichtbarkeitsstufen, beide mit einer Regel im CSS:
4 der Normalfall -- eine Liste zum Nachschlagen
7 wo die Woche die Einheit ist (die Tagesliste der Zahlen)
Die Zahl ist der Schluessel zum passenden `data-klapp`-Wert; eine
unbekannte Zahl faellt auf 4 zurueck, statt still gar nichts zu
verbergen. */
const KLAPP_STUFEN = { 4: 'zu', 7: 'zu7' };
const KLAPP_ZU = 4; /* dieselbe Zahl steht im CSS als nth-child(n+5) */
window.klappe = function (liste, knopf, textfeld, wieviele = KLAPP_ZU) {
if (!liste || !knopf) return;
const anzahl = liste.children.length;
const stufe = KLAPP_STUFEN[wieviele] || 'zu';
const zeigt = KLAPP_STUFEN[wieviele] ? wieviele : KLAPP_ZU;
/* Ein Knopf, der nichts zu verbergen hat, verwirrt nur. */
knopf.hidden = anzahl <= zeigt;
if (knopf.hidden) { liste.removeAttribute('data-klapp'); return; }
/* Liest die Anzahl bei jedem Aufruf frisch aus der Liste, statt sie
von oben mitzunehmen. Der Zuhoerer unten wird nur einmal gesetzt
und haelt darum fuer immer die Fassung vom ersten Durchlauf fest
-- mit einer festgehaltenen Zahl stuende spaeter "Alle 3 zeigen"
an einer Liste mit dreissig Zeilen. */
const stand = (offen) => {
if (offen) liste.removeAttribute('data-klapp');
else liste.dataset.klapp = stufe;
knopf.setAttribute('aria-expanded', offen ? 'true' : 'false');
/* Der Knopf sagt, was er TUT, nicht wo man gerade ist -- und nennt
die Zahl, damit man weiss, was dahintersteckt. */
if (textfeld) {
textfeld.textContent = offen
? `Nur die letzten ${zeigt}`
: `Alle ${liste.children.length} zeigen`;
}
};
/* Die Liste wird bei jeder Aktualisierung neu aufgebaut, also laeuft
auch klappe() mehrfach. Der Zuhoerer wird deshalb nur EINMAL
gesetzt -- sonst haengten beim zweiten Mal zwei daran und ein
Klick haette doppelt umgeschaltet, also gar nichts bewirkt. */
if (!knopf.dataset.klappBereit) {
knopf.dataset.klappBereit = 'ja';
knopf.addEventListener('click', () => stand(liste.dataset.klapp === 'zu'));
}
stand(false);
};
/* Gibt es eine sinnvolle vorherige Seite INNERHALB des Workspace?
Blindes history.back() reicht nicht: Wer die Adresse direkt eingibt
oder gerade von der Anmeldung kommt, landet damit außerhalb des
Workspace oder wieder im Anmeldeformular. Deshalb wird zuerst
geprüft, woher der Aufruf kam. */
function hatVorherigeSeite() {
if (!document.referrer) return false;
let u;
try { u = new URL(document.referrer); } catch { return false; }
if (u.origin !== location.origin) return false;
if (!u.pathname.startsWith('/workspace/')) return false;
/* Die Anmeldeseite ist kein sinnvolles Ziel -- wer angemeldet ist,
würde dort sofort wieder weitergeleitet. */
if (u.pathname === '/workspace/' || u.pathname.endsWith('/index.html')) return false;
/* Gleiche Seite heißt: neu geladen, nicht hergekommen. */
if (u.pathname === location.pathname) return false;
return true;
}
/* ---------- Das Licht folgt dem Zeiger ---------------------------------
Auf der Startseite gab es das schon, aber nur dort und nur als
Lichtfleck. Jetzt auf JEDER Karte im ganzen Workspace -- und mit
zwei Schichten:
der FLECK ein weicher Schein unter dem Zeiger, in der Farbe des
Bereichs
der RAND eine helle Stelle, die auf der Kante mitwandert
Der Rand ist der Teil, der den Unterschied macht. Er entsteht aus
einem Farbverlauf, von dem eine Maske nur den ein Pixel breiten
Saum stehen laesst (mask-composite: exclude). Dadurch leuchtet
wirklich die KANTE an der Stelle, an der der Zeiger ist -- nicht
ein Rechteck, das so tut als ob.
Beides steckt in einem eingefuegten <span class="licht">. Ein
eigenes Element statt ::before/::after, weil die bei fast allen
Karten schon fuer etwas anderes gebraucht werden (Akzentstreifen,
Wasserzeichen) -- ein Pseudo-Element doppelt zu belegen geht nicht,
und der Fehler faellt erst auf, wenn eines der beiden verschwindet.
Kosten: EIN Zuhoerer fuer das ganze Dokument, gedrosselt auf einen
Bildaufbau. Ohne feinen Zeiger (Handy) und bei "weniger Bewegung"
laeuft gar nichts -- dann wird auch kein Element eingefuegt. */
const LICHT_KARTEN = [
'.kachel', '.karte', '.spalte', '.k-anlasskarte', '.k-tag', '.k-wochentag',
'.dran__punkt', '.zahl', '.datei', '.person', '.eintrag', '.thema',
'.listentag', '.k-listentag', '.steckbrief', '.leer-hinweis', '.block',
'.saeule', '.karte-gross', '.kandidat', '.punkt', '.call', '.report',
].join(', ');
window.lichtFolgen = function (wurzel = document) {
const feinerZeiger = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
const ruhig = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!feinerZeiger || ruhig) return;
/* Jede Karte bekommt ihr Licht-Element -- einmal. Karten kommen
nachtraeglich dazu (Listen werden neu aufgebaut), deshalb wird das
beim ersten Ueberfahren nachgeholt statt einmalig beim Laden. */
const vorbereiten = (karte) => {
if (karte.dataset.lichtBereit) return;
karte.dataset.lichtBereit = 'ja';
const l = document.createElement('span');
l.className = 'licht';
l.setAttribute('aria-hidden', 'true');
/* Als ERSTES Kind: Bei gleichem Stapelwert gewinnt das spaetere
Element -- so liegt aller Inhalt ueber dem Licht. */
karte.prepend(l);
};
let warten = false;
let letzte = null;
/* Die Karte, auf der das Licht gerade AN ist. Genau eine -- mehr
kann es nicht geben, es gibt ja nur einen Zeiger. */
let aktuelle = null;
const aus = (karte) => {
if (!karte) return;
karte.removeAttribute('data-licht');
karte.style.removeProperty('--mx');
karte.style.removeProperty('--my');
if (aktuelle === karte) aktuelle = null;
};
wurzel.addEventListener('pointermove', (e) => {
const karte = e.target.closest?.(LICHT_KARTEN);
if (!karte) {
/* Der Zeiger ist zwischen den Karten -- das Licht der letzten
geht aus. Ohne das blieb es haengen, sobald man eine Karte
ueber eine Luecke verliess statt ueber eine Nachbarkarte. */
aus(aktuelle);
letzte = null;
return;
}
/* Direkt von einer Karte auf die naechste: Die alte ausschalten,
bevor die neue angeht. */
if (aktuelle && aktuelle !== karte) aus(aktuelle);
letzte = { karte, x: e.clientX, y: e.clientY };
if (warten) return;
warten = true;
requestAnimationFrame(() => {
warten = false;
/* WETTLAUF. Zwischen dem Anmelden dieses Bildaufbaus und seinem
Ablauf kann der Zeiger die Karte laengst verlassen haben --
dann hat pointerout schon aufgeraeumt, und diese Zeilen
schalteten das Licht gleich wieder AN. Genau so blieb es
haengen. Deshalb wird hier noch einmal geprueft, ob ueberhaupt
noch etwas zu beleuchten ist. */
if (!letzte) return;
const { karte: k, x, y } = letzte;
vorbereiten(k);
const r = k.getBoundingClientRect();
k.style.setProperty('--mx', ((x - r.left) / r.width * 100).toFixed(1) + '%');
k.style.setProperty('--my', ((y - r.top) / r.height * 100).toFixed(1) + '%');
k.dataset.licht = 'an';
aktuelle = k;
});
}, { passive: true });
/* Beim Verlassen zurueck in die Ruhelage. Ohne das bliebe der Fleck
dort stehen, wo der Zeiger die Karte verlassen hat -- und die
Karten saehen nach einer Weile alle unterschiedlich beleuchtet
aus, ohne dass jemand wuesste, warum. */
wurzel.addEventListener('pointerout', (e) => {
const karte = e.target.closest?.(LICHT_KARTEN);
if (!karte) return;
/* pointerout feuert auch beim Wechsel zwischen Kindern INNERHALB
derselben Karte. Nur wenn der Zeiger die Karte wirklich
verlaesst, wird geloescht -- sonst flackerte das Licht bei jeder
Bewegung ueber ein Wort hinweg. */
if (karte.contains(e.relatedTarget)) return;
/* NUR DEN EIGENEN Bildaufbau entwerten (07.09.2026).
Hier stand `letzte = null` ohne Bedingung -- und das hat beim
Wechsel von einer Karte auf die naechste das Licht GANZ
ausgeschaltet. Der Grund ist die Reihenfolge der Ereignisse:
`pointermove` auf der neuen Karte kommt zuerst und meldet
einen Bildaufbau an; `pointerout` der alten Karte kommt danach
und hat ihn geloescht, obwohl er gar nicht ihr gehoerte. Der
Bildaufbau lief, fand `letzte === null` und tat nichts.
Gemessen mit pruef-start-ansicht: "beim Wechsel leuchtet
weiterhin nur eine (0)" -- null statt einer. Ein Fehler, den
man von Hand nur bemerkt, wenn man zufaellig schnell genug
von Kachel zu Kachel faehrt. */
if (letzte && letzte.karte === karte) letzte = null;
aus(karte);
}, { capture: true, passive: true });
/* Drei weitere Wege, auf denen der Zeiger verschwindet, ohne dass
pointerout die Karte meldet -- jeder davon hat das Licht haengen
lassen:
Fenster verlassen der Zeiger geht ueber den Rand hinaus
Tab gewechselt es kommt gar kein Ereignis mehr
gescrollt die Karte wandert unter dem stehenden
Zeiger weg; mausbewegt wurde nichts
Beim Scrollen wird nicht blind geloescht: Es wird nachgesehen, ob
die zuletzt beleuchtete Karte noch unter dem Zeiger liegt. */
let zeigerX = 0, zeigerY = 0;
wurzel.addEventListener('pointermove', (e) => { zeigerX = e.clientX; zeigerY = e.clientY; },
{ passive: true });
const pruefen = () => {
if (!aktuelle) return;
const unten = document.elementFromPoint(zeigerX, zeigerY);
if (!unten || !aktuelle.contains(unten)) { letzte = null; aus(aktuelle); }
};
window.addEventListener('scroll', pruefen, { passive: true, capture: true });
window.addEventListener('blur', () => { letzte = null; aus(aktuelle); });
document.addEventListener('visibilitychange', () => {
if (document.hidden) { letzte = null; aus(aktuelle); }
});
document.addEventListener('pointerleave', () => { letzte = null; aus(aktuelle); });
};
/* ---------- Jede Seite trägt Zeichen und Farbe ihrer Kachel -------------
Auf der Startseite hat jeder Bereich sein eigenes Zeichen und seinen
eigenen Farbton. Bisher endete das an der Kachel: Wer sie anklickte,
landete auf einer Seite, der man nicht mehr ansah, woher sie kam --
dreizehn Seiten, alle in demselben Blau, alle ohne Zeichen.
Jetzt wird die Kachel weitergereicht. Der Kopf der Seite bekommt
dasselbe Zeichen groß daneben, denselben Farbton für Strich, Zeichen
und Titel, und dasselbe Zeichen noch einmal riesig und fast
unsichtbar als Wasserzeichen -- genau die Behandlung, die auf der
Startseite aus siebzehn Kästen siebzehn unterscheidbare Bereiche
gemacht hat.
Die Zuordnung kommt aus assets/js/bereiche.js, also aus derselben
Liste wie die Kachel selbst. Wer dort eine Farbe ändert, ändert
Kachel UND Seite -- sie können gar nicht auseinanderlaufen. */
(function seiteEinfaerben() {
const B = window.Bereiche;
/* Der Kalender hat einen eigenen Kopf (.k-kopf), weil dort der
Zeitraum die Überschrift ist. Er soll sein Zeichen trotzdem
bekommen -- sonst wäre ausgerechnet die aufwendigste Seite die
einzige ohne. */
const kopf = document.querySelector('.kopf-zeile, .k-kopf');
if (!B || !kopf) return;
const bereich = B.zuSeite(location.pathname, location.search);
if (!bereich) return;
/* Der Ton hängt am body: Damit erreicht er auch Bauteile, die weit
unten auf der Seite stehen (Karten, Knöpfe, Zähler) -- nicht nur
den Kopf. Die 17 Töne selbst stehen in start.css und hängen nur am
Merkmal data-ton, nicht an einer Klasse. */
document.body.dataset.ton = String(bereich.ton);
/* Und ihre eigene Bühne. Neun Szenen aus Filipes Bildern, jede Seite
bekommt die, die zu ihr passt -- die Werkstatt bei den Aufgaben,
die Nachtstadt beim Kalender, die Sitzecke bei den Calls, der Wald
beim Scouting. Welche wohin gehört, steht in bereiche.js; das Bild
selbst wählt start.css über dieses Merkmal aus. */
if (bereich.szene) document.body.dataset.buehne = bereich.szene;
/* Der Kopf ist bisher ein <div> mit Text. Er bekommt links eine
Plakette mit dem Zeichen -- eingefügt VOR dem vorhandenen Text,
ohne ihn anzufassen. Dadurch funktioniert das auf allen dreizehn
Seiten, ohne dreizehn HTML-Dateien zu ändern. */
const erstes = kopf.firstElementChild;
if (!erstes) return;
const plakette = document.createElement('span');
plakette.className = 'kopf-zeichen';
plakette.setAttribute('aria-hidden', 'true');
plakette.append(B.zeichenBauen(bereich.zeichen, 'kopf-zeichen__svg'));
const reihe = document.createElement('div');
reihe.className = 'kopf-zeile__marke';
reihe.append(plakette);
kopf.insertBefore(reihe, erstes);
reihe.append(erstes);
/* Das Wasserzeichen: dasselbe Zeichen riesig und bei sehr geringer
Deckkraft hinter dem Kopf. Es liegt im Kopf selbst, nicht auf der
Seite -- sonst würde es beim Blättern mitwandern und stören. */
const schatten = document.createElement('span');
schatten.className = 'kopf-wasserzeichen';
schatten.setAttribute('aria-hidden', 'true');
schatten.append(B.zeichenBauen(bereich.zeichen, 'kopf-wasserzeichen__svg'));
kopf.append(schatten);
kopf.dataset.gezeichnet = 'ja';
})();
/* ---------- Der Schriftzug oben links -----------------------------------
Er stand als eine Zeile in einer Farbe da: "DOGFATHER UNIVERSE ·
CREATOR WORKSPACE". Alles gleich laut, und der Mittelpunkt war ein
Satzzeichen wie jedes andere.
Jetzt bekommt er einen Rang: Der vordere Teil ist der Name und steht
kräftig, der hintere sagt, wo man gerade ist, und tritt zurück. Der
Trenner ist kein Zeichen mehr, sondern eine kleine Raute in der
Hausfarbe.
Das passiert hier zentral statt in sechzehn HTML-Dateien -- und ohne
den Text neu zu schreiben: Nur die vorhandenen Textknoten werden
aufgeteilt, Verweise (der Zurück-Link auf den Unterseiten) bleiben
unberührt. Ändert jemand die Beschriftung im HTML, wirkt das hier
weiterhin. */
for (const marke of document.querySelectorAll('.kopfleiste .marke__text')) {
if (marke.dataset.rang) continue;
for (const knoten of [...marke.childNodes]) {
if (knoten.nodeType !== 3 || !knoten.textContent.includes('·')) continue;
const stueck = document.createDocumentFragment();
knoten.textContent.split('·').forEach((teil, i) => {
if (i) {
const raute = document.createElement('span');
raute.className = 'marke__raute';
raute.setAttribute('aria-hidden', 'true');
stueck.append(raute);
}
if (!teil.trim()) return;
const s = document.createElement('span');
s.className = 'marke__teil';
/* Der geschützte Leerraum im Quelltext soll erhalten bleiben --
"Creator Workspace" darf nicht umbrechen. */
s.textContent = teil.replace(/^[\s ]+|[\s ]+$/g, '');
stueck.append(s);
});
knoten.replaceWith(stueck);
}
/* Der erste Teil ist der Name (oder auf Unterseiten der Rückweg),
alles danach sagt nur noch, wo man ist.
Genommen werden ALLE direkten Kinder außer der Raute -- nicht nur
die eben erzeugten. Auf bereich.html steht der Bereichsname in
einem eigenen span, das erst später von bereich.js gefüllt wird;
eine Liste, die nur die selbst gebauten Stücke kennt, hätte ihn
stumm übergangen. */
const teile = [...marke.children].filter((e) => !e.classList.contains('marke__raute'));
teile.forEach((e, i) => e.classList.add(i === 0 ? 'marke__haupt' : 'marke__leise'));
marke.dataset.rang = 'ja';
}
/* Das Licht auf dem ganzen Dokument scharfstellen -- ein Zuhoerer
fuer alle Karten, auch fuer die, die erst spaeter dazukommen. */
window.lichtFolgen(document);
/* Die Startseite hat den Knopf gar nicht erst im Dokument -- dort gibt
es kein "davor". Deshalb genügt hier die Prüfung, ob er existiert. */
const knopf = document.getElementById('zurueck');
if (knopf) {
const beschriftung = knopf.querySelector('.zurueck-knopf__text');
if (hatVorherigeSeite()) {
knopf.hidden = false;
knopf.addEventListener('click', () => {
/* history.back() statt eines festen Ziels: Es führt wirklich
dorthin zurück, wo man war -- samt Bildlaufposition. Nur wenn
es keine Historie gibt, wird ersatzweise umgeleitet. */
if (history.length > 1) history.back();
else location.assign(START);
});
} else {
/* Kein Verlauf, aber wir sind in einem Unterbereich: dann wenigstens
ein Weg zur Übersicht. */
knopf.hidden = false;
if (beschriftung) beschriftung.textContent = 'Übersicht';
knopf.addEventListener('click', () => location.assign(START));
}
}
const abmelden = document.getElementById('abmelden');
if (abmelden) {
abmelden.addEventListener('click', async () => {
abmelden.disabled = true;
try {
await fetch('/workspace/api/abmelden', {
method: 'POST', credentials: 'same-origin',
});
} catch { /* Auch ohne Antwort weiterleiten -- das Cookie läuft ohnehin ab. */ }
location.assign('/workspace/');
});
}
/* ================================================================
Suche über alle Bereiche (Konzept, Phase 4).
Baut sich selbst in die Kopfleiste ein, statt in neun HTML-Dateien
zu stehen. Eine Suche, die man nur auf einer Extraseite findet,
benutzt niemand -- sie muss überall sein, wo man gerade ist.
Mit "/" zu öffnen, mit Esc zu schließen. Das Ergebnis wird erst
nach einer kurzen Pause geholt, damit nicht jeder Tastendruck eine
Abfrage auslöst.
================================================================ */
const kopfRechts = document.querySelector('.kopfleiste__rechts');
if (kopfRechts) aufbauSuche(kopfRechts);
if (kopfRechts) aufbauChat(kopfRechts);
/* ---------- Der Chat-Knopf mit Zähler -----------------------------------
Wunsch Filipe, 06.09.2026: *"die andere person bekommt eine
benachrichtigung und kann dann antworten."*
WARUM DER ZÄHLER IN DIE KOPFLEISTE GEHÖRT und nicht nur auf die
Chatseite: Eine Nachricht, die man erst sieht, wenn man den Chat
aufmacht, ist keine Nachricht -- sie ist ein Fundstück. Die
Push-Benachrichtigung erreicht nur, wer sie erlaubt hat und wer
gerade nicht am Rechner sitzt; hier drin sieht sie jeder, auf jeder
Seite.
Auf der Chatseite selbst wird der Knopf NICHT gebaut -- dort ist
man schon, und ein Knopf, der auf die eigene Seite führt, ist eine
Sackgasse. Die Zahl aktualisiert chat.js dann direkt. */
function aufbauChat(wohin) {
/* Auf der Chatseite selbst gibt es nichts zu verlinken. */
if (/\/chat\.html$/.test(location.pathname)) return;
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
const a = el('a', 'chat-knopf');
a.href = 'chat.html';
a.title = 'Chat';
a.setAttribute('aria-label', 'Chat');
const blase = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
blase.setAttribute('viewBox', '0 0 24 24');
blase.setAttribute('class', 'chat-knopf__zeichen');
blase.setAttribute('aria-hidden', 'true');
blase.innerHTML = '<path d="M4 5.5h16v11H9.5L5 20.5V16.5H4z"/>';
const zahl = el('span', 'chat-knopf__zahl');
zahl.hidden = true;
a.append(blase, el('span', 'chat-knopf__text', 'Chat'), zahl);
/* Vor der Suche: Der Chat ist das, was am ehesten auf eine Antwort
wartet -- die Suche fängt man selbst an, wenn man sie braucht. */
wohin.prepend(a);
/** Setzt die Zahl. Wird auch von chat.js aufgerufen, damit sie dort
* sofort verschwindet, wenn man gelesen hat. */
window.chatZahlZeigen = (n) => {
const wert = Number(n) || 0;
zahl.hidden = wert === 0;
/* Über 99 wird die Zahl breiter als der Knopf -- und die genaue
Menge interessiert ab da ohnehin niemanden mehr. */
zahl.textContent = wert > 99 ? '99+' : String(wert);
a.dataset.neu = wert > 0 ? 'ja' : '';
a.setAttribute('aria-label', wert
? `Chat, ${wert} ungelesene ${wert === 1 ? 'Nachricht' : 'Nachrichten'}`
: 'Chat');
};
async function holen() {
try {
const antwort = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' });
if (!antwort.ok) return;
window.chatZahlZeigen((await antwort.json()).ungelesen);
} catch { /* die Zahl ist Zugabe, kein Grund für eine Fehlermeldung */ }
}
holen();
/* NEUE NACHRICHTEN KOMMEN VON SELBST -- derselbe Strom wie im Chat.
Ohne ihn müsste man die Seite neu laden, um zu erfahren, dass
jemand geschrieben hat. Kein Nachfragen im Takt: Bei zehn
Angemeldeten wären das siebentausend Anfragen in der Stunde für
eine Zahl, die sich selten ändert. */
if (window.EventSource) {
try {
const strom = new EventSource('/workspace/api/chat/strom');
strom.addEventListener('neu', holen);
/* Nach einem Abriss verbindet der Browser selbst neu -- dann
einmal nachzählen, was inzwischen kam.
NUR AB DEM ZWEITEN MAL (06.09.2026). Hier stand schlicht
`strom.addEventListener('open', holen)` -- und damit fragte
JEDE Seite die Zahl zweimal ab: einmal direkt beim Laden und
einmal Millisekunden später, sobald der Strom aufging.
Gemessen von pruef-tempo-workspace auf fünf Seiten.
Zwei Anfragen für dieselbe Zahl sind kein Fehler, den jemand
bemerkt -- und genau deshalb bleibt so etwas jahrelang
stehen. Bei zehn Angemeldeten ist es die doppelte Last für
nichts. */
let schonOffen = false;
strom.addEventListener('open', () => {
if (schonOffen) holen();
schonOffen = true;
});
} catch { /* dann eben nur beim Laden */ }
}
/* Zurück an den Bildschirm: nachsehen. Fängt den Fall ab, dass der
Strom währenddessen stillgelegt wurde. */
document.addEventListener('visibilitychange', () => {
if (!document.hidden) holen();
});
}
function aufbauSuche(wohin) {
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
/* Auslöser in der Kopfleiste */
const knopf = el('button', 'suche-knopf');
knopf.type = 'button';
knopf.title = 'Suchen (Taste /)';
knopf.setAttribute('aria-label', 'Suchen');
const lupe = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
lupe.setAttribute('viewBox', '0 0 24 24');
lupe.setAttribute('class', 'suche-knopf__lupe');
lupe.setAttribute('aria-hidden', 'true');
lupe.innerHTML = '<circle cx="11" cy="11" r="6.5"/><path d="M15.8 15.8 20 20"/>';
knopf.append(lupe, el('span', 'suche-knopf__text', 'Suchen'));
wohin.prepend(knopf);
/* Overlay */
const schleier = el('div', 'suche-schleier');
schleier.hidden = true;
const fenster = el('div', 'suche-fenster');
fenster.setAttribute('role', 'dialog');
fenster.setAttribute('aria-modal', 'true');
fenster.setAttribute('aria-label', 'Suche über alle Bereiche');
const zeile = el('div', 'suche-zeile');
const feld = el('input', 'suche-feld');
feld.type = 'search';
feld.placeholder = 'Suchen – Aufgaben, Gespräche, Dateien, Profile …';
feld.setAttribute('aria-label', 'Suchbegriff');
feld.maxLength = 100;
const zu = el('button', 'suche-zu', 'Esc');
zu.type = 'button';
zeile.append(feld, zu);
const ergebnis = el('div', 'suche-ergebnis');
ergebnis.setAttribute('aria-live', 'polite');
fenster.append(zeile, ergebnis);
schleier.append(fenster);
document.body.append(schleier);
const zeigeHinweis = (text) => {
ergebnis.textContent = '';
ergebnis.append(el('p', 'suche-hinweis', text));
};
let letzte = 0;
async function suche() {
const q = feld.value.trim();
if (q.length < 2) { zeigeHinweis('Mindestens zwei Zeichen.'); return; }
const meine = ++letzte;
try {
const a = await fetch('/workspace/api/suche?q=' + encodeURIComponent(q),
{ credentials: 'same-origin' });
if (meine !== letzte) return; // eine neuere Abfrage ist schon unterwegs
if (a.status === 401) { location.assign('/workspace/'); return; }
if (!a.ok) { zeigeHinweis('Die Suche ist gerade nicht erreichbar.'); return; }
zeichne(await a.json());
} catch {
if (meine === letzte) zeigeHinweis('Die Suche ist gerade nicht erreichbar.');
}
}
function zeichne(d) {
ergebnis.textContent = '';
if (!d.gesamt) {
zeigeHinweis(`Nichts gefunden zu „${d.frage}".`);
return;
}
for (const g of d.gruppen) {
const s = el('section', 'suche-gruppe');
s.append(el('h3', 'suche-gruppe__titel', g.titel));
for (const tr of g.treffer) {
const a = el('a', 'suche-treffer');
a.href = tr.ziel;
a.append(el('span', 'suche-treffer__titel', tr.titel));
if (tr.zusatz) a.append(el('span', 'suche-treffer__zusatz', tr.zusatz));
if (tr.text) a.append(el('span', 'suche-treffer__text', tr.text));
s.append(a);
}
ergebnis.append(s);
}
}
let warte = null;
feld.addEventListener('input', () => {
clearTimeout(warte);
warte = setTimeout(suche, 220);
});
function auf() {
schleier.hidden = false;
document.body.classList.add('suche-offen');
feld.focus();
feld.select();
if (!feld.value.trim()) zeigeHinweis('Tippen, um alle Bereiche zu durchsuchen.');
}
function zuMachen() {
schleier.hidden = true;
document.body.classList.remove('suche-offen');
knopf.focus();
}
knopf.addEventListener('click', auf);
zu.addEventListener('click', zuMachen);
schleier.addEventListener('mousedown', (e) => { if (e.target === schleier) zuMachen(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !schleier.hidden) { zuMachen(); return; }
/* "/" oeffnet die Suche -- aber nur, wenn man nicht ohnehin
gerade in ein Feld schreibt. */
if (e.key !== '/' || schleier.hidden === false) return;
const z = document.activeElement;
if (z && (z.tagName === 'INPUT' || z.tagName === 'TEXTAREA' || z.tagName === 'SELECT'
|| z.isContentEditable)) return;
e.preventDefault();
auf();
});
}
})();
/* ===================================================================
"Zeig mir genau das" -- der Sprung von einer Zahl zu dem, was sie zaehlt.
Wunsch vom 01.09.2026 zur Report-Seite: "wenn man auf die Kisten
drueckt, will ich sofort zu der Seite -- diesem Punkt --
weitergeschickt werden, wo man draufgedrueckt hat."
Eine Zahl auf einem Bericht ist eine Behauptung. Wer sie nicht
nachsehen kann, muss sie glauben -- und wer sie nachsehen will, sucht
sie auf der Zielseite von Hand zusammen. Genau dieser Weg faellt hier
weg.
Die Zahl fuehrt deshalb nicht nur auf die richtige SEITE, sondern auf
die richtige STELLE: ?zeigen=<schluessel> in der Adresse, die
Zielseite sucht die passenden Elemente, springt hin und hebt sie kurz
hervor.
EIN Helfer fuer alle Seiten und nicht fuenf eigene: Sonst springt die
eine Seite hart, die andere weich, die dritte hebt gar nichts hervor
-- und niemand traut dem Weg mehr.
Wichtig ist die RUECKNAHME. Eine Seite, die nach dem Sprung dauerhaft
gefiltert bleibt, ist eine Falle: Man kommt spaeter wieder, sieht
drei von zwanzig Aufgaben und haelt das fuer den ganzen Bestand.
Deshalb steht immer eine Zeile da, die sagt, was gerade gezeigt wird
-- mit einem Weg zurueck zu allem.
=================================================================== */
(() => {
'use strict';
const ZEIGEN = new URLSearchParams(location.search).get('zeigen') || '';
/** Was steht in der Adresse? Leerer Text, wenn nichts. */
window.zielSchluessel = () => ZEIGEN;
/** Hervorheben und hinspringen.
* elemente: was gemeint war (Feld, darf leer sein)
* text: was gerade gezeigt wird, in Worten
* aufraeumen: optional -- wird gerufen, wenn "alles zeigen" geklickt wird */
window.zielMarkieren = function (elemente, text, aufraeumen) {
const liste = [...(elemente || [])].filter(Boolean);
/* Die Zeile kommt IMMER, auch wenn nichts gefunden wurde. Ein
stiller Sprung ins Leere laesst einen glauben, man sei falsch
abgebogen -- dabei ist die Sache schlicht erledigt. */
const leiste = document.createElement('div');
leiste.className = 'ziel-leiste';
leiste.setAttribute('role', 'status');
const marke = document.createElement('span');
marke.className = 'ziel-leiste__marke';
marke.textContent = liste.length ? String(liste.length) : '0';
leiste.append(marke);
const satz = document.createElement('span');
satz.className = 'ziel-leiste__text';
satz.textContent = liste.length
? text
: text + ' – hier ist gerade nichts davon da.';
leiste.append(satz);
const weg = document.createElement('button');
weg.type = 'button';
weg.className = 'ziel-leiste__weg';
weg.textContent = 'Alles zeigen';
weg.addEventListener('click', () => {
for (const e of liste) e.removeAttribute('data-treffer');
leiste.remove();
/* Die Adresse mitaufraeumen: Sonst zeigt ein Neuladen wieder den
Ausschnitt, und man wundert sich, warum die Haelfte fehlt. */
const u = new URL(location.href);
u.searchParams.delete('zeigen');
history.replaceState(null, '', u);
aufraeumen?.();
});
leiste.append(weg);
const inhalt = document.querySelector('main.inhalt') || document.body;
const nach = inhalt.querySelector('.kopf-zeile');
if (nach && nach.nextSibling) inhalt.insertBefore(leiste, nach.nextSibling);
else inhalt.prepend(leiste);
for (const e of liste) e.dataset.treffer = 'ja';
/* Erst im naechsten Bild springen: Die Elemente sind gerade eben
entstanden, ihre Position steht noch nicht fest. */
if (liste.length) {
requestAnimationFrame(() => {
const ruhig = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
liste[0].scrollIntoView({
block: 'center', behavior: ruhig ? 'auto' : 'smooth',
});
});
}
return liste.length;
};
})();
/* ===================================================================
DIE SICHT EINES ANDEREN — nur fuer DogFather, auf jeder Seite.
Wunsch vom 01.09.2026: "ich will alles von den Scouts und Managern
einsehen koennen, auswaehlen, was und von wem ich sehen will. Und das
soll ich als DogFather-Rolle ueberall haben, auf jeder Seite.
NUR WIR BEIDE SOLLEN DIESE OPTION HABEN."
ZWEI ENTSCHEIDUNGEN, die alles andere nach sich ziehen.
1. DIE PERSON WIRD GETAUSCHT, NICHT DIE REGEL.
Waehlt DogFather einen Scout, laeuft jede vorhandene
Sichtbarkeitsregel auf dem Server unveraendert mit diesem Scout --
er sieht dann exakt dessen Arbeitsplatz. Kein neuer Filter je
Seite, keine zweite Rechteregel, die irgendwann von der ersten
abweicht.
2. EIN EINZIGER ORT IM BROWSER.
Statt in vierzehn Dateien "&sicht=" anzuhaengen (und es in der
fuenfzehnten zu vergessen), wird fetch EINMAL umgeleitet: Jede
lesende Anfrage an /workspace/api bekommt den Wert automatisch
mit. Neue Seiten sind damit von selbst dabei -- man kann es gar
nicht vergessen.
Angehaengt wird NUR bei GET. Ein Schreibvorgang findet immer im
eigenen Namen statt; alles andere waere ein Eintrag im Protokoll
unter falschem Namen.
Die Auswahl liegt im Browser (localStorage), nicht am Konto: Sie ist
eine Ansicht dieses Geraets, keine Eigenschaft der Person. Wer sich
woanders anmeldet, faengt bei "alles" an -- und das ist richtig so.
Der Server prueft die Rolle NOCH EINMAL (workspace.js, sichtPerson).
Was hier steht, ist Bedienung, kein Schutz: Wer kein DogFather ist,
kann den Wert von Hand anhaengen, so oft er mag -- er wird ignoriert.
=================================================================== */
(() => {
'use strict';
/* Welcher Kalendertag ist heute -- nach der ORTSZEIT.
Steht in kopf.js, weil die Datei auf jeder Workspace-Seite geladen
wird und VOR den Seitenskripten laeuft. Vorher rechnete jede Seite
selbst, und vier von fuenf rechneten in UTC: Zwischen Mitternacht
und 2 Uhr war "heute" dort der Vortag.
Fuers RECHNEN mit Datumsangaben bleibt es bei UTC-Mittag (siehe
kalender.js) -- das ueberlebt die Zeitumstellung. Hier geht es nur
um die Frage "welcher Tag ist gerade". */
window.heuteLokal = function () {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
};
const SCHLUESSEL = 'dfw-sicht';
const lesen = () => {
try { return Number(localStorage.getItem(SCHLUESSEL)) || 0; } catch { return 0; }
};
const schreiben = (id) => {
try {
if (id) localStorage.setItem(SCHLUESSEL, String(id));
else localStorage.removeItem(SCHLUESSEL);
} catch { /* privater Modus -- dann gilt es nur fuer diese Seite */ }
};
window.sichtId = lesen;
/* ---------- fetch einmal umleiten ------------------------------------- */
/* SOFORT gesetzt, nicht erst beim Bauen der Kopfleiste.
Genau das war der Fehler hinter "ich hab die Ansicht von Tili
ausgewaehlt und seh immer noch die Seite genau wie meine": `aktiv`
wurde erst in sichtAufbauen() gefuellt, und das laeuft ueber
werZeigen() -- also NACHDEM die Seite ihre erste Abfrage schon
abgeschickt hat. Die allererste Anfrage (/api/ich, aus der Kacheln,
Rolle und Begruessung entstehen) ging damit immer ohne die gewaehlte
Sicht hinaus.
Ein Fehler der REIHENFOLGE, nicht der Logik -- und deshalb einer,
den man im Quelltext nicht sieht: Beide Zeilen sind fuer sich
richtig. */
let aktiv = lesen();
const echtesFetch = window.fetch.bind(window);
window.fetch = function (eingabe, optionen) {
try {
const art = (optionen?.method || (typeof eingabe === 'object' ? eingabe.method : '') || 'GET')
.toUpperCase();
const pfad = typeof eingabe === 'string' ? eingabe : (eingabe?.url || '');
if (aktiv && art === 'GET' && pfad.startsWith('/workspace/api/')
&& !pfad.includes('sicht=')) {
const neu = pfad + (pfad.includes('?') ? '&' : '?') + 'sicht=' + aktiv;
return echtesFetch(typeof eingabe === 'string' ? neu : new Request(neu, eingabe), optionen);
}
} catch { /* im Zweifel unveraendert weiterreichen */ }
return echtesFetch(eingabe, optionen);
};
/* ---------- Der Umschalter im Seitenkopf -------------------------------- */
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
const ROLLE_GRUPPE = [
['manager', 'Manager'],
['scout', 'Scouts'],
['creator', 'Creator'],
['admin', 'DogFather'],
];
/* EIN WETTLAUF, den es wirklich gab: Auf profil.html rufen zwei Dateien
werZeigen() auf (profil.js und steckbrief.js). Beide kamen an der
Pruefung "gibt es den Umschalter schon?" vorbei, BEVOR eine von
ihnen ihn angehaengt hatte -- dazwischen liegt ein await auf die
Personenliste. Ergebnis: zwei Umschalter uebereinander, der eine
verdeckte den anderen. Gemeldet von pruef-handy als "verdeckt".
Die Sperre muss deshalb VOR dem ersten await gesetzt werden. */
let baut = false;
window.sichtAufbauen = async function (ich) {
if (!ich || ich.rolle !== 'admin') return;
const kopf = document.querySelector('.kopfleiste__rechts');
if (!kopf || baut || document.getElementById('sicht-wahl')) return;
baut = true;
aktiv = lesen(); /* nochmals lesen -- ein anderer Reiter kann sie geaendert haben */
let personen = [];
try {
/* Ueber das echte fetch -- diese eine Abfrage darf nicht durch die
gewaehlte Sicht laufen, sonst waere die Auswahlliste selbst
gefiltert und man kaeme nie wieder heraus. */
const a = await echtesFetch('/workspace/api/personen', { credentials: 'same-origin' });
if (!a.ok) { baut = false; return; }
personen = (await a.json()).personen || [];
} catch { baut = false; return; }
const feld = el('div', 'sicht');
feld.id = 'sicht-wahl';
/* EIN AUGE STATT DES WORTES "SICHT" (06.09.2026).
Vorher stand da ein Etikett mit dem Wort SICHT und daneben ein
Auswahlfeld -- ein Formular, das in die Kopfleiste gerutscht war.
Das Auge sagt dasselbe in einem Zeichen und laesst dem Namen den
Platz, um den es geht.
aria-hidden, weil der Knopf daneben bereits sagt, was er tut --
ein Vorleseprogramm soll nicht "Grafik, Auge" davor lesen. */
const auge = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
auge.setAttribute('class', 'sicht__auge');
auge.setAttribute('viewBox', '0 0 24 24');
auge.setAttribute('aria-hidden', 'true');
auge.innerHTML = '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/>'
+ '<circle cx="12" cy="12" r="3.2"/>';
feld.append(auge);
/* Fuer Vorleseprogramme bleibt die Beschriftung erhalten -- sie ist
nur nicht mehr zu sehen. */
const schild = el('label', 'sicht__schild', 'Sicht');
schild.setAttribute('for', 'sicht-feld');
feld.append(schild);
const s = el('select', 'sicht__feld');
s.id = 'sicht-feld';
/* "Meine Sicht" statt "Alles (meine Sicht)". Der Zusatz erklaerte
nichts und machte den Knopf so breit, dass am Handy nichts anderes
mehr danebenpasste. */
s.append(new Option('Meine Sicht', ''));
for (const [rolle, name] of ROLLE_GRUPPE) {
const drin = personen.filter((p) => p.rolle === rolle && p.id !== ich.id && p.aktiv !== false);
if (!drin.length) continue;
const g = document.createElement('optgroup');
g.label = name;
for (const p of drin) g.append(new Option(p.name, String(p.id)));
s.append(g);
}
if (aktiv) s.value = String(aktiv);
/* Stand die gemerkte Person nicht mehr in der Liste (geloescht oder
abgeschaltet), faellt die Auswahl zurueck -- sonst zeigte das Feld
"Alles" und die Seiten trotzdem eine fremde Sicht. */
if (aktiv && s.value !== String(aktiv)) { aktiv = 0; schreiben(0); }
s.addEventListener('change', () => {
aktiv = Number(s.value) || 0;
schreiben(aktiv);
/* Neu laden statt jede Seite einzeln zu benachrichtigen: Jede
Seite baut sich beim Laden ohnehin vollstaendig auf, und ein
halb umgestellter Bildschirm waere schlimmer als eine kurze
Pause. */
location.reload();
});
feld.append(s);
/* Ein deutlicher Hinweis, SOLANGE eine fremde Sicht aktiv ist.
Ohne ihn ist der gefaehrlichste Fall dieser Funktion: Man vergisst
die Auswahl, sieht am naechsten Tag drei Aufgaben statt dreissig
und haelt das fuer den Bestand. */
if (aktiv) {
feld.dataset.fremd = 'ja';
/* EIN KREUZ STATT "zurueck zu mir". Der Satz brauchte mehr Platz
als der Name daneben, und was ein Kreuz an einer aktiven
Auswahl tut, weiss jeder. Die Worte bleiben im aria-label. */
const zurueck = el('button', 'sicht__weg');
zurueck.type = 'button';
zurueck.setAttribute('aria-label', 'Zurück zu meiner eigenen Sicht');
zurueck.title = 'Zurück zu meiner eigenen Sicht';
const kreuz = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
kreuz.setAttribute('viewBox', '0 0 24 24');
kreuz.setAttribute('aria-hidden', 'true');
kreuz.innerHTML = '<path d="M7 7l10 10M17 7L7 17"/>';
zurueck.append(kreuz);
zurueck.addEventListener('click', () => { schreiben(0); location.reload(); });
feld.append(zurueck);
document.body.dataset.fremdeSicht = 'ja';
}
kopf.prepend(feld);
};
/* An werZeigen anhaengen: Jede Seite ruft es ohnehin auf, sobald sie
weiss, wer angemeldet ist. Damit ist der Umschalter wirklich auf
JEDER Seite da, ohne dass eine davon eine Zeile bekommt. */
let gerufen = false;
const vorher = window.werZeigen;
window.werZeigen = function (ich) {
gerufen = true;
vorher?.(ich);
window.sichtAufbauen(ich);
};
/* DAS NETZ DARUNTER.
"Jede Seite ruft es ohnehin auf" stimmte nicht ganz: steckbrief.html
tat es nie, und deshalb stand dort oben rechts monatelang "…" statt
des eigenen Namens. Gefunden hat es nicht das Auge, sondern eine
Pruefung, die den Umschalter auf ALLEN Seiten verlangt hat.
Ein Fehler, der einmal passiert ist, passiert wieder -- die
naechste neue Seite vergisst dieselbe Zeile. Meldet sich also nach
kurzer Zeit niemand, holt der Kopf sich selbst, wer angemeldet ist.
Im Normalfall kostet das nichts: Dann war jemand schneller, und
diese Abfrage findet gar nicht erst statt. */
setTimeout(async () => {
if (gerufen || !document.getElementById('wer')) return;
try {
const a = await echtesFetch('/workspace/api/ich', { credentials: 'same-origin' });
if (a.ok) window.werZeigen(await a.json());
} catch { /* nicht angemeldet -- die Seite leitet ohnehin um */ }
}, 1200);
/* =====================================================================
ZURÜCK-TASTE: die Seite neu holen statt aus der Schublade nehmen
(02.09.2026)
Gemeldet: "wie du siehst hab ich es gemacht und wird es immer noch
angezeigt" -- ein Protokoll war geschrieben, die Startseite meldete
trotzdem weiter "1 Gespräch hat noch kein Protokoll".
Nachgestellt: Der Server lag richtig. Der Hinweis erscheint, sobald
ein Gespräch ohne Protokoll vorbei ist, und verschwindet in dem
Moment, in dem das Protokoll steht. Falsch war der BILDSCHIRM.
Browser legen eine verlassene Seite komplett beiseite (bfcache) und
holen sie beim Zurückgehen unverändert wieder hervor -- mitsamt
allen Zahlen, die beim ersten Laden geholt wurden. Kein Skript läuft
dabei erneut. Wer also ein Protokoll schreibt und dann auf "Zurück"
tippt, sieht zwangsläufig den Stand von vorher.
Das ist kein Schönheitsfehler: Eine Zahl, die etwas Falsches
behauptet, ist schlimmer als gar keine -- man glaubt ihr ja. Genau
diese Sorte Fehler kostet danach Vertrauen in ALLE Zahlen.
`persisted` unterscheidet die beiden Fälle sauber: true heisst
"aus der Schublade geholt", false heisst "ganz normal geladen".
Nur im ersten Fall wird neu geladen -- ein pauschales Neuladen bei
jedem Anzeigen wäre eine Endlosschleife. */
addEventListener('pageshow', (e) => {
if (e.persisted) location.reload();
});
})();