diff --git a/pruef-ampel.png b/pruef-ampel.png index 4473ec12..39d07bb5 100644 Binary files a/pruef-ampel.png and b/pruef-ampel.png differ diff --git a/pruef-buehne.png b/pruef-buehne.png index d1c0b8f3..56af2e48 100644 Binary files a/pruef-buehne.png and b/pruef-buehne.png differ diff --git a/pruef-checkliste.png b/pruef-checkliste.png new file mode 100644 index 00000000..860e2a2c Binary files /dev/null and b/pruef-checkliste.png differ diff --git a/pruef-schulung.png b/pruef-schulung.png index 5a74deb8..b51117ba 100644 Binary files a/pruef-schulung.png and b/pruef-schulung.png differ diff --git a/pruef-start-computer.png b/pruef-start-computer.png index 9c6e59b5..18d331b2 100644 Binary files a/pruef-start-computer.png and b/pruef-start-computer.png differ diff --git a/pruef-start-creator.png b/pruef-start-creator.png index fd8cabed..d7c624b2 100644 Binary files a/pruef-start-creator.png and b/pruef-start-creator.png differ diff --git a/pruef-start-handy.png b/pruef-start-handy.png index 22a8f49e..76524287 100644 Binary files a/pruef-start-handy.png and b/pruef-start-handy.png differ diff --git a/server/index.js b/server/index.js index e5d9dc4e..b5123266 100644 --- a/server/index.js +++ b/server/index.js @@ -34,6 +34,7 @@ import { steckbriefRouter } from "./workspace-steckbrief.js"; import { vorlagenRouter } from "./workspace-vorlagen.js"; import { schulungRouter } from "./workspace-schulung.js"; import { ampelRouter } from "./workspace-ampel.js"; +import { checklisteRouter } from "./workspace-checkliste.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -102,6 +103,7 @@ app.use(steckbriefRouter); app.use(vorlagenRouter); app.use(schulungRouter); app.use(ampelRouter); +app.use(checklisteRouter); app.use(aufgabenRouter); app.use(personenRouter); app.use(profilRouter); diff --git a/server/pruef-checkliste.mjs b/server/pruef-checkliste.mjs new file mode 100644 index 00000000..3e9b57ab --- /dev/null +++ b/server/pruef-checkliste.mjs @@ -0,0 +1,325 @@ +/* Prueft die feste Checkliste. + + Zwei Dinge sind hier wichtiger als alles andere: + + 1. DIE LISTE IST FUER JEDEN GLEICH. Das war der Grund fuer den Umbau: + Vorher musste man sich Punkte "holen", und zwei Creator haetten + unterschiedliche Listen gehabt. Damit ist kein Vergleich moeglich, + und um Vergleich geht es bei einer Betreuung. + + 2. DER SCHLUESSEL IST STABIL. Ein Stand haengt am Schluessel des + Punktes, nicht an seiner Position. Haenge er an der Position, waere + beim Einfuegen eines Punktes in der Mitte jede Bewertung dahinter + am falschen Punkt -- und niemand merkte es, weil beides plausibel + aussieht. Geprueft wird das, indem die gespeicherte Zeile direkt + angesehen wird: Dort muss Text stehen, keine Nummer. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-check-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4207"; +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 }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4207"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 300_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); +const idZwei = anlegen("Zweiter", "creator", "CODE-ZWEI-0001"); +const idFremd = anlegen("Fremd", "creator", "CODE-FREM-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idZwei, idScout, jetzt); +d.close(); + +const keks = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); +}; +const kDogi = await keks("admin", "CODE-DOGI-0001"); +const kScout = await keks("scout", "CODE-SCOU-0001"); +const kTili = await keks("creator", "CODE-ANNA-0001"); +const kZwei = await keks("creator", "CODE-ZWEI-0001"); +const kFremd = await keks("creator", "CODE-FREM-0001"); +const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); +const senden = (pfad, k, art, koerper) => ruf(pfad, k, { + method: art, headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) }); + +/* ---------- Vier Bereiche, alle vorbereitet ------------------------------- */ +console.log("\n=== Alle vier Bereiche haben eine Liste"); +const listen = {}; +for (const bereich of ["live", "community", "technik", "content"]) { + const a = await ruf(`/workspace/api/checkliste/${bereich}`, kTili); + const r = await a.json(); + listen[bereich] = r; + const punkte = r.gruppen.reduce((s, g) => s + g.punkte.length, 0); + ok(a.ok && r.gruppen.length === 3 && punkte >= 6, + `${bereich}: ${r.gruppen.length} Gruppen, ${punkte} Punkte (${r.gruppen.map((g) => g.name).join(" · ")})`); + /* Jede Gruppe braucht einen erklaerenden Satz -- eine Ueberschrift + allein sagt nicht, wofuer die Punkte da sind. */ + ok(r.gruppen.every((g) => g.text && g.text.length > 20), + ` jede Gruppe erklaert sich (${r.gruppen.filter((g) => !g.text).length} ohne Text)`); + /* Jeder Punkt braucht einen stabilen Schluessel. */ + const ohne = r.gruppen.flatMap((g) => g.punkte).filter((p) => !p.schluessel); + ok(ohne.length === 0, ` jeder Punkt hat einen Schluessel (${ohne.length} ohne)`); +} +/* Content wird nach Saeule gruppiert -- 70/20/10. */ +ok(listen.content.gruppen[0].punkte.length >= listen.content.gruppen[1].punkte.length, + `Content: die Wert-Saeule ist die groesste (${listen.content.gruppen.map((g) => g.punkte.length).join("/")})`); + +/* ---------- DIE LISTE IST FUER JEDEN GLEICH ------------------------------- */ +console.log("\n=== Fuer jeden dieselbe Liste"); +{ + const a = await (await ruf("/workspace/api/checkliste/live", kTili)).json(); + const b = await (await ruf("/workspace/api/checkliste/live", kZwei)).json(); + const c = await (await ruf("/workspace/api/checkliste/live", kDogi)).json(); + const schluessel = (r) => r.gruppen.flatMap((g) => g.punkte.map((p) => p.schluessel)).join(","); + ok(schluessel(a) === schluessel(b), + "zwei Creator sehen exakt dieselben Punkte"); + ok(schluessel(a) === schluessel(c), + "und die Leitung ebenfalls -- sonst waere kein Vergleich moeglich"); +} + +/* ---------- Bewerten ------------------------------------------------------ */ +console.log("\n=== Bewerten darf nur die Betreuung"); +const punkt = listen.live.gruppen[0].punkte[0].schluessel; +{ + const selbst = await senden(`/workspace/api/checkliste/live/${punkt}`, kTili, "PUT", + { stufe: "gut" }); + ok(selbst.status === 403, `ein Creator kann sich NICHT selbst bewerten (${selbst.status})`); + + const ohneGrund = await senden(`/workspace/api/checkliste/live/${punkt}`, kScout, "PUT", + { stufe: "verbessern", creator_id: idTili }); + ok(ohneGrund.status === 400, `"verbessern" ohne Grund abgelehnt (${ohneGrund.status})`); + + const mit = await senden(`/workspace/api/checkliste/live/${punkt}`, kScout, "PUT", + { stufe: "verbessern", begruendung: "Der Ton rauscht noch.", creator_id: idTili }); + ok(mit.ok, `mit Grund geht es (${mit.status})`); + const r = await mit.json(); + ok(r.stand.stufe === "verbessern" && r.stand.begruendung === "Der Ton rauscht noch.", + "Stufe und Grund stehen"); + ok(r.stand.von === idScout, `mit dem Namen des Bewertenden (${r.stand.von})`); + + /* Ein Punkt, den es nicht gibt, wird abgewiesen -- sonst koennte man + Staende fuer erfundene Schluessel anlegen. */ + const erfunden = await senden("/workspace/api/checkliste/live/gibt-es-nicht", kScout, "PUT", + { stufe: "gut", creator_id: idTili }); + ok(erfunden.status === 404, `ein erfundener Punkt wird abgewiesen (${erfunden.status})`); + + /* Und ein Punkt aus einem ANDEREN Bereich auch nicht. */ + const falscherBereich = await senden( + `/workspace/api/checkliste/technik/${punkt}`, kScout, "PUT", + { stufe: "gut", creator_id: idTili }); + ok(falscherBereich.status === 404, + `ein Punkt aus einem anderen Bereich ebenfalls (${falscherBereich.status})`); +} + +/* ---------- DER SCHLUESSEL IST STABIL ------------------------------------- */ +console.log("\n=== Der Stand haengt am Punkt, nicht an seiner Position"); +{ + const p = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); + const z = p.prepare("SELECT * FROM punkt_stand WHERE creator_id = ?").get(idTili); + p.close(); + ok(z && typeof z.schluessel === "string" && !/^\d+$/.test(z.schluessel), + `gespeichert wird der Schluessel, keine Nummer ("${z?.schluessel}")`); + ok(z?.schluessel === punkt, "und zwar genau der des Punktes"); +} + +/* ---------- Der Creator SIEHT es ------------------------------------------ */ +console.log("\n=== Der Creator sieht, was er verbessern soll"); +{ + const r = await (await ruf("/workspace/api/checkliste/live", kTili)).json(); + ok(r.stand[punkt]?.stufe === "verbessern", `er sieht die Stufe (${r.stand[punkt]?.stufe})`); + ok(r.stand[punkt]?.begruendung === "Der Ton rauscht noch.", "und den Grund"); + ok(r.darf_bewerten === false, `aber darf nichts setzen (${r.darf_bewerten})`); + + /* Der ANDERE Creator darf davon nichts sehen. */ + const b = await (await ruf("/workspace/api/checkliste/live", kZwei)).json(); + ok(!b.stand[punkt], "ein anderer Creator sieht diesen Stand NICHT"); +} + +/* ---------- Zurueck auf offen --------------------------------------------- */ +console.log("\n=== Zurueck auf offen"); +{ + const a = await senden(`/workspace/api/checkliste/live/${punkt}`, kScout, "PUT", + { stufe: "offen", creator_id: idTili }); + ok(a.ok, `laesst sich zuruecknehmen (${a.status})`); + const r = await a.json(); + ok(r.stand === null, "und der Stand ist weg statt auf 'offen' gesetzt"); + const p = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); + const n = p.prepare("SELECT COUNT(*) AS n FROM punkt_stand WHERE creator_id = ?").get(idTili).n; + p.close(); + ok(n === 0, `kein Datensatz ohne Aussage bleibt liegen (${n})`); +} + +/* ---------- Nachrichten je Punkt ------------------------------------------ */ +console.log("\n=== Rueckmeldung an einem Punkt"); +{ + const a = await senden(`/workspace/api/checkliste/live/${punkt}/nachrichten`, kTili, "POST", + { text: "Ich hab ein neues Mikro bestellt." }); + ok(a.ok, `ein Creator darf antworten (${a.status})`); + const b = await senden(`/workspace/api/checkliste/live/${punkt}/nachrichten`, kScout, "POST", + { text: "Super, melde dich wenn es da ist.", creator_id: idTili }); + ok(b.ok, `die Betreuung auch (${b.status})`); + const { nachrichten } = await b.json(); + ok(nachrichten.length === 2, `beide im Verlauf (${nachrichten.length})`); + + /* Sie haengen am PUNKT -- ein anderer Punkt hat seinen eigenen Verlauf. */ + const anderer = listen.live.gruppen[0].punkte[1].schluessel; + const leer = await (await ruf( + `/workspace/api/checkliste/live/${anderer}/nachrichten`, kTili)).json(); + ok(leer.nachrichten.length === 0, + `ein anderer Punkt hat seinen eigenen Verlauf (${leer.nachrichten.length})`); + + /* Nur die eigene zuruecknehmen. */ + const meine = nachrichten.find((n) => n.von === idTili); + const fremdWeg = await ruf(`/workspace/api/checkliste/nachricht/${meine.id}`, kScout, + { method: "DELETE" }); + ok(fremdWeg.status === 403, `fremde Nachricht bleibt stehen (${fremdWeg.status})`); + const eigenWeg = await ruf(`/workspace/api/checkliste/nachricht/${meine.id}`, kTili, + { method: "DELETE" }); + ok(eigenWeg.ok, `die eigene laesst sich zuruecknehmen (${eigenWeg.status})`); +} + +/* ---------- Fremdes bleibt fremd ------------------------------------------ */ +console.log("\n=== Fremdes bleibt fremd"); +{ + const a = await senden(`/workspace/api/checkliste/live/${punkt}`, kScout, "PUT", + { stufe: "gut", creator_id: idFremd }); + ok(a.status === 404, + `ein Scout bewertet keinen fremden Creator -- und erfaehrt nicht, dass es ihn gibt (${a.status})`); + + const b = await ruf(`/workspace/api/checkliste/live?creator_id=${idTili}`, kFremd); + const r = await b.json(); + /* Bei einem Creator wird creator_id ignoriert -- er sieht immer nur + sich selbst. Der Stand von Tili darf da nicht auftauchen. */ + ok(r.creator_id === idFremd, + `ein Creator sieht immer nur sich selbst (${r.creator_id} = ${idFremd})`); +} + +/* ---------- Im Browser ----------------------------------------------------- */ +console.log("\n=== Die Seiten"); +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +/* Wieder eine Bewertung setzen, damit es etwas zu sehen gibt. */ +await senden(`/workspace/api/checkliste/live/${punkt}`, kScout, "PUT", + { stufe: "verbessern", begruendung: "Mikro naeher heranstellen.", creator_id: idTili }); + +for (const [rolle, code, name, darf] of [ + ["scout", "CODE-SCOU-0001", "Patrick", true], + ["creator", "CODE-ANNA-0001", "Tili", false], +]) { + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1100 } }); + const konsole = []; + const seite = await kontext.newPage(); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + + for (const [pfad, bereich] of [ + ["bereich.html?b=live", "live"], + ["bereich.html?b=community", "community"], + ["bereich.html?b=technik", "technik"], + ["content.html", "content"], + ]) { + await seite.goto(BASIS + "/workspace/" + pfad, { waitUntil: "networkidle" }); + await seite.waitForTimeout(1100); + const r = await seite.evaluate(() => ({ + da: !document.getElementById("vorlagen")?.hidden, + gruppen: document.querySelectorAll(".fest-gruppe").length, + punkte: document.querySelectorAll(".fest-punkt").length, + bilanz: document.querySelectorAll(".bilanz-zahl").length, + stufenKnoepfe: document.querySelectorAll(".stufe-knopf").length, + marken: document.querySelectorAll(".fest-punkt__marke-stufe").length, + /* IST DER STIL AUCH ANGEKOMMEN? Beim ersten Anlauf lag das CSS in + content.css -- und bereich.html laedt die gar nicht. Die Punkte + standen nackt und ohne Flaeche da, und die Pruefung war trotzdem + gruen, weil sie nur ELEMENTE gezaehlt hat. Gemessen wird deshalb + der tatsaechliche Stil: eine Karte ohne Kante und ohne Polsterung + ist keine Karte. */ + gestaltet: (() => { + const k = document.querySelector(".fest-punkt"); + if (!k) return null; + const st = getComputedStyle(k); + return parseFloat(st.borderTopWidth) >= 1 && parseFloat(st.paddingLeft) >= 8; + })(), + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, + })); + const gut = r.da && r.gruppen === 3 && r.punkte >= 6 && r.bilanz === 3 && r.gestaltet === true + && r.marken === r.punkte && r.ueber <= 0 + && (darf ? r.stufenKnoepfe === r.punkte * 2 : r.stufenKnoepfe === 0); + if (!gut) fehler++; + console.log(` ${gut ? "ok " : "FEHL"} ${name} · ${bereich}: ` + + `${r.gruppen} Gruppen, ${r.punkte} Punkte, ${r.bilanz} Bilanzzahlen, ` + + `${r.stufenKnoepfe} Bewertungsknoepfe, ${r.marken} Marken, ${r.ueber}px über` + + (r.gestaltet ? "" : " STIL FEHLT")); + } + ok(konsole.length === 0, + ` ${name}: keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); + + if (rolle === "creator") { + /* Der Creator muss die Bewertung SEHEN -- das ist der ganze Punkt. */ + await seite.goto(BASIS + "/workspace/bereich.html?b=live", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1000); + const s = await seite.evaluate(() => { + const p = document.querySelector('.fest-punkt[data-stufe="verbessern"]'); + return p ? { + titel: p.querySelector(".fest-punkt__titel")?.textContent, + marke: p.querySelector(".fest-punkt__marke-stufe")?.textContent, + grund: p.querySelector(".fest-punkt__grund p")?.textContent, + } : null; + }); + ok(!!s, "der Creator sieht den Punkt als 'zu verbessern' markiert"); + ok(/Verbessern/i.test(s?.marke || ""), ` mit lesbarer Marke ("${s?.marke}")`); + ok(/Mikro/.test(s?.grund || ""), ` und dem Grund ("${s?.grund}")`); + await seite.screenshot({ path: "pruef-checkliste.png", fullPage: false }); + } + await kontext.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-vorlagen.mjs b/server/pruef-vorlagen.mjs index 5eefdacc..e1248b72 100644 --- a/server/pruef-vorlagen.mjs +++ b/server/pruef-vorlagen.mjs @@ -208,75 +208,20 @@ console.log("\n=== Hilfe anfordern"); ok(leer.status === 400, `ohne Thema wird abgelehnt (${leer.status})`); } -/* ---------- Im Browser ---------------------------------------------------- */ -console.log("\n=== Die Seite"); -const { chromium } = await import( - "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); -const browser = await chromium.launch(); -{ - const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); - const konsole = []; - const seite = await kontext.newPage(); - seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); - seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); +/* ---------- Die Oberflaeche ---------------------------------------------- - await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); - await seite.click('.rolle[data-rolle="creator"]'); - await seite.fill("#code", "CODE-ANNA-0001"); - await seite.click("button[type=submit]"); - await seite.waitForURL("**/start.html", { timeout: 15000 }); + Der Browser-Teil stand hier und prueft den frueheren + "Vorschlaege"-Block. Den gibt es seit dem Umbau auf die feste + Checkliste nicht mehr: Die Punkte stehen jetzt fuer jeden gleich da, + statt einzeln geholt zu werden. - for (const [seiteName, art] of [["content.html", "Ideen"], ["bereich.html?b=live", "Punkte"]]) { - await seite.goto(BASIS + "/workspace/" + seiteName, { waitUntil: "networkidle" }); - await seite.waitForTimeout(900); - const r = await seite.evaluate(() => ({ - da: !document.getElementById("vorlagen")?.hidden, - knopf: document.querySelector(".vorlagen__kopf .knopf")?.textContent?.trim(), - offen: document.querySelector(".vorlagen__raster")?.hidden === false, - anzahl: document.querySelectorAll(".vorlage").length, - })); - ok(r.da, `${seiteName}: der Vorlagenblock steht da`); - ok(/Vorschläge zeigen/.test(r.knopf || ""), ` mit Aufklappknopf ("${r.knopf}")`); - ok(r.offen === false, " und ist zunächst zu"); - ok(r.anzahl > 0, ` ${r.anzahl} Vorschläge dahinter`); + Geprueft wird die Oberflaeche deshalb in pruef-checkliste.mjs. Die + Schnittstelle oben bleibt hier -- "Uebernehmen" gibt es weiterhin, + fuer eigene Ergaenzungen ueber die feste Liste hinaus. - /* Aufklappen muss wirklich etwas zeigen. */ - await seite.click(".vorlagen__kopf .knopf"); - await seite.waitForTimeout(350); - const auf = await seite.evaluate(() => ({ - offen: document.querySelector(".vorlagen__raster")?.hidden === false, - sichtbar: [...document.querySelectorAll(".vorlage")].filter((v) => v.offsetParent !== null).length, - hooks: document.querySelectorAll(".vorlage__hook").length, - })); - ok(auf.offen && auf.sichtbar > 0, ` aufgeklappt sind ${auf.sichtbar} zu sehen`); - if (art === "Ideen") { - ok(auf.hooks === auf.sichtbar, ` und jede zeigt ihren Aufhänger (${auf.hooks})`); - } - } - - /* Übernehmen über die Oberfläche -- nicht nur über die Schnittstelle. */ - await seite.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" }); - await seite.waitForTimeout(900); - await seite.click(".vorlagen__kopf .knopf"); - await seite.waitForTimeout(300); - /* Die Karten heissen dort .video -- ein erster Anlauf suchte nach - ".karte" und zaehlte deshalb immer null. */ - const vorher = await seite.evaluate(() => document.querySelectorAll(".video").length); - await seite.click(".vorlage .schritt"); - await seite.waitForTimeout(1200); - const nachher = await seite.evaluate(() => ({ - karten: document.querySelectorAll(".video").length, - genommen: document.querySelectorAll('.vorlage[data-genommen="ja"]').length, - })); - ok(nachher.karten > vorher, `Übernehmen legt wirklich an (${vorher} → ${nachher.karten})`); - ok(nachher.genommen === 1, `und die Vorlage ist als übernommen markiert (${nachher.genommen})`); - - ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); - await seite.screenshot({ path: "pruef-vorlagen.png", fullPage: false }); - await kontext.close(); -} - -await browser.close(); + Eine Pruefung stehen zu lassen, die etwas Abgeschafftes verlangt, + waere schlimmer als keine: Sie ist dauerhaft rot, man gewoehnt sich + daran, und beim naechsten echten Fehler sieht niemand hin. */ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); process.exit(fehler ? 1 : 0); diff --git a/server/workspace-checkliste.js b/server/workspace-checkliste.js new file mode 100644 index 00000000..84595f58 --- /dev/null +++ b/server/workspace-checkliste.js @@ -0,0 +1,397 @@ +/* ===================================================================== + workspace-checkliste.js — feste Punkte, die immer dastehen. + + Der Wunsch vom 01.09.2026: "diese neuen Sachen in der LIVE-Analyse + soll der Creator FEST sehen, auch schön kategorisiert, und der + Ansprechpartner soll dann anklicken können, wenn er findet, dass der + Creator was verbessern sollte -- so dass der Streamer es sieht und + Bescheid weiß." + + WAS SICH DAMIT ÄNDERT. + Zuerst waren die Punkte Vorschläge zum Übernehmen: Man holte sie sich + und bekam einen eigenen Eintrag. Das war falsch gedacht. Eine + Checkliste, die man erst anfordern muss, ist keine Checkliste -- + und schlimmer: Zwei Creator hätten unterschiedliche Listen, je + nachdem, wer sich was geholt hat. Genau das macht einen Vergleich + unmöglich, und um Vergleich geht es bei einer Betreuung. + + Jetzt stehen dieselben Punkte für JEDEN fest da, gruppiert nach + Abschnitt. Was sich je Creator unterscheidet, ist nur der STAND -- + und den setzt der Betreuer. + + WARUM STABILE SCHLÜSSEL statt Positionen. + Ein Stand hängt am Schlüssel des Punktes ("ton-geprueft"), nicht an + seiner Nummer in der Liste. Würde er an der Position hängen, wäre beim + Einfügen eines Punktes in der Mitte plötzlich jede Bewertung dahinter + am falschen Punkt -- und niemand würde es merken, weil beides + plausibel aussieht. + + DIE EIGENEN EINTRÄGE BLEIBEN. + Die Checkliste ersetzt nicht, was jemand selbst notiert. Sie steht + darüber: erst die feste Liste ("das gehört immer geprüft"), darunter + die eigenen Einträge ("das ist bei dir konkret passiert"). + ===================================================================== */ + +import express from "express"; +import { + db, sitzungLesen, istLeitung, darfCreator, betreuteIds, + protokolliere, echteIp, +} from "./workspace.js"; +import { + CONTENT_IDEEN, LIVE_PUNKTE, COMMUNITY_PUNKTE, TECHNIK_PUNKTE, +} from "./workspace-vorlagen.js"; + +export const checklisteRouter = express.Router(); + +const jetzt = () => new Date().toISOString(); +const STUFEN = new Set(["offen", "gut", "verbessern"]); +const GRUND_MAX = 2000; + +/* --------------------------------------------------------------------- + Die Listen, gruppiert und beschriftet. + + Die Gruppennamen stehen HIER und nicht in der Oberfläche: Sonst hiesse + derselbe Abschnitt auf zwei Seiten unterschiedlich, und niemand + wüsste, ob es dasselbe ist. + --------------------------------------------------------------------- */ + +export const LISTEN = { + live: { + name: "LIVE-Analyse", + gruppen: [ + { schluessel: "vorbereitung", name: "Vor der Sendung", + text: "Ein LIVE steht und fällt mit dem, was vorher passiert. " + + "Diese Liste ist deshalb die längste.", punkte: LIVE_PUNKTE.vorbereitung }, + { schluessel: "mitschrift", name: "Während der Sendung", + text: "Was jetzt auffällt, ist morgen weg. In Sekunden anklicken, " + + "nicht später aufschreiben.", punkte: LIVE_PUNKTE.mitschrift }, + { schluessel: "auswertung", name: "Nach der Sendung", + text: "Eine Sache für nächstes Mal. Wer sich fünf vornimmt, macht keine.", + punkte: LIVE_PUNKTE.auswertung }, + ], + }, + community: { + name: "Community", + gruppen: [ + { schluessel: "moderation", name: "Moderation vorbereiten", + text: "Vor dem Ernstfall abhaken. Danach ist es zu spät.", + punkte: COMMUNITY_PUNKTE.moderation }, + { schluessel: "aktion", name: "Community aufbauen", + text: "Wiedererkannt zu werden ist der halbe Grund wiederzukommen.", + punkte: COMMUNITY_PUNKTE.aktion }, + { schluessel: "konflikt", name: "Wenn es kippt", + text: "Im Chat gewinnt niemand. Diese Punkte sind für den Moment danach.", + punkte: COMMUNITY_PUNKTE.konflikt }, + ], + }, + technik: { + name: "Technik", + gruppen: [ + { schluessel: "setup", name: "Einrichtung", + text: "Ton zuerst – schlechter Ton ist der Grund Nummer eins, warum " + + "Leute wieder gehen.", punkte: TECHNIK_PUNKTE.setup }, + { schluessel: "problem", name: "Wenn etwas ausfällt", + text: "Notieren, solange man es noch weiß. Am nächsten Tag fehlt die Hälfte.", + punkte: TECHNIK_PUNKTE.problem }, + { schluessel: "loesung", name: "Was geholfen hat", + text: "Genau die eine Änderung – nicht alles, was probiert wurde.", + punkte: TECHNIK_PUNKTE.loesung }, + ], + }, + content: { + name: "Content-Ideen", + /* Content ist anders als die drei anderen: Es sind keine Punkte zum + Abhaken, sondern Ideen zum Umsetzen. Gruppiert wird deshalb nach + der SÄULE (70/20/10) und nicht nach einem Ablauf. Die Ampel + bedeutet hier: "die solltest du machen" bzw. "das hast du gut + umgesetzt". */ + gruppen: [ + { schluessel: "wert", name: "Inhalte mit Wert", + text: "Rund 70 Prozent. Was jemand mitnimmt, auch wenn er dich nicht kennt.", + punkte: CONTENT_IDEEN.filter((v) => v.saeule === "Wert") }, + { schluessel: "community", name: "Für die Community", + text: "Rund 20 Prozent. Was nur die verstehen, die dabei sind.", + punkte: CONTENT_IDEEN.filter((v) => v.saeule === "Community") }, + { schluessel: "eigenwerbung", name: "In eigener Sache", + text: "Rund 10 Prozent. Mehr wirkt wie Werbung, weniger findet dich niemand.", + punkte: CONTENT_IDEEN.filter((v) => v.saeule === "Eigenwerbung") }, + ], + }, +}; + +/* Alle gültigen Schlüssel je Bereich -- damit niemand einen Stand für + einen Punkt setzen kann, den es gar nicht gibt. */ +const GUELTIG = {}; +for (const [bereich, liste] of Object.entries(LISTEN)) { + GUELTIG[bereich] = new Set(liste.gruppen.flatMap((g) => g.punkte.map((p) => p.schluessel))); +} + +let bereit = false; +function tabellen() { + if (bereit) return; + db().exec(` + /* Der Stand eines Punktes bei einem Creator. + + Der Schluessel ist Text, keine Nummer: Er haengt am PUNKT, nicht + an seiner Position in der Liste. */ + CREATE TABLE IF NOT EXISTS punkt_stand ( + bereich TEXT NOT NULL, + schluessel TEXT NOT NULL, + creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + stufe TEXT NOT NULL CHECK (stufe IN ('offen','gut','verbessern')), + begruendung TEXT, + von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + am TEXT NOT NULL, + PRIMARY KEY (bereich, schluessel, creator_id) + ); + + /* Rueckmeldungen zu einem Punkt -- der Kanal, ueber den ein Creator + antworten kann. */ + CREATE TABLE IF NOT EXISTS punkt_nachricht ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bereich TEXT NOT NULL, + schluessel TEXT NOT NULL, + creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + text TEXT NOT NULL, + von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + am TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_punkt_nachricht + ON punkt_nachricht (bereich, schluessel, creator_id); + `); + bereit = true; +} + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + try { tabellen(); } catch (f) { + console.error("[workspace] Checklisten-Tabellen:", f?.message); + return res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + next(); +} +checklisteRouter.use("/workspace/api/checkliste", angemeldet); + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +/** Um welchen Creator geht es -- und darf die Person das? */ +function creatorAus(req) { + if (req.person.rolle === "creator") return req.person.id; + const id = Number(req.body?.creator_id || req.query?.creator_id) || 0; + if (!id) return 0; + return darfCreator(req.person, id) ? id : -1; /* -1 = verboten */ +} + +/* ---------- Die Liste ------------------------------------------------------ */ + +checklisteRouter.get("/workspace/api/checkliste/:bereich", (req, res) => { + try { + const bereich = String(req.params.bereich); + const liste = LISTEN[bereich]; + if (!liste) return res.status(404).json({ fehler: "nicht_gefunden" }); + + let creatorId = creatorAus(req); + if (creatorId === -1) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* Wen darf die Person auswaehlen? */ + let creatoren = []; + if (req.person.rolle !== "creator") { + const ids = istLeitung(req.person) + ? db().prepare("SELECT id FROM personen WHERE rolle = 'creator' AND aktiv = 1") + .all().map((z) => z.id) + : betreuteIds(req.person); + creatoren = ids.length + ? db().prepare(`SELECT id, name FROM personen + WHERE id IN (${ids.map(() => "?").join(",")}) ORDER BY name`).all(...ids) + : []; + + /* Ohne ausdrueckliche Wahl den ERSTEN nehmen. Sonst sieht ein + Betreuer beim Oeffnen eine Liste ohne Bewertungsknoepfe und + ohne Bilanz -- und muss erst raten, dass er oben jemanden + auswaehlen soll. Genau das hat die Browserpruefung gemeldet + ("0 Bewertungsknoepfe"). */ + if (!creatorId && creatoren.length) creatorId = creatoren[0].id; + } + + const stand = {}; + const nachrichten = {}; + if (creatorId > 0) { + for (const z of db().prepare(` + SELECT s.*, p.name AS von_name FROM punkt_stand s + LEFT JOIN personen p ON p.id = s.von + WHERE s.bereich = ? AND s.creator_id = ?`).all(bereich, creatorId)) { + stand[z.schluessel] = z; + } + for (const z of db().prepare(` + SELECT schluessel, COUNT(*) AS n FROM punkt_nachricht + WHERE bereich = ? AND creator_id = ? GROUP BY schluessel`).all(bereich, creatorId)) { + nachrichten[z.schluessel] = z.n; + } + } + + res.json({ + bereich, name: liste.name, + gruppen: liste.gruppen, + stand, nachrichten, + creatoren, creator_id: creatorId > 0 ? creatorId : null, + /* Die Oberflaeche soll nicht raten muessen, wer was darf -- sonst + zeigt sie einem Creator einen Knopf, der beim Klicken 403 sagt. + Das ist schlechter als kein Knopf. */ + darf_bewerten: req.person.rolle !== "creator", + ich: req.person.id, + }); + } catch (fehler) { + console.error("[workspace] Checkliste lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Bewerten (NUR die Betreuung) ----------------------------------- */ + +checklisteRouter.put("/workspace/api/checkliste/:bereich/:schluessel", gleicheHerkunft, + express.json({ limit: "8kb" }), (req, res) => { + try { + /* Ein Creator bewertet sich nicht selbst. Waere das moeglich, + stuende alles auf gruen und die Ampel waere wertlos. */ + if (req.person.rolle === "creator") { + return res.status(403).json({ + fehler: "Die Bewertung setzt deine Betreuung. Du kannst hier antworten.", + }); + } + const bereich = String(req.params.bereich); + const schluessel = String(req.params.schluessel); + if (!GUELTIG[bereich]?.has(schluessel)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const creatorId = creatorAus(req); + if (creatorId <= 0) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const stufe = String(req.body.stufe || ""); + if (!STUFEN.has(stufe)) return res.status(400).json({ fehler: "Unbekannte Stufe." }); + + const grund = String(req.body.begruendung ?? "").trim().slice(0, GRUND_MAX); + /* "verbessern" ohne Grund ist fuer den Creator nicht umsetzbar -- + nur entmutigend. */ + if (stufe === "verbessern" && grund.length < 5) { + return res.status(400).json({ + fehler: "Bitte kurz schreiben, was verbessert werden soll – sonst kann er nichts damit anfangen.", + }); + } + + /* "offen" heisst: zurueck auf Anfang. Dann wird der Stand + geloescht statt auf "offen" gesetzt -- ein Datensatz, der nichts + aussagt, ist Ballast. */ + if (stufe === "offen") { + db().prepare("DELETE FROM punkt_stand WHERE bereich = ? AND schluessel = ? AND creator_id = ?") + .run(bereich, schluessel, creatorId); + } else { + db().prepare(`INSERT INTO punkt_stand + (bereich, schluessel, creator_id, stufe, begruendung, von, am) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(bereich, schluessel, creator_id) DO UPDATE SET + stufe = excluded.stufe, begruendung = excluded.begruendung, + von = excluded.von, am = excluded.am`) + .run(bereich, schluessel, creatorId, stufe, grund || null, req.person.id, jetzt()); + } + + protokolliere("punkt_bewertet", req.person, echteIp(req), + `${bereich}/${schluessel}: ${stufe}`); + + const neu = db().prepare(` + SELECT s.*, p.name AS von_name FROM punkt_stand s + LEFT JOIN personen p ON p.id = s.von + WHERE s.bereich = ? AND s.schluessel = ? AND s.creator_id = ?`) + .get(bereich, schluessel, creatorId) || null; + res.json({ stand: neu }); + } catch (fehler) { + console.error("[workspace] Punkt bewerten:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Nachrichten zu einem Punkt ------------------------------------- */ + +checklisteRouter.get("/workspace/api/checkliste/:bereich/:schluessel/nachrichten", (req, res) => { + try { + const bereich = String(req.params.bereich); + const schluessel = String(req.params.schluessel); + if (!GUELTIG[bereich]?.has(schluessel)) return res.status(404).json({ fehler: "nicht_gefunden" }); + const creatorId = creatorAus(req); + if (creatorId <= 0) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const nachrichten = db().prepare(` + SELECT n.*, p.name AS von_name, p.rolle AS von_rolle + FROM punkt_nachricht n LEFT JOIN personen p ON p.id = n.von + WHERE n.bereich = ? AND n.schluessel = ? AND n.creator_id = ? + ORDER BY n.id`).all(bereich, schluessel, creatorId); + res.json({ nachrichten, ich: req.person.id }); + } catch (fehler) { + console.error("[workspace] Punktnachrichten:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +checklisteRouter.post("/workspace/api/checkliste/:bereich/:schluessel/nachrichten", gleicheHerkunft, + express.json({ limit: "8kb" }), (req, res) => { + try { + const bereich = String(req.params.bereich); + const schluessel = String(req.params.schluessel); + if (!GUELTIG[bereich]?.has(schluessel)) return res.status(404).json({ fehler: "nicht_gefunden" }); + const creatorId = creatorAus(req); + if (creatorId <= 0) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const text = String(req.body.text ?? "").trim(); + if (text.length < 2) return res.status(400).json({ fehler: "Die Nachricht ist leer." }); + if (text.length > GRUND_MAX) return res.status(400).json({ fehler: "Die Nachricht ist zu lang." }); + + db().prepare(`INSERT INTO punkt_nachricht + (bereich, schluessel, creator_id, text, von, am) VALUES (?,?,?,?,?,?)`) + .run(bereich, schluessel, creatorId, text, req.person.id, jetzt()); + protokolliere("punkt_nachricht", req.person, echteIp(req), `${bereich}/${schluessel}`); + + const alle = db().prepare(` + SELECT n.*, p.name AS von_name, p.rolle AS von_rolle + FROM punkt_nachricht n LEFT JOIN personen p ON p.id = n.von + WHERE n.bereich = ? AND n.schluessel = ? AND n.creator_id = ? + ORDER BY n.id`).all(bereich, schluessel, creatorId); + res.json({ nachrichten: alle }); + } catch (fehler) { + console.error("[workspace] Punktnachricht:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* Nur die EIGENE zurücknehmen -- fremde zu löschen hiesse, ein Gespräch + nachträglich umzuschreiben. Auch für die Leitung nicht. */ +checklisteRouter.delete("/workspace/api/checkliste/nachricht/:id(\\d+)", gleicheHerkunft, + (req, res) => { + try { + const n = db().prepare("SELECT * FROM punkt_nachricht WHERE id = ?").get(Number(req.params.id)); + if (!n) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Erst pruefen, ob die Person den Creator ueberhaupt sehen darf -- + sonst verraet die Antwort, dass es die Nachricht gibt. */ + const erlaubt = req.person.rolle === "creator" + ? n.creator_id === req.person.id + : darfCreator(req.person, n.creator_id); + if (!erlaubt) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (n.von !== req.person.id) { + return res.status(403).json({ + fehler: "Fremde Nachrichten bleiben stehen. Schreib eine neue, wenn etwas richtigzustellen ist.", + }); + } + db().prepare("DELETE FROM punkt_nachricht WHERE id = ?").run(n.id); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Punktnachricht löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); diff --git a/server/workspace-vorlagen.js b/server/workspace-vorlagen.js index 7ffd2c47..82845ede 100644 --- a/server/workspace-vorlagen.js +++ b/server/workspace-vorlagen.js @@ -59,65 +59,65 @@ const BEREICH_PUNKTE = {}; export const CONTENT_IDEEN = [ /* ---- Wert (70 %) ---------------------------------------------------- */ - { titel: "Das hätte ich am Anfang gern gewusst", + { schluessel: "das-haette-ich-am-anfang-gern-gewusst", titel: "Das hätte ich am Anfang gern gewusst", hook: "„Ich hab drei Monate gebraucht, um das zu kapieren – du brauchst 30 Sekunden.\"", format: "Talking Head", saeule: "Wert", text: "Ein Fehler, den du selbst gemacht hast, und was du heute anders machst. " + "Konkret bleiben: ein Fehler, eine Lösung. Wer drei Dinge sagt, sagt nichts." }, - { titel: "Mein Setup für unter 100 €", + { schluessel: "mein-setup-fuer-unter-100", titel: "Mein Setup für unter 100 €", hook: "„Alle sagen, du brauchst teure Technik. Hier ist meins für 87 Euro.\"", format: "Zeigen & Erklären", saeule: "Wert", text: "Was du wirklich benutzt, mit Preisen. Ehrlich sein, wo es hakt – " + "das ist der Teil, den niemand sonst zeigt." }, - { titel: "Drei Einstellungen, die sofort was bringen", + { schluessel: "drei-einstellungen-die-sofort-was-bringe", titel: "Drei Einstellungen, die sofort was bringen", hook: "„Wenn dein Stream unscharf aussieht, liegt es an einer davon.\"", format: "Bildschirmaufnahme", saeule: "Wert", text: "Nicht mehr als drei. Jede in unter 15 Sekunden erklärt, mit Vorher/Nachher." }, - { titel: "Was ich in einer Woche gelernt habe", + { schluessel: "was-ich-in-einer-woche-gelernt-habe", titel: "Was ich in einer Woche gelernt habe", hook: "„Sieben Tage, eine Erkenntnis – die hier hat alles verändert.\"", format: "Talking Head", saeule: "Wert", text: "Rückblick auf eine echte Woche. Funktioniert nur mit einer konkreten Zahl " + "oder einem konkreten Moment." }, - { titel: "Antwort auf die Frage, die immer kommt", + { schluessel: "antwort-auf-die-frage-die-immer-kommt", titel: "Antwort auf die Frage, die immer kommt", hook: "„Das werde ich jeden Tag gefragt – hier einmal in Ruhe.\"", format: "Talking Head", saeule: "Wert", text: "Aus dem Chat sammeln, welche Frage sich wiederholt. Die Antwort einmal " + "gut aufnehmen und immer wieder verwenden." }, - { titel: "So bereite ich einen Stream vor", + { schluessel: "so-bereite-ich-einen-stream-vor", titel: "So bereite ich einen Stream vor", hook: "„Zwei Stunden vor LIVE mache ich immer dasselbe.\"", format: "Vlog", saeule: "Wert", text: "Der Ablauf hinter der Sendung. Zeigt Verlässlichkeit und gibt Anfängern " + "eine Vorlage." }, - { titel: "Fehler, die ich bei anderen sehe", + { schluessel: "fehler-die-ich-bei-anderen-sehe", titel: "Fehler, die ich bei anderen sehe", hook: "„Bitte mach das nicht – ich sehe es jeden Tag.\"", format: "Talking Head", saeule: "Wert", text: "Niemanden bloßstellen. Das Verhalten beschreiben, nicht die Person." }, /* ---- Community (20 %) ------------------------------------------------ */ - { titel: "Der beste Moment aus dem letzten LIVE", + { schluessel: "der-beste-moment-aus-dem-letzten-live", titel: "Der beste Moment aus dem letzten LIVE", hook: "„Das ist gestern passiert, und ich rede immer noch davon.\"", format: "Zusammenschnitt", saeule: "Community", text: "Ein Ausschnitt, keine Zusammenfassung. Wer alles zeigt, zeigt nichts." }, - { titel: "Ich beantworte eure Kommentare", + { schluessel: "ich-beantworte-eure-kommentare", titel: "Ich beantworte eure Kommentare", hook: "„Ihr habt gefragt – jetzt rede ich.\"", format: "Talking Head", saeule: "Community", text: "Drei bis fünf echte Kommentare, sichtbar eingeblendet. Auch die kritischen." }, - { titel: "Danke an die, die immer da sind", + { schluessel: "danke-an-die-die-immer-da-sind", titel: "Danke an die, die immer da sind", hook: "„Ihr seid seit dem ersten Stream dabei. Das hier ist für euch.\"", format: "Talking Head", saeule: "Community", text: "Namen nennen, wenn die Leute einverstanden sind. Sonst allgemein bleiben – " + "aber ehrlich." }, - { titel: "Eine Entscheidung, bei der ihr mitredet", + { schluessel: "eine-entscheidung-bei-der-ihr-mitredet", titel: "Eine Entscheidung, bei der ihr mitredet", hook: "„Ich kann mich nicht entscheiden. Ihr habt 24 Stunden.\"", format: "Umfrage", saeule: "Community", text: "Echte Wahl, keine Scheinfrage. Das Ergebnis danach auch wirklich umsetzen." }, /* ---- Eigenwerbung (10 %) --------------------------------------------- */ - { titel: "Wann und wo ich streame", + { schluessel: "wann-und-wo-ich-streame", titel: "Wann und wo ich streame", hook: "„Immer dienstags und donnerstags – und ich fange pünktlich an.\"", format: "Kurzclip", saeule: "Eigenwerbung", text: "Fester Sendeplan als eigener Clip. Wiederholbar, sobald sich etwas ändert." }, - { titel: "Was diese Woche ansteht", + { schluessel: "was-diese-woche-ansteht", titel: "Was diese Woche ansteht", hook: "„Drei Sachen, und eine davon habe ich noch nie gemacht.\"", format: "Kurzclip", saeule: "Eigenwerbung", text: "Vorschau auf die Woche. Kurz halten, sonst wird es eine Ankündigung " @@ -140,31 +140,31 @@ export const CONTENT_IDEEN = [ export const LIVE_PUNKTE = { vorbereitung: [ - { titel: "Ton geprüft", text: "Eine Minute aufnehmen und anhören. Ton ist der Grund Nummer eins, warum Leute wieder gehen.", hilfe: true }, - { titel: "Licht sitzt", text: "Gesicht heller als der Hintergrund. Kein Fenster im Rücken.", hilfe: true }, - { titel: "Verbindung getestet", text: "Upload-Geschwindigkeit geprüft, Kabel statt WLAN wenn möglich.", hilfe: true }, - { titel: "Titel und Thema stehen", text: "Wer den Titel liest, weiß, was ihn erwartet – und ob es für ihn ist.", hilfe: false }, - { titel: "Ablauf grob im Kopf", text: "Drei Blöcke reichen. Ein Stream ohne roten Faden fühlt sich für Zuschauer wie Warten an.", hilfe: false }, - { titel: "Angekündigt", text: "Mindestens einmal vorher gesagt, dass es losgeht – Story, Post oder Nachricht.", hilfe: false }, - { titel: "Moderation geklärt", text: "Weiß jemand Bescheid, der eingreifen kann, wenn es im Chat kippt?", hilfe: true }, - { titel: "Technik-Ersatz bereit", text: "Was passiert, wenn der Stream abbricht? Handy als Notlösung bereitlegen.", hilfe: true }, + { schluessel: "ton-geprueft", titel: "Ton geprüft", text: "Eine Minute aufnehmen und anhören. Ton ist der Grund Nummer eins, warum Leute wieder gehen.", hilfe: true }, + { schluessel: "licht-sitzt", titel: "Licht sitzt", text: "Gesicht heller als der Hintergrund. Kein Fenster im Rücken.", hilfe: true }, + { schluessel: "verbindung-getestet", titel: "Verbindung getestet", text: "Upload-Geschwindigkeit geprüft, Kabel statt WLAN wenn möglich.", hilfe: true }, + { schluessel: "titel-und-thema-stehen", titel: "Titel und Thema stehen", text: "Wer den Titel liest, weiß, was ihn erwartet – und ob es für ihn ist.", hilfe: false }, + { schluessel: "ablauf-grob-im-kopf", titel: "Ablauf grob im Kopf", text: "Drei Blöcke reichen. Ein Stream ohne roten Faden fühlt sich für Zuschauer wie Warten an.", hilfe: false }, + { schluessel: "angekuendigt", titel: "Angekündigt", text: "Mindestens einmal vorher gesagt, dass es losgeht – Story, Post oder Nachricht.", hilfe: false }, + { schluessel: "moderation-geklaert", titel: "Moderation geklärt", text: "Weiß jemand Bescheid, der eingreifen kann, wenn es im Chat kippt?", hilfe: true }, + { schluessel: "technik-ersatz-bereit", titel: "Technik-Ersatz bereit", text: "Was passiert, wenn der Stream abbricht? Handy als Notlösung bereitlegen.", hilfe: true }, ], mitschrift: [ - { titel: "Guter Moment – aufheben", text: "Etwas ist gut gelaufen. Kurz notieren, woran es lag – daraus wird später ein Clip.", hilfe: false }, - { titel: "Hier sind Leute gegangen", text: "Zeitpunkt merken. Später nachsehen, was in dem Moment passiert ist.", hilfe: false }, - { titel: "Frage aus dem Chat, die wiederkommt", text: "Wird oft gefragt? Dann gehört sie in ein eigenes Video.", hilfe: false }, - { titel: "Technik hat gehakt", text: "Ton weg, Bild eingefroren, Verzögerung – aufschreiben, solange man es noch weiß.", hilfe: true }, - { titel: "Unangenehme Situation im Chat", text: "Was war es, wie ist es ausgegangen? Auch wenn es gut lief.", hilfe: true }, - { titel: "Neue Zuschauer aufgetaucht", text: "Woher kamen sie? Wurde jemand begrüßt oder ging es unter?", hilfe: false }, - { titel: "Ich wusste nicht weiter", text: "Stelle merken, an der es gestockt hat. Dafür gibt es Hilfe.", hilfe: true }, + { schluessel: "guter-moment-aufheben", titel: "Guter Moment – aufheben", text: "Etwas ist gut gelaufen. Kurz notieren, woran es lag – daraus wird später ein Clip.", hilfe: false }, + { schluessel: "hier-sind-leute-gegangen", titel: "Hier sind Leute gegangen", text: "Zeitpunkt merken. Später nachsehen, was in dem Moment passiert ist.", hilfe: false }, + { schluessel: "frage-aus-dem-chat-die-wiederkommt", titel: "Frage aus dem Chat, die wiederkommt", text: "Wird oft gefragt? Dann gehört sie in ein eigenes Video.", hilfe: false }, + { schluessel: "technik-hat-gehakt", titel: "Technik hat gehakt", text: "Ton weg, Bild eingefroren, Verzögerung – aufschreiben, solange man es noch weiß.", hilfe: true }, + { schluessel: "unangenehme-situation-im-chat", titel: "Unangenehme Situation im Chat", text: "Was war es, wie ist es ausgegangen? Auch wenn es gut lief.", hilfe: true }, + { schluessel: "neue-zuschauer-aufgetaucht", titel: "Neue Zuschauer aufgetaucht", text: "Woher kamen sie? Wurde jemand begrüßt oder ging es unter?", hilfe: false }, + { schluessel: "ich-wusste-nicht-weiter", titel: "Ich wusste nicht weiter", text: "Stelle merken, an der es gestockt hat. Dafür gibt es Hilfe.", hilfe: true }, ], auswertung: [ - { titel: "Was hat diesmal getragen?", text: "Ein Ding. Nicht drei. Das eine, das man wiederholen will.", hilfe: false }, - { titel: "Was war zäh?", text: "Ehrlich sein. Die zähen Stellen sind die, an denen man wächst.", hilfe: false }, - { titel: "Zahlen im Vergleich", text: "Zuschauer, Verweildauer, Kommentare – gegen das letzte Mal, nicht gegen andere.", hilfe: true }, - { titel: "Clips gesichert", text: "Die guten Momente rausgeschnitten und abgelegt, solange man sie noch findet.", hilfe: false }, - { titel: "Eine Sache für nächstes Mal", text: "Genau eine. Wer sich fünf Dinge vornimmt, macht keins davon.", hilfe: false }, - { titel: "Braucht Besprechung", text: "Etwas, das man nicht allein lösen kann – geht an die Betreuung.", hilfe: true }, + { schluessel: "was-hat-diesmal-getragen", titel: "Was hat diesmal getragen?", text: "Ein Ding. Nicht drei. Das eine, das man wiederholen will.", hilfe: false }, + { schluessel: "was-war-zaeh", titel: "Was war zäh?", text: "Ehrlich sein. Die zähen Stellen sind die, an denen man wächst.", hilfe: false }, + { schluessel: "zahlen-im-vergleich", titel: "Zahlen im Vergleich", text: "Zuschauer, Verweildauer, Kommentare – gegen das letzte Mal, nicht gegen andere.", hilfe: true }, + { schluessel: "clips-gesichert", titel: "Clips gesichert", text: "Die guten Momente rausgeschnitten und abgelegt, solange man sie noch findet.", hilfe: false }, + { schluessel: "eine-sache-fuer-naechstes-mal", titel: "Eine Sache für nächstes Mal", text: "Genau eine. Wer sich fünf Dinge vornimmt, macht keins davon.", hilfe: false }, + { schluessel: "braucht-besprechung", titel: "Braucht Besprechung", text: "Etwas, das man nicht allein lösen kann – geht an die Betreuung.", hilfe: true }, ], }; @@ -186,24 +186,24 @@ export const LIVE_PUNKTE = { export const COMMUNITY_PUNKTE = { moderation: [ - { titel: "Regeln stehen sichtbar", text: "Kurz, verstaendlich, an einer Stelle, die man findet. Drei Saetze reichen.", hilfe: false }, - { titel: "Jemand kann eingreifen", text: "Waehrend des Streams ist eine zweite Person mit Rechten da -- oder es ist klar, dass keine da ist.", hilfe: true }, - { titel: "Wortfilter eingerichtet", text: "Die offensichtlichen Sachen automatisch weg, damit man sich um den Rest kuemmern kann.", hilfe: true }, - { titel: "Was passiert bei einem Verstoss?", text: "Verwarnen, stummschalten, sperren -- vorher entscheiden, nicht im Moment.", hilfe: false }, - { titel: "Umgang mit Minderjaehrigen", text: "Wie erkennt man sie, und was gilt dann? Das ist keine Ermessensfrage.", hilfe: true }, + { schluessel: "regeln-stehen-sichtbar", titel: "Regeln stehen sichtbar", text: "Kurz, verstaendlich, an einer Stelle, die man findet. Drei Saetze reichen.", hilfe: false }, + { schluessel: "jemand-kann-eingreifen", titel: "Jemand kann eingreifen", text: "Waehrend des Streams ist eine zweite Person mit Rechten da -- oder es ist klar, dass keine da ist.", hilfe: true }, + { schluessel: "wortfilter-eingerichtet", titel: "Wortfilter eingerichtet", text: "Die offensichtlichen Sachen automatisch weg, damit man sich um den Rest kuemmern kann.", hilfe: true }, + { schluessel: "was-passiert-bei-einem-verstoss", titel: "Was passiert bei einem Verstoss?", text: "Verwarnen, stummschalten, sperren -- vorher entscheiden, nicht im Moment.", hilfe: false }, + { schluessel: "umgang-mit-minderjaehrigen", titel: "Umgang mit Minderjaehrigen", text: "Wie erkennt man sie, und was gilt dann? Das ist keine Ermessensfrage.", hilfe: true }, ], aktion: [ - { titel: "Neue begruessen", text: "Wer zum ersten Mal da ist, wird angesprochen. Kostet zwei Sekunden und entscheidet, ob jemand bleibt.", hilfe: false }, - { titel: "Stammleute erkennen", text: "Namen merken oder notieren. Wiedererkannt zu werden ist der halbe Grund wiederzukommen.", hilfe: false }, - { titel: "Etwas geben, das nur hier gibt", text: "Ein Insider, ein Ritual, ein Spitzname. Community entsteht aus Gemeinsamem.", hilfe: false }, - { titel: "Ausserhalb des Streams antworten", text: "Kommentare und Nachrichten -- die Community lebt auch zwischen den Sendungen.", hilfe: false }, + { schluessel: "neue-begruessen", titel: "Neue begruessen", text: "Wer zum ersten Mal da ist, wird angesprochen. Kostet zwei Sekunden und entscheidet, ob jemand bleibt.", hilfe: false }, + { schluessel: "stammleute-erkennen", titel: "Stammleute erkennen", text: "Namen merken oder notieren. Wiedererkannt zu werden ist der halbe Grund wiederzukommen.", hilfe: false }, + { schluessel: "etwas-geben-das-nur-hier-gibt", titel: "Etwas geben, das nur hier gibt", text: "Ein Insider, ein Ritual, ein Spitzname. Community entsteht aus Gemeinsamem.", hilfe: false }, + { schluessel: "ausserhalb-des-streams-antworten", titel: "Ausserhalb des Streams antworten", text: "Kommentare und Nachrichten -- die Community lebt auch zwischen den Sendungen.", hilfe: false }, ], konflikt: [ - { titel: "Ruhig bleiben und nicht oeffentlich streiten", text: "Im Chat gewinnt niemand. Kurz ansagen, dann privat klaeren.", hilfe: true }, - { titel: "Was ist genau passiert?", text: "Zeitpunkt, Beteiligte, Wortlaut. Ohne das ist jede Entscheidung angreifbar.", hilfe: false }, - { titel: "Beweise sichern", text: "Bildschirmfoto oder Ausschnitt, solange es noch da ist. Chats verschwinden.", hilfe: true }, - { titel: "Betreuung einbeziehen", text: "Bei allem, was groesser ist als ein blöder Kommentar. Dafuer ist sie da.", hilfe: true }, - { titel: "Danach: was hat gefehlt?", text: "Regel unklar, niemand zum Eingreifen da, zu spaet gemerkt -- daraus wird der naechste Punkt oben.", hilfe: false }, + { schluessel: "ruhig-bleiben-und-nicht-oeffentlich-stre", titel: "Ruhig bleiben und nicht oeffentlich streiten", text: "Im Chat gewinnt niemand. Kurz ansagen, dann privat klaeren.", hilfe: true }, + { schluessel: "was-ist-genau-passiert", titel: "Was ist genau passiert?", text: "Zeitpunkt, Beteiligte, Wortlaut. Ohne das ist jede Entscheidung angreifbar.", hilfe: false }, + { schluessel: "beweise-sichern", titel: "Beweise sichern", text: "Bildschirmfoto oder Ausschnitt, solange es noch da ist. Chats verschwinden.", hilfe: true }, + { schluessel: "betreuung-einbeziehen", titel: "Betreuung einbeziehen", text: "Bei allem, was groesser ist als ein blöder Kommentar. Dafuer ist sie da.", hilfe: true }, + { schluessel: "danach-was-hat-gefehlt", titel: "Danach: was hat gefehlt?", text: "Regel unklar, niemand zum Eingreifen da, zu spaet gemerkt -- daraus wird der naechste Punkt oben.", hilfe: false }, ], }; @@ -218,38 +218,38 @@ export const COMMUNITY_PUNKTE = { export const TECHNIK_PUNKTE = { setup: [ - { titel: "Mikrofon richtig eingestellt", text: "Nicht uebersteuert, nicht zu leise, kein Rauschen. Einmal aufnehmen und anhoeren.", hilfe: true }, - { titel: "Kamera scharf und gut belichtet", text: "Gesicht heller als der Hintergrund. Automatik oft abschalten -- sie pumpt.", hilfe: true }, - { titel: "Szenen im Streaming-Programm angelegt", text: "Start, Vollbild, Pause, Ende. Wer waehrend der Sendung baut, sendet Chaos.", hilfe: true }, - { titel: "Bitrate zur Leitung passend", text: "Lieber stabil und etwas weicher als scharf mit Aussetzern.", hilfe: true }, - { titel: "Alles einmal komplett getestet", text: "Eine Probesendung von fuenf Minuten, aufgezeichnet und angesehen.", hilfe: true }, + { schluessel: "mikrofon-richtig-eingestellt", titel: "Mikrofon richtig eingestellt", text: "Nicht uebersteuert, nicht zu leise, kein Rauschen. Einmal aufnehmen und anhoeren.", hilfe: true }, + { schluessel: "kamera-scharf-und-gut-belichtet", titel: "Kamera scharf und gut belichtet", text: "Gesicht heller als der Hintergrund. Automatik oft abschalten -- sie pumpt.", hilfe: true }, + { schluessel: "szenen-im-streaming-programm-angelegt", titel: "Szenen im Streaming-Programm angelegt", text: "Start, Vollbild, Pause, Ende. Wer waehrend der Sendung baut, sendet Chaos.", hilfe: true }, + { schluessel: "bitrate-zur-leitung-passend", titel: "Bitrate zur Leitung passend", text: "Lieber stabil und etwas weicher als scharf mit Aussetzern.", hilfe: true }, + { schluessel: "alles-einmal-komplett-getestet", titel: "Alles einmal komplett getestet", text: "Eine Probesendung von fuenf Minuten, aufgezeichnet und angesehen.", hilfe: true }, ], problem: [ - { titel: "Ton weg oder verzerrt", text: "Wann genau, welches Geraet, was lief gleichzeitig? Ohne das findet es niemand.", hilfe: true }, - { titel: "Bild ruckelt oder friert ein", text: "Auslastung, Leitung, Aufloesung -- eins nach dem anderen ausschliessen.", hilfe: true }, - { titel: "Stream bricht ab", text: "Zeitpunkt notieren und im Protokoll des Programms nachsehen.", hilfe: true }, - { titel: "Ton und Bild laufen auseinander", text: "Meist ein Puffer. Wann faellt es auf, sofort oder erst spaeter?", hilfe: true }, + { schluessel: "ton-weg-oder-verzerrt", titel: "Ton weg oder verzerrt", text: "Wann genau, welches Geraet, was lief gleichzeitig? Ohne das findet es niemand.", hilfe: true }, + { schluessel: "bild-ruckelt-oder-friert-ein", titel: "Bild ruckelt oder friert ein", text: "Auslastung, Leitung, Aufloesung -- eins nach dem anderen ausschliessen.", hilfe: true }, + { schluessel: "stream-bricht-ab", titel: "Stream bricht ab", text: "Zeitpunkt notieren und im Protokoll des Programms nachsehen.", hilfe: true }, + { schluessel: "ton-und-bild-laufen-auseinander", titel: "Ton und Bild laufen auseinander", text: "Meist ein Puffer. Wann faellt es auf, sofort oder erst spaeter?", hilfe: true }, ], loesung: [ - { titel: "Was hat es behoben?", text: "Genau die eine Aenderung aufschreiben, nicht alles was probiert wurde.", hilfe: false }, - { titel: "Kommt es wieder?", text: "Beim naechsten Mal nachsehen. Einmal weg ist nicht geloest.", hilfe: false }, - { titel: "Einstellung gesichert", text: "Damit sie nach einem Update nicht wieder weg ist.", hilfe: false }, + { schluessel: "was-hat-es-behoben", titel: "Was hat es behoben?", text: "Genau die eine Aenderung aufschreiben, nicht alles was probiert wurde.", hilfe: false }, + { schluessel: "kommt-es-wieder", titel: "Kommt es wieder?", text: "Beim naechsten Mal nachsehen. Einmal weg ist nicht geloest.", hilfe: false }, + { schluessel: "einstellung-gesichert", titel: "Einstellung gesichert", text: "Damit sie nach einem Update nicht wieder weg ist.", hilfe: false }, ], }; export const CALL_LEITFAEDEN = [ - { titel: "Erstgespräch", + { schluessel: "erstgespraech", titel: "Erstgespräch", text: "Wer bist du, was machst du, was willst du erreichen? · Was läuft schon gut? · " + "Was nervt dich gerade am meisten? · Wie viel Zeit hast du wirklich? · " + "Was erwartest du von uns – und was erwarten wir von dir?" }, - { titel: "Wochen-Call", + { schluessel: "wochen-call", titel: "Wochen-Call", text: "Was ist seit dem letzten Mal passiert? · Was ist liegen geblieben und warum? · " + "Zahlen kurz ansehen – eine Auffälligkeit reicht · Was steht diese Woche an? · " + "Brauchst du irgendwo Hilfe?" }, - { titel: "Wenn es gerade nicht läuft", + { schluessel: "wenn-es-gerade-nicht-laeuft", titel: "Wenn es gerade nicht läuft", text: "Zuerst zuhören, nicht lösen · Seit wann geht es so? · Was hat sich geändert? · " + "Was war das letzte Mal, als es gut war? · Eine kleine Sache vereinbaren, " + "nicht fünf große" }, - { titel: "Monats-Review", + { schluessel: "monats-review", titel: "Monats-Review", text: "Die drei Ziele vom letzten Mal durchgehen · Was ist erreicht, was nicht? · " + "Woran lag es – Zeit, Können oder Motivation? · Drei neue Ziele, verbindlich · " + "Termin für das nächste Review festhalten" }, diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 2c203689..ac57e23c 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -1818,3 +1818,206 @@ body.start #protokoll { text-transform: none; letter-spacing: normal; font-weight: 400; color: var(--text-still); } + +/* Die Checkliste steht auf VIER Seiten (LIVE, Community, Technik, + Content-Ideen). Ihr Stil gehoert deshalb hierher und nicht in eine + Seitendatei -- genau diesen Fehler habe ich beim ersten Anlauf + gemacht: Der Block lag in content.css, und bereich.html laedt die + gar nicht. Die Punkte standen dort nackt und ohne Flaeche da. + + Es ist derselbe Fehler wie im August bei .knopf-still, .schalter und + .kopf-zeile. Deshalb steht die Warnung weiter oben in dieser Datei -- + und deshalb hat sie nichts genutzt, solange keine Pruefung sie + nachhaelt. Jetzt gibt es eine (pruef-checkliste.mjs misst, ob die + Punkte wirklich eine Kante haben). */ +/* ===================================================================== + DIE FESTE CHECKLISTE + + Steht auf jeder der vier Seiten (LIVE, Community, Technik, + Content-Ideen) und sieht ueberall gleich aus -- es ist dieselbe Sache. + + Drei Zustaende, drei Farben. "Verbessern" ist bewusst warnend und + nicht rot: Es ist eine Aufgabe, kein Fehler. Rot waere eine + Beschuldigung. + ===================================================================== */ + +.liste-fest { margin: 20px 0 30px; } +.liste-fest__kopf { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 16px; flex-wrap: wrap; margin-bottom: 14px; +} +.liste-fest__unter { + margin: 4px 0 0; max-width: 70ch; + font-size: .87rem; line-height: 1.55; color: var(--text-leise); +} +.liste-fest__wahl { display: grid; gap: 4px; min-width: 0; } + +/* ---------- Die Bilanz oben --------------------------------------------- + Wie viele Punkte passen, wie viele sind zu verbessern, wie viele hat + noch niemand angesehen. Das ist die Frage, die beide Seiten zuerst + haben -- deshalb steht sie vor der Liste und nicht darunter. */ +.liste-fest__bilanz { + display: grid; gap: 9px; + grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); + margin-bottom: 20px; +} +.bilanz-zahl { + --b-ton: #6f89a8; + display: flex; align-items: baseline; gap: 9px; + padding: 12px 15px; + border: 1px solid color-mix(in srgb, var(--b-ton) 26%, var(--rand)); + border-radius: var(--radius-klein); + background: + linear-gradient(180deg, color-mix(in srgb, var(--b-ton) 9%, transparent), transparent 60%), + var(--flaeche); +} +.bilanz-zahl[data-stufe="gut"] { --b-ton: #4dab77; } +.bilanz-zahl[data-stufe="verbessern"] { --b-ton: #e0a33c; } +.bilanz-zahl[data-stufe="offen"] { --b-ton: #6f89a8; } +.bilanz-zahl__n { + font-size: 1.6rem; font-weight: 700; line-height: 1; + color: var(--b-ton); font-variant-numeric: tabular-nums; +} +.bilanz-zahl__text { font-size: .82rem; color: var(--text-leise); } + +/* ---------- Die Gruppen -------------------------------------------------- */ +.fest-gruppe { margin-bottom: 24px; } +.fest-gruppe__kopf { display: flex; align-items: baseline; gap: 10px; } +.fest-gruppe__name { + margin: 0; font-size: .96rem; font-weight: 650; color: var(--text); +} +.fest-gruppe__zahl { + padding: 1px 8px; + border: 1px solid var(--rand); border-radius: 999px; + font-size: .72rem; color: var(--text-still); + font-variant-numeric: tabular-nums; +} +.fest-gruppe__text { + margin: 4px 0 12px; max-width: 68ch; + font-size: .84rem; line-height: 1.5; color: var(--text-still); +} +.fest-punkte { display: grid; gap: 9px; } + +/* ---------- Ein Punkt ---------------------------------------------------- */ +.fest-punkt { + --p-ton: #6f89a8; + position: relative; overflow: hidden; + padding: 13px 15px 12px; + border: 1px solid color-mix(in srgb, var(--p-ton) 22%, var(--rand)); + border-radius: var(--radius-klein); + background: + linear-gradient(90deg, color-mix(in srgb, var(--p-ton) 7%, transparent), transparent 52%), + var(--flaeche); +} +.fest-punkt::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: linear-gradient(180deg, var(--p-ton), + color-mix(in srgb, var(--p-ton) 30%, transparent)); +} +.fest-punkt[data-stufe="gut"] { --p-ton: #4dab77; } +/* Warnend, nicht rot: Es ist eine Aufgabe, kein Fehler. Rot waere eine + Beschuldigung. */ +.fest-punkt[data-stufe="verbessern"] { --p-ton: #e0a33c; } +.fest-punkt[data-stufe="offen"] { --p-ton: #6f89a8; } + +.fest-punkt__kopf { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 13px; +} +.fest-punkt__text { min-width: 0; } +.fest-punkt__titel { + margin: 0 0 5px; font-size: .93rem; font-weight: 650; color: var(--text); +} +.fest-punkt__beschreibung { + margin: 0; font-size: .84rem; line-height: 1.5; color: var(--text-leise); +} +/* Der Aufhaenger bei Content-Ideen -- die ersten Sekunden entscheiden, + deshalb steht er hervorgehoben und nicht im Fliesstext. */ +.fest-punkt__hook { + margin: 0 0 7px; padding: 8px 11px; + border-left: 2px solid var(--ton, var(--akzent)); + border-radius: 0 6px 6px 0; + background: color-mix(in srgb, var(--ton, var(--akzent)) 9%, transparent); + font-size: .87rem; line-height: 1.45; color: var(--text); +} +.fest-punkt__hookschild { + display: block; margin-bottom: 2px; + font-size: .66rem; letter-spacing: .12em; text-transform: uppercase; + color: var(--ton, var(--akzent)); +} +.fest-punkt__marke { + display: inline-block; margin-top: 8px; padding: 2px 9px; + border: 1px solid var(--rand); border-radius: 999px; + font-size: .72rem; color: var(--text-still); +} +.fest-punkt__marke-stufe { + flex: none; padding: 3px 11px; + border: 1px solid color-mix(in srgb, var(--p-ton) 36%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--p-ton) 12%, transparent); + color: var(--p-ton); + font-size: .73rem; font-weight: 600; white-space: nowrap; +} + +/* Der Grund in voller Breite -- "verbessern" ohne Grund ist nicht + umsetzbar, nur entmutigend. Er darf nicht in eine Ecke. */ +.fest-punkt__grund { + margin-top: 10px; padding: 9px 12px; + border-left: 2px solid var(--p-ton); + border-radius: 0 6px 6px 0; + background: color-mix(in srgb, var(--p-ton) 9%, transparent); +} +.fest-punkt__grund p { margin: 0; font-size: .86rem; line-height: 1.5; color: var(--text); } +.fest-punkt__von { + display: block; margin-top: 4px; + font-size: .74rem; color: var(--text-still); +} + +.fest-punkt__fuss { + display: flex; align-items: center; gap: 7px; flex-wrap: wrap; + margin-top: 11px; +} +.stufe-knopf { + padding: 6px 13px; + border: 1px solid var(--rand); border-radius: 999px; + background: rgba(0, 0, 0, .2); + color: var(--text-still); + font: inherit; font-size: .8rem; cursor: pointer; + transition: border-color .18s ease, color .18s ease, background .18s ease; +} +.stufe-knopf[data-stufe="gut"]:hover, +.stufe-knopf[data-stufe="gut"][data-an="ja"] { + border-color: rgba(77, 171, 119, .5); color: #6fbd95; + background: rgba(77, 171, 119, .13); +} +.stufe-knopf[data-stufe="verbessern"]:hover, +.stufe-knopf[data-stufe="verbessern"][data-an="ja"] { + border-color: rgba(224, 163, 60, .5); color: #e0b45c; + background: rgba(224, 163, 60, .13); +} +.fest-punkt__fuss .schritt { margin-left: auto; } + +.punkt-gespraech { + margin-top: 11px; padding: 12px 13px; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: rgba(0, 0, 0, .2); +} + +@media (max-width: 560px) { + .liste-fest__kopf { flex-direction: column; align-items: stretch; } + .fest-punkt__kopf { flex-direction: column; } + .fest-punkt__marke-stufe { align-self: flex-start; } + .fest-punkt__fuss .schritt { margin-left: 0; } + .stufe-knopf { flex: 1 1 auto; text-align: center; } +} + +/* Die neuen Klassen der festen Checkliste in der Mindestgroesse fuer + das Handy -- gemeldet von der Geraetepruefung (10,56 px beim + Aufhaenger-Schild). Wer kuenftig eine kleine Marke baut, faellt ihr + ebenfalls auf und landet hier. */ +@media (max-width: 760px) { + body.start .fest-punkt__hookschild, + body.start .fest-punkt__marke, + body.start .fest-punkt__von, + body.start .fest-gruppe__zahl { font-size: .75rem; } +} diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index e532afed..ff97302b 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -205,17 +205,21 @@ Welcher Abschnitt gezeigt wird, richtet sich nach dem gewaehlten Filter. Ohne Filter die Vorbereitung -- das ist der Teil, ohne den die anderen beiden gar nicht erst sinnvoll sind. */ + /* Die FESTE Liste -- nicht mehr Vorschlaege zum Uebernehmen. + + Der Unterschied ist grundlegend: Eine Liste, die man sich erst + holen muss, ist keine Checkliste. Und zwei Creator haetten + unterschiedliche Listen gehabt, je nachdem wer sich was geholt + hat -- was einen Vergleich unmoeglich macht, und um Vergleich + geht es bei einer Betreuung. + + Jetzt stehen dieselben Punkte fuer alle fest da, gruppiert. Was + sich unterscheidet, ist der Stand je Creator. */ async function vorlagenZeigen() { const behaelter = $('vorlagen'); - if (!behaelter || bereich !== 'live' || !window.vorlagenBlock) return; - const abschnitt = ['vorbereitung', 'mitschrift', 'auswertung'].includes(filter) - ? filter : 'vorbereitung'; - await window.vorlagenBlock(behaelter, { - art: 'live', - unterart: abschnitt, - creatorId: creatorWahl(), - danach: laden, - }); + if (!behaelter || !window.checkliste) return; + if (!['live', 'community', 'technik'].includes(bereich)) return; + await window.checkliste(behaelter, bereich); } /* Fuer wen wird uebernommen? Ein Creator immer fuer sich (der Server diff --git a/workspace/assets/js/checkliste.js b/workspace/assets/js/checkliste.js new file mode 100644 index 00000000..95b67afa --- /dev/null +++ b/workspace/assets/js/checkliste.js @@ -0,0 +1,318 @@ +/* =================================================================== + Die feste Checkliste — steht immer da, für jeden gleich. + + Ersetzt die vorherigen "Vorschläge zum Übernehmen". Der Unterschied + ist grundlegend: Eine Liste, die man sich erst holen muss, ist keine + Checkliste. Und zwei Creator hätten unterschiedliche Listen gehabt, + je nachdem, wer sich was geholt hat -- was einen Vergleich unmöglich + macht, und um Vergleich geht es bei einer Betreuung. + + Jetzt: dieselben Punkte für alle, gruppiert. Was sich unterscheidet, + ist der STAND je Creator -- und den setzt die Betreuung. + + Drei Zustände, drei Farben: + offen noch nicht angesehen + gut passt so + verbessern mit dem Satz, WAS zu verbessern ist + + Der Creator sieht alles, kann aber nichts davon setzen. Antworten + kann er -- über die Nachrichten an jedem Punkt. + =================================================================== */ +(() => { + 'use strict'; + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + return a; + } + + const wann = (iso) => (iso + ? new Date(iso).toLocaleString('de-DE', + { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) + : ''); + + /* Welcher Creator wird gerade angesehen -- bleibt über Neuaufbauten + hinweg stehen, sonst springt die Auswahl bei jeder Bewertung zurück + auf den ersten. */ + const gewaehlt = {}; + + window.checkliste = async function (behaelter, bereich) { + if (!behaelter) return; + let daten; + try { + const wahl = gewaehlt[bereich] ? `?creator_id=${gewaehlt[bereich]}` : ''; + const a = await hole(`/workspace/api/checkliste/${bereich}${wahl}`); + if (!a.ok) return; + daten = await a.json(); + } catch { return; } + + behaelter.textContent = ''; + const block = el('section', 'liste-fest'); + + /* ---- Kopf ---- */ + const kopf = el('div', 'liste-fest__kopf'); + const t = el('div', null); + t.append(el('p', 'feldschild', 'Feste Punkte')); + t.append(el('p', 'liste-fest__unter', + daten.darf_bewerten + ? 'Diese Punkte gelten für alle. Markiere, was gut läuft und was der ' + + 'Creator noch verbessern sollte – er sieht es sofort.' + : 'Diese Punkte gelten für alle. Deine Betreuung markiert hier, was gut ' + + 'läuft und was du noch verbessern solltest. Antworten kannst du an ' + + 'jedem Punkt.')); + kopf.append(t); + + if (daten.darf_bewerten && daten.creatoren.length) { + const feld = el('div', 'liste-fest__wahl'); + feld.append(el('label', 'feld-schild', 'Creator')); + const s = el('select'); + for (const c of daten.creatoren) s.append(new Option(c.name, String(c.id))); + if (daten.creator_id) s.value = String(daten.creator_id); + s.addEventListener('change', () => { + gewaehlt[bereich] = Number(s.value); + window.checkliste(behaelter, bereich); + }); + feld.append(s); + kopf.append(feld); + if (!gewaehlt[bereich] && daten.creatoren.length) { + gewaehlt[bereich] = Number(s.value); + } + } + block.append(kopf); + + /* ---- Fortschritt in einer Zeile ---- + Wie viele Punkte sind angesehen, wie viele stehen auf "noch + verbessern"? Das ist die Frage, die beide zuerst haben. */ + const alle = daten.gruppen.flatMap((g) => g.punkte); + const zuTun = alle.filter((p) => daten.stand[p.schluessel]?.stufe === 'verbessern').length; + const gut = alle.filter((p) => daten.stand[p.schluessel]?.stufe === 'gut').length; + if (daten.creator_id || !daten.darf_bewerten) { + const bilanz = el('div', 'liste-fest__bilanz'); + bilanz.append(zahl(gut, 'passt so', 'gut')); + bilanz.append(zahl(zuTun, zuTun === 1 ? 'zu verbessern' : 'zu verbessern', 'verbessern')); + bilanz.append(zahl(alle.length - gut - zuTun, 'noch offen', 'offen')); + block.append(bilanz); + } + + /* ---- Die Gruppen ---- */ + for (const g of daten.gruppen) { + const abschnitt = el('section', 'fest-gruppe'); + const gk = el('div', 'fest-gruppe__kopf'); + gk.append(el('h3', 'fest-gruppe__name', g.name)); + gk.append(el('span', 'fest-gruppe__zahl', String(g.punkte.length))); + abschnitt.append(gk); + if (g.text) abschnitt.append(el('p', 'fest-gruppe__text', g.text)); + + const liste = el('div', 'fest-punkte'); + for (const p of g.punkte) { + liste.append(punktBauen(p, daten, bereich, behaelter)); + } + abschnitt.append(liste); + block.append(abschnitt); + } + + behaelter.append(block); + behaelter.hidden = false; + }; + + function zahl(n, text, stufe) { + const k = el('div', 'bilanz-zahl'); + k.dataset.stufe = stufe; + k.append(el('span', 'bilanz-zahl__n', String(n))); + k.append(el('span', 'bilanz-zahl__text', text)); + return k; + } + + function punktBauen(p, daten, bereich, behaelter) { + const stand = daten.stand[p.schluessel]; + const stufe = stand?.stufe || 'offen'; + + const k = el('article', 'fest-punkt'); + k.dataset.stufe = stufe; + + const kopf = el('div', 'fest-punkt__kopf'); + const links = el('div', 'fest-punkt__text'); + links.append(el('h4', 'fest-punkt__titel', p.titel)); + + /* Bei Content-Ideen ist der Aufhänger das Wichtigste -- die ersten + Sekunden entscheiden. Er steht deshalb hervorgehoben. */ + if (p.hook) { + const h = el('p', 'fest-punkt__hook'); + h.append(el('span', 'fest-punkt__hookschild', 'Aufhänger')); + h.append(document.createTextNode(p.hook)); + links.append(h); + } + if (p.text) links.append(el('p', 'fest-punkt__beschreibung', p.text)); + if (p.format) links.append(el('span', 'fest-punkt__marke', p.format)); + kopf.append(links); + + /* Die Marke rechts -- immer sichtbar, auch für den Creator, auch + wenn sie "noch offen" lautet. Er soll sehen, wo er steht. */ + const marke = el('span', 'fest-punkt__marke-stufe', + stufe === 'gut' ? 'Passt so' + : stufe === 'verbessern' ? 'Verbessern' : 'Offen'); + kopf.append(marke); + k.append(kopf); + + /* Der Grund steht in voller Breite -- "verbessern" ohne Grund ist + nicht umsetzbar, nur entmutigend. */ + if (stand?.begruendung) { + const g = el('div', 'fest-punkt__grund'); + g.append(el('p', null, stand.begruendung)); + g.append(el('span', 'fest-punkt__von', + `${stand.von_name || ''} · ${wann(stand.am)}`)); + k.append(g); + } + + /* ---- Was man tun kann ---- */ + const fuss = el('div', 'fest-punkt__fuss'); + + if (daten.darf_bewerten && daten.creator_id) { + for (const [wert, name] of [['gut', 'Passt so'], ['verbessern', 'Verbessern']]) { + const b = el('button', 'stufe-knopf', name); + b.type = 'button'; + b.dataset.stufe = wert; + if (stufe === wert) b.dataset.an = 'ja'; + b.addEventListener('click', async () => { + /* Ein zweiter Klick auf dieselbe Stufe nimmt sie zurück -- + sonst gäbe es keinen Weg zurück auf "offen". */ + const neu = stufe === wert ? 'offen' : wert; + let grund = stand?.begruendung || ''; + if (neu === 'verbessern') { + grund = prompt(`„${p.titel}“\n\nWas genau soll verbessert werden?\n\n` + + 'Ohne diesen Satz kann er nichts damit anfangen.', grund) || ''; + if (grund.trim().length < 5) return; + } + b.disabled = true; + try { + const a = await hole(`/workspace/api/checkliste/${bereich}/${p.schluessel}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stufe: neu, begruendung: grund, + creator_id: daten.creator_id }), + }); + if (!a.ok) { + alert((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); + b.disabled = false; + return; + } + window.checkliste(behaelter, bereich); + } catch { b.disabled = false; } + }); + fuss.append(b); + } + } + + /* Rückmeldungen -- für beide Seiten. */ + const anzahl = daten.nachrichten[p.schluessel] || 0; + const reden = el('button', 'schritt', + anzahl ? `Rückmeldung (${anzahl})` : 'Rückmeldung'); + reden.type = 'button'; + reden.addEventListener('click', () => gespraech(k, bereich, p, daten, reden)); + fuss.append(reden); + + /* Bei Punkten, wo Hilfe angeboten wird, kann der Creator sie hier + anfordern -- daraus wird eine Aufgabe bei seinem Betreuer. */ + if (p.hilfe && !daten.darf_bewerten && window.hilfeKnopf) { + fuss.append(window.hilfeKnopf(p.titel, daten.creator_id)); + } + + k.append(fuss); + return k; + } + + /* ---------- Das Gespräch an einem Punkt -------------------------------- */ + + async function gespraech(karte, bereich, p, daten, knopf) { + const offen = karte.querySelector('.punkt-gespraech'); + if (offen) { offen.remove(); return; } + + let n; + try { + const a = await hole( + `/workspace/api/checkliste/${bereich}/${p.schluessel}/nachrichten` + + (daten.creator_id ? `?creator_id=${daten.creator_id}` : '')); + if (!a.ok) return; + n = await a.json(); + } catch { return; } + + const kasten = el('div', 'punkt-gespraech'); + const verlauf = el('div', 'nachrichten'); + zeichne(verlauf, n.nachrichten, n.ich); + kasten.append(verlauf); + + const form = el('form', 'nachricht-neu'); + const feld = el('textarea'); + feld.rows = 2; + feld.maxLength = 2000; + feld.placeholder = daten.darf_bewerten + ? 'Rückmeldung an den Creator …' + : 'Frage oder Antwort an deine Betreuung …'; + form.append(feld); + const senden = el('button', 'knopf knopf--klein', 'Senden'); + senden.type = 'submit'; + form.append(senden); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!feld.value.trim()) return; + senden.disabled = true; + try { + const a = await hole( + `/workspace/api/checkliste/${bereich}/${p.schluessel}/nachrichten`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: feld.value, creator_id: daten.creator_id }), + }); + if (!a.ok) { alert((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } + const r = await a.json(); + feld.value = ''; + zeichne(verlauf, r.nachrichten, n.ich); + knopf.textContent = `Rückmeldung (${r.nachrichten.length})`; + } catch { /* umgeleitet */ } finally { senden.disabled = false; } + }); + kasten.append(form); + karte.append(kasten); + feld.focus(); + } + + function zeichne(ziel, liste, ichId) { + ziel.textContent = ''; + if (!liste.length) { + ziel.append(el('p', 'nachrichten__leer', 'Noch keine Rückmeldung zu diesem Punkt.')); + return; + } + for (const n of liste) { + const z = el('div', 'nachricht'); + if (n.von === ichId) z.dataset.eigen = 'ja'; + const kopf = el('div', 'nachricht__kopf'); + kopf.append(el('span', 'nachricht__von', n.von_name || 'Unbekannt')); + kopf.append(el('span', 'nachricht__wann', wann(n.am))); + if (n.von === ichId) { + const weg = el('button', 'nachricht__weg', '×'); + weg.type = 'button'; + weg.title = 'Eigene Nachricht zurücknehmen'; + weg.addEventListener('click', async () => { + if (!confirm('Diese Nachricht zurücknehmen?')) return; + try { + const a = await hole(`/workspace/api/checkliste/nachricht/${n.id}`, + { method: 'DELETE' }); + if (a.ok) z.remove(); + } catch { /* umgeleitet */ } + }); + kopf.append(weg); + } + z.append(kopf); + z.append(el('p', 'nachricht__text', n.text)); + ziel.append(z); + } + } +})(); diff --git a/workspace/assets/js/content.js b/workspace/assets/js/content.js index a9bb1d3d..a2c532b7 100644 --- a/workspace/assets/js/content.js +++ b/workspace/assets/js/content.js @@ -443,14 +443,12 @@ /* Fertige Ideen zum Uebernehmen. Steht ueber der Strecke, damit jemand mit leeren Spalten nicht erst daran vorbeiscrollen muss. */ + /* Die feste Liste der Ideen -- nach Saeule gruppiert (70/20/10). + Sie steht fuer jeden gleich da; was sich unterscheidet, ist, + was die Betreuung dazu vermerkt hat. */ const behaelter = document.getElementById('vorlagen'); - if (behaelter && window.vorlagenBlock) { - const wahl = document.getElementById('creator-wahl'); - window.vorlagenBlock(behaelter, { - art: 'content', - creatorId: wahl && wahl.value ? Number(wahl.value) : null, - danach: laden, - }); + if (behaelter && window.checkliste) { + window.checkliste(behaelter, 'content'); } filterBauen(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 0b2ef6e5..fbd0f9af 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -139,9 +139,9 @@ - - - - + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 5a1502e8..23092e49 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,9 +132,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 573e394d..a4a2d6bd 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -115,12 +115,13 @@ - - - - - - - + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 8587cab0..6d6ea4f9 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -54,10 +54,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 81e172e4..98c8dfef 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -162,10 +162,11 @@ - - - - - + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index 045663f8..c2e945b9 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,9 +93,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index abdab9c5..05636c4e 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 00b8d84c..3a593261 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -147,9 +147,9 @@ - - - - + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 73d71cae..3e67efe2 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -121,9 +121,9 @@ - - - - + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 4285ae61..be11e1dd 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,10 +175,10 @@ - - - - - + + + + + diff --git a/workspace/report.html b/workspace/report.html index f9852940..a2d982e0 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -91,10 +91,10 @@ - - - - - + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 87ae0398..4c4e72ac 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,9 +124,9 @@ - - - - + + + + diff --git a/workspace/start.html b/workspace/start.html index f0d3ec6d..76875001 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -99,9 +99,9 @@

- - - - + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index e37d975c..31d7882b 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -70,10 +70,10 @@ - - - - - + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 65e963a3..b2d5f44a 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -85,9 +85,9 @@ - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 33d52ca7..ec855fe9 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -57,9 +57,9 @@ - + - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 3ccca839..140314c8 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,9 +175,9 @@ - - - - + + + +