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(); + }); + } })();