Kategorie-Kanaele und angeheftete Ankuendigungen (Kapitel 7.2)
Die vier Saetze aus dem Anforderungsdokument, der Reihe nach: Team- Gruppenchat, Kategorie-Kanaele mit Zugriff fuer Owner und rechte Hand, private 1:1-Chats OHNE diesen Zugriff, und Pin-Nachrichten an alle. EIN KANAL IST KEINE VIERTE TABELLE, sondern eine dritte Art Raum (`art = 'kanal'` neben 'direkt' und 'gruppe'). Damit gilt fuer ihn ohne eine einzige neue Zeile alles, was schon da ist: Verlauf, Anhaenge, Suche, Ungelesen-Zaehler, Live-Strom, Wegraeumen. Eine eigene Tabelle haette all das ein zweites Mal gebraucht -- und die zweite Fassung waere die gewesen, in der die Zugriffsregel fehlt. Welche Zustaendigkeit, kommt aus MODI_KATEGORIEN -- derselben Liste, aus der auch die Aufgaben ihre Kategorie nehmen. Der NAME kommt aus der Kategorie und ist kein freies Feld: "Clipping" neben "Clipping-Team" waeren zwei halbe Verlaeufe, und man merkt es erst, wenn jemand die Antwort im falschen sucht. Ein eindeutiger Teilindex haelt das auch dann fest, wenn zwei Anfragen im selben Augenblick ankommen. DIE ZUGRIFFSREGEL STEHT IN istDrin() -- der Funktion, durch die alle sieben lesenden und schreibenden Wege gehen. In den Routen stuende sie in sechs davon und in der siebten nicht. Sie gilt AUSDRUECKLICH nur fuer 'kanal': Zweier-Gespraeche bleiben zu, auch fuer DogFather (so steht es im Dokument), und Gruppen ebenfalls -- wer eine Gruppe aufmacht, hat sich fuer einen geschlossenen Kreis entschieden, und den nachtraeglich still zu oeffnen waere das Gegenteil dessen, was er getan hat. Wenn Filipe das anders will, ist es eine Zeile -- aber es waere seine Entscheidung und muesste fuer die Beteiligten SICHTBAR sein. istDrin() nimmt dafuer die PERSON statt ihrer Nummer und wirft bei einer Nummer einen Fehler, statt stillschweigend "nein" zu antworten. HOECHSTENS DREI ANKUENDIGUNGEN je Raum. Nicht eine (Regeln, Live-Plan und Frist muessen gleichzeitig oben stehen koennen) und nicht beliebig viele -- eine Pinnwand, die scrollt, ist ein zweiter Verlauf. Der Aushang hat eine EIGENE Abfrage, weil der Verlauf nur 200 Zeilen liefert: Eine Ansage von vorletzter Woche waere sonst genau dann weg, wenn sie am laengsten oben stehen sollte. Wird die Nachricht zurueckgenommen, faellt sie ab UND gibt den Platz frei. DREI DINGE, DIE ERST DAS HINSEHEN GEZEIGT HAT: Der frisch angelegte Kanal hatte zwei Leute statt vier. Die Personenauswahl zeichnete nach der Rollenfolge aus bereiche.js -- wer dort nicht steht, wurde NICHT GEZEICHNET. Team Dogi steht dort nicht und darf es auch nicht (der Rollenname gehoert in keine Datei, die jeder herunterlaedt). Folge: DogFather konnte ueber die Auswahl niemandem aus seinem Team schreiben. Der Server schickt die Ueberschrift jetzt mit; der Browser braucht dafuer keinen Rollennamen. Der Kommentar, der genau davor warnte, stand die ganze Zeit darueber. Dieser Fehler war nebenbei ein Netz: Was nicht gezeichnet wird, kann auch nicht falsch gezeichnet werden. Deshalb ist jetzt gemessen, dass ein Manager und Spicy Media Team Dogi gar nicht erst geschickt bekommen -- und dabei fiel auf, dass Spicy Media in der EIGENEN Auswahl stand: Sobald es jemanden zu verbergen gibt, schreibt ohneVerborgene() aus "sieht alles" eine echte Liste, und darin steckt man selbst. Am Fuss jeder Nachricht stehen jetzt vier Handgriffe statt drei. Bei 390 px -- der haeufigsten Handybreite -- stand "kopieren" 18 px ueber der Blase, bei 320 px 75. Behoben mit `flex-wrap: wrap` und nicht mit einer Schwelle: Eine Regel, die misst, bleibt beim fuenften Handgriff richtig; eine Zahl nicht. pruef-chat-optik misst es ab jetzt. NACHGEZOGEN, was pruef-css-klassen an MEINEM letzten Commit fand: teamlage.html lud kopf.js ohne wahl.js (der Sicht-Umschalter sah aus wie aus einem anderen Programm -- kaputt war nichts, und genau deshalb faellt es niemandem auf), und .tampel__tag stand auf 11,2 px. Beides war schon gepusht, weil ich die Pruefung nicht laufen liess. pruef-chat-kanaele 73 (neu) · pruef-chat 48 · pruef-chat-ausbau 64 · pruef-chat-anhaenge 60 · pruef-chat-optik 24 -> 26 · pruef-css-klassen gruen · pruef-rollen 274 · pruef-modi-verborgen 78 · pruef-team-ampel 28 · pruef-start-ansicht gruen · pruef-modi-wortleck 5 · pruef-zwischenspeicher 21. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,554 @@
|
||||
/* KATEGORIE-KANÄLE UND ANGEHEFTETE ANKÜNDIGUNGEN (10.09.2026)
|
||||
|
||||
Kapitel 7.2 des Anforderungsdokuments, die vier Sätze im Wortlaut:
|
||||
|
||||
"Team-Gruppenchat: für alle Mitglieder, vollständig einsehbar für
|
||||
Owner & rechte Hand.
|
||||
Kategorie-Kanäle: z. B. 'Clipping-Team', 'Live-Moderation' -
|
||||
Owner/rechte Hand haben Zugriff auf alle Kanäle.
|
||||
Private 1:1-Chats: ausschließlich für die beteiligten Personen
|
||||
sichtbar - kein Zugriff durch Owner/rechte Hand.
|
||||
Ankündigungs-Funktion: 'Pin'-Nachrichten von Owner/rechte Hand
|
||||
an alle."
|
||||
|
||||
---------------------------------------------------------------------
|
||||
DIE EINE PRÜFUNG, DERENTWEGEN ES DIESE DATEI GIBT
|
||||
|
||||
Der Zugriff auf Kanäle wurde in istDrin() eingebaut -- der Funktion,
|
||||
durch die JEDER Verlauf, JEDER Anhang und JEDE Suche geht. Das ist
|
||||
die richtige Stelle (sonst gäbe es die Regel in sechs von sieben
|
||||
Routen), aber es ist auch die gefährlichste: Ein zu weit gefasstes
|
||||
`return true` dort öffnet in EINEM Schritt jedes Gespräch im Haus.
|
||||
|
||||
Deshalb steht die Gegenprobe hier vorn und nicht als Anhängsel:
|
||||
DogFather muss an einem Kanal vorbeikommen UND an einem
|
||||
Zweier-Gespräch scheitern, das ihn nichts angeht. Eine Prüfung, die
|
||||
nur das Erste zeigt, wäre grün, wenn die Regel gar nicht mehr
|
||||
unterscheidet.
|
||||
|
||||
AUSSERDEM GEMESSEN:
|
||||
* Ein Kanal je Zuständigkeit -- der zweite wird abgelehnt.
|
||||
* Die rechte Hand kommt in einen Kanal, in den sie nie jemand
|
||||
gesetzt hat (das ist der Sinn des Satzes im Dokument).
|
||||
* Anheften: nur die Leitung, höchstens drei, und eine
|
||||
zurückgenommene Nachricht fällt vom Aushang UND gibt den Platz frei.
|
||||
* Eine Ankündigung von vor 300 Nachrichten steht immer noch oben.
|
||||
(Der Verlauf liefert nur 200 Zeilen -- deshalb hat der Aushang
|
||||
eine eigene Abfrage. Ohne diese Prüfung wäre der Grund dafür in
|
||||
einem Kommentar begraben und die Abfrage beim nächsten Aufräumen
|
||||
wegoptimiert.)
|
||||
===================================================================== */
|
||||
|
||||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-kanal-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4359, "die Kanalpruefung");
|
||||
|
||||
process.env.PORT = "4359";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
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, 1000));
|
||||
const BASIS = "http://127.0.0.1:4359";
|
||||
|
||||
/* ---- Notbremse und eigener Zuhoerer -------------------------------------
|
||||
|
||||
BEIDES WURDE BEIM ERSTEN LAUF GEBRAUCHT, und zwar sofort.
|
||||
|
||||
Ein Zugriff auf `undefined[0]` liess diese Datei mitten im Lauf
|
||||
abstuerzen. Zu sehen war davon: nichts. `index.js` haengt fuer den
|
||||
BETRIEB ein Auffangnetz an `uncaughtException` -- dort voellig
|
||||
richtig, ein Fehler in einer Nebensache darf die Website nicht
|
||||
offline nehmen. Eine Pruefung importiert dieselbe Datei in DENSELBEN
|
||||
Prozess und erbt es: Der Absturz wurde nur notiert, der Server hielt
|
||||
den Prozess am Leben, und von aussen sah es fuenf Minuten lang aus
|
||||
wie "laeuft noch".
|
||||
|
||||
`process.on` ERGAENZT, es ersetzt nicht -- der Zuhoerer unten laeuft
|
||||
also zusaetzlich zu dem aus index.js und beendet den Lauf wirklich.
|
||||
Die Notbremse faengt den anderen Fall: nicht Absturz, sondern
|
||||
Warten. `unref()`, damit sie einen sauberen Lauf nie aufhaelt. */
|
||||
process.on("uncaughtException", (f) => {
|
||||
console.error(""); console.error("ABBRUCH durch einen Absturz:", f?.stack || f);
|
||||
process.exit(1);
|
||||
});
|
||||
process.on("unhandledRejection", (f) => {
|
||||
console.error(""); console.error("ABBRUCH durch ein unbehandeltes Versprechen:", f?.stack || f);
|
||||
process.exit(1);
|
||||
});
|
||||
const notbremse = setTimeout(() => {
|
||||
console.error(""); console.error("ABBRUCH: Die Pruefung laeuft seit 120 s. Die letzte Zeile oben"
|
||||
+ " ist die Stelle, an der sie stehengeblieben ist.");
|
||||
process.exit(1);
|
||||
}, 120000);
|
||||
notbremse.unref?.();
|
||||
|
||||
let fehler = 0;
|
||||
let geprueft = 0;
|
||||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------
|
||||
DogFather, seine rechte Hand und drei Modis. Drei und nicht zwei:
|
||||
Fuer das Zweier-Gespraech, das DogFather nichts angeht, braucht es
|
||||
zwei, die NICHT er sind -- und fuer die Besetzung eines Kanals noch
|
||||
einen dritten, den man hinein- und wieder herausnehmen kann. */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
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 idHand = anlegen("Vanessa", "hand", "CODE-HAND-0001");
|
||||
const idEins = anlegen("Ayla", "modi", "CODE-TEAM-0001");
|
||||
const idZwei = anlegen("Ben", "modi", "CODE-TEAM-0002");
|
||||
const idDrei = anlegen("Cem", "modi", "CODE-TEAM-0003");
|
||||
/* Die Agenturseite des Hauses. Sie kommt in dieser Pruefung nur einmal
|
||||
vor -- aber an der Stelle, die ohne sie unbewiesen bliebe. */
|
||||
anlegen("Cigdem", "manager", "CODE-MANA-0001");
|
||||
anlegen("VanVan", "spicy", "CODE-SPIC-0001");
|
||||
|
||||
/* DIE ANMELDUNG KLOPFT AN DIE CREW-WAND -- und das ist kein Kniff,
|
||||
sondern die Regel, die am 10.09.2026 auf Filipes Wunsch entstanden
|
||||
ist: "auf der workspace seite fuer die agentur und so sollen die
|
||||
modis von mir nicht mehr rein kommen. die sollen nur noch ueber die
|
||||
crew seite rein kommen." Wer sie an der Agenturwand versucht,
|
||||
bekommt 401 -- ohne Hinweis, absichtlich.
|
||||
|
||||
Beim ersten Anlauf stand hier ein blankes `fetch` auf 127.0.0.1.
|
||||
Ergebnis: DogFather kam herein, die rechte Hand und alle drei
|
||||
Teammitglieder nicht, und die halbe Pruefung meldete 401 statt der
|
||||
Sache, die sie messen wollte. Die Sperre hat also getan, was sie
|
||||
soll -- sie hat nur zuerst mich erwischt.
|
||||
|
||||
WARUM node:http UND NICHT fetch: `fetch` (undici) setzt den
|
||||
Host-Kopf selbst aus der Adresse und ignoriert einen mitgegebenen --
|
||||
nachgemessen, nicht vermutet. Fuer alles danach genuegt der Keks:
|
||||
sitzungPasstZurAdresse() laesst 127.0.0.1 unberuehrt. */
|
||||
const { request: httpAnfrage } = await import("node:http");
|
||||
function anmelden(rolle, code, wand = "crew.dogfather-universe.com") {
|
||||
return new Promise((fertig) => {
|
||||
const rumpf = JSON.stringify({ rolle, code });
|
||||
const a = httpAnfrage({
|
||||
host: "127.0.0.1", port: 4359, path: "/workspace/api/anmelden", method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(rumpf),
|
||||
Host: wand,
|
||||
},
|
||||
}, (antwort) => {
|
||||
antwort.resume();
|
||||
antwort.on("end", () => fertig({
|
||||
ok: antwort.statusCode === 200,
|
||||
keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "),
|
||||
}));
|
||||
});
|
||||
a.on("error", () => fertig({ ok: false, keks: "" }));
|
||||
a.end(rumpf);
|
||||
});
|
||||
}
|
||||
const rufe = (keks, weg, koerper, methode = "GET") =>
|
||||
fetch(BASIS + weg, {
|
||||
method: methode,
|
||||
headers: { "Content-Type": "application/json", Cookie: keks },
|
||||
body: koerper === undefined ? undefined : JSON.stringify(koerper),
|
||||
});
|
||||
const holen = async (keks, weg) => (await rufe(keks, weg)).json();
|
||||
|
||||
const dogi = await anmelden("admin", "CODE-DOGI-0001");
|
||||
const hand = await anmelden("hand", "CODE-HAND-0001");
|
||||
const eins = await anmelden("modi", "CODE-TEAM-0001");
|
||||
const zwei = await anmelden("modi", "CODE-TEAM-0002");
|
||||
const drei = await anmelden("modi", "CODE-TEAM-0003");
|
||||
ok([dogi, hand, eins, zwei, drei].every((x) => x.ok && x.keks),
|
||||
"alle fuenf sind angemeldet");
|
||||
|
||||
/* =======================================================================
|
||||
1. Einen Kanal anlegen -- und wer das darf
|
||||
======================================================================= */
|
||||
console.log("\n=== Kanaele anlegen ===");
|
||||
let kanalId = 0;
|
||||
{
|
||||
const a = await rufe(dogi.keks, "/workspace/api/chat/kanal",
|
||||
{ kategorie: "clipping", mit: [idEins, idZwei] }, "POST");
|
||||
const antwort = await a.json();
|
||||
kanalId = antwort.raum_id;
|
||||
ok(a.status === 201 && kanalId > 0, `DogFather legt einen Kanal an (${a.status})`);
|
||||
ok(antwort.name === "Clipping",
|
||||
`der Name kommt aus der Kategorie ("${antwort.name}")`);
|
||||
|
||||
/* GEGENPROBE ZUR REGEL, nicht nur zur Sperre: Eine zweite Kategorie
|
||||
muss gehen. Ginge sie nicht, waere die 409 unten wertlos -- sie
|
||||
koennte auch heissen "Kanaele gehen ueberhaupt nicht mehr". */
|
||||
const b = await rufe(dogi.keks, "/workspace/api/chat/kanal", { kategorie: "technik" }, "POST");
|
||||
ok(b.status === 201, `eine zweite Zustaendigkeit geht (${b.status})`);
|
||||
|
||||
const c = await rufe(dogi.keks, "/workspace/api/chat/kanal", { kategorie: "clipping" }, "POST");
|
||||
ok(c.status === 409, `dieselbe Zustaendigkeit ein zweites Mal: abgelehnt (${c.status})`);
|
||||
const anzahl = d.prepare(
|
||||
"SELECT COUNT(*) AS n FROM chat_raeume WHERE art = 'kanal' AND kategorie = 'clipping'").get().n;
|
||||
ok(anzahl === 1, `und es steht wirklich nur einer in der Datenbank (${anzahl})`);
|
||||
|
||||
const e = await rufe(dogi.keks, "/workspace/api/chat/kanal", { kategorie: "gibtsnicht" }, "POST");
|
||||
ok(e.status === 400, `eine erfundene Kategorie wird abgelehnt (${e.status})`);
|
||||
|
||||
/* Wer keine Kanaele fuehrt, soll nicht einmal erfahren, dass es sie
|
||||
gibt -- deshalb 404 und nicht 403. */
|
||||
const f = await rufe(eins.keks, "/workspace/api/chat/kanal", { kategorie: "community" }, "POST");
|
||||
ok(f.status === 404, `ein Teammitglied kann keinen anlegen (${f.status})`);
|
||||
const g = d.prepare("SELECT COUNT(*) AS n FROM chat_raeume WHERE kategorie = 'community'").get().n;
|
||||
ok(g === 0, "und es ist auch keiner entstanden");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. Wer sieht den Kanal?
|
||||
======================================================================= */
|
||||
console.log("\n=== Wer sieht ihn ===");
|
||||
{
|
||||
const raeumeVon = async (keks) => (await holen(keks, "/workspace/api/chat/raeume")).raeume || [];
|
||||
|
||||
const beiDogi = await raeumeVon(dogi.keks);
|
||||
const k = beiDogi.find((r) => r.id === kanalId);
|
||||
ok(!!k, "DogFather hat den Kanal in seiner Liste");
|
||||
ok(k?.art === "kanal" && k?.kategorie === "clipping",
|
||||
`er kommt als Kanal mit seiner Kategorie an (${k?.art}/${k?.kategorie})`);
|
||||
|
||||
/* DER SATZ AUS DEM DOKUMENT. Die rechte Hand wurde NIRGENDS
|
||||
eingetragen -- sie war beim Anlegen nicht dabei und steht in
|
||||
keiner Auswahl. Trotzdem muss der Kanal bei ihr stehen. */
|
||||
const beiHand = await raeumeVon(hand.keks);
|
||||
ok(beiHand.some((r) => r.id === kanalId),
|
||||
"die rechte Hand sieht ihn, ohne je hinzugefuegt worden zu sein");
|
||||
const lesen = await rufe(hand.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok(lesen.status === 200, `und kommt in den Verlauf (${lesen.status})`);
|
||||
|
||||
const beiEins = await raeumeVon(eins.keks);
|
||||
ok(beiEins.some((r) => r.id === kanalId), "wer hineingesetzt wurde, sieht ihn auch");
|
||||
|
||||
/* GEGENPROBE: Der dritte ist NICHT drin. Er darf ihn weder in der
|
||||
Liste haben noch ueber die Nummer erreichen. */
|
||||
const beiDrei = await raeumeVon(drei.keks);
|
||||
ok(!beiDrei.some((r) => r.id === kanalId), "wer nicht drin ist, sieht ihn nicht");
|
||||
const versuch = await rufe(drei.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok(versuch.status === 404, `und kommt auch ueber die Nummer nicht hinein (${versuch.status})`);
|
||||
|
||||
const wahl = await holen(dogi.keks, "/workspace/api/chat/raeume");
|
||||
ok(wahl.darf_kanal === true, "DogFather bekommt das Ja zum Anlegen");
|
||||
ok(Array.isArray(wahl.kanal_frei) && !wahl.kanal_frei.some((x) => x.wert === "clipping"),
|
||||
"die belegte Zustaendigkeit steht nicht mehr zur Auswahl");
|
||||
const wahlModi = await holen(eins.keks, "/workspace/api/chat/raeume");
|
||||
ok(wahlModi.darf_kanal === false && wahlModi.kanal_frei.length === 0,
|
||||
"ein Teammitglied bekommt weder das Ja noch die Liste");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. DIE WICHTIGSTE PRÜFUNG: Die Ausnahme bleibt bei den Kanaelen
|
||||
======================================================================= */
|
||||
console.log("\n=== Und was NICHT offensteht ===");
|
||||
{
|
||||
/* Ein Zweier-Gespraech und eine Gruppe, beide OHNE DogFather. Direkt
|
||||
in die Datenbank gesetzt und nicht ueber die Schnittstelle: Diese
|
||||
Pruefung gilt der Zugriffsregel, nicht dem Anlegeweg -- und ein
|
||||
Anlegeweg, der sich aendert, duerfte sie nicht mit umwerfen. */
|
||||
function raumMit(art, name, leute) {
|
||||
d.prepare("INSERT INTO chat_raeume (art, name, erstellt, letzte_am) VALUES (?,?,?,?)")
|
||||
.run(art, name, jetzt, jetzt);
|
||||
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
for (const p of leute) {
|
||||
d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)")
|
||||
.run(id, p, jetzt);
|
||||
}
|
||||
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
|
||||
.run(id, leute[0], "Etwas, das niemanden sonst angeht.", jetzt);
|
||||
return id;
|
||||
}
|
||||
const privat = raumMit("direkt", null, [idEins, idZwei]);
|
||||
const gruppe = raumMit("gruppe", "Unter uns", [idEins, idZwei, idDrei]);
|
||||
|
||||
for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks]]) {
|
||||
const a = await rufe(keks, `/workspace/api/chat/raeume/${privat}/nachrichten`);
|
||||
ok(a.status === 404, `${wer} kommt NICHT in ein fremdes Zweier-Gespraech (${a.status})`);
|
||||
const b = await rufe(keks, `/workspace/api/chat/raeume/${gruppe}/nachrichten`);
|
||||
ok(b.status === 404, `${wer} kommt NICHT in eine fremde Gruppe (${b.status})`);
|
||||
const c = await rufe(keks, `/workspace/api/chat/raeume/${privat}/nachrichten`,
|
||||
{ text: "hallo" }, "POST");
|
||||
ok(c.status === 404, `${wer} kann dort auch nichts hineinschreiben (${c.status})`);
|
||||
const liste = (await holen(keks, "/workspace/api/chat/raeume")).raeume || [];
|
||||
ok(!liste.some((r) => r.id === privat || r.id === gruppe),
|
||||
`und beides steht nicht in der Liste von ${wer}`);
|
||||
}
|
||||
|
||||
/* GEGENPROBE: Die Beteiligten kommen sehr wohl hinein. Ohne sie
|
||||
koennte die Sperre oben auch "diese Raeume gibt es gar nicht"
|
||||
heissen. */
|
||||
const drin = await rufe(eins.keks, `/workspace/api/chat/raeume/${privat}/nachrichten`);
|
||||
ok(drin.status === 200, `wer dabei ist, kommt hinein (${drin.status})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. Besetzung aendern
|
||||
======================================================================= */
|
||||
console.log("\n=== Wer ist im Kanal ===");
|
||||
{
|
||||
const a = await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
|
||||
{ mit: [idEins, idDrei] }, "PUT");
|
||||
const antwort = await a.json();
|
||||
ok(a.status === 200 && antwort.dazu === 1 && antwort.raus === 1,
|
||||
`einer kommt dazu, einer geht (${antwort.dazu}/${antwort.raus})`);
|
||||
|
||||
const beiDrei = (await holen(drei.keks, "/workspace/api/chat/raeume")).raeume || [];
|
||||
ok(beiDrei.some((r) => r.id === kanalId), "der Neue sieht den Kanal jetzt");
|
||||
const raus = await rufe(zwei.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok(raus.status === 404, `wer herausgenommen wurde, kommt nicht mehr hinein (${raus.status})`);
|
||||
|
||||
/* NIEMAND VERSCHWINDET RUECKWIRKEND. Die Zeile bleibt mit Datum
|
||||
stehen -- geloescht wird sie nicht. */
|
||||
const zeile = d.prepare(
|
||||
"SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?").get(kanalId, idZwei);
|
||||
ok(!!zeile && !!zeile.raus_am, "die Zeile bleibt stehen, mit Austrittsdatum");
|
||||
|
||||
/* DIE LEITUNG LAESST SICH NICHT HINAUSDRAENGEN. Eine leere Liste
|
||||
muesste alle Nicht-Leitungen entfernen und die Leitung lassen. */
|
||||
const b = await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
|
||||
{ mit: [] }, "PUT");
|
||||
ok(b.status === 200, `eine leere Besetzung ist erlaubt (${b.status})`);
|
||||
const nachher = (await holen(dogi.keks, "/workspace/api/chat/raeume")).raeume || [];
|
||||
ok(nachher.some((r) => r.id === kanalId), "DogFather ist immer noch drin");
|
||||
const handNachher = (await holen(hand.keks, "/workspace/api/chat/raeume")).raeume || [];
|
||||
ok(handNachher.some((r) => r.id === kanalId), "die rechte Hand auch");
|
||||
|
||||
/* Und wieder zurueck -- fuer den Rest der Pruefung wird jemand
|
||||
gebraucht, der drin ist, aber nichts anheften darf. */
|
||||
await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
|
||||
{ mit: [idEins] }, "PUT");
|
||||
const wieder = await rufe(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok(wieder.status === 200, `zurueckgeholt kommt er wieder hinein (${wieder.status})`);
|
||||
|
||||
const c = await rufe(eins.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
|
||||
{ mit: [idEins, idZwei] }, "PUT");
|
||||
ok(c.status === 404, `ein Teammitglied kann die Besetzung nicht aendern (${c.status})`);
|
||||
|
||||
/* Und diese Route fasst KEINE Gruppen an -- sonst waere sie ein
|
||||
Werkzeug, um fremde Runden umzubesetzen. */
|
||||
const gruppeId = d.prepare(
|
||||
"SELECT id FROM chat_raeume WHERE art = 'gruppe' ORDER BY id DESC LIMIT 1").get()?.id;
|
||||
const g = await rufe(dogi.keks, `/workspace/api/chat/kanal/${gruppeId}/mitglieder`,
|
||||
{ mit: [idDogi] }, "PUT");
|
||||
ok(g.status === 404, `sie greift nicht auf eine Gruppe durch (${g.status})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. Ankuendigungen anheften
|
||||
======================================================================= */
|
||||
console.log("\n=== Anheften ===");
|
||||
{
|
||||
async function schreiben(keks, text) {
|
||||
const a = await rufe(keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`, { text }, "POST");
|
||||
return (await a.json())?.nachricht?.id || 0;
|
||||
}
|
||||
const n1 = await schreiben(dogi.keks, "Heute 20 Uhr Live, bitte alle da.");
|
||||
ok(n1 > 0, `eine Nachricht im Kanal (#${n1})`);
|
||||
|
||||
const a = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n1}/anheften`, { an: true }, "PUT");
|
||||
ok(a.status === 200, `DogFather heftet sie an (${a.status})`);
|
||||
|
||||
const verlauf = await holen(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok((verlauf.angeheftet || []).some((x) => x.id === n1),
|
||||
"sie steht bei ALLEN im Kanal oben, nicht nur bei ihm");
|
||||
ok(verlauf.angeheftet[0]?.heftete === "Filipe",
|
||||
`und es steht dabei, wer sie angeheftet hat (${verlauf.angeheftet[0]?.heftete})`);
|
||||
ok(verlauf.darf_anheften === false, "ein Teammitglied bekommt kein Ja zum Anheften");
|
||||
ok((verlauf.nachrichten || []).find((x) => x.id === n1)?.angeheftet === true,
|
||||
"auch im Verlauf ist sie als angeheftet erkennbar");
|
||||
|
||||
const b = await rufe(eins.keks, `/workspace/api/chat/nachrichten/${n1}/anheften`, { an: true }, "PUT");
|
||||
ok(b.status === 403, `ein Teammitglied darf nicht anheften (${b.status})`);
|
||||
|
||||
/* HOECHSTENS DREI. Der vierte muss abgelehnt werden -- und die
|
||||
Gegenprobe steckt in den drei davor: Gingen die auch nicht, waere
|
||||
die 409 nichtssagend. */
|
||||
const n2 = await schreiben(dogi.keks, "Zweite Ansage.");
|
||||
const n3 = await schreiben(dogi.keks, "Dritte Ansage.");
|
||||
const n4 = await schreiben(dogi.keks, "Vierte Ansage.");
|
||||
const r2 = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n2}/anheften`, { an: true }, "PUT");
|
||||
const r3 = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n3}/anheften`, { an: true }, "PUT");
|
||||
ok(r2.status === 200 && r3.status === 200, "zwei weitere gehen");
|
||||
const r4 = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n4}/anheften`, { an: true }, "PUT");
|
||||
ok(r4.status === 409, `die vierte wird abgelehnt (${r4.status})`);
|
||||
|
||||
/* ZURUECKNEHMEN LOEST DEN AUSHANG -- und gibt den Platz frei. Der
|
||||
zweite Teil ist der wichtigere: Ohne ihn zaehlte die Nachricht
|
||||
weiter mit, und niemand kaeme darauf, warum sich nichts mehr
|
||||
anheften laesst. */
|
||||
const weg = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n3}`, undefined, "DELETE");
|
||||
ok(weg.status === 200, `die dritte wird zurueckgenommen (${weg.status})`);
|
||||
const nachWeg = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok(!(nachWeg.angeheftet || []).some((x) => x.id === n3), "sie faellt vom Aushang");
|
||||
const r4b = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n4}/anheften`, { an: true }, "PUT");
|
||||
ok(r4b.status === 200, `und der Platz ist wieder frei (${r4b.status})`);
|
||||
|
||||
const los = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n1}/anheften`, { an: false }, "PUT");
|
||||
ok(los.status === 200, `loesen geht (${los.status})`);
|
||||
const nachLos = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok(!(nachLos.angeheftet || []).some((x) => x.id === n1), "und sie ist oben verschwunden");
|
||||
|
||||
/* Wer gar nicht im Raum ist, bekommt 404 -- nicht 403. Sonst liesse
|
||||
sich durch Ausprobieren herausfinden, welche Nachrichten es gibt. */
|
||||
const fremd = await rufe(drei.keks, `/workspace/api/chat/nachrichten/${n2}/anheften`, { an: false }, "PUT");
|
||||
ok(fremd.status === 404, `wer nicht im Raum ist, sieht die Nachricht nicht (${fremd.status})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
6. Eine Ankuendigung von vor 300 Nachrichten
|
||||
======================================================================= */
|
||||
console.log("\n=== Der Aushang haelt laenger als der Verlauf ===");
|
||||
{
|
||||
const alt = d.prepare(
|
||||
"SELECT id FROM chat_nachrichten WHERE raum_id = ? ORDER BY id LIMIT 1").get(kanalId).id;
|
||||
d.prepare("UPDATE chat_nachrichten SET angeheftet_am = ?, angeheftet_von = ? WHERE id = ?")
|
||||
.run(jetzt, idDogi, alt);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
|
||||
.run(kanalId, idDogi, `Zeile ${i}`, jetzt);
|
||||
}
|
||||
const v = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok((v.nachrichten || []).length === 200,
|
||||
`der Verlauf liefert 200 Zeilen (${(v.nachrichten || []).length})`);
|
||||
ok(!(v.nachrichten || []).some((x) => x.id === alt),
|
||||
"die alte Nachricht ist aus dem Verlauf herausgefallen");
|
||||
ok((v.angeheftet || []).some((x) => x.id === alt),
|
||||
"und steht trotzdem noch oben -- genau dafuer hat der Aushang eine eigene Abfrage");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
7. Weggeraeumt heisst weggeraeumt -- auch fuer den Aushang
|
||||
======================================================================= */
|
||||
console.log("\n=== Wer wegraeumt, sieht auch den Aushang nicht mehr ===");
|
||||
{
|
||||
const vorher = await holen(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok((vorher.angeheftet || []).length > 0, "vorher steht etwas oben");
|
||||
const weg = await rufe(eins.keks, `/workspace/api/chat/raeume/${kanalId}`, undefined, "DELETE");
|
||||
ok(weg.status === 200, `das Teammitglied raeumt den Kanal weg (${weg.status})`);
|
||||
const nachher = await holen(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok((nachher.angeheftet || []).length === 0,
|
||||
`bei ihm ist der Aushang jetzt leer (${(nachher.angeheftet || []).length})`);
|
||||
const beiDogi = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
|
||||
ok((beiDogi.angeheftet || []).length > 0,
|
||||
"bei DogFather steht er unveraendert -- weggeraeumt wird nur bei einem selbst");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
8. Wer steht ueberhaupt in der Personenauswahl?
|
||||
=======================================================================
|
||||
|
||||
WARUM DAS HIER STEHT, und zwar seit heute: Bis eben zeichnete die
|
||||
Auswahl im Browser nur die fuenf Rollen aus bereiche.js. Team Dogi
|
||||
steht dort nicht (der Rollenname gehoert in keine Datei, die jeder
|
||||
herunterlaedt) -- und wurde deshalb NICHT GEZEICHNET. Das war ein
|
||||
Fehler (DogFather konnte niemandem aus seinem Team schreiben), aber
|
||||
es war nebenbei auch ein Netz: Selbst wenn der Server jemanden
|
||||
mitgeschickt haette, den man nicht sehen soll, waere er unsichtbar
|
||||
geblieben.
|
||||
|
||||
Dieses Netz ist jetzt weg -- die Auswahl zeichnet, was ankommt.
|
||||
Also muss bewiesen sein, dass beim Falschen nichts ankommt. Genau
|
||||
diese Sorte Nebeneffekt ist gemeint, wenn im Haus steht: eine
|
||||
Zustaendigkeit, die nur zufaellig aus einer anderen Regel
|
||||
herausfaellt, faellt beim naechsten Umbau zufaellig wieder heraus.
|
||||
======================================================================= */
|
||||
console.log("");
|
||||
console.log("=== Die Personenauswahl ===");
|
||||
{
|
||||
/* Manager und Spicy Media melden sich an der AGENTURWAND an -- an der
|
||||
Crew-Wand haetten sie gar keine Kachel. Zwei Waende, zwei Tueren;
|
||||
die Pruefung benutzt beide, weil das Haus beide hat. */
|
||||
const cigdem = await anmelden("manager", "CODE-MANA-0001", "workspace.dogfather-universe.com");
|
||||
const vanvan = await anmelden("spicy", "CODE-SPIC-0001", "workspace.dogfather-universe.com");
|
||||
ok(cigdem.ok && vanvan.ok, "Manager und Spicy Media kommen ueber die Agenturwand herein");
|
||||
|
||||
const namen = async (keks) => {
|
||||
const { personen } = await holen(keks, "/workspace/api/chat/partner");
|
||||
return (personen || []).map((p) => p.name);
|
||||
};
|
||||
const teamNamen = ["Ayla", "Ben", "Cem", "Vanessa"];
|
||||
|
||||
const beiDogi = await namen(dogi.keks);
|
||||
ok(teamNamen.every((n) => beiDogi.includes(n)),
|
||||
`DogFather hat sein Team in der Auswahl (${beiDogi.join(", ")})`);
|
||||
|
||||
const beiCigdem = await namen(cigdem.keks);
|
||||
ok(!teamNamen.some((n) => beiCigdem.includes(n)),
|
||||
`ein Manager hat es NICHT (${beiCigdem.join(", ") || "leer"})`);
|
||||
ok(beiCigdem.length > 0, "und seine Auswahl ist trotzdem nicht leer");
|
||||
|
||||
const beiVanVan = await namen(vanvan.keks);
|
||||
ok(!teamNamen.some((n) => beiVanVan.includes(n)),
|
||||
`Spicy Media hat es NICHT (${beiVanVan.join(", ") || "leer"})`);
|
||||
ok(beiVanVan.includes("Cigdem"), "sieht aber die Agentur");
|
||||
/* NIEMAND STEHT IN SEINER EIGENEN AUSWAHL. Bei Spicy Media stand er
|
||||
es doch -- siehe schreibbareIds() in workspace.js. Der Fall
|
||||
entsteht nur, wenn es jemanden zu verbergen gibt, und genau den
|
||||
stellt diese Pruefung her. */
|
||||
ok(!beiVanVan.includes("VanVan"), "und nicht sich selbst");
|
||||
ok(!beiDogi.includes("Filipe"), "DogFather ebenfalls nicht");
|
||||
ok(!beiCigdem.includes("Cigdem"), "der Manager auch nicht");
|
||||
|
||||
/* DIE UEBERSCHRIFT KOMMT MIT. Ohne sie faellt der Browser auf
|
||||
"Weitere" zurueck -- richtig gezeichnet, aber ohne Auskunft. */
|
||||
const roh = await holen(dogi.keks, "/workspace/api/chat/partner");
|
||||
const ayla = (roh.personen || []).find((p) => p.name === "Ayla");
|
||||
ok(!!ayla?.gruppe, `jede Person bringt ihre Ueberschrift mit ("${ayla?.gruppe}")`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
9. Eine Quelle fuer die Zugriffsfrage
|
||||
======================================================================= */
|
||||
console.log("\n=== Kein Aufruf mit blanker Nummer ===");
|
||||
{
|
||||
/* istDrin() braucht seit heute die PERSON, weil die Antwort an der
|
||||
Rolle haengt. Ein Aufruf mit `.id` liefe in den TypeError -- aber
|
||||
erst dann, wenn jemand genau diese Route benutzt. Hier faellt es
|
||||
sofort auf.
|
||||
|
||||
GEGENPROBE zur Suche selbst: Sie muss ein absichtlich falsches
|
||||
Muster erkennen. Sonst waere sie gruen, weil sie nie etwas findet. */
|
||||
const quelle = readFileSync(new URL("./workspace-chat.js", import.meta.url), "utf8");
|
||||
const muster = /istDrin\([^)]*\.id\s*\)/g;
|
||||
const treffer = quelle.match(muster) || [];
|
||||
ok(treffer.length === 0, `kein Aufruf uebergibt nur die Nummer (${treffer.join(", ") || "keiner"})`);
|
||||
const aufrufe = (quelle.match(/istDrin\(/g) || []).length;
|
||||
ok(aufrufe >= 8, `und es gibt wirklich Aufrufe zu pruefen (${aufrufe})`);
|
||||
ok("istDrin(raumId, req.person.id)".match(muster)?.length === 1,
|
||||
"die Suche erkennt einen falschen Aufruf");
|
||||
}
|
||||
|
||||
d.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
console.log(`\n${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"} -- ${geprueft} geprueft, ${fehler} Fehler`);
|
||||
/* process.exit(), wie in pruef-chat: Der Express-Server und die
|
||||
offenen Ereignisstroeme halten den Prozess sonst am Leben. */
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -319,6 +319,78 @@ melde("\n=== Gegenprobe ===");
|
||||
ok(!erfunden, "ein nie geschriebener Satz wird nicht gefunden");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
DER FUSS EINER NACHRICHT BLEIBT IN SEINER BLASE (10.09.2026)
|
||||
|
||||
Am Fuss jeder Nachricht stehen die Handgriffe: Zeit, antworten,
|
||||
zuruecknehmen, kopieren -- und seit Kapitel 7.2 auch anheften. Mit
|
||||
drei Eintraegen ging die Rechnung auf, mit vier nicht mehr:
|
||||
Gemessen stand "kopieren" bei 390 px, der haeufigsten Handybreite,
|
||||
18 px UEBER der Blase; bei 320 px waren es 75.
|
||||
|
||||
Gefunden hat das kein Testlauf, sondern ein Blick auf ein
|
||||
Bildschirmfoto. Damit das beim FUENFTEN Handgriff nicht wieder
|
||||
passiert, wird es ab jetzt gemessen -- und zwar an echten
|
||||
Bildpunkten, nicht an der Rechnung, die zu dem Fehler gefuehrt hat.
|
||||
|
||||
GEPRUEFT WIRD NICHT DIE ZAHL DER ZEILEN. Ob der Fuss ein- oder
|
||||
zweizeilig ist, darf sich aendern; dass etwas darin ueber den Rand
|
||||
haengt, nicht. Ein `flex-wrap: wrap` ist deshalb die Loesung und
|
||||
keine Schwelle: Es bricht genau dann um, wenn der Platz nicht reicht.
|
||||
======================================================================= */
|
||||
melde("\n=== Der Fuss bleibt in der Blase ===");
|
||||
{
|
||||
let gemessen = 0;
|
||||
let heraus = [];
|
||||
for (const breite of [320, 360, 390, 430]) {
|
||||
await luna.seite.setViewportSize({ width: breite, height: 844 });
|
||||
await luna.seite.waitForTimeout(350);
|
||||
const m = await luna.seite.evaluate(() => {
|
||||
const raus = [];
|
||||
let stuecke = 0;
|
||||
for (const f of document.querySelectorAll(".chat-nachricht__fuss")) {
|
||||
const blase = f.closest(".chat-nachricht__blase")?.getBoundingClientRect();
|
||||
if (!blase) continue;
|
||||
for (const k of f.children) {
|
||||
stuecke++;
|
||||
const r = k.getBoundingClientRect();
|
||||
if (r.width === 0) continue; /* ausgeblendet: kein Fall */
|
||||
if (r.right > blase.right + 0.5 || r.left < blase.left - 0.5) {
|
||||
raus.push((k.textContent || "?").trim() + " +" + Math.round(r.right - blase.right));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { raus, stuecke };
|
||||
});
|
||||
gemessen += m.stuecke;
|
||||
if (m.raus.length) heraus.push(`${breite}px: ${m.raus.join(", ")}`);
|
||||
}
|
||||
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG, nicht nur in den Meldetext.
|
||||
Findet der Selektor nichts, waere `heraus.length === 0` gruen --
|
||||
und haette nichts angesehen. Genau der Fehler vom 01.09. */
|
||||
ok(gemessen >= 12 && heraus.length === 0,
|
||||
`${gemessen} Fusselemente auf vier Breiten gemessen, nichts haengt heraus`
|
||||
+ (heraus.length ? ` -- ${heraus.join(" | ")}` : ""));
|
||||
|
||||
/* GEGENPROBE: Kann die Messung ueberhaupt "haengt heraus" sagen?
|
||||
Ein Element wird absichtlich hinausgeschoben. Sagt sie dann
|
||||
weiterhin "alles gut", misst sie gar nichts. */
|
||||
const erkannt = await luna.seite.evaluate(() => {
|
||||
const f = document.querySelector(".chat-nachricht__fuss");
|
||||
const k = f?.lastElementChild;
|
||||
if (!k) return null;
|
||||
const alt = k.style.marginLeft;
|
||||
k.style.marginLeft = "400px";
|
||||
const blase = f.closest(".chat-nachricht__blase").getBoundingClientRect();
|
||||
const draussen = k.getBoundingClientRect().right > blase.right + 0.5;
|
||||
k.style.marginLeft = alt;
|
||||
return draussen;
|
||||
});
|
||||
ok(erkannt === true, "und sie erkennt ein absichtlich hinausgeschobenes Element");
|
||||
await luna.seite.setViewportSize({ width: 1280, height: 900 });
|
||||
await luna.seite.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await luna.ctx.close();
|
||||
await pat.ctx.close();
|
||||
await browser.close();
|
||||
|
||||
+448
-16
@@ -44,6 +44,7 @@ import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, istDogFather,
|
||||
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
|
||||
fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE,
|
||||
} from "./workspace.js";
|
||||
|
||||
export const chatRouter = express.Router();
|
||||
@@ -95,12 +96,42 @@ chatRouter.use("/workspace/api/chat", angemeldet);
|
||||
* DIE WICHTIGSTE FUNKTION DER DATEI. Jede Route, die einen Verlauf
|
||||
* liest oder etwas hineinschreibt, geht hier durch. Wer sie umgeht,
|
||||
* macht aus einer Raumnummer einen Schlüssel. */
|
||||
function istDrin(raumId, personId) {
|
||||
/* SIE NIMMT DIE PERSON, NICHT IHRE NUMMER (10.09.2026, Kapitel 7.2).
|
||||
*
|
||||
* Seit es Kanaele gibt, haengt die Antwort auch an der ROLLE. Eine
|
||||
* Nummer allein koennte die Frage nicht mehr beantworten -- und haette
|
||||
* sie trotzdem beantwortet, mit "nein", ohne ein Wort dazu. Deshalb
|
||||
* wirft ein Aufruf mit einer Nummer einen Fehler statt weiterzulaufen:
|
||||
* Er faellt beim ersten Durchgang auf, nicht im Betrieb. */
|
||||
function istDrin(raumId, person) {
|
||||
if (!person || typeof person !== "object") {
|
||||
throw new TypeError("istDrin() braucht die Person, nicht ihre Nummer.");
|
||||
}
|
||||
try {
|
||||
const z = db().prepare(
|
||||
"SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL")
|
||||
.get(raumId, personId);
|
||||
return !!z;
|
||||
.get(raumId, person.id);
|
||||
if (z) return true;
|
||||
|
||||
/* KANAELE STEHEN DER TEAMFUEHRUNG OFFEN -- Kapitel 7.2: "Owner/
|
||||
rechte Hand haben Zugriff auf alle Kanaele."
|
||||
|
||||
DIE AUSNAHME STEHT HIER und nicht in den sieben Routen darueber.
|
||||
Dort waere sie in sechs davon gelandet und in der siebten nicht,
|
||||
und die siebte waere die gewesen, die keiner ausprobiert.
|
||||
|
||||
SIE GILT NUR FUER 'kanal'. Zweier-Gespraeche bleiben zu, auch
|
||||
fuer DogFather -- Kapitel 7.2 sagt das ausdruecklich: "Private
|
||||
1:1-Chats: ausschliesslich fuer die beteiligten Personen
|
||||
sichtbar - kein Zugriff durch Owner/rechte Hand." Und Gruppen
|
||||
bleiben ebenfalls zu: Wer eine Gruppe aufmacht, hat sich fuer
|
||||
einen geschlossenen Kreis entschieden, und den nachtraeglich
|
||||
still zu oeffnen waere das Gegenteil dessen, was er getan hat.
|
||||
Ein Kanal ist etwas anderes: Er gehoert dem Team, nicht den
|
||||
Anwesenden. */
|
||||
if (!fuehrtTeamDogi(person)) return false;
|
||||
const raum = db().prepare("SELECT art FROM chat_raeume WHERE id = ?").get(raumId);
|
||||
return raum?.art === "kanal";
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
@@ -286,7 +317,86 @@ function geloeschtBis(raumId, personId) {
|
||||
* Ein Zweier-Gespräch heißt für jede Seite anders -- nach dem
|
||||
* Gegenüber. Deshalb steht der Name nicht in der Tabelle: Er wäre für
|
||||
* eine der beiden Seiten immer falsch. */
|
||||
/* =====================================================================
|
||||
KATEGORIE-KANAELE (10.09.2026, Kapitel 7.2)
|
||||
===================================================================== */
|
||||
|
||||
/** Hoechstens drei Ankuendigungen je Raum.
|
||||
*
|
||||
* Nicht eine (dann kann man Regeln, Live-Plan und Frist nicht
|
||||
* gleichzeitig oben haben) und nicht beliebig viele: Eine Pinnwand,
|
||||
* die scrollt, ist keine Pinnwand mehr, sondern ein zweiter Verlauf.
|
||||
* Wer die vierte anheften will, muss sich fuer eine entscheiden --
|
||||
* und genau diese Entscheidung ist der Sinn der Funktion. */
|
||||
const PIN_MAX = 3;
|
||||
|
||||
/** Der Name eines Kanals kommt aus der Kategorie, nicht aus einem Feld.
|
||||
*
|
||||
* Ein freies Namensfeld waere die naheliegende Freiheit -- und der
|
||||
* sichere Weg zu "Clipping" neben "Clipping-Team", mit je der Haelfte
|
||||
* der Nachrichten darin. Wer die Kategorie umbenennt, benennt damit
|
||||
* jeden Kanal um; sie stehen nicht doppelt. */
|
||||
function kanalName(kategorie) {
|
||||
return MODI_KATEGORIEN.find((k) => k.wert === kategorie)?.name || "Kanal";
|
||||
}
|
||||
|
||||
/** Wer darf hier etwas anheften?
|
||||
*
|
||||
* Die Teamfuehrung ueberall -- so steht es im Dokument. UND wer eine
|
||||
* Gruppe angelegt hat, in seiner eigenen: Der Chat gehoert nicht nur
|
||||
* Team Dogi, auch Manager und Scouts fuehren Gruppen. Waere das
|
||||
* Anheften allein der Teamfuehrung vorbehalten, gaebe es die Funktion
|
||||
* fuer die Haelfte des Hauses nicht -- und sie waere trotzdem
|
||||
* sichtbar. */
|
||||
function darfAnheften(person, raumId) {
|
||||
if (fuehrtTeamDogi(person)) return true;
|
||||
try {
|
||||
return !!db().prepare(`SELECT 1 FROM chat_teilnehmer
|
||||
WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL AND leitung = 1`)
|
||||
.get(raumId, person.id);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Traegt die Teamfuehrung in jeden Kanal ein, in dem sie fehlt.
|
||||
*
|
||||
* WARUM SELBSTHEILEND UND NICHT AN DREI HAKEN: Die Zeile muesste sonst
|
||||
* beim Anlegen eines Kanals, beim Anlegen einer Person und beim
|
||||
* Rollenwechsel entstehen -- drei Stellen, von denen die dritte beim
|
||||
* naechsten Umbau vergessen wird. Hier laeuft es genau dann, wenn es
|
||||
* gebraucht wird: beim Blick in die Gespraechsliste, und nur fuer
|
||||
* zwei Rollen.
|
||||
*
|
||||
* `gelesen_bis` STARTET AM AKTUELLEN STAND, nicht bei null. Wer heute
|
||||
* dazukommt, hat die vierhundert Nachrichten von vorher nicht
|
||||
* verpasst -- eine rote Vierhundert waere keine Auskunft, sondern
|
||||
* Rauschen, und man schaltet sie ab statt sie zu lesen. */
|
||||
function kanaeleAngleichen(person) {
|
||||
if (!fuehrtTeamDogi(person)) return 0;
|
||||
try {
|
||||
const d = db();
|
||||
const offen = d.prepare(`
|
||||
SELECT r.id FROM chat_raeume r
|
||||
WHERE r.art = 'kanal'
|
||||
AND NOT EXISTS (SELECT 1 FROM chat_teilnehmer t
|
||||
WHERE t.raum_id = r.id AND t.person_id = ?)`).all(person.id);
|
||||
if (!offen.length) return 0;
|
||||
const rein = d.prepare(`
|
||||
INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung)
|
||||
SELECT ?, ?, ?, COALESCE((SELECT MAX(id) FROM chat_nachrichten WHERE raum_id = ?), 0), 1`);
|
||||
const n = jetzt();
|
||||
for (const r of offen) rein.run(r.id, person.id, n, r.id);
|
||||
return offen.length;
|
||||
} catch (fehler) {
|
||||
/* Ein Fehler hier darf die Gespraechsliste nicht kosten. Der
|
||||
Zugriff selbst haengt ohnehin nicht daran -- istDrin() laesst die
|
||||
Teamfuehrung auch ohne Zeile in einen Kanal. */
|
||||
console.error("[chat] Kanal-Abgleich:", fehler?.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function raumName(raum, teilnehmer, ichId) {
|
||||
if (raum.art === "kanal") return raum.name || kanalName(raum.kategorie);
|
||||
if (raum.art === "gruppe") return raum.name || "Gruppe";
|
||||
const andere = teilnehmer.filter((t) => t.id !== ichId);
|
||||
return andere.map((t) => t.name).join(", ") || "Gespräch";
|
||||
@@ -305,10 +415,19 @@ chatRouter.get("/workspace/api/chat/partner", (req, res) => {
|
||||
const werte = ids === null ? [req.person.id] : ids;
|
||||
if (ids !== null && !ids.length) return res.json({ personen: [] });
|
||||
|
||||
/* DIE UEBERSCHRIFT KOMMT MIT (10.09.2026).
|
||||
|
||||
Die Auswahl im Browser zeichnete nach der Rollenfolge aus
|
||||
bereiche.js -- und wer dort nicht steht, wurde nicht gezeichnet.
|
||||
Still: keine Luecke, kein Fehler, nur ein Mensch, den es fuer
|
||||
die Auswahl nicht gab. Mit einem Text vom Server kann der
|
||||
Browser auch eine Rolle zeigen, deren Namen er gar nicht kennen
|
||||
darf. */
|
||||
res.json({
|
||||
personen: db().prepare(
|
||||
`SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND ${wo}
|
||||
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte),
|
||||
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte)
|
||||
.map((p) => ({ ...p, gruppe: ROLLEN_GRUPPE[p.rolle] || "Weitere" })),
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Partner:", fehler?.message);
|
||||
@@ -330,8 +449,16 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||||
mit dem Neuen (id > geloescht_bis).
|
||||
* Es kam nichts -> es bleibt weg. Auch dann, wenn es nie eine
|
||||
Nachricht gab; dafür steht geloescht_am. */
|
||||
/* ERST ANGLEICHEN, DANN LESEN. Ein Kanal, in dem die Teamfuehrung
|
||||
keine Zeile hat, waere ihr zugaenglich (istDrin laesst sie
|
||||
hinein) und stuende trotzdem nicht in ihrer Liste -- erreichbar
|
||||
nur ueber einen Link, den ihr jemand schickt. Das ist genau die
|
||||
Sorte Loch, die man nicht bemerkt, weil nichts kaputt aussieht. */
|
||||
kanaeleAngleichen(req.person);
|
||||
|
||||
const reihen = db().prepare(`
|
||||
SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung, t.geloescht_bis
|
||||
SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am,
|
||||
t.gelesen_bis, t.leitung, t.geloescht_bis
|
||||
FROM chat_raeume r
|
||||
JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ?
|
||||
WHERE t.raus_am IS NULL
|
||||
@@ -361,6 +488,7 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||||
return {
|
||||
id: r.id,
|
||||
art: r.art,
|
||||
kategorie: r.kategorie || null,
|
||||
name: raumName(r, leute, req.person.id),
|
||||
leitung: !!r.leitung,
|
||||
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })),
|
||||
@@ -378,7 +506,26 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||||
ungelesen,
|
||||
};
|
||||
});
|
||||
res.json({ raeume });
|
||||
/* WELCHE KANAELE ES NOCH NICHT GIBT -- und nur fuer die, die sie
|
||||
anlegen duerfen.
|
||||
|
||||
Die Liste steht bewusst NICHT in einer Datei im Browser. Sie
|
||||
verraet fuer sich genommen nichts Schlimmes, aber sie ist der
|
||||
Anfang einer Aufzaehlung dessen, was das Team tut; und die
|
||||
Oberflaeche zeigt den Knopf ohnehin nur, wenn hier etwas
|
||||
ankommt. Wer nichts anlegen darf, bekommt ein leeres Feld -- und
|
||||
ein leeres Feld sagt nichts. */
|
||||
const darfKanal = fuehrtTeamDogi(req.person);
|
||||
const belegt = darfKanal
|
||||
? new Set(db().prepare("SELECT kategorie FROM chat_raeume WHERE art = 'kanal'")
|
||||
.all().map((z) => z.kategorie))
|
||||
: new Set();
|
||||
|
||||
res.json({
|
||||
raeume,
|
||||
darf_kanal: darfKanal,
|
||||
kanal_frei: darfKanal ? MODI_KATEGORIEN.filter((k) => !belegt.has(k.wert)) : [],
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Räume:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
@@ -449,6 +596,173 @@ chatRouter.post("/workspace/api/chat/gruppe", gleicheHerkunft, express.json({ li
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
EINEN KANAL ANLEGEN (10.09.2026, Kapitel 7.2)
|
||||
|
||||
Kein Name im Rumpf, nur die Kategorie -- siehe kanalName(). Und keine
|
||||
Pflicht, gleich jemanden hineinzusetzen: Ein Kanal, den DogFather
|
||||
morgens anlegt und mittags fuellt, ist ein normaler Vorgang; ihn
|
||||
dafuer zweimal aufmachen zu muessen waere eine Regel ohne Zweck.
|
||||
===================================================================== */
|
||||
chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||||
try {
|
||||
/* 404 und nicht 403: Wer keine Kanaele fuehren darf, soll auch
|
||||
nicht erfahren, dass es die Moeglichkeit gibt. */
|
||||
if (!fuehrtTeamDogi(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const kategorie = String(req.body?.kategorie ?? "").trim();
|
||||
if (!MODI_KATEGORIEN.some((k) => k.wert === kategorie)) {
|
||||
return res.status(400).json({ fehler: "Unbekannte Kategorie." });
|
||||
}
|
||||
|
||||
const d = db();
|
||||
/* Die Regel steht hier UND als eindeutiger Index in der Datenbank.
|
||||
Doppelt gemoppelt ist es nicht: Hier gibt es eine Meldung, die
|
||||
jemand lesen kann, und dort eine Zusage, die auch dann gilt, wenn
|
||||
einmal zwei Anfragen im selben Augenblick ankommen. */
|
||||
const schon = d.prepare("SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?")
|
||||
.get(kategorie);
|
||||
if (schon) {
|
||||
return res.status(409).json({ fehler: "Diesen Kanal gibt es schon.", raum_id: schon.id });
|
||||
}
|
||||
|
||||
const roh = Array.isArray(req.body?.mit) ? req.body.mit : [];
|
||||
const mit = [...new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
.filter((id) => id !== req.person.id);
|
||||
/* Jede Nummer einzeln gegen dieselbe Regel wie bei einer Gruppe.
|
||||
Ein Kanal ist kein Schlupfloch, um jemanden zu erreichen, den man
|
||||
einzeln nicht anschreiben duerfte. */
|
||||
if (mit.some((id) => !darfSchreibenMit(req.person, id))) {
|
||||
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
|
||||
}
|
||||
|
||||
const n = jetzt();
|
||||
d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von)
|
||||
VALUES ('kanal',?,?,?,?)`)
|
||||
.run(kanalName(kategorie), kategorie, n, req.person.id);
|
||||
const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
|
||||
/* DIE GANZE TEAMFUEHRUNG KOMMT HINEIN, nicht nur der Anlegende.
|
||||
kanaeleAngleichen() wuerde das beim naechsten Blick in die Liste
|
||||
ohnehin nachholen -- aber "beim naechsten Blick" heisst: Die
|
||||
rechte Hand sieht den Kanal erst, wenn sie die Seite neu laedt.
|
||||
Hier ist er sofort da. */
|
||||
const fuehrung = d.prepare(
|
||||
"SELECT id FROM personen WHERE aktiv = 1 AND rolle IN ('admin','hand')").all().map((z) => z.id);
|
||||
const reinLeitung = d.prepare(
|
||||
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) VALUES (?,?,?,0,1)");
|
||||
for (const id of new Set([req.person.id, ...fuehrung])) reinLeitung.run(raumId, id, n);
|
||||
|
||||
const rein = d.prepare(
|
||||
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)");
|
||||
let drin = 0;
|
||||
for (const id of mit) {
|
||||
if (fuehrung.includes(id)) continue; /* steht schon als Leitung drin */
|
||||
if (d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) {
|
||||
rein.run(raumId, id, n); drin++;
|
||||
}
|
||||
}
|
||||
|
||||
protokolliere("chat_kanal_angelegt", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${raumId} ${kategorie} (${drin})`.slice(0, 120),
|
||||
});
|
||||
res.status(201).json({ raum_id: raumId, name: kanalName(kategorie) });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Kanal:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
WER IST IM KANAL (10.09.2026)
|
||||
|
||||
Ohne diese Route waere ein Kanal beim Anlegen fuer immer festgelegt:
|
||||
Ein neuer Modi koennte nie in "Clipping" -- der Kanal waere richtig
|
||||
gebaut und trotzdem unbrauchbar.
|
||||
|
||||
ENTFERNT WIRD MIT `raus_am`, nicht mit DELETE. Wer draussen ist,
|
||||
schreibt und liest nichts Neues mehr; der Verlauf bis dahin bleibt
|
||||
aber lesbar, und niemand verschwindet rueckwirkend aus der
|
||||
Geschichte. Dieselbe Entscheidung wie bei den Gruppen.
|
||||
===================================================================== */
|
||||
chatRouter.put("/workspace/api/chat/kanal/:id/mitglieder", gleicheHerkunft,
|
||||
express.json({ limit: "8kb" }), (req, res) => {
|
||||
try {
|
||||
if (!fuehrtTeamDogi(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const d = db();
|
||||
const raum = d.prepare("SELECT id, art FROM chat_raeume WHERE id = ?").get(raumId);
|
||||
/* Ausdruecklich nur Kanaele. Diese Route auf Gruppen loszulassen
|
||||
hiesse, dass die Teamfuehrung fremde Gruppen umbesetzen kann --
|
||||
etwas voellig anderes, und niemand hat darum gebeten. */
|
||||
if (!raum || raum.art !== "kanal") return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const roh = Array.isArray(req.body?.mit) ? req.body.mit : [];
|
||||
const soll = new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0));
|
||||
if ([...soll].some((id) => !darfSchreibenMit(req.person, id))) {
|
||||
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
|
||||
}
|
||||
|
||||
/* Die Teamfuehrung bleibt unangetastet -- sie hat laut Kapitel 7.2
|
||||
Zugriff auf alle Kanaele. Sie aus der Liste entfernen zu koennen
|
||||
waere eine Tuer, die man hinter sich zuzieht: istDrin() liesse
|
||||
sie zwar weiter hinein, in der Gespraechsliste stuende der Kanal
|
||||
aber nicht mehr. */
|
||||
const fuehrung = new Set(d.prepare(
|
||||
"SELECT id FROM personen WHERE rolle IN ('admin','hand')").all().map((z) => z.id));
|
||||
const ist = d.prepare(
|
||||
"SELECT person_id FROM chat_teilnehmer WHERE raum_id = ? AND raus_am IS NULL")
|
||||
.all(raumId).map((z) => z.person_id).filter((id) => !fuehrung.has(id));
|
||||
|
||||
const n = jetzt();
|
||||
/* WER HEUTE DAZUKOMMT, HAT NICHTS VERPASST. Ohne diesen Stand
|
||||
staende beim ersten Oeffnen eine rote Zahl ueber allem, was das
|
||||
Team je in diesem Kanal geschrieben hat -- lesbar ist es
|
||||
weiterhin, ungelesen war es nie. */
|
||||
const hoechste = d.prepare(
|
||||
"SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?").get(raumId).m;
|
||||
|
||||
let dazu = 0, raus = 0;
|
||||
for (const id of soll) {
|
||||
if (fuehrung.has(id) || ist.includes(id)) continue;
|
||||
if (!d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) continue;
|
||||
/* Zurueckgeholt oder neu -- eine Zeile kann es schon geben (mit
|
||||
raus_am). Ein blankes INSERT liefe dann auf den
|
||||
Primaerschluessel auf. */
|
||||
const wieder = d.prepare(
|
||||
"SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?").get(raumId, id);
|
||||
if (wieder) {
|
||||
d.prepare("UPDATE chat_teilnehmer SET raus_am = NULL, seit = ? WHERE raum_id = ? AND person_id = ?")
|
||||
.run(n, raumId, id);
|
||||
} else {
|
||||
d.prepare(`INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis)
|
||||
VALUES (?,?,?,?)`).run(raumId, id, n, hoechste);
|
||||
}
|
||||
dazu++;
|
||||
}
|
||||
for (const id of ist) {
|
||||
if (soll.has(id)) continue;
|
||||
d.prepare("UPDATE chat_teilnehmer SET raus_am = ? WHERE raum_id = ? AND person_id = ?")
|
||||
.run(n, raumId, id);
|
||||
raus++;
|
||||
}
|
||||
|
||||
if (dazu || raus) {
|
||||
protokolliere("chat_kanal_besetzung", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${raumId} +${dazu} -${raus}`,
|
||||
});
|
||||
}
|
||||
res.json({ ok: true, dazu, raus, teilnehmer: teilnehmerVon(raumId).map((t) => t.id) });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Kanal-Besetzung:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Der Verlauf eines Gesprächs. */
|
||||
chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
try {
|
||||
@@ -457,7 +771,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
/* Was man nicht sehen darf, existiert nicht -- 404 statt 403,
|
||||
sonst liesse sich durch Ausprobieren herausfinden, welche
|
||||
Gespräche es gibt. */
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
/* Nur die letzten 200. Ein Gespräch, das ein Jahr läuft, soll den
|
||||
Browser nicht mit zehntausend Zeilen bewerfen; ältere holt man
|
||||
@@ -478,7 +792,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
const reihen = db().prepare(`
|
||||
SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle,
|
||||
p.bild AS von_bild,
|
||||
n.antwort_auf,
|
||||
n.antwort_auf, n.angeheftet_am,
|
||||
n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
|
||||
n.anhang_breite, n.anhang_hoehe,
|
||||
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
|
||||
@@ -500,17 +814,53 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
"SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
|
||||
.get(raumId, req.person.id)?.gelesen_bis ?? 0;
|
||||
|
||||
const raum = db().prepare("SELECT id, art, name FROM chat_raeume WHERE id = ?").get(raumId);
|
||||
const raum = db().prepare(
|
||||
"SELECT id, art, name, kategorie FROM chat_raeume WHERE id = ?").get(raumId);
|
||||
const leute = teilnehmerVon(raumId);
|
||||
|
||||
/* DIE ANGEHEFTETEN, in einer eigenen Abfrage.
|
||||
|
||||
Sie MUESSEN getrennt geholt werden: Der Verlauf oben liefert nur
|
||||
die letzten 200 Zeilen. Eine Ankuendigung von vorletzter Woche
|
||||
faellt da heraus -- und waere damit ausgerechnet dann verschwunden,
|
||||
wenn sie am laengsten oben stehen sollte.
|
||||
|
||||
DIESELBEN ZWEI GRENZEN WIE DER VERLAUF: `n.id > weg` (was ich
|
||||
weggeraeumt habe, kommt nicht ueber die Hintertuer zurueck) und
|
||||
`weg_am IS NULL` (eine zurueckgenommene Nachricht bleibt
|
||||
zurueckgenommen, auch als Aushang). */
|
||||
const weggeraeumt = weg;
|
||||
const angeheftet = db().prepare(`
|
||||
SELECT n.id, n.text, n.erstellt, n.angeheftet_am, n.anhang_art,
|
||||
p.name AS von, q.name AS heftete
|
||||
FROM chat_nachrichten n
|
||||
LEFT JOIN personen p ON p.id = n.person_id
|
||||
LEFT JOIN personen q ON q.id = n.angeheftet_von
|
||||
WHERE n.raum_id = ? AND n.angeheftet_am IS NOT NULL
|
||||
AND n.weg_am IS NULL AND n.id > ?
|
||||
ORDER BY n.angeheftet_am DESC LIMIT ?`).all(raumId, weggeraeumt, PIN_MAX);
|
||||
|
||||
res.json({
|
||||
raum: {
|
||||
id: raum.id, art: raum.art,
|
||||
id: raum.id, art: raum.art, kategorie: raum.kategorie || null,
|
||||
name: raumName(raum, leute, req.person.id),
|
||||
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle,
|
||||
bild: t.bild, leitung: !!t.leitung })),
|
||||
},
|
||||
gelesen_bis: gelesenBis,
|
||||
/* OB DER KNOPF UEBERHAUPT ERSCHEINT, entscheidet der Server.
|
||||
Die Oberflaeche koennte `rolle === 'admin'` vergleichen -- aber
|
||||
assets/js/chat.js bekommt jeder, der die Seite oeffnet. Ein
|
||||
Ja/Nein verraet nichts ueber die Rollen dahinter. */
|
||||
darf_anheften: darfAnheften(req.person, raumId),
|
||||
angeheftet: angeheftet.map((a) => ({
|
||||
id: a.id,
|
||||
von: a.von || "Gelöscht",
|
||||
heftete: a.heftete || null,
|
||||
seit: a.angeheftet_am,
|
||||
text: String(a.text || "").slice(0, 200)
|
||||
|| (a.anhang_art === "bild" ? "Foto" : a.anhang_art === "pdf" ? "PDF" : ""),
|
||||
})),
|
||||
/* Aufsteigend zurück -- gelesen wird von oben nach unten. */
|
||||
nachrichten: reihen.reverse().map((n) => ({
|
||||
id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle,
|
||||
@@ -519,6 +869,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
zurueckgenommen: !!n.weg_am,
|
||||
erstellt: n.erstellt,
|
||||
selbst: n.person_id === req.person.id,
|
||||
angeheftet: !!n.angeheftet_am,
|
||||
/* Das Zitat wird GEKUERZT geschickt, nicht ganz: Es ist ein
|
||||
Hinweis auf die Stelle, nicht ihre Wiederholung. Und eine
|
||||
zurueckgenommene Nachricht bleibt auch im Zitat
|
||||
@@ -555,7 +906,7 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
|
||||
try {
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const text = String(req.body?.text ?? "").trim();
|
||||
if (!text) return res.status(400).json({ fehler: "leer" });
|
||||
@@ -640,7 +991,7 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
|
||||
function drinFuerAnhang(req, res, next) {
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -733,7 +1084,7 @@ chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => {
|
||||
anhang_datei, anhang_name, anhang_art, anhang_typ
|
||||
FROM chat_nachrichten WHERE id = ?`).get(id);
|
||||
if (!n || !n.anhang_datei || n.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(n.raum_id, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(n.raum_id, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (n.id <= geloeschtBis(n.raum_id, req.person.id)) {
|
||||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
}
|
||||
@@ -788,7 +1139,7 @@ chatRouter.delete("/workspace/api/chat/raeume/:id", gleicheHerkunft, (req, res)
|
||||
try {
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const d = db();
|
||||
const bis = d.prepare("SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?")
|
||||
@@ -904,7 +1255,7 @@ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft,
|
||||
if (!Number.isInteger(raumId) || !Number.isInteger(bis)) {
|
||||
return res.status(400).json({ fehler: "ungueltig" });
|
||||
}
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
/* NUR VORWÄRTS. Ein zu spät eintreffender Aufruf einer älteren
|
||||
Ansicht darf den Stand nicht zurückdrehen -- sonst stünde eine
|
||||
gelesene Nachricht wieder als ungelesen da. */
|
||||
@@ -918,6 +1269,80 @@ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft,
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
ANKUENDIGUNGEN ANHEFTEN (10.09.2026, Kapitel 7.2)
|
||||
|
||||
"Ankuendigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand an
|
||||
alle."
|
||||
|
||||
KEINE ZWEITE SORTE NACHRICHT. Naheliegend waere eine eigene Tabelle
|
||||
"ankuendigungen" gewesen -- und damit ein zweiter Verlauf mit
|
||||
eigenen Rechten, eigener Suche, eigenem Loeschen. Angeheftet wird
|
||||
deshalb eine ganz normale Nachricht: Sie steht im Verlauf, sie ist
|
||||
ueber die Suche zu finden, sie laesst sich zuruecknehmen, und sie
|
||||
traegt zusaetzlich ein Datum und einen Namen.
|
||||
|
||||
WAS PASSIERT, WENN DER ABSENDER SIE ZURUECKNIMMT: Sie faellt von
|
||||
selbst ab (siehe unten im Zuruecknehmen). Ein Aushang, dessen Text
|
||||
"Nachricht zurueckgenommen" lautet, waere die schlechteste aller
|
||||
Antworten -- er steht oben, faellt auf und sagt nichts.
|
||||
===================================================================== */
|
||||
chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft,
|
||||
express.json({ limit: "2kb" }), (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const d = db();
|
||||
const n = d.prepare(
|
||||
"SELECT id, raum_id, weg_am, angeheftet_am FROM chat_nachrichten WHERE id = ?").get(id);
|
||||
/* Erst die Mitgliedschaft (404 -- was man nicht sehen darf,
|
||||
existiert nicht), dann das Recht (403 -- man sieht es, darf es
|
||||
aber nicht). Zwei verschiedene Antworten fuer zwei verschiedene
|
||||
Faelle; sie zu vermischen hiesse, ueber die eine die andere
|
||||
verraten zu koennen. */
|
||||
if (!n || !istDrin(n.raum_id, req.person)) {
|
||||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
}
|
||||
if (!darfAnheften(req.person, n.raum_id)) {
|
||||
return res.status(403).json({ fehler: "Anheften darf nur die Leitung." });
|
||||
}
|
||||
|
||||
const an = req.body?.an !== false;
|
||||
if (an && n.weg_am) {
|
||||
return res.status(400).json({ fehler: "Eine zurückgenommene Nachricht geht nicht." });
|
||||
}
|
||||
|
||||
if (an && !n.angeheftet_am) {
|
||||
const schon = d.prepare(`SELECT COUNT(*) AS c FROM chat_nachrichten
|
||||
WHERE raum_id = ? AND angeheftet_am IS NOT NULL AND weg_am IS NULL`).get(n.raum_id).c;
|
||||
if (schon >= PIN_MAX) {
|
||||
return res.status(409).json({
|
||||
fehler: `Es hängen schon ${PIN_MAX} oben. Löse zuerst eine ab.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
d.prepare("UPDATE chat_nachrichten SET angeheftet_am = ?, angeheftet_von = ? WHERE id = ?")
|
||||
.run(an ? jetzt() : null, an ? req.person.id : null, id);
|
||||
|
||||
/* ALLE IM RAUM SEHEN ES SOFORT. Ein Aushang, den die anderen erst
|
||||
beim naechsten Laden bemerken, ist bei einer kurzfristigen
|
||||
Ansage genau einen Live-Abend zu spaet. Keine Push-Meldung
|
||||
dazu: Das Anheften ist kein neuer Satz, sondern eine Betonung. */
|
||||
chatEreignis(n.raum_id, { raum_id: n.raum_id, id, angeheftet: an }, [], "pin");
|
||||
|
||||
protokolliere(an ? "chat_angeheftet" : "chat_abgeheftet", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${id} in Raum #${n.raum_id}`,
|
||||
});
|
||||
res.json({ ok: true, angeheftet: an });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Anheften:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Eine eigene Nachricht zurücknehmen. */
|
||||
chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
@@ -926,7 +1351,7 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
|
||||
|
||||
const n = db().prepare(
|
||||
"SELECT id, raum_id, person_id, anhang_datei FROM chat_nachrichten WHERE id = ?").get(id);
|
||||
if (!n || !istDrin(n.raum_id, req.person.id)) {
|
||||
if (!n || !istDrin(n.raum_id, req.person)) {
|
||||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
}
|
||||
/* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem
|
||||
@@ -945,8 +1370,15 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
|
||||
einer. Erst die Zeile leeren, dann die Datei -- scheitert das
|
||||
Entfernen (Datei schon weg), ist der Verweis trotzdem fort. */
|
||||
const dateiWeg = n.anhang_datei;
|
||||
/* UND SIE FAELLT VOM AUSHANG (10.09.2026). Ohne diese zwei
|
||||
Spalten stuende die Nachricht weiter oben angeheftet -- mit dem
|
||||
Text "Nachricht zurueckgenommen", an der auffaelligsten Stelle
|
||||
des Raums. Die Anzeige filtert zwar auf `weg_am IS NULL`, aber
|
||||
der Platz waere belegt: PIN_MAX zaehlt mit, und niemand kaeme
|
||||
darauf, warum sich nichts mehr anheften laesst. */
|
||||
db().prepare(`UPDATE chat_nachrichten
|
||||
SET text = '', weg_am = ?,
|
||||
angeheftet_am = NULL, angeheftet_von = NULL,
|
||||
anhang_datei = NULL, anhang_name = NULL, anhang_art = NULL,
|
||||
anhang_typ = NULL, anhang_groesse = NULL,
|
||||
anhang_breite = NULL, anhang_hoehe = NULL
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
===================================================================== */
|
||||
import express from "express";
|
||||
import {
|
||||
db, sitzungLesen, istDogFather, heuteLokal, protokolliere, echteIp,
|
||||
db, sitzungLesen, istDogFather, fuehrtTeamDogi, heuteLokal, protokolliere, echteIp,
|
||||
} from "./workspace.js";
|
||||
import { MODI_PUNKTE } from "./workspace-modi-punkte.js";
|
||||
import { checklistenTabellen } from "./workspace-checkliste.js";
|
||||
@@ -112,8 +112,12 @@ function stufeVon(wert) {
|
||||
return STUFEN.includes(String(wert || "")) ? String(wert) : "probe";
|
||||
}
|
||||
|
||||
/* AUS DER EINEN QUELLE (10.09.2026). Hier stand
|
||||
`istDogFather(person) || person?.rolle === "hand"` -- dieselbe Regel
|
||||
noch einmal. Beim naechsten Mal (den Kategorie-Kanaelen) waere sie
|
||||
ein drittes Mal dagestanden. Jetzt fragen alle dieselbe Funktion. */
|
||||
function darfEingang(person) {
|
||||
return istDogFather(person) || person?.rolle === "hand";
|
||||
return fuehrtTeamDogi(person);
|
||||
}
|
||||
|
||||
teamlageRouter.get("/workspace/api/teamlage", (req, res) => {
|
||||
|
||||
+143
-5
@@ -115,6 +115,21 @@ const LEITUNG = new Set(["spicy", "admin", "manager"]);
|
||||
export const istLeitung = (person) => !!person && LEITUNG.has(person.rolle);
|
||||
export const istDogFather = (person) => !!person && person.rolle === "admin";
|
||||
|
||||
/* WER FUEHRT TEAM DOGI? (10.09.2026)
|
||||
*
|
||||
* DogFather und seine rechte Hand -- und sonst niemand. Spicy Media und
|
||||
* die Manager stehen bewusst NICHT darin: Sie fuehren die Agentur, nicht
|
||||
* das Team.
|
||||
*
|
||||
* WARUM DAS HIER STEHT UND NICHT DORT, WO ES GEBRAUCHT WIRD: Es wurde
|
||||
* schon zweimal gebraucht -- im Eingang (`darfEingang`) und jetzt bei
|
||||
* den Kategorie-Kanaelen. Beim dritten Mal waere es dreimal
|
||||
* hingeschrieben, und die dritte Abschrift ist die, die eine Rolle
|
||||
* vergisst. Der Eingang ruft ab jetzt hierher.
|
||||
*/
|
||||
export const fuehrtTeamDogi = (person) =>
|
||||
!!person && (person.rolle === "admin" || person.rolle === "hand");
|
||||
|
||||
/* =======================================================================
|
||||
SPICY MEDIA (07.09.2026)
|
||||
|
||||
@@ -262,6 +277,36 @@ export const ROLLEN_NAME = {
|
||||
modi: "Modi",
|
||||
};
|
||||
|
||||
/* DIE UEBERSCHRIFT UEBER EINER PERSONENLISTE (10.09.2026).
|
||||
*
|
||||
* Sie ist NICHT dasselbe wie das Namensschild: Ueber einer Liste steht
|
||||
* die Mehrzahl ("Scouts"), am einzelnen Menschen die Einzahl ("Scout").
|
||||
* Der Browser hat diese Zuordnung in bereiche.js ebenfalls -- aber nur
|
||||
* fuer die fuenf Rollen, die dort stehen duerfen.
|
||||
*
|
||||
* WARUM SIE HIER GEBRAUCHT WIRD: In der Personenauswahl des Chats
|
||||
* wurde nach `ROLLENFOLGE` aus bereiche.js gezeichnet. Wer dort nicht
|
||||
* vorkommt, wurde nicht gezeichnet -- ohne Fehler, ohne Luecke, ohne
|
||||
* Hinweis. Team Dogi kommt dort nicht vor und darf es auch nicht (der
|
||||
* Rollenname gehoert in keine Datei, die jeder herunterlaedt). Folge:
|
||||
* DogFather konnte ueber die Auswahl niemandem aus seinem Team
|
||||
* schreiben und keinen in einen Kanal setzen. Aufgefallen ist es nicht
|
||||
* beim Lesen, sondern beim Hinsehen: Der Kanal hatte hinterher zwei
|
||||
* Leute statt vier.
|
||||
*
|
||||
* Jetzt schickt der Server die Ueberschrift mit. Der Browser braucht
|
||||
* dafuer keinen Rollennamen zu kennen -- er bekommt einen Text.
|
||||
*/
|
||||
export const ROLLEN_GRUPPE = {
|
||||
...ROLLEN_NAME,
|
||||
scout: "Scouts",
|
||||
/* Beide unter EINER Ueberschrift: In der Auswahl sucht man einen
|
||||
Menschen, keine Rangordnung -- und zwei Ueberschriften mit je
|
||||
einem Namen darunter waeren mehr Gliederung als Inhalt. */
|
||||
hand: "Team Dogi",
|
||||
modi: "Team Dogi",
|
||||
};
|
||||
|
||||
/* =====================================================================
|
||||
DIE KACHELN EINES MODIS (10.09.2026)
|
||||
|
||||
@@ -1131,6 +1176,40 @@ function umstellungen(d) {
|
||||
sich keine Aufgabe machen, ohne zu raten, welcher Satz gemeint
|
||||
war. */
|
||||
["eintraege", "einsatz", "TEXT"],
|
||||
|
||||
/* KATEGORIE-KANAELE (10.09.2026, Kapitel 7.2).
|
||||
|
||||
"Kategorie-Kanaele: z. B. Clipping-Team, Live-Moderation --
|
||||
Owner/rechte Hand haben Zugriff auf alle Kanaele."
|
||||
|
||||
Ein Kanal ist KEINE vierte Tabelle, sondern eine dritte Art Raum.
|
||||
Damit gilt fuer ihn ohne eine einzige neue Zeile alles, was schon
|
||||
da ist: Verlauf, Anhaenge, Suche, Ungelesen-Zaehler, der
|
||||
Live-Strom, das Wegraeumen. Eine eigene Tabelle haette all das
|
||||
ein zweites Mal gebraucht -- und die zweite Fassung waere die
|
||||
gewesen, in der die Zugriffsregel fehlt.
|
||||
|
||||
WELCHE Zustaendigkeit, steht in kategorie und kommt aus
|
||||
MODI_KATEGORIEN -- derselben Liste, aus der auch die Aufgaben
|
||||
ihre Kategorie nehmen. Ein Kanal "Clipping" und Aufgaben
|
||||
"clipping" waeren sonst zwei Woerter fuer dieselbe Sache. */
|
||||
["chat_raeume", "kategorie", "TEXT"],
|
||||
|
||||
/* ANGEHEFTETE NACHRICHTEN (10.09.2026, Kapitel 7.2).
|
||||
|
||||
"Ankuendigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand
|
||||
an alle."
|
||||
|
||||
Am Anheften haengt ein ZWEITER Name (angeheftet_von) und nicht
|
||||
nur ein Datum. Eine Ankuendigung ohne Absender ist ein Aushang
|
||||
ohne Unterschrift -- und wer sie geloest hat, ist genau die
|
||||
Frage, die spaeter gestellt wird.
|
||||
|
||||
KEIN FREMDSCHLUESSEL AUF personen: Wird jemand geloescht, soll
|
||||
die Ankuendigung nicht mitverschwinden. Dieselbe Ueberlegung wie
|
||||
bei antwort_auf weiter oben. */
|
||||
["chat_nachrichten", "angeheftet_am", "TEXT"],
|
||||
["chat_nachrichten", "angeheftet_von", "INTEGER"],
|
||||
]) {
|
||||
try {
|
||||
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
|
||||
@@ -1695,6 +1774,36 @@ function umstellungen(d) {
|
||||
Vorschlag geworden ist. */
|
||||
checkListeErweitern(d, "eintraege", "status", "angenommen",
|
||||
["offen", "erledigt", "angenommen", "abgelehnt"], jetztStempel);
|
||||
|
||||
/* DIE DRITTE RAUMART (10.09.2026, Kapitel 7.2). Auf einer frischen
|
||||
Anlage steht 'kanal' schon im Bauplan -- dann findet der Aufruf die
|
||||
Regel bereits erweitert und tut nichts. */
|
||||
checkListeErweitern(d, "chat_raeume", "art", "kanal",
|
||||
["direkt", "gruppe", "kanal"], jetztStempel);
|
||||
|
||||
/* EIN KANAL JE ZUSTAENDIGKEIT, und die Datenbank haelt das fest.
|
||||
|
||||
Die Regel steht auch in der Route -- aber eine Regel, die nur in
|
||||
einer Route steht, gilt genau so lange, bis jemand eine zweite
|
||||
Route schreibt. Zwei Kanaele "Clipping" waeren zwei halbe
|
||||
Verlaeufe, und man merkt es erst, wenn jemand die Antwort im
|
||||
falschen sucht.
|
||||
|
||||
TEILINDEX (`WHERE art = 'kanal'`): Gruppen und Zweier-Gespraeche
|
||||
haben gar keine Kategorie; ohne die Bedingung waere schon die
|
||||
zweite Gruppe mit NULL ein Verstoss -- nein, NULL zaehlt in SQLite
|
||||
nicht als Dublette, aber der Index waere trotzdem groesser als
|
||||
noetig und wuerde etwas versprechen, was er nicht meint.
|
||||
|
||||
ER STEHT HIER UNTEN und nicht im Bauplan: Die Spalte `kategorie`
|
||||
entsteht auf einer bestehenden Anlage erst durch die Schleife
|
||||
weiter oben -- im Bauplan gaebe es sie beim Start noch nicht. */
|
||||
try {
|
||||
d.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_kanal_kategorie
|
||||
ON chat_raeume (kategorie) WHERE art = 'kanal'`);
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Kanal-Index:", fehler?.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function db() {
|
||||
@@ -1902,11 +2011,17 @@ export function db() {
|
||||
CREATE TABLE IF NOT EXISTS chat_raeume (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
art TEXT NOT NULL DEFAULT 'direkt'
|
||||
CHECK (art IN ('direkt','gruppe')),
|
||||
/* Nur Gruppen haben einen Namen. Ein Zweier-Gespraech heisst
|
||||
immer nach dem Gegenueber -- und zwar fuer jeden anders. Ein
|
||||
gespeicherter Name waere fuer eine der beiden Seiten falsch. */
|
||||
CHECK (art IN ('direkt','gruppe','kanal')),
|
||||
/* Gruppen und Kanaele haben einen Namen. Ein Zweier-Gespraech
|
||||
heisst immer nach dem Gegenueber -- und zwar fuer jeden
|
||||
anders. Ein gespeicherter Name waere fuer eine der beiden
|
||||
Seiten falsch.
|
||||
Bei einem Kanal steht in kategorie, WORUM es geht; der Name
|
||||
wird daraus gebildet. Zwei Namen fuer dieselbe Zustaendigkeit
|
||||
sind der sichere Weg zu zwei Kanaelen mit je der Haelfte der
|
||||
Nachrichten darin. */
|
||||
name TEXT,
|
||||
kategorie TEXT,
|
||||
erstellt TEXT NOT NULL,
|
||||
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
/* Der Zeitpunkt der letzten Nachricht, mitgefuehrt. Ohne ihn
|
||||
@@ -4047,5 +4162,28 @@ export const sichtbareCreatorIds = (person) => ohneVerborgene(sichtbareCreatorId
|
||||
export const sichtbarePersonenIds = (person) => ohneVerborgene(sichtbarePersonenIdsRoh(person), person);
|
||||
export const betreuerIds = (person) => ohneVerborgene(betreuerIdsRoh(person), person);
|
||||
export const einladbareIds = (person) => ohneVerborgene(einladbareIdsRoh(person), person);
|
||||
export const schreibbareIds = (person) => ohneVerborgene(schreibbareIdsRoh(person), person);
|
||||
/* SICH SELBST NICHT -- UND ZWAR AUCH DANN NICHT, WENN DIE LISTE
|
||||
AUSGESCHRIEBEN WIRD (10.09.2026).
|
||||
|
||||
schreibbareIdsRoh() endet mit `basis.delete(person.id)`. Das greift
|
||||
aber nur, solange eine Liste gebaut wird. Wer `siehtAlles` hat,
|
||||
bekam `null` = jeder, und die Route setzte dafuer `id <> ?` ein --
|
||||
auch richtig.
|
||||
|
||||
Dazwischen liegt der Fall, den beide nicht abdecken: Sobald es
|
||||
jemanden zu VERBERGEN gibt, schreibt ohneVerborgene() aus `null`
|
||||
eine echte Liste -- und die enthaelt alle ausser den Verborgenen,
|
||||
also auch die fragende Person selbst. Spicy Media stand dadurch in
|
||||
der eigenen Auswahl, und darfSchreibenMit() haette ein Gespraech mit
|
||||
sich selbst durchgelassen.
|
||||
|
||||
Aufgefallen ist das nicht beim Lesen des Codes, sondern an einer
|
||||
Zeile Messausgabe: "Spicy Media hat es NICHT (VanVan, Filipe,
|
||||
Cigdem)" -- der erste Name war ihr eigener. Der Fall entsteht erst,
|
||||
seit es ueberhaupt jemanden zu verbergen gibt; vorher konnte es ihn
|
||||
nicht geben. */
|
||||
export const schreibbareIds = (person) => {
|
||||
const ids = ohneVerborgene(schreibbareIdsRoh(person), person);
|
||||
return ids === null ? null : ids.filter((i) => i !== person?.id);
|
||||
};
|
||||
export const pipelineIds = (person) => ohneVerborgene(pipelineIdsRoh(person), person);
|
||||
|
||||
@@ -13,25 +13,25 @@
|
||||
"theme_color": "#06090f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/img/app-symbole/workspace-192.png?v=202609101807",
|
||||
"src": "/assets/img/app-symbole/workspace-192.png?v=202609102008",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/workspace-512.png?v=202609101807",
|
||||
"src": "/assets/img/app-symbole/workspace-512.png?v=202609102008",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609101807",
|
||||
"src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609102008",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609101807",
|
||||
"src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609102008",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
|
||||
@@ -340,8 +340,27 @@
|
||||
Die Kinder erben sie (color: inherit). Vier eigene Farbangaben wären
|
||||
vier Stellen, die beim nächsten Mal auseinanderlaufen -- der Fehler,
|
||||
der in diesem Haus am häufigsten vorkommt. */
|
||||
/* VIER HANDGRIFFE PASSEN NICHT IN EINE ZEILE (10.09.2026).
|
||||
|
||||
Hier stand `display: flex` ohne Umbruch. Mit drei Eintraegen (Zeit,
|
||||
antworten, kopieren) ging die Rechnung auf. Seit "anheften"
|
||||
danebensteht, geht sie nicht mehr: Gemessen an echten Bildpunkten
|
||||
stand "kopieren" bei 390 px -- der haeufigsten Handybreite -- 18 px
|
||||
ueber der Blase, bei 320 px waren es 75. Die Zeile lief einfach
|
||||
weiter, ueber ihren eigenen Rand hinaus.
|
||||
|
||||
DIE LEHRE IST NICHT "eine Schwelle einbauen". Das waere derselbe
|
||||
Fehler wie in der Kopfleiste am 06.09., nur mit einer anderen Zahl
|
||||
-- und beim fuenften Eintrag stuende sie wieder falsch da.
|
||||
`flex-wrap: wrap` ist eine REGEL statt einer Rechnung: Sie bricht
|
||||
genau dann um, wenn der Platz nicht reicht, und zwar unabhaengig
|
||||
davon, wie viele Handgriffe es einmal sein werden.
|
||||
|
||||
`row-gap` kleiner als `column-gap`: Zwei Zeilen Fusstext sollen wie
|
||||
ein Block wirken, nicht wie zwei Absaetze. */
|
||||
.chat-nachricht__fuss {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
display: flex; align-items: center; flex-wrap: wrap;
|
||||
column-gap: 9px; row-gap: 2px;
|
||||
margin-top: 4px;
|
||||
color: var(--text-leise);
|
||||
}
|
||||
@@ -1080,3 +1099,159 @@
|
||||
@media (forced-colors: active) {
|
||||
.chat-datei, .chat__ablage { border: 1px solid CanvasText; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
KATEGORIE-KANÄLE UND ANGEHEFTETE ANKÜNDIGUNGEN (10.09.2026)
|
||||
|
||||
Kapitel 7.2 des Anforderungsdokuments: "Kategorie-Kanäle" und
|
||||
"Ankündigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand an
|
||||
alle."
|
||||
|
||||
DER TON IST GEDÄMPFTES BERNSTEIN, nicht Signalgelb. Eine Ankündigung
|
||||
steht dauerhaft oben im Bild – sie ist das Element, das am längsten
|
||||
im Blick liegt, und genau deshalb darf es nicht das lauteste sein.
|
||||
Auffallen soll sie durch ihre POSITION, nicht durch ihre Farbe.
|
||||
===================================================================== */
|
||||
|
||||
.chat {
|
||||
/* Bernstein, entsättigt. Auf dem dunklen Grund der Seite kommt es
|
||||
als warmes Signal an, ohne zu blenden. */
|
||||
--pin: #d9ae62;
|
||||
}
|
||||
|
||||
/* ---- Ein Kanal in der Gesprächsliste -------------------------------- */
|
||||
/* Die Raute statt einer Zahl oder Initiale: Ein Kanal hat keine feste
|
||||
Größe (es kommen Leute dazu) und keinen Menschen dahinter. Eckiger
|
||||
als ein Kreis, runder als eine Gruppe -- so sind die drei Arten auch
|
||||
ohne Farbe auseinanderzuhalten. */
|
||||
.chat-raum__zeichen[data-kanal="ja"] {
|
||||
--r: var(--pin);
|
||||
border-radius: 10px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---- Die Pinnwand über dem Verlauf ---------------------------------- */
|
||||
.chat-pins {
|
||||
flex: none;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--rand);
|
||||
background: color-mix(in srgb, var(--pin) 6%, transparent);
|
||||
}
|
||||
/* Drei Aushänge sind das Höchste (PIN_MAX im Server). Selbst dann
|
||||
bleibt die Wand niedrig genug, dass der Verlauf die Seite behält --
|
||||
mehr als ein Drittel der Höhe darf sie nie nehmen. */
|
||||
.chat-pin {
|
||||
display: flex; align-items: stretch; gap: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--pin) 26%, transparent);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, .025);
|
||||
}
|
||||
.chat-pin__auf {
|
||||
flex: 1; min-width: 0;
|
||||
display: block; text-align: left;
|
||||
appearance: none; border: 0; background: none;
|
||||
padding: 7px 10px;
|
||||
font: inherit; color: inherit; cursor: pointer;
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
.chat-pin__auf:hover { background: rgba(255, 255, 255, .03); }
|
||||
.chat-pin__auf:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; }
|
||||
|
||||
.chat-pin__kopf {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: .72rem; line-height: 1.2;
|
||||
}
|
||||
.chat-pin__nadel { flex: none; width: 13px; height: 13px; color: var(--pin); }
|
||||
.chat-pin__von { font-weight: 700; color: var(--pin); }
|
||||
/* Wer angeheftet hat, steht daneben und nicht darunter: Es ist eine
|
||||
Randnotiz, keine zweite Zeile. Am Handy fällt sie weg -- dort zählt
|
||||
der Text der Ankündigung, nicht ihre Herkunft. */
|
||||
/* .72rem und nicht .68 (10.09.2026, gefunden von pruef-css-klassen).
|
||||
Unter 11,5 px wird Text auf einem Handy zur Zumutung -- und diese
|
||||
Zeile steht dauerhaft im Bild. Sie soll leiser sein als der Text
|
||||
daneben; das macht die FARBE, nicht die Groesse. */
|
||||
.chat-pin__wer { color: var(--text-still); font-size: .72rem; }
|
||||
.chat-pin__text {
|
||||
display: block; margin-top: 2px;
|
||||
font-size: .8rem; color: var(--text-leise);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.chat-pin__los {
|
||||
flex: none;
|
||||
appearance: none; border: 0; border-left: 1px solid color-mix(in srgb, var(--pin) 20%, transparent);
|
||||
background: none; padding: 0 10px;
|
||||
font: inherit; font-size: .72rem;
|
||||
color: var(--text-still); cursor: pointer;
|
||||
border-radius: 0 10px 10px 0;
|
||||
}
|
||||
.chat-pin__los:hover { color: var(--text); background: rgba(255, 255, 255, .04); }
|
||||
.chat-pin__los:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.chat-pin__wer { display: none; }
|
||||
}
|
||||
|
||||
/* ---- Die angeheftete Nachricht im Verlauf --------------------------- */
|
||||
/* Ein schmaler Streifen an der Blase, mehr nicht. Wer im Verlauf
|
||||
zurückrollt, soll erkennen, dass genau DIESE Zeile oben hängt --
|
||||
ohne dass sie sich wie eine andere Sorte Nachricht liest. */
|
||||
.chat-nachricht[data-angeheftet="ja"] .chat-nachricht__blase {
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--pin) 70%, transparent);
|
||||
}
|
||||
|
||||
/* Der Anheften-Knopf sieht aus wie "antworten" daneben -- dieselbe
|
||||
Sorte Handgriff, dieselbe Zeile, dieselbe Lautstärke. */
|
||||
.chat-nachricht__pin-knopf {
|
||||
appearance: none; border: 0; background: none; padding: 0;
|
||||
font: inherit; font-size: .72rem; font-weight: 600;
|
||||
color: inherit; cursor: pointer;
|
||||
text-decoration: underline; text-underline-offset: 2px;
|
||||
}
|
||||
.chat-nachricht__pin-knopf:hover { color: var(--pin); }
|
||||
.chat-nachricht__pin-knopf:focus-visible {
|
||||
outline: 2px solid var(--akzent); outline-offset: 2px; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---- Wer ist im Kanal ----------------------------------------------- */
|
||||
/* Derselbe Knopf wie das Wegräumen, nur wird er beim Darüberfahren
|
||||
nicht rot: Leute hinzuzunehmen ist keine gefährliche Handlung. */
|
||||
.chat__leute { margin-left: auto; }
|
||||
.chat__leute + .chat__weg { margin-left: 6px; }
|
||||
.chat__leute:hover {
|
||||
color: var(--pin);
|
||||
border-color: color-mix(in srgb, var(--pin) 45%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Die zwei Arten im Neu-Dialog ----------------------------------- */
|
||||
.neu__art {
|
||||
display: flex; gap: 6px;
|
||||
margin: 0 0 14px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--rand); border-radius: 12px;
|
||||
background: rgba(255, 255, 255, .02);
|
||||
}
|
||||
.neu__art button {
|
||||
flex: 1;
|
||||
appearance: none; border: 0; border-radius: 9px;
|
||||
padding: 8px 10px;
|
||||
background: none;
|
||||
font: inherit; font-size: .82rem; font-weight: 600;
|
||||
color: var(--text-still); cursor: pointer;
|
||||
transition: color var(--tempo), background var(--tempo);
|
||||
}
|
||||
.neu__art button[data-an="ja"] {
|
||||
background: color-mix(in srgb, var(--akzent) 14%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
.neu__art button:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
|
||||
|
||||
.neu__hinweis {
|
||||
margin: 6px 0 0;
|
||||
font-size: .78rem; color: var(--text-still);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-pin__auf, .chat-pin__los, .neu__art button { transition: none; }
|
||||
}
|
||||
|
||||
@@ -601,10 +601,16 @@
|
||||
display: grid; gap: 4px;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
/* .72rem und nicht .7 (10.09.2026, nachgezogen).
|
||||
In den sieben Feldern steht die Zahl der Termine -- klein, aber es
|
||||
ist die Auskunft, wegen der die Ampel da ist. Unter 11,5 px gilt im
|
||||
Haus als zu klein; .7rem waren 11,2. Gefunden hat es
|
||||
pruef-css-klassen, EINEN Commit zu spaet: Die Ampel war schon
|
||||
gepusht, weil ich diese Pruefung nicht laufen liess. */
|
||||
.tampel__tag {
|
||||
display: grid; place-items: center;
|
||||
padding: 7px 0 6px; border-radius: 8px;
|
||||
font-size: .7rem; font-weight: 700; letter-spacing: .02em;
|
||||
font-size: .72rem; font-weight: 700; letter-spacing: .02em;
|
||||
cursor: default;
|
||||
border: 1px solid var(--rand);
|
||||
}
|
||||
|
||||
+288
-16
@@ -48,6 +48,19 @@
|
||||
let strom = null; // die EventSource
|
||||
let gewaehlt = new Set(); // im Neu-Dialog
|
||||
|
||||
/* KANAELE UND ANGEHEFTETES (10.09.2026, Kapitel 7.2).
|
||||
|
||||
`darfKanal` und `darfAnheftenHier` kommen vom SERVER, nicht aus
|
||||
einem Rollenvergleich hier. Diese Datei bekommt jeder, der die
|
||||
Seite oeffnet -- ein `rolle === 'admin'` darin waere eine Auskunft
|
||||
ueber den Aufbau des Hauses an alle. Ein Ja/Nein ist keine. */
|
||||
let angeheftet = [];
|
||||
let darfAnheftenHier = false;
|
||||
let darfKanal = false;
|
||||
let kanalFrei = [];
|
||||
let neuArt = 'gespraech'; // 'gespraech' | 'kanal'
|
||||
let kanalUmbau = null; // Raumnummer, wenn die Besetzung geaendert wird
|
||||
|
||||
/* ---------- Kleinkram ---------------------------------------------------- */
|
||||
|
||||
async function hole(weg, einst) {
|
||||
@@ -104,7 +117,15 @@
|
||||
try {
|
||||
const a = await hole('/workspace/api/chat/raeume');
|
||||
if (!a.ok) return;
|
||||
raeume = (await a.json()).raeume || [];
|
||||
const daten = await a.json();
|
||||
raeume = daten.raeume || [];
|
||||
darfKanal = !!daten.darf_kanal;
|
||||
kanalFrei = daten.kanal_frei || [];
|
||||
/* Der Knopf erscheint nur, wenn der Server etwas zu bieten hat.
|
||||
Ein Knopf, der beim Druecken "darfst du nicht" sagt, ist eine
|
||||
Enttaeuschung mit Ansage. */
|
||||
$('neu-art').hidden = !darfKanal;
|
||||
if (!darfKanal) neuArt = 'gespraech';
|
||||
} catch { return; }
|
||||
raeumeZeichnen();
|
||||
ungelesenMelden();
|
||||
@@ -141,7 +162,12 @@
|
||||
|
||||
const zeichen = el('span', 'chat-raum__zeichen');
|
||||
zeichen.setAttribute('aria-hidden', 'true');
|
||||
if (r.art === 'gruppe') {
|
||||
if (r.art === 'kanal') {
|
||||
/* Das Rautenzeichen ist die Schreibweise, die jeder aus
|
||||
anderen Messengern kennt -- es muss nicht erklaert werden. */
|
||||
zeichen.dataset.kanal = 'ja';
|
||||
zeichen.textContent = '#';
|
||||
} else if (r.art === 'gruppe') {
|
||||
zeichen.dataset.gruppe = 'ja';
|
||||
zeichen.textContent = String(r.teilnehmer.length);
|
||||
} else {
|
||||
@@ -167,7 +193,12 @@
|
||||
|
||||
const vorschau = el('span', 'chat-raum__vorschau');
|
||||
if (r.letzte) {
|
||||
const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art === 'gruppe' ? `${r.letzte.von}: ` : '');
|
||||
/* Wer geschrieben hat, gehoert ueberall dorthin, wo mehr als
|
||||
zwei Leute schreiben koennen -- in einer Gruppe wie in einem
|
||||
Kanal. Hier stand `=== 'gruppe'`, und ein Kanal waere damit
|
||||
die einzige Stelle ohne Absender in der Vorschau gewesen. */
|
||||
const wer = r.letzte.von === ich.name ? 'Du: '
|
||||
: (r.art !== 'direkt' ? `${r.letzte.von}: ` : '');
|
||||
vorschau.textContent = r.letzte.zurueckgenommen
|
||||
? `${wer}Nachricht zurückgenommen`
|
||||
: wer + r.letzte.text;
|
||||
@@ -271,6 +302,8 @@
|
||||
const daten = await a.json();
|
||||
offen = daten.raum;
|
||||
nachrichten = daten.nachrichten;
|
||||
angeheftet = daten.angeheftet || [];
|
||||
darfAnheftenHier = !!daten.darf_anheften;
|
||||
/* DER STAND VON VORHIN, nicht der von jetzt. `gelesenMelden()`
|
||||
weiter unten setzt ihn gleich auf die letzte Nachricht -- die
|
||||
Linie muss also aus dem Wert gezogen werden, der beim OEFFNEN
|
||||
@@ -280,9 +313,15 @@
|
||||
} catch { return; }
|
||||
|
||||
$('verlauf-titel').textContent = offen.name;
|
||||
$('verlauf-unter').textContent = offen.art === 'gruppe'
|
||||
/* Bei allem, was mehr als zwei Leute hat, stehen die Namen darunter
|
||||
-- bei einem Zweier-Gespraech die Rolle des Gegenuebers. */
|
||||
$('verlauf-unter').textContent = offen.art !== 'direkt'
|
||||
? offen.teilnehmer.map((t) => t.name).join(' · ')
|
||||
: (ROLLENKURZ[offen.teilnehmer.find((t) => t.id !== ich.id)?.rolle] || '');
|
||||
/* Die Besetzung aendern kann nur, wer Kanaele fuehrt -- und nur in
|
||||
einem Kanal. In einer Gruppe waere es ein Eingriff in fremde
|
||||
Runden; siehe die Route dazu im Server. */
|
||||
$('kanal-leute').hidden = !(darfKanal && offen.art === 'kanal');
|
||||
$('schreiben').hidden = false;
|
||||
$('verlauf-leer').hidden = true;
|
||||
$('chat').dataset.ansicht = 'verlauf';
|
||||
@@ -292,6 +331,7 @@
|
||||
/* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will
|
||||
das Letzte sehen, nicht das Erste. */
|
||||
unten = true; neuUnten = 0;
|
||||
pinsZeichnen();
|
||||
verlaufZeichnen();
|
||||
/* Der Entwurf von vorhin, falls einer da ist. */
|
||||
$('text').value = entwurfHolen(id);
|
||||
@@ -306,6 +346,79 @@
|
||||
raeumeZeichnen();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
DIE ANGEHEFTETEN (10.09.2026, Kapitel 7.2)
|
||||
|
||||
Sie stehen UEBER dem Verlauf und rollen nicht mit. Eine Ankuendigung,
|
||||
die man erst suchen muss, ist keine.
|
||||
|
||||
Jeder Aushang fuehrt zu seiner Stelle im Verlauf. Ohne das waere er
|
||||
ein Bild von einer Nachricht: Man liest den Anfang und kann nicht
|
||||
nachsehen, was darauf geantwortet wurde.
|
||||
======================================================================= */
|
||||
function pinNadel() {
|
||||
const raum = 'http://www.w3.org/2000/svg';
|
||||
const s = document.createElementNS(raum, 'svg');
|
||||
s.setAttribute('viewBox', '0 0 24 24');
|
||||
s.setAttribute('aria-hidden', 'true');
|
||||
s.setAttribute('class', 'chat-pin__nadel');
|
||||
const pfad = document.createElementNS(raum, 'path');
|
||||
pfad.setAttribute('d', 'M9 3h6l-1 5 3 3v2h-5v6l-1 2-1-2v-6H5v-2l3-3z');
|
||||
pfad.setAttribute('fill', 'currentColor');
|
||||
s.append(pfad);
|
||||
return s;
|
||||
}
|
||||
|
||||
function pinsZeichnen() {
|
||||
const ziel = $('pins');
|
||||
ziel.textContent = '';
|
||||
ziel.hidden = !angeheftet.length;
|
||||
if (!angeheftet.length) return;
|
||||
|
||||
for (const a of angeheftet) {
|
||||
const kasten = el('div', 'chat-pin');
|
||||
|
||||
const auf = el('button', 'chat-pin__auf');
|
||||
auf.type = 'button';
|
||||
auf.title = 'Zur Nachricht springen';
|
||||
const kopf = el('span', 'chat-pin__kopf');
|
||||
kopf.append(pinNadel());
|
||||
kopf.append(el('span', 'chat-pin__von', a.von));
|
||||
if (a.heftete) kopf.append(el('span', 'chat-pin__wer', `angeheftet von ${a.heftete}`));
|
||||
auf.append(kopf);
|
||||
auf.append(el('span', 'chat-pin__text', a.text || 'Ohne Text'));
|
||||
auf.addEventListener('click', () => zurStelle(a.id));
|
||||
kasten.append(auf);
|
||||
|
||||
if (darfAnheftenHier) {
|
||||
const los = el('button', 'chat-pin__los', 'lösen');
|
||||
los.type = 'button';
|
||||
los.setAttribute('aria-label', `„${(a.text || '').slice(0, 40)}“ lösen`);
|
||||
los.addEventListener('click', () => anheften(a.id, false));
|
||||
kasten.append(los);
|
||||
}
|
||||
ziel.append(kasten);
|
||||
}
|
||||
}
|
||||
|
||||
async function anheften(id, an) {
|
||||
try {
|
||||
const a = await hole(`/workspace/api/chat/nachrichten/${id}/anheften`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ an }),
|
||||
});
|
||||
if (!a.ok) {
|
||||
melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
|
||||
return;
|
||||
}
|
||||
/* Neu holen statt hier nachzurechnen: Der Server kennt die Grenze
|
||||
von drei, das Abfallen zurueckgenommener Nachrichten und die
|
||||
Reihenfolge. Zwei Rechnungen darueber liefen irgendwann
|
||||
auseinander. */
|
||||
if (offen) await raumOeffnen(offen.id, { sanft: true });
|
||||
} catch { /* umgeleitet */ }
|
||||
}
|
||||
|
||||
function verlaufZeichnen() {
|
||||
const ziel = $('verlauf');
|
||||
/* NUR DIE NACHRICHTEN WEG — NICHT DER GANZE KASTEN.
|
||||
@@ -396,8 +509,10 @@
|
||||
zeile.append(z);
|
||||
}
|
||||
|
||||
if (n.angeheftet) zeile.dataset.angeheftet = 'ja';
|
||||
|
||||
const blase = el('div', 'chat-nachricht__blase');
|
||||
if (!gleicheFolge && !n.selbst && offen.art === 'gruppe') {
|
||||
if (!gleicheFolge && !n.selbst && offen.art !== 'direkt') {
|
||||
blase.append(el('p', 'chat-nachricht__von', n.von));
|
||||
}
|
||||
|
||||
@@ -456,6 +571,18 @@
|
||||
weg.addEventListener('click', () => zuruecknehmen(n.id));
|
||||
fuss.append(weg);
|
||||
}
|
||||
/* ANHEFTEN steht an derselben Stelle wie "antworten" -- dieselbe
|
||||
Sorte Handgriff, dieselbe Zeile. Nur fuer die, die es duerfen,
|
||||
und nie an einer zurueckgenommenen oder noch unterwegs
|
||||
befindlichen Nachricht (`n.id > 0`): Die haette noch keine
|
||||
Nummer, unter der man sie anheften koennte. */
|
||||
if (darfAnheftenHier && !n.zurueckgenommen && n.id > 0) {
|
||||
const pin = el('button', 'chat-nachricht__pin-knopf',
|
||||
n.angeheftet ? 'lösen' : 'anheften');
|
||||
pin.type = 'button';
|
||||
pin.addEventListener('click', () => anheften(n.id, !n.angeheftet));
|
||||
fuss.append(pin);
|
||||
}
|
||||
/* KOPIEREN. Eine Adresse, ein Code, eine Uhrzeit -- man will sie
|
||||
weitergeben, nicht abtippen. Der Knopf bestaetigt sich selbst
|
||||
und faellt danach zurueck; eine Meldung anderswo waere ein
|
||||
@@ -869,6 +996,15 @@
|
||||
raeumeLaden();
|
||||
});
|
||||
|
||||
/* ANGEHEFTET ODER GELOEST -- alle im Raum sehen es sofort.
|
||||
Betroffen ist nur, wer denselben Raum offen hat; sonst genuegt
|
||||
die Liste. */
|
||||
strom.addEventListener('pin', (e) => {
|
||||
let n;
|
||||
try { n = JSON.parse(e.data); } catch { return; }
|
||||
if (offen && n.raum_id === offen.id) raumOeffnen(offen.id, { sanft: true });
|
||||
});
|
||||
|
||||
/* Bei einem Abriss verbindet EventSource von selbst neu. Wir holen
|
||||
danach den Verlauf nach -- sonst fehlte, was während der Pause
|
||||
geschrieben wurde. */
|
||||
@@ -880,10 +1016,10 @@
|
||||
|
||||
/* ---------- Neues Gespräch ----------------------------------------------- */
|
||||
|
||||
async function partnerLaden() {
|
||||
async function partnerLaden(vorbelegt = []) {
|
||||
const ziel = $('partner');
|
||||
ziel.textContent = '';
|
||||
gewaehlt = new Set();
|
||||
gewaehlt = new Set(vorbelegt);
|
||||
$('gruppenname-zeile').hidden = true;
|
||||
$('neu-los').disabled = true;
|
||||
$('neu-fehler').textContent = '';
|
||||
@@ -906,17 +1042,13 @@
|
||||
fehlte 'spicy' -- und wer nicht in der Schleife vorkommt, taucht
|
||||
in der Gesprächsliste gar nicht auf. Kein Fehler, nur eine
|
||||
Person, die es fuer die Anderen nicht gibt. */
|
||||
for (const rolle of window.Bereiche.ROLLENFOLGE) {
|
||||
const leute = personen.filter((p) => p.rolle === rolle);
|
||||
if (!leute.length) continue;
|
||||
|
||||
function gruppeBauen(schluessel, ueberschrift, leute) {
|
||||
const gruppe = el('div', 'chat-wahl__gruppe');
|
||||
gruppe.dataset.rolle = rolle;
|
||||
gruppe.dataset.rolle = schluessel;
|
||||
const titel = el('p', 'chat-wahl__titel');
|
||||
const punkt = el('span', 'chat-wahl__punkt');
|
||||
punkt.setAttribute('aria-hidden', 'true');
|
||||
titel.append(punkt, document.createTextNode(
|
||||
window.Bereiche.ROLLEN_GRUPPE[rolle]));
|
||||
titel.append(punkt, document.createTextNode(ueberschrift));
|
||||
gruppe.append(titel);
|
||||
|
||||
const liste = el('div', 'chat-wahl__liste');
|
||||
@@ -926,7 +1058,8 @@
|
||||
b.type = 'button';
|
||||
b.dataset.id = String(p.id);
|
||||
b.dataset.rolle = p.rolle;
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
if (gewaehlt.has(p.id)) { b.dataset.an = 'ja'; }
|
||||
b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : 'false');
|
||||
|
||||
const z = el('span', 'chat-wahl__zeichen', initialen(p.name));
|
||||
z.setAttribute('aria-hidden', 'true');
|
||||
@@ -943,10 +1076,89 @@
|
||||
gruppe.append(liste);
|
||||
ziel.append(gruppe);
|
||||
}
|
||||
|
||||
const gezeichnet = new Set();
|
||||
for (const rolle of window.Bereiche.ROLLENFOLGE) {
|
||||
const leute = personen.filter((p) => p.rolle === rolle);
|
||||
gezeichnet.add(rolle);
|
||||
if (!leute.length) continue;
|
||||
gruppeBauen(rolle, window.Bereiche.ROLLEN_GRUPPE[rolle], leute);
|
||||
}
|
||||
|
||||
/* UND DANN ALLE UEBRIGEN (10.09.2026).
|
||||
|
||||
Genau die Lücke, vor der der Kommentar oben warnt -- sie war
|
||||
offen. `ROLLENFOLGE` hat fünf Einträge; wer eine sechste Rolle
|
||||
hat, wurde von der Schleife darüber nie erfasst und verschwand
|
||||
lautlos aus der Auswahl. Der Server schickte ihn mit, der
|
||||
Browser zeichnete ihn nicht, und niemand konnte sehen, dass
|
||||
etwas fehlt.
|
||||
|
||||
Aufgefallen ist es beim HINSEHEN, nicht beim Lesen: Ein frisch
|
||||
angelegter Kanal hatte hinterher zwei Leute statt vier.
|
||||
|
||||
Die Überschrift kommt vom Server -- so kann hier auch eine Rolle
|
||||
stehen, deren Namen diese Datei gar nicht kennen darf. Nach
|
||||
Überschrift gruppiert und nicht nach Rolle: Wer einen Menschen
|
||||
sucht, sucht ihn in einer Liste, nicht in einer Rangordnung. */
|
||||
const uebrig = personen.filter((p) => !gezeichnet.has(p.rolle));
|
||||
const nachTitel = new Map();
|
||||
for (const p of uebrig) {
|
||||
const t = p.gruppe || 'Weitere';
|
||||
if (!nachTitel.has(t)) nachTitel.set(t, []);
|
||||
nachTitel.get(t).push(p);
|
||||
}
|
||||
for (const [titel, leute] of nachTitel) gruppeBauen(leute[0].rolle, titel, leute);
|
||||
|
||||
wahlGeaendert();
|
||||
}
|
||||
|
||||
/* Welche Zustaendigkeiten noch keinen Kanal haben. Die Liste kommt vom
|
||||
Server; steht nichts darin, gibt es alle schon. */
|
||||
function kanalWahlFuellen() {
|
||||
const feld = $('kanal-kategorie');
|
||||
feld.textContent = '';
|
||||
for (const k of kanalFrei) {
|
||||
const o = document.createElement('option');
|
||||
o.value = k.wert; o.textContent = k.name;
|
||||
feld.append(o);
|
||||
}
|
||||
$('kanal-alle').hidden = kanalFrei.length > 0;
|
||||
feld.hidden = kanalFrei.length === 0;
|
||||
}
|
||||
|
||||
/* Umschalten zwischen "Gespräch" und "Kanal". Ein zweiter Dialog
|
||||
waere dieselbe Personenwahl ein zweites Mal -- und die zweite
|
||||
Fassung waere die, in der eine Regel fehlt. */
|
||||
function neuArtSetzen(art) {
|
||||
neuArt = art;
|
||||
for (const b of document.querySelectorAll('#neu-art [data-art]')) {
|
||||
const an = b.dataset.art === art;
|
||||
b.dataset.an = an ? 'ja' : '';
|
||||
b.setAttribute('aria-pressed', an ? 'true' : 'false');
|
||||
}
|
||||
$('kanal-zeile').hidden = art !== 'kanal' || !!kanalUmbau;
|
||||
$('neu-titel').textContent = kanalUmbau ? 'Wer ist im Kanal?'
|
||||
: art === 'kanal' ? 'Neuer Kanal' : 'Neues Gespräch';
|
||||
$('neu-unter').textContent = kanalUmbau
|
||||
? 'Antippen, um jemanden hinein- oder herauszunehmen. Die Leitung bleibt immer dabei.'
|
||||
: art === 'kanal'
|
||||
? 'Ein Kanal je Zuständigkeit. Der Name kommt aus der Kategorie – so gibt es ihn nur einmal.'
|
||||
: 'Eine Person antippen, um ihr zu schreiben. Mehrere auswählen ergibt eine Gruppe.';
|
||||
wahlGeaendert();
|
||||
}
|
||||
|
||||
function wahlGeaendert() {
|
||||
const n = gewaehlt.size;
|
||||
if (neuArt === 'kanal') {
|
||||
/* Ein Kanal braucht keinen Namen (er kommt aus der Kategorie) und
|
||||
auch niemanden -- man legt ihn an und fuellt ihn spaeter. Nur
|
||||
eine Kategorie muss es geben, sonst gibt es nichts anzulegen. */
|
||||
$('gruppenname-zeile').hidden = true;
|
||||
$('neu-los').disabled = !kanalUmbau && !$('kanal-kategorie').value;
|
||||
$('neu-los').textContent = kanalUmbau ? 'Übernehmen' : 'Kanal anlegen';
|
||||
return;
|
||||
}
|
||||
/* Ab zwei ist es eine Gruppe -- und die braucht einen Namen. */
|
||||
$('gruppenname-zeile').hidden = n < 2;
|
||||
$('neu-los').disabled = n === 0;
|
||||
@@ -956,8 +1168,48 @@
|
||||
async function neuAbschicken(e) {
|
||||
e.preventDefault();
|
||||
const ids = [...gewaehlt];
|
||||
if (!ids.length) return;
|
||||
const knopf = $('neu-los');
|
||||
|
||||
/* KANAL: anlegen oder umbesetzen. Steht zuerst, weil hier auch eine
|
||||
LEERE Auswahl erlaubt ist -- ein Kanal ohne Leute ist ein
|
||||
gueltiger Zwischenstand, ein Gespraech ohne Gegenueber nicht. */
|
||||
if (neuArt === 'kanal') {
|
||||
knopf.disabled = true;
|
||||
try {
|
||||
if (kanalUmbau) {
|
||||
const a = await hole(`/workspace/api/chat/kanal/${kanalUmbau}/mitglieder`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mit: ids }),
|
||||
});
|
||||
if (!a.ok) {
|
||||
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
|
||||
return;
|
||||
}
|
||||
const raumId = kanalUmbau;
|
||||
$('neu-dialog').close();
|
||||
await raeumeLaden();
|
||||
await raumOeffnen(raumId, { sanft: true });
|
||||
return;
|
||||
}
|
||||
const kategorie = $('kanal-kategorie').value;
|
||||
if (!kategorie) { $('neu-fehler').textContent = 'Wähle eine Kategorie.'; return; }
|
||||
const a = await hole('/workspace/api/chat/kanal', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kategorie, mit: ids }),
|
||||
});
|
||||
if (!a.ok) {
|
||||
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
|
||||
return;
|
||||
}
|
||||
const raumId = (await a.json()).raum_id;
|
||||
$('neu-dialog').close();
|
||||
await raeumeLaden();
|
||||
await raumOeffnen(raumId);
|
||||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ids.length) return;
|
||||
knopf.disabled = true;
|
||||
|
||||
try {
|
||||
@@ -1194,9 +1446,29 @@
|
||||
|
||||
$('suche').addEventListener('input', () => { raeumeZeichnen(); nachrichtenSuchen(); });
|
||||
$('neu-oeffnen').addEventListener('click', async () => {
|
||||
kanalUmbau = null;
|
||||
kanalWahlFuellen();
|
||||
neuArtSetzen('gespraech');
|
||||
await partnerLaden();
|
||||
$('neu-dialog').showModal();
|
||||
});
|
||||
|
||||
for (const b of document.querySelectorAll('#neu-art [data-art]')) {
|
||||
b.addEventListener('click', () => neuArtSetzen(b.dataset.art));
|
||||
}
|
||||
$('kanal-kategorie').addEventListener('change', wahlGeaendert);
|
||||
|
||||
/* WER IST IM KANAL. Derselbe Dialog, vorbelegt mit denen, die schon
|
||||
drin sind -- so sieht man beim Oeffnen den Stand und muss ihn nicht
|
||||
erst aus der Kopfzeile abschreiben. */
|
||||
$('kanal-leute').addEventListener('click', async () => {
|
||||
if (!offen || offen.art !== 'kanal') return;
|
||||
kanalUmbau = offen.id;
|
||||
kanalWahlFuellen();
|
||||
neuArtSetzen('kanal');
|
||||
await partnerLaden(offen.teilnehmer.map((t) => t.id));
|
||||
$('neu-dialog').showModal();
|
||||
});
|
||||
$('neu-zu').addEventListener('click', () => $('neu-dialog').close());
|
||||
$('neu-form').addEventListener('submit', neuAbschicken);
|
||||
$('zur-liste').addEventListener('click', () => {
|
||||
|
||||
+11
-11
@@ -12,12 +12,12 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -245,10 +245,10 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -140,10 +140,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+16
-16
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -157,14 +157,14 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-13
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -82,11 +82,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+49
-14
@@ -12,19 +12,19 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
|
||||
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
|
||||
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
|
||||
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
|
||||
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
|
||||
Dialog zu sehen bekam. -->
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/chat.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/chat.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -117,6 +117,18 @@
|
||||
Es verschwindet nur bei DIR -- das Gegenüber behält seinen
|
||||
Verlauf. Der Knopf steht erst da, wenn auch ein Gespräch
|
||||
offen ist; sonst wäre er ein Angebot ohne Gegenstand. -->
|
||||
<!-- WER IST IM KANAL (10.09.2026, Kapitel 7.2).
|
||||
Steht nur in einem Kanal da und nur bei der Teamleitung --
|
||||
der Server entscheidet das, nicht diese Datei. -->
|
||||
<button type="button" class="chat__weg chat__leute" id="kanal-leute" hidden
|
||||
aria-label="Wer ist in diesem Kanal?"
|
||||
title="Wer ist in diesem Kanal?">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="9" cy="8.5" r="3" />
|
||||
<path d="M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5" />
|
||||
<path d="M16 6.2a3 3 0 0 1 0 5.6M17.5 14.4c2 .7 3.3 2.4 3.3 4.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="chat__weg" id="gespraech-weg" hidden
|
||||
aria-label="Dieses Gespräch wegräumen"
|
||||
title="Gespräch wegräumen – nur bei dir">
|
||||
@@ -140,6 +152,12 @@
|
||||
<div class="chat__ablage" id="ablage" hidden aria-hidden="true">
|
||||
<span>Loslassen – Foto oder PDF schicken</span>
|
||||
</div>
|
||||
<!-- ANGEHEFTETE ANKÜNDIGUNGEN (10.09.2026, Kapitel 7.2).
|
||||
Sie stehen ÜBER dem Verlauf und rollen nicht mit: Eine
|
||||
Ankündigung, die man erst suchen muss, ist keine. Leer bleibt
|
||||
der Kasten verborgen – ein leerer Rahmen wäre jeden Tag im
|
||||
Weg für den Fall, dass irgendwann etwas darin steht. -->
|
||||
<div class="chat-pins" id="pins" hidden aria-label="Angeheftet"></div>
|
||||
<div class="chat__verlauf" id="verlauf" role="log" aria-live="polite" aria-relevant="additions">
|
||||
<p class="chat__leer" id="verlauf-leer">
|
||||
Links ein Gespräch auswählen – oder mit <strong>Neu</strong> eines anfangen.
|
||||
@@ -223,12 +241,29 @@
|
||||
eigentliche Absicht gestellt. -->
|
||||
<dialog class="dialog" id="neu-dialog">
|
||||
<form method="dialog" id="neu-form" class="neu neu--blank">
|
||||
<h2 class="dialog__titel">Neues Gespräch</h2>
|
||||
<p class="dialog__unter">
|
||||
<h2 class="dialog__titel" id="neu-titel">Neues Gespräch</h2>
|
||||
<p class="dialog__unter" id="neu-unter">
|
||||
Eine Person antippen, um ihr zu schreiben. Mehrere auswählen ergibt
|
||||
eine Gruppe.
|
||||
</p>
|
||||
|
||||
<!-- ZWEI ARTEN, EIN DIALOG (10.09.2026, Kapitel 7.2).
|
||||
Ein zweiter Dialog wäre dieselbe Personenwahl ein zweites Mal –
|
||||
und die zweite Fassung wäre die, in der eine Regel fehlt.
|
||||
Sichtbar nur, wenn der Server sagt, dass Kanäle erlaubt sind. -->
|
||||
<div class="neu__art" id="neu-art" role="group" aria-label="Was soll es werden?" hidden>
|
||||
<button type="button" data-art="gespraech" data-an="ja" aria-pressed="true">Gespräch</button>
|
||||
<button type="button" data-art="kanal" aria-pressed="false">Kanal</button>
|
||||
</div>
|
||||
|
||||
<div class="neu__zeile" id="kanal-zeile" hidden>
|
||||
<label class="feld-schild" for="kanal-kategorie">Zuständigkeit</label>
|
||||
<select id="kanal-kategorie"></select>
|
||||
<p class="neu__hinweis" id="kanal-alle" hidden>
|
||||
Für jede Zuständigkeit gibt es schon einen Kanal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="chat-wahl" id="partner"></div>
|
||||
|
||||
<!-- Erscheint erst ab zwei Ausgewählten -- vorher wäre ein
|
||||
@@ -247,10 +282,10 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202609101807"></script>
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807"></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807"></script>
|
||||
<script src="assets/js/chat.js?v=202609101807"></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008"></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008"></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008"></script>
|
||||
<script src="assets/js/chat.js?v=202609102008"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+14
-14
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -172,12 +172,12 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/content.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/content.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
<link rel="preload" as="image" href="assets/img/buehne/crew-gate-hoch-580.webp"
|
||||
type="image/webp" media="(max-width: 899px)" />
|
||||
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/crew-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/crew-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/crew-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/crew-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="gate gate--crew">
|
||||
@@ -246,6 +246,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/gate.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/gate.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,25 +13,25 @@
|
||||
"theme_color": "#06090f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/img/app-symbole/crew-192.png?v=202609101807",
|
||||
"src": "/assets/img/app-symbole/crew-192.png?v=202609102008",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/crew-512.png?v=202609101807",
|
||||
"src": "/assets/img/app-symbole/crew-512.png?v=202609102008",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609101807",
|
||||
"src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609102008",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609101807",
|
||||
"src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609102008",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
|
||||
+12
-12
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -101,10 +101,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
<link rel="preload" as="image" href="assets/img/buehne/gate-hoch-580.webp"
|
||||
type="image/webp" media="(max-width: 899px)" />
|
||||
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="gate">
|
||||
@@ -401,6 +401,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/gate.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/gate.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-13
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -370,11 +370,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kalender-abo.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kalender-abo.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/leistung.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/leistung.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -217,10 +217,10 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807"></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807"></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807"></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807"></script>
|
||||
<script src="assets/js/leistung.js?v=202609101807"></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008"></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008"></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008"></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008"></script>
|
||||
<script src="assets/js/leistung.js?v=202609102008"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -364,10 +364,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-13
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -207,11 +207,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-13
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -99,11 +99,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/report.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/report.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -132,10 +132,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-11
@@ -12,14 +12,14 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe
|
||||
den Hinweis in start.css -- dort stand es bis zum 07.09.2026. -->
|
||||
<link rel="stylesheet" href="assets/css/heim.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/heim.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -667,10 +667,10 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/start.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/start.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-13
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -78,11 +78,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -119,10 +119,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -7,13 +7,13 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/team.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/team.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -103,10 +103,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/team.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/team.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+17
-10
@@ -7,13 +7,13 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/team.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/team.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<!-- `data-buehne` steht hier fest und nicht im Skript: Die Kachel zu
|
||||
@@ -71,8 +71,15 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/teamlage.js?v=202609101807" defer></script>
|
||||
<!-- wahl.js VOR kopf.js (nachgetragen 10.09.2026).
|
||||
kopf.js baut den Sicht-Umschalter; ohne wahl.js sieht er aus wie
|
||||
ein blankes Browser-Auswahlfeld statt wie der Rest des Hauses.
|
||||
Kaputt ist dabei nichts -- er funktioniert. Genau deshalb faellt
|
||||
es niemandem auf, und deshalb prueft pruef-css-klassen die
|
||||
ABHAENGIGKEIT statt der Optik. Hier hat sie angeschlagen. -->
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/teamlage.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -12,15 +12,15 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<!-- Formular- und Dialogstile: seit dem 07.09.2026 steht hier der
|
||||
Dialog "Creator anlegen". -->
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -142,11 +142,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -12,13 +12,13 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101807" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609101807" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609101807" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609102008" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609102008" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609102008" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -183,10 +183,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609101807" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609102008" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609102008" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user