Anleitungen & Checklisten
+Wissens-Bibliothek
+Alles zu TikTok, Livestreaming, Technik und Content – an einer Stelle.
+Neue Anleitung
+ +Bibliothek wird geladen …
diff --git a/server/index.js b/server/index.js index cda8d81e..8afc7d34 100644 --- a/server/index.js +++ b/server/index.js @@ -26,6 +26,7 @@ import { callRouter } from "./workspace-calls.js"; import { hinweisRouter } from "./workspace-hinweise.js"; import { sucheRouter } from "./workspace-suche.js"; import { startcheckRouter } from "./workspace-startcheck.js"; +import { wissenRouter } from "./workspace-wissen.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -92,6 +93,7 @@ app.use(callRouter); app.use(hinweisRouter); app.use(sucheRouter); app.use(startcheckRouter); +app.use(wissenRouter); /* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen). SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert diff --git a/server/workspace-wissen.js b/server/workspace-wissen.js new file mode 100644 index 00000000..c92ad958 --- /dev/null +++ b/server/workspace-wissen.js @@ -0,0 +1,485 @@ +/* ===================================================================== + workspace-wissen.js — Wissens-Bibliothek (Anleitungen als PDF). + + Die Gliederung ist der eigentliche Wert dieses Bereichs, deshalb steht + sie hier im Code und nicht in der Datenbank: zwanzig Kategorien, von + Filipe vorgegeben, in sechs Welten gebuendelt. Zwanzig gleich + aussehende Kacheln untereinander findet niemand -- gebuendelt sucht + man erst die Welt und dann die Kategorie, und das geht mit einem Blick. + + JEDE PDF HAT GENAU EINE HAUPTKATEGORIE. So gibt es sie nur einmal, es + entstehen keine Doppelungen und keine Frage "wo lag die noch mal". + Alles Weitere laeuft ueber Tags -- damit ist dieselbe Anleitung + trotzdem ueber mehrere Suchbegriffe auffindbar. + + Sicherheit beim Ausliefern: Hochgeladen wird nur, was wirklich eine + PDF ist (geprueft an den ersten Bytes, nicht an der Dateiendung -- die + kann jeder umbenennen). Nur deshalb darf die Datei ueberhaupt im + Browser angezeigt statt bloss heruntergeladen werden. + ===================================================================== */ + +import express from "express"; +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { mkdirSync, createReadStream, writeFileSync, unlinkSync, statSync } from "node:fs"; +import { db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER } from "./workspace.js"; + +export const wissenRouter = express.Router(); + +const ORDNER = join(DATEN_ORDNER, "wissen"); +const MAX_BYTES = 40 * 1024 * 1024; // 40 MB je Anleitung +const TITEL_MAX = 140; +const TEXT_MAX = 400; +const TAGS_MAX = 8; + +const jetzt = () => new Date().toISOString(); +const heute = () => new Date().toISOString().slice(0, 10); + +/* ---------- Die Gliederung ------------------------------------------------ + Die Beschreibungen sind woertlich Filipes Vorgabe -- sie sagen, was in + die Kategorie gehoert, und beantworten damit die einzige Frage, die + man beim Einsortieren wirklich hat. */ + +export const WELTEN = [ + { schluessel: "tiktok", name: "TikTok" }, + { schluessel: "aufbau", name: "Streaming einrichten" }, + { schluessel: "bildton", name: "Bild & Ton" }, + { schluessel: "technik", name: "Übertragung & Aussehen" }, + { schluessel: "inhalt", name: "Inhalt & Menschen" }, + { schluessel: "hilfe", name: "Hilfe & Schnellstart" }, +]; + +export const KATEGORIEN = [ + { nr: 1, schluessel: "tiktok-grundlagen", welt: "tiktok", symbol: "tiktok", + name: "TikTok – Grundlagen & Funktionen", + beschreibung: "Allgemeine TikTok-Erklärungen, Einstellungen, LIVE-Funktionen, Gäste, Co-Hosts, Abos, Geschenke, Coins und neue Features." }, + { nr: 2, schluessel: "tiktok-regeln", welt: "tiktok", symbol: "schild", + name: "TikTok – Regeln & Sicherheit", + beschreibung: "Community-Richtlinien, erlaubte und verbotene Inhalte, Altersprüfung, Sperren, Verwarnungen, Datenschutz, Jugendschutz und sicheres Verhalten." }, + { nr: 17, schluessel: "events-einnahmen", welt: "tiktok", symbol: "pokal", + name: "Events, Kampagnen & Einnahmen", + beschreibung: "TikTok-Events, Ranglisten, Kampagnen, LIVE-Geschenke, Abonnements, Monetarisierung und Teilnahmebedingungen." }, + { nr: 20, schluessel: "neuigkeiten", welt: "tiktok", symbol: "glocke", + name: "Neuigkeiten & Updates", + beschreibung: "Neue TikTok-Funktionen, Änderungen an Regeln, Software-Updates und aktuelle Entwicklungen im Streaming-Bereich." }, + + { nr: 3, schluessel: "streaming-pc", welt: "aufbau", symbol: "monitor", + name: "Streaming mit PC", + beschreibung: "Komplettes Streaming-Setup am Computer, TikTok LIVE Studio, PC-Einstellungen, Leistung, Verkabelung und Einrichtung." }, + { nr: 4, schluessel: "obs", welt: "aufbau", symbol: "ebenen", + name: "OBS & Streaming-Software", + beschreibung: "OBS Studio, Szenen, Quellen, Übergänge, Plugins, virtuelle Kamera, TikTok-Anbindung und weitere Streaming-Programme." }, + { nr: 5, schluessel: "streaming-handy", welt: "aufbau", symbol: "handy", + name: "Streaming mit Handy & Outdoor-Lives", + beschreibung: "Mobile Livestreams, IRL-Streaming, Internetverbindung unterwegs, Powerbanks, Smartphone-Halterungen, mobile Mikrofone und Outdoor-Ausrüstung." }, + + { nr: 6, schluessel: "kamera", welt: "bildton", symbol: "kamera", + name: "Kamera & Bildqualität", + beschreibung: "Webcams, Kameras, Kameraeinstellungen, Auflösung, FPS, Belichtung, Fokus, Unschärfe, Bildausschnitt, Filter und optimale Bildqualität." }, + { nr: 7, schluessel: "licht", welt: "bildton", symbol: "licht", + name: "Licht & Hintergrund", + beschreibung: "Beleuchtung, Ringlichter, Softboxen, LED-Lichter, Greenscreen, Hintergrundgestaltung und richtige Positionierung." }, + { nr: 8, schluessel: "audio", welt: "bildton", symbol: "mikrofon", + name: "Mikrofone & Audio", + beschreibung: "Mikrofone, Audioeinstellungen, Lautstärke, Rauschunterdrückung, Equalizer, Sprachqualität, Musik und Soundprobleme." }, + { nr: 9, schluessel: "mischpulte", welt: "bildton", symbol: "regler", + name: "Mischpulte, Soundboards & Effekte", + beschreibung: "Maono, RØDECaster, Stream Deck, Soundpads, Sprach- und Soundeffekte sowie das Belegen von Tasten mit Sounds." }, + { nr: 10, schluessel: "hardware", welt: "bildton", symbol: "stecker", + name: "Streaming-Hardware & Zubehör", + beschreibung: "Computer, Monitore, Capture Cards, Kabel, Adapter, Stative, Kopfhörer, Stream Decks und weiteres Zubehör." }, + + { nr: 11, schluessel: "internet", welt: "technik", symbol: "wlan", + name: "Internet & Stream-Qualität", + beschreibung: "WLAN, LAN, Upload-Geschwindigkeit, Bitrate, Verbindungsabbrüche, Lags, eingefrorene Bilder und Optimierung der Übertragung." }, + { nr: 12, schluessel: "design", welt: "technik", symbol: "rahmen", + name: "Stream-Design & Darstellung", + beschreibung: "Overlays, Rahmen, Szenen, Animationen, Einblendungen, Alerts, Logos, Chat-Anzeigen und ein professionelles Stream-Layout." }, + { nr: 13, schluessel: "tools", welt: "technik", symbol: "zahnrad", + name: "Tools, Bots & Automatisierungen", + beschreibung: "Tikfinity, Discord-Bots, Benachrichtigungen, automatische Aktionen, Chat-Befehle und weitere hilfreiche Programme." }, + + { nr: 14, schluessel: "content", welt: "inhalt", symbol: "kalender", + name: "Content & Stream-Planung", + beschreibung: "Stream-Ideen, Vorbereitung, Ablaufpläne, Challenges, Spiele, Themenstreams, Zuschauerbindung und Content-Planung." }, + { nr: 15, schluessel: "community", welt: "inhalt", symbol: "menschen", + name: "Community & Moderation", + beschreibung: "Aufgaben der Modis, Chatregeln, Deeskalation, Community-Aufbau, Umgang mit Zuschauern, Trollen und Konflikten." }, + { nr: 16, schluessel: "reichweite", welt: "inhalt", symbol: "wachstum", + name: "Reichweite & Wachstum", + beschreibung: "TikTok-Wachstum, Videos, Clips, Highlights, Hashtags, Stream-Zeiten, Zuschauergewinnung und Verbesserung der Reichweite." }, + + { nr: 18, schluessel: "fehler", welt: "hilfe", symbol: "werkzeug", + name: "Fehlerbehebung & Problemlösungen", + beschreibung: "Lösungen für technische Fehler, fehlenden Ton, schlechtes Bild, Abstürze, Verbindungsprobleme, OBS-Fehler und TikTok-LIVE-Probleme." }, + { nr: 19, schluessel: "checklisten", welt: "hilfe", symbol: "haken", + name: "Checklisten & Schnellstart-Anleitungen", + beschreibung: "Kurze Schritt-für-Schritt-Anleitungen, Einsteiger-Checklisten, Stream-Vorbereitung und schnelle Einrichtungshilfen." }, +]; + +const KAT_SCHLUESSEL = new Set(KATEGORIEN.map((k) => k.schluessel)); + +export const STUFEN = { einsteiger: "Einsteiger", fortgeschritten: "Fortgeschritten", profi: "Profi" }; +export const GERAETE = { egal: "Egal", pc: "PC", handy: "Handy", beide: "PC & Handy" }; + +/* Vorschlaege, keine Vorschrift -- eigene Tags bleiben moeglich. */ +export const TAG_VORSCHLAEGE = [ + "tiktok", "obs", "pc", "handy", "audio", "kamera", "licht", "internet", + "overlay", "moderation", "einnahmen", "sicherheit", "checkliste", +]; + +/* ---------- Zugang ------------------------------------------------------ + Lesen darf jeder Angemeldete -- die Bibliothek ist fuer das ganze Team. + Pflegen darf nur DogFather: Es ist kuratiertes Wissen, kein Ablageort. */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} +function nurDogFather(req, res, next) { + if (req.person.rolle !== "admin") { + return res.status(403).json({ fehler: "Die Bibliothek pflegt DogFather." }); + } + next(); +} +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +wissenRouter.use("/workspace/api/wissen", angemeldet); + +/* ---------- Aufbau: Kategorien mit Anzahl -------------------------------- */ + +wissenRouter.get("/workspace/api/wissen/aufbau", (req, res) => { + try { + const zaehler = new Map(db().prepare( + "SELECT kategorie, COUNT(*) AS n FROM wissen GROUP BY kategorie") + .all().map((z) => [z.kategorie, z.n])); + + /* Tags, die tatsaechlich benutzt werden -- eine Filterliste mit + Begriffen ohne Treffer waere eine Einladung ins Leere. */ + const benutzt = new Set(); + for (const z of db().prepare("SELECT tags FROM wissen WHERE tags IS NOT NULL").all()) { + for (const t of String(z.tags).split(",")) if (t.trim()) benutzt.add(t.trim()); + } + + res.json({ + welten: WELTEN, + kategorien: KATEGORIEN.map((k) => ({ ...k, anzahl: zaehler.get(k.schluessel) || 0 })), + stufen: STUFEN, + geraete: GERAETE, + tags: [...benutzt].sort(), + tag_vorschlaege: TAG_VORSCHLAEGE, + darf_pflegen: req.person.rolle === "admin", + gesamt: [...zaehler.values()].reduce((s, n) => s + n, 0), + }); + } catch (fehler) { + console.error("[workspace] Wissen Aufbau:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Liste, Suche und Filter -------------------------------------- */ + +function muster(text) { + return "%" + text.replace(/[\\%_]/g, (z) => "\\" + z) + "%"; +} + +wissenRouter.get("/workspace/api/wissen", (req, res) => { + try { + const bedingungen = []; + const werte = []; + + const k = String(req.query.k ?? "").trim(); + if (k && KAT_SCHLUESSEL.has(k)) { bedingungen.push("w.kategorie = ?"); werte.push(k); } + + const stufe = String(req.query.stufe ?? "").trim(); + if (stufe && Object.hasOwn(STUFEN, stufe)) { bedingungen.push("w.stufe = ?"); werte.push(stufe); } + + /* "PC" soll auch die Anleitungen zeigen, die fuer beide Geraete + gelten -- sonst filtert man sich versehentlich das Halbe weg. */ + const geraet = String(req.query.geraet ?? "").trim(); + if (geraet === "pc" || geraet === "handy") { + bedingungen.push("(w.geraet = ? OR w.geraet = 'beide')"); + werte.push(geraet); + } + + const tag = String(req.query.tag ?? "").trim().toLowerCase(); + if (tag) { + /* Kommas drumherum, sonst wuerde der Filter "pc" auch bei + "pc-spiele" anschlagen. COALESCE, weil Tags leer sein duerfen -- + NULL || ',' waere NULL und die Zeile fiele stumm raus. */ + bedingungen.push("(',' || COALESCE(w.tags, '') || ',') LIKE ? ESCAPE '\\'"); + werte.push(muster("," + tag + ",")); + } + + const q = String(req.query.q ?? "").trim().slice(0, 80); + if (q.length >= 2) { + const m = muster(q); + bedingungen.push("(w.titel LIKE ? ESCAPE '\\' OR w.beschreibung LIKE ? ESCAPE '\\' OR w.tags LIKE ? ESCAPE '\\' OR w.name_original LIKE ? ESCAPE '\\')"); + werte.push(m, m, m, m); + } + + const wo = bedingungen.length ? "WHERE " + bedingungen.join(" AND ") : ""; + const liste = db().prepare(` + SELECT w.id, w.titel, w.beschreibung, w.kategorie, w.stufe, w.geraet, w.tags, + w.name_original, w.groesse, w.wichtig, w.veroeffentlicht, w.aktualisiert, + p.name AS von + FROM wissen w LEFT JOIN personen p ON p.id = w.erstellt_von + ${wo} + ORDER BY w.wichtig DESC, + COALESCE(w.aktualisiert, w.veroeffentlicht) DESC, + w.titel`).all(...werte); + + for (const e of liste) e.tags = e.tags ? String(e.tags).split(",").filter(Boolean) : []; + + /* Neu und wichtig -- der Einstieg fuer alle, die nicht suchen, + sondern schauen wollen, was sich getan hat. */ + const neu = !k && !q && !stufe && !geraet && !tag + ? liste.filter((e) => e.wichtig + || (Date.now() - Date.parse(e.aktualisiert || e.veroeffentlicht) < 30 * 86400_000)) + .slice(0, 6) + : []; + + res.json({ eintraege: liste, neu, gesamt: liste.length }); + } catch (fehler) { + console.error("[workspace] Wissen lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Angaben pruefen ---------------------------------------------- */ + +function pruefe(kopf, { neu }) { + const fehler = []; + const aus = {}; + const hole = (name) => { + const roh = kopf[name]; + if (roh === undefined) return undefined; + try { return decodeURIComponent(String(roh)); } catch { return String(roh); } + }; + + const titel = hole("titel"); + if (neu || titel !== undefined) { + const t = String(titel ?? "").trim(); + if (t.length < 3) fehler.push("Der Titel fehlt."); + else if (t.length > TITEL_MAX) fehler.push("Der Titel ist zu lang."); + else aus.titel = t; + } + + const besch = hole("beschreibung"); + if (besch !== undefined) aus.beschreibung = String(besch).trim().slice(0, TEXT_MAX) || null; + + const kat = hole("kategorie"); + if (neu || kat !== undefined) { + if (!KAT_SCHLUESSEL.has(String(kat))) fehler.push("Diese Kategorie gibt es nicht."); + else aus.kategorie = String(kat); + } + + const stufe = hole("stufe"); + if (stufe !== undefined) { + if (!Object.hasOwn(STUFEN, String(stufe))) fehler.push("Unbekannte Stufe."); + else aus.stufe = String(stufe); + } + + const geraet = hole("geraet"); + if (geraet !== undefined) { + if (!Object.hasOwn(GERAETE, String(geraet))) fehler.push("Unbekanntes Gerät."); + else aus.geraet = String(geraet); + } + + const tags = hole("tags"); + if (tags !== undefined) { + /* Klein geschrieben und ohne Komma gespeichert -- sonst waeren + "OBS" und "obs" zwei verschiedene Filter, und ein Komma im Tag + wuerde die Liste zerlegen. */ + const liste = [...new Set(String(tags).split(",") + .map((t) => t.trim().toLowerCase().replace(/[^a-z0-9äöüß+\- ]/g, "").trim()) + .filter(Boolean))].slice(0, TAGS_MAX); + aus.tags = liste.length ? liste.join(",") : null; + } + + const wichtig = hole("wichtig"); + if (wichtig !== undefined) aus.wichtig = (wichtig === "1" || wichtig === "true") ? 1 : 0; + + const datum = hole("veroeffentlicht"); + if (datum !== undefined && String(datum).trim()) { + const d = String(datum).trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || Number.isNaN(Date.parse(d))) { + fehler.push("Das Veröffentlichungsdatum ist ungültig."); + } else aus.veroeffentlicht = d; + } + + return { aus, fehler }; +} + +/* ---------- Hochladen ---------------------------------------------------- + Angaben stehen in Kopfzeilen, weil der Rumpf die Datei selbst ist -- + dieselbe Loesung wie bei den Dateien, kein multipart-Paket noetig. */ + +wissenRouter.post( + "/workspace/api/wissen", + gleicheHerkunft, + nurDogFather, + express.raw({ type: "*/*", limit: MAX_BYTES }), + (req, res) => { + let pfad = null; + try { + if (!req.body?.length) return res.status(400).json({ fehler: "Keine Datei angekommen." }); + + /* Ist das wirklich eine PDF? Geprueft an den ersten Bytes, nicht an + der Endung -- die kann jeder umbenennen. Nur weil das hier sicher + ist, darf die Datei spaeter im Browser angezeigt werden. */ + const kopf = req.body.subarray(0, 5).toString("latin1"); + if (kopf !== "%PDF-") { + return res.status(400).json({ fehler: "Das ist keine PDF-Datei." }); + } + + const felder = {}; + for (const n of ["titel", "beschreibung", "kategorie", "stufe", "geraet", "tags", "wichtig", "veroeffentlicht"]) { + const w = req.get("x-" + n); + if (w !== undefined) felder[n] = w; + } + const { aus, fehler } = pruefe(felder, { neu: true }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + + let name = "anleitung.pdf"; + try { name = decodeURIComponent(req.get("x-dateiname") || "").trim() || name; } catch { /* Vorgabe */ } + name = name.replace(/[\x00-\x1f\x7f/\\<>:"|?*]/g, "").slice(0, 160) || "anleitung.pdf"; + + mkdirSync(ORDNER, { recursive: true }); + const dateiname = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}.pdf`; + pfad = join(ORDNER, dateiname); + writeFileSync(pfad, req.body, { flag: "wx" }); + + const { lastInsertRowid } = db().prepare(` + INSERT INTO wissen + (titel, beschreibung, kategorie, stufe, geraet, tags, + name_original, name_datei, groesse, wichtig, veroeffentlicht, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run( + aus.titel, aus.beschreibung ?? null, aus.kategorie, + aus.stufe ?? "einsteiger", aus.geraet ?? "egal", aus.tags ?? null, + name, dateiname, req.body.length, aus.wichtig ?? 0, + aus.veroeffentlicht ?? heute(), req.person.id); + + protokolliere("wissen_hochgeladen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${lastInsertRowid} ${aus.titel} (${Math.round(req.body.length / 1024)} KB)`.slice(0, 120), + }); + res.status(201).json({ id: Number(lastInsertRowid) }); + } catch (fehler) { + /* Liegt die Datei schon auf der Platte, der Eintrag aber nicht in + der Datenbank, waere sie fuer immer verwaist. */ + if (pfad) { try { unlinkSync(pfad); } catch { /* war nie da */ } } + console.error("[workspace] Wissen hochladen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Angaben aendern ---------------------------------------------- */ + +wissenRouter.patch("/workspace/api/wissen/:id", gleicheHerkunft, nurDogFather, express.json(), (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const vorhanden = db().prepare("SELECT id FROM wissen WHERE id = ?").get(id); + if (!vorhanden) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* Der Aenderungsweg nimmt die Werte im Rumpf, nicht in Kopfzeilen -- + hier haengt keine Datei dran. */ + const felder = {}; + for (const n of ["titel", "beschreibung", "kategorie", "stufe", "geraet", "tags", "wichtig", "veroeffentlicht"]) { + if (req.body?.[n] !== undefined) felder[n] = encodeURIComponent(String(req.body[n])); + } + const { aus, fehler } = pruefe(felder, { neu: false }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + const namen = Object.keys(aus); + if (!namen.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); + + const setz = namen.map((n) => `${n} = ?`); + const daten = namen.map((n) => aus[n]); + setz.push("aktualisiert = ?"); daten.push(heute()); + + db().prepare(`UPDATE wissen SET ${setz.join(", ")} WHERE id = ?`).run(...daten, id); + protokolliere("wissen_geaendert", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${namen.join(",")}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Wissen ändern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +wissenRouter.delete("/workspace/api/wissen/:id", gleicheHerkunft, nurDogFather, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const e = db().prepare("SELECT titel, name_datei FROM wissen WHERE id = ?").get(id); + if (!e) return res.status(404).json({ fehler: "nicht_gefunden" }); + + db().prepare("DELETE FROM wissen WHERE id = ?").run(id); + try { unlinkSync(join(ORDNER, e.name_datei)); } catch { /* schon weg */ } + + protokolliere("wissen_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${e.titel}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Wissen löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Ausliefern ---------------------------------------------------- */ + +wissenRouter.get("/workspace/api/wissen/:id/datei", (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const e = db().prepare("SELECT titel, name_original, name_datei FROM wissen WHERE id = ?").get(id); + if (!e) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const pfad = join(ORDNER, e.name_datei); + try { statSync(pfad); } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); } + + /* Anzeigen ist nur erlaubt, weil beim Hochladen geprueft wurde, dass + es wirklich eine PDF ist. Die strenge Regel darunter verbietet der + Datei trotzdem jeden Nachladeversuch und jedes Skript. */ + const ansehen = req.query.modus === "ansehen"; + res.setHeader("Content-Type", ansehen ? "application/pdf" : "application/octet-stream"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Security-Policy", "default-src 'none'; object-src 'self'; sandbox"); + res.setHeader("Content-Disposition", + `${ansehen ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(e.name_original)}`); + + protokolliere(ansehen ? "wissen_angesehen" : "wissen_geladen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${e.titel}`.slice(0, 120), + }); + createReadStream(pfad).pipe(res); + } catch (fehler) { + console.error("[workspace] Wissen ausliefern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* Grosse Datei: express.raw bricht ab, BEVOR der eigene Code laeuft -- + ohne diesen Fanghaken kaeme ein nackter 500er. */ +wissenRouter.use((fehler, req, res, next) => { + if (fehler?.type === "entity.too.large" || fehler?.status === 413) { + return res.status(413).json({ + fehler: `Die PDF ist zu groß – erlaubt sind ${Math.round(MAX_BYTES / 1024 / 1024)} MB.`, + }); + } + return next(fehler); +}); diff --git a/server/workspace.js b/server/workspace.js index 2c686f65..acf7e1ab 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -237,6 +237,32 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id); + /* Wissens-Bibliothek: Anleitungen als PDF, hinter dem Login. + Jede PDF hat GENAU EINE Hauptkategorie -- so gibt es sie nur + einmal. Zusaetzliche Themen laufen ueber Tags, damit dieselbe + Anleitung trotzdem ueber mehrere Suchbegriffe auffindbar ist. + Die Kategorien selbst stehen im Code (workspace-wissen.js), + nicht hier: Sie sind eine bewusste Gliederung, keine Nutzdaten. */ + CREATE TABLE IF NOT EXISTS wissen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titel TEXT NOT NULL, + beschreibung TEXT, + kategorie TEXT NOT NULL, + stufe TEXT NOT NULL DEFAULT 'einsteiger' + CHECK (stufe IN ('einsteiger','fortgeschritten','profi')), + geraet TEXT NOT NULL DEFAULT 'egal' + CHECK (geraet IN ('pc','handy','beide','egal')), + tags TEXT, + name_original TEXT NOT NULL, + name_datei TEXT NOT NULL, + groesse INTEGER NOT NULL, + wichtig INTEGER NOT NULL DEFAULT 0, + veroeffentlicht TEXT NOT NULL, + aktualisiert TEXT, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_wissen_kat ON wissen (kategorie); + /* Start-Check / Erstanalyse (Konzept, Seite 5). Eine Zeile je geprueftem Punkt -- ungeprueft heisst: gar keine Zeile. So sieht man am Bestand sofort, wie weit die Analyse ist, ohne leere @@ -462,6 +488,7 @@ const GESCHUETZT = { "/workspace/scouting.html": ["admin", "scout"], "/workspace/calls.html": null, "/workspace/startcheck.html": null, + "/workspace/wissen.html": null, }; workspaceRouter.use((req, res, next) => { diff --git a/workspace/assets/css/wissen.css b/workspace/assets/css/wissen.css new file mode 100644 index 00000000..b8908727 --- /dev/null +++ b/workspace/assets/css/wissen.css @@ -0,0 +1,241 @@ +/* =================================================================== + Wissens-Bibliothek. + + Zwanzig Kategorien sind viel. Damit sie trotzdem in einem Blick + erfassbar bleiben, sind sie in sechs Welten gebuendelt, und jede Welt + hat ihre eigene Farbe. Man sucht dann nicht unter zwanzig gleichen + Kacheln, sondern erst die Welt -- und da stehen nur noch drei bis vier. + + Die Farben sind gedeckt und tragen nur den Symbolring, nicht die + ganze Kachel. Zwanzig farbige Flaechen waeren ein Flickenteppich. + =================================================================== */ + +/* ---------- Suche und Filter ---------------------------------------------- */ + +.suchleiste { + display: grid; gap: 12px; margin-bottom: 30px; + padding: 16px 18px; + background: rgba(255, 255, 255, .028); + border: 1px solid var(--rand); border-radius: var(--radius); +} +.suchleiste__feld { position: relative; display: flex; align-items: center; } +.suchleiste__lupe { + position: absolute; left: 13px; width: 17px; height: 17px; + fill: none; stroke: var(--text-still); stroke-width: 1.8; stroke-linecap: round; + pointer-events: none; +} +.suchleiste__feld input { + width: 100%; padding: 11px 14px 11px 40px; + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} +.suchleiste__feld input:focus { + outline: none; border-color: rgba(63, 189, 245, .6); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .14); +} +.suchleiste__feld input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; } + +.suchleiste__filter { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.suchleiste__filter .feld-schild { margin: 0; } +.suchleiste__filter select { + padding: 8px 11px; min-width: 118px; + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; font-size: .85rem; +} +.suchleiste__filter select:focus { + outline: none; border-color: rgba(63, 189, 245, .6); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .14); +} + +/* ---------- Bloecke -------------------------------------------------------- */ + +.block { margin-bottom: 34px; } +.block__kopf { display: flex; align-items: center; gap: 10px; margin-bottom: 13px; } +.block__titel { + margin: 0; font-size: .78rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--akzent); +} +.block__zahl { + min-width: 22px; padding: 1px 8px; text-align: center; + font-size: .74rem; font-weight: 700; color: var(--text-still); + background: rgba(255, 255, 255, .06); border-radius: 999px; +} + +/* ---------- Kacheln --------------------------------------------------------- */ + +.kacheln { + display: grid; gap: 11px; + grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); +} + +.kachel { + --w: var(--akzent); + position: relative; display: flex; align-items: flex-start; gap: 14px; + padding: 15px 16px; + text-decoration: none; color: var(--text); + border: 1px solid transparent; border-radius: 13px; + background: + linear-gradient(#111825, #0c1320) padding-box, + linear-gradient(150deg, + color-mix(in srgb, var(--w) 42%, transparent), + rgba(255, 255, 255, .06) 55%, + color-mix(in srgb, var(--w) 16%, transparent)) border-box; + transition: transform .16s ease, box-shadow .2s ease; +} +.kachel::after { + content: ""; position: absolute; left: 14px; right: 14px; top: 1px; height: 1px; + background: linear-gradient(90deg, transparent, + color-mix(in srgb, var(--w) 45%, transparent), transparent); +} +.kachel:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px -12px color-mix(in srgb, var(--w) 85%, transparent); +} +.kachel:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--w) 32%, transparent); +} +.kachel[data-leer="ja"] { opacity: .55; } +.kachel[data-leer="ja"]:hover { opacity: 1; } + +.kachel__ring { + flex: none; display: grid; place-items: center; + width: 42px; height: 42px; border-radius: 12px; + background: color-mix(in srgb, var(--w) 13%, transparent); + border: 1px solid color-mix(in srgb, var(--w) 30%, transparent); +} +.kachel__symbol { + width: 21px; height: 21px; + fill: none; stroke: var(--w); stroke-width: 1.6; + stroke-linecap: round; stroke-linejoin: round; +} +.kachel__text { min-width: 0; flex: 1; } +.kachel__name { display: block; font-size: .93rem; font-weight: 650; line-height: 1.3; } +.kachel__beschreibung { + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; margin-top: 3px; + font-size: .78rem; color: var(--text-still); line-height: 1.45; +} +.kachel__zahl { + flex: none; min-width: 26px; padding: 2px 8px; align-self: flex-start; + text-align: center; font-size: .75rem; font-weight: 700; + color: color-mix(in srgb, var(--w) 55%, #ffffff); + background: color-mix(in srgb, var(--w) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--w) 26%, transparent); + border-radius: 999px; +} + +/* Eine Farbe je Welt -- die Reihenfolge folgt der Gliederung. */ +.block[data-welt="tiktok"] .kachel { --w: #3fbdf5; } +.block[data-welt="aufbau"] .kachel { --w: #8a76ff; } +.block[data-welt="bildton"] .kachel { --w: #5fc99a; } +.block[data-welt="technik"] .kachel { --w: #e8b877; } +.block[data-welt="inhalt"] .kachel { --w: #e88fb4; } +.block[data-welt="hilfe"] .kachel { --w: #8e9cb0; } + +/* ---------- Karte einer Anleitung -------------------------------------------- */ + +.karten { display: grid; gap: 10px; } + +.pdf { + padding: 15px 17px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + transition: border-color .18s ease; +} +.pdf:hover { border-color: rgba(255, 255, 255, .18); } +.pdf[data-wichtig="ja"] { + border-color: rgba(232, 192, 125, .32); + background: rgba(232, 192, 125, .045); +} + +.pdf__kopf { display: flex; gap: 14px; align-items: flex-start; } +.pdf__ring { + flex: none; display: grid; place-items: center; + width: 38px; height: 38px; border-radius: 11px; + background: rgba(63, 189, 245, .1); + border: 1px solid rgba(63, 189, 245, .24); +} +.pdf__symbol { + width: 19px; height: 19px; + fill: none; stroke: var(--akzent); stroke-width: 1.6; + stroke-linecap: round; stroke-linejoin: round; +} +.pdf__mitte { min-width: 0; flex: 1; } +.pdf__titel { margin: 0; font-size: 1rem; font-weight: 650; line-height: 1.35; } +.pdf__text { margin: 4px 0 0; font-size: .86rem; color: var(--text-leise); max-width: 78ch; } +.pdf__fuss { margin: 9px 0 0; font-size: .75rem; color: var(--text-still); } + +.pdf__marken { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 9px; } +.marke-stufe, .marke-geraet, .marke-wichtig, .marke-tag { + padding: 3px 9px; border-radius: 999px; + font: inherit; font-size: .71rem; font-weight: 600; + border: 1px solid var(--rand); color: var(--text-still); + background: transparent; +} +.marke-stufe[data-stufe="einsteiger"] { + color: #5fc99a; border-color: rgba(95, 201, 154, .38); background: rgba(95, 201, 154, .1); +} +.marke-stufe[data-stufe="fortgeschritten"] { + color: #e8b877; border-color: rgba(232, 184, 119, .38); background: rgba(232, 184, 119, .1); +} +.marke-stufe[data-stufe="profi"] { + color: #e88fb4; border-color: rgba(232, 143, 180, .4); background: rgba(232, 143, 180, .1); +} +.marke-geraet { color: var(--text-leise); } +.marke-wichtig { + color: #e8c07d; border-color: rgba(232, 192, 125, .4); background: rgba(232, 192, 125, .1); +} +.marke-tag { cursor: pointer; transition: color .16s ease, border-color .16s ease; } +.marke-tag:hover { color: var(--akzent); border-color: rgba(63, 189, 245, .42); } + +.pdf__knoepfe { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 13px; } + +/* ---------- Formular ---------------------------------------------------------- */ + +.haken { display: flex; align-items: center; gap: 9px; font-size: .87rem; cursor: pointer; } +.haken input { width: 17px; height: 17px; accent-color: var(--akzent); cursor: pointer; } + +.tag-vorschlag { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.tag-knopf { + padding: 4px 10px; cursor: pointer; + background: transparent; border: 1px dashed var(--rand); border-radius: 999px; + color: var(--text-still); font: inherit; font-size: .74rem; + transition: color .16s ease, border-color .16s ease; +} +.tag-knopf:hover { color: var(--akzent); border-color: rgba(63, 189, 245, .45); border-style: solid; } + +#n-datei { + width: 100%; padding: 10px 12px; + background: rgba(255, 255, 255, .04); + border: 1px dashed var(--rand); border-radius: var(--radius-klein); + color: var(--text-leise); font: inherit; font-size: .85rem; cursor: pointer; +} +#n-datei::file-selector-button { + margin-right: 12px; padding: 6px 13px; cursor: pointer; + background: rgba(63, 189, 245, .12); + border: 1px solid rgba(63, 189, 245, .3); border-radius: 8px; + color: var(--akzent); font: inherit; font-size: .8rem; font-weight: 600; +} + +.leer-hinweis { + display: grid; gap: 12px; justify-items: center; + padding: 34px 22px; margin-bottom: 26px; text-align: center; + color: var(--text-still); font-size: .92rem; + border: 1px dashed var(--rand); border-radius: var(--radius); +} +.leer-hinweis p { margin: 0; max-width: 52ch; } + +@media (max-width: 620px) { + .kacheln { grid-template-columns: 1fr; } + .suchleiste__filter { display: grid; grid-template-columns: auto 1fr; gap: 8px 10px; } + .suchleiste__filter .knopf-still { grid-column: 1 / -1; justify-self: start; } + .pdf__ring { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .kachel, .pdf, .marke-tag, .tag-knopf { transition: none; } + .kachel:hover { transform: none; } +} diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index c650d98d..d0267ca2 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -21,6 +21,7 @@ { name: 'Creator-Profile', phase: 1, rollen: ['admin', 'scout'], ziel: 'profil.html' }, { name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' }, { name: 'Start-Check', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'startcheck.html' }, + { name: 'Wissens-Bibliothek', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'wissen.html' }, { name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' }, { name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'kalender.html' }, { name: 'Dateien', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'dateien.html' }, diff --git a/workspace/assets/js/wissen.js b/workspace/assets/js/wissen.js new file mode 100644 index 00000000..4465430c --- /dev/null +++ b/workspace/assets/js/wissen.js @@ -0,0 +1,444 @@ +/* =================================================================== + Wissens-Bibliothek. + + Zwei Ansichten, eine Seite: die Übersicht mit den Kacheln und die + Liste innerhalb einer Kategorie. Der Zustand steht in der Adresse + (?k=...), damit Zurück, Neuladen und Weiterschicken funktionieren -- + eine Kategorie, die man niemandem verlinken kann, ist keine Seite. + + Sobald gesucht oder gefiltert wird, verschwinden die Kacheln und es + erscheinen Treffer. Wer sucht, will nicht erst noch klicken. + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + let aufbau = null; + let bearbeite = null; // id, wenn ein vorhandener Eintrag geändert wird + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + const melde = (t) => { $('fehler').textContent = t || ''; }; + const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : ''); + const groesse = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.round(b / 1024) + ' KB'); + + function symbol(name, klasse) { + const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + s.setAttribute('viewBox', '0 0 24 24'); + s.setAttribute('aria-hidden', 'true'); + if (klasse) s.setAttribute('class', klasse); + const u = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + u.setAttribute('href', '#s-' + name); + s.append(u); + return s; + } + + 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; + } + + /* ---------- Zustand in der Adresse ------------------------------------- */ + + const zustand = () => { + const p = new URLSearchParams(location.search); + return { + k: p.get('k') || '', q: p.get('q') || '', + stufe: p.get('stufe') || '', geraet: p.get('geraet') || '', tag: p.get('tag') || '', + }; + }; + + function setzeZustand(neu, ersetzen = false) { + const z = { ...zustand(), ...neu }; + const p = new URLSearchParams(); + for (const [n, w] of Object.entries(z)) if (w) p.set(n, w); + const ziel = location.pathname + (p.toString() ? '?' + p : ''); + if (ersetzen) history.replaceState(null, '', ziel); else history.pushState(null, '', ziel); + /* Gefiltert wird auf dem Server, nicht im Browser. Sonst muesste die + ganze Bibliothek beim ersten Aufruf mitgeladen werden, auch wenn + man nur eine Kategorie sehen will. */ + laden_(); + } + + const filtertAktiv = () => { + const z = zustand(); + return !!(z.q || z.stufe || z.geraet || z.tag); + }; + + /* ---------- Kachelübersicht -------------------------------------------- */ + + function uebersicht(ziel, neuLetzte) { + /* Neu & wichtig zuerst -- der Einstieg für alle, die nicht suchen, + sondern sehen wollen, was dazugekommen ist. */ + if (neuLetzte.length) { + const s = el('section', 'block'); + const kopf = el('div', 'block__kopf'); + kopf.append(el('h2', 'block__titel', 'Neu & wichtig')); + s.append(kopf); + const box = el('div', 'karten'); + for (const e of neuLetzte) box.append(karte(e)); + s.append(box); + ziel.append(s); + } + + for (const welt of aufbau.welten) { + const kats = aufbau.kategorien.filter((k) => k.welt === welt.schluessel); + if (!kats.length) continue; + const s = el('section', 'block'); + /* Traegt die Farbe der Welt an alle Kacheln darin weiter. */ + s.dataset.welt = welt.schluessel; + const kopf = el('div', 'block__kopf'); + kopf.append(el('h2', 'block__titel', welt.name)); + const summe = kats.reduce((n, k) => n + k.anzahl, 0); + kopf.append(el('span', 'block__zahl', String(summe))); + s.append(kopf); + + const raster = el('div', 'kacheln'); + for (const k of kats) { + const a = el('a', 'kachel'); + a.href = '?k=' + encodeURIComponent(k.schluessel); + a.addEventListener('click', (e) => { e.preventDefault(); setzeZustand({ k: k.schluessel, q: '' }); }); + if (!k.anzahl) a.dataset.leer = 'ja'; + + const ring = el('span', 'kachel__ring'); + ring.append(symbol(k.symbol, 'kachel__symbol')); + a.append(ring); + + const text = el('span', 'kachel__text'); + text.append(el('span', 'kachel__name', k.name)); + text.append(el('span', 'kachel__beschreibung', k.beschreibung)); + a.append(text); + + const zahl = el('span', 'kachel__zahl', k.anzahl ? String(k.anzahl) : '–'); + zahl.title = k.anzahl === 1 ? '1 Anleitung' : k.anzahl + ' Anleitungen'; + a.append(zahl); + raster.append(a); + } + s.append(raster); + ziel.append(s); + } + } + + /* ---------- Karte einer Anleitung -------------------------------------- */ + + function karte(e) { + const kat = aufbau.kategorien.find((k) => k.schluessel === e.kategorie); + const k = el('article', 'pdf'); + if (e.wichtig) k.dataset.wichtig = 'ja'; + + const kopf = el('div', 'pdf__kopf'); + const ring = el('span', 'pdf__ring'); + ring.append(symbol(kat ? kat.symbol : 'haken', 'pdf__symbol')); + kopf.append(ring); + + const mitte = el('div', 'pdf__mitte'); + mitte.append(el('h3', 'pdf__titel', e.titel)); + if (e.beschreibung) mitte.append(el('p', 'pdf__text', e.beschreibung)); + + const marken = el('div', 'pdf__marken'); + const stufe = el('span', 'marke-stufe', aufbau.stufen[e.stufe] || e.stufe); + stufe.dataset.stufe = e.stufe; + marken.append(stufe); + if (e.geraet && e.geraet !== 'egal') marken.append(el('span', 'marke-geraet', aufbau.geraete[e.geraet])); + if (e.wichtig) marken.append(el('span', 'marke-wichtig', 'Wichtig')); + for (const t of e.tags) { + const b = el('button', 'marke-tag', '#' + t); + b.type = 'button'; + b.title = 'Nach diesem Tag filtern'; + b.addEventListener('click', () => setzeZustand({ tag: t })); + marken.append(b); + } + mitte.append(marken); + + const fuss = el('p', 'pdf__fuss'); + fuss.append(document.createTextNode( + (kat ? kat.name + ' · ' : '') + + 'veröffentlicht ' + datum(e.veroeffentlicht) + + (e.aktualisiert && e.aktualisiert !== e.veroeffentlicht + ? ' · aktualisiert ' + datum(e.aktualisiert) : '') + + ' · ' + groesse(e.groesse))); + mitte.append(fuss); + kopf.append(mitte); + k.append(kopf); + + const knoepfe = el('div', 'pdf__knoepfe'); + const ansehen = el('a', 'knopf knopf--klein', 'Öffnen'); + ansehen.href = `/workspace/api/wissen/${e.id}/datei?modus=ansehen`; + ansehen.target = '_blank'; + ansehen.rel = 'noopener'; + const laden = el('a', 'knopf-still', 'Herunterladen'); + laden.href = `/workspace/api/wissen/${e.id}/datei`; + knoepfe.append(ansehen, laden); + + if (aufbau.darf_pflegen) { + const aendern = el('button', 'knopf-still', 'Bearbeiten'); + aendern.type = 'button'; + aendern.addEventListener('click', () => formularOeffnen(e)); + const weg = el('button', 'knopf-still knopf-still--warn', 'Löschen'); + weg.type = 'button'; + weg.addEventListener('click', async () => { + if (!confirm(`„${e.titel}" wirklich löschen?\n\nDie PDF wird dabei auch vom Server entfernt.`)) return; + const a = await hole('/workspace/api/wissen/' + e.id, { method: 'DELETE' }); + if (!a.ok) { melde('Löschen hat nicht geklappt.'); return; } + await laden_(); + }); + knoepfe.append(aendern, weg); + } + k.append(knoepfe); + return k; + } + + /* ---------- Zeichnen ----------------------------------------------------- */ + + let daten = { eintraege: [], neu: [] }; + + function zeichne() { + const z = zustand(); + const ziel = $('inhalt'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + /* Kopf an die Ansicht anpassen */ + const kat = aufbau.kategorien.find((k) => k.schluessel === z.k); + if (kat) { + $('titel').textContent = kat.name; + $('unterzeile').textContent = kat.beschreibung; + } else { + $('titel').textContent = 'Wissens-Bibliothek'; + $('unterzeile').textContent = aufbau.gesamt + ? `${aufbau.gesamt} Anleitung${aufbau.gesamt === 1 ? '' : 'en'} zu TikTok, Livestreaming, Technik und Content.` + : 'Noch keine Anleitung eingestellt.'; + } + $('f-weg').hidden = !(filtertAktiv() || z.k); + + /* Suchen oder filtern schlägt die Kachelansicht -- wer sucht, will + Treffer sehen, nicht erst noch eine Kachel anklicken. */ + if (!z.k && !filtertAktiv()) { + if (!aufbau.gesamt) { + const leer = el('div', 'leer-hinweis'); + leer.append(el('p', null, 'Die Bibliothek ist noch leer.')); + leer.append(el('p', 'leise', aufbau.darf_pflegen + ? 'Mit „PDF hinzufügen“ oben rechts geht es los. Die zwanzig Kategorien stehen schon bereit.' + : 'Sobald DogFather Anleitungen einstellt, erscheinen sie hier.')); + ziel.append(leer); + } + uebersicht(ziel, daten.neu); + return; + } + + const liste = daten.eintraege; + if (!liste.length) { + const leer = el('div', 'leer-hinweis'); + leer.append(el('p', null, kat && !filtertAktiv() + ? 'In dieser Kategorie liegt noch keine Anleitung.' + : 'Dazu wurde nichts gefunden.')); + const b = el('button', 'knopf-still', 'Filter zurücksetzen'); + b.type = 'button'; + b.addEventListener('click', () => setzeZustand({ k: '', q: '', stufe: '', geraet: '', tag: '' })); + leer.append(b); + ziel.append(leer); + return; + } + + const kopf = el('div', 'block__kopf'); + kopf.append(el('h2', 'block__titel', filtertAktiv() ? 'Treffer' : 'Anleitungen')); + kopf.append(el('span', 'block__zahl', String(liste.length))); + ziel.append(kopf); + + const box = el('div', 'karten'); + for (const e of liste) box.append(karte(e)); + ziel.append(box); + } + + async function laden_() { + const z = zustand(); + const p = new URLSearchParams(); + for (const [n, w] of Object.entries(z)) if (w) p.set(n, w); + try { + const [a1, a2] = await Promise.all([ + hole('/workspace/api/wissen/aufbau'), + hole('/workspace/api/wissen?' + p), + ]); + if (!a1.ok || !a2.ok) { melde('Die Bibliothek konnte nicht geladen werden.'); return; } + aufbau = await a1.json(); + daten = await a2.json(); + fuelleFilter(); + zeichne(); + } catch { /* umgeleitet */ } + } + + /* ---------- Filterfelder ------------------------------------------------- */ + + let filterGefuellt = false; + function fuelleFilter() { + const z = zustand(); + const setz = (id, eintraege, wert) => { + const f = $(id); + f.textContent = ''; + const alle = document.createElement('option'); + alle.value = ''; alle.textContent = 'Alle'; + f.append(alle); + for (const [w, name] of eintraege) { + const o = document.createElement('option'); + o.value = w; o.textContent = name; + f.append(o); + } + f.value = wert || ''; + }; + setz('f-stufe', Object.entries(aufbau.stufen), z.stufe); + setz('f-geraet', [['pc', 'PC'], ['handy', 'Handy']], z.geraet); + setz('f-tag', aufbau.tags.map((t) => [t, '#' + t]), z.tag); + $('q').value = z.q; + + if (filterGefuellt) return; + filterGefuellt = true; + $('f-stufe').addEventListener('change', () => setzeZustand({ stufe: $('f-stufe').value })); + $('f-geraet').addEventListener('change', () => setzeZustand({ geraet: $('f-geraet').value })); + $('f-tag').addEventListener('change', () => setzeZustand({ tag: $('f-tag').value })); + $('f-weg').addEventListener('click', () => setzeZustand({ k: '', q: '', stufe: '', geraet: '', tag: '' })); + let warte = null; + $('q').addEventListener('input', () => { + clearTimeout(warte); + warte = setTimeout(() => setzeZustand({ q: $('q').value.trim() }, true), 260); + }); + } + + window.addEventListener('popstate', laden_); + + /* ---------- Formular ------------------------------------------------------ */ + + function formularOeffnen(eintrag) { + bearbeite = eintrag ? eintrag.id : null; + $('neu-titel').textContent = eintrag ? 'Anleitung bearbeiten' : 'Neue Anleitung'; + $('n-speichern').textContent = eintrag ? 'Änderungen speichern' : 'Veröffentlichen'; + /* Beim Bearbeiten wird die Datei nicht ersetzt -- wer eine neue + Fassung hat, stellt sie neu ein und löscht die alte. So bleibt + nachvollziehbar, was wann galt. */ + $('n-datei-block').hidden = !!eintrag; + $('n-titel').value = eintrag ? eintrag.titel : ''; + $('n-beschreibung').value = eintrag ? (eintrag.beschreibung || '') : ''; + $('n-kategorie').value = eintrag ? eintrag.kategorie : (zustand().k || aufbau.kategorien[0].schluessel); + $('n-stufe').value = eintrag ? eintrag.stufe : 'einsteiger'; + $('n-geraet').value = eintrag ? eintrag.geraet : 'egal'; + $('n-tags').value = eintrag ? eintrag.tags.join(', ') : ''; + $('n-wichtig').checked = !!(eintrag && eintrag.wichtig); + $('n-datum').value = eintrag ? eintrag.veroeffentlicht : new Date().toISOString().slice(0, 10); + $('n-fehler').textContent = ''; + $('neu-block').hidden = false; + $('neu-auf').hidden = true; + $('n-titel').focus(); + $('neu-block').scrollIntoView({ block: 'start', behavior: 'smooth' }); + } + + function formularSchliessen() { + $('neu-block').hidden = true; + $('neu-auf').hidden = !aufbau.darf_pflegen; + $('neu-form').reset(); + $('n-dateiname').textContent = 'Höchstens 40 MB. Nur echte PDF-Dateien.'; + bearbeite = null; + } + + function fuelleFormular() { + const setz = (id, eintraege) => { + const f = $(id); + f.textContent = ''; + for (const [w, name] of eintraege) { + const o = document.createElement('option'); + o.value = w; o.textContent = name; + f.append(o); + } + }; + setz('n-kategorie', aufbau.kategorien.map((k) => [k.schluessel, k.nr + '. ' + k.name])); + setz('n-stufe', Object.entries(aufbau.stufen)); + setz('n-geraet', Object.entries(aufbau.geraete)); + + const v = $('n-vorschlaege'); + v.textContent = ''; + for (const t of aufbau.tag_vorschlaege) { + const b = el('button', 'tag-knopf', '#' + t); + b.type = 'button'; + b.addEventListener('click', () => { + const jetzt = $('n-tags').value.split(',').map((x) => x.trim()).filter(Boolean); + if (jetzt.includes(t)) return; + jetzt.push(t); + $('n-tags').value = jetzt.join(', '); + }); + v.append(b); + } + } + + $('neu-auf').addEventListener('click', () => formularOeffnen(null)); + $('neu-zu').addEventListener('click', formularSchliessen); + $('n-datei').addEventListener('change', () => { + const d = $('n-datei').files[0]; + $('n-dateiname').textContent = d + ? `${d.name} · ${groesse(d.size)}` + : 'Höchstens 40 MB. Nur echte PDF-Dateien.'; + }); + + $('neu-form').addEventListener('submit', async (e) => { + e.preventDefault(); + $('n-fehler').textContent = ''; + const knopf = $('n-speichern'); + const angaben = { + titel: $('n-titel').value.trim(), + beschreibung: $('n-beschreibung').value.trim(), + kategorie: $('n-kategorie').value, + stufe: $('n-stufe').value, + geraet: $('n-geraet').value, + tags: $('n-tags').value, + wichtig: $('n-wichtig').checked ? '1' : '0', + veroeffentlicht: $('n-datum').value, + }; + if (angaben.titel.length < 3) { $('n-fehler').textContent = 'Der Titel fehlt.'; return; } + + knopf.disabled = true; + try { + let a; + if (bearbeite) { + a = await hole('/workspace/api/wissen/' + bearbeite, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(angaben), + }); + } else { + const datei = $('n-datei').files[0]; + if (!datei) { $('n-fehler').textContent = 'Wähle eine PDF-Datei aus.'; return; } + const kopf = { 'Content-Type': 'application/pdf', 'x-dateiname': encodeURIComponent(datei.name) }; + for (const [n, w] of Object.entries(angaben)) kopf['x-' + n] = encodeURIComponent(w); + a = await hole('/workspace/api/wissen', { method: 'POST', headers: kopf, body: datei }); + } + if (!a.ok) { + $('n-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Das hat nicht geklappt.'; + return; + } + formularSchliessen(); + melde(''); + $('n-stand').textContent = ''; + await laden_(); + } catch { /* umgeleitet */ } finally { knopf.disabled = false; } + }); + + /* ---------- Start --------------------------------------------------------- */ + + (async () => { + let ich; + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + ich = await a.json(); + } catch { return; } + $('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle); + + await laden_(); + if (aufbau) { + fuelleFormular(); + $('neu-auf').hidden = !aufbau.darf_pflegen; + } + })(); +})(); diff --git a/workspace/wissen.html b/workspace/wissen.html new file mode 100644 index 00000000..c01358a4 --- /dev/null +++ b/workspace/wissen.html @@ -0,0 +1,151 @@ + + +
+ + +Creator Workspace · Wissen
+Anleitungen & Checklisten
+Alles zu TikTok, Livestreaming, Technik und Content – an einer Stelle.
+Bibliothek wird geladen …