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 @@ - - - - - + + + + +