Workspace: Phase 2 -- LIVE, Content, Technik, Community, Schutz
Fuenf Bereiche auf einmal, aber NICHT fuenf Module. Im Deck haben sie dieselbe Grundform: Eintraege zu einem Creator mit Art, Datum, Titel, Text und Status. Sie unterscheiden sich nur darin, welche Arten es gibt und ob eine Bewertung oder eine Dringlichkeit dazugehoert. Deshalb ein gemeinsamer Unterbau: eine Tabelle, eine Sichtbarkeitsregel, eine Pruefung, eine Ansichtsseite (bereich.html?b=live). Ein Fehler laesst sich damit an EINER Stelle beheben statt an fuenf, und ein weiterer Bereich waere ein Eintrag in BEREICHE -- kein neues Modul. Arten woertlich aus dem Deck (Seiten 7-12): live Vorbereitung / Waehrend LIVE / Auswertung + Bewertung 1-5 content Idee / Produktion / Veroeffentlicht technik Setup / Problem / Loesung / Anleitung + Dringlichkeit community Moderation / Aktion / Konflikt + Dringlichkeit schutz Richtlinie / Vorfall / Eskalation / Gelernt + Dringlichkeit Die Oberflaeche kennt die Bereiche nicht auswendig -- welche Arten und Zusatzfelder es gibt, sagt der Server in der Antwort. Geprueft, und zwar fuer alle fuenf gleichzeitig: - Sichtbarkeit: Chef sieht alles, Luna nur ihren Bereich, Mika nur seinen, Sam (Scout) gar nichts (404 -- Scouts haben mit der Creator-Betreuung nichts zu tun) - Luna legt Eintrag mit creator_id=Mika an: landet still in ihrem eigenen Bereich, Mika sieht ihn nicht - Luna auf Mikas Eintrag: 404; Luna loescht Chefs Eintrag ueber ihren Bereich: 403 (loeschen darf das Management und wer ihn schrieb -- sonst koennte ein Creator eine Notiz ueber sich verschwinden lassen) - Eintrag ueber den falschen Bereich in der Adresse ansprechen: 404 - unbekannte Art 400, Bewertung 9 (erlaubt 1-5) 400, unbekannter Bereich 404, fremde Herkunft 403 Beim Testen sahen Umlaute zunaechst zerstoert aus (efbfbd, das Unicode-Ersatzzeichen). Ursache war der curl-Aufruf: Git Bash kodiert $'\xc3\xbc' nach Locale um. Ueber den echten Weg (Browser) kommen Umlaute, ss und Gedankenstrich unveraendert an und liegen sauber in der Datenbank -- gegengeprueft auf Byte-Ebene.
This commit is contained in:
@@ -19,6 +19,7 @@ import { personenRouter } from "./workspace-personen.js";
|
||||
import { profilRouter } from "./workspace-profil.js";
|
||||
import { kalenderRouter } from "./workspace-kalender.js";
|
||||
import { dateienRouter } from "./workspace-dateien.js";
|
||||
import { bereicheRouter } from "./workspace-bereiche.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SITE_DIR = join(__dirname, "..");
|
||||
@@ -78,6 +79,7 @@ app.use(personenRouter);
|
||||
app.use(profilRouter);
|
||||
app.use(kalenderRouter);
|
||||
app.use(dateienRouter);
|
||||
app.use(bereicheRouter);
|
||||
|
||||
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
|
||||
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/* =====================================================================
|
||||
workspace-bereiche.js — LIVE, Content, Technik, Community und Schutz.
|
||||
|
||||
Diese fuenf Bereiche aus Phase 2 haben im Konzept dieselbe Grundform:
|
||||
Eintraege zu einem Creator, mit Art, Datum, Titel, Text und Status.
|
||||
Sie unterscheiden sich nur darin, WELCHE Arten es gibt und ob eine
|
||||
Bewertung oder eine Dringlichkeit dazugehoert.
|
||||
|
||||
Deshalb ein gemeinsamer Unterbau statt fuenf fast gleicher Module:
|
||||
eine Tabelle, eine Sichtbarkeitsregel, eine Pruefung. Ein Fehler laesst
|
||||
sich damit an einer Stelle beheben statt an fuenf, und ein neuer
|
||||
Bereich ist ein Eintrag in BEREICHE -- kein neues Modul.
|
||||
|
||||
Die Feldnamen und Arten stammen woertlich aus dem Deck (Seiten 7-12).
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js";
|
||||
|
||||
export const bereicheRouter = express.Router();
|
||||
|
||||
/* Die Bereiche und ihre Arten. Bewusst hier und nicht in der Datenbank:
|
||||
Es sind Festlegungen aus dem Konzept, keine Nutzdaten. */
|
||||
export const BEREICHE = {
|
||||
live: {
|
||||
name: "LIVE-Analyse",
|
||||
arten: { vorbereitung: "Vorbereitung", mitschrift: "Während LIVE", auswertung: "Auswertung" },
|
||||
bewertung: true, // Review-Score aus dem Konzept, Seite 7
|
||||
dringlichkeit: false,
|
||||
},
|
||||
content: {
|
||||
name: "Content-Planung",
|
||||
arten: { idee: "Idee", produktion: "Produktion", veroeffentlicht: "Veröffentlicht" },
|
||||
bewertung: false,
|
||||
dringlichkeit: false,
|
||||
},
|
||||
technik: {
|
||||
name: "Technik",
|
||||
arten: { setup: "Setup", problem: "Problem", loesung: "Lösung", anleitung: "Anleitung" },
|
||||
bewertung: false,
|
||||
dringlichkeit: true,
|
||||
},
|
||||
community: {
|
||||
name: "Community",
|
||||
arten: { moderation: "Moderation", aktion: "Aktion", konflikt: "Konflikt" },
|
||||
bewertung: false,
|
||||
dringlichkeit: true,
|
||||
},
|
||||
schutz: {
|
||||
name: "Schutz & Regeln",
|
||||
arten: { richtlinie: "Richtlinie", vorfall: "Vorfall", eskalation: "Eskalation", gelernt: "Gelernt" },
|
||||
bewertung: false,
|
||||
dringlichkeit: true,
|
||||
},
|
||||
};
|
||||
|
||||
const DRINGLICHKEITEN = ["hoch", "mittel", "niedrig"];
|
||||
const STATUS = ["offen", "erledigt"];
|
||||
const TITEL_MAX = 160;
|
||||
const TEXT_MAX = 6000;
|
||||
|
||||
const jetzt = () => new Date().toISOString();
|
||||
|
||||
function angemeldet(req, res, next) {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
req.person = person;
|
||||
next();
|
||||
}
|
||||
|
||||
function gleicheHerkunft(req, res, next) {
|
||||
const herkunft = req.get("origin");
|
||||
if (!herkunft) return next();
|
||||
let erlaubt;
|
||||
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
||||
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
||||
next();
|
||||
}
|
||||
|
||||
bereicheRouter.use("/workspace/api/bereich", angemeldet);
|
||||
|
||||
/* Scouts haben mit der Creator-Betreuung nichts zu tun -- sie sehen hier
|
||||
nichts. Creator sehen ihren eigenen Bereich, das Management alles. */
|
||||
function sichtbar(person) {
|
||||
if (person.rolle === "admin") return { wo: "1=1", werte: [] };
|
||||
if (person.rolle === "creator") return { wo: "e.creator_id = ?", werte: [person.id] };
|
||||
return null;
|
||||
}
|
||||
|
||||
const SPALTEN = `
|
||||
e.id, e.bereich, e.art, e.titel, e.text, e.datum, e.bewertung,
|
||||
e.dringlichkeit, e.status, e.creator_id, e.erstellt, e.erstellt_von, e.geaendert,
|
||||
pc.name AS creator_name, pe.name AS erstellt_name`;
|
||||
|
||||
const VERBUND = `
|
||||
FROM eintraege e
|
||||
LEFT JOIN personen pc ON pc.id = e.creator_id
|
||||
LEFT JOIN personen pe ON pe.id = e.erstellt_von`;
|
||||
|
||||
/* ---------- Lesen ------------------------------------------------------- */
|
||||
|
||||
bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
|
||||
try {
|
||||
const bereich = String(req.params.bereich);
|
||||
const einstellung = BEREICHE[bereich];
|
||||
if (!einstellung) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const regel = sichtbar(req.person);
|
||||
if (!regel) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const eintraege = db().prepare(`
|
||||
SELECT ${SPALTEN} ${VERBUND}
|
||||
WHERE ${regel.wo} AND e.bereich = ?
|
||||
ORDER BY
|
||||
CASE e.status WHEN 'offen' THEN 0 ELSE 1 END,
|
||||
CASE e.dringlichkeit WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END,
|
||||
e.datum DESC, e.id DESC`).all(...regel.werte, bereich);
|
||||
|
||||
res.json({ bereich, einstellung, eintraege });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Bereich lesen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Prüfen ------------------------------------------------------- */
|
||||
|
||||
function pruefe(bereich, körper, { neu }) {
|
||||
const einstellung = BEREICHE[bereich];
|
||||
const fehler = [];
|
||||
const aus = {};
|
||||
|
||||
if (neu || körper.titel !== undefined) {
|
||||
const t = String(körper.titel ?? "").trim();
|
||||
if (t.length < 2) fehler.push("Titel fehlt.");
|
||||
else if (t.length > TITEL_MAX) fehler.push("Titel ist zu lang.");
|
||||
else aus.titel = t;
|
||||
}
|
||||
if (neu || körper.art !== undefined) {
|
||||
const a = String(körper.art ?? "");
|
||||
if (!Object.hasOwn(einstellung.arten, a)) fehler.push("Unbekannte Art.");
|
||||
else aus.art = a;
|
||||
}
|
||||
if (körper.text !== undefined) {
|
||||
const t = String(körper.text ?? "").trim();
|
||||
if (t.length > TEXT_MAX) fehler.push("Text ist zu lang.");
|
||||
else aus.text = t || null;
|
||||
}
|
||||
if (neu || körper.datum !== undefined) {
|
||||
const d = String(körper.datum ?? "").trim();
|
||||
if (!d) aus.datum = jetzt().slice(0, 10);
|
||||
else if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || Number.isNaN(Date.parse(d))) {
|
||||
fehler.push("Datum ist ungültig.");
|
||||
} else aus.datum = d;
|
||||
}
|
||||
if (körper.status !== undefined) {
|
||||
if (!STATUS.includes(körper.status)) fehler.push("Unbekannter Status.");
|
||||
else aus.status = körper.status;
|
||||
}
|
||||
/* Bewertung und Dringlichkeit gibt es nur dort, wo der Bereich sie
|
||||
vorsieht -- sonst werden sie stillschweigend verworfen. */
|
||||
if (einstellung.bewertung && körper.bewertung !== undefined) {
|
||||
if (körper.bewertung === null || körper.bewertung === "") aus.bewertung = null;
|
||||
else {
|
||||
const b = Number(körper.bewertung);
|
||||
if (!Number.isInteger(b) || b < 1 || b > 5) fehler.push("Bewertung muss 1 bis 5 sein.");
|
||||
else aus.bewertung = b;
|
||||
}
|
||||
}
|
||||
if (einstellung.dringlichkeit && körper.dringlichkeit !== undefined) {
|
||||
if (!DRINGLICHKEITEN.includes(körper.dringlichkeit)) fehler.push("Unbekannte Dringlichkeit.");
|
||||
else aus.dringlichkeit = körper.dringlichkeit;
|
||||
}
|
||||
if (körper.creator_id !== undefined) {
|
||||
const w = körper.creator_id;
|
||||
if (w === null || w === "") aus.creator_id = null;
|
||||
else {
|
||||
const z = Number(w);
|
||||
if (!Number.isInteger(z) || z < 1) fehler.push("Ungültige Zuordnung.");
|
||||
else aus.creator_id = z;
|
||||
}
|
||||
}
|
||||
return { aus, fehler };
|
||||
}
|
||||
|
||||
/* ---------- Anlegen ------------------------------------------------------ */
|
||||
|
||||
bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const bereich = String(req.params.bereich);
|
||||
if (!BEREICHE[bereich]) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
const regel = sichtbar(req.person);
|
||||
if (!regel) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const { aus, fehler } = pruefe(bereich, req.body || {}, { neu: true });
|
||||
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
|
||||
|
||||
/* Ein Creator schreibt immer in den eigenen Bereich, egal was im
|
||||
Aufruf steht. */
|
||||
if (req.person.rolle !== "admin") aus.creator_id = req.person.id;
|
||||
if (aus.creator_id
|
||||
&& !db().prepare("SELECT 1 FROM personen WHERE id = ? AND rolle = 'creator'").get(aus.creator_id)) {
|
||||
return res.status(400).json({ fehler: "Zugeordneter Creator existiert nicht." });
|
||||
}
|
||||
|
||||
const { lastInsertRowid } = db().prepare(`
|
||||
INSERT INTO eintraege
|
||||
(bereich, art, titel, text, datum, bewertung, dringlichkeit, status,
|
||||
creator_id, erstellt, erstellt_von)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`).run(
|
||||
bereich, aus.art, aus.titel, aus.text ?? null, aus.datum,
|
||||
aus.bewertung ?? null, aus.dringlichkeit ?? "mittel", aus.status ?? "offen",
|
||||
aus.creator_id ?? null, jetzt(), req.person.id);
|
||||
|
||||
protokolliere("eintrag_angelegt", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `${bereich} #${lastInsertRowid} ${aus.titel}`.slice(0, 120),
|
||||
});
|
||||
res.status(201).json({ id: Number(lastInsertRowid) });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Eintrag anlegen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Ändern und Löschen ------------------------------------------- */
|
||||
|
||||
function holen(req, id) {
|
||||
const regel = sichtbar(req.person);
|
||||
if (!regel) return null;
|
||||
return db().prepare(`SELECT e.* ${VERBUND} WHERE ${regel.wo} AND e.id = ?`)
|
||||
.get(...regel.werte, id);
|
||||
}
|
||||
|
||||
bereicheRouter.patch("/workspace/api/bereich/:bereich/:id", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const bereich = String(req.params.bereich);
|
||||
const id = Number(req.params.id);
|
||||
if (!BEREICHE[bereich] || !Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const eintrag = holen(req, id);
|
||||
if (!eintrag || eintrag.bereich !== bereich) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const { aus, fehler } = pruefe(bereich, req.body || {}, { neu: false });
|
||||
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
|
||||
if (req.person.rolle !== "admin") delete aus.creator_id;
|
||||
|
||||
const felder = Object.keys(aus);
|
||||
if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" });
|
||||
|
||||
db().prepare(
|
||||
`UPDATE eintraege SET ${felder.map((f) => `${f} = ?`).join(", ")}, geaendert = ? WHERE id = ?`
|
||||
).run(...felder.map((f) => aus[f]), jetzt(), id);
|
||||
|
||||
protokolliere("eintrag_geaendert", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `${bereich} #${id} ${felder.join(",")}`.slice(0, 120),
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Eintrag ändern:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
bereicheRouter.delete("/workspace/api/bereich/:bereich/:id", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const bereich = String(req.params.bereich);
|
||||
const id = Number(req.params.id);
|
||||
if (!BEREICHE[bereich] || !Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const eintrag = holen(req, id);
|
||||
if (!eintrag || eintrag.bereich !== bereich) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
/* Löschen darf das Management und wer den Eintrag selbst geschrieben
|
||||
hat -- sonst könnte ein Creator eine Notiz des Managements über
|
||||
seinen eigenen Bereich verschwinden lassen. */
|
||||
if (req.person.rolle !== "admin" && eintrag.erstellt_von !== req.person.id) {
|
||||
return res.status(403).json({ fehler: "nicht_erlaubt" });
|
||||
}
|
||||
|
||||
db().prepare("DELETE FROM eintraege WHERE id = ?").run(id);
|
||||
protokolliere("eintrag_geloescht", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `${bereich} #${id} ${eintrag.titel}`.slice(0, 120),
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Eintrag löschen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
@@ -197,6 +197,31 @@ export function db() {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dateien_creator ON dateien (creator_id);
|
||||
|
||||
/* Eintraege der Phase-2-Bereiche (LIVE, Content, Technik,
|
||||
Community, Schutz). Eine gemeinsame Tabelle statt fuenf fast
|
||||
gleicher: Die Bereiche unterscheiden sich nur in den erlaubten
|
||||
Arten und darin, ob Bewertung oder Dringlichkeit dazugehoeren --
|
||||
das steht in workspace-bereiche.js, nicht in der Datenbank. */
|
||||
CREATE TABLE IF NOT EXISTS eintraege (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bereich TEXT NOT NULL
|
||||
CHECK (bereich IN ('live','content','technik','community','schutz')),
|
||||
art TEXT NOT NULL,
|
||||
titel TEXT NOT NULL,
|
||||
text TEXT,
|
||||
datum TEXT NOT NULL,
|
||||
bewertung INTEGER CHECK (bewertung IS NULL OR (bewertung BETWEEN 1 AND 5)),
|
||||
dringlichkeit TEXT NOT NULL DEFAULT 'mittel'
|
||||
CHECK (dringlichkeit IN ('hoch','mittel','niedrig')),
|
||||
status TEXT NOT NULL DEFAULT 'offen'
|
||||
CHECK (status IN ('offen','erledigt')),
|
||||
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
erstellt TEXT NOT NULL,
|
||||
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
geaendert TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS versuche (
|
||||
ip TEXT NOT NULL,
|
||||
zeitpunkt TEXT NOT NULL
|
||||
@@ -332,6 +357,7 @@ const GESCHUETZT = {
|
||||
"/workspace/profil.html": ["admin", "creator"],
|
||||
"/workspace/kalender.html": null,
|
||||
"/workspace/dateien.html": null,
|
||||
"/workspace/bereich.html": ["admin", "creator"],
|
||||
};
|
||||
|
||||
workspaceRouter.use((req, res, next) => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/* ===================================================================
|
||||
Gemeinsame Ansicht für LIVE, Content, Technik, Community und Schutz.
|
||||
=================================================================== */
|
||||
|
||||
.eintrag-karte {
|
||||
padding: 15px 17px; margin-bottom: 10px;
|
||||
background: rgba(255, 255, 255, .03);
|
||||
border: 1px solid var(--rand);
|
||||
border-left: 3px solid var(--rand);
|
||||
border-radius: var(--radius-klein);
|
||||
}
|
||||
.eintrag-karte[data-dringlich="hoch"] { border-left-color: #d8656e; }
|
||||
.eintrag-karte[data-dringlich="mittel"] { border-left-color: #4b6c8a; }
|
||||
.eintrag-karte[data-dringlich="niedrig"] { border-left-color: #3c4a5a; }
|
||||
.eintrag-karte[data-status="erledigt"] { opacity: .55; }
|
||||
|
||||
.eintrag-kopf {
|
||||
display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
.eintrag-titel { font-weight: 600; font-size: .96rem; flex: 1 1 240px; }
|
||||
|
||||
.marke-art {
|
||||
padding: 1px 9px; border-radius: 999px;
|
||||
background: rgba(63, 189, 245, .14); color: #7fd2fb;
|
||||
font-size: .68rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Bewertung als Punktreihe statt als Zahl -- auf einen Blick erfassbar,
|
||||
und ohne Sterne, die im Dunkeln flimmern. */
|
||||
.bewertung { display: inline-flex; gap: 3px; align-items: center; }
|
||||
.bewertung__punkt {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: rgba(150, 186, 220, .22);
|
||||
}
|
||||
.bewertung__punkt[data-voll="ja"] { background: var(--akzent); }
|
||||
|
||||
.eintrag-text {
|
||||
margin: 9px 0 0; font-size: .87rem; color: var(--text-leise);
|
||||
line-height: 1.55; white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.eintrag-fuss {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
margin-top: 11px; font-size: .77rem; color: var(--text-still);
|
||||
}
|
||||
.eintrag-knoepfe { display: flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
|
||||
|
||||
.filter { display: flex; gap: 8px; margin: 0 0 20px; flex-wrap: wrap; }
|
||||
.filter .schritt[aria-pressed="true"] {
|
||||
background: rgba(63, 189, 245, .14);
|
||||
border-color: rgba(63, 189, 245, .55);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.leer-hinweis {
|
||||
padding: 26px 20px; text-align: center;
|
||||
color: var(--text-still); font-size: .9rem;
|
||||
border: 1px dashed var(--rand); border-radius: var(--radius);
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.eintrag-knoepfe { margin-left: 0; width: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/* ===================================================================
|
||||
Eine Ansicht für fünf Bereiche (LIVE, Content, Technik, Community,
|
||||
Schutz). Welcher gemeint ist, steht in der Adresse: ?b=live
|
||||
|
||||
Welche Arten es gibt und ob Bewertung oder Dringlichkeit dazugehören,
|
||||
sagt der Server in der Antwort -- diese Datei kennt die Bereiche
|
||||
nicht auswendig. Ein neuer Bereich braucht hier also keine Zeile.
|
||||
=================================================================== */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const UNTERZEILE = {
|
||||
live: 'Vorbereitung, Mitschrift und Auswertung – LIVE systematisch verbessern.',
|
||||
content: 'Ideen, Produktion und Veröffentlichtes an einer Stelle statt in Chats.',
|
||||
technik: 'Setups, Fehlerbilder und Lösungen pro Creator sauber dokumentiert.',
|
||||
community: 'Moderation, Aktionen und Konflikte – ruhig statt Drama.',
|
||||
schutz: 'Richtlinien, Vorfälle und was daraus gelernt wurde.',
|
||||
};
|
||||
|
||||
let ich = null;
|
||||
let bereich = null;
|
||||
let einstellung = null;
|
||||
let filter = '';
|
||||
|
||||
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('.') : '—');
|
||||
|
||||
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'); }
|
||||
if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('unbekannt'); }
|
||||
return a;
|
||||
}
|
||||
|
||||
function tuKnopf(text, tu) {
|
||||
const b = el('button', 'schritt', text);
|
||||
b.type = 'button';
|
||||
b.addEventListener('click', async () => {
|
||||
b.disabled = true; melde('');
|
||||
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ---------- Zeichnen --------------------------------------------------- */
|
||||
|
||||
function karte(e) {
|
||||
const k = el('article', 'eintrag-karte');
|
||||
k.dataset.dringlich = e.dringlichkeit;
|
||||
k.dataset.status = e.status;
|
||||
|
||||
const kopf = el('div', 'eintrag-kopf');
|
||||
kopf.append(el('span', 'marke-art', einstellung.arten[e.art] || e.art));
|
||||
kopf.append(el('h3', 'eintrag-titel', e.titel));
|
||||
|
||||
if (einstellung.bewertung && e.bewertung) {
|
||||
const b = el('span', 'bewertung');
|
||||
b.setAttribute('aria-label', `Bewertung ${e.bewertung} von 5`);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const punkt = el('span', 'bewertung__punkt');
|
||||
if (i <= e.bewertung) punkt.dataset.voll = 'ja';
|
||||
b.append(punkt);
|
||||
}
|
||||
kopf.append(b);
|
||||
}
|
||||
k.append(kopf);
|
||||
|
||||
if (e.text) k.append(el('p', 'eintrag-text', e.text));
|
||||
|
||||
const fuss = el('div', 'eintrag-fuss');
|
||||
fuss.append(el('span', null, datum(e.datum)));
|
||||
if (ich.rolle === 'admin' && e.creator_name) fuss.append(el('span', null, '· ' + e.creator_name));
|
||||
if (e.erstellt_name) fuss.append(el('span', null, '· von ' + e.erstellt_name));
|
||||
if (e.status === 'erledigt') fuss.append(el('span', null, '· erledigt'));
|
||||
|
||||
const knoepfe = el('div', 'eintrag-knoepfe');
|
||||
knoepfe.append(tuKnopf(e.status === 'offen' ? 'erledigt ▶' : '◀ wieder offen', async () => {
|
||||
const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: e.status === 'offen' ? 'erledigt' : 'offen' }),
|
||||
});
|
||||
if (!a.ok) { melde('Ändern hat nicht geklappt.'); return; }
|
||||
await laden();
|
||||
}));
|
||||
if (ich.rolle === 'admin' || e.erstellt_von === ich.id) {
|
||||
knoepfe.append(tuKnopf('löschen', async () => {
|
||||
if (!confirm(`„${e.titel}" wirklich löschen?`)) return;
|
||||
const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, { method: 'DELETE' });
|
||||
if (!a.ok) { melde('Löschen hat nicht geklappt.'); return; }
|
||||
await laden();
|
||||
}));
|
||||
}
|
||||
fuss.append(knoepfe);
|
||||
k.append(fuss);
|
||||
return k;
|
||||
}
|
||||
|
||||
function filterBauen() {
|
||||
const ziel = $('filter');
|
||||
ziel.textContent = '';
|
||||
const knopf = (wert, text) => {
|
||||
const b = el('button', 'schritt', text);
|
||||
b.type = 'button';
|
||||
b.setAttribute('aria-pressed', String(filter === wert));
|
||||
b.addEventListener('click', async () => { filter = wert; await laden(); });
|
||||
return b;
|
||||
};
|
||||
ziel.append(knopf('', 'Alle'));
|
||||
ziel.append(knopf('offen', 'Nur offene'));
|
||||
for (const [wert, name] of Object.entries(einstellung.arten)) ziel.append(knopf(wert, name));
|
||||
}
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const a = await hole(`/workspace/api/bereich/${bereich}`);
|
||||
if (!a.ok) { melde('Einträge konnten nicht geladen werden.'); return; }
|
||||
const daten = await a.json();
|
||||
einstellung = daten.einstellung;
|
||||
filterBauen();
|
||||
|
||||
const ziel = $('liste');
|
||||
ziel.textContent = '';
|
||||
ziel.setAttribute('aria-busy', 'false');
|
||||
|
||||
const sichtbar = daten.eintraege.filter((e) => {
|
||||
if (!filter) return true;
|
||||
if (filter === 'offen') return e.status === 'offen';
|
||||
return e.art === filter;
|
||||
});
|
||||
if (!sichtbar.length) {
|
||||
ziel.append(el('p', 'leer-hinweis', daten.eintraege.length
|
||||
? 'Zu dieser Auswahl gibt es nichts.'
|
||||
: 'Noch kein Eintrag in diesem Bereich.'));
|
||||
return;
|
||||
}
|
||||
for (const e of sichtbar) ziel.append(karte(e));
|
||||
} catch { /* umgeleitet */ }
|
||||
}
|
||||
|
||||
/* ---------- Neuer Eintrag ---------------------------------------------- */
|
||||
|
||||
const umschalten = (auf) => {
|
||||
$('neu').hidden = !auf; $('neu-oeffnen').hidden = auf;
|
||||
if (auf) $('f-titel').focus();
|
||||
};
|
||||
$('neu-oeffnen').addEventListener('click', () => {
|
||||
$('f-datum').value = new Date().toISOString().slice(0, 10);
|
||||
umschalten(true);
|
||||
});
|
||||
$('neu-abbrechen').addEventListener('click', () => {
|
||||
$('neu').reset(); $('neu-fehler').textContent = ''; umschalten(false);
|
||||
});
|
||||
|
||||
$('neu').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
$('neu-fehler').textContent = '';
|
||||
const knopf = $('neu-speichern');
|
||||
const daten = {
|
||||
art: $('f-art').value,
|
||||
titel: $('f-titel').value,
|
||||
text: $('f-text').value,
|
||||
datum: $('f-datum').value,
|
||||
};
|
||||
if (einstellung.dringlichkeit) daten.dringlichkeit = $('f-dringlich').value;
|
||||
if (einstellung.bewertung) daten.bewertung = $('f-bewertung').value || null;
|
||||
if (ich.rolle === 'admin') daten.creator_id = $('f-creator').value || null;
|
||||
|
||||
knopf.disabled = true;
|
||||
try {
|
||||
const a = await hole(`/workspace/api/bereich/${bereich}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(daten),
|
||||
});
|
||||
if (!a.ok) {
|
||||
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
|
||||
return;
|
||||
}
|
||||
$('neu').reset(); umschalten(false);
|
||||
await laden();
|
||||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||||
});
|
||||
|
||||
/* ---------- Start -------------------------------------------------------- */
|
||||
|
||||
(async () => {
|
||||
bereich = new URLSearchParams(location.search).get('b') || '';
|
||||
if (!/^[a-z]+$/.test(bereich)) { location.assign('/workspace/start.html'); return; }
|
||||
|
||||
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;
|
||||
|
||||
let daten;
|
||||
try {
|
||||
const a = await hole(`/workspace/api/bereich/${bereich}`);
|
||||
if (!a.ok) { location.assign('/workspace/start.html'); return; }
|
||||
daten = await a.json();
|
||||
} catch { return; }
|
||||
|
||||
einstellung = daten.einstellung;
|
||||
document.title = einstellung.name + ' · Creator Workspace';
|
||||
$('marke-bereich').textContent = einstellung.name;
|
||||
$('titel').textContent = einstellung.name;
|
||||
$('unterzeile').textContent = UNTERZEILE[bereich] || '';
|
||||
|
||||
for (const [wert, name] of Object.entries(einstellung.arten)) {
|
||||
const o = document.createElement('option');
|
||||
o.value = wert; o.textContent = name;
|
||||
$('f-art').append(o);
|
||||
}
|
||||
$('feld-dringlich').hidden = !einstellung.dringlichkeit;
|
||||
$('feld-bewertung').hidden = !einstellung.bewertung;
|
||||
|
||||
if (ich.rolle === 'admin') {
|
||||
$('feld-creator').hidden = false;
|
||||
try {
|
||||
const { personen } = await (await hole('/workspace/api/personen')).json();
|
||||
for (const p of personen.filter((p) => p.rolle === 'creator')) {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(p.id); o.textContent = p.name;
|
||||
$('f-creator').append(o);
|
||||
}
|
||||
} catch { /* Zuordnung bleibt leer */ }
|
||||
}
|
||||
|
||||
await laden();
|
||||
})();
|
||||
})();
|
||||
@@ -23,11 +23,11 @@
|
||||
{ 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' },
|
||||
{ name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator'] },
|
||||
{ name: 'Content-Planung', phase: 2, rollen: ['admin', 'creator'] },
|
||||
{ name: 'Technik', phase: 2, rollen: ['admin', 'creator'] },
|
||||
{ name: 'Community', phase: 2, rollen: ['admin', 'creator'] },
|
||||
{ name: 'Schutz & Regeln', phase: 2, rollen: ['admin', 'creator'] },
|
||||
{ name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=live' },
|
||||
{ name: 'Content-Planung', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=content' },
|
||||
{ name: 'Technik', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=technik' },
|
||||
{ name: 'Community', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=community' },
|
||||
{ name: 'Schutz & Regeln', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=schutz' },
|
||||
{ name: 'Reports & Review', phase: 2, rollen: ['admin', 'creator'] },
|
||||
{ name: 'Calls', phase: 3, rollen: ['admin', 'creator', 'scout'] },
|
||||
{ name: 'Scout-Pipeline', phase: 3, rollen: ['admin', 'scout'] },
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Bereich · Creator Workspace</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css" />
|
||||
<link rel="stylesheet" href="assets/css/start.css" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
|
||||
<header class="kopfleiste">
|
||||
<button type="button" class="zurueck-knopf" id="zurueck" hidden>
|
||||
<svg class="zurueck-knopf__pfeil" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14.5 5.5 8 12l6.5 6.5" />
|
||||
</svg>
|
||||
<span class="zurueck-knopf__text">Zurück</span>
|
||||
</button>
|
||||
<p class="marke"><a class="zurueck" href="start.html">Creator Workspace</a> ·
|
||||
<span id="marke-bereich">…</span></p>
|
||||
<div class="kopfleiste__rechts">
|
||||
<span class="wer" id="wer">…</span>
|
||||
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="inhalt inhalt--breit">
|
||||
|
||||
<section class="kopf-zeile">
|
||||
<div>
|
||||
<p class="marke" id="ober">Betreuung</p>
|
||||
<h1 class="titel" id="titel">…</h1>
|
||||
<p class="unterzeile" id="unterzeile"></p>
|
||||
</div>
|
||||
<button type="button" class="knopf knopf--klein" id="neu-oeffnen">Neuer Eintrag</button>
|
||||
</section>
|
||||
|
||||
<form class="neu" id="neu" hidden>
|
||||
<div class="neu__raster">
|
||||
<div>
|
||||
<label class="feld-schild" for="f-art">Art</label>
|
||||
<select id="f-art"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="feld-schild" for="f-datum">Datum</label>
|
||||
<input id="f-datum" type="date" />
|
||||
</div>
|
||||
<div id="feld-dringlich" hidden>
|
||||
<label class="feld-schild" for="f-dringlich">Dringlichkeit</label>
|
||||
<select id="f-dringlich">
|
||||
<option value="hoch">hoch</option>
|
||||
<option value="mittel" selected>mittel</option>
|
||||
<option value="niedrig">niedrig</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="feld-bewertung" hidden>
|
||||
<label class="feld-schild" for="f-bewertung">Bewertung (1–5)</label>
|
||||
<select id="f-bewertung">
|
||||
<option value="">—</option>
|
||||
<option value="1">1 – schwach</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3 – solide</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5 – stark</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="feld-creator" hidden>
|
||||
<label class="feld-schild" for="f-creator">Creator</label>
|
||||
<select id="f-creator"><option value="">—</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="neu__zeile">
|
||||
<label class="feld-schild" for="f-titel">Überschrift</label>
|
||||
<input id="f-titel" maxlength="160" required />
|
||||
</div>
|
||||
<div class="neu__zeile">
|
||||
<label class="feld-schild" for="f-text">Text <span class="leise">(optional)</span></label>
|
||||
<textarea id="f-text" rows="4" maxlength="6000"></textarea>
|
||||
</div>
|
||||
<p class="fehler" id="neu-fehler" role="alert" aria-live="polite"></p>
|
||||
<div class="neu__knoepfe">
|
||||
<button type="submit" class="knopf knopf--klein" id="neu-speichern">Eintragen</button>
|
||||
<button type="button" class="abmelden" id="neu-abbrechen">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
|
||||
|
||||
<div class="filter" id="filter"></div>
|
||||
|
||||
<div id="liste" aria-busy="true"><p class="leise">Einträge werden geladen …</p></div>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/kopf.js" defer></script>
|
||||
<script src="assets/js/bereich.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user