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