diff --git a/pruef-content-computer.png b/pruef-content-computer.png new file mode 100644 index 00000000..dc808851 Binary files /dev/null and b/pruef-content-computer.png differ diff --git a/pruef-content-creator.png b/pruef-content-creator.png new file mode 100644 index 00000000..00b3d3a0 Binary files /dev/null and b/pruef-content-creator.png differ diff --git a/pruef-content-handy.png b/pruef-content-handy.png new file mode 100644 index 00000000..b3472c0a Binary files /dev/null and b/pruef-content-handy.png differ diff --git a/pruef-protokoll-auf.png b/pruef-protokoll-auf.png index 75777512..935c4f25 100644 Binary files a/pruef-protokoll-auf.png and b/pruef-protokoll-auf.png differ diff --git a/pruef-protokoll-zu.png b/pruef-protokoll-zu.png index 1cde832a..5be6068b 100644 Binary files a/pruef-protokoll-zu.png and b/pruef-protokoll-zu.png differ diff --git a/server/index.js b/server/index.js index f265dbe4..75180c91 100644 --- a/server/index.js +++ b/server/index.js @@ -28,6 +28,7 @@ import { sucheRouter } from "./workspace-suche.js"; import { startcheckRouter } from "./workspace-startcheck.js"; import { wissenRouter } from "./workspace-wissen.js"; import { kiRouter } from "./workspace-ki.js"; +import { contentRouter } from "./workspace-content.js"; import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -102,6 +103,7 @@ app.use(sucheRouter); app.use(startcheckRouter); app.use(wissenRouter); app.use(kiRouter); +app.use(contentRouter); /* Naechtliche Sicherung. Startet einen eigenen Takt im Prozess -- siehe workspace-sicherung.js, dort steht auch, warum sie NICHT als eigener diff --git a/server/pruef-bereiche-lesend.mjs b/server/pruef-bereiche-lesend.mjs new file mode 100644 index 00000000..2dcb2a30 --- /dev/null +++ b/server/pruef-bereiche-lesend.mjs @@ -0,0 +1,171 @@ +/* =================================================================== + Pruefung: Ein Creator kann in den Bereichen NICHTS mehr schreiben. + + Wunsch Filipe 31.08.2026: "die creator sollen da nur die sehen die wir + ihnen eintragen, also dogfather manager und scout." + + Der Schwerpunkt liegt auf den VERNEINENDEN Faellen -- dass DogFather + schreiben darf, faellt sofort auf; dass ein Creator es nicht mehr kann, + merkt man nur, wenn man es ausdruecklich versucht. Und zwar auf JEDEM + Weg und in JEDEM der fuenf Bereiche, nicht nur in dem einen, den man + gerade im Kopf hat. + =================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-bereiche-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4183"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-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:4183"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 180_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"); +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("Dogfather", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Ein Scout", "scout", "CODE-SCOU-0001"); +const idAnna = anlegen("Anna", "creator", "CODE-ANNA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idAnna, idScout, jetzt); + +/* Ein Eintrag, den ANNA sich frueher selbst haette anlegen koennen -- + damit auch das Loeschen "eigener" Eintraege geprueft wird. */ +const eigenerId = (() => { + d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von) VALUES ('live','auswertung','Von Anna selbst','alt',?,'offen',?,?,?)") + .run(jetzt.slice(0, 10), idAnna, jetzt, idAnna); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +})(); +d.close(); + +const anmelden = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }), redirect: "manual" }); + return (a.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; "); +}; +const ruf = (pfad, keks, opt = {}) => fetch(BASIS + pfad, { + ...opt, headers: { cookie: keks, "Content-Type": "application/json", ...(opt.headers || {}) }, +}); + +const kDogi = await anmelden("admin", "CODE-DOGI-0001"); +const kScout = await anmelden("scout", "CODE-SCOU-0001"); +const kCreator = await anmelden("creator", "CODE-ANNA-0001"); + +const BEREICHE = ["live", "content", "technik", "community", "schutz"]; + +console.log("\n1) DogFather trägt in jedem Bereich ein"); +const angelegt = {}; +for (const b of BEREICHE) { + const arten = { live: "auswertung", content: "idee", technik: "problem", community: "moderation", schutz: "vorfall" }; + const a = await ruf(`/workspace/api/bereich/${b}`, kDogi, { + method: "POST", + body: JSON.stringify({ art: arten[b], titel: "Von der Betreuung", text: "…", + datum: jetzt.slice(0, 10), creator_id: idAnna }), + }); + const j = await a.json().catch(() => ({})); + angelegt[b] = j.id; + ok(a.ok, `${b}: angelegt (${a.status})`); +} + +console.log("\n2) Der Scout darf ebenfalls – er betreut Anna"); +{ + const a = await ruf("/workspace/api/bereich/live", kScout, { + method: "POST", + body: JSON.stringify({ art: "vorbereitung", titel: "Vom Scout", datum: jetzt.slice(0, 10), creator_id: idAnna }), + }); + ok(a.ok, `Scout darf eintragen (${a.status})`); +} + +console.log("\n3) Der Creator SIEHT alles, was zu ihm gehört"); +for (const b of BEREICHE) { + const a = await ruf(`/workspace/api/bereich/${b}`, kCreator); + const j = await a.json().catch(() => ({})); + const anzahl = (j.eintraege || []).length; + ok(a.ok && anzahl >= 1, `${b}: sichtbar, ${anzahl} Eintrag/Einträge (${a.status})`); + if (b === "live") { + ok((j.eintraege || []).some((e) => e.titel === "Von der Betreuung"), + "der Eintrag der Betreuung ist dabei"); + ok((j.eintraege || []).some((e) => e.titel === "Vom Scout"), + "der Eintrag des Scouts ist dabei"); + } +} + +console.log("\n4) Der Creator kann NICHTS schreiben – in keinem Bereich, auf keinem Weg"); +for (const b of BEREICHE) { + const neu = await ruf(`/workspace/api/bereich/${b}`, kCreator, { + method: "POST", + body: JSON.stringify({ art: "idee", titel: "Selbst geschrieben", datum: jetzt.slice(0, 10) }), + }); + ok(neu.status === 403, `${b}: anlegen abgelehnt (${neu.status})`); + + const id = angelegt[b]; + const aend = await ruf(`/workspace/api/bereich/${b}/${id}`, kCreator, { + method: "PATCH", body: JSON.stringify({ titel: "Umgeschrieben" }), + }); + ok(aend.status === 403, `${b}: ändern abgelehnt (${aend.status})`); + + const weg = await ruf(`/workspace/api/bereich/${b}/${id}`, kCreator, { method: "DELETE" }); + ok(weg.status === 403, `${b}: löschen abgelehnt (${weg.status})`); +} + +console.log("\n5) Auch den EIGENEN alten Eintrag nicht mehr"); +{ + /* Das war vorher ausdruecklich erlaubt ("wer ihn selbst geschrieben + hat"). Genau der Weg muss jetzt auch zu sein -- sonst bliebe ein + Schlupfloch fuer alles, was vor der Umstellung entstanden ist. */ + const weg = await ruf(`/workspace/api/bereich/live/${eigenerId}`, kCreator, { method: "DELETE" }); + ok(weg.status === 403, `eigener Alteintrag lässt sich nicht löschen (${weg.status})`); + const aend = await ruf(`/workspace/api/bereich/live/${eigenerId}`, kCreator, { + method: "PATCH", body: JSON.stringify({ status: "erledigt" }) }); + ok(aend.status === 403, `und auch nicht auf erledigt setzen (${aend.status})`); +} + +console.log("\n6) Nichts hat sich tatsächlich verändert"); +{ + const a = await ruf("/workspace/api/bereich/live", kDogi); + const { eintraege } = await a.json(); + ok(eintraege.some((e) => e.titel === "Von der Betreuung"), "der Eintrag steht unverändert da"); + ok(eintraege.some((e) => e.titel === "Von Anna selbst"), "der Alteintrag ist noch da"); + ok(!eintraege.some((e) => e.titel === "Selbst geschrieben"), "nichts vom Creator ist durchgerutscht"); + ok(!eintraege.some((e) => e.titel === "Umgeschrieben"), "nichts wurde umgeschrieben"); + ok(eintraege.every((e) => e.status === "offen"), "kein Status wurde verändert"); +} + +console.log("\n7) Die Antwort erklärt, was los ist"); +{ + const a = await ruf("/workspace/api/bereich/live", kCreator, { + method: "POST", body: JSON.stringify({ art: "idee", titel: "x", datum: jetzt.slice(0, 10) }) }); + const j = await a.json(); + console.log(" ->", j.fehler); + ok(/Betreuer/.test(j.fehler || ""), "die Meldung sagt, wer einträgt"); + ok(/siehst/.test(j.fehler || ""), "und dass er trotzdem alles sieht"); +} + +console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/pruef-content-ansicht.mjs b/server/pruef-content-ansicht.mjs new file mode 100644 index 00000000..b306b053 --- /dev/null +++ b/server/pruef-content-ansicht.mjs @@ -0,0 +1,223 @@ +/* Browserpruefung der Content-Planung: Strecke, Kennzahlen, Balance, + Ueberfuehren -- und was ein Creator davon zu sehen bekommt. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-content-b-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4185"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-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:4185"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_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"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const heute = jetzt.slice(0, 10); +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("Dogfather", "admin", "CODE-DOGI-0001"); +const idAnna = anlegen("Anna Sturm", "creator", "CODE-ANNA-0001"); + +/* Vier Saeulen -- damit vier der fuenf Farbplaetze im Bild sind. */ +const saeulen = {}; +for (const [i, [name, ziel]] of [["Tutorials", 55], ["Community", 25], ["Hinter den Kulissen", 10], ["Eigenwerbung", 10]].entries()) { + d.prepare("INSERT INTO content_saeulen (creator_id, name, ziel, slot, erstellt) VALUES (?,?,?,?,?)") + .run(idAnna, name, ziel, i + 1, jetzt); + saeulen[name] = d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +const video = (art, titel, hook, saeule, format, geplant) => + d.prepare(`INSERT INTO eintraege (bereich, art, titel, hook, format, saeule_id, geplant, datum, status, creator_id, erstellt, erstellt_von) + VALUES ('content',?,?,?,?,?,?,?,'offen',?,?,?)`) + .run(art, titel, hook, format, saeule ? saeulen[saeule] : null, geplant, heute, idAnna, jetzt, idDogi); + +video("idee", "3 Fehler beim ersten LIVE", null, "Tutorials", null, null); +video("idee", "Warum ich fast aufgehört hätte", null, "Hinter den Kulissen", null, null); +video("skript", "Setup für unter 100 €", "Das hier kostet weniger als dein Handy.", "Tutorials", "Tutorial", null); +video("skript", "Antwort auf eure Fragen", null, "Community", "Talking Head", null); +video("gedreht", "Mein Tag als Creator", "Um 6 klingelt der Wecker – freiwillig.", "Hinter den Kulissen", "Story", null); +video("gedreht", "Die 3 häufigsten Fragen", "Nummer 2 fragt mir wirklich jeder.", "Community", "Talking Head", null); +video("fertig", "Licht richtig setzen", "Ein Fenster reicht. Ehrlich.", "Tutorials", "Tutorial", + new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 10)); +video("fertig", "Verpasst und überfällig", "Guck bis zum Schluss.", "Tutorials", "Tutorial", "2026-01-05"); +for (let i = 0; i < 9; i++) video("veroeffentlicht", "Draußen " + i, "x", "Tutorials", "Tutorial", null); +for (let i = 0; i < 4; i++) video("veroeffentlicht", "Community " + i, "x", "Community", "Talking Head", null); +for (let i = 0; i < 2; i++) video("veroeffentlicht", "Kulissen " + i, "x", "Hinter den Kulissen", "Story", null); +video("veroeffentlicht", "Ohne Zuordnung", "x", null, null, null); +d.close(); + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +async function seiteFuer(kontext, rolle, code) { + const seite = await kontext.newPage(); + 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 }); + await seite.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(700); + return seite; +} + +const lesen = (seite) => seite.evaluate(() => ({ + spalten: [...document.querySelectorAll(".spalte")].map((s) => ({ + name: s.querySelector(".spalte__name")?.textContent, + zahl: s.querySelector(".spalte__zahl")?.textContent, + karten: s.querySelectorAll(".video").length, + })), + kacheln: [...document.querySelectorAll(".kachel")].map((k) => ({ + wert: k.querySelector(".kachel__wert")?.textContent, + name: k.querySelector(".kachel__name")?.textContent, + zusatz: k.querySelector(".kachel__zusatz")?.textContent, + stufe: k.dataset.stufe, + })), + stuecke: [...document.querySelectorAll(".balance__stueck")].map((s) => ({ + slot: s.dataset.slot, breite: s.style.width, + farbe: getComputedStyle(s).backgroundColor, + })), + legende: [...document.querySelectorAll(".legende__punkt")].map((l) => l.textContent), + hooks: [...document.querySelectorAll(".video__hook")].map((h) => h.textContent).slice(0, 3), + fehlendeHooks: document.querySelectorAll(".video__hook--fehlt").length, + anlegenDa: !document.getElementById("neu-oeffnen")?.hidden, + saeulenKnopfDa: !document.getElementById("saeulen-oeffnen")?.hidden, + /* Nur SICHTBARE zaehlen -- versteckte Knoepfe waeren zwar unschoen, + aber die Frage ist, was der Creator vor sich hat. Gezaehlt wird + zusaetzlich, was ueberhaupt im Dokument steht (siehe knoepfeGesamt): + Ein Knopf, den nur CSS versteckt, ist mit der Tastatur erreichbar. */ + knoepfe: [...document.querySelectorAll(".tu-knopf")].filter((b) => b.offsetParent !== null).length, + knoepfeGesamt: document.querySelectorAll(".tu-knopf").length, + mehrKnopf: document.querySelectorAll(".spalte__mehr").length, + unterzeile: document.getElementById("unterzeile")?.textContent, + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, + ueberstehend: [...document.querySelectorAll("main *")] + .filter((e) => e.getBoundingClientRect().right > window.innerWidth + 1) + .map((e) => String(e.className).slice(0, 40)).slice(0, 4), +})); + +for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) { + console.log(`\n=== DogFather · ${wie}`); + const kontext = await browser.newContext({ viewport: { width: breite, height: 1100 } }); + const konsole = []; + const seite = await seiteFuer(kontext, "admin", "CODE-DOGI-0001"); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + await seite.reload({ waitUntil: "networkidle" }); + await seite.waitForTimeout(700); + + const r = await lesen(seite); + console.log(" Spalten:", r.spalten.map((s) => `${s.name}=${s.zahl}`).join(" · ")); + console.log(" Kacheln:", r.kacheln.map((k) => `${k.name}=${k.wert}`).join(" · ")); + + ok(r.spalten.length === 5, `fünf Stufen (${r.spalten.length})`); + ok(r.spalten[0].zahl === "2" && r.spalten[4].zahl === "16", + `Ideen 2, Veröffentlicht 16 (${r.spalten[0].zahl}/${r.spalten[4].zahl})`); + ok(r.kacheln.length === 5, `fünf Kennzahlen (${r.kacheln.length})`); + + const vorrat = r.kacheln.find((k) => k.name === "Vorrat"); + ok(vorrat?.wert === "4", `Vorrat 4 (${vorrat?.wert})`); + ok(/reicht noch/.test(vorrat?.zusatz || ""), `als Tage ausgedrückt ("${vorrat?.zusatz}")`); + + const ohneHook = r.kacheln.find((k) => k.name === "ohne Hook"); + ok(ohneHook?.wert === "1" && ohneHook?.stufe === "warn", + `ein Video ohne Hook, auffällig (${ohneHook?.wert}/${ohneHook?.stufe})`); + const ueberf = r.kacheln.find((k) => k.name === "überfällig"); + ok(ueberf?.wert === "1", `eines überfällig (${ueberf?.wert})`); + + /* Die Balance -- vier Farben, jede eine andere, keine zwei gleich. */ + ok(r.stuecke.length === 4, `vier Stücke im Balken (${r.stuecke.length})`); + const farben = new Set(r.stuecke.map((s) => s.farbe)); + ok(farben.size === r.stuecke.length, `jede Farbe kommt genau einmal vor (${farben.size})`); + console.log(" Farben:", [...farben].join(" ")); + ok(r.legende.some((l) => /Ziel 55/.test(l)), "die Legende nennt das Ziel"); + ok(r.legende.some((l) => /über|unter/.test(l)), "und die Abweichung davon"); + + ok(r.fehlendeHooks === 1, `genau eine Karte sagt „kein Hook" (${r.fehlendeHooks})`); + ok(r.hooks.some((h) => /„/.test(h)), "Hooks stehen in Anführungszeichen"); + ok(r.anlegenDa && r.saeulenKnopfDa, "DogFather kann anlegen und Säulen verwalten"); + ok(r.ueber <= 0, `nichts steht über (${r.ueber}px, ${r.ueberstehend.join(", ")})`); + ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); + + /* Die lange Spalte darf nicht alles erschlagen -- und der Aufklapper + muss sie wirklich vollstaendig zeigen. */ + ok(r.mehrKnopf === 1, `ein Aufklapper an der zu langen Spalte (${r.mehrKnopf})`); + ok(r.spalten[4].karten === 6, `Veröffentlicht zeigt 6 von 16 (${r.spalten[4].karten})`); + await seite.click(".spalte__mehr"); + await seite.waitForTimeout(300); + const offen = await lesen(seite); + ok(offen.spalten[4].karten === 16, `aufgeklappt sind es alle 16 (${offen.spalten[4].karten})`); + await seite.click(".spalte__mehr"); + await seite.waitForTimeout(300); + ok((await lesen(seite)).spalten[4].karten === 6, "und wieder zu"); + + if (breite === 1440) { + await seite.screenshot({ path: "pruef-content-computer.png", fullPage: true }); + + console.log("\n Überführen im Browser"); + /* Die Karte mit dem geplanten Termin -- dort gibt es alle drei Wege. */ + const knopf = seite.locator('.video:has-text("Licht richtig setzen") .tu-knopf', { hasText: "→ Aufgabe" }); + await knopf.first().click(); + await seite.waitForTimeout(1200); + const meldung = await seite.evaluate(() => document.getElementById("fehler")?.textContent); + ok(/Aufgabe angelegt/.test(meldung || ""), `Rückmeldung erscheint ("${meldung}")`); + const link = await seite.evaluate(() => document.querySelector("#fehler a")?.getAttribute("href")); + ok(link === "aufgaben.html", `mit Weg dorthin (${link})`); + } else { + await seite.screenshot({ path: "pruef-content-handy.png", fullPage: true }); + } + await kontext.close(); +} + +console.log("\n=== Creator sieht, ändert aber nichts"); +{ + const kontext = await browser.newContext({ viewport: { width: 1440, height: 1100 } }); + const seite = await seiteFuer(kontext, "creator", "CODE-ANNA-0001"); + const r = await lesen(seite); + ok(r.spalten.length === 5, `sieht die ganze Strecke (${r.spalten.length} Spalten)`); + /* 2+2+2+2 plus die gekuerzten 6 der letzten Spalte = 14. Die Kuerzung + gilt fuer JEDE Rolle -- sie ist eine Frage der Lesbarkeit, nicht der + Rechte. Der Rest ist einen Klick entfernt. */ + ok(r.spalten.reduce((n, s) => n + s.karten, 0) === 14, + `und seine Videos, letzte Spalte gekürzt (${r.spalten.reduce((n, s) => n + s.karten, 0)})`); + ok(r.mehrKnopf === 1, `mit einem Aufklapper an der langen Spalte (${r.mehrKnopf})`); + ok(r.kacheln.length === 5, "sieht seine Kennzahlen"); + ok(r.stuecke.length === 4, "sieht die Verteilung"); + ok(!r.anlegenDa, "aber keinen Anlegen-Knopf"); + ok(!r.saeulenKnopfDa, "und keine Säulenverwaltung"); + ok(r.knoepfe === 0, `keinen einzigen sichtbaren Knopf (${r.knoepfe})`); + ok(r.knoepfeGesamt === 0, `und auch keinen versteckten im Dokument (${r.knoepfeGesamt})`); + ok(/deine Betreuung/.test(r.unterzeile || ""), `und die Unterzeile erklärt es ("${r.unterzeile}")`); + await seite.screenshot({ path: "pruef-content-creator.png", fullPage: true }); + await kontext.close(); +} + +await browser.close(); +console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/pruef-content.mjs b/server/pruef-content.mjs new file mode 100644 index 00000000..bdf26680 --- /dev/null +++ b/server/pruef-content.mjs @@ -0,0 +1,296 @@ +/* =================================================================== + Pruefung der Content-Planung: Strecke, Saeulen, Kennzahlen und das + Ueberfuehren in Aufgabe / Termin / LIVE-Thema. + =================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-content-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4184"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-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:4184"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_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"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const heute = jetzt.slice(0, 10); +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("Dogfather", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Ein Scout", "scout", "CODE-SCOU-0001"); +const idAnna = anlegen("Anna", "creator", "CODE-ANNA-0001"); +const idBen = anlegen("Ben", "creator", "CODE-BENN-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idAnna, idScout, jetzt); + +/* Ein Alteintrag mit der ALTEN Art -- die Umstellung muss ihn retten, + sonst faellt er aus jeder Spalte heraus und ist unsichtbar. */ +d.prepare("INSERT INTO eintraege (bereich, art, titel, datum, status, creator_id, erstellt, erstellt_von) VALUES ('content','produktion','Alter Eintrag',?,'offen',?,?,?)") + .run(heute, idAnna, jetzt, idDogi); +d.close(); + +/* Die Umstellung laeuft NUR beim Oeffnen der Datenbank -- und die war + beim ersten Anmeldeversuch oben laengst offen. Der Alteintrag entstand + also DANACH und waere hier nie umgestellt worden. Genau daran ist die + erste Fassung dieser Pruefung gescheitert, und sie haette den Eindruck + erweckt, die Umstellung funktioniere nicht. + + Deshalb wird jetzt der echte Weg geprueft: ein zweiter Serverstart auf + derselben Datei, so wie es morgen frueh auf dem Server passiert. */ +{ + const { spawnSync } = await import("node:child_process"); + const zweiter = spawnSync(process.execPath, ["-e", ` + process.env.WORKSPACE_DB = ${JSON.stringify(process.env.WORKSPACE_DB)}; + process.env.PORT = "4194"; + process.env.SITE_ACCESS_SECRET = "lokaler-test"; + process.env.SITE_ACCESS_CODE_DOGI = "X"; + process.env.SITE_ACCESS_CODE_VANVAN = "Y"; + process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + const { db } = await import(${JSON.stringify( + "file:///" + join(import.meta.dirname, "workspace.js").replace(/\\\\/g, "/"))}); + db(); // oeffnet und stellt um + process.exit(0); + `], { encoding: "utf8", timeout: 30000 }); + const ausgabe = (zweiter.stdout || "") + (zweiter.stderr || ""); + console.log(" Zweiter Start:", ausgabe.trim().split("\n").filter((z) => /umgestellt|Spalte/.test(z)).join(" | ") || "(still)"); +} + +const anmelden = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }), redirect: "manual" }); + return (a.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; "); +}; +const ruf = (pfad, keks, opt = {}) => fetch(BASIS + pfad, { + ...opt, headers: { cookie: keks, "Content-Type": "application/json", ...(opt.headers || {}) }, +}); +const jsn = async (a) => { try { return await a.json(); } catch { return {}; } }; + +const kDogi = await anmelden("admin", "CODE-DOGI-0001"); +const kScout = await anmelden("scout", "CODE-SCOU-0001"); +const kCreator = await anmelden("creator", "CODE-ANNA-0001"); + +console.log("\n1) Die Umstellung der alten Arten"); +{ + const j = await jsn(await ruf("/workspace/api/bereich/content", kDogi)); + ok(Object.keys(j.einstellung.arten).length === 5, `fünf Stufen (${Object.keys(j.einstellung.arten).join(", ")})`); + const alt = j.eintraege.find((e) => e.titel === "Alter Eintrag"); + ok(alt?.art === "gedreht", `der Alteintrag steht jetzt auf 'gedreht' (${alt?.art})`); + ok(!j.eintraege.some((e) => e.art === "produktion"), "kein Eintrag hängt noch in der alten Art fest"); +} + +console.log("\n2) Säulen anlegen"); +let saeuleA, saeuleB; +{ + for (const [name, ziel] of [["Tutorials", 70], ["Community", 20], ["Eigenwerbung", 10]]) { + const a = await ruf("/workspace/api/content/saeulen", kDogi, { + method: "POST", body: JSON.stringify({ name, ziel, creator_id: idAnna }) }); + const j = await jsn(a); + if (name === "Tutorials") saeuleA = j.id; + if (name === "Community") saeuleB = j.id; + ok(a.ok, `„${name}" angelegt, Farbplatz ${j.slot} (${a.status})`); + } + const doppelt = await ruf("/workspace/api/content/saeulen", kDogi, { + method: "POST", body: JSON.stringify({ name: "tutorials", creator_id: idAnna }) }); + ok(doppelt.status === 400, `derselbe Name wird abgelehnt (${doppelt.status})`); + + for (const n of ["Vier", "Fünf"]) { + await ruf("/workspace/api/content/saeulen", kDogi, { + method: "POST", body: JSON.stringify({ name: n, creator_id: idAnna }) }); + } + const sechste = await ruf("/workspace/api/content/saeulen", kDogi, { + method: "POST", body: JSON.stringify({ name: "Sechs", creator_id: idAnna }) }); + ok(sechste.status === 400, `die sechste Säule wird abgelehnt (${sechste.status})`); + console.log(" ->", (await jsn(sechste)).fehler); +} + +console.log("\n3) Eine Säule gehört genau einem Creator"); +{ + /* Ben eine Saeule anhaengen, die Anna gehoert -- muss scheitern. */ + const a = await ruf("/workspace/api/bereich/content", kDogi, { + method: "POST", + body: JSON.stringify({ art: "idee", titel: "Für Ben", datum: heute, + creator_id: idBen, saeule_id: saeuleA }), + }); + ok(a.status === 400, `fremde Säule beim Anlegen abgelehnt (${a.status})`); + console.log(" ->", (await jsn(a)).fehler); + + const eigen = await ruf("/workspace/api/bereich/content", kDogi, { + method: "POST", + body: JSON.stringify({ art: "idee", titel: "Für Ben, ohne Säule", datum: heute, creator_id: idBen }) }); + const bId = (await jsn(eigen)).id; + const nach = await ruf(`/workspace/api/bereich/content/${bId}`, kDogi, { + method: "PATCH", body: JSON.stringify({ saeule_id: saeuleA }) }); + ok(nach.status === 400, `und auch beim nachträglichen Anhängen (${nach.status})`); +} + +console.log("\n4) Videos durch die Strecke schieben"); +const ids = {}; +{ + const felder = [ + ["idee", "Erste Idee", null], + ["skript", "Mit Hook", "Ich hab 4 Monate gebraucht, um das zu kapieren."], + ["gedreht", "Abgedreht", "Das wusste ich vorher auch nicht."], + ["fertig", "Fertig geschnitten", "Guck bis zum Schluss."], + ]; + for (const [art, titel, hook] of felder) { + const a = await ruf("/workspace/api/bereich/content", kDogi, { + method: "POST", + body: JSON.stringify({ art, titel, hook, datum: heute, creator_id: idAnna, + saeule_id: saeuleA, format: "Tutorial", + geplant: art === "fertig" ? new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 10) : null }), + }); + ids[art] = (await jsn(a)).id; + ok(a.ok, `„${titel}" auf Stufe ${art} (${a.status})`); + } + /* Etwas Veroeffentlichtes fuer die Auswertung -- eines je Saeule. */ + for (const [s, n] of [[saeuleA, 6], [saeuleB, 2]]) { + for (let i = 0; i < n; i++) { + await ruf("/workspace/api/bereich/content", kDogi, { + method: "POST", + body: JSON.stringify({ art: "veroeffentlicht", titel: `Draußen ${s}-${i}`, + datum: heute, creator_id: idAnna, saeule_id: s }), + }); + } + } + const weiter = await ruf(`/workspace/api/bereich/content/${ids.idee}`, kDogi, { + method: "PATCH", body: JSON.stringify({ art: "skript" }) }); + ok(weiter.ok, `eine Stufe weiter geht (${weiter.status})`); + await ruf(`/workspace/api/bereich/content/${ids.idee}`, kDogi, { + method: "PATCH", body: JSON.stringify({ art: "idee" }) }); +} + +console.log("\n5) Die Kennzahlen"); +{ + const z = await jsn(await ruf(`/workspace/api/content/kennzahlen?creator=${idAnna}`, kDogi)); + console.log(" ->", JSON.stringify({ vorrat: z.vorrat, tage: z.reichweiteTage, + v30: z.veroeffentlicht30, ohneHook: z.ohneHook, ueberfaellig: z.ueberfaellig })); + /* Vorrat = gedreht + fertig. Der Alteintrag steht auch auf 'gedreht'. */ + ok(z.vorrat === 3, `Vorrat zählt nur Gedrehtes und Fertiges (${z.vorrat})`); + ok(z.stufen.idee === 1, `eine Idee (${z.stufen.idee})`); + ok(z.veroeffentlicht30 === 8, `8 in 30 Tagen veröffentlicht (${z.veroeffentlicht30})`); + ok(z.reichweiteTage !== null && z.reichweiteTage >= 10, + `daraus wird eine Reichweite in Tagen (${z.reichweiteTage})`); + /* Der Alteintrag hat keinen Hook und steht auf 'gedreht'. */ + ok(z.ohneHook === 1, `ein Video in Arbeit ohne Hook (${z.ohneHook})`); + ok(z.ueberfaellig === 0, `nichts überfällig (${z.ueberfaellig})`); + + const balance = z.balance.filter((b) => b.n > 0); + ok(balance.length === 2, `zwei Säulen mit Veröffentlichtem (${balance.length})`); + const tut = z.balance.find((b) => b.name === "Tutorials"); + ok(tut?.n === 6, `Tutorials: 6 (${tut?.n})`); + ok(tut?.ziel === 70, `mit Ziel 70 % (${tut?.ziel})`); +} + +console.log("\n6) Überfälliges wird erkannt"); +{ + await ruf("/workspace/api/bereich/content", kDogi, { + method: "POST", + body: JSON.stringify({ art: "fertig", titel: "Hätte längst raus sollen", hook: "x", + datum: heute, creator_id: idAnna, geplant: "2020-01-01" }) }); + const z = await jsn(await ruf(`/workspace/api/content/kennzahlen?creator=${idAnna}`, kDogi)); + ok(z.ueberfaellig === 1, `jetzt eines überfällig (${z.ueberfaellig})`); +} + +console.log("\n7) Überführen – der Kern aus dem Konzept"); +{ + const a = await ruf(`/workspace/api/content/${ids.fertig}/uebernehmen`, kDogi, { + method: "POST", body: JSON.stringify({ ziel: "aufgabe" }) }); + const j = await jsn(a); + ok(a.ok && j.art === "Aufgabe", `als Aufgabe übernommen (${a.status})`); + const auf = await jsn(await ruf("/workspace/api/aufgaben", kDogi)); + const neue = (auf.aufgaben || []).find((x) => x.id === j.id); + ok(!!neue, "die Aufgabe steht im Brett"); + ok(neue?.creator_id === idAnna, "und hängt am richtigen Creator"); + + const t = await ruf(`/workspace/api/content/${ids.fertig}/uebernehmen`, kDogi, { + method: "POST", body: JSON.stringify({ ziel: "termin" }) }); + ok(t.ok, `als Termin übernommen (${t.status})`); + + const l = await ruf(`/workspace/api/content/${ids.fertig}/uebernehmen`, kDogi, { + method: "POST", body: JSON.stringify({ ziel: "live" }) }); + ok(l.ok, `als LIVE-Thema übernommen (${l.status})`); + const live = await jsn(await ruf("/workspace/api/bereich/live", kDogi)); + ok((live.eintraege || []).some((e) => e.titel === "Fertig geschnitten"), "es steht in der LIVE-Analyse"); + + /* Das Video bleibt bestehen und traegt einen Vermerk -- sonst legt + beim naechsten Durchsehen jemand dieselbe Aufgabe noch einmal an. */ + const c = await jsn(await ruf("/workspace/api/bereich/content", kDogi)); + const video = c.eintraege.find((e) => e.id === ids.fertig); + ok(!!video, "das Video ist noch da"); + ok(/Übernommen als Aufgabe/.test(video?.text || ""), "und trägt einen Vermerk"); + + /* Ohne geplanten Zeitpunkt gibt es keinen Termin -- lieber eine klare + Absage als ein Termin auf gut Glueck. */ + const ohne = await ruf(`/workspace/api/content/${ids.idee}/uebernehmen`, kDogi, { + method: "POST", body: JSON.stringify({ ziel: "termin" }) }); + ok(ohne.status === 400, `ohne Zeitpunkt kein Termin (${ohne.status})`); + console.log(" ->", (await jsn(ohne)).fehler); +} + +console.log("\n8) Der Scout darf – für seine Creator"); +{ + const a = await ruf("/workspace/api/content/saeulen", kScout, { + method: "POST", body: JSON.stringify({ name: "Scout-Säule", creator_id: idBen }) }); + ok(a.status === 403, `nicht für einen fremden Creator (${a.status})`); + const u = await ruf(`/workspace/api/content/${ids.gedreht}/uebernehmen`, kScout, { + method: "POST", body: JSON.stringify({ ziel: "aufgabe" }) }); + ok(u.ok, `aber für Anna, die er betreut (${u.status})`); +} + +console.log("\n9) Der Creator sieht alles und ändert nichts"); +{ + const c = await jsn(await ruf("/workspace/api/bereich/content", kCreator)); + ok((c.eintraege || []).length >= 10, `sieht seine Videos (${(c.eintraege || []).length})`); + const s = await jsn(await ruf("/workspace/api/content/saeulen", kCreator)); + ok((s.saeulen || []).length === 5, `sieht seine Säulen (${(s.saeulen || []).length})`); + const z = await jsn(await ruf("/workspace/api/content/kennzahlen", kCreator)); + ok(typeof z.vorrat === "number", `sieht seine Kennzahlen (Vorrat ${z.vorrat})`); + + const neu = await ruf("/workspace/api/content/saeulen", kCreator, { + method: "POST", body: JSON.stringify({ name: "Meine", creator_id: idAnna }) }); + ok(neu.status === 403, `kann keine Säule anlegen (${neu.status})`); + const weg = await ruf(`/workspace/api/content/saeulen/${saeuleA}`, kCreator, { method: "DELETE" }); + ok(weg.status === 403, `keine löschen (${weg.status})`); + const ueb = await ruf(`/workspace/api/content/${ids.idee}/uebernehmen`, kCreator, { + method: "POST", body: JSON.stringify({ ziel: "aufgabe" }) }); + ok(ueb.status === 403, `nichts überführen (${ueb.status})`); +} + +console.log("\n10) Eine Säule löschen darf keine Arbeit löschen"); +{ + const vorher = (await jsn(await ruf("/workspace/api/bereich/content", kDogi))).eintraege.length; + const a = await ruf(`/workspace/api/content/saeulen/${saeuleB}`, kDogi, { method: "DELETE" }); + ok(a.ok, `Säule entfernt (${a.status})`); + const nachher = (await jsn(await ruf("/workspace/api/bereich/content", kDogi))).eintraege; + ok(nachher.length === vorher, `alle ${vorher} Videos sind noch da (${nachher.length})`); + ok(nachher.every((e) => e.saeule_id !== saeuleB), "sie hängen nur nicht mehr an der Säule"); +} + +console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/pruef-workspace-seiten.mjs b/server/pruef-workspace-seiten.mjs index d6ba456e..0b35b279 100644 --- a/server/pruef-workspace-seiten.mjs +++ b/server/pruef-workspace-seiten.mjs @@ -48,7 +48,7 @@ const browser = await chromium.launch(); const SEITEN = ["start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "personen.html", "profil.html", "wissen.html", "dateien.html", "calls.html", "startcheck.html", "report.html", "scouting.html", - "automation.html", "bereich.html?b=live"]; + "automation.html", "content.html", "bereich.html?b=live"]; let fehler = 0; for (const [breite, hoehe, wie] of [[1440, 1000, "Computer"], [390, 844, "Handy"]]) { diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index ca22b942..636cc85e 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -30,11 +30,28 @@ export const BEREICHE = { bewertung: true, // Review-Score aus dem Konzept, Seite 7 dringlichkeit: false, }, + /* Content ist der einzige Bereich mit einer STRECKE statt einer Liste: + Die Arten sind hier Stufen, die ein Video der Reihe nach durchlaeuft. + Grundlage ist der Satz aus dem Konzept ("Hook bis Upload") und die + Recherche vom 31.08.2026: Eine Redaktionsplanung ist eine Strecke, + kein Kalender -- ein Kalender voller Termine verbirgt genau die drei + Stellen, an denen es klemmt. + + Fuenf Stufen, nicht drei: Zwischen "Idee" und "Veroeffentlicht" lag + vorher alles, was den Unterschied macht. Und nicht mehr als fuenf -- + jede weitere Spalte ist eine, die auf dem Handy nicht mehr passt. */ content: { name: "Content-Planung", - arten: { idee: "Idee", produktion: "Produktion", veroeffentlicht: "Veröffentlicht" }, + arten: { + idee: "Idee", + skript: "Hook & Skript", + gedreht: "Gedreht", + fertig: "Fertig & geplant", + veroeffentlicht: "Veröffentlicht", + }, bewertung: false, dringlichkeit: false, + strecke: true, // hat eine Reihenfolge, nicht nur Kategorien }, technik: { name: "Technik", @@ -81,6 +98,47 @@ function gleicheHerkunft(req, res, next) { bereicheRouter.use("/workspace/api/bereich", angemeldet); +/* ---------- Die Bereiche sind fuer Creator zum LESEN da ---------------- + + Wunsch Filipe, 31.08.2026: "die creator sollen da nur die sehen die wir + ihnen eintragen, also dogfather manager und scout." + + Vorher durfte ein Creator hier selbst Eintraege anlegen, aendern und + die eigenen wieder loeschen. Das klingt harmlos, hebelt aber den Zweck + dieser Bereiche aus: Sie sind die Betreuungsakte -- LIVE-Auswertungen, + Technikbefunde, Konfliktverlaeufe, Schutzvorfaelle. Was dort steht, + muss der Stand der Betreuung sein und nicht das, was jemand ueber sich + selbst notiert hat. Ein Creator, der eine Auswertung umschreiben oder + eine unbequeme Notiz loeschen kann, macht die Akte wertlos. + + Der Creator SIEHT weiterhin alles, was zu ihm gehoert -- vollstaendig, + nichts wird ihm vorenthalten. Er kann es nur nicht veraendern. + + Bewusst 403 mit klarem Text und nicht 404: Der Bereich existiert fuer + ihn ja, er steht sogar davor. Ein 404 wuerde hier luegen. (Bei etwas, + das er gar nicht sehen darf, bleibt es weiterhin 404.) + + Steht als EINE Schranke vor allen schreibenden Wegen. Die Alternative + -- in jeder der drei Stellen einzeln pruefen -- ist genau die Bauweise, + durch die am 31.08.2026 schon einmal ein Loch entstanden ist: Zwei von + drei Stellen waren abgesichert, die dritte vergessen. */ +function nichtSchreibendFuerCreator(req, res, next) { + if (req.person?.rolle === "creator") { + return res.status(403).json({ + fehler: "Hier tragen deine Betreuer ein. Du siehst alles, was zu dir gehört.", + }); + } + next(); +} + +/* Express 4 -- dort ist der Platzhalter `*`, nicht `*name` wie in + Fassung 5. Mit der Fassung-5-Schreibweise haette der Pfad wortwoertlich + gepasst werden muessen und die Schranke waere NIE gegriffen: kein + Fehler, keine Warnung, nur eine Sperre, die stumm nichts tut. */ +for (const weg of ["post", "patch", "delete", "put"]) { + bereicheRouter[weg]("/workspace/api/bereich/*", nichtSchreibendFuerCreator); +} + /* Scouts haben mit der Creator-Betreuung nichts zu tun -- sie sehen hier nichts. Creator sehen ihren eigenen Bereich, das Management alles. */ export function sichtbar(person) { @@ -94,7 +152,20 @@ export function sichtbar(person) { const SPALTEN = ` e.id, e.bereich, e.art, e.titel, e.text, e.datum, e.bewertung, e.dringlichkeit, e.status, e.creator_id, e.erstellt, e.erstellt_von, e.geaendert, - pc.name AS creator_name, pe.name AS erstellt_name`; + e.hook, e.format, e.saeule_id, e.geplant, + pc.name AS creator_name, pe.name AS erstellt_name, + (SELECT name FROM content_saeulen s WHERE s.id = e.saeule_id) AS saeule_name, + (SELECT slot FROM content_saeulen s WHERE s.id = e.saeule_id) AS saeule_slot`; + +/* Passt die gewaehlte Saeule zu dem Creator, um den es geht? Gibt einen + Fehlertext zurueck oder null, wenn alles stimmt. */ +function saeulePasst(saeuleId, creatorId) { + if (!saeuleId) return null; + const s = db().prepare("SELECT creator_id FROM content_saeulen WHERE id = ?").get(saeuleId); + if (!s) return "Diese Säule gibt es nicht."; + if (creatorId && s.creator_id !== creatorId) return "Diese Säule gehört zu einem anderen Creator."; + return null; +} const VERBUND = ` FROM eintraege e @@ -175,6 +246,40 @@ function pruefe(bereich, körper, { neu }) { if (!DRINGLICHKEITEN.includes(körper.dringlichkeit)) fehler.push("Unbekannte Dringlichkeit."); else aus.dringlichkeit = körper.dringlichkeit; } + + /* Die vier Content-Felder gibt es nur in der Content-Planung. In jedem + anderen Bereich werden sie stillschweigend verworfen -- wie Bewertung + und Dringlichkeit auch. Sonst stuende irgendwann ein Aufhaenger an + einem Schutzvorfall. */ + if (bereich === "content") { + if (körper.hook !== undefined) { + const h = String(körper.hook ?? "").trim(); + if (h.length > 300) fehler.push("Der Hook ist zu lang."); + else aus.hook = h || null; + } + if (körper.format !== undefined) { + const f = String(körper.format ?? "").trim(); + if (f.length > 60) fehler.push("Das Format ist zu lang."); + else aus.format = f || null; + } + if (körper.saeule_id !== undefined) { + if (körper.saeule_id === null || körper.saeule_id === "") aus.saeule_id = null; + else { + const s = Number(körper.saeule_id); + if (!Number.isInteger(s) || s < 1) fehler.push("Unbekannte Säule."); + else aus.saeule_id = s; + } + } + if (körper.geplant !== undefined) { + const g = String(körper.geplant ?? "").trim(); + if (!g) aus.geplant = null; + /* Datum ODER Datum mit Uhrzeit -- wer nur den Tag weiss, soll nicht + zu einer erfundenen Uhrzeit gezwungen werden. */ + else if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/.test(g) || Number.isNaN(Date.parse(g))) { + fehler.push("Der geplante Zeitpunkt ist ungültig."); + } else aus.geplant = g; + } + } if (körper.creator_id !== undefined) { const w = körper.creator_id; if (w === null || w === "") aus.creator_id = null; @@ -218,14 +323,23 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re return res.status(400).json({ fehler: "Zugeordneter Creator existiert nicht." }); } + /* Eine Saeule gehoert immer GENAU EINEM Creator. Ohne diese Pruefung + koennte man ein Video an die Saeule eines fremden Creators haengen + -- und darueber in der Balance-Auswertung Namen sehen, die einen + nichts angehen. Die Sichtbarkeitsregel oben schuetzt die Eintraege, + nicht die Verweise zwischen ihnen. */ + const saeuleFehler = saeulePasst(aus.saeule_id, aus.creator_id); + if (saeuleFehler) return res.status(400).json({ fehler: saeuleFehler }); + const { lastInsertRowid } = db().prepare(` INSERT INTO eintraege (bereich, art, titel, text, datum, bewertung, dringlichkeit, status, - creator_id, erstellt, erstellt_von) - VALUES (?,?,?,?,?,?,?,?,?,?,?)`).run( + creator_id, erstellt, erstellt_von, hook, format, saeule_id, geplant) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( bereich, aus.art, aus.titel, aus.text ?? null, aus.datum, aus.bewertung ?? null, aus.dringlichkeit ?? "mittel", aus.status ?? "offen", - aus.creator_id ?? null, jetzt(), req.person.id); + aus.creator_id ?? null, jetzt(), req.person.id, + aus.hook ?? null, aus.format ?? null, aus.saeule_id ?? null, aus.geplant ?? null); protokolliere("eintrag_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), @@ -260,6 +374,14 @@ bereicheRouter.patch("/workspace/api/bereich/:bereich/:id", gleicheHerkunft, (re if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); if (!istLeitung(req.person)) delete aus.creator_id; + /* Beim Aendern gilt dieselbe Regel -- sonst waere die Pruefung beim + Anlegen wertlos: Man legt ohne Saeule an und haengt sie danach an. */ + if (aus.saeule_id !== undefined) { + const zuWem = aus.creator_id !== undefined ? aus.creator_id : eintrag.creator_id; + const saeuleFehler = saeulePasst(aus.saeule_id, zuWem); + if (saeuleFehler) return res.status(400).json({ fehler: saeuleFehler }); + } + const felder = Object.keys(aus); if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); diff --git a/server/workspace-content.js b/server/workspace-content.js new file mode 100644 index 00000000..adb60d2a --- /dev/null +++ b/server/workspace-content.js @@ -0,0 +1,403 @@ +/* ===================================================================== + workspace-content.js — was die Content-Planung ueber einen gewoehnlichen + Bereich hinaus braucht. + + Die Eintraege selbst bleiben in workspace-bereiche.js. Es gibt bewusst + NUR EINEN Schreiber fuer die Tabelle `eintraege` -- zwei Module, die + dieselben Zeilen anfassen, sind der sichere Weg zu zwei Wahrheiten. + + Hier liegt nur, was es woanders nicht gibt: + + 1. Saeulen -- die drei bis fuenf Themen, aus denen ein Kanal besteht. + 2. Die Kennzahlen der Strecke, allen voran der VORRAT. + 3. Das Ueberfuehren einer Idee in eine Aufgabe, einen Termin oder ein + LIVE-Thema. Das steht woertlich im Konzept ("Ideen lassen sich + direkt in Aufgabe/Termin/LIVE-Thema ueberfuehren") und war das + letzte unumgesetzte Stueck des Content-Moduls. + + Warum ausgerechnet der Vorrat die wichtigste Zahl ist: Die Recherche + vom 31.08.2026 ist an dieser Stelle einhellig -- der Algorithmus + belohnt Regelmaessigkeit staerker als Menge; ein Video pro Tag zur + gleichen Zeit schlaegt sieben Videos an einem Tag. Wer regelmaessig + senden will, braucht Vorrat. Und Vorrat ist die eine Zahl, die man + NICHT sieht, wenn man auf eine Liste von Ideen schaut. + ===================================================================== */ + +import express from "express"; +import { + db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, + istLeitung, +} from "./workspace.js"; + +export const contentRouter = express.Router(); + +const NAME_MAX = 40; +/* Drei bis fuenf Saeulen -- aus der Recherche. Weniger traegt keinen + Kanal, mehr kann niemand bespielen, und mehr als fuenf Farben lassen + sich in einem Balken ohnehin nicht mehr auseinanderhalten (die + Farbpruefung deckt genau fuenf ab). */ +const SAEULEN_MAX = 5; + +const jetzt = () => new Date().toISOString(); +const heute = () => jetzt().slice(0, 10); + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +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(); +} + +contentRouter.use("/workspace/api/content", angemeldet); + +/* Dieselbe Regel wie in den Bereichen: Der Creator liest, die Betreuung + schreibt. Steht als eine Schranke vor allen schreibenden Wegen. */ +for (const weg of ["post", "patch", "delete", "put"]) { + contentRouter[weg]("/workspace/api/content/*", (req, res, next) => { + if (req.person?.rolle === "creator") { + return res.status(403).json({ + fehler: "Hier trägt deine Betreuung ein. Du siehst alles, was zu dir gehört.", + }); + } + next(); + }); +} + +/* Welche Creator darf diese Person sehen? Gibt eine Liste von Nummern + zurueck -- oder null fuer "alle" (Leitung). */ +function sichtbareCreator(person) { + if (istLeitung(person)) return null; + if (person.rolle === "creator") return [person.id]; + return betreuteIds(person); +} + +function darfCreatorSehen(person, creatorId) { + const erlaubt = sichtbareCreator(person); + return erlaubt === null || erlaubt.includes(Number(creatorId)); +} + +/* ---------- Saeulen ---------------------------------------------------- */ + +contentRouter.get("/workspace/api/content/saeulen", (req, res) => { + try { + const erlaubt = sichtbareCreator(req.person); + let wo = "1=1", werte = []; + if (erlaubt !== null) { + if (!erlaubt.length) return res.json({ saeulen: [] }); + wo = `creator_id IN (${erlaubt.map(() => "?").join(",")})`; + werte = erlaubt; + } + const saeulen = db().prepare(` + SELECT s.id, s.creator_id, s.name, s.ziel, s.slot, + (SELECT name FROM personen p WHERE p.id = s.creator_id) AS creator_name, + (SELECT COUNT(*) FROM eintraege e + WHERE e.saeule_id = s.id AND e.bereich = 'content') AS videos + FROM content_saeulen s WHERE ${wo} + ORDER BY s.creator_id, s.slot, s.id`).all(...werte); + res.json({ saeulen, hoechstens: SAEULEN_MAX }); + } catch (fehler) { + console.error("[content] Säulen lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +contentRouter.post("/workspace/api/content/saeulen", gleicheHerkunft, (req, res) => { + try { + const name = String(req.body?.name ?? "").trim(); + if (name.length < 2) return res.status(400).json({ fehler: "Name fehlt." }); + if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Name ist zu lang." }); + + let creatorId = Number(req.body?.creator_id); + if (req.person.rolle === "scout" && !darfCreator(req.person, creatorId)) { + return res.status(403).json({ fehler: "Dieser Creator ist dir nicht zugeteilt." }); + } + if (!Number.isInteger(creatorId) + || !db().prepare("SELECT 1 FROM personen WHERE id = ? AND rolle = 'creator'").get(creatorId)) { + return res.status(400).json({ fehler: "Bitte einen Creator auswählen." }); + } + + const vorhanden = db().prepare( + "SELECT slot FROM content_saeulen WHERE creator_id = ? ORDER BY slot").all(creatorId); + if (vorhanden.length >= SAEULEN_MAX) { + return res.status(400).json({ + fehler: `Mehr als ${SAEULEN_MAX} Säulen kann niemand bespielen. Lieber eine schärfen als eine sechste anfangen.`, + }); + } + /* Doppelte Namen verhindern -- zwei Saeulen "Tutorials" waeren in + jeder Auswertung nebeneinander und niemand koennte sie trennen. */ + if (db().prepare( + "SELECT 1 FROM content_saeulen WHERE creator_id = ? AND lower(name) = lower(?)").get(creatorId, name)) { + return res.status(400).json({ fehler: "Diese Säule gibt es schon." }); + } + + /* Den kleinsten freien Farbplatz nehmen, nicht einfach den naechsten: + Nach dem Loeschen der zweiten von drei Saeulen bekaeme die naechste + sonst Platz 4, und Platz 2 bliebe fuer immer leer. Die Farbe folgt + damit der Saeule, nicht ihrem Alter. */ + const belegt = new Set(vorhanden.map((s) => s.slot)); + let slot = 1; + while (belegt.has(slot) && slot < SAEULEN_MAX) slot++; + + const ziel = req.body?.ziel === null || req.body?.ziel === undefined || req.body?.ziel === "" + ? null : Math.max(0, Math.min(100, Number(req.body.ziel) || 0)); + + const { lastInsertRowid } = db().prepare( + "INSERT INTO content_saeulen (creator_id, name, ziel, slot, erstellt) VALUES (?,?,?,?,?)") + .run(creatorId, name, ziel, slot, jetzt()); + + protokolliere("saeule_angelegt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: name.slice(0, 60), + }); + res.status(201).json({ id: Number(lastInsertRowid), slot }); + } catch (fehler) { + console.error("[content] Säule anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +contentRouter.patch("/workspace/api/content/saeulen/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + const s = db().prepare("SELECT * FROM content_saeulen WHERE id = ?").get(id); + if (!s || !darfCreatorSehen(req.person, s.creator_id)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const aus = {}; + if (req.body?.name !== undefined) { + const n = String(req.body.name).trim(); + if (n.length < 2 || n.length > NAME_MAX) return res.status(400).json({ fehler: "Name ist ungültig." }); + aus.name = n; + } + if (req.body?.ziel !== undefined) { + aus.ziel = req.body.ziel === null || req.body.ziel === "" + ? null : Math.max(0, Math.min(100, Number(req.body.ziel) || 0)); + } + const felder = Object.keys(aus); + if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); + db().prepare(`UPDATE content_saeulen SET ${felder.map((f) => `${f} = ?`).join(", ")} WHERE id = ?`) + .run(...felder.map((f) => aus[f]), id); + res.json({ ok: true }); + } catch (fehler) { + console.error("[content] Säule ändern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +contentRouter.delete("/workspace/api/content/saeulen/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + const s = db().prepare("SELECT * FROM content_saeulen WHERE id = ?").get(id); + if (!s || !darfCreatorSehen(req.person, s.creator_id)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + /* Die Videos bleiben, sie verlieren nur ihre Saeule. Eine Saeule zu + loeschen darf niemals Arbeit loeschen -- das waere eine + Aufraeumaktion mit Nebenwirkung, die niemand erwartet. */ + const betroffen = db().prepare( + "SELECT COUNT(*) AS n FROM eintraege WHERE saeule_id = ?").get(id)?.n ?? 0; + db().prepare("UPDATE eintraege SET saeule_id = NULL WHERE saeule_id = ?").run(id); + db().prepare("DELETE FROM content_saeulen WHERE id = ?").run(id); + + protokolliere("saeule_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${s.name} (${betroffen} Videos behalten)`.slice(0, 100), + }); + res.json({ ok: true, gelöst: betroffen }); + } catch (fehler) { + console.error("[content] Säule löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Kennzahlen der Strecke ------------------------------------- + + Bewusst wenige. Aus der Recherche zu Kennzahlen-Anzeigen: Eine Zahl + ohne Vergleich ist eine Eitelkeitszahl. Was hier steht, beantwortet je + eine Frage, auf die eine Handlung folgt. + ----------------------------------------------------------------------- */ + +contentRouter.get("/workspace/api/content/kennzahlen", (req, res) => { + try { + const erlaubt = sichtbareCreator(req.person); + let wo = "e.bereich = 'content'", werte = []; + if (erlaubt !== null) { + if (!erlaubt.length) return res.json({ leer: true }); + wo += ` AND e.creator_id IN (${erlaubt.map(() => "?").join(",")})`; + werte = erlaubt; + } + /* Auf einen Creator eingrenzen, wenn danach gefragt wird -- die + Balance einer Saeule ergibt nur je Creator einen Sinn. */ + const nur = Number(req.query.creator); + if (Number.isInteger(nur) && nur > 0) { + if (!darfCreatorSehen(req.person, nur)) return res.status(404).json({ fehler: "nicht_gefunden" }); + wo += " AND e.creator_id = ?"; + werte = [...werte, nur]; + } + + const d = db(); + const stufen = Object.fromEntries( + d.prepare(`SELECT e.art, COUNT(*) AS n FROM eintraege e WHERE ${wo} GROUP BY e.art`) + .all(...werte).map((r) => [r.art, r.n])); + + /* VORRAT: alles, was gedreht oder fertig ist und noch nicht draussen. + Das ist der Puffer, aus dem sich Regelmaessigkeit speist. Ideen und + Skripte zaehlen NICHT mit -- eine Idee ist kein Video. Genau diese + Unterscheidung macht die Zahl ehrlich; wer Ideen mitzaehlt, fuehlt + sich reich und steht am Donnerstag ohne Material da. */ + const vorrat = (stufen.gedreht || 0) + (stufen.fertig || 0); + + const vor30 = new Date(Date.now() - 30 * 86400_000).toISOString().slice(0, 10); + const veroeffentlicht30 = d.prepare( + `SELECT COUNT(*) AS n FROM eintraege e WHERE ${wo} AND e.art = 'veroeffentlicht' AND e.datum >= ?`) + .get(...werte, vor30)?.n ?? 0; + + /* Aus dem Tempo der letzten 30 Tage wird der Vorrat in TAGE + umgerechnet. "4 Videos uebrig" sagt wenig; "reicht noch 5 Tage" + sagt, ob man dieses Wochenende drehen muss. Ohne Verlauf keine + Umrechnung -- dann wird die Zahl weggelassen statt geraten. */ + const proTag = veroeffentlicht30 / 30; + const reichweiteTage = proTag > 0 ? Math.floor(vorrat / proTag) : null; + + /* Was ueberfaellig ist: geplant, aber der Zeitpunkt ist vorbei und es + steht immer noch nicht auf "veroeffentlicht". */ + const ueberfaellig = d.prepare( + `SELECT COUNT(*) AS n FROM eintraege e + WHERE ${wo} AND e.art <> 'veroeffentlicht' + AND e.geplant IS NOT NULL AND e.geplant < ?`).get(...werte, jetzt().slice(0, 16))?.n ?? 0; + + /* Ideen ohne Hook. Die Recherche ist eindeutig: Die ersten ein bis + drei Sekunden entscheiden ueber die Verbreitung. Eine Idee ohne + Aufhaenger ist eine halbe Idee -- und man sieht es ihr nicht an. */ + const ohneHook = d.prepare( + `SELECT COUNT(*) AS n FROM eintraege e + WHERE ${wo} AND e.art IN ('skript','gedreht','fertig') + AND (e.hook IS NULL OR trim(e.hook) = '')`).get(...werte)?.n ?? 0; + + /* Balance ueber die letzten 90 Tage -- kuerzer schwankt zu stark, + laenger reagiert nicht mehr auf eine Kursaenderung. Gezaehlt wird + nur VEROEFFENTLICHTES: Was auf dem Kanal steht, ist die Wahrheit + ueber seine Ausrichtung, nicht was in der Ideenliste liegt. */ + const vor90 = new Date(Date.now() - 90 * 86400_000).toISOString().slice(0, 10); + const balance = d.prepare(` + SELECT s.id, s.name, s.slot, s.ziel, COUNT(e.id) AS n + FROM content_saeulen s + LEFT JOIN eintraege e ON e.saeule_id = s.id AND e.bereich = 'content' + AND e.art = 'veroeffentlicht' AND e.datum >= ? + ${Number.isInteger(nur) && nur > 0 ? "WHERE s.creator_id = ?" : ""} + GROUP BY s.id ORDER BY s.slot`) + .all(...(Number.isInteger(nur) && nur > 0 ? [vor90, nur] : [vor90])); + + const ohneSaeule = d.prepare( + `SELECT COUNT(*) AS n FROM eintraege e + WHERE ${wo} AND e.art = 'veroeffentlicht' AND e.datum >= ? AND e.saeule_id IS NULL`) + .get(...werte, vor90)?.n ?? 0; + + res.json({ + stufen, vorrat, veroeffentlicht30, reichweiteTage, ueberfaellig, ohneHook, + balance, ohneSaeule, balanceTage: 90, + stand: jetzt(), + }); + } catch (fehler) { + console.error("[content] Kennzahlen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Ueberfuehren ------------------------------------------------ + + Woertlich aus dem Konzept: "Ideen lassen sich direkt in + Aufgabe/Termin/LIVE-Thema ueberfuehren." + + Der Sinn dahinter: Eine Idee, die man abtippen muss, um sie zu einer + Aufgabe zu machen, wird nicht abgetippt. Sie bleibt in der Liste + liegen und stirbt dort. + + Die Idee bleibt bewusst BESTEHEN. Sie wandert nicht weg, sie bekommt + ein Gegenstueck. Sonst waere die Strecke nach dem Ueberfuehren + loechrig, und man wuesste spaeter nicht mehr, woher die Aufgabe kam. + ----------------------------------------------------------------------- */ + +const ZIELE = new Set(["aufgabe", "termin", "live"]); + +contentRouter.post("/workspace/api/content/:id/uebernehmen", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + const ziel = String(req.body?.ziel || ""); + if (!Number.isInteger(id) || !ZIELE.has(ziel)) { + return res.status(400).json({ fehler: "ungueltig" }); + } + + const e = db().prepare( + "SELECT * FROM eintraege WHERE id = ? AND bereich = 'content'").get(id); + if (!e || !darfCreatorSehen(req.person, e.creator_id)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + /* Der Hook ist der Kern -- er wandert in die Beschreibung mit, sonst + steht die neue Aufgabe ohne das Wichtigste da. */ + const beschreibung = [ + e.hook ? `Hook: ${e.hook}` : null, + e.text || null, + e.format ? `Format: ${e.format}` : null, + `Aus der Content-Planung (#${e.id}).`, + ].filter(Boolean).join("\n\n"); + + let neu = null; + if (ziel === "aufgabe") { + const frist = e.geplant ? e.geplant.slice(0, 10) : null; + const r = db().prepare(` + INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id, frist, erstellt, erstellt_von) + VALUES (?,?,'offen','mittel',?,?,?,?)`).run( + e.titel.slice(0, 160), beschreibung, e.creator_id, frist, jetzt(), req.person.id); + neu = { art: "Aufgabe", id: Number(r.lastInsertRowid), wohin: "aufgaben.html" }; + + } else if (ziel === "termin") { + /* Ohne geplanten Zeitpunkt gibt es nichts einzutragen -- lieber + eine klare Absage als ein Termin auf gut Glueck. */ + if (!e.geplant) { + return res.status(400).json({ + fehler: "Für einen Termin braucht es einen geplanten Zeitpunkt am Video.", + }); + } + const beginn = e.geplant.length === 10 ? e.geplant + "T10:00" : e.geplant; + const r = db().prepare(` + INSERT INTO termine (titel, beschreibung, art, beginn, dauer_min, creator_id, erledigt, erstellt, erstellt_von) + VALUES (?,?,'termin',?,60,?,0,?,?)`).run( + ("Dreh: " + e.titel).slice(0, 160), beschreibung, beginn, e.creator_id, jetzt(), req.person.id); + neu = { art: "Termin", id: Number(r.lastInsertRowid), wohin: "kalender.html" }; + + } else { + const r = db().prepare(` + INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von) + VALUES ('live','vorbereitung',?,?,?,'offen',?,?,?)`).run( + e.titel.slice(0, 160), beschreibung, e.geplant ? e.geplant.slice(0, 10) : heute(), + e.creator_id, jetzt(), req.person.id); + neu = { art: "LIVE-Thema", id: Number(r.lastInsertRowid), wohin: "bereich.html?b=live" }; + } + + /* Am Video vermerken, dass es uebernommen wurde -- sonst legt beim + naechsten Durchsehen jemand dieselbe Aufgabe ein zweites Mal an. */ + const vermerk = `Übernommen als ${neu.art} (#${neu.id}) am ${heute().split("-").reverse().join(".")}.`; + db().prepare("UPDATE eintraege SET text = ?, geaendert = ? WHERE id = ?") + .run([e.text, vermerk].filter(Boolean).join("\n\n"), jetzt(), id); + + protokolliere("content_uebernommen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} → ${neu.art} #${neu.id}`.slice(0, 120), + }); + res.status(201).json(neu); + } catch (fehler) { + console.error("[content] Übernehmen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 57922a99..58a1cedd 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -136,6 +136,75 @@ function umstellungen(d) { console.error("[workspace] Spalte 'hochgeladen':", fehler?.message); } + /* ---- Content-Planung: aus der Liste wird eine Strecke ---------------- + + Die Content-Planung war eine flache Liste mit drei Arten. Die + Recherche vom 31.08.2026 ist an dieser Stelle eindeutig: *"A content + calendar for creators is a pipeline, not a datebook."* Und der + wichtigste Einzelwert eines Kurzvideos ist der Aufhaenger -- die + ersten ein bis drei Sekunden entscheiden ueber die Verbreitung. Der + gehoert in ein eigenes Feld und nicht irgendwo in den Fliesstext. + + Vier neue Spalten. ALTER TABLE ADD COLUMN laesst SQLite anstandslos + zu; alte Eintraege bleiben leer und stoeren nicht. */ + for (const [tabelle, spalte, typ] of [ + ["eintraege", "hook", "TEXT"], // die ersten Sekunden + ["eintraege", "format", "TEXT"], // Talking Head, Tutorial, Story … + ["eintraege", "saeule_id", "INTEGER"],// zu welcher Content-Saeule + ["eintraege", "geplant", "TEXT"], // wann es raus soll + ]) { + try { + const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); + if (vorhanden.length && !vorhanden.includes(spalte)) { + d.exec(`ALTER TABLE ${tabelle} ADD COLUMN ${spalte} ${typ}`); + console.log(`[workspace] Spalte '${spalte}' in ${tabelle} ergaenzt.`); + } + } catch (fehler) { + console.error(`[workspace] Spalte '${spalte}':`, fehler?.message); + } + } + + /* Content-Saeulen: die drei bis fuenf Themen, aus denen der Kanal + besteht. Aus der Recherche: 3-5 Saeulen nach der 70/20/10-Regel + (70 % Wert, 20 % Community, 10 % Eigenwerbung); eine Saeule wird + erst brauchbar, wenn Formate an ihr haengen, und einmal im Monat + schaut man nach, welche ihr Gewicht traegt. + + Je Creator eigene Saeulen -- ein Gaming-Kanal und ein Bastelkanal + haben nichts gemeinsam, eine gemeinsame Liste waere fuer beide + falsch. */ + try { + d.exec(` + CREATE TABLE IF NOT EXISTS content_saeulen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + name TEXT NOT NULL, + ziel INTEGER, -- gewuenschter Anteil in Prozent + slot INTEGER NOT NULL DEFAULT 1, -- Farbplatz 1..5, siehe content.css + erstellt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_saeulen_creator ON content_saeulen (creator_id); + `); + } catch (fehler) { + console.error("[workspace] content_saeulen:", fehler?.message); + } + + /* Die alte Art "produktion" wird zu "gedreht". Vorher gab es drei + Stufen (Idee, Produktion, Veroeffentlicht) -- dazwischen lag alles, + was in Wahrheit den Unterschied macht: Aufhaenger geschrieben, + abgedreht, geschnitten, terminiert. Ohne diese Umbenennung fielen + bestehende Eintraege aus jeder Spalte heraus und waeren unsichtbar. */ + try { + const betroffen = d.prepare( + "SELECT COUNT(*) AS n FROM eintraege WHERE bereich = 'content' AND art = 'produktion'").get()?.n ?? 0; + if (betroffen) { + d.prepare("UPDATE eintraege SET art = 'gedreht' WHERE bereich = 'content' AND art = 'produktion'").run(); + console.log(`[workspace] ${betroffen} Content-Eintraege von 'produktion' auf 'gedreht' umgestellt.`); + } + } catch (fehler) { + console.error("[workspace] Content-Arten:", fehler?.message); + } + /* ---- Rolle "manager" erlauben ---- */ const bauplan = d.prepare( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || ""; @@ -642,6 +711,7 @@ export const workspaceRouter = express.Router(); const GESCHUETZT = { "/workspace/start.html": null, "/workspace/uebersicht.html": null, + "/workspace/content.html": null, "/workspace/aufgaben.html": null, "/workspace/personen.html": ["admin", "manager"], "/workspace/profil.html": ["admin", "manager", "scout", "creator"], diff --git a/workspace/assets/css/content.css b/workspace/assets/css/content.css new file mode 100644 index 00000000..1c84b28d --- /dev/null +++ b/workspace/assets/css/content.css @@ -0,0 +1,315 @@ +/* =================================================================== + Content-Planung. + + Zwei Dinge bestimmen die Gestaltung: + + 1. Es ist eine STRECKE. Die Spalten stehen für Fortschritt von links + nach rechts, deshalb wird die Farbe der Spaltenköpfe von kühl + (Idee) nach warm-hell (draußen) heller — man sieht die Richtung, + ohne sie zu lesen. + + 2. Die Säulenfarben sind DATEN, keine Dekoration. Sie stammen aus + einer geprüften Palette und wurden gegen genau diesen Hintergrund + (#0a0d13) gerechnet: Helligkeitsband, Farbstärke, Abstand bei + Farbfehlsichtigkeit (schlechtestes Paar ΔE 8.4), Normalsicht- + Abstand (19.3) und Kontrast — alle Prüfungen bestanden. Deshalb + wird an diesen fünf Werten nichts „nachgebessert": Jede Änderung + macht die Prüfung ungültig. + + Augenschonend bleibt alles: gedeckte Töne, keine Leuchtflächen, keine + Bewegung außer weichen Übergängen. + =================================================================== */ + +.content-wurzel, +body.start { + /* Fünf Plätze für Content-Säulen. Die Zuordnung folgt dem Platz + (slot), nicht dem Rang -- sonst wechselt eine Säule die Farbe, + sobald eine andere sie überholt. */ + --saeule-1: #3987e5; /* Blau */ + --saeule-2: #d95926; /* Orange */ + --saeule-3: #199e70; /* Türkis */ + --saeule-4: #c98500; /* Gelb */ + --saeule-5: #d55181; /* Magenta */ + --saeule-0: #56657a; /* ohne Säule -- bewusst grau, keine sechste Farbe */ +} + +/* ---------- Kennzahlen ---------------------------------------------------- */ + +.kennzahlen { + display: grid; gap: 10px; margin-bottom: 26px; + grid-template-columns: 1.4fr repeat(4, 1fr); +} +.kachel { + display: grid; gap: 2px; align-content: start; + padding: 15px 17px; + border: 1px solid var(--rand); border-radius: var(--radius); + background: rgba(255, 255, 255, .028); +} +.kachel[data-gross="ja"] { padding: 17px 20px; } +.kachel[data-stufe="warn"] { + border-color: rgba(255, 154, 162, .3); background: rgba(255, 154, 162, .05); +} +.kachel__wert { + font-size: 1.7rem; font-weight: 700; line-height: 1.05; + font-variant-numeric: tabular-nums; letter-spacing: -.02em; +} +.kachel[data-gross="ja"] .kachel__wert { font-size: 2.6rem; } +.kachel[data-stufe="warn"] .kachel__wert { color: var(--warn); } +.kachel__name { + font-size: .72rem; font-weight: 700; letter-spacing: .07em; + text-transform: uppercase; color: var(--text-leise); +} +.kachel__zusatz { font-size: .76rem; color: var(--text-still); } + +/* ---------- Säulen-Balance ------------------------------------------------ */ + +.balance { + margin-bottom: 26px; padding: 16px 18px 14px; + border: 1px solid var(--rand); border-radius: var(--radius); + background: rgba(255, 255, 255, .022); +} + +/* Ein 2px-Spalt zwischen den Stücken statt einer Trennlinie: Die Lücke + zeigt die Grenze, ohne eine weitere Farbe einzuführen. */ +.balance__balken { + display: flex; gap: 2px; height: 30px; margin: 4px 0 12px; + border-radius: 6px; overflow: hidden; +} +.balance__stueck { + display: flex; align-items: center; justify-content: center; + min-width: 3px; transition: filter var(--tempo); +} +.balance__stueck:hover { filter: brightness(1.15); } +.balance__stueck[data-slot="1"] { background: var(--saeule-1); } +.balance__stueck[data-slot="2"] { background: var(--saeule-2); } +.balance__stueck[data-slot="3"] { background: var(--saeule-3); } +.balance__stueck[data-slot="4"] { background: var(--saeule-4); } +.balance__stueck[data-slot="5"] { background: var(--saeule-5); } +.balance__stueck[data-slot="0"] { background: var(--saeule-0); } +/* Die Zahl im Balken trägt Text-Tinte, nicht die Säulenfarbe -- Text in + der Serienfarbe ist auf jedem Untergrund ein Kontrastproblem. Auf + diesen fünf Flächen ist Dunkel der sichere Wert. */ +.balance__zahl { + font-size: .72rem; font-weight: 700; color: #06090f; + font-variant-numeric: tabular-nums; +} + +.legende { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 0; padding: 0; list-style: none; } +.legende__punkt { display: flex; align-items: center; gap: 7px; font-size: .82rem; } +.legende__farbe { + width: 10px; height: 10px; flex: none; border-radius: 3px; + background: var(--saeule-0); +} +.legende__farbe[data-slot="1"] { background: var(--saeule-1); } +.legende__farbe[data-slot="2"] { background: var(--saeule-2); } +.legende__farbe[data-slot="3"] { background: var(--saeule-3); } +.legende__farbe[data-slot="4"] { background: var(--saeule-4); } +.legende__farbe[data-slot="5"] { background: var(--saeule-5); } +.legende__name { color: var(--text); } +.legende__wert { color: var(--text-leise); font-variant-numeric: tabular-nums; } +.legende__ziel { color: var(--text-still); font-size: .76rem; } +.legende__ziel[data-warn="ja"] { color: var(--warn); } + +.balance__hinweis { margin: 10px 0 0; font-size: .78rem; color: var(--text-still); } + +/* ---------- Säulen verwalten ---------------------------------------------- */ + +.saeulen-kasten { + margin-bottom: 26px; padding: 18px 20px; + border: 1px solid var(--rand-hell); border-radius: var(--radius); + background: rgba(255, 255, 255, .03); +} +.saeule { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 9px 4px; border-bottom: 1px solid var(--rand); + font-size: .86rem; +} +.saeule:last-of-type { border-bottom: 0; } +.saeule__name { font-weight: 650; } +.saeule__wer, .saeule__zahl, .saeule__ziel { color: var(--text-still); font-size: .79rem; } +.saeule__zahl { margin-left: auto; } + +.saeule-neu { + display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; + margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--rand); +} +.saeule-neu .feld { flex: 1 1 200px; min-width: 0; } +.saeule-neu .feld--schmal { flex: 0 1 130px; } + +/* ---------- Filterzeile --------------------------------------------------- */ + +.filterzeile { display: flex; gap: 22px; flex-wrap: wrap; margin-bottom: 18px; } +.filtergruppe { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.filtergruppe__schild { + font-size: .68rem; font-weight: 700; letter-spacing: .07em; + text-transform: uppercase; color: var(--text-still); margin-right: 2px; +} + +/* ---------- Die Strecke --------------------------------------------------- */ + +.strecke { + display: grid; gap: 10px; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-items: start; +} + +.spalte { + min-width: 0; padding: 12px 12px 14px; + border: 1px solid var(--rand); border-radius: var(--radius); + background: rgba(255, 255, 255, .02); +} +/* Die Richtung der Strecke, ohne ein Wort: Der Streifen über der Spalte + wird von links nach rechts heller und wärmer. */ +.spalte::before { + content: ""; display: block; height: 2px; margin: -4px -4px 12px; + border-radius: 2px; opacity: .55; +} +.spalte[data-stufe="idee"]::before { background: #2c4a6b; opacity: .8; } +.spalte[data-stufe="skript"]::before { background: #2f6a97; opacity: .8; } +.spalte[data-stufe="gedreht"]::before { background: #3987e5; opacity: .7; } +.spalte[data-stufe="fertig"]::before { background: #4fb3d9; opacity: .7; } +.spalte[data-stufe="veroeffentlicht"]::before { background: #7fd4c1; opacity: .7; } + +.spalte__kopf { + display: flex; align-items: center; gap: 8px; margin-bottom: 10px; +} +.spalte__name { + margin: 0; font-size: .74rem; font-weight: 700; + letter-spacing: .06em; text-transform: uppercase; color: var(--text-leise); +} +.spalte__zahl { + margin-left: auto; min-width: 20px; padding: 1px 7px; text-align: center; + font-size: .72rem; font-weight: 700; color: var(--text-still); + background: rgba(255, 255, 255, .06); border-radius: 999px; + font-variant-numeric: tabular-nums; +} +.spalte__leer { margin: 6px 0 0; text-align: center; color: var(--text-still); opacity: .5; } + +/* ---------- Eine Video-Karte ---------------------------------------------- */ + +.video { + position: relative; overflow: hidden; + margin-bottom: 8px; padding: 11px 13px 10px; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: rgba(255, 255, 255, .035); + transition: border-color var(--tempo), background var(--tempo); +} +.video:last-child { margin-bottom: 0; } +.video:hover { border-color: var(--rand-hell); background: rgba(255, 255, 255, .055); } + +/* Die Säulenfarbe als schmale Kante links -- die Karte selbst bleibt + ruhig. Fünf eingefärbte Karten nebeneinander wären ein Bonbonglas. */ +.video[data-slot]::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: var(--saeule-0); +} +.video[data-slot="1"]::before { background: var(--saeule-1); } +.video[data-slot="2"]::before { background: var(--saeule-2); } +.video[data-slot="3"]::before { background: var(--saeule-3); } +.video[data-slot="4"]::before { background: var(--saeule-4); } +.video[data-slot="5"]::before { background: var(--saeule-5); } + +.video__titel { margin: 0 0 4px; font-size: .93rem; font-weight: 650; line-height: 1.35; } + +/* Der Hook ist gesprochener Text -- kursiv und in Anführungszeichen + liest er sich als das, was er ist. */ +.video__hook { + margin: 0 0 7px; font-size: .82rem; font-style: italic; + color: var(--text-leise); line-height: 1.45; +} +.video__hook--fehlt { + font-style: normal; font-size: .74rem; color: var(--warn); opacity: .75; +} + +.video__marken { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; } +.marke-saeule, .marke-still { + padding: 1px 8px; border-radius: 999px; + font-size: .69rem; font-weight: 600; +} +.marke-still { + color: var(--text-still); background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); +} +/* Der Säulenname trägt seine Farbe als Rand und schwachen Grund, nicht + als Textfarbe -- farbiger Text auf dunklem Grund wird bei den + dunkleren Plätzen schwer lesbar. */ +.marke-saeule { color: var(--text); border: 1px solid var(--saeule-0); background: rgba(255, 255, 255, .04); } +.marke-saeule[data-slot="1"] { border-color: var(--saeule-1); background: rgba(57, 135, 229, .14); } +.marke-saeule[data-slot="2"] { border-color: var(--saeule-2); background: rgba(217, 89, 38, .14); } +.marke-saeule[data-slot="3"] { border-color: var(--saeule-3); background: rgba(25, 158, 112, .14); } +.marke-saeule[data-slot="4"] { border-color: var(--saeule-4); background: rgba(201, 133, 0, .14); } +.marke-saeule[data-slot="5"] { border-color: var(--saeule-5); background: rgba(213, 81, 129, .14); } + +.video__termin { + margin: 0 0 7px; font-size: .75rem; color: var(--text-still); + font-variant-numeric: tabular-nums; +} +.video__termin[data-warn="ja"] { color: var(--warn); } + +.video__knoepfe { + display: flex; flex-wrap: wrap; gap: 4px; + padding-top: 8px; border-top: 1px solid var(--rand); +} +.tu-knopf { + padding: 3px 9px; + font: inherit; font-size: .72rem; font-weight: 600; + color: var(--text-still); cursor: pointer; + background: rgba(255, 255, 255, .04); + border: 1px solid var(--rand); border-radius: 999px; + transition: color var(--tempo), border-color var(--tempo), background var(--tempo); +} +.tu-knopf:hover:not(:disabled) { + color: var(--text); border-color: var(--rand-hell); background: rgba(255, 255, 255, .09); +} +.tu-knopf[data-haupt="ja"] { + color: var(--akzent); border-color: rgba(63, 189, 245, .35); + background: rgba(63, 189, 245, .08); +} +.tu-knopf[data-haupt="ja"]:hover:not(:disabled) { + color: #fff; border-color: rgba(63, 189, 245, .6); background: rgba(63, 189, 245, .16); +} +.tu-knopf:disabled { opacity: .45; cursor: default; } +.tu-knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } + +/* ---------- Schmale Schirme ------------------------------------------------ + + Fünf Spalten nebeneinander sind auf einem Handy je 60px breit -- der + Titel bräche dann nach jedem zweiten Buchstaben um. Deshalb werden aus + den Spalten untereinander liegende Abschnitte. Die Strecke bleibt + lesbar, sie läuft nur von oben nach unten statt von links nach rechts. + --------------------------------------------------------------------------- */ + +@media (max-width: 1100px) { + .strecke { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .kennzahlen { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .kachel[data-gross="ja"] { grid-column: span 3; } +} + +@media (max-width: 700px) { + .strecke { grid-template-columns: 1fr; } + .kennzahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .kachel[data-gross="ja"] { grid-column: span 2; } + .kachel[data-gross="ja"] .kachel__wert { font-size: 2.1rem; } + /* Leere Spalten kosten auf dem Handy nur Platz zum Scrollen. */ + .spalte:has(.spalte__leer) { display: none; } + .balance__balken { height: 26px; } + .saeule__zahl { margin-left: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .video, .tu-knopf, .balance__stueck { transition: none; } +} + +/* Der Aufklapper am Fuß einer zu langen Spalte. Bewusst unauffällig -- + er ist kein Ziel, sondern ein Ausweg. */ +.spalte__mehr { + display: block; width: 100%; margin-top: 6px; padding: 6px; + font: inherit; font-size: .74rem; font-weight: 600; + color: var(--text-still); cursor: pointer; + background: transparent; + border: 1px dashed var(--rand); border-radius: var(--radius-klein); + transition: color var(--tempo), border-color var(--tempo); +} +.spalte__mehr:hover { color: var(--akzent); border-color: rgba(63, 189, 245, .35); } +.spalte__mehr:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +@media (prefers-reduced-motion: reduce) { .spalte__mehr { transition: none; } } diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index a7175a42..cd68961b 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -13,6 +13,13 @@ macht nur die Personenverwaltung, und die prueft der Server. */ const LEITUNG = new Set(['admin', 'manager']); + /* Wer hier eintraegt: DogFather, Manager und Scout. Der Creator liest + nur mit -- diese Bereiche sind die Betreuungsakte, nicht sein + Notizbuch. Der Server setzt das ebenfalls durch (siehe + workspace-bereiche.js); hier geht es nur darum, ihm keine Knoepfe + hinzustellen, die ohnehin nur eine Absage bringen. */ + const darfEintragen = () => ich && ich.rolle !== 'creator'; + const $ = (id) => document.getElementById(id); const UNTERZEILE = { live: 'Vorbereitung, Mitschrift und Auswertung – LIVE systematisch verbessern.', @@ -84,6 +91,8 @@ if (e.erstellt_name) fuss.append(el('span', null, '· von ' + e.erstellt_name)); if (e.status === 'erledigt') fuss.append(el('span', null, '· erledigt')); + if (!darfEintragen()) { k.append(fuss); return k; } + const knoepfe = el('div', 'eintrag-knoepfe'); knoepfe.append(tuKnopf(e.status === 'offen' ? 'erledigt ▶' : '◀ wieder offen', async () => { const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, { @@ -152,6 +161,12 @@ /* ---------- Neuer Eintrag ---------------------------------------------- */ const umschalten = (auf) => { + /* Auch hier die Regel, nicht nur einmal beim Aufbau: Diese Funktion + ist die einzige Stelle, die das Formular je wieder sichtbar macht. + Sperrt man nur beim Laden, holt der naechste Aufruf den Knopf + zurueck -- und niemand faellt es auf, weil es der Alltagsweg nicht + ist. */ + if (!darfEintragen()) { $('neu').hidden = true; $('neu-oeffnen').hidden = true; return; } $('neu').hidden = !auf; $('neu-oeffnen').hidden = auf; if (auf) $('f-titel').focus(); }; @@ -219,6 +234,16 @@ $('titel').textContent = einstellung.name; $('unterzeile').textContent = UNTERZEILE[bereich] || ''; + /* Fuer den Creator verschwindet alles Schreibende -- und die + Unterzeile sagt, warum. Ein leerer Bereich ohne Erklaerung wirkt + kaputt; einer mit Erklaerung wirkt gedacht. */ + if (!darfEintragen()) { + $('neu-oeffnen').hidden = true; + $('neu').hidden = true; + $('unterzeile').textContent = + (UNTERZEILE[bereich] || '') + ' Hier trägt deine Betreuung ein – du siehst alles, was zu dir gehört.'; + } + for (const [wert, name] of Object.entries(einstellung.arten)) { const o = document.createElement('option'); o.value = wert; o.textContent = name; diff --git a/workspace/assets/js/content.js b/workspace/assets/js/content.js new file mode 100644 index 00000000..4d740e2a --- /dev/null +++ b/workspace/assets/js/content.js @@ -0,0 +1,583 @@ +/* =================================================================== + Content-Planung. + + Bekommt als einziger der sechs Bereiche eine eigene Seite. Grund: Die + anderen fuenf sind Sammlungen -- Eintraege zu einem Thema. Content ist + eine STRECKE. Ein Video wandert von der Idee bis zum Upload durch + feste Stufen, und die Frage ist nie "was gibt es alles", sondern "wo + klemmt es und wie viel liegt bereit". + + Aus der Recherche (31.08.2026): + - "A content calendar for creators is a pipeline, not a datebook" -- + eine Wand aus Terminen verbirgt genau die Stellen, an denen es hakt. + - Die ersten ein bis drei Sekunden entscheiden ueber die Verbreitung. + Der Hook steht deshalb auf jeder Karte, nicht im Fliesstext. + - Regelmaessigkeit schlaegt Menge. Ein Video taeglich zur selben Zeit + schlaegt sieben an einem Tag. Wer regelmaessig senden will, braucht + Vorrat -- und Vorrat ist die eine Zahl, die eine Ideenliste + verschweigt. + =================================================================== */ +(() => { + 'use strict'; + + const LEITUNG = new Set(['admin', 'manager']); + const darfEintragen = () => ich && ich.rolle !== 'creator'; + + const $ = (id) => document.getElementById(id); + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + const melde = (t) => { $('fehler').textContent = t || ''; }; + + let ich = null; + let stufen = {}; // schluessel -> Name, vom Server + let eintraege = []; + let saeulen = []; + let zahlen = null; + let filterCreator = ''; + let filterSaeule = ''; + + /* Wie viele Karten je Spalte hoechstens stehen, bevor gekuerzt wird. + Sechs, weil das die Hoehe der Nachbarspalten ungefaehr trifft -- + eine Spalte, die dreimal so lang ist wie die anderen, ist kein + Brett mehr, sondern eine Liste mit vier Randnotizen. */ + const SICHTBAR_MAX = 6; + const offeneSpalten = new Set(); + + 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 datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : ''); + const WOCHENTAG = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; + + function zeitpunkt(iso) { + if (!iso) return ''; + const [d, z] = String(iso).split('T'); + const [j, m, t] = d.split('-'); + const tag = new Date(Number(j), Number(m) - 1, Number(t)); + const w = Number.isNaN(tag.getTime()) ? '' : WOCHENTAG[tag.getDay()] + ', '; + return `${w}${t}.${m}.` + (z ? ' · ' + z.slice(0, 5) : ''); + } + + /* ---------- Kennzahlen --------------------------------------------------- + + Keine Diagramme. Das sind Einzelwerte mit einer Handlung dahinter -- + dafuer ist eine Kachel die richtige Form, kein Balken mit einem Balken. */ + + function kennzahlenZeigen() { + const k = $('kennzahlen'); + k.textContent = ''; + if (!zahlen || zahlen.leer) { k.hidden = true; return; } + + /* Der Vorrat ist die Leitzahl und steht deshalb als Erstes und + groesser. Er zaehlt NUR Gedrehtes und Fertiges -- eine Idee ist + kein Video. Wer Ideen mitzaehlt, fuehlt sich reich und steht am + Donnerstag ohne Material da. */ + const vorratText = zahlen.reichweiteTage === null + ? 'noch kein Tempo bekannt' + : zahlen.reichweiteTage === 0 + ? 'reicht keinen Tag mehr' + : `reicht noch etwa ${zahlen.reichweiteTage} Tag${zahlen.reichweiteTage === 1 ? '' : 'e'}`; + + const felder = [ + { wert: zahlen.vorrat, name: 'Vorrat', zusatz: vorratText, + stufe: zahlen.reichweiteTage !== null && zahlen.reichweiteTage < 3 ? 'warn' + : zahlen.vorrat === 0 ? 'warn' : 'gut', gross: true }, + { wert: zahlen.veroeffentlicht30, name: 'veröffentlicht', zusatz: 'in 30 Tagen' }, + { wert: (zahlen.stufen?.idee || 0), name: 'Ideen', zusatz: 'noch nicht angefasst' }, + { wert: zahlen.ohneHook, name: 'ohne Hook', zusatz: 'in Arbeit, aber ohne Aufhänger', + stufe: zahlen.ohneHook > 0 ? 'warn' : 'gut', ziel: 'hook' }, + { wert: zahlen.ueberfaellig, name: 'überfällig', zusatz: 'Termin vorbei, nicht draußen', + stufe: zahlen.ueberfaellig > 0 ? 'warn' : 'gut' }, + ]; + + for (const f of felder) { + const kachel = el('div', 'kachel'); + if (f.gross) kachel.dataset.gross = 'ja'; + if (f.stufe) kachel.dataset.stufe = f.stufe; + kachel.append(el('span', 'kachel__wert', String(f.wert))); + kachel.append(el('span', 'kachel__name', f.name)); + kachel.append(el('span', 'kachel__zusatz', f.zusatz)); + k.append(kachel); + } + k.hidden = false; + } + + /* ---------- Säulen-Balance ----------------------------------------------- + + Ein liegender gestapelter Balken: Teil vom Ganzen ueber wenige + Kategorien. Farben nach Platz (slot), nicht nach Rang -- sonst + wechselt eine Saeule die Farbe, sobald eine andere sie ueberholt. + Bis zu fuenf Farben, geprueft gegen Farbfehlsichtigkeit auf genau + dieser Hintergrundfarbe. Immer mit Legende UND Beschriftung: Farbe + allein darf nie die einzige Information sein. */ + + function balanceZeigen() { + const kasten = $('balance'); + if (!zahlen || zahlen.leer || !zahlen.balance) { kasten.hidden = true; return; } + + const teile = zahlen.balance.filter((b) => b.n > 0); + const gesamt = teile.reduce((s, b) => s + b.n, 0) + (zahlen.ohneSaeule || 0); + + $('balance-zeit').textContent = `· letzte ${zahlen.balanceTage} Tage, nur Veröffentlichtes`; + + const balken = $('balance-balken'); + const legende = $('balance-legende'); + balken.textContent = ''; + legende.textContent = ''; + + if (!gesamt) { + kasten.hidden = !saeulen.length; + $('balance-hinweis').textContent = saeulen.length + ? 'Noch nichts veröffentlicht – sobald etwas draußen ist, steht hier die Verteilung.' + : ''; + return; + } + + const stuecke = [...teile]; + if (zahlen.ohneSaeule) { + stuecke.push({ id: 0, name: 'ohne Säule', slot: 0, n: zahlen.ohneSaeule, ziel: null }); + } + + for (const s of stuecke) { + const anteil = Math.round((s.n / gesamt) * 100); + const stueck = el('span', 'balance__stueck'); + stueck.dataset.slot = String(s.slot); + stueck.style.width = (s.n / gesamt) * 100 + '%'; + stueck.title = `${s.name}: ${s.n} von ${gesamt} (${anteil} %)`; + /* Beschriftung direkt im Balken, sobald Platz ist. Bei schmalen + Stuecken traegt die Legende die Information. */ + if (anteil >= 12) stueck.append(el('span', 'balance__zahl', anteil + ' %')); + balken.append(stueck); + + const li = el('li', 'legende__punkt'); + const punkt = el('span', 'legende__farbe'); + punkt.dataset.slot = String(s.slot); + li.append(punkt); + li.append(el('span', 'legende__name', s.name)); + li.append(el('span', 'legende__wert', `${anteil} %`)); + if (s.ziel !== null && s.ziel !== undefined) { + const ab = anteil - s.ziel; + const marke = el('span', 'legende__ziel', + `Ziel ${s.ziel} %${ab === 0 ? '' : (ab > 0 ? ` · ${ab} über` : ` · ${-ab} unter`)}`); + if (Math.abs(ab) > 15) marke.dataset.warn = 'ja'; + li.append(marke); + } + legende.append(li); + } + + $('balance-hinweis').textContent = zahlen.ohneSaeule + ? `${zahlen.ohneSaeule} veröffentlichte Videos hängen an keiner Säule – dann sagt die Verteilung nur die halbe Wahrheit.` + : ''; + kasten.hidden = false; + } + + /* ---------- Die Strecke -------------------------------------------------- */ + + function karte(e) { + const k = el('article', 'video'); + if (e.saeule_slot) k.dataset.slot = String(e.saeule_slot); + + k.append(el('h3', 'video__titel', e.titel)); + + /* Der Hook steht direkt unter dem Titel und in Anfuehrungszeichen -- + er ist gesprochener Text, kein Etikett. Fehlt er, steht das da: + eine Luecke, die man sieht, wird gefuellt. */ + if (e.hook) { + k.append(el('p', 'video__hook', '„' + e.hook + '"')); + } else if (e.art !== 'idee' && e.art !== 'veroeffentlicht') { + k.append(el('p', 'video__hook video__hook--fehlt', 'Noch kein Hook')); + } + + const marken = el('div', 'video__marken'); + if (e.saeule_name) { + const m = el('span', 'marke-saeule', e.saeule_name); + m.dataset.slot = String(e.saeule_slot || 0); + marken.append(m); + } + if (e.format) marken.append(el('span', 'marke-still', e.format)); + if (LEITUNG.has(ich.rolle) && e.creator_name) marken.append(el('span', 'marke-still', e.creator_name)); + if (marken.children.length) k.append(marken); + + if (e.geplant) { + const vorbei = e.art !== 'veroeffentlicht' && e.geplant < new Date().toISOString().slice(0, 16); + const z = el('p', 'video__termin', (vorbei ? 'war fällig ' : 'geplant ') + zeitpunkt(e.geplant)); + if (vorbei) z.dataset.warn = 'ja'; + k.append(z); + } + + if (!darfEintragen()) return k; + + const knoepfe = el('div', 'video__knoepfe'); + const reihe = Object.keys(stufen); + const jetztIndex = reihe.indexOf(e.art); + + if (jetztIndex > 0) { + knoepfe.append(tuKnopf('◀', 'Eine Stufe zurück', () => stufeSetzen(e, reihe[jetztIndex - 1]))); + } + if (jetztIndex >= 0 && jetztIndex < reihe.length - 1) { + const weiter = tuKnopf(stufen[reihe[jetztIndex + 1]] + ' ▶', + 'Auf die nächste Stufe', () => stufeSetzen(e, reihe[jetztIndex + 1])); + weiter.dataset.haupt = 'ja'; + knoepfe.append(weiter); + } + + /* Ueberfuehren -- woertlich aus dem Konzept. Nur solange das Video + noch nicht draussen ist; danach gibt es nichts mehr zu planen. */ + if (e.art !== 'veroeffentlicht') { + knoepfe.append(tuKnopf('→ Aufgabe', 'Als Aufgabe ins Brett', () => uebernehmen(e, 'aufgabe'))); + if (e.geplant) knoepfe.append(tuKnopf('→ Termin', 'Als Drehtermin in den Kalender', () => uebernehmen(e, 'termin'))); + knoepfe.append(tuKnopf('→ LIVE', 'Als LIVE-Thema vormerken', () => uebernehmen(e, 'live'))); + } + knoepfe.append(tuKnopf('löschen', 'Dieses Video entfernen', async () => { + if (!confirm(`„${e.titel}" wirklich löschen?`)) return; + const a = await hole(`/workspace/api/bereich/content/${e.id}`, { method: 'DELETE' }); + if (!a.ok) { melde('Löschen hat nicht geklappt.'); return; } + await laden(); + })); + k.append(knoepfe); + return k; + } + + function tuKnopf(text, titel, tun) { + const b = el('button', 'tu-knopf', text); + b.type = 'button'; + b.title = titel; + b.addEventListener('click', async () => { + b.disabled = true; + try { await tun(); } catch { /* umgeleitet */ } finally { b.disabled = false; } + }); + return b; + } + + async function stufeSetzen(e, neueStufe) { + const daten = { art: neueStufe }; + /* Was veroeffentlicht wird, bekommt das heutige Datum -- sonst + stuende die Auswertung der letzten 30 Tage auf dem Tag, an dem die + Idee entstand, und waere durchgehend falsch. */ + if (neueStufe === 'veroeffentlicht') daten.datum = new Date().toISOString().slice(0, 10); + const a = await hole(`/workspace/api/bereich/content/${e.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(daten), + }); + if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } + melde(''); + await laden(); + } + + async function uebernehmen(e, ziel) { + const a = await hole(`/workspace/api/content/${e.id}/uebernehmen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ziel }), + }); + const d = await a.json().catch(() => ({})); + if (!a.ok) { melde(d.fehler || 'Ging nicht.'); return; } + melde(''); + /* Sagen, wohin es gewandert ist -- und den Weg dorthin anbieten. + Eine stille Uebernahme laesst einen ratlos zurueck. */ + $('fehler').textContent = ''; + const hinweis = $('fehler'); + hinweis.textContent = `Als ${d.art} angelegt. `; + const link = el('a', null, 'Ansehen →'); + link.href = d.wohin; + hinweis.append(link); + await laden(); + } + + function streckeZeigen() { + const ziel = $('strecke'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + let sichtbar = eintraege; + if (filterCreator) sichtbar = sichtbar.filter((e) => String(e.creator_id) === filterCreator); + if (filterSaeule === 'ohne') sichtbar = sichtbar.filter((e) => !e.saeule_id); + else if (filterSaeule) sichtbar = sichtbar.filter((e) => String(e.saeule_id) === filterSaeule); + + for (const [schluessel, name] of Object.entries(stufen)) { + const drin = sichtbar.filter((e) => e.art === schluessel); + const spalte = el('section', 'spalte'); + spalte.dataset.stufe = schluessel; + + const kopf = el('div', 'spalte__kopf'); + kopf.append(el('h2', 'spalte__name', name)); + kopf.append(el('span', 'spalte__zahl', String(drin.length))); + spalte.append(kopf); + + if (!drin.length) { + spalte.append(el('p', 'spalte__leer', '–')); + } else { + /* Die letzte Spalte waechst ewig -- nach einem halben Jahr + stehen dort hundert Videos und schieben alles andere aus dem + Bild. Genau daran scheitert jedes Brett mit einer + "Fertig"-Spalte. Sichtbar bleibt deshalb nur die jüngste + Handvoll; der Rest ist einen Klick entfernt. + + Die vorderen Spalten bleiben ungekürzt: Was dort liegt, ist + Arbeit, und Arbeit darf man nicht wegklappen. */ + const kuerzen = !offeneSpalten.has(schluessel) && drin.length > SICHTBAR_MAX; + const zeigen = kuerzen ? drin.slice(0, SICHTBAR_MAX) : drin; + for (const e of zeigen) spalte.append(karte(e)); + + if (drin.length > SICHTBAR_MAX) { + const rest = drin.length - SICHTBAR_MAX; + const b = el('button', 'spalte__mehr', + kuerzen ? `${rest} weitere zeigen` : `nur die letzten ${SICHTBAR_MAX}`); + b.type = 'button'; + b.addEventListener('click', () => { + if (offeneSpalten.has(schluessel)) offeneSpalten.delete(schluessel); + else offeneSpalten.add(schluessel); + streckeZeigen(); + }); + spalte.append(b); + } + } + ziel.append(spalte); + } + } + + /* ---------- Filter ------------------------------------------------------- */ + + function filterBauen() { + const zeile = $('filterzeile'); + zeile.textContent = ''; + + const creator = [...new Map(eintraege.filter((e) => e.creator_id) + .map((e) => [e.creator_id, e.creator_name])).entries()]; + + if (LEITUNG.has(ich.rolle) && creator.length > 1) { + zeile.append(knopfGruppe('Creator', [['', 'Alle'], ...creator.map(([id, n]) => [String(id), n])], + filterCreator, (w) => { filterCreator = w; filterBauen(); streckeZeigen(); kennzahlenLaden(); })); + } + if (saeulen.length) { + const passend = filterCreator + ? saeulen.filter((s) => String(s.creator_id) === filterCreator) : saeulen; + zeile.append(knopfGruppe('Säule', + [['', 'Alle'], ...passend.map((s) => [String(s.id), s.name]), ['ohne', 'ohne Säule']], + filterSaeule, (w) => { filterSaeule = w; filterBauen(); streckeZeigen(); })); + } + } + + function knopfGruppe(name, werte, aktiv, beiWahl) { + const g = el('div', 'filtergruppe'); + g.append(el('span', 'filtergruppe__schild', name)); + for (const [wert, text] of werte) { + const b = el('button', 'filter', text); + b.type = 'button'; + if (wert === aktiv) b.dataset.aktiv = 'ja'; + b.addEventListener('click', () => beiWahl(wert)); + g.append(b); + } + return g; + } + + /* ---------- Säulen verwalten --------------------------------------------- */ + + function saeulenZeigen() { + const ziel = $('saeulen-liste'); + ziel.textContent = ''; + if (!saeulen.length) { + ziel.append(el('p', 'leise', 'Noch keine Säule. Fang mit drei an.')); + return; + } + for (const s of saeulen) { + const z = el('div', 'saeule'); + const punkt = el('span', 'legende__farbe'); + punkt.dataset.slot = String(s.slot); + z.append(punkt); + z.append(el('span', 'saeule__name', s.name)); + if (LEITUNG.has(ich.rolle) && s.creator_name) { + z.append(el('span', 'saeule__wer', s.creator_name)); + } + z.append(el('span', 'saeule__zahl', `${s.videos} Video${s.videos === 1 ? '' : 's'}`)); + z.append(el('span', 'saeule__ziel', s.ziel === null ? 'kein Ziel' : `Ziel ${s.ziel} %`)); + /* Auch hier: Ein Creator bekommt gar keinen Knopf erst gebaut. Der + Kasten ist fuer ihn zwar ausgeblendet -- aber ein Knopf, der nur + unsichtbar ist, steht trotzdem im Dokument, wird von Vorlese- + programmen gefunden und laesst sich mit der Tastatur ansteuern. + Ausgeblendet ist nicht dasselbe wie nicht vorhanden. */ + if (!darfEintragen()) { ziel.append(z); continue; } + z.append(tuKnopf('löschen', 'Säule entfernen, Videos bleiben', async () => { + if (!confirm(`Säule „${s.name}" entfernen? Die Videos bleiben, sie verlieren nur die Zuordnung.`)) return; + const a = await hole(`/workspace/api/content/saeulen/${s.id}`, { method: 'DELETE' }); + if (!a.ok) { melde('Ging nicht.'); return; } + await laden(); + })); + ziel.append(z); + } + } + + /* ---------- Laden -------------------------------------------------------- */ + + async function kennzahlenLaden() { + try { + const p = filterCreator ? `?creator=${encodeURIComponent(filterCreator)}` : ''; + const a = await hole('/workspace/api/content/kennzahlen' + p); + if (!a.ok) return; + zahlen = await a.json(); + kennzahlenZeigen(); + balanceZeigen(); + } catch { /* umgeleitet */ } + } + + async function laden() { + try { + const [bA, sA] = await Promise.all([ + hole('/workspace/api/bereich/content'), + hole('/workspace/api/content/saeulen'), + ]); + if (!bA.ok) { location.assign('/workspace/start.html'); return; } + const b = await bA.json(); + stufen = b.einstellung.arten; + eintraege = b.eintraege || []; + saeulen = sA.ok ? (await sA.json()).saeulen || [] : []; + } catch { return; } + + filterBauen(); + streckeZeigen(); + saeulenZeigen(); + saeulenAuswahlFuellen(); + await kennzahlenLaden(); + } + + function saeulenAuswahlFuellen() { + const feld = $('f-saeule'); + const vorher = feld.value; + feld.textContent = ''; + const leer = document.createElement('option'); + leer.value = ''; leer.textContent = '— keine Säule —'; + feld.append(leer); + const passend = LEITUNG.has(ich.rolle) && $('f-creator')?.value + ? saeulen.filter((s) => String(s.creator_id) === $('f-creator').value) + : saeulen; + for (const s of passend) { + const o = document.createElement('option'); + o.value = String(s.id); o.textContent = s.name; + feld.append(o); + } + feld.value = vorher; + feld.dispatchEvent(new Event('wahl:auffrischen')); + } + + /* ---------- Formular ----------------------------------------------------- */ + + const umschalten = (auf) => { + if (!darfEintragen()) { $('neu').hidden = true; $('neu-oeffnen').hidden = true; return; } + $('neu').hidden = !auf; $('neu-oeffnen').hidden = auf; + if (auf) $('f-titel').focus(); + }; + $('neu-oeffnen').addEventListener('click', () => umschalten(true)); + $('neu-abbrechen').addEventListener('click', () => { + $('neu').reset(); $('neu-fehler').textContent = ''; umschalten(false); + }); + + $('neu').addEventListener('submit', async (e) => { + e.preventDefault(); + $('neu-fehler').textContent = ''; + const daten = { + titel: $('f-titel').value.trim(), + hook: $('f-hook').value.trim(), + art: $('f-art').value, + format: $('f-format').value.trim(), + saeule_id: $('f-saeule').value || null, + geplant: $('f-geplant').value || null, + text: $('f-text').value.trim(), + datum: new Date().toISOString().slice(0, 10), + }; + if (LEITUNG.has(ich.rolle)) daten.creator_id = $('f-creator').value || null; + + const a = await hole('/workspace/api/bereich/content', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(daten), + }); + if (!a.ok) { + $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + $('neu').reset(); umschalten(false); + await laden(); + }); + + $('saeulen-oeffnen').addEventListener('click', () => { $('saeulen-kasten').hidden = false; }); + $('saeulen-zu').addEventListener('click', () => { $('saeulen-kasten').hidden = true; }); + + $('saeule-neu').addEventListener('submit', async (e) => { + e.preventDefault(); + const daten = { + name: $('s-name').value.trim(), + ziel: $('s-ziel').value === '' ? null : Number($('s-ziel').value), + creator_id: LEITUNG.has(ich.rolle) ? $('s-creator').value : undefined, + }; + const a = await hole('/workspace/api/content/saeulen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(daten), + }); + if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } + melde(''); + $('saeule-neu').reset(); + await laden(); + }); + + /* ---------- Start -------------------------------------------------------- */ + + (async () => { + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + ich = await a.json(); + } catch { return; } + $('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle); + + if (!darfEintragen()) { + $('neu-oeffnen').hidden = true; + $('neu').hidden = true; + $('saeulen-oeffnen').hidden = true; + $('unterzeile').textContent = + 'Von der Idee bis zum Upload. Hier plant deine Betreuung – du siehst alles, was zu dir gehört.'; + } + + /* Die Stufen kommen vom Server, nicht aus dieser Datei. Stuenden sie + hier, waeren sie beim naechsten Mal an einer Stelle geaendert und + an der anderen nicht. */ + try { + const b = await (await hole('/workspace/api/bereich/content')).json(); + stufen = b.einstellung.arten; + for (const [wert, name] of Object.entries(stufen)) { + const o = document.createElement('option'); + o.value = wert; o.textContent = name; + $('f-art').append(o); + } + } catch { /* laden() faengt es gleich noch einmal */ } + + if (LEITUNG.has(ich.rolle)) { + $('feld-creator').hidden = false; + $('feld-saeule-creator').hidden = false; + try { + const { personen } = await (await hole('/workspace/api/personen')).json(); + for (const p of personen.filter((p) => p.rolle === 'creator')) { + for (const feld of ['f-creator', 's-creator']) { + const o = document.createElement('option'); + o.value = String(p.id); o.textContent = p.name; + $(feld).append(o); + } + } + } catch { /* Auswahl bleibt leer */ } + /* Wechselt der Creator, passen die Säulen dazu -- sonst böte die + Liste Säulen an, die zu jemand anderem gehören, und der Server + lehnte beim Speichern ab. */ + $('f-creator').addEventListener('change', saeulenAuswahlFuellen); + } + + await laden(); + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 00471f8e..85eb096f 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -34,7 +34,7 @@ { name: 'Kalender', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'kalender.html' }, { name: 'Dateien', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'dateien.html' }, { name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=live' }, - { name: 'Content-Planung', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=content' }, + { name: 'Content-Planung', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'content.html' }, { name: 'Technik', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=technik' }, { name: 'Community', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=community' }, { name: 'Schutz & Regeln', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=schutz' }, diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 08f98aaf..f06536fe 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -134,8 +134,8 @@ - - - + + + diff --git a/workspace/automation.html b/workspace/automation.html index 2825268b..8ff1df05 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -127,8 +127,8 @@ - - - + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index e1b84935..fdf67649 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -98,8 +98,8 @@ - - - + + + diff --git a/workspace/calls.html b/workspace/calls.html index 2afedbb2..0f3c8197 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -49,9 +49,9 @@ - - - - + + + + diff --git a/workspace/content.html b/workspace/content.html new file mode 100644 index 00000000..6fd27e99 --- /dev/null +++ b/workspace/content.html @@ -0,0 +1,160 @@ + + + + + +Content-Planung · Creator Workspace + + + + + + + + + + + +
+ +

Creator Workspace · Content

+
+ … + +
+
+ +
+ +
+
+

Betreuung

+

Content-Planung

+

+ Von der Idee bis zum Upload – eine Strecke, kein Terminkalender. +

+
+ +
+ + + + + + + + + + + + + + + +
+ + +
+

Wird geladen …

+
+ +
+ + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index 260d4aba..5ecc7cc9 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -88,8 +88,8 @@ - - - + + + diff --git a/workspace/index.html b/workspace/index.html index 59af40d2..501d5e83 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 1a466df7..e55166c0 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -94,8 +94,8 @@ - - - + + + diff --git a/workspace/personen.html b/workspace/personen.html index fd060f39..e12aa97e 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -104,8 +104,8 @@ - - - + + + diff --git a/workspace/profil.html b/workspace/profil.html index 60ab69d6..3d79a814 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -127,8 +127,8 @@ - - - + + + diff --git a/workspace/report.html b/workspace/report.html index 58cfd3ac..82563b31 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -86,9 +86,9 @@ - - - - + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 4efa4704..7cdf4ae0 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,8 +124,8 @@ - - - + + + diff --git a/workspace/start.html b/workspace/start.html index af80e4bf..6fe8b9d8 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -56,8 +56,8 @@

- - - + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 775cf274..e29e3e95 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -65,9 +65,9 @@ - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 05594c95..9c5e2790 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -52,7 +52,7 @@ - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index f2383bd4..030e9725 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -170,8 +170,8 @@ - - - + + +