diff --git a/server/pruef-suchfeld.mjs b/server/pruef-suchfeld.mjs new file mode 100644 index 00000000..559e7de8 --- /dev/null +++ b/server/pruef-suchfeld.mjs @@ -0,0 +1,200 @@ +/* KANN MAN IM SUCHFELD DER AUSWAHLLISTE TIPPEN? + Filipe: „bei suchen kann man nichts reinschreiben." + + VERDACHT: Die Liste liegt seit dem Popover-Umbau von heute frueh im + Top-Layer -- und der Dialog darunter ist MODAL. Ein modaler Dialog + sperrt den Fokus auf sich selbst ein; ein Element ausserhalb ist + dann zwar sichtbar, aber nicht bedienbar. + + BESTAETIGT, UND ES WAR MEIN FEHLER VOM SELBEN TAG: Die Liste hing + nach dem Popover-Umbau am
. Sichtbar (der Popover hebt sie in + die Top Layer), aber ausserhalb des Fokus-Kaefigs -- klicken ging, + tippen nicht. + + WARUM DAS EINE DAUERPRUEFUNG IST: Der Fehler war von aussen nicht + zu sehen. Die Liste stand da, sie liess sich anklicken, sie + filterte auf Knopfdruck. Nur eine Eingabe kam nicht an, und das + faellt erst auf, wenn jemand wirklich tippt. Genau so hat Filipe + ihn gefunden -- nicht ich. + + GEPRUEFT WIRD BEIDES GEGENEINANDER: Der Fokus muss ankommen UND + die Liste darf nicht abgeschnitten sein. Die beiden Anforderungen + haben sich an dieser Stelle schon zweimal gegenseitig kaputt + gemacht (draussen: nicht bedienbar, drinnen: beschnitten). */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-sf-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Suchfeld-Pruefung"); +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(150_000, "pruef-suchfeld"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = `http://127.0.0.1:${PORT}`; + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const ks = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", ks, jetzt); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", ks).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("VanVan", "hand", "CODE-HAND-0001"); +d.close(); + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +try { + const kontext = await browser.newContext({ viewport: { width: 1100, height: 850 } }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1800); + + await seite.evaluate(() => document.getElementById("neu-oeffnen")?.click()); + await seite.waitForTimeout(700); + await seite.evaluate(() => { + [...document.querySelectorAll("#neu-art [data-art]")] + .find((x) => /kanal/i.test(x.dataset?.art || x.textContent))?.click(); + }); + await seite.waitForTimeout(500); + await seite.evaluate(() => document.querySelector("#kanal-zeile .wahl2__knopf")?.click()); + await seite.waitForTimeout(800); + + const lage = await seite.evaluate(() => { + const feld = document.querySelector(".wahl2__suche"); + const liste = document.querySelector(".wahl2__liste"); + const dlg = document.querySelector("dialog[open]"); + return { + feldDa: !!feld, + listeDa: !!liste, + popover: liste ? liste.matches(":popover-open") : null, + /* DIE ENTSCHEIDENDE FRAGE: Liegt die Liste INNERHALB des + modalen Dialogs? Ein modaler Dialog sperrt den Fokus auf + seinen eigenen Baum ein. */ + imDialog: dlg && liste ? dlg.contains(liste) : null, + dialogModal: dlg ? dlg.matches(":modal") : null, + frei: document.getElementById("kanal-kategorie")?.dataset.frei || "(nicht gesetzt)", + }; + }); + console.log(" " + JSON.stringify(lage)); + ok(lage.feldDa, "das Suchfeld ist ueberhaupt da"); + + /* 1. LAESST SICH DER FOKUS SETZEN? */ + const fokus = await seite.evaluate(() => { + const feld = document.querySelector(".wahl2__suche"); + if (!feld) return null; + feld.focus(); + return document.activeElement === feld + || document.activeElement?.className === feld.className; + }); + ok(fokus === true, `der Fokus landet im Suchfeld (${fokus})`); + + /* 2. KOMMT EIN GETIPPTES ZEICHEN AN? Ueber die echte Tastatur -- + `fill()` setzt den Wert direkt und wuerde auch bei gesperrtem + Fokus gelingen. */ + await seite.keyboard.type("comm"); + await seite.waitForTimeout(400); + const getippt = await seite.evaluate(() => + document.querySelector(".wahl2__suche")?.value || ""); + console.log(` nach dem Tippen steht im Feld: "${getippt}"`); + ok(getippt.length > 0, `ein getipptes Zeichen kommt an ("${getippt}")`); + + /* 3. UND FILTERT ES WIRKLICH? */ + const gefiltert = await seite.evaluate(() => + document.querySelectorAll(".wahl2__zeilen .wahl2__eintrag").length); + console.log(` sichtbare Eintraege nach dem Tippen: ${gefiltert}`); + ok(getippt.length === 0 || gefiltert > 0, "und die Liste filtert danach"); + + /* 4. GIBT ES DEN WEG FUER EINEN EIGENEN NAMEN? */ + ok(lage.frei === "ja", + `das Feld erlaubt freie Namen (data-frei="${lage.frei}")`); + + /* 5. UND DAS ALTE PROBLEM DARF NICHT ZURUECK SEIN. + + Die Liste hing frueher am , WEIL sie als Kind des Dialogs + vom clip-path der abgeschraegten Ecke abgeschnitten wurde ("Die + Liste endete mitten im Wort Events"). Jetzt haengt sie wieder im + Dialog -- die Annahme ist, dass der Popover sie in die Top Layer + hebt und der Beschnitt sie dort nicht erreicht. + + DAS IST EINE ANNAHME, ALSO WIRD SIE GEMESSEN: Ist der letzte + Eintrag der Liste wirklich sichtbar, oder liegt er unter dem + Beschnitt? Gefragt wird nicht das Rechteck (das stimmt auch bei + abgeschnittenem Inhalt), sondern was an seiner Stelle wirklich + zu sehen ist. */ + await seite.evaluate(() => { + const f = document.querySelector(".wahl2__suche"); + if (f) { f.value = ""; f.dispatchEvent(new Event("input", { bubbles: true })); } + }); + await seite.waitForTimeout(400); + const beschnitt = await seite.evaluate(() => { + const zeilen = [...document.querySelectorAll(".wahl2__zeilen .wahl2__eintrag")]; + if (!zeilen.length) return { keine: true }; + const letzter = zeilen[zeilen.length - 1]; + letzter.scrollIntoView({ block: "nearest" }); + const r = letzter.getBoundingClientRect(); + const mitte = document.elementFromPoint( + Math.round(r.left + r.width / 2), Math.round(r.top + r.height / 2)); + return { + anzahl: zeilen.length, + hoehe: Math.round(r.height), + /* Trifft ein Klick auf die letzte Zeile wirklich sie selbst? */ + trifft: !!(mitte && (mitte === letzter || letzter.contains(mitte))), + was: mitte ? (mitte.className || mitte.tagName) : "(nichts)", + /* Und liegt sie ueberhaupt im Bild? */ + imBild: r.top >= 0 && r.bottom <= window.innerHeight + 1, + }; + }); + console.log(" letzte Zeile: " + JSON.stringify(beschnitt)); + ok(!beschnitt.keine && beschnitt.hoehe > 0, + `die Liste hat sichtbare Zeilen (${beschnitt.anzahl})`); + ok(beschnitt.trifft === true, + `die LETZTE Zeile ist wirklich anklickbar -- kein Beschnitt (${beschnitt.was})`); + + ok(konsole.length === 0, konsole.length ? konsole[0] : "keine Abstuerze"); + await seite.screenshot({ path: "pruef-suchfeld.png", fullPage: false }); + await kontext.close(); +} finally { + await browser.close(); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +} + +console.log(""); +console.log(`${geprueft} Pruefungen, ${fehler} Fehler`); +console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 5cc04af1..98812339 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -764,7 +764,11 @@ function kanalName(kategorie) { Teams, sondern ein eigener Raum mit eigenen Regeln. Ohne diese Zeile hiesse er schlicht "Kanal". */ if (kategorie === TREFF_KATEGORIE) return TREFF_NAME; - return MODI_KATEGORIEN.find((k) => k.wert === kategorie)?.name || "Kanal"; + /* EIN EIGENER KANAL TRAEGT SEINEN NAMEN IN DER ZEILE (24.09.2026). + MODI_KATEGORIEN kennt ihn nicht -- fuer ihn steht der Name in + der Spalte "name" des Raums. Ohne diesen Weg hiesse jeder eigene + Kanal schlicht „Kanal". */ + return MODI_KATEGORIEN.find((k) => k.wert === kategorie)?.name || null; } /** Wer darf hier etwas anheften? @@ -918,7 +922,11 @@ function gehoertInKanal(id) { } function raumName(raum, teilnehmer, ichId) { - if (raum.art === "kanal") return raum.name || kanalName(raum.kategorie); + /* Der gespeicherte Name zuerst -- bei einem eigenen Kanal ist er + die einzige Quelle. Der Rueckfall auf "Kanal" gilt Raeumen aus + der Zeit vor der Namensspalte; ohne ihn stuende dort seit dem + 24.09. gar nichts, weil kanalName() jetzt null liefert. */ + if (raum.art === "kanal") return raum.name || kanalName(raum.kategorie) || "Kanal"; if (raum.art === "gruppe") return raum.name || "Gruppe"; const andere = teilnehmer.filter((t) => t.id !== ichId); return andere.map((t) => t.name).join(", ") || "Gespräch"; @@ -931,7 +939,24 @@ function raumName(raum, teilnehmer, ichId) { /** Mit wem kann ich ein Gespräch anfangen? */ chatRouter.get("/workspace/api/chat/partner", (req, res) => { try { - const ids = schreibbareIds(req.person); + /* ==== FUER EINEN KANAL GILT EINE ANDERE LISTE (24.09.2026) ==== + + Filipe: „ich will auch die option kanäle mit den leuten mit der + community rolle." + + Seit dem 19.09. nimmt "ohneAussen()" die Community aus den + Listen aller anderen -- weil ein Zweier-Gespraech mit einem + Zuschauer eine Standleitung ohne Moderation waere. Diese + Begruendung gilt fuer ein Gespraech und NICHT fuer einen Kanal: + Dort sitzt die ganze Teamfuehrung drin, und er ist moderierbar. + + "?fuer=kanal" fragt also eine andere Frage, nicht dieselbe mit + weniger Sorgfalt. Wer Kanaele gar nicht aufmachen darf, bekommt + die erweiterte Liste auch nicht -- sonst waere der Parameter + ein Weg, an "ohneAussen" vorbei Namen zu erfahren. */ + const fuerKanal = String(req.query?.fuer || "") === "kanal" + && darfKanalAufmachen(req.person); + const ids = fuerKanal ? null : schreibbareIds(req.person); const wo = ids === null ? "id <> ?" : `id IN (${ids.map(() => "?").join(",")})`; const werte = ids === null ? [req.person.id] : ids; @@ -1296,9 +1321,54 @@ chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ lim nicht erfahren, dass es die Moeglichkeit gibt. */ if (!darfKanalAufmachen(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* ==== EIN EIGENER NAME IST ERLAUBT (24.09.2026) =============== + + Filipe: „ich will dass man die option hat einen neuen namen zu + erstellen den es noch nicht da in der liste gibt." + + Bis heute musste die Zustaendigkeit aus MODI_KATEGORIEN + stammen -- neun feste Themen. Jetzt geht auch ein eigener + Name, und die Liste bleibt als Vorschlag. + + DER SCHLUESSEL WIRD ABGELEITET, NICHT GETIPPT. Er landet in + der Datenbank und in Adressen; Leerzeichen, Umlaute und + Satzzeichen haetten dort nichts verloren. Aus „Technik & Ton" + wird "technik-ton". Der ANGEZEIGTE Name bleibt, wie er + geschrieben wurde -- er steht in "name". + + WARUM EIN PRAEFIX: Ein eigener Schluessel darf nie mit einem + festen zusammenfallen. Schriebe jemand „Community", entstuende + "community" -- derselbe Schluessel wie das feste Thema, und + der eindeutige Index in der Datenbank wuerde ihn ablehnen, + obwohl der Kanal noch gar nicht existiert. Mit "eigen-" davor + kann das nicht passieren. + + DER NAME WIRD MITGESPEICHERT. Bei den festen Themen kommt er + aus MODI_KATEGORIEN (siehe kanalName weiter oben); ein eigener + hat dort keinen Eintrag, deshalb steht er in der Spalte + "name". */ const kategorie = String(req.body?.kategorie ?? "").trim(); - if (!MODI_KATEGORIEN.some((k) => k.wert === kategorie)) { - return res.status(400).json({ fehler: "Unbekannte Kategorie." }); + const eigenerName = String(req.body?.eigener_name ?? "").trim() + .replace(/\s+/g, " ").slice(0, 60); + const fest = MODI_KATEGORIEN.some((k) => k.wert === kategorie); + let schluessel = kategorie; + let anzeigeName = null; + if (!fest) { + if (eigenerName.length < 2) { + return res.status(400).json({ + fehler: "Für einen eigenen Kanal brauchst du einen Namen (mindestens zwei Zeichen).", + }); + } + const rein = eigenerName.toLowerCase() + .replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40); + if (!rein) { + return res.status(400).json({ + fehler: "Der Name braucht mindestens einen Buchstaben oder eine Ziffer.", + }); + } + schluessel = "eigen-" + rein; + anzeigeName = eigenerName; } const d = db(); @@ -1307,7 +1377,7 @@ chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ lim jemand lesen kann, und dort eine Zusage, die auch dann gilt, wenn einmal zwei Anfragen im selben Augenblick ankommen. */ const schon = d.prepare("SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?") - .get(kategorie); + .get(schluessel); if (schon) { return res.status(409).json({ fehler: "Diesen Kanal gibt es schon.", raum_id: schon.id }); } @@ -1335,7 +1405,10 @@ chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ lim const n = jetzt(); d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von) VALUES ('kanal',?,?,?,?)`) - .run(kanalName(kategorie), kategorie, n, req.person.id); + /* Der eigene Name geht in die Spalte, der feste kommt aus + MODI_KATEGORIEN. Beides landet in derselben Spalte -- die + Zeile im Chat liest nur noch dort nach. */ + .run(anzeigeName || kanalName(schluessel), schluessel, n, req.person.id); const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id; /* DIE GANZE TEAMFUEHRUNG KOMMT HINEIN, nicht nur der Anlegende. @@ -1363,7 +1436,7 @@ chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ lim personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${raumId} ${kategorie} (${drin})`.slice(0, 120), }); - res.status(201).json({ raum_id: raumId, name: kanalName(kategorie) }); + res.status(201).json({ raum_id: raumId, name: anzeigeName || kanalName(schluessel) }); } catch (fehler) { console.error("[chat] Kanal:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index c44395e4..cb334625 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +