diff --git a/server/pruef-bremse.mjs b/server/pruef-bremse.mjs new file mode 100644 index 00000000..84a42d23 --- /dev/null +++ b/server/pruef-bremse.mjs @@ -0,0 +1,275 @@ +/* ===================================================================== + STUFE 4: DIE BREMSE UND DIE TEAM-SICHT (17.09.2026) + + Aus dem Plan im Vault, Stufe 4 -- und sie steht dort bewusst VOR + Galerie und Gespraech: Mehr Sichtbarkeit heisst mehr Angriffsflaeche. + Ein Bereich, der waechst und keine Bremse hat, waechst genau einmal. + + --------------------------------------------------------------------- + WAS ES SCHON GAB -- UND WAS DAZWISCHEN FEHLTE + + Der Treff kennt Stufen (WER ueberhaupt schreiben darf: neu, dabei, + stamm) und Massnahmen (wer NICHT MEHR darf: Hinweis, Pause, + Ausschluss). Dazwischen fehlte die Frage, wie SCHNELL. Ein Stammgast + konnte in einer Minute vierzig Beitraege absetzen -- aus Aerger, aus + Versehen oder weil jemand ein Skript laufen laesst. + + --------------------------------------------------------------------- + UND WAS BEWUSST NICHT GEBAUT WURDE + + Der Plan schlug zusaetzlich eine STILLE SPERRE vor (er schreibt, nur + sieht es niemand). Beim Messen stellte sich heraus: `gast` ist eine + Rolle mit Zugangscode, es gibt KEINE Selbstanmeldung. Wer + ausgeschlossen wird, kann sich also nicht einfach neu anmelden -- + und damit loest eine stille Sperre ein Problem, das dieses System + gar nicht hat. Sie bliebe ein Werkzeug, das Menschen taeuscht, ohne + etwas zu verhindern. + + --------------------------------------------------------------------- + DREI FRAGEN + + 1. Greift sie ueberhaupt -- und sagt sie, WANN es weitergeht? + 2. Trifft sie die richtige Arbeit? (Content-Ideen sind keine + Community-Beitraege und duerfen nicht mitzaehlen.) + 3. Laesst sie den NORMALEN Fall in Ruhe? Eine Bremse, die das + richtige Verhalten bestraft, wird abgeschaltet -- und ab da ist + sie wirkungslos. + + Aufrufen mit: node server/pruef-bremse.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { heuteLokal } from "./helfer-tag.mjs"; + +const PORT = await portMussFreiSein(4485, "pruef-bremse"); +const ordner = mkdtempSync(join(tmpdir(), "ws-bremse-")); +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-bremse"); +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++; }; + +/* DIE GRENZE WIRD NICHT ABGESCHRIEBEN, sondern aus dem Code geholt. + Eine zweite Zahl hier waere beim naechsten Anpassen auseinander- + gelaufen -- und die Pruefung haette dann etwas anderes gemessen als + der Server tut. */ +const { BEITRAEGE_JE_STUNDE } = await import("./workspace-bereiche.js") + .then((m) => ({ BEITRAEGE_JE_STUNDE: m.BEITRAEGE_JE_STUNDE })); + +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); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idRuhig = anlegen("Nane", "modi", "CODE-RUHIG-0001"); +void idRuhig; +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 ruhig = await anmelden("creator", "CODE-RUHIG-0001"); +const kopf = (keks) => ({ "Content-Type": "application/json", cookie: keks, origin: BASIS }); + +async function schreiben(keks, bereich, titel, extra = {}) { + const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { + method: "POST", headers: kopf(keks), + body: JSON.stringify({ art: bereich === "wunsch" ? "stream" : "regel", + titel, datum: heuteLokal(), ...extra }), + }); + return { status: a.status, daten: await a.json().catch(() => null) }; +} + +/* ======================================================================= + 1. SIE GREIFT -- UND SAGT, WANN ES WEITERGEHT + ======================================================================= */ +melde(""); +melde("=== 1. Die Bremse ==="); +{ + ok(Number.isInteger(BEITRAEGE_JE_STUNDE) && BEITRAEGE_JE_STUNDE > 0, + `die Grenze steht im Code: ${BEITRAEGE_JE_STUNDE} je Stunde`); + + let letzter = null; + for (let i = 1; i <= BEITRAEGE_JE_STUNDE; i++) { + letzter = await schreiben(dogi, "wunsch", `Wunsch Nummer ${i}`); + if (letzter.status !== 201) break; + } + /* DER NORMALE FALL MUSS DURCHGEHEN. Eine Bremse, die das richtige + Verhalten bestraft, wird abgeschaltet -- und ist ab da wirkungslos. + Das ist in diesem Haus schon einmal passiert. */ + ok(letzter?.status === 201, + `die ersten ${BEITRAEGE_JE_STUNDE} gehen durch (${letzter?.status})`); + + const zuViel = await schreiben(dogi, "wunsch", "Einer zu viel"); + ok(zuViel.status === 429, `der naechste wird gebremst (${zuViel.status})`); + /* OHNE ZEITANGABE probiert jemand im Minutentakt weiter -- genau die + Last, die man verhindern wollte. */ + ok(/\d+\s+Minute/.test(zuViel.daten?.fehler || ""), + `und die Absage sagt, wann es weitergeht ("${zuViel.daten?.fehler || ""}")`); + ok(/\d+ Beiträge/.test(zuViel.daten?.fehler || ""), + "und warum"); +} + +/* ======================================================================= + 2. SIE TRIFFT NUR DIE COMMUNITY + ======================================================================= */ +melde(""); +melde("=== 2. Die richtige Arbeit ==="); +{ + /* DIESELBE PERSON ist im Treff gerade gesperrt. In der + Content-Planung muss sie weiterarbeiten koennen -- dort ist ein + Eintrag Arbeit am eigenen Kanal, kein Community-Beitrag. + + Beim ersten Entwurf zaehlte die Bremse ALLE Eintraege. Dann haette + eine Creatorin mit zehn Content-Ideen danach im Treff nichts mehr + schreiben koennen. */ + const a = await fetch(`${BASIS}/workspace/api/bereich/content`, { + method: "POST", headers: kopf(dogi), + body: JSON.stringify({ art: "idee", titel: "Eine Content-Idee", datum: heuteLokal() }), + }); + const s = a.status; + if (s === 400 || s === 403 || s === 404) { + melde(` -- Content-Bereich nicht beschreibbar (${s}) -- Abschnitt nicht messbar`); + } else { + ok(s === 201, `die Content-Planung bleibt offen (${s})`); + } + + /* UND EIN ANDERES BRETT DES TREFFS ist mitgesperrt -- die Bremse + gilt der Person, nicht dem Brett. Sonst wechselt man einfach. */ + const b = await schreiben(dogi, "anschlag", "Eine Ansage"); + ok(b.status === 429, `ein anderes Community-Brett ebenfalls (${b.status})`); +} + +/* ======================================================================= + 3. SIE TRIFFT NUR DIE RICHTIGE PERSON + ======================================================================= */ +melde(""); +melde("=== 3. Nur wer zu viel schreibt ==="); +{ + /* OHNE DIESEN ABSCHNITT hiesse "429" nur, dass gerade alles gebremst + wird -- etwa durch eine Zaehlung, die die Person vergisst. */ + const a = await schreiben(ruhig, "wunsch", "Mein einziger Wunsch"); + ok(a.status === 201, + `wer nichts geschrieben hat, schreibt weiter (${a.status} ${a.daten?.fehler || ""})`); +} + +/* ======================================================================= + 4. GEGENPROBE: ZAEHLT SIE WIRKLICH DIE STUNDE? + ======================================================================= */ +melde(""); +melde("=== 4. Die Gegenprobe ==="); +{ + /* Die Beitraege der gesperrten Person auf VORGESTERN zurueckdatieren. + Dann muss sie sofort wieder schreiben duerfen -- sonst zaehlt die + Bremse nicht die letzte Stunde, sondern alles. */ + const e = new DatabaseSync(process.env.WORKSPACE_DB); + const vorgestern = new Date(Date.now() - 2 * 86400_000).toISOString(); + const wer = e.prepare("SELECT id FROM personen WHERE name = 'Filipe'").get().id; + const n = e.prepare("UPDATE eintraege SET erstellt = ? WHERE erstellt_von = ?") + .run(vorgestern, wer).changes; + e.close(); + ok(n >= BEITRAEGE_JE_STUNDE, `${n} Beitraege auf vorgestern zurueckdatiert`); + + const a = await schreiben(dogi, "wunsch", "Und jetzt wieder"); + ok(a.status === 201, `danach geht es sofort wieder (${a.status})`); +} + +/* ======================================================================= + 5. WAS HEREINKOMMT -- DIE TEAM-SICHT + ======================================================================= */ +melde(""); +melde("=== 5. Alle Bretter in einer Liste ==="); +{ + /* Ein Moderator musste acht Bretter durchklicken, um zu sehen, ob + etwas Neues da ist. Das tut niemand achtmal am Tag -- und was + niemand ansieht, moderiert auch niemand. */ + await schreiben(dogi, "anschlag", "Frische Ansage"); + await schreiben(ruhig, "wunsch", "Ein frischer Wunsch"); + + const a = await fetch(BASIS + "/workspace/api/treff/zulauf", { headers: { cookie: dogi } }); + const j = await a.json().catch(() => null); + const z = j?.zulauf || []; + ok(a.status === 200 && z.length > 0, `${z.length} Beitraege in der Team-Sicht`); + + /* AUS MEHREREN BRETTERN -- sonst waere es nur eine Liste mehr. */ + const bretter = new Set(z.map((e) => e.bereich)); + ok(bretter.size >= 2, + `aus mehreren Brettern (${[...bretter].join(", ")})`); + + /* DAS NEUESTE ZUERST. In Abschnitt 4 wurden Filipes Beitraege auf + vorgestern zurueckdatiert -- die frischen muessen darueber stehen. */ + const zeiten = z.map((e) => Date.parse(e.erstellt)); + ok(zeiten.every((t, i) => i === 0 || zeiten[i - 1] >= t), + "das Neueste zuerst"); + ok(z[0]?.titel === "Ein frischer Wunsch" || z[0]?.titel === "Frische Ansage", + `und ganz oben steht der juengste ("${z[0]?.titel}")`); + + /* WER ES GESCHRIEBEN HAT gehoert dazu -- eine Liste ohne Namen sagt + einem Moderator nichts ueber ein Muster. */ + ok(z.every((e) => e.von_name), "jede Zeile nennt, wer geschrieben hat"); + + /* UND SIE IST NUR FUERS TEAM. Ein Creator sieht die Community-Bretter + gar nicht -- er darf auch ihre Uebersicht nicht bekommen. */ + const idC = 0; void idC; + const creaKeks = await (async () => { + const e = new DatabaseSync(process.env.WORKSPACE_DB); + const salt2 = randomBytes(16).toString("hex"); + const hash2 = scryptSync("CODE-CREA-0001", salt2, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + e.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run("Tili", "creator", hash2, salt2, 32768, + createHmac("sha256", schluessel).update("CODE-CREA-0001").digest("hex"), jetzt); + e.close(); + return anmelden("creator", "CODE-CREA-0001"); + })(); + const b = await fetch(BASIS + "/workspace/api/treff/zulauf", { headers: { cookie: creaKeks } }); + ok(b.status === 403 || b.status === 404, + `ein Creator bekommt sie nicht (${b.status})`); +} + +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/workspace-bereiche.js b/server/workspace-bereiche.js index b69971f5..54c1bab6 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -580,6 +580,15 @@ const BEREICHE_FUER_ALLE = Object.entries(BEREICHE) const DRINGLICHKEITEN = ["hoch", "mittel", "niedrig"]; const STATUS = ["offen", "erledigt"]; +/* Wie viele Beitraege in einer Stunde? Zehn ist reichlich fuer einen + Menschen und wenig fuer ein Skript. Die Zahl steht hier und nicht in + der Route, damit sie an genau einer Stelle steht -- die Pruefung + liest sie mit. */ +export const BEITRAEGE_JE_STUNDE = 10; +/* Gilt die Bremse hier? Nur in den Community-Bereichen. Eine + Content-Planung ist Arbeit am eigenen Kanal; dort waere eine Bremse + eine Behinderung ohne Gegenwert. */ +const treffBereich = (b) => TREFF_BRETTER.includes(b); const TITEL_MAX = 160; const TEXT_MAX = 6000; @@ -1459,6 +1468,54 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re const warum = darfSchreiben(req.person, bereich); if (warum) return res.status(403).json({ fehler: warum }); + /* DIE BREMSE (17.09.2026) -- aus dem Plan im Vault, Stufe 4. + + Der Treff hatte Stufen (wer ueberhaupt schreiben darf) und + Massnahmen (wer nicht mehr darf), aber nichts dazwischen: WIE + SCHNELL. Ein Stammgast konnte in einer Minute vierzig Beitraege + absetzen -- aus Aerger, aus Versehen oder weil jemand ein Skript + laufen laesst. + + Ein Bereich, der waechst und keine Bremse hat, waechst genau + einmal. Deshalb steht diese Stufe im Plan VOR Galerie und + Gespraech: Mehr Sichtbarkeit heisst mehr Angriffsflaeche. + + KEINE NEUE TABELLE. Die Antwort steht schon in `eintraege` -- + eine Zaehlung ueber die letzte Stunde genuegt. Eine eigene + Tabelle waere ein zweiter Ort fuer dieselbe Wahrheit und muesste + zusaetzlich aufgeraeumt werden. + + UND DIE ABSAGE SAGT, WANN ES WEITERGEHT. "Zu viele Beitraege" + ohne Zeitangabe laesst jemanden im Minutentakt weiterprobieren + -- das ist genau die Last, die man verhindern wollte. */ + if (treffBereich(bereich)) { + const seit = new Date(Date.now() - 3600_000).toISOString(); + /* NUR DIE COMMUNITY-BRETTER ZAEHLEN MIT. Beim ersten Entwurf + stand hier keine Bereichsgrenze -- dann haette eine Creatorin, + die zehn Content-Ideen eintraegt, danach im Treff nichts mehr + schreiben koennen. Eine Bremse, die die falsche Arbeit + bestraft, wird abgeschaltet. */ + const felder = TREFF_BRETTER.map(() => "?").join(", "); + const { anzahl, aeltester } = db().prepare( + `SELECT COUNT(*) AS anzahl, MIN(erstellt) AS aeltester + FROM eintraege + WHERE erstellt_von = ? AND erstellt >= ? + AND bereich IN (${felder})`) + .get(req.person.id, seit, ...TREFF_BRETTER); + if (anzahl >= BEITRAEGE_JE_STUNDE) { + const frei = new Date(Date.parse(aeltester) + 3600_000); + const min = Math.max(1, Math.ceil((frei.getTime() - Date.now()) / 60000)); + protokolliere("treff_bremse", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${bereich} ${anzahl} in einer Stunde`, + }); + return res.status(429).json({ + fehler: `Du hast in der letzten Stunde ${anzahl} Beiträge geschrieben. ` + + `In ${min} ${min === 1 ? "Minute" : "Minuten"} geht es weiter.`, + }); + } + } + const { aus, fehler } = pruefe(bereich, req.body || {}, { neu: true }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); diff --git a/server/workspace-treff.js b/server/workspace-treff.js index c5a0d495..dd088d17 100644 --- a/server/workspace-treff.js +++ b/server/workspace-treff.js @@ -832,6 +832,39 @@ treffRouter.delete("/workspace/api/treff/massnahme/:id", gleicheHerkunft, nurTea } }); +/* ===================================================================== + WAS HEREINKOMMT (17.09.2026) -- aus dem Plan im Vault, Stufe 4. + + Ein Moderator musste bisher acht Bretter durchklicken, um zu sehen, + ob etwas Neues da ist. Das tut niemand achtmal am Tag -- und was + niemand ansieht, moderiert auch niemand. + + ALLE BRETTER IN EINER LISTE, das Neueste zuerst. Ohne Inhalt zu + wiederholen: Titel, Brett, wer, wann. Wer mehr will, klickt. + + KEINE NEUE TABELLE UND KEINE ZWEITE WAHRHEIT: Es ist dieselbe + Abfrage auf `eintraege`, nur ueber alle Community-Bretter statt eins. + Die Bretterliste kommt aus TREFF_BRETTER -- dieselbe, aus der auch + die Kacheln und die Schreibregeln kommen. Eine eigene Liste hier + waere beim naechsten Brett auseinandergelaufen, und zwar still. */ +treffRouter.get("/workspace/api/treff/zulauf", nurTeam, (req, res) => { + try { + const felder = TREFF_BRETTER.map(() => "?").join(", "); + const zeilen = db().prepare(` + SELECT e.id, e.bereich, e.art, e.titel, e.status, e.erstellt, + p.name AS von_name, p.rolle AS von_rolle + FROM eintraege e + LEFT JOIN personen p ON p.id = e.erstellt_von + WHERE e.bereich IN (${felder}) + ORDER BY e.erstellt DESC, e.id DESC + LIMIT 40`).all(...TREFF_BRETTER); + res.json({ zulauf: zeilen.map(mitRollenname) }); + } catch (f) { + console.error("[treff] Zulauf:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + treffRouter.get("/workspace/api/treff/massnahmen", nurTeam, (req, res) => { try { const zeilen = db().prepare(` diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 4932c13e..72d25968 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=202609171125", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609171134", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609171125", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609171134", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171125", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171134", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171125", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171134", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/treff.css b/workspace/assets/css/treff.css index 90b66579..f3e9cc96 100644 --- a/workspace/assets/css/treff.css +++ b/workspace/assets/css/treff.css @@ -371,3 +371,67 @@ @media (prefers-reduced-motion: reduce) { .rechte-feld { transition: none; } } + +/* ===================================================================== + WAS HEREINKOMMT — die Moderationsliste (17.09.2026) + + Aus dem Plan im Vault, Stufe 4. Ein Moderator musste acht Bretter + durchklicken, um zu sehen, ob etwas Neues da ist. Das tut niemand + achtmal am Tag — und was niemand ansieht, moderiert auch niemand. + + EINE ZEILE JE BEITRAG, nicht eine Karte. Karten sind zum Lesen, + Zeilen zum Überfliegen — und hier überfliegt man: Ist etwas + dazugekommen, und wo? Der Inhalt steht auf dem Brett, nicht hier. + Eine Vorschau wäre eine zweite Stelle, an der derselbe Beitrag steht. + ------------------------------------------------------------------- */ +.zulauf { list-style: none; margin: 0; padding: 0; } +.zulauf__zeile { + display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; + padding: 9px 2px; + border-bottom: 1px solid var(--rand); +} +.zulauf__zeile:last-child { border-bottom: 0; } + +/* Das Brett zuerst — es ist die Frage, die man beim Überfliegen hat: + WO ist etwas passiert. Ein eigener Ton je Brett macht die Liste ohne + Lesen sortierbar. */ +.zulauf__brett { + --z-ton: #6f89a8; + flex: 0 0 auto; min-width: 104px; + padding: 1px 8px; border-radius: 999px; + font-size: .72rem; font-weight: 600; + color: color-mix(in srgb, var(--z-ton) 76%, #ffffff); + background: color-mix(in srgb, var(--z-ton) 13%, transparent); + border: 1px solid color-mix(in srgb, var(--z-ton) 30%, transparent); + text-align: center; +} +.zulauf__brett[data-brett="anschlag"] { --z-ton: #cc9451; } +.zulauf__brett[data-brett="ansteht"] { --z-ton: #656a9e; } +.zulauf__brett[data-brett="treff"] { --z-ton: #cc92c6; } +.zulauf__brett[data-brett="wunsch"] { --z-ton: #b2bc78; } +.zulauf__brett[data-brett="highlight"] { --z-ton: #8e5b77; } +.zulauf__brett[data-brett="regeln"] { --z-ton: #8e784f; } +.zulauf__brett[data-brett="mitmachen"] { --z-ton: #668e6e; } + +.zulauf__titel { + flex: 1 1 200px; min-width: 0; + color: var(--text); font-weight: 600; font-size: .92rem; + text-decoration: none; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.zulauf__titel:hover { text-decoration: underline; } +.zulauf__wer { font-size: .78rem; color: var(--text-still); } +/* Die Zeit rechts und in Tabellenziffern — so stehen die Angaben + untereinander auf derselben Linie statt zu tanzen. */ +.zulauf__wann { + margin-left: auto; flex: 0 0 auto; + font-size: .76rem; color: var(--text-leise); + font-variant-numeric: tabular-nums; +} + +@media (max-width: 560px) { + /* Auf dem Handy bleibt die Zeit unter dem Titel statt ihn zu + quetschen — 390 px tragen keine vier Spalten. */ + .zulauf__wann { margin-left: 0; } + .zulauf__titel { flex-basis: 100%; } +} diff --git a/workspace/assets/js/treff-moderation.js b/workspace/assets/js/treff-moderation.js index cbe8fdd6..e85a9f04 100644 --- a/workspace/assets/js/treff-moderation.js +++ b/workspace/assets/js/treff-moderation.js @@ -66,6 +66,61 @@ } /* ---- Meldungen ---------------------------------------------------- */ + /* WAS HEREINKOMMT -- alle Community-Bretter in einer Liste. + Nicht der Inhalt, nur: was, wo, von wem, wann. Wer mehr will, + klickt auf das Brett. Eine Vorschau des Textes hier waere eine + zweite Stelle, an der derselbe Beitrag steht. */ + const BRETT_NAME = { + anschlag: 'Anschlagbrett', ansteht: 'Was ansteht', treff: 'Der Treff', + wunsch: 'Wunschliste', highlight: 'Highlights', regeln: 'Regeln & Hilfe', + mitmachen: 'Mitmachen', + }; + + async function zulaufLaden() { + const ziel = document.getElementById('zulauf'); + if (!ziel) return; + const a = await hole('/workspace/api/treff/zulauf'); + if (!a.ok) { ziel.textContent = ''; ziel.append(el('p', 'leise', 'Ging nicht.')); return; } + const { zulauf } = await a.json(); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + if (!zulauf.length) { + /* Ein Satz statt einer leeren Flaeche -- und einer, der nicht wie + ein Fehler klingt. */ + ziel.append(el('p', 'leer-hinweis', 'Noch nichts hereingekommen.')); + return; + } + const liste = el('ul', 'zulauf'); + for (const e of zulauf) { + const z = el('li', 'zulauf__zeile'); + const brett = el('span', 'zulauf__brett', BRETT_NAME[e.bereich] || e.bereich); + brett.dataset.brett = e.bereich; + z.append(brett); + const a2 = el('a', 'zulauf__titel', e.titel || '(ohne Titel)'); + a2.href = `/workspace/bereich.html?b=${encodeURIComponent(e.bereich)}#e${e.id}`; + z.append(a2); + z.append(el('span', 'zulauf__wer', + (e.von_name || 'unbekannt') + (e.von_rolle_name ? ' · ' + e.von_rolle_name : ''))); + z.append(el('span', 'zulauf__wann', zeitKurz(e.erstellt))); + liste.append(z); + } + ziel.append(liste); + } + + /* "vor 3 Min" statt eines Zeitstempels: Bei einer Moderationsliste + ist die Frage "wie frisch", nicht "wann genau". */ + function zeitKurz(iso) { + const ms = Date.now() - Date.parse(iso); + if (!Number.isFinite(ms)) return ''; + const min = Math.floor(ms / 60000); + if (min < 1) return 'gerade eben'; + if (min < 60) return `vor ${min} Min`; + const std = Math.floor(min / 60); + if (std < 24) return `vor ${std} Std`; + const tage = Math.floor(std / 24); + return tage === 1 ? 'gestern' : `vor ${tage} Tagen`; + } + async function meldungen() { const ziel = $('meldungen'); const a = await hole('/workspace/api/treff/meldungen'); @@ -303,6 +358,9 @@ (async () => { try { await leiter(); + /* ZUERST DER ZULAUF: Er steht oben auf der Seite, und eine Seite, + die von unten nach oben fuellt, wackelt beim Aufbau. */ + await zulaufLaden(); await meldungen(); await entfernt(); await mitglieder(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 6e4b04b2..2d09a3fe 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 @@ - - - - - + + + + +