diff --git a/server/index.js b/server/index.js
index f535fb81..c5fc0b12 100644
--- a/server/index.js
+++ b/server/index.js
@@ -24,6 +24,7 @@ import { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
import { callRouter } from "./workspace-calls.js";
import { hinweisRouter } from "./workspace-hinweise.js";
+import { sucheRouter } from "./workspace-suche.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SITE_DIR = join(__dirname, "..");
@@ -88,6 +89,7 @@ app.use(reportRouter);
app.use(scoutRouter);
app.use(callRouter);
app.use(hinweisRouter);
+app.use(sucheRouter);
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
diff --git a/server/workspace-suche.js b/server/workspace-suche.js
new file mode 100644
index 00000000..bb858177
--- /dev/null
+++ b/server/workspace-suche.js
@@ -0,0 +1,268 @@
+/* =====================================================================
+ workspace-suche.js — Suche über alle Bereiche (Konzept, Phase 4).
+
+ Zwei Dinge machen den Unterschied zwischen einer Suche und einer
+ brauchbaren Suche:
+
+ 1. SIE DARF NICHTS FINDEN, WAS DIE SEITE VERBERGEN WUERDE. Eine Suche
+ ist die verlockendste Stelle fuer ein Datenleck: Man tippt einen
+ Namen und bekommt Treffer aus Bereichen, die man nie oeffnen
+ duerfte. Deshalb wird jede Quelle mit der Sichtbarkeitsregel ihres
+ eigenen Moduls abgefragt -- importiert, nicht abgeschrieben. Und
+ die management-internen Profilfelder (admin_notiz, plan_start,
+ naechster_review) werden gar nicht erst durchsucht, fuer niemanden
+ ausser dem Management.
+
+ 2. SIE MUSS SAGEN, WO ETWAS STEHT. Ein Treffer ohne Textstelle und
+ ohne Weg dorthin zwingt zum Weitersuchen. Jeder Treffer traegt
+ deshalb einen Ausschnitt rund um die Fundstelle und ein Ziel.
+
+ Bewusst kein Volltextindex (FTS5). Die Datenmengen hier sind klein --
+ ein paar tausend Zeilen -- und LIKE braucht keinen zweiten Datenstand,
+ der irgendwann auseinanderlaeuft.
+ ===================================================================== */
+
+import express from "express";
+import { db, sitzungLesen, betreuteIds } from "./workspace.js";
+import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js";
+import { sichtbar as sichtbarTermine } from "./workspace-kalender.js";
+import { sichtbar as sichtbarDateien } from "./workspace-dateien.js";
+import { sichtbar as sichtbarEintraege } from "./workspace-bereiche.js";
+
+export const sucheRouter = express.Router();
+
+const MIN = 2;
+const JE_BEREICH = 6;
+const AUSSCHNITT = 110;
+
+const BEREICHSNAME = {
+ live: "LIVE-Analyse", content: "Content", technik: "Technik",
+ community: "Community", schutz: "Schutz",
+};
+
+function angemeldet(req, res, next) {
+ const person = sitzungLesen(req);
+ if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
+ req.person = person;
+ next();
+}
+
+sucheRouter.use("/workspace/api/suche", angemeldet);
+
+/* LIKE-Sonderzeichen unschaedlich machen. Ohne das waere die Suche nach
+ "100%" eine Suche nach "100 gefolgt von irgendwas" und "a_b" faende
+ auch "axb" -- beides falsch und beides faellt erst spaet auf. */
+function muster(text) {
+ return "%" + text.replace(/[\\%_]/g, (z) => "\\" + z) + "%";
+}
+
+/* Ausschnitt rund um die Fundstelle, damit man sieht, WARUM etwas
+ gefunden wurde -- nicht nur die ersten Zeichen des Feldes. */
+function ausschnitt(text, suche) {
+ if (!text) return null;
+ const flach = String(text).replace(/\s+/g, " ").trim();
+ const wo = flach.toLowerCase().indexOf(suche.toLowerCase());
+ if (wo < 0) return flach.length > AUSSCHNITT ? flach.slice(0, AUSSCHNITT) + " …" : flach;
+ const von = Math.max(0, wo - 35);
+ const bis = Math.min(flach.length, wo + suche.length + 70);
+ return (von > 0 ? "… " : "") + flach.slice(von, bis) + (bis < flach.length ? " …" : "");
+}
+
+sucheRouter.get("/workspace/api/suche", (req, res) => {
+ try {
+ const person = req.person;
+ const q = String(req.query.q ?? "").trim().slice(0, 100);
+ if (q.length < MIN) {
+ return res.json({ frage: q, zu_kurz: true, gruppen: [], gesamt: 0 });
+ }
+ const m = muster(q);
+ const gruppen = [];
+
+ /* Eine Quelle abfragen. Faellt eine aus, bleibt die Suche im Rest
+ benutzbar -- eine leere Trefferliste waere schlimmer als eine
+ unvollstaendige. */
+ const quelle = (titel, sql, werte, bau) => {
+ try {
+ const zeilen = db().prepare(sql).all(...werte);
+ if (!zeilen.length) return;
+ gruppen.push({ titel, treffer: zeilen.map(bau) });
+ } catch (fehler) {
+ console.error(`[workspace] Suche (${titel}):`, fehler?.message);
+ }
+ };
+
+ /* ---------- Aufgaben ---------- */
+ const a = sichtbarAufgaben(person);
+ if (a) {
+ quelle("Aufgaben", `
+ SELECT a.id, a.titel, a.beschreibung, a.status, a.frist, p.name AS creator_name
+ FROM aufgaben a LEFT JOIN personen p ON p.id = a.creator_id
+ WHERE ${a.wo} AND (a.titel LIKE ? ESCAPE '\\' OR a.beschreibung LIKE ? ESCAPE '\\')
+ ORDER BY a.status = 'erledigt', a.id DESC LIMIT ${JE_BEREICH}`,
+ [...a.werte, m, m],
+ (z) => ({
+ titel: z.titel,
+ text: ausschnitt(z.beschreibung, q),
+ zusatz: [z.creator_name, z.status].filter(Boolean).join(" · "),
+ ziel: "aufgaben.html",
+ }));
+ }
+
+ /* ---------- Termine, Calls und Protokolle ---------- */
+ const t = sichtbarTermine(person);
+ if (t) {
+ quelle("Termine & Calls", `
+ SELECT t.id, t.titel, t.beschreibung, t.art, t.beginn, t.ort, p.name AS creator_name
+ FROM termine t LEFT JOIN personen p ON p.id = t.creator_id
+ WHERE ${t.wo} AND (t.titel LIKE ? ESCAPE '\\' OR t.beschreibung LIKE ? ESCAPE '\\'
+ OR t.ort LIKE ? ESCAPE '\\')
+ ORDER BY t.beginn DESC LIMIT ${JE_BEREICH}`,
+ [...t.werte, m, m, m],
+ (z) => ({
+ titel: z.titel,
+ text: ausschnitt(z.beschreibung, q),
+ zusatz: [z.beginn?.replace("T", " · "), z.creator_name].filter(Boolean).join(" · "),
+ ziel: z.art === "termin" ? "kalender.html" : "calls.html",
+ }));
+
+ /* Protokolle haengen an Terminen und erben deren Sichtbarkeit.
+ Hier steckt oft das, wonach man wirklich sucht: was besprochen
+ und was entschieden wurde. */
+ quelle("Gesprächsprotokolle", `
+ SELECT pr.id, pr.punkte, pr.entscheidungen, t.titel, t.beginn
+ FROM protokolle pr JOIN termine t ON t.id = pr.termin_id
+ WHERE ${t.wo} AND (pr.punkte LIKE ? ESCAPE '\\' OR pr.entscheidungen LIKE ? ESCAPE '\\')
+ ORDER BY t.beginn DESC LIMIT ${JE_BEREICH}`,
+ [...t.werte, m, m],
+ (z) => {
+ const treffer = (z.entscheidungen || "").toLowerCase().includes(q.toLowerCase())
+ ? { feld: "Entscheidung", wert: z.entscheidungen }
+ : { feld: "Besprochen", wert: z.punkte };
+ return {
+ titel: z.titel,
+ text: ausschnitt(treffer.wert, q),
+ zusatz: `${treffer.feld} · ${(z.beginn || "").slice(0, 10).split("-").reverse().join(".")}`,
+ ziel: "calls.html",
+ };
+ });
+ }
+
+ /* ---------- Dateien ---------- */
+ const d = sichtbarDateien(person);
+ if (d) {
+ quelle("Dateien", `
+ SELECT d.id, d.name_original, d.notiz, d.status, p.name AS creator_name
+ FROM dateien d LEFT JOIN personen p ON p.id = d.creator_id
+ WHERE ${d.wo} AND (d.name_original LIKE ? ESCAPE '\\' OR d.notiz LIKE ? ESCAPE '\\')
+ ORDER BY d.id DESC LIMIT ${JE_BEREICH}`,
+ [...d.werte, m, m],
+ (z) => ({
+ titel: z.name_original,
+ text: ausschnitt(z.notiz, q),
+ zusatz: [z.creator_name, z.status].filter(Boolean).join(" · "),
+ ziel: "dateien.html",
+ }));
+ }
+
+ /* ---------- Betreuungsbereiche ---------- */
+ const e = sichtbarEintraege(person);
+ if (e) {
+ quelle("Betreuungsbereiche", `
+ SELECT e.id, e.bereich, e.titel, e.text, e.datum, e.status, p.name AS creator_name
+ FROM eintraege e LEFT JOIN personen p ON p.id = e.creator_id
+ WHERE ${e.wo} AND (e.titel LIKE ? ESCAPE '\\' OR e.text LIKE ? ESCAPE '\\')
+ ORDER BY e.datum DESC LIMIT ${JE_BEREICH}`,
+ [...e.werte, m, m],
+ (z) => ({
+ titel: z.titel,
+ text: ausschnitt(z.text, q),
+ zusatz: [BEREICHSNAME[z.bereich] || z.bereich, z.creator_name].filter(Boolean).join(" · "),
+ ziel: `bereich.html?b=${z.bereich}`,
+ }));
+ }
+
+ /* ---------- Scout-Pipeline ---------- */
+ if (person.rolle === "admin" || person.rolle === "scout") {
+ const nur = person.rolle === "admin" ? "1=1" : "l.scout_id = ?";
+ const werte = person.rolle === "admin" ? [] : [person.id];
+ quelle("Scout-Pipeline", `
+ SELECT l.id, l.name, l.plattform, l.handle, l.status, l.notizen, l.potenzial, l.aktivitaet
+ FROM leads l
+ WHERE ${nur} AND (l.name LIKE ? ESCAPE '\\' OR l.handle LIKE ? ESCAPE '\\'
+ OR l.notizen LIKE ? ESCAPE '\\' OR l.potenzial LIKE ? ESCAPE '\\'
+ OR l.aktivitaet LIKE ? ESCAPE '\\')
+ ORDER BY l.id DESC LIMIT ${JE_BEREICH}`,
+ [...werte, m, m, m, m, m],
+ (z) => ({
+ titel: z.name,
+ text: ausschnitt(z.notizen || z.potenzial || z.aktivitaet, q),
+ zusatz: [z.plattform, z.handle, z.status].filter(Boolean).join(" · "),
+ ziel: "scouting.html",
+ }));
+ }
+
+ /* ---------- Creator-Profile ----------
+ Nur die offenen Felder. admin_notiz, plan_start und
+ naechster_review werden NICHT durchsucht -- auch nicht fuer das
+ Management, denn ein Treffer daraus taucht sonst spaeter in einer
+ Ansicht auf, die diese Felder gar nicht zeigen darf. Wer die Notiz
+ lesen will, oeffnet das Profil. */
+ if (person.rolle === "admin" || person.rolle === "scout") {
+ let nur = "";
+ let werte = [];
+ if (person.rolle === "scout") {
+ const ids = betreuteIds(person);
+ if (!ids.length) nur = null;
+ else {
+ nur = ` AND p.id IN (${ids.map(() => "?").join(",")})`;
+ werte = ids;
+ }
+ }
+ if (nur !== null) {
+ quelle("Creator-Profile", `
+ SELECT p.id, p.name, f.nische, f.handles, f.live_zeiten, f.technik,
+ f.ziel_live, f.ziel_content, f.ziel_community, f.ziel_technik,
+ f.plan_prio1, f.plan_prio2, f.plan_prio3
+ FROM personen p LEFT JOIN profile f ON f.person_id = p.id
+ WHERE p.rolle = 'creator' AND p.aktiv = 1${nur}
+ AND (p.name LIKE ? ESCAPE '\\' OR f.nische LIKE ? ESCAPE '\\'
+ OR f.handles LIKE ? ESCAPE '\\' OR f.live_zeiten LIKE ? ESCAPE '\\'
+ OR f.technik LIKE ? ESCAPE '\\'
+ OR f.ziel_live LIKE ? ESCAPE '\\' OR f.ziel_content LIKE ? ESCAPE '\\'
+ OR f.ziel_community LIKE ? ESCAPE '\\' OR f.ziel_technik LIKE ? ESCAPE '\\'
+ OR f.plan_prio1 LIKE ? ESCAPE '\\' OR f.plan_prio2 LIKE ? ESCAPE '\\'
+ OR f.plan_prio3 LIKE ? ESCAPE '\\')
+ ORDER BY p.name LIMIT ${JE_BEREICH}`,
+ [...werte, ...Array(12).fill(m)],
+ (z) => {
+ /* Zeigen, WELCHES Feld getroffen hat -- sonst raetselt man,
+ warum ein Profil auftaucht. */
+ const felder = [
+ ["Nische", z.nische], ["Handles", z.handles], ["LIVE-Zeiten", z.live_zeiten],
+ ["Technik", z.technik], ["Ziel LIVE", z.ziel_live], ["Ziel Content", z.ziel_content],
+ ["Ziel Community", z.ziel_community], ["Ziel Technik", z.ziel_technik],
+ ["Priorität 1", z.plan_prio1], ["Priorität 2", z.plan_prio2],
+ ["Priorität 3", z.plan_prio3],
+ ];
+ const t2 = felder.find(([, w]) => w && w.toLowerCase().includes(q.toLowerCase()));
+ return {
+ titel: z.name,
+ text: t2 ? ausschnitt(t2[1], q) : null,
+ zusatz: t2 ? t2[0] : "Name",
+ ziel: "profil.html",
+ };
+ });
+ }
+ }
+
+ res.json({
+ frage: q,
+ zu_kurz: false,
+ gruppen,
+ gesamt: gruppen.reduce((s, g) => s + g.treffer.length, 0),
+ });
+ } catch (fehler) {
+ console.error("[workspace] Suche:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
diff --git a/workspace/assets/css/gate.css b/workspace/assets/css/gate.css
index e3967bce..08f4bd9a 100644
--- a/workspace/assets/css/gate.css
+++ b/workspace/assets/css/gate.css
@@ -379,3 +379,114 @@ body.oeffnet .buehne__bild { filter: saturate(1) brightness(1); transition: filt
}
.buehne__bild { transform: none; transition: none; }
}
+
+/* ===================================================================
+ Suche über alle Bereiche.
+
+ Liegt bewusst hier und nicht in einer Seiten-Datei: kopf.js baut die
+ Suche auf JEDER Workspace-Seite ein, also muss auch das Aussehen
+ überall geladen sein. gate.css ist die einzige Datei, die das
+ erfüllt. (Genau der Fehler, der bei .knopf-still schon einmal
+ passiert ist -- Baustein in einer Seiten-Datei, prompt fehlte er
+ woanders.)
+ =================================================================== */
+
+.suche-knopf {
+ display: inline-flex; align-items: center; gap: 7px;
+ padding: 7px 13px; cursor: pointer;
+ background: rgba(255, 255, 255, .04);
+ border: 1px solid var(--rand); border-radius: 999px;
+ color: var(--text-still); font: inherit; font-size: .82rem;
+ transition: color .16s ease, border-color .16s ease, background .16s ease;
+}
+.suche-knopf:hover {
+ color: var(--text);
+ border-color: rgba(63, 189, 245, .4);
+ background: rgba(63, 189, 245, .08);
+}
+.suche-knopf__lupe {
+ width: 15px; height: 15px; flex: none;
+ fill: none; stroke: currentColor; stroke-width: 1.8;
+ stroke-linecap: round;
+}
+@media (max-width: 620px) { .suche-knopf__text { display: none; } }
+
+.suche-schleier {
+ position: fixed; inset: 0; z-index: 80;
+ /* flex-start, nicht das voreingestellte stretch -- sonst zieht sich
+ das Fenster auf volle Hoehe, auch bei zwei Treffern. */
+ display: flex; justify-content: center; align-items: flex-start;
+ padding: 9vh 18px 18px;
+ background: rgba(3, 5, 10, .74);
+ backdrop-filter: blur(5px);
+}
+body.suche-offen { overflow: hidden; }
+
+.suche-fenster {
+ width: min(680px, 100%); max-height: 78vh;
+ display: flex; flex-direction: column;
+ background: #0b111b;
+ border: 1px solid rgba(63, 189, 245, .26);
+ border-radius: var(--radius);
+ box-shadow: 0 28px 70px -22px rgba(0, 0, 0, .85);
+ overflow: hidden;
+}
+
+.suche-zeile {
+ display: flex; align-items: center; gap: 10px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--rand);
+}
+.suche-feld {
+ flex: 1; min-width: 0; padding: 6px 2px;
+ background: transparent; border: 0;
+ color: var(--text); font: inherit; font-size: 1.02rem;
+}
+.suche-feld:focus { outline: none; }
+.suche-feld::placeholder { color: var(--text-still); }
+/* Das eingebaute Kreuz von type="search" steht sonst direkt neben dem
+ Esc-Knopf -- zwei Wege, dasselbe zu tun, dicht nebeneinander. */
+.suche-feld::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
+.suche-zu {
+ flex: none; padding: 4px 9px; cursor: pointer;
+ background: rgba(255, 255, 255, .05);
+ border: 1px solid var(--rand); border-radius: var(--radius-klein);
+ color: var(--text-still); font: inherit; font-size: .74rem; letter-spacing: .04em;
+}
+.suche-zu:hover { color: var(--text); border-color: rgba(255, 255, 255, .22); }
+
+.suche-ergebnis { overflow-y: auto; padding: 6px 10px 12px; }
+.suche-hinweis {
+ margin: 0; padding: 26px 8px; text-align: center;
+ color: var(--text-still); font-size: .88rem;
+}
+
+.suche-gruppe { margin-top: 10px; }
+.suche-gruppe__titel {
+ margin: 0 0 5px; padding: 0 8px;
+ font-size: .71rem; font-weight: 700;
+ letter-spacing: .08em; text-transform: uppercase; color: var(--akzent);
+}
+
+.suche-treffer {
+ display: block; padding: 9px 11px; margin-bottom: 3px;
+ border-radius: var(--radius-klein);
+ color: var(--text); text-decoration: none;
+ border: 1px solid transparent;
+}
+.suche-treffer:hover, .suche-treffer:focus-visible {
+ background: rgba(255, 255, 255, .05);
+ border-color: var(--rand);
+ outline: none;
+}
+.suche-treffer__titel { display: block; font-size: .92rem; font-weight: 600; }
+.suche-treffer__zusatz {
+ display: block; margin-top: 1px;
+ font-size: .74rem; color: var(--text-still);
+}
+.suche-treffer__text {
+ display: block; margin-top: 3px;
+ font-size: .81rem; color: var(--text-leise); line-height: 1.45;
+}
+
+@media (prefers-reduced-motion: reduce) { .suche-knopf { transition: none; } }
diff --git a/workspace/assets/js/kopf.js b/workspace/assets/js/kopf.js
index d4f1ae4f..4a51be8f 100644
--- a/workspace/assets/js/kopf.js
+++ b/workspace/assets/js/kopf.js
@@ -1,5 +1,5 @@
/* ===================================================================
- Gemeinsames Verhalten der Kopfleiste: Zurück-Knopf und Abmelden.
+ 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
@@ -65,4 +65,143 @@
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();
+ });
+ }
})();