Die "Letzten Ereignisse" im Personen-Bereich fuellten mit 25 Zeilen die
halbe Seite, obwohl sie Nachschlagewerk sind und kein Startbild. Jetzt
stehen nur die vier neuesten da, der Rest kommt auf Knopfdruck.
Der Knopf sagt, was er TUT ("Alle 25 zeigen" / "Nur die letzten 4") und
nennt die Zahl, damit man weiss, was dahintersteckt. Bei hoechstens vier
Eintraegen bleibt er ganz weg -- ein Knopf, der nichts verbirgt, verwirrt
nur. Der Pfeil dreht sich, aria-expanded stimmt.
Gemeinsamer Helfer in kopf.js statt zweimal derselbe Block: Dieselbe
Liste gab es auf der Automationen-Seite ("Zuletzt automatisch passiert"),
die verhaelt sich jetzt genauso. Zugeklappt wird per CSS
([data-klapp="zu"]) statt durch Entfernen von Zeilen -- das Aufklappen
braucht so weder Neuaufbau noch zweite Abfrage.
Der Zuhoerer wird nur einmal gesetzt (die Listen werden bei jeder
Aktualisierung neu aufgebaut), und die Anzahl wird bei jedem Umschalten
frisch gelesen statt in der Fassung des ersten Durchlaufs festzuhaengen.
Geprueft: beide Seiten, Computer und Handy, zweimaliges Hin- und
Herklappen, Gegenprobe mit drei Eintraegen -- server/pruef-protokoll-klappe.mjs
Co-Authored-By: Claude Opus 5 <[email protected]>
260 lines
10 KiB
JavaScript
260 lines
10 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';
|
||
|
||
/* ---------- 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;
|
||
}
|
||
|
||
/* 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 = '<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();
|
||
});
|
||
}
|
||
})();
|