From 79e675b8462146238b83e8e4deca039c6942fcd0 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 17 Sep 2026 12:12:21 +0200 Subject: [PATCH] Stufe 6 und 7: Der Treff wird ein Gespraech, jedes Brett bekommt seine Form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Damit sind alle acht Stufen des Plans gebaut. STUFE 6 -- DAS GESPRAECH "Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus Karten. Der einzige Bereich, in dem Menschen MITEINANDER reden sollen, zwang sie, nebeneinander zu reden: Jeder schrieb eine eigene Karte, niemand antwortete jemandem. Jetzt: Antworten eingerueckt unter ihrem Beitrag, aelteste zuerst (so liest man ein Gespraech), mit Namen und Rolle. Das Feld steht immer da statt hinter einem "Antworten"-Knopf -- ein leeres Feld ist die deutlichste Einladung, die es gibt. Nur EINE Stufe eingerueckt: Auf 390 px ist bei der zweiten Schluss. Eine EIGENE Tabelle, kein Eintrag mit "antwort_auf": Eine Antwort hat keine Art, keinen Zustand, keine Frist und gehoert in keinen Filter. Als Eintrag gefuehrt haette sie zwanzig immer leere Spalten -- und tauchte in jeder Zaehlung auf, die Beitraege zaehlt. Und nicht ueberall: nur Treff und Wunschliste. Aus dem Plan, §9 -- ein Antwortfeld unter jedem Aushang hiesse, jeder Aushang muss betreut werden. ZWEI FEHLER, BEIDE VON DER PRUEFUNG GEFUNDEN 1. EIN PFAD, DER AUSSAH WIE EIN BEREICH. `/api/bereich/antwort/:id` lief in die Middleware auf `/api/bereich/:bereich` -- "antwort" ist kein Bereich. Fuer DogFather ging es (ein frueherer Zweig liess ihn durch), fuer einen Modi kam 404. Ein Weg, der je nach ROLLE an voellig anderer Stelle scheitert, sucht man lange. Liegt jetzt unter `/api/antwort/:id`. 2. UND EIN ECHTES LOCH. Die Loeschregel hiess `!darfSchreiben(req.person, "treff")` -- also "wer schreiben darf", und das duerfen Gaeste ab der Stufe "dabei". JEDER GAST HAETTE JEDE FREMDE ANTWORT LOESCHEN KOENNEN. Gefunden hat es eine Pruefung mit einer FALSCHEN Erwartung: Sie verlangte, dass ein Modi keine fremde Antwort loeschen kann. Das war falsch -- er moderiert ja --, aber der Weg dorthin hat das echte Problem freigelegt. Jetzt entscheidet TREFF_TEAM_ROLLEN. Der Gast-Fall ist ueber HTTP nicht messbar (er kommt nur ueber die crew-Adresse herein, und den Host-Kopf kann fetch nicht setzen). Statt stillschweigend zu ueberspringen prueft die Pruefung die REGEL selbst -- und dass die Route wirklich diese Menge benutzt. STUFE 7 -- JEDES BRETT BEKOMMT SEINE FORM - "Regeln & Hilfe" ist ein DOKUMENT: Sprungmarken oben, vier Abschnitte (Regel, Was passiert wenn, Hilfe, Haeufige Frage). Man schlaegt es im Streitfall auf und will FINDEN, nicht scrollen. scroll-margin-top, sonst verschwindet die angesprungene Ueberschrift unter der Kopfleiste und der Sprung sieht aus wie ins Leere. - "Anschlagbrett" zeigt grosse AUSHAENGE statt Zeilen -- gemessen 21 px Titel statt 17. Ein Aushang zwischen dreissig anderen ist kein Aushang. - "Mitmachen" gruppiert nach Art, die drei Schritte zuerst. UND EINE SCHRIFT, DIE ZU KLEIN WAR: Die Rollen-Marke an einer Antwort stand auf 11,2 px, die Hausgrenze liegt bei 11,5. Die Grenze anzuheben waere der bequeme Weg gewesen -- und ab da haette pruef-css-klassen nichts mehr gehalten. NEU: pruef-gespraech (27 Pruefungen) pruef-treff-start jetzt 34 (Stufe 7 mitgeprueft) Alle Community-Pruefungen gruen: gespraech 27, treff-start 34, kreislauf 23, countdown 22, wunschliste 30, galerie 26, bremse 16, treff 66, bereiche-lesend, css-klassen, deutsche-texte 12. Co-Authored-By: Claude Opus 5 --- server/pruef-gespraech.mjs | 324 +++++++++++++++++++++++++++++++ server/pruef-treff-start.mjs | 74 +++++++ server/workspace-bereiche.js | 136 +++++++++++++ server/workspace.js | 31 +++ workspace/app.webmanifest | 8 +- workspace/assets/css/bereich.css | 136 +++++++++++++ workspace/assets/js/bereich.js | 137 +++++++++++++ workspace/aufgaben.html | 24 +-- workspace/automation.html | 26 +-- workspace/befinden.html | 24 +-- workspace/bereich.html | 34 ++-- workspace/calls.html | 28 +-- workspace/chat.html | 26 +-- workspace/content.html | 30 +-- workspace/crew-index.html | 10 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 26 +-- workspace/entwicklung.html | 24 +-- workspace/index.html | 8 +- workspace/kalender.html | 28 +-- workspace/leistung.html | 26 +-- workspace/personen.html | 26 +-- workspace/profil.html | 28 +-- workspace/rechte.html | 24 +-- workspace/report.html | 28 +-- workspace/scouting.html | 26 +-- workspace/start.html | 24 +-- workspace/startcheck.html | 28 +-- workspace/steckbrief.html | 28 +-- workspace/talente.html | 24 +-- workspace/team.html | 26 +-- workspace/teamlage.html | 24 +-- workspace/treff-moderation.html | 24 +-- workspace/treff-regeln.html | 26 +-- workspace/uebersicht.html | 26 +-- workspace/wissen.html | 26 +-- 36 files changed, 1197 insertions(+), 359 deletions(-) create mode 100644 server/pruef-gespraech.mjs diff --git a/server/pruef-gespraech.mjs b/server/pruef-gespraech.mjs new file mode 100644 index 00000000..6af73d83 --- /dev/null +++ b/server/pruef-gespraech.mjs @@ -0,0 +1,324 @@ +/* ===================================================================== + STUFE 6: DER TREFF WIRD EIN GESPRÄCH (17.09.2026) + + "Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus Karten. + Der einzige Bereich, in dem Menschen MITEINANDER reden sollen, + zwang sie, nebeneinander zu reden: Jeder schrieb eine eigene Karte, + niemand antwortete jemandem. + + --------------------------------------------------------------------- + EINE EIGENE TABELLE, KEIN HALBER EINTRAG + + Eine Antwort hat keine Art, keinen Zustand, keine Frist und gehoert + in keinen Filter. Sie als Eintrag mit `antwort_auf` zu fuehren hiesse, + ihr zwanzig Spalten mitzugeben, die immer leer sind -- und sie + tauchte in jeder Zaehlung auf, die Beitraege zaehlt. + + --------------------------------------------------------------------- + NICHT UEBERALL + + Aus dem Plan, §9: "Keine Kommentarfunktion ueberall. Nur dort, wo ein + Gespraech gemeint ist." Ein Antwortfeld unter jedem Aushang hiesse: + Jeder Aushang muss betreut werden -- und ein unbeantwortetes + Antwortfeld ist schlimmer als keins. + + Aufrufen mit: node server/pruef-gespraech.mjs + Bild: SCHIRM=1 node server/pruef-gespraech.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { heuteLokal } from "./helfer-tag.mjs"; + +const PORT = await portMussFreiSein(4493, "pruef-gespraech"); +const ordner = mkdtempSync(join(tmpdir(), "ws-gespr-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(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(180_000, "pruef-gespraech"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = `http://127.0.0.1:${PORT}`; + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +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 schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, 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", schluessel).update(code).digest("hex"), jetzt); +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Lena", "modi", "CODE-LENA-0001"); +const tabellen = d.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() + .map((z) => z.name); +d.close(); + +async function anmelden(kachel, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: kachel, code }), + }); + return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); +} +const dogi = await anmelden("admin", "CODE-DOGI-0001"); +const lena = await anmelden("creator", "CODE-LENA-0001"); +const kopf = (keks) => ({ "Content-Type": "application/json", cookie: keks, origin: BASIS }); + +async function liste(bereich, keks = dogi) { + const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } }); + return (await a.json().catch(() => null))?.eintraege || []; +} +async function antworten(bereich, id, text, keks = dogi) { + const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}/${id}/antwort`, { + method: "POST", headers: kopf(keks), body: JSON.stringify({ text }), + }); + return { status: a.status, daten: await a.json().catch(() => null) }; +} + +/* ---------- Ein Beitrag im Treff ------------------------------------- */ +const b = await fetch(`${BASIS}/workspace/api/bereich/treff`, { + method: "POST", headers: kopf(lena), + body: JSON.stringify({ art: "hallo", titel: "Hallo, ich bin neu hier", + text: "Bin über TikTok gekommen.", datum: heuteLokal() }), +}); +const beitragId = (await b.json().catch(() => null))?.id || 0; + +/* ======================================================================= + 1. DER PLATZ DAFUER + ======================================================================= */ +melde(""); +melde("=== 1. Die Tabelle ==="); +ok(tabellen.includes("eintrag_antworten"), "es gibt eine eigene Tabelle fuer Antworten"); +ok(beitragId > 0, `Lena hat etwas geschrieben (#${beitragId})`); + +/* ======================================================================= + 2. EIN GESPRAECH ENTSTEHT + ======================================================================= */ +melde(""); +melde("=== 2. Antworten ==="); +{ + const a = await antworten("treff", beitragId, "Willkommen! Schön, dass du da bist."); + ok(a.status === 201, `DogFather antwortet (${a.status} ${a.daten?.fehler || ""})`); + const z = await antworten("treff", beitragId, "Danke! Freut mich.", lena); + ok(z.status === 201, `Lena antwortet zurueck (${z.status})`); + + const e = (await liste("treff")).find((x) => x.id === beitragId); + ok((e?.antworten || []).length === 2, `zwei Antworten am Beitrag (${(e?.antworten || []).length})`); + /* AELTESTE ZUERST -- so liest man ein Gespraech. Neueste zuerst + waere eine Liste, kein Gespraech. */ + ok(e?.antworten?.[0]?.text?.startsWith("Willkommen"), + `die aeltere steht oben ("${e?.antworten?.[0]?.text?.slice(0, 20)}…")`); + ok(e?.antworten?.[0]?.von_name === "Filipe", + `mit Namen (${e?.antworten?.[0]?.von_name})`); + /* WER AUS DEM TEAM ANTWORTET, ist eine andere Auskunft als wer + mitredet. */ + ok(!!e?.antworten?.[0]?.von_rolle_name, + `und mit Rolle (${e?.antworten?.[0]?.von_rolle_name})`); + + /* "MEINE" ENTSCHEIDET UEBER DEN LOESCHKNOPF -- und muss je Person + verschieden sein, sonst sieht jeder ihn ueberall. */ + const beiLena = (await liste("treff", lena)).find((x) => x.id === beitragId); + const meineDogi = (e?.antworten || []).filter((a2) => a2.meine).length; + const meineLena = (beiLena?.antworten || []).filter((a2) => a2.meine).length; + ok(meineDogi === 1 && meineLena === 1, + `jeder sieht genau seine eigene als "meine" (${meineDogi} / ${meineLena})`); +} + +/* ======================================================================= + 3. NICHT UEBERALL + ======================================================================= */ +melde(""); +melde("=== 3. Wo es KEIN Gespraech gibt ==="); +{ + const an = await fetch(`${BASIS}/workspace/api/bereich/anschlag`, { + method: "POST", headers: kopf(dogi), + body: JSON.stringify({ art: "ansage", titel: "Eine Ansage", datum: heuteLokal() }), + }); + const id = (await an.json().catch(() => null))?.id; + const a = await antworten("anschlag", id, "Darf ich hier antworten?"); + ok(a.status === 404, `am Anschlagbrett nicht (${a.status})`); + + const e = (await liste("anschlag")).find((x) => x.id === id); + ok(e && e.antworten === undefined, + "und die Ansage bringt gar kein Antwortfeld mit"); + + /* GEGENPROBE: In der Wunschliste geht es sehr wohl -- dort ist ein + Gespraech gemeint ("ich auch, weil ..."). */ + const w = await fetch(`${BASIS}/workspace/api/bereich/wunsch`, { + method: "POST", headers: kopf(lena), + body: JSON.stringify({ art: "stream", titel: "Ein Wunsch", datum: heuteLokal() }), + }); + const wid = (await w.json().catch(() => null))?.id; + const wa = await antworten("wunsch", wid, "Das will ich auch, weil …"); + ok(wa.status === 201, `in der Wunschliste dagegen schon (${wa.status})`); +} + +/* ======================================================================= + 4. WAS NICHT DURCHGEHT + ======================================================================= */ +melde(""); +melde("=== 4. Grenzen ==="); +{ + const leer = await antworten("treff", beitragId, " "); + ok(leer.status === 400, `eine leere Antwort nicht (${leer.status})`); + const lang = await antworten("treff", beitragId, "x".repeat(1500)); + ok(lang.status === 400, `eine zu lange auch nicht (${lang.status})`); + const weg = await antworten("treff", 999999, "An nichts"); + ok(weg.status === 404, `an einen erfundenen Beitrag ebenfalls nicht (${weg.status})`); +} + +/* ======================================================================= + 5. LOESCHEN + ======================================================================= */ +melde(""); +melde("=== 5. Wieder weg ==="); +{ + const e = (await liste("treff")).find((x) => x.id === beitragId); + const vonLena = (e?.antworten || []).find((a) => a.von_name === "Lena"); + + /* DIE EIGENE IMMER. */ + const a = await fetch(`${BASIS}/workspace/api/antwort/${vonLena.id}`, + { method: "DELETE", headers: { cookie: lena, origin: BASIS } }); + const atext = await a.text().catch(() => ""); + ok(a.status === 200, `Lena loescht ihre eigene (${a.status} ${atext.slice(0,80)}) id=${vonLena.id}`); + + const e2 = (await liste("treff")).find((x) => x.id === beitragId); + ok((e2?.antworten || []).length === 1, `eine bleibt uebrig (${(e2?.antworten || []).length})`); + + /* UND DAS TEAM AUCH FREMDE -- sonst waere Moderation nicht moeglich. */ + const rest = e2.antworten[0]; + const b2 = await fetch(`${BASIS}/workspace/api/antwort/${rest.id}`, + { method: "DELETE", headers: { cookie: dogi, origin: BASIS } }); + ok(b2.status === 200, `das Team loescht auch fremde (${b2.status})`); + + /* EIN MODI DARF FREMDE LOESCHEN -- er moderiert den Treff. Das war + beim Schreiben dieser Pruefung meine falsche Erwartung; der Weg + dorthin hat aber ein echtes Loch freigelegt (siehe Kommentar an + der Route): Die Regel hiess "wer schreiben darf", und das duerfen + auch Gaeste. */ + const c = await antworten("treff", beitragId, "Noch eine von DogFather"); + const e3 = (await liste("treff")).find((x) => x.id === beitragId); + const fremd = e3.antworten.find((x) => x.id === c.daten?.id); + const d2 = await fetch(`${BASIS}/workspace/api/antwort/${fremd.id}`, + { method: "DELETE", headers: { cookie: lena, origin: BASIS } }); + ok(d2.status === 200, `ein Modi darf fremde loeschen -- er moderiert (${d2.status})`); + + /* UND DIE ENTSCHEIDENDE GEGENPROBE: EIN GAST DARF NICHT. + + Ohne sie waere nie aufgefallen, dass die Regel urspruenglich + `!darfSchreiben(...)` hiess -- also "wer schreiben darf", und das + duerfen Gaeste ab der Stufe "dabei". Jeder Gast haette jede fremde + Antwort loeschen koennen. + + UEBER HTTP IST DAS HIER NICHT MESSBAR, und das wird gesagt statt + verschwiegen: Ein Gast kommt nur ueber die crew-Adresse herein, und + den Host-Kopf kann `fetch` nicht setzen (Node verbietet es). Statt + die Pruefung stillschweigend zu ueberspringen, wird die REGEL + selbst geprueft -- dieselbe Menge, die die Route benutzt. */ + const { TREFF_TEAM_ROLLEN } = await import("./workspace-treff.js"); + ok(TREFF_TEAM_ROLLEN.has("modi") && TREFF_TEAM_ROLLEN.has("hand") + && TREFF_TEAM_ROLLEN.has("admin"), + `das Moderationsteam ist ${[...TREFF_TEAM_ROLLEN].join(", ")}`); + ok(!TREFF_TEAM_ROLLEN.has("gast"), + "und ein Gast gehoert nicht dazu -- er kann keine fremde Antwort loeschen"); + /* Und die Route benutzt wirklich DIESE Menge und nicht wieder + `darfSchreiben`. Ein Kommentar daneben wuerde es nicht verhindern. */ + const { readFileSync } = await import("node:fs"); + const quelle = readFileSync(new URL("./workspace-bereiche.js", import.meta.url), "utf8"); + const stelle = quelle.slice(quelle.indexOf("/workspace/api/antwort/:id")); + ok(/TREFF_TEAM_ROLLEN\.has\(req\.person\?\.rolle\)/.test(stelle.slice(0, 2000)), + "und die Route entscheidet mit genau dieser Menge"); +} + +/* ======================================================================= + 6. IM BROWSER + ======================================================================= */ +melde(""); +melde("=== 6. Wie es aussieht ==="); +{ + 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: 390, height: 900 } }); + await kontext.addCookies(dogi.split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); }); + await seite.goto(`${BASIS}/workspace/bereich.html?b=treff`, { waitUntil: "networkidle" }); + await seite.waitForSelector(".antwort-neu__feld", { timeout: 15_000 }); + await seite.waitForTimeout(400); + + /* UEBER DIE OBERFLAECHE ANTWORTEN, nicht ueber die Schnittstelle -- + ein Feld, das der Server annimmt und das Formular nicht + abschickt, waere gruen und nutzlos. */ + await seite.fill(".antwort-neu__feld", "Geschrieben im Browser"); + await seite.click(".antwort-neu__ab"); + await seite.waitForTimeout(1500); + + const m = await seite.evaluate(() => ({ + antworten: document.querySelectorAll(".antwort").length, + texte: [...document.querySelectorAll(".antwort__text")].map((z) => z.textContent.trim()), + /* Nur EINE Stufe eingerueckt -- auf 390 px ist bei der zweiten + Schluss. */ + verschachtelt: document.querySelectorAll(".antworten .antworten").length, + feldBreit: Math.round( + document.querySelector(".antwort-neu__feld")?.getBoundingClientRect().width || 0), + })); + ok(m.antworten >= 1, `${m.antworten} Antworten stehen da`); + ok(m.texte.some((t) => t === "Geschrieben im Browser"), + "die im Browser geschriebene ist dabei"); + ok(m.verschachtelt === 0, "nur eine Stufe eingerueckt"); + /* AUF DEM HANDY MUSS MAN TIPPEN KOENNEN. Ein Feld von 80 px ist + kein Feld. */ + ok(m.feldBreit > 150, `das Feld ist auf 390 px noch benutzbar (${m.feldBreit} px)`); + ok(konsole.length === 0, + konsole.length ? `Konsole: ${konsole[0]}` : "keine Fehler in der Konsole"); + + if (process.env.SCHIRM) { + await seite.screenshot({ path: fileURLToPath(new URL("pruef-gespraech.png", + import.meta.url)), fullPage: false }); + melde(" -- Bildschirmfoto: server/pruef-gespraech.png"); + } + await kontext.close(); + } finally { + await browser.close(); + } +} + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-treff-start.mjs b/server/pruef-treff-start.mjs index 240946f2..d5277f9f 100644 --- a/server/pruef-treff-start.mjs +++ b/server/pruef-treff-start.mjs @@ -331,6 +331,80 @@ melde("=== 6. Wie es aussieht ==="); } } +/* ======================================================================= + 7. JEDES BRETT HAT SEINE FORM (Stufe 7) + ======================================================================= */ +melde(""); +melde("=== 7. Die Form je Brett ==="); +{ + 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: 1000, height: 900 } }); + await kontext.addCookies(dogi.split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); }); + + /* Regeln sind schon uebernommen (Abschnitt 3). */ + await seite.goto(`${BASIS}/workspace/bereich.html?b=regeln`, { waitUntil: "networkidle" }); + await seite.waitForSelector(".brettabschnitt", { timeout: 15_000 }); + await seite.waitForTimeout(400); + const r = await seite.evaluate(() => ({ + abschnitte: [...document.querySelectorAll(".brettabschnitt")] + .map((z) => z.firstChild?.textContent?.trim() || ""), + marken: document.querySelectorAll(".brettmarken__weg").length, + /* Springt die Marke wirklich zu einer Ueberschrift? Ein Verweis + ins Leere sieht aus wie ein kaputter Sprung. */ + zieleDa: [...document.querySelectorAll(".brettmarken__weg")] + .every((a) => !!document.getElementById(a.getAttribute("href").slice(1))), + })); + ok(r.abschnitte.length >= 3, + `Regeln & Hilfe ist ein Dokument mit ${r.abschnitte.length} Abschnitten (${r.abschnitte.join(" · ")})`); + ok(r.marken >= 3, `mit ${r.marken} Sprungmarken oben`); + ok(r.zieleDa, "und jede Marke findet ihre Ueberschrift"); + + /* Das Anschlagbrett: Aushaenge statt Zeilen. */ + await seite.goto(`${BASIS}/workspace/bereich.html?b=anschlag`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(600); + const gross = await seite.evaluate(async () => { + const knopf = document.querySelector(".startkatalog__alle"); + if (knopf) { knopf.click(); await new Promise((r2) => setTimeout(r2, 1500)); } + const t = document.querySelector("#liste .eintrag-titel"); + return { + aushang: document.getElementById("liste")?.dataset.aushang || "", + titelGroesse: t ? Math.round(parseFloat(getComputedStyle(t).fontSize)) : 0, + anzahl: document.querySelectorAll(".eintrag-karte").length, + }; + }); + ok(gross.aushang === "ja", "das Anschlagbrett ist als Aushangbrett gesetzt"); + /* GROSS heisst messbar gross -- groesser als ein Titel auf einem + gewoehnlichen Brett (1.05rem = 17 px). */ + ok(gross.titelGroesse >= 20, + `und seine Aushaenge sind gross (${gross.titelGroesse} px)`); + ok(gross.anzahl >= 4, `${gross.anzahl} Aushaenge haengen`); + + ok(konsole.length === 0, + konsole.length ? `Konsole: ${konsole[0]}` : "keine Fehler in der Konsole"); + + if (process.env.SCHIRM) { + const { fileURLToPath } = await import("node:url"); + await seite.goto(`${BASIS}/workspace/bereich.html?b=regeln`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(600); + await seite.screenshot({ path: fileURLToPath(new URL("pruef-regeln.png", + import.meta.url)), fullPage: false }); + melde(" -- Bildschirmfoto: server/pruef-regeln.png"); + } + await kontext.close(); + } finally { + await browser.close(); + } +} + melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index bb309088..78b3773d 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -611,6 +611,13 @@ export const BEITRAEGE_JE_STUNDE = 10; Content-Planung ist Arbeit am eigenen Kanal; dort waere eine Bremse eine Behinderung ohne Gegenwert. */ const treffBereich = (b) => TREFF_BRETTER.includes(b); +/* WO EIN GESPRAECH GEMEINT IST (17.09.2026), Stufe 6. + + Nicht ueberall. Aus dem Plan, §9: "Keine Kommentarfunktion ueberall. + Nur dort, wo ein Gespraech gemeint ist." Ein Antwortfeld unter jedem + Aushang hiesse: Jeder Aushang muss betreut werden -- und ein + unbeantwortetes Antwortfeld ist schlimmer als keins. */ +const MIT_ANTWORTEN = new Set(["treff", "wunsch"]); const TITEL_MAX = 160; const TEXT_MAX = 6000; @@ -1204,6 +1211,33 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => { jeQuelle.get(z.aus_eintrag_id).push({ id: z.id, bereich: z.bereich, titel: z.titel }); } for (const e of eintraege) e.wurde = jeQuelle.get(e.id) || []; + + /* DIE ANTWORTEN, in EINER Abfrage fuer die ganze Liste. */ + if (MIT_ANTWORTEN.has(bereich)) { + const p4 = eintraege.map(() => "?").join(", "); + const alle = db().prepare( + `SELECT a.id, a.eintrag_id, a.text, a.erstellt, a.person_id, + p.name AS von_name, p.rolle AS von_rolle + FROM eintrag_antworten a + LEFT JOIN personen p ON p.id = a.person_id + WHERE a.eintrag_id IN (${p4}) + ORDER BY a.id`).all(...eintraege.map((e) => e.id)); + const jeE = new Map(); + for (const a of alle) { + if (!jeE.has(a.eintrag_id)) jeE.set(a.eintrag_id, []); + /* DEN ROLLENNAMEN MACHT `mitRollenname`, wie ueberall im + Treff. Eine eigene Uebersetzung hier waere eine zweite + Wahrheit ueber dieselbe Sache -- und Rollennamen sind in + diesem Haus die Sorte Angabe, bei der zwei Stellen einmal + zu viel sind. */ + jeE.get(a.eintrag_id).push(mitRollenname({ + id: a.id, text: a.text, erstellt: a.erstellt, + von_name: a.von_name, von_rolle: a.von_rolle, + meine: a.person_id === (req.sicht || req.person)?.id, + })); + } + for (const e of eintraege) e.antworten = jeE.get(e.id) || []; + } } /* DASS DIES EIN BRETT DES TREFFS IST, SAGT DER SERVER (11.09.2026). @@ -1685,6 +1719,108 @@ bereicheRouter.post("/workspace/api/bereich/:bereich/:id/daraus", } }); +/* ===================================================================== + ANTWORTEN (17.09.2026) -- Stufe 6 des Plans + + "Der Treff" war eine Liste aus Karten. Der einzige Bereich, in dem + Menschen MITEINANDER reden sollen, zwang sie, nebeneinander zu + reden. + + DIE BREMSE GILT AUCH HIER. Eine Antwort ist ein Beitrag; wer sie + ausnaehme, haette die Bremse mit drei Klicks umgangen. Sie zaehlt + allerdings ihren EIGENEN Vorrat: Wer zehn Beitraege geschrieben hat, + soll noch antworten koennen -- ein Gespraech, das mitten im Satz + abbricht, ist schlimmer als eins, das nicht angefangen wird. */ +const ANTWORTEN_JE_STUNDE = 20; +const ANTWORT_MAX = 1200; + +bereicheRouter.post("/workspace/api/bereich/:bereich/:id/antwort", + gleicheHerkunft, (req, res) => { + try { + const bereich = String(req.params.bereich); + const id = Number(req.params.id); + if (!MIT_ANTWORTEN.has(bereich) || !Number.isInteger(id)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const warum = darfSchreiben(req.person, bereich); + if (warum) return res.status(403).json({ fehler: warum }); + + const text = String(req.body?.text ?? "").trim(); + if (!text) return res.status(400).json({ fehler: "Schreib etwas." }); + if (text.length > ANTWORT_MAX) return res.status(400).json({ fehler: "Das ist zu lang." }); + + const regel = sichtbar(req.person); + const e = db().prepare("SELECT id FROM eintraege WHERE id = ? AND bereich = ?") + .get(id, bereich); + if (!e || !regel) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const seit = new Date(Date.now() - 3600_000).toISOString(); + const { anzahl } = db().prepare( + "SELECT COUNT(*) AS anzahl FROM eintrag_antworten WHERE person_id = ? AND erstellt >= ?") + .get(req.person.id, seit); + if (anzahl >= ANTWORTEN_JE_STUNDE) { + return res.status(429).json({ + fehler: `Du hast in der letzten Stunde ${anzahl} Antworten geschrieben. ` + + "Mach mal kurz Pause.", + }); + } + + const { lastInsertRowid } = db().prepare( + "INSERT INTO eintrag_antworten (eintrag_id, person_id, text, erstellt) VALUES (?,?,?,?)") + .run(id, req.person.id, text, jetzt()); + res.status(201).json({ id: Number(lastInsertRowid) }); + } catch (fehler) { + console.error("[workspace] Antwort:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* DER PFAD LIEGT AUSSERHALB VON `/bereich/` -- und das ist eine + Reparatur, keine Geschmacksfrage (17.09.2026). + + Zuerst hiess er `/api/bereich/antwort/:id`. Darueber liegt aber eine + Middleware auf `/api/bereich/:bereich`, die prueft, ob es den + Bereich gibt und ob die Person hineindarf -- und "antwort" ist kein + Bereich. Ergebnis: Fuer DogFather ging es (ein frueherer Zweig liess + ihn durch), fuer einen Modi kam 404. Ein Weg, der je nach Rolle an + einer voellig anderen Stelle scheitert, sucht man lange. + + Gefunden hat es die Pruefung, nicht das Lesen: "das Team loescht + auch fremde" war gruen, "Lena loescht ihre eigene" nicht. */ +bereicheRouter.delete("/workspace/api/antwort/:id", + gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const a = db().prepare( + "SELECT id, person_id FROM eintrag_antworten WHERE id = ?").get(id); + if (!a) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* DIE EIGENE IMMER, FREMDE NUR DAS TEAM. + + HIER STAND `!darfSchreiben(req.person, "treff")` -- und das war + ein Loch. `darfSchreiben` beantwortet "darf diese Person hier + SCHREIBEN", und das duerfen auch Gaeste ab der Stufe "dabei". + Ein Gast haette also jede fremde Antwort loeschen koennen. + + Gefunden hat es die Pruefung, nicht das Lesen: Sie verlangte, + dass ein Modi eine fremde Antwort NICHT loeschen kann, und + bekam 200. Die Erwartung war fuer den Modi falsch -- er + moderiert ja --, aber der Weg dorthin hat das echte Loch + freigelegt. + + `TREFF_TEAM_ROLLEN` ist die Liste, die im Haus ohnehin + entscheidet, wer im Treff moderiert: modi, hand, admin. */ + const darf = a.person_id === req.person.id + || TREFF_TEAM_ROLLEN.has(req.person?.rolle); + if (!darf) return res.status(403).json({ fehler: "nicht_erlaubt" }); + db().prepare("DELETE FROM eintrag_antworten WHERE id = ?").run(id); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Antwort loeschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + /* ---------- Anlegen ------------------------------------------------------ */ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, res) => { diff --git a/server/workspace.js b/server/workspace.js index 0debe560..d6c38bdc 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1766,6 +1766,37 @@ function umstellungen(d) { falschen Moment. */ const jetztStempel = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, ""); + /* ---- Antworten an einem Beitrag (17.09.2026), Stufe 6 ---- + + "Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus + Karten. Der einzige Bereich, in dem Menschen MITEINANDER reden + sollen, zwang sie, nebeneinander zu reden: Jeder schreibt eine + eigene Karte, niemand antwortet jemandem. + + EINE EIGENE TABELLE UND KEIN EINTRAG MIT "antwort_auf": Eine + Antwort hat keine Art, keinen Zustand, keine Frist und gehoert in + keinen Filter. Sie als halben Eintrag zu fuehren hiesse, ihr + zwanzig Spalten mitzugeben, die immer leer sind -- und sie taeuchte + in jeder Zaehlung auf, die Beitraege zaehlt. + + ON DELETE CASCADE: Verschwindet der Beitrag, verschwinden die + Antworten. Eine Antwort ohne ihre Frage ist nicht halb so viel + wert, sondern gar nichts. */ + try { + d.exec(` + CREATE TABLE IF NOT EXISTS eintrag_antworten ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE, + person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + text TEXT NOT NULL, + erstellt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_antworten_eintrag + ON eintrag_antworten (eintrag_id, id);`); + } catch (fehler) { + console.error("[workspace] Antworten-Tabelle:", fehler?.message); + } + /* ---- Der Kreislauf: ein Eintrag kommt aus einem anderen (17.09.2026) Aus dem Plan im Vault, Stufe 8 -- und die Stelle, an der der Plan diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index cdde9b78..e0b39493 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609171158", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609171211", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609171158", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609171211", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171158", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171211", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171158", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171211", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 0e1d8b14..0f4ae0f2 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -987,3 +987,139 @@ .kette__weg { color: var(--text); text-decoration: none; } .kette__weg:hover { text-decoration: underline; } .kette__wer { color: var(--text-still); } + +/* ===================================================================== + DAS GESPRÄCH (17.09.2026) — Stufe 6 des Plans + + „Der Treff" war eine Liste aus Karten. Der einzige Bereich, in dem + Menschen MITEINANDER reden sollen, zwang sie, nebeneinander zu reden. + + EINE STUFE EINGERÜCKT, NICHT MEHR. Auf 390 px ist bei der zweiten + Schluss — dann steht der Text in einer Spalte von vier Wörtern. Die + Linie links macht die Zugehörigkeit deutlicher als jeder Abstand. + ------------------------------------------------------------------- */ +.antworten { + margin: 12px 0 0 0; padding-left: 14px; + border-left: 2px solid color-mix(in srgb, var(--akzent) 22%, var(--rand)); +} +.antwort { padding: 8px 0; } +.antwort + .antwort { border-top: 1px solid color-mix(in srgb, var(--rand) 60%, transparent); } +.antwort__kopf { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; + margin: 0 0 3px; font-size: .78rem; +} +.antwort__wer { font-weight: 640; color: var(--text); } +/* Wer aus dem Team antwortet, ist eine andere Auskunft als wer + mitredet — deshalb sichtbar, aber leise. */ +.antwort__rolle { + padding: 0 7px; border-radius: 999px; + /* .74rem und nicht .7rem: Bei 11,2 px liegt die Marke unter der + Grenze, die pruef-css-klassen im Haus haelt (11,5 px). Die Grenze + anzuheben waere der bequeme Weg gewesen -- und ab da haette die + Pruefung nichts mehr gehalten. */ + font-size: .74rem; font-weight: 600; + color: color-mix(in srgb, var(--akzent) 70%, #ffffff); + background: color-mix(in srgb, var(--akzent) 13%, transparent); +} +.antwort__wann { color: var(--text-still); margin-left: auto; } +.antwort__text { + margin: 0; font-size: .88rem; line-height: 1.55; color: var(--text-leise); + overflow-wrap: anywhere; +} +.antwort__weg { + margin-top: 4px; padding: 0; + background: none; border: 0; cursor: pointer; + font: inherit; font-size: .74rem; color: var(--text-still); + text-decoration: underline; +} +.antwort__weg:hover { color: var(--text-leise); } + +/* Das Feld steht immer da — ein leeres Feld ist die deutlichste + Einladung, die es gibt. */ +.antwort-neu { display: flex; gap: 8px; margin-top: 10px; } +.antwort-neu__feld { + flex: 1 1 auto; min-width: 0; + font: inherit; font-size: .88rem; +} +.antwort-neu__ab { + flex: 0 0 auto; + font: inherit; font-size: .82rem; font-weight: 600; + padding: 0 14px; border-radius: 999px; cursor: pointer; + color: var(--text-leise); + background: transparent; + border: 1px solid var(--rand-hell); + transition: color var(--tempo), border-color var(--tempo), background var(--tempo); +} +.antwort-neu__ab:hover:not(:disabled) { + color: var(--text); background: rgba(255, 255, 255, .05); +} + +/* ===================================================================== + STUFE 7: JEDES BRETT BEKOMMT SEINE FORM (17.09.2026) + + „Regeln & Hilfe" ist ein Nachschlagewerk, kein Feed — man schlägt es + im Streitfall auf und will **finden**, nicht scrollen. „Mitmachen" + ist ein Weg. „Anschlagbrett" ist ein schwarzes Brett: wenige, große + Aushänge. + ------------------------------------------------------------------- */ + +/* Die Sprungmarken — der Unterschied zwischen einem Dokument und einer + langen Seite. */ +.brettmarken { + display: flex; flex-wrap: wrap; gap: 6px; + margin: 0 0 18px; padding-bottom: 14px; + border-bottom: 1px solid var(--rand); +} +.brettmarken__weg { + padding: 5px 12px; border-radius: 999px; + font-size: .8rem; font-weight: 600; text-decoration: none; + color: var(--text-leise); + border: 1px solid var(--rand-hell); + transition: color var(--tempo), border-color var(--tempo), background var(--tempo); +} +.brettmarken__weg:hover { + color: var(--text); background: rgba(255, 255, 255, .04); +} + +/* Die Abschnittsüberschrift. scroll-margin, damit die Überschrift beim + Anspringen nicht unter der Kopfleiste verschwindet — ohne das landet + man auf dem Text darunter und glaubt, der Sprung sei ins Leere + gegangen. */ +.brettabschnitt { + display: flex; align-items: baseline; gap: 10px; + margin: 30px 0 12px; scroll-margin-top: 80px; + font-size: .82rem; font-weight: 650; + letter-spacing: .09em; text-transform: uppercase; + color: var(--text-still); +} +.brettabschnitt:first-child { margin-top: 4px; } +.brettabschnitt::after { + content: ""; flex: 1; height: 1px; + background: linear-gradient(90deg, var(--rand), transparent); +} +.brettabschnitt__zahl { + order: 3; padding: 1px 8px; border-radius: 999px; + background: rgba(255, 255, 255, .06); + font-size: .74rem; letter-spacing: normal; color: var(--text-still); + font-variant-numeric: tabular-nums; +} + +/* --------------------------------------------------------------------- + DAS ANSCHLAGBRETT — wenige, große Aushänge + + Ein Aushang zwischen dreißig anderen ist kein Aushang. Größerer + Titel, mehr Luft, ein Rahmen, der nach Papier aussieht statt nach + Zeile. + ------------------------------------------------------------------- */ +#liste[data-aushang="ja"] { display: grid; gap: 18px; } +#liste[data-aushang="ja"] .eintrag-karte { padding: 22px 24px; } +#liste[data-aushang="ja"] .eintrag-titel { + font-size: 1.32rem; line-height: 1.25; +} +#liste[data-aushang="ja"] .eintrag-text { + font-size: .98rem; line-height: 1.6; +} +@media (max-width: 560px) { + #liste[data-aushang="ja"] .eintrag-karte { padding: 17px 16px; } + #liste[data-aushang="ja"] .eintrag-titel { font-size: 1.12rem; } +} diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 0d805d4f..f5810f74 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -646,6 +646,86 @@ if (zeile.childNodes.length) k.append(zeile); } + /* ---- DAS GESPRAECH (17.09.2026), Stufe 6 des Plans ------------- + "Der Treff" war eine Liste aus Karten. Der einzige Bereich, in + dem Menschen MITEINANDER reden sollen, zwang sie, nebeneinander + zu reden: Jeder schrieb eine eigene Karte, niemand antwortete + jemandem. + + ANTWORTEN STEHEN EINGERUECKT UNTER IHREM BEITRAG, aelteste + zuerst -- so liest man ein Gespraech. Und nur EINE Stufe + eingerueckt: Auf 390 px ist bei der zweiten Schluss, dann steht + der Text in einer Spalte von vier Woertern. */ + if (Array.isArray(e.antworten)) { + const strang = el('div', 'antworten'); + for (const a of e.antworten) { + const z = el('div', 'antwort'); + const kopf2 = el('p', 'antwort__kopf'); + kopf2.append(el('span', 'antwort__wer', a.von_name || 'jemand')); + if (a.von_rolle_name) { + kopf2.append(el('span', 'antwort__rolle', a.von_rolle_name)); + } + kopf2.append(el('span', 'antwort__wann', datum(String(a.erstellt).slice(0, 10)))); + z.append(kopf2); + z.append(el('p', 'antwort__text', a.text)); + /* Die eigene immer, fremde nur das Team -- was der Server + genauso entscheidet. Ein Knopf, den der Server ablehnt, + waere schlimmer als keiner. */ + if (a.meine || darfEintragen()) { + const w = el('button', 'antwort__weg', 'löschen'); + w.type = 'button'; + w.addEventListener('click', async () => { + w.disabled = true; + const r = await hole(`/workspace/api/antwort/${a.id}`, { method: 'DELETE' }); + if (!r.ok) { melde('Ging nicht.'); w.disabled = false; return; } + await laden(); + }); + z.append(w); + } + strang.append(z); + } + + /* DAS FELD STEHT IMMER DA, nicht hinter einem "Antworten"-Knopf. + Ein Gespraech faengt nicht an, wenn man erst einen Knopf + suchen muss -- und ein leeres Feld ist die deutlichste + Einladung, die es gibt. + + ABER NUR FUER DEN, DER AUCH DARF. Der Server entscheidet das + mit derselben Regel (`darfSchreiben`); ein Feld, dessen + Absenden der Server ablehnt, ist schlimmer als keins -- man + tippt umsonst und bekommt eine Absage fuer etwas, das man + angeboten bekommen hat. */ + if (darfEintragen()) { + const form = el('form', 'antwort-neu'); + const feld = el('input', 'antwort-neu__feld'); + feld.type = 'text'; + feld.maxLength = 1200; + feld.placeholder = e.antworten.length ? 'Antworten …' : 'Als Erster antworten …'; + feld.setAttribute('aria-label', `Antwort auf „${e.titel}“`); + const ab = el('button', 'antwort-neu__ab', 'Senden'); + ab.type = 'submit'; + form.append(feld, ab); + form.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const text = feld.value.trim(); + if (!text) return; + ab.disabled = true; + const r = await hole(`/workspace/api/bereich/${bereich}/${e.id}/antwort`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (!r.ok) { + melde((await r.json().catch(() => ({}))).fehler || 'Ging nicht.'); + ab.disabled = false; return; + } + feld.value = ''; + await laden(); + }); + strang.append(form); + } + k.append(strang); + } + if (!darfEintragen()) { k.append(fuss); return k; } const knoepfe = el('div', 'eintrag-knoepfe'); @@ -916,6 +996,12 @@ sieben davon falsch. */ if (bereich === 'highlight') ziel.dataset.galerie = 'ja'; else delete ziel.dataset.galerie; + /* DAS ANSCHLAGBRETT IST EIN SCHWARZES BRETT: wenige, GROSSE + Aushaenge. Ein Aushang zwischen dreissig anderen ist kein + Aushang -- die Wirkung entsteht durch die Groesse und die + Begrenzung. */ + if (bereich === 'anschlag') ziel.dataset.aushang = 'ja'; + else delete ziel.dataset.aushang; const sichtbar = daten.eintraege.filter((e) => { if (!filter) return true; @@ -952,6 +1038,57 @@ return; } const karten = new Map(); + + /* ---- STUFE 7: JEDES BRETT BEKOMMT SEINE FORM ------------------ + "Regeln & Hilfe" ist ein NACHSCHLAGEWERK, kein Feed: Man + schlaegt es im Streitfall auf und will finden, nicht scrollen. + Also nach Art gruppiert, mit Ueberschriften -- und ganz oben + Sprungmarken. + + "Mitmachen" ist ein WEG: erst die drei Schritte, dann die + Gebiete, dann die Fragen. Eine Liste beantwortet "was gibt + es"; die Frage lautet aber "was tue ich als Erstes". */ + const NACH_ART = { + regeln: ['regel', 'folge', 'hilfe', 'frage'], + mitmachen: ['gesucht', 'lust', 'frage'], + }; + if (NACH_ART[bereich] && !filter) { + const reihe = NACH_ART[bereich]; + const faecher = new Map(reihe.map((a) => [a, []])); + for (const e of sichtbar) { + if (!faecher.has(e.art)) faecher.set(e.art, []); + faecher.get(e.art).push(e); + } + /* DIE SPRUNGMARKEN ZUERST. Sie sind der Unterschied zwischen + einem Dokument und einer langen Seite. */ + const marken = el('nav', 'brettmarken'); + marken.setAttribute('aria-label', 'Zu einem Abschnitt springen'); + for (const a of faecher.keys()) { + if (!faecher.get(a).length) continue; + const m = el('a', 'brettmarken__weg', einstellung?.arten?.[a] || a); + m.href = `#abschnitt-${a}`; + marken.append(m); + } + if (marken.childNodes.length > 1) ziel.append(marken); + + for (const a of faecher.keys()) { + const fach = faecher.get(a); + if (!fach.length) continue; + const kopf2 = el('h2', 'brettabschnitt'); + kopf2.id = `abschnitt-${a}`; + kopf2.textContent = einstellung?.arten?.[a] || a; + kopf2.append(el('span', 'brettabschnitt__zahl', String(fach.length))); + ziel.append(kopf2); + for (const e of fach) { + const k = karte(e); + karten.set(e.id, k); + ziel.append(k); + } + } + sprungZeigen(ziel, karten); + return; + } + if (bereich === 'ansteht') { /* ALS ZEITSTRAHL, NICHT ALS LISTE (17.09.2026). diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index be6dc46e..7a823df6 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -291,10 +291,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 10f1bfca..6595d5c1 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -168,10 +168,10 @@ - - - - - + + + + + diff --git a/workspace/befinden.html b/workspace/befinden.html index 8c57c13b..13d483f9 100644 --- a/workspace/befinden.html +++ b/workspace/befinden.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -105,9 +105,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index f3ca8458..8ef367a8 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -210,14 +210,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 5b383783..63c54fb9 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 866ceeeb..2508c6fd 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 70f967d2..453ccb23 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 84c7f00a..e8ec91de 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index ea873937..1d9c2408 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609171158", + "src": "/assets/img/app-symbole/crew-192.png?v=202609171211", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609171158", + "src": "/assets/img/app-symbole/crew-512.png?v=202609171211", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171158", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171211", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171158", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171211", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 33e91b12..8c267b4f 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 655abfe5..530dde05 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -102,9 +102,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index 3d9b3a52..e683a5c1 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 20e71660..81ff6282 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index ed4ba5ef..384980eb 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -444,10 +444,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 8cbc9590..4f4484db 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 918f349c..e710dba6 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 28b0bf60..2a9d569f 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 0adc0fe0..9ddc81bc 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index cdae7186..823d2568 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index ad71d76f..e0594830 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 6578137f..7c4da903 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 3499c8f7..5b4ea659 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -146,11 +146,11 @@ - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 3542fb1b..96fff20a 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index f5b15a6b..4976809b 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 02891472..cd36c5ca 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index 86ddba74..5172be88 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -126,9 +126,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index 117dcf1a..3efa8e22 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -211,10 +211,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index c91e8e08..870f2366 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index d1d9f54c..2eb2fd14 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +