diff --git a/pruef-handy.png b/pruef-handy.png index bd836b28..b7e5166d 100644 Binary files a/pruef-handy.png and b/pruef-handy.png differ diff --git a/pruef-vorlagen.png b/pruef-vorlagen.png new file mode 100644 index 00000000..04933b21 Binary files /dev/null and b/pruef-vorlagen.png differ diff --git a/server/index.js b/server/index.js index 82c1960b..eee04fcf 100644 --- a/server/index.js +++ b/server/index.js @@ -31,6 +31,7 @@ import { kiRouter } from "./workspace-ki.js"; import { contentRouter } from "./workspace-content.js"; import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js"; import { steckbriefRouter } from "./workspace-steckbrief.js"; +import { vorlagenRouter } from "./workspace-vorlagen.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -96,6 +97,7 @@ app.use(sicherungRouter); JSON in einem ergibt nur ein kaputtes Bild ohne jeden Hinweis. Der Steckbrief bringt seine eigene Schranke mit. */ app.use(steckbriefRouter); +app.use(vorlagenRouter); app.use(aufgabenRouter); app.use(personenRouter); app.use(profilRouter); diff --git a/server/pruef-vorlagen.mjs b/server/pruef-vorlagen.mjs new file mode 100644 index 00000000..5eefdacc --- /dev/null +++ b/server/pruef-vorlagen.mjs @@ -0,0 +1,282 @@ +/* Prueft die fertigen Vorlagen und was daraus wird. + + Der Kern ist nicht "es gibt Vorlagen", sondern was beim UEBERNEHMEN + passiert: Aus einem Vorschlag muss ein ganz normaler Eintrag werden -- + aenderbar, loeschbar, und ab dann unabhaengig von der Vorlage. + + Und "Hilfe anfordern" muss eine AUFGABE erzeugen, keine Nachricht. + Eine Nachricht ist gelesen und dann weg; eine Aufgabe bleibt stehen, + bis sie jemand erledigt. Geprueft wird deshalb, dass sie beim + richtigen Betreuer landet und eine Frist hat. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-vorl-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4204"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4204"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 250_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); +d.close(); + +const keks = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); +}; +const kDogi = await keks("admin", "CODE-DOGI-0001"); +const kTili = await keks("creator", "CODE-ANNA-0001"); +const hole = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); + +/* ---------- Was es gibt --------------------------------------------------- */ +console.log("\n=== Die Vorlagen"); +let vorlagen; +{ + const a = await hole("/workspace/api/vorlagen", kTili); + vorlagen = await a.json(); + ok(a.ok, "die Vorlagen sind abrufbar"); + ok(vorlagen.content.length >= 12, `genug Content-Ideen (${vorlagen.content.length})`); + ok(vorlagen.live.vorbereitung.length >= 6, `Vorbereitungspunkte (${vorlagen.live.vorbereitung.length})`); + ok(vorlagen.live.mitschrift.length >= 5, `Punkte fuer waehrend LIVE (${vorlagen.live.mitschrift.length})`); + ok(vorlagen.live.auswertung.length >= 5, `Auswertungspunkte (${vorlagen.live.auswertung.length})`); + + /* Jede Content-Idee braucht einen ausformulierten Aufhaenger -- die + ersten Sekunden entscheiden, und "mach was Persoenliches" hilft + niemandem. */ + const ohneHook = vorlagen.content.filter((v) => !v.hook || v.hook.length < 15); + ok(ohneHook.length === 0, + `jede Idee hat einen ausformulierten Aufhaenger (${ohneHook.length} ohne)`); + const ohneFormat = vorlagen.content.filter((v) => !v.format); + ok(ohneFormat.length === 0, `und ein Format (${ohneFormat.length} ohne)`); + + /* 70/20/10: der groesste Teil muss Inhalte mit Wert sein. */ + const zaehl = {}; + for (const v of vorlagen.content) zaehl[v.saeule] = (zaehl[v.saeule] || 0) + 1; + const anteilWert = zaehl.Wert / vorlagen.content.length; + ok(anteilWert >= 0.45, + `der groesste Teil sind Inhalte mit Wert (${Math.round(anteilWert * 100)} %: ${JSON.stringify(zaehl)})`); + + /* Punkte, bei denen man Hilfe braucht, muessen als solche erkennbar + sein -- sonst weiss niemand, wo er fragen darf. */ + const mitHilfe = Object.values(vorlagen.live).flat().filter((v) => v.hilfe); + ok(mitHilfe.length >= 5, `Punkte mit Hilfe-Angebot (${mitHilfe.length})`); +} + +/* ---------- Uebernehmen --------------------------------------------------- */ +console.log("\n=== Uebernehmen"); +{ + /* Ein Creator uebernimmt fuer sich selbst -- ohne auf jemanden zu warten. */ + const a = await hole("/workspace/api/vorlagen/uebernehmen", kTili, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ art: "content", nr: 0 }) }); + const r = await a.json(); + ok(a.ok && r.angelegt === 1, `ein Creator darf selbst uebernehmen (${r.angelegt})`); + + const liste = await (await hole("/workspace/api/bereich/content", kTili)).json(); + const drin = liste.eintraege.find((e) => e.titel === vorlagen.content[0].titel); + ok(!!drin, `die Idee steht jetzt in seiner Liste ("${drin?.titel}")`); + /* Der Aufhaenger muss MITKOMMEN -- sonst ist die halbe Vorlage weg. */ + ok(drin?.hook === vorlagen.content[0].hook, `mit dem Aufhaenger (${drin?.hook?.slice(0, 30)}…)`); + ok(drin?.format === vorlagen.content[0].format, `und dem Format (${drin?.format})`); + ok(drin?.art === "idee", `und beginnt bei "Idee" (${drin?.art})`); + + /* Sie ist ab jetzt ein NORMALER Eintrag: aenderbar. Das ist der + eigentliche Punkt an Vorlagen gegenueber fest eingebauten Daten. */ + const p = await hole(`/workspace/api/bereich/content/${drin.id}`, kTili, { + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ titel: "Selbst umbenannt" }) }); + ok(p.ok, `und laesst sich aendern (${p.status})`); + + /* Alle auf einmal. */ + const b = await hole("/workspace/api/vorlagen/uebernehmen", kTili, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ art: "live_alle", unterart: "vorbereitung" }) }); + const rb = await b.json(); + ok(b.ok && rb.angelegt === vorlagen.live.vorbereitung.length, + `alle Vorbereitungspunkte auf einmal (${rb.angelegt})`); + + const live = await (await hole("/workspace/api/bereich/live", kTili)).json(); + ok(live.eintraege.filter((e) => e.art === "vorbereitung").length === rb.angelegt, + "und sie stehen alle im Bereich LIVE"); +} + +/* Ein Betreuer uebernimmt fuer SEINEN Creator -- und nur fuer den. */ +console.log("\n=== Wer fuer wen"); +{ + const fremd = anlegenFremd(); + const a = await hole("/workspace/api/vorlagen/uebernehmen", kDogi, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ art: "content", nr: 1, creator_id: idTili }) }); + ok(a.ok, `DogFather darf fuer Tili uebernehmen (${a.status})`); + + const kScout = await keks("scout", "CODE-SCOU-0001"); + const b = await hole("/workspace/api/vorlagen/uebernehmen", kScout, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ art: "content", nr: 2, creator_id: fremd }) }); + ok(b.status === 404, + `ein Scout NICHT fuer einen fremden Creator -- und erfaehrt nicht, dass es ihn gibt (${b.status})`); +} +function anlegenFremd() { + const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync("CODE-FREM-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d2.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run("Fremd", "creator", hash, salt, 32768, jetzt); + const id = d2.prepare("SELECT last_insert_rowid() AS id").get().id; + d2.close(); + return id; +} + +/* ---------- Hilfe anfordern ----------------------------------------------- */ +console.log("\n=== Hilfe anfordern"); +{ + const a = await hole("/workspace/api/vorlagen/hilfe", kTili, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ thema: "Ton geprüft", notiz: "Mein Mikro rauscht." }) }); + ok(a.ok, `ein Creator kann Hilfe anfordern (${a.status})`); + + /* Es muss eine AUFGABE geworden sein, nicht eine Nachricht. */ + const d3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); + const auf = d3.prepare( + "SELECT * FROM aufgaben WHERE titel LIKE 'Hilfe gewünscht%' ORDER BY id DESC LIMIT 1").get(); + d3.close(); + ok(!!auf, "daraus wird eine Aufgabe"); + ok(auf?.verantwortlich_id === idScout, + `sie geht an seinen Betreuer Patrick (${auf?.verantwortlich_id} = ${idScout})`); + ok(auf?.creator_id === idTili, "und haengt an ihm"); + ok(auf?.prioritaet === "hoch", `mit hoher Prioritaet (${auf?.prioritaet})`); + /* Ohne Frist bleibt sie liegen -- das ist der Unterschied zwischen + einer Aufgabe und einem Zettel. */ + ok(!!auf?.frist, `und einer Frist (${auf?.frist})`); + ok(String(auf?.beschreibung || "").includes("Mein Mikro rauscht"), + "die Notiz steht mit drin"); + + /* Der Betreuer sieht sie auf seiner Startseite. */ + const kScout = await keks("scout", "CODE-SCOU-0001"); + /* Sie steht in SEINER Aufgabenliste. Als Hinweis auf der Startseite + erscheint sie erst, wenn die Frist naeher rueckt -- eine Aufgabe mit + drei Tagen Zeit ist weder ueberfaellig noch heute faellig, und das + ist richtig so. Ein erster Anlauf hat genau das geprueft und war + rot, obwohl das Verhalten stimmte. */ + const seine = await (await hole("/workspace/api/aufgaben", kScout)).json(); + const dabei = (seine.aufgaben || []).some((x) => String(x.titel).startsWith("Hilfe gewünscht")); + ok(dabei, `und steht in seiner Aufgabenliste (${(seine.aufgaben || []).length} Aufgaben)`); + + /* Ohne Thema geht nichts -- eine leere Bitte hilft niemandem. */ + const leer = await hole("/workspace/api/vorlagen/hilfe", kTili, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ thema: "" }) }); + ok(leer.status === 400, `ohne Thema wird abgelehnt (${leer.status})`); +} + +/* ---------- Im Browser ---------------------------------------------------- */ +console.log("\n=== Die Seite"); +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); +{ + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); + const konsole = []; + const seite = await kontext.newPage(); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="creator"]'); + await seite.fill("#code", "CODE-ANNA-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + + for (const [seiteName, art] of [["content.html", "Ideen"], ["bereich.html?b=live", "Punkte"]]) { + await seite.goto(BASIS + "/workspace/" + seiteName, { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + const r = await seite.evaluate(() => ({ + da: !document.getElementById("vorlagen")?.hidden, + knopf: document.querySelector(".vorlagen__kopf .knopf")?.textContent?.trim(), + offen: document.querySelector(".vorlagen__raster")?.hidden === false, + anzahl: document.querySelectorAll(".vorlage").length, + })); + ok(r.da, `${seiteName}: der Vorlagenblock steht da`); + ok(/Vorschläge zeigen/.test(r.knopf || ""), ` mit Aufklappknopf ("${r.knopf}")`); + ok(r.offen === false, " und ist zunächst zu"); + ok(r.anzahl > 0, ` ${r.anzahl} Vorschläge dahinter`); + + /* Aufklappen muss wirklich etwas zeigen. */ + await seite.click(".vorlagen__kopf .knopf"); + await seite.waitForTimeout(350); + const auf = await seite.evaluate(() => ({ + offen: document.querySelector(".vorlagen__raster")?.hidden === false, + sichtbar: [...document.querySelectorAll(".vorlage")].filter((v) => v.offsetParent !== null).length, + hooks: document.querySelectorAll(".vorlage__hook").length, + })); + ok(auf.offen && auf.sichtbar > 0, ` aufgeklappt sind ${auf.sichtbar} zu sehen`); + if (art === "Ideen") { + ok(auf.hooks === auf.sichtbar, ` und jede zeigt ihren Aufhänger (${auf.hooks})`); + } + } + + /* Übernehmen über die Oberfläche -- nicht nur über die Schnittstelle. */ + await seite.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + await seite.click(".vorlagen__kopf .knopf"); + await seite.waitForTimeout(300); + /* Die Karten heissen dort .video -- ein erster Anlauf suchte nach + ".karte" und zaehlte deshalb immer null. */ + const vorher = await seite.evaluate(() => document.querySelectorAll(".video").length); + await seite.click(".vorlage .schritt"); + await seite.waitForTimeout(1200); + const nachher = await seite.evaluate(() => ({ + karten: document.querySelectorAll(".video").length, + genommen: document.querySelectorAll('.vorlage[data-genommen="ja"]').length, + })); + ok(nachher.karten > vorher, `Übernehmen legt wirklich an (${vorher} → ${nachher.karten})`); + ok(nachher.genommen === 1, `und die Vorlage ist als übernommen markiert (${nachher.genommen})`); + + ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); + await seite.screenshot({ path: "pruef-vorlagen.png", fullPage: false }); + await kontext.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 636cc85e..4f29d4f9 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -41,7 +41,10 @@ export const BEREICHE = { 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", + /* Umbenannt am 01.09.2026: "Planung" klang nach Terminen und + Tabellen. Was hier wirklich passiert, ist das Sammeln und + Weiterentwickeln von IDEEN -- der Kalender daneben plant. */ + name: "Content-Ideen", arten: { idee: "Idee", skript: "Hook & Skript", @@ -122,13 +125,56 @@ bereicheRouter.use("/workspace/api/bereich", angemeldet); -- 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. */ +/* Ein Creator schreibt hier nicht -- MIT EINER AUSNAHME, die am + 01.09.2026 dazukam: Was er SELBST angelegt hat, darf er auch + aendern und loeschen. + + Die urspruengliche Regel ("hier tragen deine Betreuer ein") bleibt + damit unangetastet: Er kann weiterhin nichts an dem aendern, was + ueber ihn geschrieben wurde -- keine Bewertung, keine Notiz, keinen + Eintrag seines Managers. + + Noetig wurde die Ausnahme durch die fertigen Vorlagen: Wenn ein + Creator sich eine Content-Idee uebernimmt, ist das SEINE Idee. Sie + danach nicht umbenennen zu duerfen waere absurd -- er haette einen + Eintrag erzeugt, den nur ein anderer bearbeiten kann. + + Entscheidend ist erstellt_von, nicht creator_id: Ein Eintrag, den + sein Betreuer ueber ihn angelegt hat, traegt zwar seine creator_id, + gehoert ihm aber nicht. */ function nichtSchreibendFuerCreator(req, res, next) { - if (req.person?.rolle === "creator") { + if (req.person?.rolle !== "creator") return next(); + + /* Neu anlegen bleibt gesperrt -- der Weg dafuer ist "Vorlage + uebernehmen", und der prueft eigene Regeln. */ + /* NUR im Bereich content. Das ist der Bereich, in dem der Creator + selbst arbeitet -- seine Ideen, sein Material. LIVE, Technik, + Community und Schutz sind die BETREUUNGSAKTE: Dort schreiben die + Betreuer ueber ihn, und daran aendert er nichts, auch nicht an + etwas, das frueher einmal von ihm stammte. Das war am 31.08.2026 + ausdruecklich so entschieden und bleibt so. + + Ein erster Anlauf hatte die Ausnahme fuer ALLE Bereiche erlaubt -- + die Pruefung pruef-bereiche-lesend hat das sofort gemeldet. */ + const bereich = String(req.path).split("/")[4] || ""; + if (bereich !== "content") { return res.status(403).json({ fehler: "Hier tragen deine Betreuer ein. Du siehst alles, was zu dir gehört.", }); } - next(); + + const id = Number(String(req.path).split("/").pop()); + if (Number.isInteger(id) && id > 0) { + try { + const e = db().prepare("SELECT erstellt_von FROM eintraege WHERE id = ?").get(id); + if (e && e.erstellt_von === req.person.id) return next(); + } catch { /* faellt unten auf die Sperre zurueck */ } + } + + return res.status(403).json({ + fehler: "Das hat deine Betreuung eingetragen – ändern können sie es. " + + "Was du selbst angelegt hast, kannst du bearbeiten.", + }); } /* Express 4 -- dort ist der Platzhalter `*`, nicht `*name` wie in diff --git a/server/workspace-vorlagen.js b/server/workspace-vorlagen.js new file mode 100644 index 00000000..a94a076f --- /dev/null +++ b/server/workspace-vorlagen.js @@ -0,0 +1,353 @@ +/* ===================================================================== + workspace-vorlagen.js — fertige Inhalte, die man übernehmen kann. + + Der Wunsch vom 01.09.2026, an fünf Stellen gleichlautend: "ich will + dass da schon fertige Sachen stehen". Eine leere Seite mit einem + Knopf "Neuer Eintrag" verlangt vom Creator genau das, was er noch + nicht kann — zu wissen, was überhaupt hineingehört. + + WARUM VORLAGEN UND KEINE VORAUSGEFÜLLTEN DATEN. + Man könnte beim Anlegen eines Creators dreißig Einträge in seine + Datenbank schreiben. Das wäre falsch: + * Sie wären ab dem ersten Tag "seine" Einträge -- und damit + Altlast, sobald sich etwas ändert. + * Ändert Filipe später eine Formulierung, gilt sie nur für neue + Creator. Die alten behalten die alte für immer. + * Und die Liste sähe voll aus, obwohl noch nichts geschehen ist -- + genau das Gegenteil einer ehrlichen Übersicht. + + Vorlagen bleiben deshalb VORSCHLÄGE, bis jemand sie übernimmt. Sie + stehen im Code, gelten für alle sofort, und werden erst dann zu Daten, + wenn sie tatsächlich gebraucht werden. Wer sie übernimmt, bekommt + einen ganz normalen Eintrag, den er ändern und löschen kann. + + FACHLICHE GRUNDLAGE der Inhalte: + * Die ersten ein bis drei Sekunden eines Kurzvideos entscheiden über + die Verbreitung -- deshalb hat jede Content-Vorlage einen Hook, + und zwar als eigenes Feld. + * Ein LIVE steht und fällt mit dem, was VOR der Sendung passiert. + Deshalb sind die Vorbereitungspunkte die längste Liste. + * Was während der Sendung auffällt, ist am nächsten Tag vergessen. + Deshalb gibt es Punkte für "während LIVE", die man in Sekunden + anklicken kann, statt sie zu tippen. + * 70/20/10: etwa 70 % Inhalte mit Wert, 20 % Community, 10 % + Eigenwerbung. Die Content-Vorlagen sind danach verteilt. + ===================================================================== */ + +import express from "express"; +import { + db, sitzungLesen, istLeitung, darfCreator, protokolliere, echteIp, +} from "./workspace.js"; + +export const vorlagenRouter = express.Router(); + +const jetzt = () => new Date().toISOString(); + +/* ===================================================================== + CONTENT-IDEEN + + Jede Vorlage bringt mit, was ein Video braucht, bevor man es dreht: + einen Aufhänger, ein Format und die Säule, zu der es gehört. Der + Aufhänger ist ausformuliert, nicht beschrieben -- "mach was + Persönliches" hilft niemandem, "Das hätte ich am Anfang gern gewusst" + kann man sagen. + ===================================================================== */ + +export const CONTENT_IDEEN = [ + /* ---- Wert (70 %) ---------------------------------------------------- */ + { titel: "Das hätte ich am Anfang gern gewusst", + hook: "„Ich hab drei Monate gebraucht, um das zu kapieren – du brauchst 30 Sekunden.\"", + format: "Talking Head", saeule: "Wert", + text: "Ein Fehler, den du selbst gemacht hast, und was du heute anders machst. " + + "Konkret bleiben: ein Fehler, eine Lösung. Wer drei Dinge sagt, sagt nichts." }, + { titel: "Mein Setup für unter 100 €", + hook: "„Alle sagen, du brauchst teure Technik. Hier ist meins für 87 Euro.\"", + format: "Zeigen & Erklären", saeule: "Wert", + text: "Was du wirklich benutzt, mit Preisen. Ehrlich sein, wo es hakt – " + + "das ist der Teil, den niemand sonst zeigt." }, + { titel: "Drei Einstellungen, die sofort was bringen", + hook: "„Wenn dein Stream unscharf aussieht, liegt es an einer davon.\"", + format: "Bildschirmaufnahme", saeule: "Wert", + text: "Nicht mehr als drei. Jede in unter 15 Sekunden erklärt, mit Vorher/Nachher." }, + { titel: "Was ich in einer Woche gelernt habe", + hook: "„Sieben Tage, eine Erkenntnis – die hier hat alles verändert.\"", + format: "Talking Head", saeule: "Wert", + text: "Rückblick auf eine echte Woche. Funktioniert nur mit einer konkreten Zahl " + + "oder einem konkreten Moment." }, + { titel: "Antwort auf die Frage, die immer kommt", + hook: "„Das werde ich jeden Tag gefragt – hier einmal in Ruhe.\"", + format: "Talking Head", saeule: "Wert", + text: "Aus dem Chat sammeln, welche Frage sich wiederholt. Die Antwort einmal " + + "gut aufnehmen und immer wieder verwenden." }, + { titel: "So bereite ich einen Stream vor", + hook: "„Zwei Stunden vor LIVE mache ich immer dasselbe.\"", + format: "Vlog", saeule: "Wert", + text: "Der Ablauf hinter der Sendung. Zeigt Verlässlichkeit und gibt Anfängern " + + "eine Vorlage." }, + { titel: "Fehler, die ich bei anderen sehe", + hook: "„Bitte mach das nicht – ich sehe es jeden Tag.\"", + format: "Talking Head", saeule: "Wert", + text: "Niemanden bloßstellen. Das Verhalten beschreiben, nicht die Person." }, + + /* ---- Community (20 %) ------------------------------------------------ */ + { titel: "Der beste Moment aus dem letzten LIVE", + hook: "„Das ist gestern passiert, und ich rede immer noch davon.\"", + format: "Zusammenschnitt", saeule: "Community", + text: "Ein Ausschnitt, keine Zusammenfassung. Wer alles zeigt, zeigt nichts." }, + { titel: "Ich beantworte eure Kommentare", + hook: "„Ihr habt gefragt – jetzt rede ich.\"", + format: "Talking Head", saeule: "Community", + text: "Drei bis fünf echte Kommentare, sichtbar eingeblendet. Auch die kritischen." }, + { titel: "Danke an die, die immer da sind", + hook: "„Ihr seid seit dem ersten Stream dabei. Das hier ist für euch.\"", + format: "Talking Head", saeule: "Community", + text: "Namen nennen, wenn die Leute einverstanden sind. Sonst allgemein bleiben – " + + "aber ehrlich." }, + { titel: "Eine Entscheidung, bei der ihr mitredet", + hook: "„Ich kann mich nicht entscheiden. Ihr habt 24 Stunden.\"", + format: "Umfrage", saeule: "Community", + text: "Echte Wahl, keine Scheinfrage. Das Ergebnis danach auch wirklich umsetzen." }, + + /* ---- Eigenwerbung (10 %) --------------------------------------------- */ + { titel: "Wann und wo ich streame", + hook: "„Immer dienstags und donnerstags – und ich fange pünktlich an.\"", + format: "Kurzclip", saeule: "Eigenwerbung", + text: "Fester Sendeplan als eigener Clip. Wiederholbar, sobald sich etwas ändert." }, + { titel: "Was diese Woche ansteht", + hook: "„Drei Sachen, und eine davon habe ich noch nie gemacht.\"", + format: "Kurzclip", saeule: "Eigenwerbung", + text: "Vorschau auf die Woche. Kurz halten, sonst wird es eine Ankündigung " + + "statt einer Einladung." }, +]; + +/* ===================================================================== + LIVE-PUNKTE + + Drei Listen entlang der Sendung. Die mittlere ist die wichtigste und + zugleich die, die es sonst nirgends gibt: Was während des Streams + auffällt, ist am nächsten Tag weg. Deshalb sind diese Punkte so + formuliert, dass man sie in einem Moment anklicken kann, in dem man + eigentlich keine Zeit hat. + + `hilfe: true` heißt: Der Creator kann das anklicken und sagen, dass + er dabei Unterstützung braucht -- daraus wird eine Aufgabe für seinen + Betreuer. + ===================================================================== */ + +export const LIVE_PUNKTE = { + vorbereitung: [ + { titel: "Ton geprüft", text: "Eine Minute aufnehmen und anhören. Ton ist der Grund Nummer eins, warum Leute wieder gehen.", hilfe: true }, + { titel: "Licht sitzt", text: "Gesicht heller als der Hintergrund. Kein Fenster im Rücken.", hilfe: true }, + { titel: "Verbindung getestet", text: "Upload-Geschwindigkeit geprüft, Kabel statt WLAN wenn möglich.", hilfe: true }, + { titel: "Titel und Thema stehen", text: "Wer den Titel liest, weiß, was ihn erwartet – und ob es für ihn ist.", hilfe: false }, + { titel: "Ablauf grob im Kopf", text: "Drei Blöcke reichen. Ein Stream ohne roten Faden fühlt sich für Zuschauer wie Warten an.", hilfe: false }, + { titel: "Angekündigt", text: "Mindestens einmal vorher gesagt, dass es losgeht – Story, Post oder Nachricht.", hilfe: false }, + { titel: "Moderation geklärt", text: "Weiß jemand Bescheid, der eingreifen kann, wenn es im Chat kippt?", hilfe: true }, + { titel: "Technik-Ersatz bereit", text: "Was passiert, wenn der Stream abbricht? Handy als Notlösung bereitlegen.", hilfe: true }, + ], + mitschrift: [ + { titel: "Guter Moment – aufheben", text: "Etwas ist gut gelaufen. Kurz notieren, woran es lag – daraus wird später ein Clip.", hilfe: false }, + { titel: "Hier sind Leute gegangen", text: "Zeitpunkt merken. Später nachsehen, was in dem Moment passiert ist.", hilfe: false }, + { titel: "Frage aus dem Chat, die wiederkommt", text: "Wird oft gefragt? Dann gehört sie in ein eigenes Video.", hilfe: false }, + { titel: "Technik hat gehakt", text: "Ton weg, Bild eingefroren, Verzögerung – aufschreiben, solange man es noch weiß.", hilfe: true }, + { titel: "Unangenehme Situation im Chat", text: "Was war es, wie ist es ausgegangen? Auch wenn es gut lief.", hilfe: true }, + { titel: "Neue Zuschauer aufgetaucht", text: "Woher kamen sie? Wurde jemand begrüßt oder ging es unter?", hilfe: false }, + { titel: "Ich wusste nicht weiter", text: "Stelle merken, an der es gestockt hat. Dafür gibt es Hilfe.", hilfe: true }, + ], + auswertung: [ + { titel: "Was hat diesmal getragen?", text: "Ein Ding. Nicht drei. Das eine, das man wiederholen will.", hilfe: false }, + { titel: "Was war zäh?", text: "Ehrlich sein. Die zähen Stellen sind die, an denen man wächst.", hilfe: false }, + { titel: "Zahlen im Vergleich", text: "Zuschauer, Verweildauer, Kommentare – gegen das letzte Mal, nicht gegen andere.", hilfe: true }, + { titel: "Clips gesichert", text: "Die guten Momente rausgeschnitten und abgelegt, solange man sie noch findet.", hilfe: false }, + { titel: "Eine Sache für nächstes Mal", text: "Genau eine. Wer sich fünf Dinge vornimmt, macht keins davon.", hilfe: false }, + { titel: "Braucht Besprechung", text: "Etwas, das man nicht allein lösen kann – geht an die Betreuung.", hilfe: true }, + ], +}; + +/* ===================================================================== + CALL-LEITFÄDEN + + Jeder Call endet mit To-dos -- das steht schon auf der Seite. Was + fehlte, ist der Weg dorthin. Diese Leitfäden sind bewusst kurz: Ein + Gespräch nach Fragebogen ist ein Verhör, kein Gespräch. + ===================================================================== */ + +export const CALL_LEITFAEDEN = [ + { titel: "Erstgespräch", + text: "Wer bist du, was machst du, was willst du erreichen? · Was läuft schon gut? · " + + "Was nervt dich gerade am meisten? · Wie viel Zeit hast du wirklich? · " + + "Was erwartest du von uns – und was erwarten wir von dir?" }, + { titel: "Wochen-Call", + text: "Was ist seit dem letzten Mal passiert? · Was ist liegen geblieben und warum? · " + + "Zahlen kurz ansehen – eine Auffälligkeit reicht · Was steht diese Woche an? · " + + "Brauchst du irgendwo Hilfe?" }, + { titel: "Wenn es gerade nicht läuft", + text: "Zuerst zuhören, nicht lösen · Seit wann geht es so? · Was hat sich geändert? · " + + "Was war das letzte Mal, als es gut war? · Eine kleine Sache vereinbaren, " + + "nicht fünf große" }, + { titel: "Monats-Review", + text: "Die drei Ziele vom letzten Mal durchgehen · Was ist erreicht, was nicht? · " + + "Woran lag es – Zeit, Können oder Motivation? · Drei neue Ziele, verbindlich · " + + "Termin für das nächste Review festhalten" }, +]; + +/* ---------- Schnittstelle ------------------------------------------------ */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} +vorlagenRouter.use("/workspace/api/vorlagen", angemeldet); + +vorlagenRouter.get("/workspace/api/vorlagen", (req, res) => { + res.json({ + content: CONTENT_IDEEN, + live: LIVE_PUNKTE, + calls: CALL_LEITFAEDEN, + }); +}); + +/* ---------- Übernehmen --------------------------------------------------- + + Aus einer Vorlage wird ein ganz normaler Eintrag: änderbar, löschbar, + und ab dann unabhängig von der Vorlage. Wer die Vorlage später + ändert, ändert damit NICHT, was schon übernommen wurde -- das wäre + eine böse Überraschung. + + Creator dürfen ausdrücklich selbst übernehmen. Es ist ihre Arbeit; + sie sollen nicht darauf warten müssen, dass ein Betreuer für sie eine + Idee anlegt. */ + +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(); +} + +vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft, + express.json({ limit: "16kb" }), (req, res) => { + try { + const art = String(req.body.art || ""); + const nr = Number(req.body.nr); + /* Für wen? Ein Creator immer für sich; Betreuer für den Creator, + den sie gerade ansehen. */ + let creatorId = req.person.rolle === "creator" + ? req.person.id + : Number(req.body.creator_id) || null; + + if (req.person.rolle !== "creator" && creatorId && !darfCreator(req.person, creatorId)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + const angelegt = []; + const einfuegen = (bereich, unterart, titel, text, zusatz = {}) => { + db().prepare(`INSERT INTO eintraege + (bereich, art, titel, text, datum, status, creator_id, hook, format, + erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`) + .run(bereich, unterart, titel, text, jetzt().slice(0, 10), "offen", + creatorId, zusatz.hook || null, zusatz.format || null, + jetzt(), req.person.id); + angelegt.push(titel); + }; + + if (art === "content") { + const v = CONTENT_IDEEN[nr]; + if (!v) return res.status(400).json({ fehler: "Unbekannte Vorlage." }); + einfuegen("content", "idee", v.titel, v.text, { hook: v.hook, format: v.format }); + + } else if (art === "content_alle") { + for (const v of CONTENT_IDEEN) { + einfuegen("content", "idee", v.titel, v.text, { hook: v.hook, format: v.format }); + } + + } else if (art === "live") { + const unterart = String(req.body.unterart || ""); + const liste = LIVE_PUNKTE[unterart]; + if (!liste) return res.status(400).json({ fehler: "Unbekannter Abschnitt." }); + const v = liste[nr]; + if (!v) return res.status(400).json({ fehler: "Unbekannte Vorlage." }); + einfuegen("live", unterart, v.titel, v.text); + + } else if (art === "live_alle") { + const unterart = String(req.body.unterart || ""); + const liste = LIVE_PUNKTE[unterart]; + if (!liste) return res.status(400).json({ fehler: "Unbekannter Abschnitt." }); + for (const v of liste) einfuegen("live", unterart, v.titel, v.text); + + } else { + return res.status(400).json({ fehler: "Unbekannte Art." }); + } + + protokolliere("vorlage_uebernommen", req.person, echteIp(req), + `${art}: ${angelegt.length} Eintrag/Einträge`); + res.json({ angelegt: angelegt.length, titel: angelegt }); + } catch (fehler) { + console.error("[workspace] Vorlage übernehmen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- "Ich brauche Hilfe" ------------------------------------------ + + Ein Creator klickt an einem LIVE-Punkt auf "Hilfe". Daraus wird eine + Aufgabe für seinen Betreuer -- nicht eine Nachricht, die untergeht, + sondern ein Eintrag im Aufgabenbrett mit Frist. + + Warum eine Aufgabe und keine Benachrichtigung: Eine Benachrichtigung + ist gelesen und dann weg. Eine Aufgabe bleibt stehen, bis sie jemand + erledigt -- und taucht auf der Startseite des Betreuers auf, wenn sie + überfällig wird. */ + +vorlagenRouter.post("/workspace/api/vorlagen/hilfe", gleicheHerkunft, + express.json({ limit: "8kb" }), (req, res) => { + try { + const thema = String(req.body.thema || "").trim().slice(0, 160); + if (thema.length < 3) return res.status(400).json({ fehler: "Worum geht es?" }); + const notiz = String(req.body.notiz || "").trim().slice(0, 2000); + + const creatorId = req.person.rolle === "creator" ? req.person.id + : Number(req.body.creator_id) || null; + if (!creatorId) return res.status(400).json({ fehler: "Kein Creator angegeben." }); + if (req.person.rolle !== "creator" && !darfCreator(req.person, creatorId)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + /* An wen? An den Betreuer. Gibt es keinen, an die Leitung -- eine + Bitte um Hilfe darf nicht ins Leere laufen, nur weil die + Zuordnung fehlt. */ + const betreuer = db().prepare( + "SELECT betreuer_id FROM betreuung WHERE creator_id = ? LIMIT 1").get(creatorId); + const ziel = betreuer?.betreuer_id + || db().prepare( + "SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1 ORDER BY id LIMIT 1") + .get()?.id + || null; + + const name = db().prepare("SELECT name FROM personen WHERE id = ?").get(creatorId)?.name + || "Creator"; + + /* Frist: in drei Tagen. Eine Bitte um Hilfe ohne Frist ist eine + Bitte, die liegen bleibt. */ + const frist = new Date(Date.now() + 3 * 86400_000).toISOString().slice(0, 10); + + db().prepare(`INSERT INTO aufgaben + (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, + frist, erstellt, erstellt_von) + VALUES (?,?,'offen','hoch',?,?,?,?,?)`) + .run(`Hilfe gewünscht: ${thema}`, + `${name} hat um Unterstützung gebeten.\n\n${notiz || "(keine weitere Angabe)"}`, + creatorId, ziel, frist, jetzt(), req.person.id); + + protokolliere("hilfe_angefragt", req.person, echteIp(req), thema); + res.json({ ok: true, an: ziel }); + } catch (fehler) { + console.error("[workspace] Hilfe anfragen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); diff --git a/workspace/assets/css/content.css b/workspace/assets/css/content.css index d8573fcb..bcbbfd7b 100644 --- a/workspace/assets/css/content.css +++ b/workspace/assets/css/content.css @@ -313,3 +313,119 @@ body.start { .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; } } + +/* ===================================================================== + VORLAGEN — fertige Vorschlaege zum Uebernehmen + + Sie sehen bewusst ANDERS aus als eigene Eintraege: heller Rand, + gestrichelte Kante, keine Statusfarbe. Man soll auf den ersten Blick + sehen, dass hier noch nichts "gehoert" -- es sind Angebote, keine + Arbeit. Sobald einer uebernommen ist, wird die Karte ruhig und traegt + einen Haken. + ===================================================================== */ + +.vorlagen { margin: 22px 0 28px; } +.vorlagen__kopf { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 16px; flex-wrap: wrap; + margin-bottom: 14px; +} +.vorlagen__unter { + margin: 4px 0 0; + font-size: .86rem; color: var(--text-leise); +} +.vorlagen__raster { + display: grid; gap: 11px; + grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); +} +.vorlagen__raster[hidden] { display: none; } + +.vorlage { + position: relative; + padding: 15px 16px 13px; + border: 1px dashed color-mix(in srgb, var(--ton, var(--akzent)) 30%, var(--rand)); + border-radius: var(--radius-klein); + background: + linear-gradient(180deg, color-mix(in srgb, var(--ton, var(--akzent)) 6%, transparent), transparent 55%), + var(--flaeche); + transition: border-color .18s ease; +} +.vorlage:hover { border-color: color-mix(in srgb, var(--ton, var(--akzent)) 50%, var(--rand)); } +/* Uebernommen: durchgezogene Kante statt gestrichelter -- aus einem + Angebot ist etwas Wirkliches geworden. */ +.vorlage[data-genommen="ja"] { + border-style: solid; + border-color: rgba(80, 167, 130, .4); + opacity: .72; +} +.vorlage[data-genommen="ja"]::after { + content: "✓"; + position: absolute; top: 12px; right: 14px; + color: #6fbd95; font-weight: 700; +} +.vorlage__titel { + margin: 0 0 8px; + font-size: .96rem; font-weight: 650; line-height: 1.3; color: var(--text); +} +/* Der Aufhaenger ist der wichtigste Teil einer Content-Idee -- die + ersten ein bis drei Sekunden entscheiden ueber die Verbreitung. + Deshalb steht er hervorgehoben und nicht im Fliesstext. */ +.vorlage__hook { + margin: 0 0 9px; padding: 9px 11px; + border-left: 2px solid var(--ton, var(--akzent)); + border-radius: 0 6px 6px 0; + background: color-mix(in srgb, var(--ton, var(--akzent)) 9%, transparent); + font-size: .88rem; line-height: 1.5; color: var(--text); +} +.vorlage__hookschild { + display: block; margin-bottom: 3px; + font-size: .64rem; letter-spacing: .12em; text-transform: uppercase; + color: var(--ton, var(--akzent)); +} +.vorlage__text { + margin: 0 0 12px; + font-size: .85rem; line-height: 1.55; color: var(--text-leise); +} +.vorlage__fuss { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.vorlage__marke { + padding: 2px 9px; + border: 1px solid var(--rand); border-radius: 999px; + font-size: .7rem; color: var(--text-still); +} +.vorlage__marke--saeule { + border-color: color-mix(in srgb, var(--ton, var(--akzent)) 32%, transparent); + color: var(--ton, var(--akzent)); +} +.vorlage__marke--hilfe { border-color: rgba(216, 161, 58, .34); color: #d8a13a; } +.vorlage__fuss .schritt { margin-left: auto; } + +.vorlagen__alle { + grid-column: 1 / -1; + display: flex; justify-content: center; + padding-top: 4px; +} + +/* Der Hilfe-Knopf ist bewusst auffaelliger als die uebrigen: Wer ihn + braucht, soll ihn finden, ohne zu suchen. */ +.schritt--hilfe { + border-color: rgba(216, 161, 58, .4); + color: #e0b45c; +} +.schritt--hilfe[data-fertig="ja"] { + border-color: rgba(80, 167, 130, .45); + color: #6fbd95; +} + +@media (max-width: 560px) { + .vorlagen__kopf { flex-direction: column; align-items: stretch; } + .vorlagen__kopf .knopf { width: 100%; } +} + +/* Auf dem Handy sind die kleinen Marken im Vorlagenblock zu klein -- + gemeldet von der Geraetepruefung (10,24 px beim Aufhaenger-Schild, + 11,2 px bei Format und Saeule). body.start davor, weil content.css + NACH start.css geladen wird und dort dieselbe Regel steht. */ +@media (max-width: 760px) { + body.start .vorlage__hookschild { font-size: .72rem; } + body.start .vorlage__marke { font-size: .75rem; } +} diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 825ce0aa..bf234812 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -156,6 +156,34 @@ } for (const e of sichtbar) ziel.append(karte(e)); } catch { /* umgeleitet */ } + await vorlagenZeigen(); + } + + /* Fertige Punkte zum Uebernehmen -- derzeit nur fuer die LIVE-Analyse. + Sie stehen ueber der Liste: Wer noch nichts hat, soll nicht erst an + einem leeren Kasten vorbeiscrollen. + + Welcher Abschnitt gezeigt wird, richtet sich nach dem gewaehlten + Filter. Ohne Filter die Vorbereitung -- das ist der Teil, ohne den + die anderen beiden gar nicht erst sinnvoll sind. */ + async function vorlagenZeigen() { + const behaelter = $('vorlagen'); + if (!behaelter || bereich !== 'live' || !window.vorlagenBlock) return; + const abschnitt = ['vorbereitung', 'mitschrift', 'auswertung'].includes(filter) + ? filter : 'vorbereitung'; + await window.vorlagenBlock(behaelter, { + art: 'live', + unterart: abschnitt, + creatorId: creatorWahl(), + danach: laden, + }); + } + + /* Fuer wen wird uebernommen? Ein Creator immer fuer sich (der Server + setzt das ohnehin), ein Betreuer fuer den gewaehlten Creator. */ + function creatorWahl() { + const w = document.getElementById('f-creator'); + return w && w.value ? Number(w.value) : null; } /* ---------- Neuer Eintrag ---------------------------------------------- */ diff --git a/workspace/assets/js/bereiche.js b/workspace/assets/js/bereiche.js index 6225c7e5..a830d2b0 100644 --- a/workspace/assets/js/bereiche.js +++ b/workspace/assets/js/bereiche.js @@ -125,7 +125,7 @@ rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'startcheck.html', szene: 'wald' }, { name: 'LIVE-Analyse', unter: 'Vorher, während, danach', zeichen: 'live', ton: 7, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=live', szene: 'portal' }, - { name: 'Content-Planung', unter: 'Idee bis Upload', zeichen: 'content', ton: 13, + { name: 'Content-Ideen', unter: 'Idee bis Upload', zeichen: 'content', ton: 13, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'content.html', szene: 'portal' }, { name: 'Community', unter: 'Moderation und Konflikte', zeichen: 'community', ton: 6, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=community', szene: 'lounge' }, diff --git a/workspace/assets/js/content.js b/workspace/assets/js/content.js index 438a42c1..a9bb1d3d 100644 --- a/workspace/assets/js/content.js +++ b/workspace/assets/js/content.js @@ -1,5 +1,5 @@ /* =================================================================== - Content-Planung. + Content-Ideen. Bekommt als einziger der sechs Bereiche eine eigene Seite. Grund: Die anderen fuenf sind Sammlungen -- Eintraege zu einem Thema. Content ist @@ -441,6 +441,18 @@ saeulen = sA.ok ? (await sA.json()).saeulen || [] : []; } catch { return; } + /* Fertige Ideen zum Uebernehmen. Steht ueber der Strecke, damit + jemand mit leeren Spalten nicht erst daran vorbeiscrollen muss. */ + const behaelter = document.getElementById('vorlagen'); + if (behaelter && window.vorlagenBlock) { + const wahl = document.getElementById('creator-wahl'); + window.vorlagenBlock(behaelter, { + art: 'content', + creatorId: wahl && wahl.value ? Number(wahl.value) : null, + danach: laden, + }); + } + filterBauen(); streckeZeigen(); saeulenZeigen(); diff --git a/workspace/assets/js/vorlagen.js b/workspace/assets/js/vorlagen.js new file mode 100644 index 00000000..5d50a08d --- /dev/null +++ b/workspace/assets/js/vorlagen.js @@ -0,0 +1,214 @@ +/* =================================================================== + Vorlagen — fertige Inhalte, die man übernehmen kann. + + EIN Bauteil für mehrere Seiten (Content-Ideen, LIVE-Analyse). Es + hängt sich an einen Behälter mit `data-vorlagen=""` und baut + dort eine aufklappbare Liste von Vorschlägen. + + Warum ausgeklappt-per-Knopf und nicht offen: Wer schon zwanzig eigene + Einträge hat, braucht die Vorschläge nicht mehr im Weg. Wer noch + keinen hat, sieht den Knopf sofort -- und er ist dann das Auffälligste + auf der Seite, weil sonst nichts da ist. + + Übernehmen macht aus dem Vorschlag einen ganz normalen Eintrag. Ab + dann ist er unabhängig von der Vorlage: änderbar, löschbar, und + spätere Änderungen an der Vorlage berühren ihn nicht. + =================================================================== */ +(() => { + 'use strict'; + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + return a; + } + + let vorlagen = null; + + /* Was in dieser Sitzung schon uebernommen wurde. Muss AUSSERHALB des + Bauvorgangs stehen: Nach dem Uebernehmen wird die Liste neu geladen + und der Block neu gebaut -- eine Markierung am Element allein waere + dabei jedes Mal verloren. Genau das hat die Pruefung gemeldet + ("als uebernommen markiert: 0"). + + Bewusst nur fuer diese Sitzung und nicht dauerhaft gespeichert: Wer + die Seite neu laedt, hat seine Eintraege ja in der Liste stehen -- + dort gehoeren sie hin, nicht in eine zweite Buchfuehrung. */ + const genommen = new Set(); + + /** Baut den Vorlagenblock in einen Behälter. + * art: 'content' | 'live' + * unterart: bei live der Abschnitt (vorbereitung | mitschrift | auswertung) + * creatorId: für wen übernommen wird (Betreuer); bei Creator egal + * danach: wird nach dem Übernehmen aufgerufen (Liste neu laden) */ + window.vorlagenBlock = async function (behaelter, { art, unterart, creatorId, danach }) { + if (!behaelter) return; + if (!vorlagen) { + try { + const a = await hole('/workspace/api/vorlagen'); + if (!a.ok) return; + vorlagen = await a.json(); + } catch { return; } + } + + const liste = art === 'content' ? vorlagen.content + : (vorlagen.live?.[unterart] || []); + if (!liste.length) return; + + behaelter.textContent = ''; + const block = el('section', 'vorlagen'); + + /* ---- Kopf mit Aufklappknopf ---- */ + const kopf = el('div', 'vorlagen__kopf'); + const titel = el('div', null); + titel.append(el('p', 'feldschild', 'Fertige Vorschläge')); + titel.append(el('p', 'vorlagen__unter', + art === 'content' + ? `${liste.length} Ideen mit Aufhänger und Format – zum Übernehmen und Anpassen.` + : `${liste.length} Punkte, die sich bewährt haben. Übernehmen und abhaken.`)); + kopf.append(titel); + + const auf = el('button', 'knopf knopf--klein'); + auf.type = 'button'; + auf.setAttribute('aria-expanded', 'false'); + auf.textContent = 'Vorschläge zeigen'; + kopf.append(auf); + block.append(kopf); + + /* ---- Die Liste ---- */ + const raster = el('div', 'vorlagen__raster'); + raster.hidden = true; + + for (const [nr, v] of liste.entries()) { + const k = el('article', 'vorlage'); + const schluessel = art + ':' + (unterart || '') + ':' + nr; + if (genommen.has(schluessel)) k.dataset.genommen = 'ja'; + if (v.saeule) k.dataset.saeule = v.saeule; + + k.append(el('h3', 'vorlage__titel', v.titel)); + + /* Der Aufhänger ist der wichtigste Teil einer Content-Idee -- die + ersten Sekunden entscheiden über die Verbreitung. Deshalb steht + er hervorgehoben und nicht im Fließtext. */ + if (v.hook) { + const h = el('p', 'vorlage__hook'); + h.append(el('span', 'vorlage__hookschild', 'Aufhänger')); + h.append(document.createTextNode(v.hook)); + k.append(h); + } + if (v.text) k.append(el('p', 'vorlage__text', v.text)); + + const fuss = el('div', 'vorlage__fuss'); + if (v.format) fuss.append(el('span', 'vorlage__marke', v.format)); + if (v.saeule) fuss.append(el('span', 'vorlage__marke vorlage__marke--saeule', v.saeule)); + /* Punkte, bei denen man Hilfe anfordern kann, sagen das auch. */ + if (v.hilfe) fuss.append(el('span', 'vorlage__marke vorlage__marke--hilfe', 'Hilfe möglich')); + + const nehmen = el('button', 'schritt', 'Übernehmen'); + nehmen.type = 'button'; + nehmen.addEventListener('click', async () => { + nehmen.disabled = true; + nehmen.textContent = 'wird übernommen …'; + try { + const a = await hole('/workspace/api/vorlagen/uebernehmen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ art, unterart, nr, creator_id: creatorId }), + }); + if (!a.ok) { + const f = await a.json().catch(() => ({})); + nehmen.textContent = f.fehler || 'ging nicht'; + nehmen.disabled = false; + return; + } + /* Übernommen bleibt sichtbar markiert -- man soll sehen, was + man schon geholt hat, ohne die Liste zu verlassen. */ + genommen.add(schluessel); + k.dataset.genommen = 'ja'; + nehmen.textContent = 'übernommen'; + danach?.(); + } catch { nehmen.disabled = false; nehmen.textContent = 'Übernehmen'; } + }); + fuss.append(nehmen); + k.append(fuss); + raster.append(k); + } + + /* ---- Alle auf einmal ---- */ + const alle = el('div', 'vorlagen__alle'); + const allesNehmen = el('button', 'knopf-still', + art === 'content' ? `Alle ${liste.length} Ideen übernehmen` + : `Alle ${liste.length} Punkte übernehmen`); + allesNehmen.type = 'button'; + allesNehmen.addEventListener('click', async () => { + if (!confirm(`Wirklich alle ${liste.length} übernehmen? ` + + 'Sie landen als eigene Einträge in der Liste und lassen sich einzeln löschen.')) return; + allesNehmen.disabled = true; + allesNehmen.textContent = 'wird übernommen …'; + try { + const a = await hole('/workspace/api/vorlagen/uebernehmen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ art: art + '_alle', unterart, creator_id: creatorId }), + }); + if (!a.ok) { allesNehmen.textContent = 'ging nicht'; allesNehmen.disabled = false; return; } + const { angelegt } = await a.json(); + /* Alle auf einmal: dann sind auch alle markiert. */ + for (let i = 0; i < liste.length; i++) genommen.add(art + ':' + (unterart || '') + ':' + i); + allesNehmen.textContent = `${angelegt} übernommen`; + danach?.(); + } catch { allesNehmen.disabled = false; } + }); + alle.append(allesNehmen); + raster.append(alle); + + block.append(raster); + + auf.addEventListener('click', () => { + const offen = auf.getAttribute('aria-expanded') === 'true'; + auf.setAttribute('aria-expanded', offen ? 'false' : 'true'); + raster.hidden = offen; + auf.textContent = offen ? 'Vorschläge zeigen' : 'Vorschläge ausblenden'; + }); + + behaelter.append(block); + behaelter.hidden = false; + }; + + /* ---------- "Ich brauche Hilfe" --------------------------------------- + + Ein Knopf, der aus einer Bitte eine AUFGABE macht -- keine + Nachricht. Eine Nachricht ist gelesen und dann weg; eine Aufgabe + bleibt stehen, bis sie jemand erledigt, und taucht beim Betreuer auf + der Startseite auf, wenn sie überfällig wird. */ + + window.hilfeKnopf = function (thema, creatorId) { + const b = el('button', 'schritt schritt--hilfe', 'Hilfe anfordern'); + b.type = 'button'; + b.addEventListener('click', async () => { + const notiz = prompt(`Hilfe zu „${thema}“.\n\n` + + 'Was genau brauchst du? (Ein Satz reicht – kann auch leer bleiben.)'); + if (notiz === null) return; /* abgebrochen */ + b.disabled = true; + b.textContent = 'wird gemeldet …'; + try { + const a = await hole('/workspace/api/vorlagen/hilfe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ thema, notiz, creator_id: creatorId }), + }); + if (!a.ok) { b.textContent = 'ging nicht'; b.disabled = false; return; } + b.textContent = 'Hilfe angefordert'; + b.dataset.fertig = 'ja'; + } catch { b.disabled = false; b.textContent = 'Hilfe anfordern'; } + }); + return b; + }; +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 997cd03e..0e808f54 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -139,9 +139,9 @@ - - - - + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 2385766a..9a7ee807 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,9 +132,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index d104ec75..86ce900f 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -103,13 +103,18 @@
+ + +

Einträge werden geladen …

- - - - + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index d111ceda..c7d6fc4d 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -54,10 +54,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 65786f83..3df4b6ee 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -3,14 +3,14 @@ -Content-Planung · Creator Workspace +Content-Ideen · Creator Workspace - - - - + + + + @@ -39,7 +39,7 @@

Betreuung

-

Content-Planung

+

Content-Ideen

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

@@ -152,15 +152,20 @@
+ + +

Wird geladen …

- - - - + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index aff17c9f..16a499dc 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,9 +93,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index f928124b..502f94f9 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 e7891b8d..16041525 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -147,9 +147,9 @@ - - - - + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 46a101c5..71ef9e99 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -121,9 +121,9 @@ - - - - + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 4249f2bb..93a6dc3a 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,10 +175,10 @@ - - - - - + + + + + diff --git a/workspace/report.html b/workspace/report.html index f930e8db..b95f4656 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -91,10 +91,10 @@ - - - - - + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index c0121606..b29dca38 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,9 +124,9 @@
- - - - + + + + diff --git a/workspace/start.html b/workspace/start.html index 061d3d23..7a01a519 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -99,9 +99,9 @@

- - - - + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 4be24a4e..cea36c37 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -70,10 +70,10 @@ - - - - - + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 1dc431b1..1171d0d4 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -85,9 +85,9 @@ - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 528854fc..a29f6ca3 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -57,9 +57,9 @@ - + - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 8ff592c7..f8655958 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,9 +175,9 @@ - - - - + + + +