DER GRUND JEDER KACHEL IM HAUS VON TEAM DOGI
Filipe: "ich will dass die hintergrunde von den kacheln immer unviersum
artig ist, es muss richtig geil sein aber immer so dass man alles noch
gut erkkent. und das IN DER GANZEN WEBSITE VON TEAM DOGI. nur die
kacheln. [...] wichtig ist die form der kacheln soll gleich bleiben."
module.css hat eine kanonische KACHELLISTE -- 48 Klassen, siebenmal in
der Datei, von pruef-css-klassen gegeneinander gehalten. Eine achte
Abschrift in crew-haus.css waere die Sorte Fehler, die nicht auffaellt:
heute vollstaendig, bei der naechsten neuen Kachel lautlos nicht mehr.
Deshalb faerbt crew-haus.css keine einzige Kachel. module.css baut den
Grund jetzt aus vier Werten (--sternenfeld, -mass, -lage,
--modul-schleier), und das zweite Haus setzt nur diese vier um. Damit
hat jede Kachel der ganzen Adresse den Himmel -- auch die, die es noch
nicht gibt. Im Agenturhaus steht `none`: kein Pixel aendert sich.
ZWEI DINGE HAT ERST DIE MESSUNG GEFUNDEN, NICHT DAS NACHDENKEN:
1. Die Nebel standen zuerst oben links. Dort ist aber JEDE Kachel dieses
Hauses schon von sich aus am hellsten -- ihr eigener Lichtverlauf
laeuft bei allen aus derselben Richtung (155/150/158 Grad) --, und
genau dort stehen ueberall die Ueberschriften. Hinter der leisesten
Textzeile lagen dadurch 2,09 % der Bildpunkte unter 4,5:1; im
Agenturhaus sind es an derselben Stelle 0,115 %. Nach dem Umzug in
die beiden gegenueberliegenden Ecken: 0,22 % -- und der Nebel durfte
dabei KRAEFTIGER werden (.80 statt .62), weil er nicht mehr auf dem
hellsten Punkt liegt. Besser lesbar und deutlicher zu sehen; das ist
selten und war hier umsonst zu haben.
2. Kleinere, dafuer hellere Sternkerne waren der falsche Weg: Der
hellste Punkt blieb fast gleich, der Stern wurde nur unschaerfer.
Entschieden hat die Deckkraft, nicht die Groesse.
Form unangetastet: Fase, Silhouette und die drei Eckwinkel werden vor
und nach dem Hauswechsel Zeichen fuer Zeichen verglichen.
pruef-kachel-universum.mjs (NEU, 37 Pruefungen, Port 4391) misst an
echten Bildpunkten und fragt nicht nach Durchschnitt allein, sondern
nach dem ANTEIL der Punkte unter 4,5:1 -- das unterscheidet einen Punkt
von einer Flaeche. Zwei Gegenproben: ein zu dunkler Text UND ein zu
heller Nebel muessen durchfallen.
MEINE SICHT -- "GENAU SO WIE SIE ES SEHEN"
Filipe: "oben bei meine sicht soll ich auch die sicht von allen jeden
moment sehen koennen und das genau genau so wie sie es sehen alles.
ausser die kalender daten oder chat daten wo ich nicht mit drin bin.."
Gemessen wurde nicht "mit Umschalter gegen ohne" -- das ist bei duenner
Datenlage ueberall gleich und beweist nichts. Gemessen wurde die
Antwort mit Umschalter gegen die Antwort, die die Person SELBST bekommt.
Das hat sechs Stellen gefunden:
* workspace-zentrale.js las `req.person.sicht` -- ein Feld, das es nicht
gibt. Der Ausdruck war immer `undefined || req.person`, daneben ein
ausfuehrlicher Kommentar, der genau das Richtige beschrieb. Die grosse
Kachel zeigte verlaesslich die eigene Lage, waehrend die Zahlen
darunter der fremden folgten -- zwei Wahrheiten in einer Kachel. Ein
Tippfehler in einem Variablennamen macht nichts kaputt; er tut nur
nichts, und genau deshalb faellt so etwas nie von selbst auf. Die
Route hatte ausserdem ZWEI Personenvariablen; jetzt hat sie eine.
* sichtPerson() gab die angesehene Person ohne Feld `haus` zurueck --
und nurHaus()/hausBedingung() fangen beide mit `haus !== "crew"` an.
Jede fremde Sicht war damit eine Agentursicht: In der Sicht auf einen
Modi kamen die Dateien, Personen und Berichte des anderen Hauses.
Das Haus haengt jetzt an der ROLLE, nicht an der Adresse.
* leistung, profil, schulung, fruehwarnung, report und teamlage lasen
weiterhin den Angemeldeten. Nur LESEN ist umgestellt, nie ein Recht --
und weil DogFather ohnehin alles sehen darf, kann das nichts oeffnen,
nur weniger zeigen.
Der Sicht-Umschalter zeichnete ausserdem nur die fuenf Rollen aus
bereiche.js; wer eine sechste hat, stand nicht darin. Dieselbe Luecke
wie in der Chat-Auswahl und der Personenliste, zum dritten Mal. Die
Ueberschrift kommt jetzt vom Server (`gruppe`), der Browser zeichnet,
was ankommt -- auch eine Rolle, deren Namen er nicht kennen darf.
DREI STELLEN FOLGEN BEWUSST NICHT: die Personenliste (aus ihr wird der
Umschalter gebaut -- folgte sie der Sicht, kaeme man aus einer fremden
nicht mehr heraus), die Auswahllisten beim Anlegen (Kategorien,
Empfaenger) und steckbrief/mein (ein Formular, das fremd liest und
eigen speichert, zerstoert Daten).
KALENDER UND CHAT BLEIBEN PRIVAT, auch mit Umschalter -- Termine,
Calls und Wiederholungen lesen ab jetzt immer die eigene Person. Eine
Pruefung musste dafuer umgedreht werden: pruef-sicht verlangte bis
heute das Gegenteil ("dafuer gibt es den Umschalter"). Die Gegenprobe
bleibt dieselbe Frage, nur andersherum -- Patrick selbst MUSS seine
Termine sehen, sonst hiesse "DogFather sieht sie nicht" nur, dass sie
niemand sieht. Dabei fiel auf, dass die Managerin gar keinen Call
hatte: Die Pruefung "bleibt privat" war nicht bestanden, sondern nicht
durchfuehrbar. Antwort darauf sind Daten, keine weichere Bedingung.
Gruen: pruef-sicht 84 (vorher 53), pruef-kachel-universum 37 (neu),
pruef-rollen 282, pruef-crew-adresse 129, pruef-start-ansicht 143,
pruef-kalender 104, pruef-haus-trennung 62, pruef-team-ampel 32,
pruef-team-stufen 26, pruef-css-klassen. Stempel 202609110209.
Co-Authored-By: Claude Opus 5 <[email protected]>
1536 lines
67 KiB
JavaScript
1536 lines
67 KiB
JavaScript
/* ===================================================================
|
||
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 || '';
|
||
|
||
/* DIE ROLLE AUCH ANS <html> (08.09.2026).
|
||
|
||
Damit gilt die Farbe der angemeldeten Rolle fuer die GANZE Seite
|
||
und nicht nur fuer dieses eine Zeichen -- die Umschaltung steht in
|
||
gate.css bei `html[data-rolle=...]`.
|
||
|
||
Warum am <html> und nicht am <body>: Das <html> steht schon, bevor
|
||
irgendetwas geladen ist. Und warum ueberhaupt an einem so weit
|
||
oben liegenden Element: Wer die Farbe an jedem einzelnen Baustein
|
||
umschaltet, vergisst den naechsten, der dazukommt. Hier erbt alles
|
||
mit, auch was es heute noch gar nicht gibt. */
|
||
if (ich.rolle) document.documentElement.dataset.rolle = ich.rolle;
|
||
|
||
/* DER NAME IN DER KOPFLEISTE (10.09.2026).
|
||
|
||
Auf jeder Seite steht dort fest "Creator Workspace · <Seite>".
|
||
Fuer wen das nicht stimmt, schickt der Server einen anderen Namen
|
||
mit -- WER das ist, steht ausdruecklich nicht in dieser Datei:
|
||
Sie bekommt jeder, der die Seite oeffnet.
|
||
|
||
Nur der Verweis wird umgeschrieben, nicht die ganze Zeile: Hinter
|
||
ihm steht noch " · Aufgaben" bzw. der Name der jeweiligen Seite,
|
||
und der bleibt richtig.
|
||
|
||
MIT GESCHUETZTEM LEERZEICHEN, wie im HTML. Die Kopfleiste rechnet
|
||
damit, dass dieser Name nicht umbricht -- ein gewoehnliches
|
||
Leerzeichen liesse ihn auf schmalen Handys in zwei Zeilen fallen
|
||
und die Leiste mitwachsen. */
|
||
if (ich.marke) {
|
||
const markeText = String(ich.marke).replace(/ /g, ' ');
|
||
/* ZWEI BAUFORMEN, UND DIE ZWEITE FEHLTE.
|
||
|
||
Auf den Unterseiten steht in der Kopfleiste ein Rueckweg
|
||
("Creator Workspace · Aufgaben") -- dort wird der Verweistext
|
||
ersetzt. Die STARTSEITE hat keinen; dort steht der Name als
|
||
blosser Text ("Spicy & Dogi · Creator Workspace").
|
||
|
||
Diese Zeile fehlte, und man sah es sofort: Im Bildschirmfoto
|
||
betroffenen Startseite stand oben links "SPICY & DOGI ·
|
||
CREATOR WORKSPACE". Reitertitel, Begruessung und Symbol waren
|
||
laengst richtig -- nur die Leiste, die man als Erstes ansieht,
|
||
nicht.
|
||
|
||
`textContent` zu setzen leert dabei die Aufteilung in
|
||
Namensteil und Raute, die weiter unten gebaut wird. Das ist
|
||
gewollt: Wo nur EIN Name steht, gibt es nichts zu trennen. */
|
||
for (const text of document.querySelectorAll('.kopfleiste .marke__text')) {
|
||
const verweis = text.querySelector('.zurueck');
|
||
if (verweis) { verweis.textContent = markeText; continue; }
|
||
text.textContent = markeText;
|
||
}
|
||
|
||
/* DER REITER UND DAS ZEICHEN GEHOEREN DAZU (10.09.2026).
|
||
|
||
Die Kopfleiste allein reicht nicht. In jeder Seite steht fest
|
||
<title>Aufgaben · Spicy & Dogi</title> und daneben das Zeichen
|
||
des Workspace -- beides sieht man als Erstes, im Browserreiter
|
||
und, wenn die Seite als App laeuft, im Fensterrahmen und beim
|
||
Umschalten zwischen Apps.
|
||
|
||
WARUM HIER UND NICHT IN 30 HTML-DATEIEN: Der Name kommt vom
|
||
Server und haengt an der Person. Er kann also gar nicht fest im
|
||
HTML stehen -- und 30 Dateien zu pflegen hiesse, die 31. zu
|
||
vergessen.
|
||
|
||
Der Seitenname bleibt stehen, nur der Teil hinter dem Punkt
|
||
wechselt. Auf der Startseite steht dort der Name des Bereichs
|
||
selbst; dann bleibt nur die Marke uebrig, statt sie doppelt zu
|
||
lesen. */
|
||
const marke = String(ich.marke);
|
||
const stueck = document.title.split(' · ');
|
||
const seitenname = stueck.slice(0, -1).join(' · ').trim();
|
||
document.title = (!seitenname || seitenname === 'Creator Workspace')
|
||
? marke
|
||
: `${seitenname} · ${marke}`;
|
||
|
||
/* Das Zeichen: derselbe Stempel, anderer Name. Ihn aus der
|
||
vorhandenen Adresse zu ersetzen statt ihn neu zu bauen haelt
|
||
die Versionsnummer daran -- sonst laedt der Browser ab hier ein
|
||
Bild ohne Stempel und behaelt es ein Jahr. */
|
||
for (const zeichen of document.querySelectorAll(
|
||
'link[rel="icon"], link[rel="apple-touch-icon"]')) {
|
||
const alt = zeichen.getAttribute('href') || '';
|
||
if (alt.includes('/workspace-')) {
|
||
zeichen.setAttribute('href', alt.replace('/workspace-', '/crew-'));
|
||
}
|
||
}
|
||
|
||
/* DIE FARBE DER BROWSERLEISTE (10.09.2026).
|
||
|
||
Am Handy faerbt Chrome die Leiste ueber der Seite nach
|
||
<meta name="theme-color">. Der Wert steht als feste Zeile im
|
||
HTML und ist von CSS nicht erreichbar -- auf dem Handy hatte
|
||
das zweite Haus damit oben einen Streifen aus dem ersten.
|
||
|
||
Der Wert wird NICHT hier hingeschrieben, sondern aus
|
||
`--haus-leiste` gelesen: Dann steht er genau einmal da, naemlich
|
||
im Stilblatt des Hauses, zusammen mit allen anderen Farben.
|
||
Ein zweiter Ort waere der, an dem es beim naechsten Farbwechsel
|
||
hakt.
|
||
|
||
Und er wird nur gesetzt, wenn das Haus ihn auch liefert: Auf der
|
||
Agenturadresse ist die Angabe leer, dann bleibt alles, wie es
|
||
ist. Kein `else`, keine zweite Wahrheit. */
|
||
const leiste = getComputedStyle(document.documentElement)
|
||
.getPropertyValue('--haus-leiste').trim();
|
||
const feld = document.querySelector('meta[name="theme-color"]');
|
||
if (leiste && feld) feld.setAttribute('content', leiste);
|
||
}
|
||
|
||
/* 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;
|
||
/* UND WER ZU TEAM DOGI GEHOERT (11.09.2026).
|
||
|
||
Filipe: "dan brauch ich auch noch einen teilen button in der
|
||
leiste." Auf der Team-Adresse fehlte er -- `DARF_TEILEN` kennt
|
||
Leitung und Scouts, und die rechte Hand ist keins von beiden.
|
||
|
||
GEFRAGT WIRD NICHT NACH DER ROLLE, sondern nach `ich.marke`. Die
|
||
setzt der Server genau dann, wenn jemand zu Team Dogi gehoert
|
||
ODER auf dessen Adresse steht -- und sie steht hier ohnehin schon
|
||
im Zugriff. Der Rollenname bleibt damit aus dieser Datei heraus,
|
||
und zwar nicht als Trick, sondern weil die Frage wirklich lautet:
|
||
"gehoert diese Person hierher?"
|
||
|
||
Teilen gibt ohnehin nur die ADRESSE der Seite weiter -- es
|
||
verraet nichts, was der Angemeldete nicht ohnehin sieht. */
|
||
if (!DARF_TEILEN.has(ich.rolle) && !ich.marke) 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';
|
||
/* Ohne den Namen des Workspace: Auf der zweiten Adresse heisst der
|
||
Bereich anders, und "Adresse dieser Seite" stimmt ueberall. */
|
||
k.title = 'Adresse dieser Seite 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) {
|
||
/* Der Name kommt aus derselben Quelle wie die Kopfleiste.
|
||
Steht dort nichts, bleibt es beim gewohnten Namen. */
|
||
const wie = document.querySelector('.kopfleiste .marke__text .zurueck');
|
||
const name = (wie?.textContent || '').replace(/ /g, ' ').trim()
|
||
|| 'Creator Workspace';
|
||
await navigator.share({
|
||
title: name,
|
||
text: `${name} · 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',
|
||
/* Die Konsole der Startseite (07.09.2026). Auf ihr ist das Licht
|
||
kein Farbschleier, sondern eine SPIEGELUNG auf Metall -- gesteuert
|
||
von denselben zwei Werten (--mx/--my), gestaltet in heim.css.
|
||
Echtes Metall zeigt seine Form erst, wenn sich das Licht darauf
|
||
bewegt; ein Standbild kann das nie leisten. */
|
||
'.willkommen',
|
||
].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. */
|
||
if (!B) return;
|
||
|
||
const bereich = B.zuSeite(location.pathname, location.search);
|
||
if (!bereich) return;
|
||
|
||
/* ==== ERST DIE SEITE, DANN DIE PLAKETTE ==========================
|
||
|
||
DIE REIHENFOLGE WAR DER FEHLER (behoben 09.09.2026, screen11).
|
||
|
||
Hier stand ganz oben:
|
||
|
||
const kopf = document.querySelector('.kopf-zeile, .k-kopf');
|
||
if (!B || !kopf) return;
|
||
|
||
Damit hing ALLES an der Frage, ob es eine Kopfzeile gibt, an die
|
||
man eine Plakette haengen kann -- auch der Farbton der Seite und
|
||
ihr Hintergrundbild, die damit nichts zu tun haben.
|
||
|
||
`chat.html` hat weder `.kopf-zeile` noch `.k-kopf` (es hat einen
|
||
eigenen Chat-Kopf). Die Seite ist damit an dieser Zeile
|
||
ausgestiegen und hat WEDER ihren Ton NOCH ihre Buehne bekommen --
|
||
obwohl in bereiche.js seit jeher `szene: 'lounge'` fuer sie
|
||
steht. Eine Zuordnung, die es gibt und die nie ankam.
|
||
|
||
Aufgefallen ist es beim Nachmessen der Hintergrundhelligkeit
|
||
aller 18 Seiten: chat.html meldete als eine von dreien
|
||
`data-buehne` = nichts. Im Quelltext sieht die Zeile richtig aus,
|
||
und auf der Seite sieht ein fehlender Hintergrund nach Absicht
|
||
aus -- deshalb hat es Jahre keiner gesehen.
|
||
|
||
Jetzt stehen Ton und Buehne VOR der Plakette. Sie brauchen nur
|
||
den Bereich; die Plakette braucht zusaetzlich einen Kopf, und
|
||
wenn es den nicht gibt, faellt eben nur sie aus. */
|
||
|
||
/* 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 kopf = document.querySelector('.kopf-zeile, .k-kopf');
|
||
if (!kopf) return;
|
||
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');
|
||
/* EIN ZEICHEN NEBEN DAS WORT (09.09.2026).
|
||
------------------------------------------------------------------
|
||
Filipe, mit einem Bildschirmfoto der Kopfleiste vom Handy: "ich
|
||
will dass es ueberall perfektionniert wird ... es soll alles in
|
||
einer reihe sein."
|
||
|
||
Auf seinem Bild stand "Abmelden" allein in einer zweiten Zeile.
|
||
Nachgemessen bei 390 px: Die rechte Gruppe hat dort 275 px Platz
|
||
und braucht 393 -- der Umbruch war die richtige Reaktion auf ein
|
||
Platzproblem, nicht der Fehler. Der Fehler war der Platzbedarf.
|
||
|
||
Das Wort allein ist 93 px breit. Als Zeichen sind es 36. Es
|
||
verschwindet aber nur auf schmalen Bildschirmen (siehe start.css);
|
||
am Rechner steht weiterhin das Wort da, weil dort Platz ist und
|
||
ein Wort immer eindeutiger ist als ein Bild.
|
||
|
||
Der Aufbau steht HIER und nicht in neunzehn HTML-Dateien: kopf.js
|
||
laeuft auf jeder Seite, und eine Aenderung an neunzehn Stellen ist
|
||
achtzehn Gelegenheiten, eine zu vergessen. */
|
||
if (abmelden && !abmelden.dataset.zeichenDa) {
|
||
abmelden.dataset.zeichenDa = 'ja';
|
||
const wort = (abmelden.textContent || 'Abmelden').trim();
|
||
abmelden.textContent = '';
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('class', 'abmelden__zeichen');
|
||
svg.setAttribute('viewBox', '0 0 24 24');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
/* Tuer mit Pfeil hinaus -- das gebraeuchlichste Zeichen dafuer. */
|
||
pfad.setAttribute('d', 'M15 4.5H6.5a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2H15M12.5 12h7m0 0-3-3m3 3-3 3');
|
||
svg.append(pfad);
|
||
const text = document.createElement('span');
|
||
text.className = 'abmelden__text';
|
||
text.textContent = wort;
|
||
abmelden.append(svg, text);
|
||
/* Die Beschriftung bleibt auch dann, wenn das Wort nicht zu sehen
|
||
ist -- sonst waere der Knopf fuer ein Vorleseprogramm leer. */
|
||
abmelden.setAttribute('aria-label', wort);
|
||
if (!abmelden.title) abmelden.title = wort;
|
||
}
|
||
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;
|
||
};
|
||
|
||
/* DIE REIHENFOLGE KOMMT AUS DER ZENTRALEN LISTE (09.09.2026).
|
||
|
||
Hier stand eine eigene: manager, scout, creator, admin. Also VIER
|
||
Rollen -- und Spicy Media fehlte. Wer eine Spicy-Media-Person
|
||
ansehen wollte, fand sie im Umschalter nicht, obwohl der Server sie
|
||
mitliefert. Filipe hat es gemeldet: "ich will da auch noch die
|
||
spicy rolle sehen und die leute in der rolle wie die anderen."
|
||
|
||
Es ist zum vierten Mal derselbe Fehler: eine zweite Fassung einer
|
||
Liste, die es zentral schon gibt. Vorher traf es die Leitungsliste
|
||
(ein Set), die Rollennamen (ein Objekt in fuenf Skripten, im Chat
|
||
stand "UNDEFINED" ueber einem Namen) und die Personenliste. Jedes
|
||
Mal wurde die zentrale Fassung gepflegt und die Kopie vergessen.
|
||
|
||
Deshalb steht hier keine Liste mehr, sondern eine Ableitung:
|
||
`ROLLENFOLGE` gibt die Reihenfolge, `ROLLEN_GRUPPE` die
|
||
Ueberschrift. Kommt morgen eine sechste Rolle dazu, ist sie hier
|
||
ohne eine Zeile Arbeit dabei.
|
||
|
||
ERST BEIM BAUEN GELESEN, nicht beim Laden dieser Datei. bereiche.js
|
||
steht auf allen achtzehn Seiten davor und beide tragen `defer`, die
|
||
Reihenfolge stimmt also -- aber eine Liste, die beim Laden
|
||
stillschweigend leer bliebe, waere ein Ausfall, den niemand sieht.
|
||
Zum Zeitpunkt des Bauens ist die Seite fertig geladen. */
|
||
const rollenGruppen = () => (window.Bereiche?.ROLLENFOLGE || [])
|
||
.map((r) => [r, window.Bereiche?.ROLLEN_GRUPPE?.[r] || r]);
|
||
|
||
/* 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', ''));
|
||
const gezeichnet = new Set();
|
||
for (const [rolle, name] of rollenGruppen()) {
|
||
gezeichnet.add(rolle);
|
||
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);
|
||
}
|
||
|
||
/* UND ALLE UEBRIGEN (11.09.2026).
|
||
|
||
Filipe: "oben bei meine sicht soll ich auch die sicht von allen
|
||
jeden moment sehen koennen."
|
||
|
||
Er konnte es nicht: `rollenGruppen()` kommt aus bereiche.js und
|
||
kennt fuenf Rollen. Wer eine sechste hat, stand nicht in der
|
||
Auswahl -- kein Fehler, keine Luecke, kein Hinweis. Das ist
|
||
DIESELBE Lücke wie in der Chat-Auswahl und in der Personenliste,
|
||
zum dritten Mal an einer dritten Stelle.
|
||
|
||
Die Ueberschrift kommt deshalb vom Server (`p.gruppe`), und was
|
||
hier ankommt, wird gezeichnet -- auch eine Rolle, deren Namen
|
||
diese Datei gar nicht kennen darf. */
|
||
const uebrig = personen.filter(
|
||
(p) => !gezeichnet.has(p.rolle) && p.id !== ich.id && p.aktiv !== false);
|
||
const nachTitel = new Map();
|
||
for (const p of uebrig) {
|
||
const titel = p.gruppe || 'Weitere';
|
||
if (!nachTitel.has(titel)) nachTitel.set(titel, []);
|
||
nachTitel.get(titel).push(p);
|
||
}
|
||
for (const [titel, leute] of nachTitel) {
|
||
const g = document.createElement('optgroup');
|
||
g.label = titel;
|
||
for (const p of leute) 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';
|
||
|
||
/* ---- WESSEN SICHT: FARBE UND ANFANGSBUCHSTABE (09.09.2026) ----
|
||
|
||
Filipe, mit Bildschirmfoto aus der App: "es ist noch nicht alles
|
||
in einer zeile."
|
||
|
||
Gemessen war der Grund NICHT die Breite seines Geraets, sondern
|
||
der Zustand: In der eigenen Sicht klappt der Umschalter auf
|
||
36 px zusammen, in einer fremden bleibt er mit dem Namen darin
|
||
152 px breit. Das sind 116 px mehr, und die passen auf KEINER
|
||
Handybreite mehr in die Reihe -- auch nicht auf 430 px.
|
||
|
||
Meine bisherigen Pruefungen liefen alle in der eigenen Sicht und
|
||
haben deshalb nie etwas gefunden. Sie haben einen Zustand
|
||
gemessen, in dem der Nutzer gar nicht war.
|
||
|
||
Der Name wandert deshalb aus der Leiste in ein eigenes Band
|
||
darunter (siehe unten). Im Knopf bleiben zwei Dinge, die
|
||
zusammen weniger als ein Viertel des Platzes brauchen und mehr
|
||
sagen als ein abgeschnittenes "Miesmus...": die FARBE der Rolle
|
||
und der ANFANGSBUCHSTABE des Namens.
|
||
|
||
Die Farbe kommt aus `data-rolle` -- dieselbe Zuordnung, die
|
||
gate.css ohnehin fuer jede Rolle hat. Keine zweite Liste; im
|
||
Haus ist genau die schon mehrfach auseinandergelaufen. */
|
||
const wen = personen.find((p) => p.id === aktiv);
|
||
if (wen?.rolle) feld.dataset.rolle = wen.rolle;
|
||
const name = (wen?.name || '').trim();
|
||
const kuerzel = el('span', 'sicht__kuerzel', (name.charAt(0) || '?').toUpperCase());
|
||
/* Fuer ein Vorleseprogramm steht der ganze Name im Band darunter
|
||
und im Auswahlfeld -- ein einzelner Buchstabe waere dort nur
|
||
Rauschen. */
|
||
kuerzel.setAttribute('aria-hidden', 'true');
|
||
auge.after(kuerzel);
|
||
|
||
/* 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';
|
||
|
||
/* ---- DAS BAND UNTER DER LEISTE --------------------------------
|
||
|
||
Es traegt, was aus der Leiste weichen musste: den vollen Namen
|
||
und den Weg zurueck -- beides in Worten statt als Kreuz. Auf dem
|
||
Handy ist es die einzige Stelle, an der der Name steht, und es
|
||
steht dort GANZ, nicht als "Miesmus...".
|
||
|
||
Es gehoert nicht in die Kopfleiste, sondern darunter: In der
|
||
Leiste waere es die zweite Zeile, die weg soll. Als eigenes
|
||
Band ist es eine Meldung zur Lage, kein Bedienelement mehr --
|
||
und es scrollt mit, waehrend der Rahmen um die Seite als
|
||
staendiges Zeichen bleibt.
|
||
|
||
Auf dem Rechner ist es ueberfluessig, dort steht der Name im
|
||
Umschalter selbst. Das entscheidet das CSS an einer Stelle. */
|
||
const band = el('div', 'fremd-band');
|
||
band.id = 'fremd-band';
|
||
if (wen?.rolle) band.dataset.rolle = wen.rolle;
|
||
band.setAttribute('role', 'status');
|
||
|
||
const bandAuge = auge.cloneNode(true);
|
||
bandAuge.setAttribute('class', 'fremd-band__auge');
|
||
band.append(bandAuge);
|
||
|
||
const satz = el('p', 'fremd-band__text');
|
||
satz.append(document.createTextNode('Du siehst gerade die Sicht von '));
|
||
satz.append(el('strong', 'fremd-band__name', name || 'jemand anderem'));
|
||
band.append(satz);
|
||
|
||
const raus = el('button', 'fremd-band__weg', 'Zurück zu meiner Sicht');
|
||
raus.type = 'button';
|
||
raus.addEventListener('click', () => { schreiben(0); location.reload(); });
|
||
band.append(raus);
|
||
|
||
const leiste = document.querySelector('.kopfleiste');
|
||
if (leiste) leiste.after(band);
|
||
else document.body.prepend(band);
|
||
}
|
||
|
||
kopf.prepend(feld);
|
||
|
||
/* DER RAHMEN UM DIE SEITE NIMMT DIESELBE FARBE AN.
|
||
|
||
Der Wert wird aus dem fertigen Element GELESEN, nicht in
|
||
Javascript nachgeschlagen -- sonst stuende hier eine zweite Liste
|
||
mit Rollenfarben neben der in gate.css, und beim naechsten
|
||
Farbwechsel bliebe eine davon stehen. Erst nach dem Einhaengen,
|
||
vorher hat das Element noch keinen berechneten Stil. */
|
||
if (aktiv) {
|
||
const ton = getComputedStyle(feld).getPropertyValue('--rolle-haupt').trim();
|
||
if (ton) document.body.style.setProperty('--fremd-ton', ton);
|
||
}
|
||
};
|
||
|
||
/* 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();
|
||
});
|
||
})();
|