/* =================================================================== Gemeinsames Verhalten der Kopfleiste: Zurück-Knopf, Abmelden, Suche. Liegt bewusst in einer eigenen Datei. Vorher stand das Abmelden in jeder Seitendatei noch einmal -- fünfmal derselbe Block, fünfmal eine Stelle zum Vergessen, wenn sich etwas ändert. =================================================================== */ (() => { 'use strict'; const START = '/workspace/start.html'; /* ---------- Wer angemeldet ist ------------------------------------------- Vorher stand in der Kopfleiste "Dogfather · DogFather". Name und Rolle lasen sich gleich, und es sah nach einem Fehler aus. Jetzt drei unterscheidbare Dinge: ein Zeichen mit dem Anfangsbuchstaben, der Name, und die Rolle als eigene Marke in ihrer Farbe. Steht hier und nicht in jeder Seitendatei -- jede von ihnen setzte bisher dieselbe Zeile selbst zusammen, vierzehnmal. */ window.werZeigen = function (ich) { const ziel = document.getElementById('wer'); if (!ziel || !ich) return; ziel.textContent = ''; ziel.dataset.rolle = ich.rolle || ''; /* Das eigene Bild, wenn es eines gibt -- sonst der Anfangsbuchstabe. Der Buchstabe bleibt als Unterlage LIEGEN und wird nicht ersetzt: Laedt das Bild nicht (Verbindung weg, Datei geloescht), steht dort weiterhin etwas Sinnvolles statt eines kaputten Bildsymbols. */ const zeichen = document.createElement('span'); zeichen.className = 'wer__zeichen'; zeichen.setAttribute('aria-hidden', 'true'); zeichen.textContent = (ich.name || '?').trim().charAt(0).toUpperCase(); if (ich.bild) { const b = document.createElement('img'); b.className = 'wer__bild'; b.src = ich.bild; b.alt = ''; b.addEventListener('error', () => b.remove()); zeichen.append(b); } const name = document.createElement('span'); name.className = 'wer__name'; name.textContent = ich.name || ''; const rolle = document.createElement('span'); rolle.className = 'wer__rolle'; rolle.textContent = ich.rolle_name || ich.rolle || ''; ziel.append(zeichen, name, rolle); /* Vorleseprogramme bekommen einen Satz statt drei Bruchstücke. */ ziel.setAttribute('aria-label', `Angemeldet als ${ich.name} in der Rolle ${ich.rolle_name || ich.rolle}`); }; /* ---------- 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. */ const KLAPP_ZU = 4; /* dieselbe Zahl steht im CSS als nth-child(n+5) */ window.klappe = function (liste, knopf, textfeld) { if (!liste || !knopf) return; const anzahl = liste.children.length; /* Ein Knopf, der nichts zu verbergen hat, verwirrt nur. */ knopf.hidden = anzahl <= KLAPP_ZU; 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 = 'zu'; 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 ${KLAPP_ZU}` : `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 . Ein eigenes Element statt ::before/::after, weil die bei fast allen Karten schon fuer etwas anderes gebraucht werden (Akzentstreifen, Wasserzeichen) -- ein Pseudo-Element doppelt zu belegen geht nicht, und der Fehler faellt erst auf, wenn eines der beiden verschwindet. Kosten: EIN Zuhoerer fuer das ganze Dokument, gedrosselt auf einen Bildaufbau. Ohne feinen Zeiger (Handy) und bei "weniger Bewegung" laeuft gar nichts -- dann wird auch kein Element eingefuegt. */ const LICHT_KARTEN = [ '.kachel', '.karte', '.spalte', '.k-anlasskarte', '.k-tag', '.k-wochentag', '.dran__punkt', '.zahl', '.datei', '.person', '.eintrag', '.thema', '.listentag', '.k-listentag', '.steckbrief', '.leer-hinweis', '.block', '.saeule', '.karte-gross', '.kandidat', '.punkt', '.call', '.report', ].join(', '); window.lichtFolgen = function (wurzel = document) { const feinerZeiger = window.matchMedia('(hover: hover) and (pointer: fine)').matches; const ruhig = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!feinerZeiger || ruhig) return; /* Jede Karte bekommt ihr Licht-Element -- einmal. Karten kommen nachtraeglich dazu (Listen werden neu aufgebaut), deshalb wird das beim ersten Ueberfahren nachgeholt statt einmalig beim Laden. */ const vorbereiten = (karte) => { if (karte.dataset.lichtBereit) return; karte.dataset.lichtBereit = 'ja'; const l = document.createElement('span'); l.className = 'licht'; l.setAttribute('aria-hidden', 'true'); /* Als ERSTES Kind: Bei gleichem Stapelwert gewinnt das spaetere Element -- so liegt aller Inhalt ueber dem Licht. */ karte.prepend(l); }; let warten = false; let letzte = null; /* Die Karte, auf der das Licht gerade AN ist. Genau eine -- mehr kann es nicht geben, es gibt ja nur einen Zeiger. */ let aktuelle = null; const aus = (karte) => { if (!karte) return; karte.removeAttribute('data-licht'); karte.style.removeProperty('--mx'); karte.style.removeProperty('--my'); if (aktuelle === karte) aktuelle = null; }; wurzel.addEventListener('pointermove', (e) => { const karte = e.target.closest?.(LICHT_KARTEN); if (!karte) { /* Der Zeiger ist zwischen den Karten -- das Licht der letzten geht aus. Ohne das blieb es haengen, sobald man eine Karte ueber eine Luecke verliess statt ueber eine Nachbarkarte. */ aus(aktuelle); letzte = null; return; } /* Direkt von einer Karte auf die naechste: Die alte ausschalten, bevor die neue angeht. */ if (aktuelle && aktuelle !== karte) aus(aktuelle); letzte = { karte, x: e.clientX, y: e.clientY }; if (warten) return; warten = true; requestAnimationFrame(() => { warten = false; /* WETTLAUF. Zwischen dem Anmelden dieses Bildaufbaus und seinem Ablauf kann der Zeiger die Karte laengst verlassen haben -- dann hat pointerout schon aufgeraeumt, und diese Zeilen schalteten das Licht gleich wieder AN. Genau so blieb es haengen. Deshalb wird hier noch einmal geprueft, ob ueberhaupt noch etwas zu beleuchten ist. */ if (!letzte) return; const { karte: k, x, y } = letzte; vorbereiten(k); const r = k.getBoundingClientRect(); k.style.setProperty('--mx', ((x - r.left) / r.width * 100).toFixed(1) + '%'); k.style.setProperty('--my', ((y - r.top) / r.height * 100).toFixed(1) + '%'); k.dataset.licht = 'an'; aktuelle = k; }); }, { passive: true }); /* Beim Verlassen zurueck in die Ruhelage. Ohne das bliebe der Fleck dort stehen, wo der Zeiger die Karte verlassen hat -- und die Karten saehen nach einer Weile alle unterschiedlich beleuchtet aus, ohne dass jemand wuesste, warum. */ wurzel.addEventListener('pointerout', (e) => { const karte = e.target.closest?.(LICHT_KARTEN); if (!karte) return; /* pointerout feuert auch beim Wechsel zwischen Kindern INNERHALB derselben Karte. Nur wenn der Zeiger die Karte wirklich verlaesst, wird geloescht -- sonst flackerte das Licht bei jeder Bewegung ueber ein Wort hinweg. */ if (karte.contains(e.relatedTarget)) return; letzte = null; /* den angemeldeten Bildaufbau entwerten */ aus(karte); }, { capture: true, passive: true }); /* Drei weitere Wege, auf denen der Zeiger verschwindet, ohne dass pointerout die Karte meldet -- jeder davon hat das Licht haengen lassen: Fenster verlassen der Zeiger geht ueber den Rand hinaus Tab gewechselt es kommt gar kein Ereignis mehr gescrollt die Karte wandert unter dem stehenden Zeiger weg; mausbewegt wurde nichts Beim Scrollen wird nicht blind geloescht: Es wird nachgesehen, ob die zuletzt beleuchtete Karte noch unter dem Zeiger liegt. */ let zeigerX = 0, zeigerY = 0; wurzel.addEventListener('pointermove', (e) => { zeigerX = e.clientX; zeigerY = e.clientY; }, { passive: true }); const pruefen = () => { if (!aktuelle) return; const unten = document.elementFromPoint(zeigerX, zeigerY); if (!unten || !aktuelle.contains(unten)) { letzte = null; aus(aktuelle); } }; window.addEventListener('scroll', pruefen, { passive: true, capture: true }); window.addEventListener('blur', () => { letzte = null; aus(aktuelle); }); document.addEventListener('visibilitychange', () => { if (document.hidden) { letzte = null; aus(aktuelle); } }); document.addEventListener('pointerleave', () => { letzte = null; aus(aktuelle); }); }; /* ---------- Jede Seite trägt Zeichen und Farbe ihrer Kachel ------------- Auf der Startseite hat jeder Bereich sein eigenes Zeichen und seinen eigenen Farbton. Bisher endete das an der Kachel: Wer sie anklickte, landete auf einer Seite, der man nicht mehr ansah, woher sie kam -- dreizehn Seiten, alle in demselben Blau, alle ohne Zeichen. Jetzt wird die Kachel weitergereicht. Der Kopf der Seite bekommt dasselbe Zeichen groß daneben, denselben Farbton für Strich, Zeichen und Titel, und dasselbe Zeichen noch einmal riesig und fast unsichtbar als Wasserzeichen -- genau die Behandlung, die auf der Startseite aus siebzehn Kästen siebzehn unterscheidbare Bereiche gemacht hat. Die Zuordnung kommt aus assets/js/bereiche.js, also aus derselben Liste wie die Kachel selbst. Wer dort eine Farbe ändert, ändert Kachel UND Seite -- sie können gar nicht auseinanderlaufen. */ (function seiteEinfaerben() { const B = window.Bereiche; /* Der Kalender hat einen eigenen Kopf (.k-kopf), weil dort der Zeitraum die Überschrift ist. Er soll sein Zeichen trotzdem bekommen -- sonst wäre ausgerechnet die aufwendigste Seite die einzige ohne. */ const kopf = document.querySelector('.kopf-zeile, .k-kopf'); if (!B || !kopf) return; const bereich = B.zuSeite(location.pathname, location.search); if (!bereich) return; /* Der Ton hängt am body: Damit erreicht er auch Bauteile, die weit unten auf der Seite stehen (Karten, Knöpfe, Zähler) -- nicht nur den Kopf. Die 17 Töne selbst stehen in start.css und hängen nur am Merkmal data-ton, nicht an einer Klasse. */ document.body.dataset.ton = String(bereich.ton); /* Und ihre eigene Bühne. Neun Szenen aus Filipes Bildern, jede Seite bekommt die, die zu ihr passt -- die Werkstatt bei den Aufgaben, die Nachtstadt beim Kalender, die Sitzecke bei den Calls, der Wald beim Scouting. Welche wohin gehört, steht in bereiche.js; das Bild selbst wählt start.css über dieses Merkmal aus. */ if (bereich.szene) document.body.dataset.buehne = bereich.szene; /* Der Kopf ist bisher ein
mit Text. Er bekommt links eine Plakette mit dem Zeichen -- eingefügt VOR dem vorhandenen Text, ohne ihn anzufassen. Dadurch funktioniert das auf allen dreizehn Seiten, ohne dreizehn HTML-Dateien zu ändern. */ const erstes = kopf.firstElementChild; if (!erstes) return; const plakette = document.createElement('span'); plakette.className = 'kopf-zeichen'; plakette.setAttribute('aria-hidden', 'true'); plakette.append(B.zeichenBauen(bereich.zeichen, 'kopf-zeichen__svg')); const reihe = document.createElement('div'); reihe.className = 'kopf-zeile__marke'; reihe.append(plakette); kopf.insertBefore(reihe, erstes); reihe.append(erstes); /* Das Wasserzeichen: dasselbe Zeichen riesig und bei sehr geringer Deckkraft hinter dem Kopf. Es liegt im Kopf selbst, nicht auf der Seite -- sonst würde es beim Blättern mitwandern und stören. */ const schatten = document.createElement('span'); schatten.className = 'kopf-wasserzeichen'; schatten.setAttribute('aria-hidden', 'true'); schatten.append(B.zeichenBauen(bereich.zeichen, 'kopf-wasserzeichen__svg')); kopf.append(schatten); kopf.dataset.gezeichnet = 'ja'; })(); /* ---------- Der Schriftzug oben links ----------------------------------- Er stand als eine Zeile in einer Farbe da: "DOGFATHER UNIVERSE · CREATOR WORKSPACE". Alles gleich laut, und der Mittelpunkt war ein Satzzeichen wie jedes andere. Jetzt bekommt er einen Rang: Der vordere Teil ist der Name und steht kräftig, der hintere sagt, wo man gerade ist, und tritt zurück. Der Trenner ist kein Zeichen mehr, sondern eine kleine Raute in der Hausfarbe. Das passiert hier zentral statt in sechzehn HTML-Dateien -- und ohne den Text neu zu schreiben: Nur die vorhandenen Textknoten werden aufgeteilt, Verweise (der Zurück-Link auf den Unterseiten) bleiben unberührt. Ändert jemand die Beschriftung im HTML, wirkt das hier weiterhin. */ for (const marke of document.querySelectorAll('.kopfleiste .marke__text')) { if (marke.dataset.rang) continue; for (const knoten of [...marke.childNodes]) { if (knoten.nodeType !== 3 || !knoten.textContent.includes('·')) continue; const stueck = document.createDocumentFragment(); knoten.textContent.split('·').forEach((teil, i) => { if (i) { const raute = document.createElement('span'); raute.className = 'marke__raute'; raute.setAttribute('aria-hidden', 'true'); stueck.append(raute); } if (!teil.trim()) return; const s = document.createElement('span'); s.className = 'marke__teil'; /* Der geschützte Leerraum im Quelltext soll erhalten bleiben -- "Creator Workspace" darf nicht umbrechen. */ s.textContent = teil.replace(/^[\s ]+|[\s ]+$/g, ''); stueck.append(s); }); knoten.replaceWith(stueck); } /* Der erste Teil ist der Name (oder auf Unterseiten der Rückweg), alles danach sagt nur noch, wo man ist. Genommen werden ALLE direkten Kinder außer der Raute -- nicht nur die eben erzeugten. Auf bereich.html steht der Bereichsname in einem eigenen span, das erst später von bereich.js gefüllt wird; eine Liste, die nur die selbst gebauten Stücke kennt, hätte ihn stumm übergangen. */ const teile = [...marke.children].filter((e) => !e.classList.contains('marke__raute')); teile.forEach((e, i) => e.classList.add(i === 0 ? 'marke__haupt' : 'marke__leise')); marke.dataset.rang = 'ja'; } /* Das Licht auf dem ganzen Dokument scharfstellen -- ein Zuhoerer fuer alle Karten, auch fuer die, die erst spaeter dazukommen. */ window.lichtFolgen(document); /* Die Startseite hat den Knopf gar nicht erst im Dokument -- dort gibt es kein "davor". Deshalb genügt hier die Prüfung, ob er existiert. */ const knopf = document.getElementById('zurueck'); if (knopf) { const beschriftung = knopf.querySelector('.zurueck-knopf__text'); if (hatVorherigeSeite()) { knopf.hidden = false; knopf.addEventListener('click', () => { /* history.back() statt eines festen Ziels: Es führt wirklich dorthin zurück, wo man war -- samt Bildlaufposition. Nur wenn es keine Historie gibt, wird ersatzweise umgeleitet. */ if (history.length > 1) history.back(); else location.assign(START); }); } else { /* Kein Verlauf, aber wir sind in einem Unterbereich: dann wenigstens ein Weg zur Übersicht. */ knopf.hidden = false; if (beschriftung) beschriftung.textContent = 'Übersicht'; knopf.addEventListener('click', () => location.assign(START)); } } const abmelden = document.getElementById('abmelden'); if (abmelden) { abmelden.addEventListener('click', async () => { abmelden.disabled = true; try { await fetch('/workspace/api/abmelden', { method: 'POST', credentials: 'same-origin', }); } catch { /* Auch ohne Antwort weiterleiten -- das Cookie läuft ohnehin ab. */ } location.assign('/workspace/'); }); } /* ================================================================ Suche über alle Bereiche (Konzept, Phase 4). Baut sich selbst in die Kopfleiste ein, statt in neun HTML-Dateien zu stehen. Eine Suche, die man nur auf einer Extraseite findet, benutzt niemand -- sie muss überall sein, wo man gerade ist. Mit "/" zu öffnen, mit Esc zu schließen. Das Ergebnis wird erst nach einer kurzen Pause geholt, damit nicht jeder Tastendruck eine Abfrage auslöst. ================================================================ */ const kopfRechts = document.querySelector('.kopfleiste__rechts'); if (kopfRechts) aufbauSuche(kopfRechts); 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 = ''; 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(); }); } })();