Im Code stand die Begruendung selbst: "«Review» allein sagt einem Neuen nichts." Das Wort ist englisch, ein Hauptwort ohne Handlung, und es verraet nicht, WER jetzt dran ist. "Zur Freigabe" sagt beides: fertig von mir, wartet auf jemanden. Geaendert wurde nur, was ein Mensch LIEST -- der Zustandsschluessel bleibt `review`, der gehoert der Datenbank. Betroffen: Spalte und Weiterknopf im Aufgabenbrett, Dateien-Filter, Startseiten-Zaehler, Kachel-Unterzeile, Hinweis "Datei wartet auf Freigabe", Report. NICHT geaendert: der Kalender. Dort ist `review` eine TERMINART (ein Gespraech, in dem man zurueckschaut), kein Zustand. Ich hatte das beim Umbenennen selbst verwechselt und wieder zurueckgenommen -- ein Kommentar an der Stelle haelt die zwei Bedeutungen jetzt auseinander. pruef-sprung hing an der Wortwahl (`/review/i` auf der Beschriftung) und wurde rot, obwohl der Filter richtig stand. Sie prueft jetzt `data-status` -- den Schluessel, der sich nicht mit der Sprache aendert. DAZU, unabhaengig gefunden: Datumsfelder waren 48 px hoch, alle anderen Felder 44. Gemessen auf drei Seiten bei 390 px. Alle liegen auf dem 44-px-Beruehrziel -- nur das Datumsfeld drueckte sich darueber, weil Chromium in `::-webkit-datetime-edit` eine eigene Innenpolsterung setzt, die sogar ein gesetztes `height: 44px` ueberstimmt. Zwei Anteile, einzeln nachgemessen (jeder allein 48->46, erst beide zusammen 48->44): 1 px Polsterung oben und unten im Feldkasten, und eine Zeilenhoehe von 24 statt 22. Keine feste Hoehe gesetzt -- die Zeilenhoehe wird aus Beruehrziel und Polsterung gerechnet, damit sie mitwandert, wenn sich eines davon aendert. Geprueft: pruef-formulare 19/0 (war 16 mit 3 Fehlern), pruef-sprung 43/0, pruef-start-ansicht 151/0, pruef-aufgabenbrett 49/0, pruef-uebersicht 35/0, pruef-uebersicht-browser 20/0, pruef-deutsche-texte, pruef-css-klassen. Ausserdem: vorlagen.js geloescht (8,2 KB). `vorlagenBlock(` wurde inca104799eingebaut und in9267797dwieder ausgebaut -- seither laedt die Datei auf zwei Seiten, ohne dass jemand sie aufruft. Co-Authored-By: Claude Opus 5 <[email protected]>
1929 lines
84 KiB
JavaScript
1929 lines
84 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) {
|
||
/* DIE AUSKUNFT LIEGT AB JETZT OFFEN (20.09.2026).
|
||
----------------------------------------------------------------
|
||
`/api/ich` sagt unter anderem, WELCHE SEITEN diesem Menschen
|
||
offenstehen (`seiten`). Jede Seite holt das ohnehin -- aber
|
||
bisher behielt es jede fuer sich.
|
||
|
||
Gebraucht wird es an Stellen, die einen WEG NENNEN: „Zugaenge
|
||
legst du in ‚Personen & Zugaenge' an" ist fuer einen Modi eine
|
||
Sackgasse, weil er diese Seite nicht oeffnen darf. Wer einen
|
||
Weg nennt, muss fragen koennen, ob es ihn fuer diesen Menschen
|
||
gibt.
|
||
|
||
Deshalb hier, an der einen Stelle, durch die jede Seite laeuft.
|
||
Nicht als zweite Abfrage -- eine zweite Abfrage waere eine
|
||
zweite Wahrheit. */
|
||
try { window.__ich = ich; } catch { /* egal */ }
|
||
/* DER CHAT-KNOPF GEHOERT NICHT ZU JEDEM (18.09.2026).
|
||
|
||
Er wurde auf JEDER Seite gebaut. Fuer ein Mitglied der Community
|
||
fuehrte er ins Leere: `chat.html` steht ihm nicht offen, der
|
||
Klick landete wieder auf der Startseite -- keine Meldung, kein
|
||
Grund, nichts passiert. Gemessen von pruef-community-sicht:
|
||
zehn Seiten, zehn tote Wege, und der Knopf steht oben rechts,
|
||
wo man ihn am ehesten drueckt.
|
||
|
||
HIER UND NICHT IN `aufbauChat`: Dort gibt es die Person nicht --
|
||
sie kommt als Parameter in diese Funktion, und zwar je nach
|
||
Seite frueher oder spaeter als der Knopf gebaut wird. Auf die
|
||
Reihenfolge zu bauen waere eine Annahme; ein vorhandener Knopf
|
||
laesst sich dagegen immer entfernen.
|
||
|
||
`=== false` und nicht `!`: Fehlt die Angabe (aeltere Antwort des
|
||
Servers), bleibt alles wie bisher. Ein fehlendes Feld soll
|
||
niemandem den Chat wegnehmen. */
|
||
if (ich && ich.darf_chat === false) {
|
||
document.querySelector('.chat-knopf')?.remove();
|
||
}
|
||
|
||
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('kopf-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 = 'kopf-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);
|
||
};
|
||
|
||
/* ---------- GANZE ABSCHNITTE AUF UND ZU (19.09.2026) ------------------
|
||
|
||
Filipe: „man soll auf dieser seite jede kategorie auf und zu
|
||
klappen können mit einem button, übrigens will ich das überall
|
||
überall wo so eine liste entstehen kann hatte ich dir schon mal
|
||
gesagt."
|
||
|
||
UNTERSCHIED ZU `klappe()` DARÜBER: Das dort verbirgt die hinteren
|
||
Zeilen EINER Liste („nur die letzten 4"). Das hier klappt einen
|
||
ganzen Abschnitt samt Überschrift zu. Zwei verschiedene Fragen,
|
||
deshalb zwei Funktionen — eine, die beides kann, müsste bei jedem
|
||
Aufruf erklärt bekommen, was gemeint ist.
|
||
|
||
DAS MUSTER STAMMT VON DER STARTSEITE, wo es seit dem 10.09.2026
|
||
funktioniert: ein Knopf als Überschrift, ein Pfeil, der sich dreht,
|
||
der Zustand in localStorage. Es steht jetzt hier, weil Filipe es
|
||
„überall" haben will — und weil eine zweite Abschrift die wäre, die
|
||
beim nächsten Umbau ausschert. (start.js hat seine eigene Fassung
|
||
noch; sie tut dasselbe und ist geprüft. Wer sie anfasst, stellt sie
|
||
auf diese hier um.)
|
||
|
||
`schluessel` darf fehlen: Dann merkt sich die Seite nichts und
|
||
alles steht beim nächsten Besuch offen. Das ist der richtige
|
||
Rückfall — ein Abschnitt, der zu ist und von dem man nicht mehr
|
||
weiß, warum, sieht aus wie fehlender Inhalt. */
|
||
window.abschnittKlappbar = function (kopf, kasten, schluessel, zuVorgabe = false) {
|
||
if (!kopf || !kasten) return;
|
||
|
||
/* Der Knopf muss ein Knopf sein. Ein `<h2 onclick>` ist für die
|
||
Tastatur nicht erreichbar und für Vorleseprogramme unsichtbar. */
|
||
if (kopf.tagName !== 'BUTTON') kopf.setAttribute('role', 'button');
|
||
if (!kopf.hasAttribute('tabindex') && kopf.tagName !== 'BUTTON') kopf.tabIndex = 0;
|
||
|
||
let zu = zuVorgabe;
|
||
if (schluessel) {
|
||
try {
|
||
const gemerkt = localStorage.getItem('abschnitt:' + schluessel);
|
||
if (gemerkt) zu = gemerkt === 'zu';
|
||
} catch { /* privates Fenster: dann eben die Vorgabe */ }
|
||
}
|
||
|
||
const stand = (istZu) => {
|
||
kopf.dataset.zu = istZu ? 'ja' : 'nein';
|
||
kopf.setAttribute('aria-expanded', istZu ? 'false' : 'true');
|
||
kasten.hidden = istZu;
|
||
};
|
||
stand(zu);
|
||
|
||
/* NUR EINMAL EINHÄNGEN. Die Bretter bauen sich bei jeder
|
||
Aktualisierung neu auf; hinge der Zuhörer zweimal daran, würde
|
||
ein Klick zweimal umschalten und damit gar nichts tun. */
|
||
if (kopf.dataset.klappBereit) return;
|
||
kopf.dataset.klappBereit = 'ja';
|
||
|
||
const um = () => {
|
||
zu = !zu;
|
||
stand(zu);
|
||
if (schluessel) {
|
||
try { localStorage.setItem('abschnitt:' + schluessel, zu ? 'zu' : 'auf'); }
|
||
catch { /* egal */ }
|
||
}
|
||
};
|
||
kopf.addEventListener('click', um);
|
||
if (kopf.tagName !== 'BUTTON') {
|
||
kopf.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); um(); }
|
||
});
|
||
}
|
||
};
|
||
|
||
/** Der Pfeil, der sich beim Zuklappen dreht.
|
||
*
|
||
* Als Funktion, damit nicht an jeder Stelle dasselbe SVG steht --
|
||
* und damit ein späterer anderer Pfeil an EINER Stelle geändert
|
||
* wird. */
|
||
window.klappPfeil = function () {
|
||
const p = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
p.setAttribute('class', 'klapp-pfeil');
|
||
p.setAttribute('viewBox', '0 0 24 24');
|
||
p.setAttribute('aria-hidden', 'true');
|
||
const weg = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
weg.setAttribute('d', 'm6 9.5 6 6 6-6');
|
||
weg.setAttribute('fill', 'none');
|
||
weg.setAttribute('stroke', 'currentColor');
|
||
weg.setAttribute('stroke-width', '2.2');
|
||
weg.setAttribute('stroke-linecap', 'round');
|
||
weg.setAttribute('stroke-linejoin', 'round');
|
||
p.append(weg);
|
||
return p;
|
||
};
|
||
|
||
/* 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. */
|
||
/* ==== GEFUNDEN AM 19.09.2026: DIE HAELFTE DER SEITEN BEKAM NICHTS =====
|
||
|
||
Der Umbau von heute Nacht heisst "Jede Seite traegt die Farbe ihrer
|
||
Kachel". Nachgemessen traf das auf 18 von 34 Seiten zu. Elf
|
||
Seiten mit echter Kachel gingen leer aus:
|
||
|
||
befinden, bewerben, bewerbungen, entwicklung, hilfe, rechte,
|
||
talente, teamlage, teilen, treff-moderation, treff-regeln,
|
||
unsere-seiten
|
||
|
||
Es sind ausgerechnet die von Team Dogi -- also die, die Filipe
|
||
taeglich benutzt.
|
||
|
||
DER GRUND: `B.zuSeite()` sucht nur in `Bereiche.GRUPPEN`, und dort
|
||
stehen ausschliesslich die Kacheln der AGENTUR. Die Kacheln von
|
||
Team Dogi und vom Treff liefert der SERVER (`ich.bereiche`,
|
||
`ich.bereiche_zusatz`) -- die kannte diese Stelle nicht.
|
||
|
||
Auffallen konnte das nicht: Eine Seite ohne Farbe sieht aus wie
|
||
eine Seite, die eben keine hat. Dieselbe Sorte Fehler wie bei
|
||
`chat.html` am 09.09. -- im Quelltext sieht die Zeile richtig aus,
|
||
und nur das Nachzaehlen findet sie.
|
||
|
||
JETZT ZWEIMAL, UND DAS IST ABSICHT:
|
||
1. sofort beim Laden aus GRUPPEN -- die Agenturseiten haben ihre
|
||
Farbe damit ohne ein einziges Flackern, wie bisher.
|
||
2. noch einmal, sobald der Server gesagt hat, wer man ist. Nur
|
||
wenn beim ersten Mal nichts gefunden wurde.
|
||
Zweimal zeichnen verhindert `kopf.dataset.gezeichnet`.
|
||
================================================================= */
|
||
function seiteEinfaerben(zusatz) {
|
||
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;
|
||
|
||
/* Erst die Kacheln, die der Server geschickt hat -- sie sind die
|
||
genauere Auskunft: Wer diese Seite oeffnen darf, hat sie auch als
|
||
Kachel bekommen. Danach die Agenturliste aus dem Browser. */
|
||
const bereich = ausListe(zusatz) || B.zuSeite(location.pathname, location.search);
|
||
if (!bereich) return false;
|
||
|
||
/* ==== DER SATZ VON DER KACHEL KOMMT MIT (20.09.2026) ==============
|
||
|
||
Gemessen: 25 der 33 Team-Dogi-Seiten tragen eine Unterzeile, die
|
||
sagt, wofuer die Seite da ist. Fuenf nicht -- obwohl ihre Kachel
|
||
den Satz laengst hat.
|
||
|
||
Bei dreien war das richtig so: Auf `aufgaben.html` heisst die
|
||
Ueberschrift „Offen. In Arbeit. Zur Freigabe. Erledigt.", auf
|
||
`teamlage.html` „Was auf dich wartet" -- der Satz stuende dort
|
||
ein zweites Mal. Nachgesehen, nicht gerechnet.
|
||
|
||
Bleiben zwei, die wirklich nichts sagen. Sie bekommen den Satz
|
||
NICHT als abgeschriebenen Text ins HTML, sondern eine leere
|
||
Zeile mit `data-aus-kachel`. Gefuellt wird sie von hier, aus der
|
||
Kachel. Damit gibt es den Satz genau einmal: Wer die Kachel
|
||
aendert, aendert die Seite mit, und sie koennen nicht
|
||
auseinanderlaufen.
|
||
|
||
Eine Seite, die ihre Ueberschrift selbst erklaert, setzt die
|
||
Zeile einfach nicht -- dann passiert hier nichts. */
|
||
const unterzeile = document.querySelector('.unterzeile[data-aus-kachel]');
|
||
if (unterzeile && !unterzeile.textContent.trim() && bereich.unter) {
|
||
unterzeile.textContent = bereich.unter;
|
||
}
|
||
|
||
/* ==== 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');
|
||
/* `true` und nicht `false`: Ton und Buehne SIND gesetzt. Nur die
|
||
Plakette faellt aus, weil es keinen Kopf gibt -- das ist kein
|
||
Grund, es beim zweiten Anlauf noch einmal zu versuchen. */
|
||
if (!kopf || kopf.dataset.gezeichnet === 'ja') return true;
|
||
const erstes = kopf.firstElementChild;
|
||
if (!erstes) return true;
|
||
|
||
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';
|
||
return true;
|
||
}
|
||
|
||
/** Die Kachel zu dieser Seite in einer Liste vom Server suchen.
|
||
*
|
||
* UEBER DAS ZIEL UND NICHT UEBER DEN NAMEN. Namen aendern sich --
|
||
* am 19.09. hiess eine Kachel morgens noch "Entwicklung" und
|
||
* mittags "Eure Aufgaben". Das Ziel ist die Angabe, die bleibt. */
|
||
function ausListe(listen) {
|
||
if (!Array.isArray(listen) || !listen.length) return null;
|
||
const datei = String(location.pathname).split('/').pop() || 'start.html';
|
||
const gesucht = datei + (location.search || '');
|
||
let treffer = null;
|
||
for (const k of listen) {
|
||
if (!k || !k.ziel) continue;
|
||
if (k.ziel === gesucht) return k;
|
||
if (!treffer && String(k.ziel).split('?')[0] === datei) treffer = k;
|
||
}
|
||
return treffer;
|
||
}
|
||
|
||
const schonEingefaerbt = seiteEinfaerben(null);
|
||
|
||
/* Der zweite Anlauf. `werZeigen` ruft jede Seite auf, sobald der
|
||
Server geantwortet hat -- es ist die einzige Stelle, an der die
|
||
Kacheln von Team Dogi im Browser ankommen. */
|
||
if (!schonEingefaerbt) {
|
||
const vorherWer = window.werZeigen;
|
||
window.werZeigen = function (ich) {
|
||
try { if (typeof vorherWer === 'function') vorherWer.apply(this, arguments); }
|
||
finally {
|
||
seiteEinfaerben([
|
||
...(ich?.bereiche || []), ...(ich?.bereiche_zusatz || []),
|
||
]);
|
||
}
|
||
};
|
||
}
|
||
|
||
/* ---------- 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 () => {
|
||
/* RUECKFRAGE, UND ZWAR EINE MIT INHALT (19.09.2026).
|
||
------------------------------------------------------------
|
||
Der Knopf sitzt am Handy als 44-Pixel-Zeichen direkt neben
|
||
Glocke und Suche. Ein Fehlgriff war bis heute sofort
|
||
wirksam -- und er ist teurer, als er aussieht:
|
||
|
||
Zum Wiederanmelden braucht man den persoenlichen Zugangscode.
|
||
Den bekommt jeder EINMAL. Wer ihn nicht aufgeschrieben hat,
|
||
kommt ohne einen neuen Code von DogFather nicht zurueck.
|
||
|
||
Deshalb steht genau das im Kasten -- nicht "Sicher?". Und
|
||
daneben die Auskunft, dass Zuklappen reicht: Die Sitzung
|
||
haelt 12 Stunden, abmelden muss man sich zum Weggehen gar
|
||
nicht. */
|
||
if (!await frageNach({
|
||
titel: 'Abmelden?',
|
||
was: 'Zum Wiederanmelden brauchst du deinen persönlichen '
|
||
+ 'Zugangscode.',
|
||
bleibt: 'Wenn du nur kurz weg willst: Einfach zumachen reicht – '
|
||
+ 'du bleibst 12 Stunden angemeldet.',
|
||
ja: 'Abmelden',
|
||
})) return;
|
||
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.
|
||
|
||
Seit dem 11.09.2026 steht hier gar keine Liste mehr -- auch keine
|
||
Ableitung. Die Reihenfolge und die Ueberschriften kommen vom
|
||
Server; `window.Bereiche.gruppieren()` setzt sie nur noch um.
|
||
Das ist die Fassung, die auch dann stimmt, wenn eine Rolle
|
||
dazukommt, deren Namen diese Datei gar nicht kennen darf. */
|
||
|
||
/* 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('kopf-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 = 'kopf-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', 'kopf-sicht-feld');
|
||
feld.append(schild);
|
||
|
||
const s = el('select', 'sicht__feld');
|
||
s.id = 'kopf-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', ''));
|
||
/* IN DER REIHENFOLGE DES SERVERS (11.09.2026).
|
||
|
||
Vorher zwei Durchgaenge: erst die fuenf Rollen aus bereiche.js,
|
||
dann "alle uebrigen". Niemand fehlte mehr -- aber Team Dogi stand
|
||
zwangslaeufig ganz unten, hinter jedem Creator. Filipe wollte die
|
||
rechte Hand ueberall vorn haben, und "ueberall" heisst auch hier.
|
||
|
||
Die Ueberschrift kommt vom Server, so kann in dieser Auswahl auch
|
||
eine Rolle stehen, deren Namen diese Datei gar nicht kennen
|
||
darf. */
|
||
const sichtbar = personen.filter((p) => p.id !== ich.id && p.aktiv !== false);
|
||
const gruppen = window.Bereiche?.gruppieren?.(sichtbar)
|
||
|| [{ titel: 'Alle', rolle: '', leute: sichtbar }];
|
||
for (const gruppe of gruppen) {
|
||
const g = document.createElement('optgroup');
|
||
g.label = gruppe.titel;
|
||
for (const p of gruppe.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 = 'kopf-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();
|
||
});
|
||
})();
|
||
|
||
/* =====================================================================
|
||
DIE SEITE MERKT SICH, WO DU WARST — 19.09.2026
|
||
|
||
Filipe: „wenn ich von einer seite auf die andere zurück gehe, swipt
|
||
die seite immer wieder komplett hoch. das nervt man muss dan immer
|
||
wieder runter scrollen bis man da ist wo man vorher war."
|
||
|
||
---------------------------------------------------------------------
|
||
WARUM DER BROWSER ES NICHT VON SELBST HINBEKOMMT
|
||
|
||
Er versucht es sogar — `history.scrollRestoration` steht ab Werk auf
|
||
„auto", und bei einer gewöhnlichen Seite klappt das.
|
||
|
||
Hier nicht, und der Grund ist die Bauweise des ganzen Hauses: Jede
|
||
Seite kommt fast leer an und holt ihren Inhalt danach per Abruf
|
||
(Aufgaben, Termine, Einträge, Karten). In dem Moment, in dem der
|
||
Browser die alte Position wiederherstellen will, ist das Dokument
|
||
ein paar hundert Pixel hoch. Er kann nicht auf Zeile 900 springen,
|
||
die es noch nicht gibt — also bleibt er oben. Eine halbe Sekunde
|
||
später ist der Inhalt da, aber der Browser versucht es kein zweites
|
||
Mal.
|
||
|
||
---------------------------------------------------------------------
|
||
WIE ES HIER GELÖST IST
|
||
|
||
Wir übernehmen die Aufgabe selbst (`manual`) und warten, bis die
|
||
Seite WIRKLICH hoch genug ist:
|
||
|
||
1. Beim Verlassen wird die Position gemerkt — je Adresse einzeln,
|
||
inklusive Frageteil (`bereich.html?b=treff` ist eine andere
|
||
Seite als `bereich.html?b=wunsch`).
|
||
2. Beim Öffnen wird sie zurückgelesen und dann jeden Bildaufbau
|
||
lang versucht, bis das Dokument die nötige Höhe hat.
|
||
3. Nach längstens drei Sekunden wird aufgegeben — eine Seite, die
|
||
ewig nachspringt, ist schlimmer als eine, die oben steht.
|
||
|
||
---------------------------------------------------------------------
|
||
DREI DINGE, DIE ABSICHT SIND
|
||
|
||
NUR BEIM ZURÜCKGEHEN, NICHT BEI JEDEM ÖFFNEN. Wer eine Kachel
|
||
anklickt, will oben anfangen. Wer zurückgeht, will dorthin, wo er
|
||
war. Unterschieden wird über `performance.navigation`: Nur bei
|
||
`back_forward` wird wiederhergestellt. Ein eigener Merker fängt den
|
||
Fall ab, dass der Zurück-Knopf der Kopfleiste benutzt wurde.
|
||
|
||
WER SELBST SCROLLT, GEWINNT. Sobald der Mensch das Rad dreht oder
|
||
wischt, hört das Nachspringen sofort auf. Nichts ist ärgerlicher
|
||
als eine Seite, die einen zurückzieht.
|
||
|
||
UND SIE VERGISST NACH EINER STUNDE. `sessionStorage` überlebt den
|
||
Reiter ohnehin nicht; der Zeitstempel fängt den Fall ab, dass
|
||
jemand denselben Reiter am nächsten Morgen weiterbenutzt. Auf
|
||
Zeile 900 zu landen, weil man gestern dort war, ist keine Hilfe.
|
||
===================================================================== */
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* Nach so vielen Millisekunden wird nicht mehr versucht. Drei
|
||
Sekunden sind großzügig für einen Abruf und kurz genug, dass es
|
||
nie nach „die Seite hängt" aussieht. */
|
||
var FRIST_MS = 3000;
|
||
/* Nach einer Stunde gilt die gemerkte Stelle nicht mehr. */
|
||
var ALTER_MS = 3600000;
|
||
/* Unter dieser Höhe lohnt das Merken nicht -- wer zehn Pixel
|
||
gescrollt hat, merkt vom Wiederherstellen nichts. */
|
||
var MINDEST_PX = 80;
|
||
|
||
var SCHLUESSEL = 'ws-stelle:' + location.pathname + location.search;
|
||
|
||
function merken() {
|
||
try {
|
||
var y = window.scrollY || document.documentElement.scrollTop || 0;
|
||
if (y < MINDEST_PX) { sessionStorage.removeItem(SCHLUESSEL); return; }
|
||
sessionStorage.setItem(SCHLUESSEL, JSON.stringify({ y: Math.round(y), am: Date.now() }));
|
||
} catch (e) { /* Kein Speicher -> dann eben ohne. */ }
|
||
}
|
||
|
||
/* MEHRERE ANLÄSSE, NICHT NUR EINER.
|
||
|
||
`beforeunload` feuert auf iOS oft gar nicht, `pagehide` dafür
|
||
zuverlässig; `visibilitychange` fängt den Fall ab, dass jemand die
|
||
App in den Hintergrund schiebt und das Gerät sie später beendet.
|
||
Dreimal dasselbe zu schreiben ist hier billiger als ein Fall, in
|
||
dem nichts gemerkt wird. */
|
||
window.addEventListener('pagehide', merken);
|
||
window.addEventListener('beforeunload', merken);
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (document.visibilityState === 'hidden') merken();
|
||
});
|
||
/* Und der Zurück-Knopf der Kopfleiste: Er ruft `history.back()`, und
|
||
dabei ist `pagehide` je nach Browser zu spät dran. */
|
||
document.addEventListener('click', function (e) {
|
||
var a = e.target && e.target.closest
|
||
&& e.target.closest('a[href], .zurueck-knopf, .zurueck');
|
||
if (a) merken();
|
||
}, true);
|
||
|
||
/* ---- Und zurück ------------------------------------------------- */
|
||
|
||
function kamVonZurueck() {
|
||
try {
|
||
var e = performance.getEntriesByType('navigation')[0];
|
||
if (e && e.type) return e.type === 'back_forward';
|
||
/* Ältere Browser: die alte Zählweise (2 = TYPE_BACK_FORWARD). */
|
||
return performance.navigation && performance.navigation.type === 2;
|
||
} catch (f) { return false; }
|
||
}
|
||
|
||
function wiederherstellen() {
|
||
var roh;
|
||
try { roh = sessionStorage.getItem(SCHLUESSEL); } catch (e) { return; }
|
||
if (!roh) return;
|
||
var stand;
|
||
try { stand = JSON.parse(roh); } catch (e) { return; }
|
||
if (!stand || typeof stand.y !== 'number') return;
|
||
if (Date.now() - (stand.am || 0) > ALTER_MS) {
|
||
try { sessionStorage.removeItem(SCHLUESSEL); } catch (f) { /* egal */ }
|
||
return;
|
||
}
|
||
|
||
var bis = Date.now() + FRIST_MS;
|
||
var abgebrochen = false;
|
||
/* WER SELBST SCROLLT, GEWINNT. `wheel`, `touchstart` und die
|
||
Tastatur -- alle drei, weil es alle drei Arten gibt, eine Seite
|
||
zu bewegen. */
|
||
var aufhoeren = function () { abgebrochen = true; };
|
||
window.addEventListener('wheel', aufhoeren, { passive: true, once: true });
|
||
window.addEventListener('touchstart', aufhoeren, { passive: true, once: true });
|
||
window.addEventListener('keydown', aufhoeren, { once: true });
|
||
|
||
(function versuchen() {
|
||
if (abgebrochen) return;
|
||
var hoch = Math.max(
|
||
document.body ? document.body.scrollHeight : 0,
|
||
document.documentElement ? document.documentElement.scrollHeight : 0);
|
||
/* Reicht die Höhe schon? Dann hin und fertig. */
|
||
if (hoch >= stand.y + window.innerHeight * 0.5) {
|
||
window.scrollTo(0, stand.y);
|
||
/* NOCH EINMAL IM NÄCHSTEN BILD. Manche Listen wachsen beim
|
||
Zeichnen noch ein Stück (Bilder, Schriften), und dann sitzt
|
||
der erste Sprung ein paar Pixel daneben. */
|
||
requestAnimationFrame(function () {
|
||
if (!abgebrochen) window.scrollTo(0, stand.y);
|
||
});
|
||
return;
|
||
}
|
||
if (Date.now() > bis) return; /* Aufgeben ist auch eine Antwort. */
|
||
requestAnimationFrame(versuchen);
|
||
})();
|
||
}
|
||
|
||
if ('scrollRestoration' in history) {
|
||
/* Wir machen es selbst -- sonst springt der Browser erst nach oben
|
||
und wir gleich danach wieder hinunter, und das sieht man. */
|
||
history.scrollRestoration = 'manual';
|
||
}
|
||
|
||
if (kamVonZurueck()) {
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', wiederherstellen);
|
||
} else {
|
||
wiederherstellen();
|
||
}
|
||
} else {
|
||
/* Ein frisch geöffneter Bereich fängt oben an -- und die alte
|
||
Stelle wird vergessen, damit sie nicht in einer halben Stunde
|
||
überraschend zuschlägt. */
|
||
try { sessionStorage.removeItem(SCHLUESSEL); } catch (e) { /* egal */ }
|
||
}
|
||
})();
|