Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a4cfdeab | ||
|
|
4637aa24e7 |
@@ -0,0 +1,102 @@
|
||||
/* Macht ein Bild der Call-Liste mit vielen Terminen -- zum Ansehen.
|
||||
Nachgestellt wird Filipes Lage: eine wöchentliche Serie, die sich
|
||||
über Monate zieht. */
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { heuteLokal } from "./helfer-zeit.mjs";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-callbild-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4319, "das Bildwerkzeug");
|
||||
process.env.PORT = "4319";
|
||||
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:4319";
|
||||
|
||||
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 idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
|
||||
/* Wie bei Filipe: zwei wöchentliche Serien über ein Vierteljahr, dazu
|
||||
etwas heute und diese Woche. */
|
||||
const tag = (versatz) => {
|
||||
const t = new Date();
|
||||
t.setDate(t.getDate() + versatz);
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`;
|
||||
};
|
||||
function call(titel, versatz, zeit, beschreibung) {
|
||||
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id,
|
||||
teilnehmer_id, beschreibung, erstellt, erstellt_von)
|
||||
VALUES (?,'call',?,30,?,?,?,?,?)`)
|
||||
.run(titel, `${tag(versatz)}T${zeit}`, idLuna, idLuna, beschreibung, jetzt, idDogi);
|
||||
}
|
||||
call("Wochen-Call mit Luna", 0, "18:00", "Kurzer Stand");
|
||||
call("Content-Abnahme", 0, "20:00", "Freigabe der Clips");
|
||||
call("Community-Talk", 2, "18:00", "Community-Call");
|
||||
call("GrundSchulung", 3, "18:00", "Schulung über GrundRegeln");
|
||||
for (let w = 1; w <= 12; w++) {
|
||||
call("Community-Talk", 7 * w, "18:00", "Community-Call");
|
||||
call("GrundSchulung", 7 * w + 1, "18:00", "Schulung über GrundRegeln");
|
||||
}
|
||||
/* Und zwei vergangene ohne Protokoll, damit die erste Gruppe nicht
|
||||
leer ist. */
|
||||
call("Rückblick August", -3, "17:00", "Monatsgespräch");
|
||||
d.close();
|
||||
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await pw.chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 1100 }, deviceScaleFactor: 2 });
|
||||
const seite = await ctx.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
await seite.goto(BASIS + "/workspace/calls.html", { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(1600);
|
||||
|
||||
const bloecke = await seite.$$eval(".zeitblock", (b) => b.map((x) => ({
|
||||
raum: x.dataset.zeitraum,
|
||||
offen: x.open,
|
||||
zahl: x.querySelector(".zeitblock__zahl")?.textContent,
|
||||
})));
|
||||
console.log("Blöcke:", JSON.stringify(bloecke));
|
||||
|
||||
/* Nur den anstehenden Teil ins Bild -- darum ging es. */
|
||||
const teil = await seite.$('.gruppe[data-gruppe="anstehend"]');
|
||||
if (teil) {
|
||||
await teil.screenshot({ path: "calls-kategorien.png" });
|
||||
console.log("calls-kategorien.png geschrieben");
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,123 @@
|
||||
/* Macht Bilder des Chats -- zum Ansehen, nicht zum Pruefen.
|
||||
Zwei Browser, die sich wirklich schreiben. */
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-chatbild-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4315, "das Bildwerkzeug");
|
||||
process.env.PORT = "4315";
|
||||
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:4315";
|
||||
|
||||
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 idCig = anlegen("Cigdem", "manager", "CODE-MANA-0001");
|
||||
const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idCig, jetzt);
|
||||
d.close();
|
||||
|
||||
async function anmelden(rolle, code) {
|
||||
const a = await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle, code }) });
|
||||
return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
|
||||
}
|
||||
const kLuna = await anmelden("creator", "CODE-CREA-0001");
|
||||
const kPat = await anmelden("scout", "CODE-SCOU-0001");
|
||||
|
||||
const rufe = (keks, weg, koerper, methode = "POST") =>
|
||||
fetch(BASIS + weg, { method: methode,
|
||||
headers: { "Content-Type": "application/json", Cookie: keks },
|
||||
body: koerper === undefined ? undefined : JSON.stringify(koerper) });
|
||||
|
||||
/* Ein Gespräch mit echtem Inhalt -- damit das Bild zeigt, wie es sich
|
||||
wirklich liest, und nicht "Test 1 / Test 2". */
|
||||
const { raum_id } = await (await rufe(kLuna, "/workspace/api/chat/direkt", { mit: idPat })).json();
|
||||
const verlauf = [
|
||||
[kPat, "Hey Luna! Kurze Erinnerung: Der Start-Check ist morgen fällig – schaffst du das noch?"],
|
||||
[kLuna, "Ja klar 👍 Ich mach ihn heute Abend nach dem Stream fertig."],
|
||||
[kPat, "Top. Und sag Bescheid, falls du bei den Zielen für Q4 Hilfe brauchst."],
|
||||
[kLuna, "Eine Frage hätte ich tatsächlich: Zählen die Abo-Einnahmen mit rein oder nur Spenden?"],
|
||||
[kPat, "Beides. Alles was reinkommt, zählt zum Monatsziel."],
|
||||
[kLuna, "Perfekt, danke dir!"],
|
||||
];
|
||||
for (const [keks, text] of verlauf) {
|
||||
await rufe(keks, `/workspace/api/chat/raeume/${raum_id}/nachrichten`, { text });
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
/* Eine Gruppe, damit die Liste beides zeigt. */
|
||||
await rufe(kPat, "/workspace/api/chat/gruppe",
|
||||
{ name: "Luna · Betreuung", mit: [idLuna, idCig] });
|
||||
const gruppen = await (await fetch(BASIS + "/workspace/api/chat/raeume",
|
||||
{ headers: { Cookie: kPat } })).json();
|
||||
const gruppeId = gruppen.raeume.find((r) => r.art === "gruppe")?.id;
|
||||
if (gruppeId) {
|
||||
await rufe(kPat, `/workspace/api/chat/raeume/${gruppeId}/nachrichten`,
|
||||
{ text: "Cigdem, ich hab Luna beim Start-Check unterstützt – Stand ist gut." });
|
||||
}
|
||||
/* Und ein zweites Gespräch, damit die Liste nicht aus einer Zeile besteht. */
|
||||
await rufe(kLuna, "/workspace/api/chat/direkt", { mit: idDogi });
|
||||
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await pw.chromium.launch();
|
||||
|
||||
for (const [breite, hoehe, name] of [[1400, 900, "computer"], [390, 800, "handy"]]) {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: breite, height: hoehe }, deviceScaleFactor: 2,
|
||||
});
|
||||
const seite = await ctx.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
await seite.click('.rolle[data-rolle="creator"]');
|
||||
await seite.fill("#code", "CODE-CREA-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
await seite.goto(`${BASIS}/workspace/chat.html?raum=${raum_id}`, { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(1500);
|
||||
await seite.screenshot({ path: `chat-${name}.png` });
|
||||
console.log(`chat-${name}.png geschrieben`);
|
||||
|
||||
/* Am Handy zusätzlich die Gesprächsliste -- das ist die Ansicht,
|
||||
mit der man dort ankommt. */
|
||||
if (name === "handy") {
|
||||
await seite.click("#zur-liste");
|
||||
await seite.waitForTimeout(500);
|
||||
await seite.screenshot({ path: "chat-handy-liste.png" });
|
||||
console.log("chat-handy-liste.png geschrieben");
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(0);
|
||||
@@ -38,7 +38,6 @@ import { schulungRouter } from "./workspace-schulung.js";
|
||||
import { ampelRouter } from "./workspace-ampel.js";
|
||||
import { checklisteRouter } from "./workspace-checkliste.js";
|
||||
import { pushRouter, pushStarten } from "./workspace-push.js";
|
||||
import { chatRouter } from "./workspace-chat.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SITE_DIR = join(__dirname, "..");
|
||||
@@ -162,7 +161,6 @@ app.use(steckbriefRouter);
|
||||
oeffentlichen Schluessel, bevor irgendetwas anderes laeuft, und die
|
||||
Push-Wege bringen ihre eigene Schranke mit. */
|
||||
app.use(pushRouter);
|
||||
app.use(chatRouter);
|
||||
app.use(vorlagenRouter);
|
||||
app.use(schulungRouter);
|
||||
app.use(ampelRouter);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/* DIE ANSTEHENDEN CALLS, NACH ZEIT GEORDNET.
|
||||
|
||||
Wunsch Filipe, 06.09.2026, mit einem Bild von "STEHT AN 27":
|
||||
*"die liste da soll kategorisiert sein und werden mit einem button
|
||||
zum auf und zu machen."*
|
||||
|
||||
WAS HIER SCHIEFGEHEN KANN, und warum es geprüft wird:
|
||||
|
||||
Die Einteilung rechnet mit Kalenderwochen -- "Diese Woche" heisst
|
||||
bis Sonntag, nicht "in sieben Tagen". Wer am Freitag "diese Woche"
|
||||
liest, erwartet nicht den Mittwoch darauf. Solche Rechnungen sind
|
||||
genau die, die an einem Sonntag oder über einen Monatswechsel
|
||||
kippen, und niemand merkt es, weil man sie nur einmal ansieht.
|
||||
|
||||
Deshalb werden die Termine hier RELATIV zum Lauftag gesetzt (heute,
|
||||
morgen, +40 Tage) und die Zuordnung danach nachgerechnet. Ein Lauf
|
||||
am Dienstag muss dasselbe ergeben wie einer am Samstag.
|
||||
|
||||
GEGENPROBEN: Bei wenigen Terminen darf es die Einteilung gar nicht
|
||||
geben -- vier Karten in vier Schubladen wären mehr Arbeit als Nutzen. */
|
||||
|
||||
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-callkat-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4321, "die Kategorienpruefung");
|
||||
|
||||
process.env.PORT = "4321";
|
||||
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:4321";
|
||||
|
||||
const PROTOKOLL = join(process.cwd(), "pruef-call-kategorien-lauf.txt");
|
||||
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
|
||||
const melde = (z) => {
|
||||
console.log(z);
|
||||
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
|
||||
};
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
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 idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
|
||||
/* ORTSZEIT. Ein Datum aus toISOString() läge nachts einen Tag daneben
|
||||
-- und ausgerechnet die Kategorie "Heute" wäre dann leer. */
|
||||
const tag = (versatz) => {
|
||||
const t = new Date();
|
||||
t.setDate(t.getDate() + versatz);
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`;
|
||||
};
|
||||
function call(titel, versatz) {
|
||||
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id,
|
||||
teilnehmer_id, erstellt, erstellt_von) VALUES (?,'call',?,30,?,?,?,?)`)
|
||||
.run(titel, `${tag(versatz)}T18:00`, idLuna, idLuna, jetzt, idDogi);
|
||||
}
|
||||
|
||||
/* WIE VIELE TAGE bis Sonntag? Dieselbe Rechnung wie in calls.js --
|
||||
damit die Prüfung weiss, was sie erwarten darf, statt es zu raten. */
|
||||
const heute = new Date();
|
||||
const wochentag = (heute.getDay() + 6) % 7; // Mo=0 … So=6
|
||||
const bisSonntag = 6 - wochentag;
|
||||
|
||||
/* Zwei heute. Dann einer, der sicher noch in diese Woche fällt (falls
|
||||
es sie gibt), einer in die nächste, und drei weit weg. */
|
||||
call("Heute A", 0);
|
||||
call("Heute B", 0);
|
||||
if (bisSonntag >= 1) call("Diese Woche", 1);
|
||||
call("Nächste Woche", bisSonntag + 2);
|
||||
for (let i = 0; i < 3; i++) call(`Später ${i}`, bisSonntag + 20 + i);
|
||||
d.close();
|
||||
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await pw.chromium.launch();
|
||||
|
||||
async function callsSeite() {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
|
||||
const seite = await ctx.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
await seite.goto(BASIS + "/workspace/calls.html", { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(1400);
|
||||
return { ctx, seite };
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
1. Es gibt Kategorien, und sie stimmen
|
||||
======================================================================= */
|
||||
melde("\n=== Die Einteilung ===");
|
||||
const { ctx, seite } = await callsSeite();
|
||||
{
|
||||
const bloecke = await seite.$$eval(".zeitblock", (b) => b.map((x) => ({
|
||||
raum: x.dataset.zeitraum,
|
||||
offen: x.open,
|
||||
zahl: Number(x.querySelector(".zeitblock__zahl")?.textContent || 0),
|
||||
karten: x.querySelectorAll(".call").length,
|
||||
})));
|
||||
|
||||
/* DIE ANZAHL IN DIE BEDINGUNG: Ohne Blöcke liefe alles Folgende über
|
||||
eine leere Menge und meldete grün. */
|
||||
ok(bloecke.length >= 3, `${bloecke.length} Kategorien (erwartet mindestens 3)`);
|
||||
|
||||
const heuteBlock = bloecke.find((b) => b.raum === "heute");
|
||||
ok(heuteBlock?.zahl === 2, `"Heute" enthält ${heuteBlock?.zahl} Termine (erwartet 2)`);
|
||||
|
||||
const spaeter = bloecke.find((b) => b.raum === "spaeter");
|
||||
ok(spaeter?.zahl === 3, `"Später" enthält ${spaeter?.zahl} (erwartet 3)`);
|
||||
|
||||
const naechste = bloecke.find((b) => b.raum === "naechste");
|
||||
ok(naechste?.zahl === 1, `"Nächste Woche" enthält ${naechste?.zahl} (erwartet 1)`);
|
||||
|
||||
/* Am Sonntag gibt es kein "Diese Woche" mehr -- das ist richtig und
|
||||
kein Fehler. Deshalb wird die Erwartung aus dem Wochentag
|
||||
abgeleitet, nicht festgeschrieben. */
|
||||
const diese = bloecke.find((b) => b.raum === "woche");
|
||||
const erwarteDiese = bisSonntag >= 1;
|
||||
ok(!!diese === erwarteDiese,
|
||||
erwarteDiese
|
||||
? `"Diese Woche" ist da (${diese?.zahl}) — heute ist noch ${bisSonntag} Tag(e) bis Sonntag`
|
||||
: `"Diese Woche" fehlt zu Recht — heute ist Sonntag, danach kommt nichts mehr`);
|
||||
|
||||
/* Die Zahl im Kopf muss zu den Karten darin passen. Eine Zahl, die
|
||||
etwas anderes sagt als der Inhalt, ist schlimmer als keine. */
|
||||
const stimmig = bloecke.every((b) => b.zahl === b.karten);
|
||||
ok(stimmig, stimmig
|
||||
? `jede Zahl passt zu den Karten darin (${bloecke.map((b) => `${b.raum}:${b.zahl}`).join(", ")})`
|
||||
: `Zahl und Inhalt weichen ab: ${JSON.stringify(bloecke)}`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. Auf und zu
|
||||
======================================================================= */
|
||||
melde("\n=== Der Knopf zum Auf- und Zumachen ===");
|
||||
{
|
||||
const offen = await seite.$$eval(".zeitblock", (b) => b.map((x) => x.open));
|
||||
ok(offen[0] === true, "der erste Block ist offen");
|
||||
ok(offen.slice(1).every((o) => o === false),
|
||||
`alle weiteren sind zu (${offen.map((o) => (o ? "auf" : "zu")).join(", ")})`);
|
||||
|
||||
/* Zumachen und wieder auf. */
|
||||
await seite.click(".zeitblock:first-of-type .zeitblock__kopf");
|
||||
await seite.waitForTimeout(250);
|
||||
const zu = await seite.$eval(".zeitblock:first-of-type", (e) => e.open);
|
||||
ok(!zu, "ein Klick auf die Überschrift macht ihn zu");
|
||||
|
||||
await seite.click(".zeitblock:first-of-type .zeitblock__kopf");
|
||||
await seite.waitForTimeout(250);
|
||||
const wiederAuf = await seite.$eval(".zeitblock:first-of-type", (e) => e.open);
|
||||
ok(wiederAuf, "und wieder auf");
|
||||
|
||||
/* Ein zugeklappter Block versteckt seine Karten WIRKLICH -- nicht
|
||||
nur optisch. Sonst fände die Browsersuche sie weiterhin und
|
||||
springt an eine Stelle, an der nichts zu sehen ist. */
|
||||
const letzter = await seite.$$(".zeitblock");
|
||||
const sichtbarZu = await letzter[letzter.length - 1].$eval(".call",
|
||||
(e) => e.getBoundingClientRect().height > 0).catch(() => false);
|
||||
ok(!sichtbarZu, "die Karten eines zugeklappten Blocks sind wirklich verborgen");
|
||||
|
||||
/* Mit der Tastatur bedienbar -- <details>/<summary> bringt das mit,
|
||||
aber nur solange niemand es mit CSS oder JS aushebelt. */
|
||||
const perTaste = await seite.evaluate(() => {
|
||||
const s = document.querySelector(".zeitblock:last-of-type .zeitblock__kopf");
|
||||
s.focus();
|
||||
const vorher = s.parentElement.open;
|
||||
s.click();
|
||||
return vorher !== s.parentElement.open;
|
||||
});
|
||||
ok(perTaste, "der Kopf ist fokussierbar und schaltet um");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. Bei wenigen Terminen KEINE Einteilung
|
||||
======================================================================= */
|
||||
melde("\n=== Wenige Termine brauchen keine Schubladen ===");
|
||||
{
|
||||
/* Vier Karten in vier Kategorien wären mehr Arbeit als Nutzen -- man
|
||||
klickt dreimal, um vier Zeilen zu sehen. */
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
p.prepare("DELETE FROM termine WHERE titel LIKE 'Später%' OR titel = 'Nächste Woche'").run();
|
||||
const rest = p.prepare("SELECT COUNT(*) AS n FROM termine").get().n;
|
||||
p.close();
|
||||
ok(rest <= 4, `nur noch ${rest} Termine übrig`);
|
||||
|
||||
await seite.reload({ waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(1400);
|
||||
|
||||
const bloecke = (await seite.$$(".zeitblock")).length;
|
||||
const karten = (await seite.$$('.gruppe[data-gruppe="anstehend"] .call')).length;
|
||||
ok(bloecke === 0 && karten === rest,
|
||||
`keine Kategorien mehr (${bloecke}), aber alle ${karten} Karten stehen da`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE
|
||||
======================================================================= */
|
||||
melde("\n=== Gegenprobe ===");
|
||||
{
|
||||
/* Erkennt die Messung Kategorien überhaupt? Der Abschnitt oben hat
|
||||
gerade bewiesen, dass sie VERSCHWINDEN -- ohne diesen Nachweis
|
||||
bewiese "0 Blöcke" nur, dass der Selektor nie etwas trifft. */
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
p.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id,
|
||||
teilnehmer_id, erstellt, erstellt_von) VALUES (?,'call',?,30,?,?,?,?)`)
|
||||
.run(`Wieder da ${i}`, `${tag(bisSonntag + 30 + i)}T18:00`, idLuna, idLuna, jetzt, idDogi);
|
||||
}
|
||||
p.close();
|
||||
|
||||
await seite.reload({ waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(1400);
|
||||
const bloecke = (await seite.$$(".zeitblock")).length;
|
||||
ok(bloecke > 0,
|
||||
`mit genug Terminen kommen die Kategorien zurück (${bloecke}) — die Messung unterscheidet also`);
|
||||
}
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
/* Server und Zeitgeber laufen weiter -- siehe pruef-chat-optik. */
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,333 @@
|
||||
/* CHAT — SCHREIBEN ZWEI LEUTE WIRKLICH MITEINANDER?
|
||||
|
||||
pruef-chat.mjs beweist, dass der Server richtig entscheidet, WER mit
|
||||
wem darf. Diese Prüfung fragt das, was der Wunsch eigentlich meinte:
|
||||
*"jeder kann dann eine nachricht reinschreiben, die andere person
|
||||
bekommt eine benachrichtigung und kann dann antworten."*
|
||||
|
||||
Dafür laufen ZWEI Browser gleichzeitig. Luna schreibt, und ohne dass
|
||||
irgendjemand etwas neu lädt, muss die Nachricht bei Patrick
|
||||
erscheinen. Das ist der Kern -- alles andere ist Ausstattung.
|
||||
|
||||
WARUM ZWEI ECHTE BROWSER und nicht zwei Anfragen: Die sofortige
|
||||
Zustellung hängt an einer offenen Verbindung (SSE), an einem
|
||||
EventSource im Browser und daran, dass die Seite darauf richtig
|
||||
reagiert. Mit fetch allein prüfte man den Server, nicht den Chat.
|
||||
|
||||
GEMESSEN WIRD AUSSERDEM:
|
||||
* Die Zahl in der Kopfleiste -- auf einer ANDEREN Seite, denn dort
|
||||
muss sie ankommen, damit jemand von der Nachricht erfährt.
|
||||
* Dass die eigene Nachricht sofort dasteht, nicht erst nach der
|
||||
Antwort des Servers.
|
||||
* Dass "gelesen" die Zahl wieder wegnimmt.
|
||||
|
||||
GEGENPROBEN durchgehend. */
|
||||
|
||||
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-chatoptik-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4317, "die Chat-Optikpruefung");
|
||||
|
||||
process.env.PORT = "4317";
|
||||
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:4317";
|
||||
|
||||
const PROTOKOLL = join(process.cwd(), "pruef-chat-optik-lauf.txt");
|
||||
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
|
||||
const melde = (z) => {
|
||||
console.log(z);
|
||||
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
|
||||
};
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
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;
|
||||
}
|
||||
anlegen("Filipe", "admin", "CODE-DOGI-0001");
|
||||
const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
d.close();
|
||||
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await pw.chromium.launch();
|
||||
|
||||
async function alsRolle(rolle, code, wohin = "chat.html", breite = 1280) {
|
||||
const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } });
|
||||
const seite = await ctx.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
await seite.click(`.rolle[data-rolle="${rolle}"]`);
|
||||
await seite.fill("#code", code);
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(900);
|
||||
return { ctx, seite };
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
1. Ein Gespräch anfangen
|
||||
======================================================================= */
|
||||
melde("\n=== Ein Gespräch anfangen ===");
|
||||
const luna = await alsRolle("creator", "CODE-CREA-0001");
|
||||
{
|
||||
const leer = await luna.seite.isVisible("#liste-leer");
|
||||
ok(leer, "am Anfang steht da, dass es noch keine Gespräche gibt");
|
||||
|
||||
await luna.seite.click("#neu-oeffnen");
|
||||
await luna.seite.waitForSelector("#neu-dialog[open]", { timeout: 5000 });
|
||||
|
||||
const knoepfe = await luna.seite.$$eval(".chat-wahl__knopf",
|
||||
(b) => b.map((x) => x.textContent.trim()));
|
||||
/* Luna erreicht ihren Scout und DogFather -- die Regel aus
|
||||
pruef-chat, hier an der Oberfläche nachgewiesen. */
|
||||
ok(knoepfe.length === 2, `${knoepfe.length} Personen zur Auswahl: ${knoepfe.join(", ")}`);
|
||||
|
||||
const los = await luna.seite.$eval("#neu-los", (b) => b.disabled);
|
||||
ok(los, "der Knopf ist gesperrt, solange niemand gewählt ist");
|
||||
|
||||
await luna.seite.click('.chat-wahl__knopf[data-rolle="scout"]');
|
||||
await luna.seite.waitForTimeout(200);
|
||||
const frei = await luna.seite.$eval("#neu-los", (b) => !b.disabled);
|
||||
const beschriftung = await luna.seite.$eval("#neu-los", (b) => b.textContent.trim());
|
||||
ok(frei && beschriftung === "Schreiben",
|
||||
`nach der Wahl ist er frei und heißt "${beschriftung}"`);
|
||||
|
||||
/* Der Gruppenname erscheint erst ab zwei -- vorher wäre er eine
|
||||
Frage ohne Anlass. */
|
||||
const nameVersteckt = await luna.seite.$eval("#gruppenname-zeile", (e) => e.hidden);
|
||||
ok(nameVersteckt, "bei einer Person wird nicht nach einem Gruppennamen gefragt");
|
||||
|
||||
await luna.seite.click("#neu-los");
|
||||
await luna.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
|
||||
const titel = await luna.seite.$eval("#verlauf-titel", (e) => e.textContent);
|
||||
ok(titel === "Patrick", `das Gespräch ist offen: "${titel}"`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. DER KERN: schreiben, und es kommt an — ohne Neuladen
|
||||
======================================================================= */
|
||||
melde("\n=== Zwei Browser, eine Nachricht ===");
|
||||
const pat = await alsRolle("scout", "CODE-SCOU-0001");
|
||||
{
|
||||
/* Patrick öffnet dasselbe Gespräch. */
|
||||
await pat.seite.waitForSelector(".chat-raum__knopf", { timeout: 8000 });
|
||||
await pat.seite.click(".chat-raum__knopf");
|
||||
await pat.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
|
||||
await pat.seite.waitForTimeout(600);
|
||||
|
||||
const vorher = (await pat.seite.$$(".chat-nachricht")).length;
|
||||
|
||||
/* Luna schreibt. */
|
||||
await luna.seite.fill("#text", "Hey Patrick, kurze Frage zum Start-Check!");
|
||||
await luna.seite.click("#senden");
|
||||
|
||||
/* SOFORT bei Luna sichtbar -- vor jeder Serverantwort. Deshalb wird
|
||||
hier bewusst nur ganz kurz gewartet. */
|
||||
await luna.seite.waitForTimeout(120);
|
||||
const beiLuna = await luna.seite.$$eval(".chat-nachricht__text", (t) => t.map((x) => x.textContent));
|
||||
ok(beiLuna.some((t) => /Start-Check/.test(t)),
|
||||
"die eigene Nachricht steht sofort da, ohne auf den Server zu warten");
|
||||
|
||||
/* UND SIE KOMMT BEI PATRICK AN, ohne dass er etwas tut. */
|
||||
const angekommen = await pat.seite.waitForFunction(
|
||||
() => [...document.querySelectorAll(".chat-nachricht__text")]
|
||||
.some((t) => /Start-Check/.test(t.textContent)),
|
||||
{ timeout: 8000 }).then(() => true).catch(() => false);
|
||||
ok(angekommen, angekommen
|
||||
? "sie erscheint bei Patrick von selbst — kein Neuladen nötig"
|
||||
: "sie kam bei Patrick NICHT an (der Strom liefert nicht)");
|
||||
|
||||
const nachher = (await pat.seite.$$(".chat-nachricht")).length;
|
||||
ok(nachher === vorher + 1, `Patrick sieht ${nachher} statt ${vorher} Nachrichten`);
|
||||
|
||||
/* Und die Seiten sind sich einig, WER sie geschrieben hat. */
|
||||
const beiPatSelbst = await pat.seite.$eval(".chat-nachricht:last-of-type",
|
||||
(e) => e.dataset.selbst === "ja");
|
||||
const beiLunaSelbst = await luna.seite.$eval(".chat-nachricht:last-of-type",
|
||||
(e) => e.dataset.selbst === "ja");
|
||||
ok(!beiPatSelbst && beiLunaSelbst,
|
||||
"bei Luna steht sie rechts (eigene), bei Patrick links (fremde)");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. Und zurück
|
||||
======================================================================= */
|
||||
melde("\n=== Patrick antwortet ===");
|
||||
{
|
||||
await pat.seite.fill("#text", "Klar, schieß los!");
|
||||
await pat.seite.click("#senden");
|
||||
|
||||
const zurueck = await luna.seite.waitForFunction(
|
||||
() => [...document.querySelectorAll(".chat-nachricht__text")]
|
||||
.some((t) => /schieß los/.test(t.textContent)),
|
||||
{ timeout: 8000 }).then(() => true).catch(() => false);
|
||||
ok(zurueck, "die Antwort erscheint bei Luna von selbst");
|
||||
|
||||
/* Der Verlauf steht in der richtigen Reihenfolge -- eine
|
||||
Unterhaltung, die durcheinandergerät, ist unbrauchbar. */
|
||||
const texte = await luna.seite.$$eval(".chat-nachricht__text", (t) => t.map((x) => x.textContent));
|
||||
ok(/Start-Check/.test(texte[0]) && /schieß los/.test(texte[1]),
|
||||
`Reihenfolge stimmt: "${texte.map((t) => t.slice(0, 18)).join('" → "')}"`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. Die Zahl in der Kopfleiste — auf einer ANDEREN Seite
|
||||
======================================================================= */
|
||||
melde("\n=== Erfährt man davon, ohne im Chat zu sein? ===");
|
||||
{
|
||||
/* Patrick geht woandershin. Genau dort muss die Zahl erscheinen --
|
||||
sonst merkt er von einer Nachricht nichts. */
|
||||
await pat.seite.goto(`${BASIS}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
|
||||
await pat.seite.waitForTimeout(900);
|
||||
|
||||
const knopfDa = await pat.seite.$("#chat-knopf, .chat-knopf");
|
||||
ok(!!knopfDa, "auf der Aufgabenseite steht ein Chat-Knopf in der Kopfleiste");
|
||||
|
||||
const vorher = await pat.seite.$eval(".chat-knopf__zahl",
|
||||
(e) => (e.hidden ? "0" : e.textContent));
|
||||
ok(vorher === "0", `vorher zeigt er nichts an (${vorher})`);
|
||||
|
||||
await luna.seite.fill("#text", "Wann hast du Zeit für einen Call?");
|
||||
await luna.seite.click("#senden");
|
||||
|
||||
const erschienen = await pat.seite.waitForFunction(() => {
|
||||
const z = document.querySelector(".chat-knopf__zahl");
|
||||
return z && !z.hidden && z.textContent === "1";
|
||||
}, { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
ok(erschienen, erschienen
|
||||
? "die Zahl 1 erscheint von selbst in der Kopfleiste"
|
||||
: "die Zahl erscheint NICHT — man erfährt nichts von der Nachricht");
|
||||
|
||||
/* Und der Knopf selbst trägt Farbe -- eine kleine Zahl allein
|
||||
übersieht man beim Überfliegen. */
|
||||
const markiert = await pat.seite.$eval(".chat-knopf", (e) => e.dataset.neu === "ja");
|
||||
ok(markiert, "der Knopf ist zusätzlich hervorgehoben, nicht nur die Zahl");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. Lesen nimmt die Zahl wieder weg
|
||||
======================================================================= */
|
||||
melde("\n=== Gelesen ===");
|
||||
{
|
||||
await pat.seite.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" });
|
||||
await pat.seite.waitForSelector(".chat-raum__knopf", { timeout: 8000 });
|
||||
|
||||
const zahlInListe = await pat.seite.$eval(".chat-raum__zahl", (e) => e.textContent)
|
||||
.catch(() => null);
|
||||
ok(zahlInListe === "1", `in der Gesprächsliste steht die Zahl (${zahlInListe})`);
|
||||
|
||||
await pat.seite.click(".chat-raum__knopf");
|
||||
await pat.seite.waitForTimeout(1200);
|
||||
|
||||
const weg = await pat.seite.$(".chat-raum__zahl");
|
||||
ok(!weg, "nach dem Öffnen ist sie weg");
|
||||
|
||||
/* Und sie bleibt weg -- auch nach einem Neuladen. Wäre "gelesen" nur
|
||||
eine Anzeige im Browser, käme die Zahl beim nächsten Aufruf zurück. */
|
||||
await pat.seite.reload({ waitUntil: "networkidle" });
|
||||
await pat.seite.waitForTimeout(1000);
|
||||
const wiederDa = await pat.seite.$(".chat-raum__zahl");
|
||||
ok(!wiederDa, "auch nach dem Neuladen bleibt sie weg — es ist wirklich gespeichert");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
6. Zurücknehmen, im Bild
|
||||
======================================================================= */
|
||||
melde("\n=== Zurücknehmen ===");
|
||||
{
|
||||
await luna.seite.bringToFront();
|
||||
const vorher = (await luna.seite.$$(".chat-nachricht")).length;
|
||||
|
||||
/* Playwright bestätigt den confirm-Dialog nicht von selbst. */
|
||||
luna.seite.once("dialog", (dlg) => dlg.accept());
|
||||
await luna.seite.click(".chat-nachricht[data-selbst='ja']:last-of-type .chat-nachricht__weg-knopf");
|
||||
await luna.seite.waitForTimeout(1000);
|
||||
|
||||
const nachher = (await luna.seite.$$(".chat-nachricht")).length;
|
||||
ok(nachher === vorher,
|
||||
`die Zeile bleibt stehen (${nachher} statt ${vorher}) — kein Loch im Verlauf`);
|
||||
|
||||
const hinweis = await luna.seite.$(".chat-nachricht__weg");
|
||||
ok(!!hinweis, "an ihrer Stelle steht 'Nachricht zurückgenommen'");
|
||||
|
||||
/* Und beim Gegenüber verschwindet der Text ebenfalls -- von selbst. */
|
||||
const beiPat = await pat.seite.waitForFunction(
|
||||
() => !![...document.querySelectorAll(".chat-nachricht__weg")].length,
|
||||
{ timeout: 8000 }).then(() => true).catch(() => false);
|
||||
ok(beiPat, "auch bei Patrick ist der Text weg, ohne Neuladen");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — misst die Prüfung überhaupt etwas?
|
||||
======================================================================= */
|
||||
melde("\n=== Gegenprobe ===");
|
||||
{
|
||||
/* Träfe der Selektor nichts, wären alle Haken oben wertlos. */
|
||||
const vorher = (await luna.seite.$$(".chat-nachricht")).length;
|
||||
await luna.seite.evaluate(() => {
|
||||
for (const n of document.querySelectorAll(".chat-nachricht")) n.remove();
|
||||
});
|
||||
const nachher = (await luna.seite.$$(".chat-nachricht")).length;
|
||||
ok(vorher >= 3 && nachher === 0,
|
||||
`der Selektor trifft (${vorher} gefunden, nach dem Entfernen ${nachher})`);
|
||||
|
||||
/* Und ein Text, der nie geschrieben wurde, darf NICHT gefunden
|
||||
werden -- sonst prüfte "kam an" gar nichts. */
|
||||
await luna.seite.reload({ waitUntil: "networkidle" });
|
||||
await luna.seite.waitForTimeout(1200);
|
||||
const erfunden = await luna.seite.$$eval(".chat-nachricht__text",
|
||||
(t) => t.some((x) => /Diesen Satz gab es nie/.test(x.textContent)));
|
||||
ok(!erfunden, "ein nie geschriebener Satz wird nicht gefunden");
|
||||
}
|
||||
|
||||
await luna.ctx.close();
|
||||
await pat.ctx.close();
|
||||
await browser.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
/* HIER IST process.exit() RICHTIG -- anders als in pruef-push-weg.
|
||||
|
||||
Diese Pruefung startet einen Express-Server, und der laeuft von
|
||||
selbst weiter: offene Verbindungen, ein Zeitgeber fuer die
|
||||
Sicherung, bei dieser Pruefung zusaetzlich die offenen
|
||||
Ereignisstroeme des Chats. Mit `exitCode` allein wartet Node ewig
|
||||
darauf, dass all das zu Ende geht -- der Lauf lief in die
|
||||
Zeitueberschreitung, obwohl alle Pruefungen schon durch waren.
|
||||
|
||||
Der Absturz, der in pruef-push-weg gegen exit() sprach, kam von
|
||||
fetch-Verbindungen mitten im Schliessen. Die gibt es hier nicht
|
||||
mehr: Die Browser sind zu, bevor diese Zeile laeuft. */
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,403 @@
|
||||
/* CHAT — wer darf mit wem, und wer bekommt nichts zu sehen.
|
||||
|
||||
Wunsch Filipe, 06.09.2026. Auf Nachfrage entschieden: Zweier-
|
||||
Gespräche UND Gruppen; Manager und Scouts dürfen sich auch ohne
|
||||
gemeinsamen Creator schreiben.
|
||||
|
||||
WARUM DIESE PRÜFUNG ZUERST KAM, vor der Oberfläche: Ein Chat ist der
|
||||
Ort, an dem Leute Dinge schreiben, die nicht jeder lesen soll. Ein
|
||||
Fehler in der Sichtbarkeit ist hier teurer als überall sonst im
|
||||
Haus -- eine falsche Zahl auf einer Kachel ärgert, ein mitgelesenes
|
||||
Gespräch ist ein Vertrauensbruch.
|
||||
|
||||
GEMESSEN WIRD:
|
||||
1. Die Regel selbst: Wer erscheint in wessen Partnerliste?
|
||||
2. Kann man sie umgehen, indem man die Anfrage von Hand baut?
|
||||
3. Kommt man an einen fremden Verlauf, wenn man die Raumnummer rät?
|
||||
4. Ist eine Gruppe ein Schlupfloch zu jemandem, den man einzeln
|
||||
nicht anschreiben dürfte?
|
||||
5. Und die Alltagsfragen: Gibt es je Paar genau EIN Gespräch?
|
||||
Zählt "ungelesen" richtig? Bleibt ein Zurücknehmen sichtbar?
|
||||
|
||||
GEGENPROBEN durchgehend: Jede Sperre wird auch von der erlaubten
|
||||
Seite versucht. Eine Prüfung, die immer "verboten" sagt, beweist
|
||||
nichts. */
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-chat-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4313, "die Chatpruefung");
|
||||
|
||||
process.env.PORT = "4313";
|
||||
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:4313";
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Ein Bestand mit ECHTER Struktur ------------------------------------
|
||||
|
||||
Zwei getrennte Ketten, damit "fremd" wirklich fremd ist:
|
||||
Cigdem (Manager) → Patrick (Scout) → Luna (Creator)
|
||||
Meral (Manager) → Sven (Scout) → Nora (Creator)
|
||||
Dazu DogFather über allem.
|
||||
|
||||
Ohne die zweite Kette liesse sich nicht prüfen, ob ein Creator an
|
||||
einen fremden Creator kommt -- es gäbe schlicht keinen. */
|
||||
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 idCigdem = anlegen("Cigdem", "manager", "CODE-MANA-0001");
|
||||
const idMeral = anlegen("Meral", "manager", "CODE-MANA-0002");
|
||||
const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001");
|
||||
const idSven = anlegen("Sven", "scout", "CODE-SCOU-0002");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
const idNora = anlegen("Nora", "creator", "CODE-CREA-0002");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idNora, idSven, jetzt);
|
||||
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idCigdem, jetzt);
|
||||
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idSven, idMeral, jetzt);
|
||||
|
||||
async function anmelden(rolle, code) {
|
||||
const a = await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle, code }) });
|
||||
const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
|
||||
return { ok: a.ok, keks };
|
||||
}
|
||||
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 cigdem = await anmelden("manager", "CODE-MANA-0001");
|
||||
const meral = await anmelden("manager", "CODE-MANA-0002");
|
||||
const pat = await anmelden("scout", "CODE-SCOU-0001");
|
||||
const luna = await anmelden("creator", "CODE-CREA-0001");
|
||||
const nora = await anmelden("creator", "CODE-CREA-0002");
|
||||
ok([dogi, cigdem, meral, pat, luna, nora].every((x) => x.ok && x.keks),
|
||||
"alle sechs Rollen sind angemeldet");
|
||||
|
||||
/* =======================================================================
|
||||
1. Die Regel: wer erscheint in wessen Liste?
|
||||
======================================================================= */
|
||||
console.log("\n=== Mit wem kann man schreiben? ===");
|
||||
{
|
||||
const namen = async (keks) => {
|
||||
const { personen } = await holen(keks, "/workspace/api/chat/partner");
|
||||
return (personen || []).map((p) => p.name).sort();
|
||||
};
|
||||
|
||||
const beiLuna = await namen(luna.keks);
|
||||
/* Ein Creator: sein Scout, dessen Manager, DogFather. Kein fremder
|
||||
Creator, kein fremder Scout. */
|
||||
ok(JSON.stringify(beiLuna) === JSON.stringify(["Cigdem", "Filipe", "Patrick"]),
|
||||
`Creator Luna: ${beiLuna.join(", ")}`);
|
||||
ok(!beiLuna.includes("Nora"),
|
||||
"Luna sieht die andere Creatorin NICHT — Creator bleiben getrennt");
|
||||
ok(!beiLuna.includes("Sven") && !beiLuna.includes("Meral"),
|
||||
"und auch nicht den fremden Scout oder dessen Manager");
|
||||
|
||||
const beiPat = await namen(pat.keks);
|
||||
/* Ein Scout: sein Creator, DogFather -- UND das ganze Team
|
||||
(Entscheidung vom 06.09.2026: "für Absprachen im Team"). */
|
||||
ok(beiPat.includes("Luna") && beiPat.includes("Filipe"),
|
||||
`Scout Patrick erreicht seinen Creator und DogFather`);
|
||||
ok(beiPat.includes("Meral") && beiPat.includes("Sven"),
|
||||
`und das Team: ${beiPat.join(", ")}`);
|
||||
ok(!beiPat.includes("Nora"),
|
||||
"aber NICHT die fremde Creatorin — die Öffnung gilt nur fürs Team");
|
||||
|
||||
const beiDogi = await namen(dogi.keks);
|
||||
ok(beiDogi.length === 6, `DogFather erreicht alle ${beiDogi.length} anderen`);
|
||||
ok(!beiDogi.includes("Filipe"), "aber nicht sich selbst");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. Lässt sich die Regel von Hand umgehen?
|
||||
======================================================================= */
|
||||
console.log("\n=== Die Regel sitzt im Server, nicht in der Oberfläche ===");
|
||||
{
|
||||
/* Luna baut die Anfrage selbst und will Nora anschreiben. */
|
||||
const a = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idNora }, "POST");
|
||||
ok(a.status === 403, `Luna → Nora von Hand: abgelehnt (${a.status})`);
|
||||
|
||||
const b = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idSven }, "POST");
|
||||
ok(b.status === 403, `Luna → fremder Scout: abgelehnt (${b.status})`);
|
||||
|
||||
/* GEGENPROBE: Zu ihrem eigenen Scout geht es. Ohne diesen Nachweis
|
||||
bewiese "403" nur, dass die Route überhaupt ablehnt. */
|
||||
const c = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idPat }, "POST");
|
||||
ok(c.ok, `Luna → ihr eigener Scout: geht (${c.status})`);
|
||||
|
||||
/* Und sich selbst anschreiben ergibt keinen Sinn. */
|
||||
const e = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idLuna }, "POST");
|
||||
ok(e.status === 403, `sich selbst anschreiben: abgelehnt (${e.status})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. Ein fremder Verlauf, mit geratener Nummer
|
||||
======================================================================= */
|
||||
console.log("\n=== Kommt man an fremde Gespräche? ===");
|
||||
let raumLunaPat = 0;
|
||||
{
|
||||
const { raum_id } = await (await rufe(luna.keks, "/workspace/api/chat/direkt",
|
||||
{ mit: idPat }, "POST")).json();
|
||||
raumLunaPat = raum_id;
|
||||
ok(Number.isInteger(raumLunaPat) && raumLunaPat > 0, `Gespräch Luna↔Patrick ist #${raumLunaPat}`);
|
||||
|
||||
await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
|
||||
{ text: "Vertraulich: mein Umsatz war 3200." }, "POST");
|
||||
|
||||
/* Nora rät die Nummer. */
|
||||
const a = await rufe(nora.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
|
||||
ok(a.status === 404, `Fremde liest Verlauf: ${a.status} (404, nicht 403 — die Nummer soll nichts verraten)`);
|
||||
|
||||
/* Und sie versucht hineinzuschreiben. */
|
||||
const b = await rufe(nora.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
|
||||
{ text: "Hallo?" }, "POST");
|
||||
ok(b.status === 404, `Fremde schreibt hinein: ${b.status}`);
|
||||
|
||||
/* Auch als gelesen markieren geht nicht -- das wäre sonst ein Weg,
|
||||
die Existenz eines Raums zu erfahren. */
|
||||
const c = await rufe(nora.keks, `/workspace/api/chat/raeume/${raumLunaPat}/gelesen`,
|
||||
{ bis: 999 }, "PUT");
|
||||
ok(c.status === 404, `Fremde markiert als gelesen: ${c.status}`);
|
||||
|
||||
/* GEGENPROBE: Patrick, der drin ist, kommt heran. */
|
||||
const e = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
|
||||
ok(e.nachrichten?.length === 1 && /3200/.test(e.nachrichten[0].text),
|
||||
`Patrick liest den Verlauf (${e.nachrichten?.length} Nachricht)`);
|
||||
|
||||
/* Selbst DogFather kommt nicht ungefragt in ein fremdes Gespräch.
|
||||
Das ist Absicht: Ein Chat, in dem der Chef stillschweigend mitliest,
|
||||
ist kein Chat. Er darf jederzeit selbst schreiben -- aber sichtbar. */
|
||||
const f = await rufe(dogi.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
|
||||
ok(f.status === 404,
|
||||
`auch DogFather liest fremde Gespräche NICHT mit (${f.status}) — er ist kein Teilnehmer`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. Genau EIN Gespräch je Paar
|
||||
======================================================================= */
|
||||
console.log("\n=== Ein Paar, ein Gespräch ===");
|
||||
{
|
||||
/* Von der anderen Seite geöffnet muss derselbe Raum kommen -- sonst
|
||||
schreibt einer ins Leere, während der andere wartet. */
|
||||
const { raum_id: vonPat } = await (await rufe(pat.keks, "/workspace/api/chat/direkt",
|
||||
{ mit: idLuna }, "POST")).json();
|
||||
ok(vonPat === raumLunaPat,
|
||||
`Patrick öffnet dasselbe Gespräch (#${vonPat} = #${raumLunaPat}), keine zweite Kopie`);
|
||||
|
||||
/* Und mehrfaches Öffnen legt nichts Neues an. */
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idPat }, "POST");
|
||||
}
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const n = p.prepare("SELECT COUNT(*) AS n FROM chat_raeume WHERE art = 'direkt'").get().n;
|
||||
p.close();
|
||||
ok(n === 1, `nach fünfmaligem Öffnen: ${n} Gespräch in der Datenbank`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. Gruppen — und ob sie ein Schlupfloch sind
|
||||
======================================================================= */
|
||||
console.log("\n=== Gruppen ===");
|
||||
{
|
||||
/* DER PUNKT: Luna darf Nora nicht einzeln anschreiben. Kann sie es
|
||||
über eine Gruppe? */
|
||||
const a = await rufe(luna.keks, "/workspace/api/chat/gruppe",
|
||||
{ name: "Umweg", mit: [idPat, idNora] }, "POST");
|
||||
ok(a.status === 403,
|
||||
`eine Gruppe ist KEIN Schlupfloch: ${a.status} (Nora war dabei)`);
|
||||
|
||||
/* GEGENPROBE: Ohne Nora geht dieselbe Gruppe. */
|
||||
const b = await rufe(luna.keks, "/workspace/api/chat/gruppe",
|
||||
{ name: "Luna, Patrick & Cigdem", mit: [idPat, idCigdem] }, "POST");
|
||||
ok(b.status === 201, `mit erlaubten Leuten: angelegt (${b.status})`);
|
||||
const { raum_id: gruppe } = await b.json();
|
||||
|
||||
const sicht = await holen(luna.keks, `/workspace/api/chat/raeume/${gruppe}/nachrichten`);
|
||||
ok(sicht.raum?.teilnehmer?.length === 3,
|
||||
`die Gruppe hat ${sicht.raum?.teilnehmer?.length} Mitglieder`);
|
||||
ok(sicht.raum?.name === "Luna, Patrick & Cigdem",
|
||||
`und ihren Namen: "${sicht.raum?.name}"`);
|
||||
|
||||
/* Wer sie angelegt hat, führt sie. */
|
||||
const chefin = sicht.raum.teilnehmer.find((t) => t.leitung);
|
||||
ok(chefin?.id === idLuna, `die Leitung liegt bei ${chefin?.name}`);
|
||||
|
||||
/* Ohne Namen keine Gruppe. */
|
||||
const c = await rufe(luna.keks, "/workspace/api/chat/gruppe",
|
||||
{ name: "", mit: [idPat] }, "POST");
|
||||
ok(c.status === 400, `ohne Namen: abgelehnt (${c.status})`);
|
||||
|
||||
/* Und ohne Leute auch nicht. */
|
||||
const e = await rufe(luna.keks, "/workspace/api/chat/gruppe",
|
||||
{ name: "Allein", mit: [] }, "POST");
|
||||
ok(e.status === 400, `ohne Mitglieder: abgelehnt (${e.status})`);
|
||||
|
||||
/* Nora sieht die Gruppe nicht in ihrer Liste. */
|
||||
const { raeume } = await holen(nora.keks, "/workspace/api/chat/raeume");
|
||||
ok(!(raeume || []).some((r) => r.id === gruppe),
|
||||
`Nora sieht die fremde Gruppe nicht (${raeume?.length || 0} eigene Gespräche)`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
6. Ungelesen — die Zahl, auf die man sich verlässt
|
||||
======================================================================= */
|
||||
console.log("\n=== Ungelesene Nachrichten ===");
|
||||
{
|
||||
const vorher = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen;
|
||||
|
||||
await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
|
||||
{ text: "Erste" }, "POST");
|
||||
await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
|
||||
{ text: "Zweite" }, "POST");
|
||||
|
||||
const nachher = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen;
|
||||
ok(nachher === vorher + 2, `Patrick: ${vorher} → ${nachher} ungelesen`);
|
||||
|
||||
/* Die EIGENEN zählen nicht mit -- sonst stünde nach jeder eigenen
|
||||
Nachricht eine rote Zahl an der eigenen Kopfleiste. */
|
||||
const beiLuna = (await holen(luna.keks, "/workspace/api/chat/ungelesen")).ungelesen;
|
||||
ok(beiLuna === 0, `Luna hat ${beiLuna} ungelesene (ihre eigenen zählen nicht)`);
|
||||
|
||||
/* Lesen setzt sie zurück. */
|
||||
const verlauf = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
|
||||
const letzteId = verlauf.nachrichten.at(-1).id;
|
||||
await rufe(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/gelesen`,
|
||||
{ bis: letzteId }, "PUT");
|
||||
const danach = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen;
|
||||
ok(danach === 0, `nach dem Lesen: ${danach}`);
|
||||
|
||||
/* NUR VORWÄRTS: Ein verspäteter Aufruf mit einer alten Nummer darf
|
||||
den Stand nicht zurückdrehen -- sonst stünde Gelesenes wieder als
|
||||
ungelesen da. */
|
||||
await rufe(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/gelesen`, { bis: 1 }, "PUT");
|
||||
const zurueck = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen;
|
||||
ok(zurueck === 0, `ein alter "gelesen bis 1" dreht nichts zurück (${zurueck})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
7. Zurücknehmen
|
||||
======================================================================= */
|
||||
console.log("\n=== Eine Nachricht zurücknehmen ===");
|
||||
{
|
||||
const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
|
||||
{ text: "Das war ein Versehen" }, "POST");
|
||||
const { nachricht } = await a.json();
|
||||
|
||||
/* Patrick darf sie NICHT zurücknehmen -- fremde Worte aus einem
|
||||
Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff in
|
||||
die Geschichte. */
|
||||
const b = await rufe(pat.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE");
|
||||
ok(b.status === 403, `fremde Nachricht zurücknehmen: abgelehnt (${b.status})`);
|
||||
|
||||
/* Auch DogFather nicht. */
|
||||
const c = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE");
|
||||
ok(c.status === 404 || c.status === 403,
|
||||
`auch DogFather nicht (${c.status})`);
|
||||
|
||||
/* Die eigene schon. */
|
||||
const e = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE");
|
||||
ok(e.ok, `die eigene: geht (${e.status})`);
|
||||
|
||||
/* UND SIE BLEIBT SICHTBAR -- als zurückgenommen, nicht als Loch. */
|
||||
const verlauf = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
|
||||
const weg = verlauf.nachrichten.find((n) => n.id === nachricht.id);
|
||||
ok(weg && weg.zurueckgenommen && weg.text === null,
|
||||
`sie steht weiterhin im Verlauf, als zurückgenommen und ohne Text`);
|
||||
ok(!JSON.stringify(verlauf).includes("Das war ein Versehen"),
|
||||
"der Text ist wirklich weg, nicht nur ausgeblendet");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
8. Grenzen der Eingabe
|
||||
======================================================================= */
|
||||
console.log("\n=== Was nicht hineingeht ===");
|
||||
{
|
||||
for (const [was, koerper, status] of [
|
||||
["leerer Text", { text: "" }, 400],
|
||||
["nur Leerzeichen", { text: " " }, 400],
|
||||
["ohne Feld", {}, 400],
|
||||
["zu lang (4001)", { text: "z".repeat(4001) }, 400],
|
||||
]) {
|
||||
const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
|
||||
koerper, "POST");
|
||||
ok(a.status === status, `${was}: ${a.status}`);
|
||||
}
|
||||
/* GEGENPROBE: 4000 Zeichen gehen. */
|
||||
const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
|
||||
{ text: "z".repeat(4000) }, "POST");
|
||||
ok(a.status === 201, `genau 4000 Zeichen: ${a.status} — die Grenze ist nicht zu streng`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
9. Ohne Anmeldung gar nichts
|
||||
======================================================================= */
|
||||
console.log("\n=== Ohne Anmeldung ===");
|
||||
{
|
||||
for (const [was, weg, methode] of [
|
||||
["Partnerliste", "/workspace/api/chat/partner", "GET"],
|
||||
["Gesprächsliste", "/workspace/api/chat/raeume", "GET"],
|
||||
["Verlauf", `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, "GET"],
|
||||
["Ungelesen", "/workspace/api/chat/ungelesen", "GET"],
|
||||
["Strom", "/workspace/api/chat/strom", "GET"],
|
||||
]) {
|
||||
const a = await fetch(BASIS + weg, { method: methode });
|
||||
ok(a.status === 401, `${was}: ${a.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
d.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
/* HIER IST process.exit() RICHTIG -- anders als in pruef-push-weg.
|
||||
|
||||
Diese Pruefung startet einen Express-Server, und der laeuft von
|
||||
selbst weiter: offene Verbindungen, ein Zeitgeber fuer die
|
||||
Sicherung, bei dieser Pruefung zusaetzlich die offenen
|
||||
Ereignisstroeme des Chats. Mit `exitCode` allein wartet Node ewig
|
||||
darauf, dass all das zu Ende geht -- der Lauf lief in die
|
||||
Zeitueberschreitung, obwohl alle Pruefungen schon durch waren.
|
||||
|
||||
Der Absturz, der in pruef-push-weg gegen exit() sprach, kam von
|
||||
fetch-Verbindungen mitten im Schliessen. Die gibt es hier nicht
|
||||
mehr: Die Browser sind zu, bevor diese Zeile laeuft. */
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,535 @@
|
||||
/* =====================================================================
|
||||
workspace-chat.js — Gespräche zwischen den Leuten im Workspace.
|
||||
|
||||
Wunsch Filipe, 06.09.2026: *"eine kategorie chat, wo die creator mit
|
||||
ihren manager nachrichten austauschen können, wie erinnerungen,
|
||||
fragen und noch vieles mehr, wo jeder immer mit denen schreiben kann
|
||||
wie verbindung auch läuft, und jeder kann dann eine nachricht
|
||||
reinschreiben, die andere person bekommt eine benachrichtigung und
|
||||
kann dann antworten."*
|
||||
|
||||
Auf Nachfrage entschieden: Zweier-Gespräche UND Gruppen; Manager und
|
||||
Scouts dürfen sich auch ohne gemeinsamen Creator schreiben.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
DIE DREI REGELN, die alles andere tragen
|
||||
|
||||
1. WER MIT WEM steht an EINER Stelle: schreibbareIds() in
|
||||
workspace.js. Nicht hier, nicht in der Oberfläche. Jede Route
|
||||
fragt dieselbe Funktion -- eine zweite Regel wäre die sichere
|
||||
Zusage, dass beide irgendwann auseinanderlaufen.
|
||||
|
||||
2. WER IM RAUM IST, entscheidet über den Verlauf. Die Mitgliedschaft
|
||||
wird bei JEDEM Zugriff geprüft, nicht nur beim Öffnen. Sonst
|
||||
könnte man eine Raumnummer erraten und mitlesen.
|
||||
|
||||
3. EINE NACHRICHT WIRD NICHT GELÖSCHT, sondern geleert. Ein Loch im
|
||||
Verlauf wirft mehr Fragen auf als der Hinweis, dass jemand etwas
|
||||
zurückgenommen hat -- und in einem Gespräch, in dem Absprachen
|
||||
stehen, ist Nachvollziehbarkeit mehr wert als ein sauberer Schnitt.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WAS BEWUSST FEHLT
|
||||
|
||||
Kein Bearbeiten einer abgeschickten Nachricht. Wer eine Absprache
|
||||
nachträglich umschreiben kann, macht den Verlauf wertlos --
|
||||
zurücknehmen und neu schreiben ist ehrlicher und hinterlässt eine
|
||||
Spur. (Zurücknehmen selbst ist da, siehe weg_am.)
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, istDogFather,
|
||||
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG,
|
||||
} from "./workspace.js";
|
||||
|
||||
export const chatRouter = express.Router();
|
||||
|
||||
const TEXT_MAX = 4000;
|
||||
const NAME_MAX = 80;
|
||||
const jetzt = () => new Date().toISOString();
|
||||
|
||||
/* ---------- Schranke ---------------------------------------------------- */
|
||||
|
||||
function angemeldet(req, res, next) {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
req.person = person;
|
||||
next();
|
||||
}
|
||||
|
||||
function gleicheHerkunft(req, res, next) {
|
||||
const herkunft = req.get("origin");
|
||||
if (!herkunft) return next();
|
||||
let erlaubt;
|
||||
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
||||
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
||||
next();
|
||||
}
|
||||
|
||||
chatRouter.use("/workspace/api/chat", angemeldet);
|
||||
|
||||
/* =====================================================================
|
||||
Bausteine
|
||||
===================================================================== */
|
||||
|
||||
/** Ist die Person in diesem Raum -- und nicht ausgetreten?
|
||||
*
|
||||
* 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) {
|
||||
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;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Alle (noch aktiven) Teilnehmer eines Raums. */
|
||||
function teilnehmerVon(raumId) {
|
||||
return db().prepare(`
|
||||
SELECT t.person_id AS id, p.name, p.rolle, t.leitung, t.gelesen_bis
|
||||
FROM chat_teilnehmer t
|
||||
JOIN personen p ON p.id = t.person_id
|
||||
WHERE t.raum_id = ? AND t.raus_am IS NULL
|
||||
ORDER BY ` + ROLLEN_SORTIERUNG + ", p.name").all(raumId);
|
||||
}
|
||||
|
||||
/** Findet das Zweier-Gespräch der beiden -- oder legt es an.
|
||||
*
|
||||
* ES DARF NUR EINES GEBEN. Zwei Gespräche zwischen denselben zwei
|
||||
* Leuten wären ein Verlauf an zwei Orten: Man schreibt in das eine,
|
||||
* der andere liest im anderen und wartet. Deshalb wird zuerst
|
||||
* gesucht, und zwar über die Teilnehmer -- ein "Paarschlüssel" als
|
||||
* Spalte wäre eine zweite Wahrheit neben chat_teilnehmer. */
|
||||
function direktRaum(personA, idB, { anlegen = true } = {}) {
|
||||
const d = db();
|
||||
const vorhanden = d.prepare(`
|
||||
SELECT r.id FROM chat_raeume r
|
||||
WHERE r.art = 'direkt'
|
||||
AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?)
|
||||
AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?)
|
||||
AND (SELECT COUNT(*) FROM chat_teilnehmer t WHERE t.raum_id = r.id) = 2`)
|
||||
.get(personA.id, idB);
|
||||
if (vorhanden) return vorhanden.id;
|
||||
if (!anlegen) return null;
|
||||
|
||||
const n = jetzt();
|
||||
d.prepare("INSERT INTO chat_raeume (art, erstellt, erstellt_von, letzte_am) VALUES ('direkt',?,?,NULL)")
|
||||
.run(n, personA.id);
|
||||
const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
const rein = d.prepare(
|
||||
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)");
|
||||
rein.run(raumId, personA.id, n);
|
||||
rein.run(raumId, idB, n);
|
||||
return raumId;
|
||||
}
|
||||
|
||||
/** Wie heißt dieser Raum für DIESE Person?
|
||||
*
|
||||
* 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. */
|
||||
function raumName(raum, teilnehmer, ichId) {
|
||||
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";
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Die Schnittstellen
|
||||
===================================================================== */
|
||||
|
||||
/** Mit wem kann ich ein Gespräch anfangen? */
|
||||
chatRouter.get("/workspace/api/chat/partner", (req, res) => {
|
||||
try {
|
||||
const ids = schreibbareIds(req.person);
|
||||
const wo = ids === null
|
||||
? "id <> ?" : `id IN (${ids.map(() => "?").join(",")})`;
|
||||
const werte = ids === null ? [req.person.id] : ids;
|
||||
if (ids !== null && !ids.length) return res.json({ personen: [] });
|
||||
|
||||
res.json({
|
||||
personen: db().prepare(
|
||||
`SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND ${wo}
|
||||
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte),
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Partner:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Meine Gespräche, das jüngste zuerst. */
|
||||
chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||||
try {
|
||||
const reihen = db().prepare(`
|
||||
SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung
|
||||
FROM chat_raeume r
|
||||
JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ?
|
||||
WHERE t.raus_am IS NULL
|
||||
ORDER BY CASE WHEN r.letzte_am IS NULL THEN 1 ELSE 0 END, r.letzte_am DESC, r.id DESC`)
|
||||
.all(req.person.id);
|
||||
|
||||
const raeume = reihen.map((r) => {
|
||||
const leute = teilnehmerVon(r.id);
|
||||
/* Die letzte Nachricht als Vorschau -- ohne sie ist eine
|
||||
Gesprächsliste nur eine Namensliste. */
|
||||
const letzte = db().prepare(`
|
||||
SELECT n.text, n.erstellt, n.weg_am, p.name AS von
|
||||
FROM chat_nachrichten n
|
||||
LEFT JOIN personen p ON p.id = n.person_id
|
||||
WHERE n.raum_id = ? ORDER BY n.id DESC LIMIT 1`).get(r.id);
|
||||
const ungelesen = db().prepare(`
|
||||
SELECT COUNT(*) AS n FROM chat_nachrichten
|
||||
WHERE raum_id = ? AND id > ? AND person_id <> ?`)
|
||||
.get(r.id, r.gelesen_bis, req.person.id).n;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
art: r.art,
|
||||
name: raumName(r, leute, req.person.id),
|
||||
leitung: !!r.leitung,
|
||||
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })),
|
||||
letzte: letzte ? {
|
||||
text: letzte.weg_am ? null : letzte.text.slice(0, 140),
|
||||
zurueckgenommen: !!letzte.weg_am,
|
||||
von: letzte.von, erstellt: letzte.erstellt,
|
||||
} : null,
|
||||
ungelesen,
|
||||
};
|
||||
});
|
||||
res.json({ raeume });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Räume:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Ein Zweier-Gespräch öffnen (und beim ersten Mal anlegen). */
|
||||
chatRouter.post("/workspace/api/chat/direkt", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||||
try {
|
||||
const mit = Number(req.body?.mit);
|
||||
if (!Number.isInteger(mit) || mit < 1) return res.status(400).json({ fehler: "ungueltig" });
|
||||
/* DIE REGEL SITZT HIER, nicht in der Oberfläche. Wer die Anfrage
|
||||
von Hand baut, kommt damit keinen Schritt weiter. */
|
||||
if (!darfSchreibenMit(req.person, mit)) {
|
||||
return res.status(403).json({ fehler: "Mit dieser Person kannst du nicht schreiben." });
|
||||
}
|
||||
const ziel = db().prepare("SELECT id FROM personen WHERE id = ? AND aktiv = 1").get(mit);
|
||||
if (!ziel) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const raumId = direktRaum(req.person, mit);
|
||||
res.json({ raum_id: raumId });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Direkt:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Eine Gruppe anlegen. Wer sie anlegt, führt sie. */
|
||||
chatRouter.post("/workspace/api/chat/gruppe", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||||
try {
|
||||
const name = String(req.body?.name ?? "").trim();
|
||||
if (name.length < 2) return res.status(400).json({ fehler: "Die Gruppe braucht einen Namen." });
|
||||
if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Der Name ist zu lang." });
|
||||
|
||||
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 die Regel -- eine Gruppe ist kein
|
||||
Schlupfloch, um jemanden zu erreichen, den man einzeln nicht
|
||||
anschreiben dürfte. */
|
||||
const verboten = mit.filter((id) => !darfSchreibenMit(req.person, id));
|
||||
if (verboten.length) {
|
||||
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
|
||||
}
|
||||
if (!mit.length) return res.status(400).json({ fehler: "Wähle mindestens eine Person aus." });
|
||||
|
||||
const d = db();
|
||||
const n = jetzt();
|
||||
d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von) VALUES ('gruppe',?,?,?)")
|
||||
.run(name, n, req.person.id);
|
||||
const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) VALUES (?,?,?,0,1)")
|
||||
.run(raumId, req.person.id, n);
|
||||
const rein = d.prepare(
|
||||
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)");
|
||||
for (const id of mit) {
|
||||
if (d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) rein.run(raumId, id, n);
|
||||
}
|
||||
|
||||
protokolliere("chat_gruppe_angelegt", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${raumId} ${name} (${mit.length + 1})`.slice(0, 120),
|
||||
});
|
||||
res.status(201).json({ raum_id: raumId });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Gruppe:", 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 {
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
/* 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" });
|
||||
|
||||
/* Nur die letzten 200. Ein Gespräch, das ein Jahr läuft, soll den
|
||||
Browser nicht mit zehntausend Zeilen bewerfen; ältere holt man
|
||||
über `vor`. */
|
||||
const vor = Number(req.query.vor) || 0;
|
||||
const reihen = db().prepare(`
|
||||
SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle
|
||||
FROM chat_nachrichten n
|
||||
LEFT JOIN personen p ON p.id = n.person_id
|
||||
WHERE n.raum_id = ? ${vor ? "AND n.id < ?" : ""}
|
||||
ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, vor] : [raumId]));
|
||||
|
||||
const raum = db().prepare("SELECT id, art, name FROM chat_raeume WHERE id = ?").get(raumId);
|
||||
const leute = teilnehmerVon(raumId);
|
||||
|
||||
res.json({
|
||||
raum: {
|
||||
id: raum.id, art: raum.art,
|
||||
name: raumName(raum, leute, req.person.id),
|
||||
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle, leitung: !!t.leitung })),
|
||||
},
|
||||
/* 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,
|
||||
text: n.weg_am ? null : n.text,
|
||||
zurueckgenommen: !!n.weg_am,
|
||||
erstellt: n.erstellt,
|
||||
selbst: n.person_id === req.person.id,
|
||||
})),
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Verlauf:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Etwas schreiben. */
|
||||
chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
|
||||
express.json({ limit: "16kb" }), async (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" });
|
||||
|
||||
const text = String(req.body?.text ?? "").trim();
|
||||
if (!text) return res.status(400).json({ fehler: "leer" });
|
||||
if (text.length > TEXT_MAX) {
|
||||
return res.status(400).json({ fehler: `Länger als ${TEXT_MAX} Zeichen geht nicht.` });
|
||||
}
|
||||
|
||||
const d = db();
|
||||
const n = jetzt();
|
||||
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
|
||||
.run(raumId, req.person.id, text, n);
|
||||
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(n, raumId);
|
||||
/* Wer schreibt, hat das Eigene gelesen. */
|
||||
d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
|
||||
.run(id, raumId, req.person.id);
|
||||
|
||||
const nachricht = {
|
||||
id, raum_id: raumId, von_id: req.person.id, von: req.person.name,
|
||||
rolle: req.person.rolle, text, erstellt: n, zurueckgenommen: false,
|
||||
};
|
||||
|
||||
/* Sofort an alle, die gerade zusehen -- und per Benachrichtigung
|
||||
an die, die es nicht tun. Beides läuft über chatEreignis(),
|
||||
damit die Entscheidung "wer bekommt was" an einer Stelle steht. */
|
||||
const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id);
|
||||
chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id));
|
||||
|
||||
res.status(201).json({ nachricht });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Schreiben:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Bis hierher gelesen. */
|
||||
chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft,
|
||||
express.json({ limit: "4kb" }), (req, res) => {
|
||||
try {
|
||||
const raumId = Number(req.params.id);
|
||||
const bis = Number(req.body?.bis);
|
||||
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" });
|
||||
/* 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. */
|
||||
db().prepare(`UPDATE chat_teilnehmer SET gelesen_bis = ?
|
||||
WHERE raum_id = ? AND person_id = ? AND gelesen_bis < ?`)
|
||||
.run(bis, raumId, req.person.id, bis);
|
||||
res.json({ ok: true });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Gelesen:", 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 {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const n = db().prepare("SELECT id, raum_id, person_id FROM chat_nachrichten WHERE id = ?").get(id);
|
||||
if (!n || !istDrin(n.raum_id, req.person.id)) {
|
||||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
}
|
||||
/* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem
|
||||
Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff
|
||||
in die Geschichte -- und niemand könnte ihm nachher glauben. */
|
||||
if (n.person_id !== req.person.id) {
|
||||
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
|
||||
}
|
||||
|
||||
db().prepare("UPDATE chat_nachrichten SET text = '', weg_am = ? WHERE id = ?").run(jetzt(), id);
|
||||
const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id);
|
||||
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, zurueckgenommen: true }, [], "weg");
|
||||
|
||||
protokolliere("chat_zurueckgenommen", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${id}`,
|
||||
});
|
||||
res.json({ ok: true, empfaenger: empfaenger.length });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Zurücknehmen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
Sofort zusehen (Server-Sent Events)
|
||||
=====================================================================
|
||||
|
||||
WARUM SSE UND NICHT NACHFRAGEN IM TAKT: Ein Chat, der alle fünf
|
||||
Sekunden nachfragt, ist fünf Sekunden langsam und stellt bei zehn
|
||||
Angemeldeten 7200 Anfragen in der Stunde -- für Nachrichten, die es
|
||||
meistens nicht gibt.
|
||||
|
||||
WARUM SSE UND NICHT WEBSOCKET: Hier fliesst alles in EINE Richtung,
|
||||
vom Server zum Browser. Geschrieben wird über die normale
|
||||
Schnittstelle. SSE ist dafür gebaut, kommt ohne fremde Bibliothek
|
||||
aus und übersteht einen Verbindungsabriss von selbst -- der Browser
|
||||
verbindet sich neu, ohne dass man dafür Code schreibt.
|
||||
|
||||
Der Browser holt sich nach einem Abriss die Lücke über den normalen
|
||||
Verlauf nach; deshalb ist hier keine Nachrichtenwarteschlange nötig. */
|
||||
|
||||
/** Wer sieht gerade zu: Personennummer → Menge offener Verbindungen.
|
||||
* Eine Person kann an zwei Geräten sitzen. */
|
||||
const zuschauer = new Map();
|
||||
|
||||
function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu") {
|
||||
/* 1. An alle offenen Verbindungen der Beteiligten. */
|
||||
const leute = teilnehmerVon(raumId).map((t) => t.id);
|
||||
for (const personId of leute) {
|
||||
for (const antwort of zuschauer.get(personId) || []) {
|
||||
try {
|
||||
antwort.write(`event: ${art}\ndata: ${JSON.stringify(nachricht)}\n\n`);
|
||||
} catch { /* die Verbindung räumt sich selbst ab */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* 2. Eine Benachrichtigung an die, die NICHT zusehen. Wer die Seite
|
||||
offen hat, sieht die Nachricht ohnehin -- ihm auch noch eine
|
||||
Meldung aufs Handy zu schicken, ist der schnellste Weg, dass er
|
||||
Benachrichtigungen abschaltet. */
|
||||
for (const personId of benachrichtigen) {
|
||||
if ((zuschauer.get(personId) || []).length) continue;
|
||||
void meldeNeueNachricht(personId, raumId, nachricht);
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Push-Benachrichtigung. Absichtlich träge geladen: Der Chat soll
|
||||
* auch dann laufen, wenn am Push-Teil etwas klemmt. */
|
||||
async function meldeNeueNachricht(personId, raumId, nachricht) {
|
||||
try {
|
||||
const { benachrichtige } = await import("./workspace-push.js");
|
||||
await benachrichtige(personId, "chat_nachricht", {
|
||||
titel: `Nachricht von ${nachricht.von}`,
|
||||
/* Der Anfang der Nachricht steht in der Meldung -- eine
|
||||
Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum
|
||||
Öffnen, um zu erfahren, ob es wichtig war. */
|
||||
text: String(nachricht.text || "").slice(0, 120),
|
||||
ziel: `/workspace/chat.html?raum=${raumId}`,
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Benachrichtigung:", fehler?.message);
|
||||
}
|
||||
}
|
||||
|
||||
chatRouter.get("/workspace/api/chat/strom", (req, res) => {
|
||||
res.set({
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
/* Ohne das puffert ein davorliegender Reverse Proxy (Caddy, nginx)
|
||||
den Strom -- und dann kommt die Nachricht erst, wenn genug
|
||||
zusammen ist. Genau das Gegenteil des Zwecks. */
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
res.write("retry: 3000\n\n");
|
||||
|
||||
const personId = req.person.id;
|
||||
if (!zuschauer.has(personId)) zuschauer.set(personId, new Set());
|
||||
zuschauer.get(personId).add(res);
|
||||
|
||||
/* Ein Lebenszeichen alle 25 Sekunden. Ohne das schliessen manche
|
||||
Vermittlungsstellen die Verbindung nach einer Minute Stille -- der
|
||||
Browser verbindet sich dann zwar neu, aber jedes Mal mit einer
|
||||
Lücke von ein paar Sekunden. */
|
||||
const puls = setInterval(() => {
|
||||
try { res.write(": puls\n\n"); } catch { /* gleich weg */ }
|
||||
}, 25_000);
|
||||
puls.unref?.();
|
||||
|
||||
const aufraeumen = () => {
|
||||
clearInterval(puls);
|
||||
const menge = zuschauer.get(personId);
|
||||
if (menge) {
|
||||
menge.delete(res);
|
||||
if (!menge.size) zuschauer.delete(personId);
|
||||
}
|
||||
};
|
||||
req.on("close", aufraeumen);
|
||||
req.on("error", aufraeumen);
|
||||
});
|
||||
|
||||
/** Wie viele Nachrichten warten insgesamt auf mich?
|
||||
* Für die Zahl in der Kopfleiste -- eine Abfrage statt einer je Raum. */
|
||||
chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
|
||||
try {
|
||||
const z = db().prepare(`
|
||||
SELECT COUNT(*) AS n FROM chat_nachrichten m
|
||||
JOIN chat_teilnehmer t ON t.raum_id = m.raum_id AND t.person_id = ?
|
||||
WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.person_id <> ?`)
|
||||
.get(req.person.id, req.person.id);
|
||||
res.json({ ungelesen: z?.n || 0 });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Ungelesen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* Für Prüfungen und andere Module: Sieht diese Person gerade zu? */
|
||||
export function siehtZu(personId) {
|
||||
return (zuschauer.get(Number(personId)) || []).size > 0;
|
||||
}
|
||||
@@ -74,6 +74,10 @@ export const ARTEN = [
|
||||
{ schluessel: "termin_gleich", name: "Termin in einer Stunde", vorgabe: true },
|
||||
{ schluessel: "protokoll_fehlt", name: "Gespräch ohne Protokoll", vorgabe: true },
|
||||
{ schluessel: "followup", name: "Follow-up in der Pipeline fällig", vorgabe: true },
|
||||
/* Chat (06.09.2026). Steht bewusst VOR der Probe: Es ist die Art,
|
||||
die am haeufigsten kommt, und wer die Liste ueberfliegt, sucht
|
||||
zuerst sie. */
|
||||
{ schluessel: "chat_nachricht", name: "Neue Nachricht im Chat", vorgabe: true },
|
||||
{ schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true },
|
||||
];
|
||||
const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel));
|
||||
|
||||
@@ -666,6 +666,82 @@ export function db() {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_aufgaben_notizen ON aufgaben_notizen (aufgabe_id);
|
||||
|
||||
/* =================================================================
|
||||
CHAT (06.09.2026)
|
||||
|
||||
Wunsch Filipe: *"eine kategorie chat, wo die creator mit ihren
|
||||
manager nachrichten austauschen können, wie erinnerungen, fragen
|
||||
und noch vieles mehr, wo jeder immer mit denen schreiben kann
|
||||
wie verbindung auch läuft."* Auf Nachfrage: Zweier-Gespraeche
|
||||
UND Gruppen, und Manager/Scouts duerfen sich auch ohne
|
||||
gemeinsamen Creator schreiben.
|
||||
|
||||
DREI TABELLEN, und die Aufteilung hat einen Grund:
|
||||
|
||||
chat_raeume das Gespraech selbst
|
||||
chat_teilnehmer wer darin ist -- und bis wohin er gelesen hat
|
||||
chat_nachrichten was gesagt wurde
|
||||
|
||||
WARUM DIE TEILNEHMER EINE EIGENE TABELLE SIND und nicht zwei
|
||||
Spalten am Raum: Ein Zweier-Gespraech kaeme mit "person_a,
|
||||
person_b" aus, eine Gruppe nicht. Zwei verschiedene Bauweisen
|
||||
fuer dieselbe Sache waeren der sichere Weg dazu, dass eine
|
||||
Abfrage die eine Haelfte vergisst. So ist ein Zweier-Gespraech
|
||||
schlicht eine Gruppe mit zwei Leuten.
|
||||
|
||||
WARUM "gelesen_bis" AM TEILNEHMER haengt und nicht an der
|
||||
Nachricht: Sonst braeuchte es je Person und Nachricht eine
|
||||
Zeile -- bei drei Leuten und tausend Nachrichten dreitausend
|
||||
Eintraege, nur um "gelesen" zu wissen. Eine Nummer je Person
|
||||
und Raum genuegt: Alles darunter ist gelesen. */
|
||||
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. */
|
||||
name TEXT,
|
||||
erstellt TEXT NOT NULL,
|
||||
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
/* Der Zeitpunkt der letzten Nachricht, mitgefuehrt. Ohne ihn
|
||||
braeuchte die Gespraechsliste je Raum eine Unterabfrage --
|
||||
bei zwanzig Raeumen zwanzig Abfragen fuer eine Liste. */
|
||||
letzte_am TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_raeume_letzte ON chat_raeume (letzte_am DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_teilnehmer (
|
||||
raum_id INTEGER NOT NULL REFERENCES chat_raeume(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
||||
seit TEXT NOT NULL,
|
||||
/* Bis zu welcher Nachricht diese Person gelesen hat. */
|
||||
gelesen_bis INTEGER NOT NULL DEFAULT 0,
|
||||
/* Wer eine Gruppe angelegt hat, darf Leute hinzufuegen und
|
||||
entfernen. In Zweier-Gespraechen bedeutungslos. */
|
||||
leitung INTEGER NOT NULL DEFAULT 0,
|
||||
/* Verlassene Gruppen: Die Zeile bleibt mit einem Datum stehen,
|
||||
damit der Verlauf bis dahin lesbar bleibt und niemand
|
||||
rueckwirkend aus der Geschichte verschwindet. */
|
||||
raus_am TEXT,
|
||||
PRIMARY KEY (raum_id, person_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_teilnehmer_person ON chat_teilnehmer (person_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_nachrichten (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raum_id INTEGER NOT NULL REFERENCES chat_raeume(id) ON DELETE CASCADE,
|
||||
person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
text TEXT NOT NULL,
|
||||
erstellt TEXT NOT NULL,
|
||||
/* Zurueckgenommen: Der Text wird geleert, die Zeile bleibt.
|
||||
Ein Loch im Verlauf wirft mehr Fragen auf als der Hinweis,
|
||||
dass hier etwas zurueckgenommen wurde. */
|
||||
weg_am TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_nachrichten_raum
|
||||
ON chat_nachrichten (raum_id, id);
|
||||
|
||||
/* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je
|
||||
Creator, entsteht erst beim ersten Speichern.
|
||||
admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an
|
||||
@@ -1146,6 +1222,11 @@ const GESCHUETZT = {
|
||||
"/workspace/uebersicht.html": null,
|
||||
"/workspace/content.html": null,
|
||||
"/workspace/aufgaben.html": null,
|
||||
/* Chat (06.09.2026): jede Rolle. WEN man erreicht, entscheidet
|
||||
schreibbareIds() -- nicht der Zugang zur Seite. Ein Creator, der
|
||||
die Seite nicht öffnen dürfte, könnte auch nicht antworten, und
|
||||
genau das Antworten ist der Zweck. */
|
||||
"/workspace/chat.html": null,
|
||||
/* NUR DogFather (02.09.2026). Die Rollenauswahl verspricht das seit
|
||||
jeher ("Manager -- dieselben Rechte, ausser der
|
||||
Personenverwaltung"), die Schranke hielt sich nur nicht daran.
|
||||
@@ -1648,6 +1729,59 @@ export function einladbareIds(person) {
|
||||
return [...new Set([...sichtbar, ...betreuerIds(person), ...chefs])];
|
||||
}
|
||||
|
||||
/** MIT WEM DARF ICH SCHREIBEN? (06.09.2026, für den Chat)
|
||||
*
|
||||
* Entschieden von Filipe: entlang der Betreuung -- PLUS Manager und
|
||||
* Scouts untereinander, auch ohne gemeinsamen Creator, "für Absprachen
|
||||
* im Team".
|
||||
*
|
||||
* WARUM NICHT einladbareIds() WIEDERVERWENDEN, obwohl es fast passt:
|
||||
* Dort geht es darum, wen man zu einem TERMIN dazustellen darf. Das
|
||||
* ist harmlos -- die Person sieht einen Eintrag im Kalender. Ein Chat
|
||||
* ist etwas anderes: Er legt ein dauerhaftes Gespräch an, schickt eine
|
||||
* Benachrichtigung und macht den eigenen Namen sichtbar. Zwei Fragen,
|
||||
* zwei Funktionen -- sonst verschiebt eine spätere Änderung an der
|
||||
* einen stillschweigend die andere.
|
||||
*
|
||||
* WAS SICH DADURCH ÄNDERT gegenüber der Terminwahl: Ein Manager
|
||||
* erreicht jetzt auch Scouts, die nicht seine sind, und umgekehrt.
|
||||
* Ein Creator NICHT -- er bleibt bei seinen Betreuern und DogFather.
|
||||
* Creator untereinander bleiben getrennt; sie sollen die Namen der
|
||||
* anderen Creator weiterhin nicht sehen.
|
||||
*
|
||||
* null = alle (nur DogFather). */
|
||||
export function schreibbareIds(person) {
|
||||
if (!person) return [];
|
||||
if (istDogFather(person)) return null;
|
||||
|
||||
const basis = new Set(einladbareIds(person) || []);
|
||||
|
||||
/* Das Team untereinander. Ein Scout, der eine Frage zu einem
|
||||
fremden Creator hat, muss den zuständigen Manager erreichen können
|
||||
-- sonst läuft alles über DogFather, und das ist genau der
|
||||
Flaschenhals, den ein Chat auflösen soll. */
|
||||
if (person.rolle === "manager" || person.rolle === "scout") {
|
||||
try {
|
||||
for (const z of db().prepare(
|
||||
"SELECT id FROM personen WHERE rolle IN ('manager','scout') AND aktiv = 1").all()) {
|
||||
basis.add(z.id);
|
||||
}
|
||||
} catch { /* im Zweifel nur die Betreuungskette */ }
|
||||
}
|
||||
|
||||
/* Sich selbst nicht -- ein Gespräch mit sich allein ist keines. */
|
||||
basis.delete(person.id);
|
||||
return [...basis];
|
||||
}
|
||||
|
||||
/** Darf diese Person mit jener schreiben? Eine Frage, eine Antwort --
|
||||
* damit die Regel nicht an fünf Stellen einzeln nachgebaut wird. */
|
||||
export function darfSchreibenMit(person, andereId) {
|
||||
const ids = schreibbareIds(person);
|
||||
if (ids === null) return Number(andereId) !== Number(person.id);
|
||||
return ids.includes(Number(andereId));
|
||||
}
|
||||
|
||||
/** SQL-Baustein daraus: "diese Spalte ist eine Person, die ich sehen
|
||||
* darf". Gibt null zurück, wenn nicht eingeschränkt werden muss. */
|
||||
export function personenWo(person, spalte) {
|
||||
|
||||
@@ -126,3 +126,101 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.call, .todo__weg { transition: none; }
|
||||
}
|
||||
|
||||
/* ---------- Aufklappbare Zeitraum-Blöcke --------------------------------
|
||||
|
||||
Wunsch Filipe, 06.09.2026, mit einem Bild von "STEHT AN 27":
|
||||
*"die liste da soll kategorisiert sein und werden mit einem button
|
||||
zum auf und zu machen."*
|
||||
|
||||
Siebenundzwanzig Karten am Stück beantworten keine Frage -- man
|
||||
scrollt an allem vorbei, was diese Woche ansteht, um zu sehen, dass
|
||||
der Rest im November liegt. Jetzt: Heute · Diese Woche · Nächste
|
||||
Woche · Später, und offen ist nur der erste gefüllte Block.
|
||||
|
||||
Gestaltet wie der Bereich "Abgebrochen" auf dem Aufgabenbrett --
|
||||
dasselbe Bauteil soll auch gleich aussehen. */
|
||||
|
||||
.zeitblock {
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 13px;
|
||||
background: rgba(255, 255, 255, .018);
|
||||
}
|
||||
.zeitblock + .zeitblock { margin-top: 8px; }
|
||||
|
||||
.zeitblock__kopf {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 15px;
|
||||
cursor: pointer;
|
||||
font-size: .8rem; font-weight: 700;
|
||||
letter-spacing: .05em; text-transform: uppercase;
|
||||
color: var(--text-still);
|
||||
/* Das eigene Dreieck von <summary> weg -- es wird unten selbst
|
||||
gezeichnet, damit es links steht und sich beim Öffnen dreht. */
|
||||
list-style: none;
|
||||
}
|
||||
.zeitblock__kopf::-webkit-details-marker { display: none; }
|
||||
.zeitblock__kopf:hover { color: var(--text-leise); }
|
||||
.zeitblock__kopf:focus-visible {
|
||||
outline: 2px solid var(--akzent); outline-offset: -2px; border-radius: 13px;
|
||||
}
|
||||
|
||||
.zeitblock__kopf::before {
|
||||
content: ""; flex: none;
|
||||
width: 0; height: 0;
|
||||
border-left: 5px solid currentColor;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
.zeitblock[open] .zeitblock__kopf::before { transform: rotate(90deg); }
|
||||
|
||||
.zeitblock__name { flex: none; }
|
||||
.zeitblock__zahl {
|
||||
padding: 2px 9px; border-radius: 999px;
|
||||
font-size: .75rem; font-weight: 700; letter-spacing: 0;
|
||||
color: var(--text-leise);
|
||||
background: rgba(255, 255, 255, .06);
|
||||
border: 1px solid var(--rand);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* "ab 23. Sep" -- ohne das sagt "Später" nicht, ob morgen oder im
|
||||
November. Steht rechts und tritt zurück. */
|
||||
.zeitblock__ab {
|
||||
margin-left: auto;
|
||||
font-size: .74rem; font-weight: 500;
|
||||
letter-spacing: 0; text-transform: none;
|
||||
color: var(--text-still);
|
||||
}
|
||||
|
||||
/* Der offene Block hebt sich ab -- sonst sieht man beim Scrollen nicht,
|
||||
wo man gerade ist. */
|
||||
.zeitblock[open] { border-color: var(--rand-hell); }
|
||||
.zeitblock[open] .zeitblock__kopf { color: var(--text-leise); }
|
||||
.zeitblock .gruppe__karten { padding: 4px 13px 14px; }
|
||||
|
||||
/* ZUGEKLAPPT HEISST WIRKLICH WEG (06.09.2026, gefunden von
|
||||
pruef-call-kategorien).
|
||||
|
||||
<details> versteckt seinen Inhalt von selbst -- aber nur, solange
|
||||
niemand dem Inhalt eine eigene `display`-Angabe gibt. `.gruppe__karten`
|
||||
traegt `display: grid` (aus dem gemeinsamen Stil der Gruppen), und
|
||||
damit war der Block zwar ZUGEKLAPPT, seine Karten standen aber
|
||||
weiterhin darunter: gemessen 367 Pixel Hoehe bei open=false.
|
||||
|
||||
Die ganze Funktion waere damit wirkungslos gewesen -- der Knopf
|
||||
haette sich bewegt und sonst nichts getan. Im Bildschirmfoto fiel es
|
||||
nicht auf, weil der Ausschnitt genau darueber endete.
|
||||
|
||||
Deshalb hier ausdruecklich: zu ist zu. */
|
||||
.zeitblock:not([open]) .gruppe__karten { display: none; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.zeitblock__kopf::before { transition: none; }
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.zeitblock { border: 1px solid CanvasText; }
|
||||
.zeitblock__zahl { border: 1px solid CanvasText; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
/* =====================================================================
|
||||
chat.css — Gespräche im Workspace.
|
||||
|
||||
Wunsch Filipe, 06.09.2026: *"richtig krass, richtig hochwertig,
|
||||
professionell und ultra geil."*
|
||||
|
||||
WAS DAS HIER HEISST — und was nicht:
|
||||
|
||||
Nicht mehr Effekte. Ein Chat wird täglich benutzt und oft lange
|
||||
angesehen; was beim ersten Blick beeindruckt, stört beim hundertsten.
|
||||
Die Wirkung kommt aus vier Dingen:
|
||||
|
||||
1. EINE FLÄCHE, kein scrollendes Dokument. Die Seite hat die Höhe
|
||||
des Fensters, gescrollt wird nur INNERHALB der Spalten. Das
|
||||
Eingabefeld steht immer an derselben Stelle -- man muss es nie
|
||||
suchen.
|
||||
2. RUHE IM VERLAUF. Fast schwarzer Grund, die eigenen Nachrichten
|
||||
in einem gedeckten Blau, fremde in einem neutralen Grau. Kein
|
||||
Farbverlauf in der Blase; er macht Text unruhig zu lesen.
|
||||
3. GRUPPIERUNG. Mehrere Nachrichten derselben Person rücken
|
||||
zusammen und wiederholen den Namen nicht. Ein Datumstrenner
|
||||
gliedert den Verlauf.
|
||||
4. GENUG LUFT. Zeilenhöhe 1.5, Blasen mit ordentlichem Innenabstand.
|
||||
Enge sieht auf einem Bildschirmfoto ordentlich aus und liest
|
||||
sich schlecht.
|
||||
|
||||
Augenschonend ist Hausregel: keine harten Weißflächen, keine
|
||||
Neonränder, Bewegung nur als kurze Blende.
|
||||
===================================================================== */
|
||||
|
||||
/* ---------- Die Fläche --------------------------------------------------- */
|
||||
|
||||
.chat-seite {
|
||||
/* Der Rest des Hauses scrollt; hier soll die Seite stehen.
|
||||
|
||||
100dvh statt 100vh: Auf dem Handy ändert sich die sichtbare Höhe,
|
||||
wenn die Adresszeile ein- und ausfährt -- mit vh springt das
|
||||
Eingabefeld dabei aus dem Bild.
|
||||
|
||||
--kopf-hoehe WIRD GEMESSEN, nicht geraten (chat.js). Der erste
|
||||
Entwurf hatte hier 62 px stehen; am Handy ist die Kopfleiste aber
|
||||
zweizeilig und gut doppelt so hoch. Folge: Die Seite war zu lang,
|
||||
und das Eingabefeld stand halb unterhalb des Bildschirmrands --
|
||||
ausgerechnet das Feld, um das es geht. Der Rückfallwert bleibt
|
||||
stehen für den Moment vor der ersten Messung. */
|
||||
height: calc(100dvh - var(--kopf-hoehe, 62px));
|
||||
display: flex; flex-direction: column;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
min-height: 0; /* sonst wächst das Raster über die Fläche */
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 320px) 1fr;
|
||||
gap: 0;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
radial-gradient(120% 90% at 0% 0%, rgba(255, 255, 255, .03), transparent 55%),
|
||||
var(--flaeche);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat__spalte {
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0; min-width: 0;
|
||||
}
|
||||
/* Der Trenner zwischen Liste und Verlauf. Läuft oben und unten aus --
|
||||
eine durchgehende Linie zerschneidet die Kachel in zwei. */
|
||||
.chat__spalte--verlauf { position: relative; }
|
||||
.chat__spalte--verlauf::before {
|
||||
content: ""; position: absolute; left: 0; top: 12px; bottom: 12px; width: 1px;
|
||||
background: linear-gradient(180deg,
|
||||
transparent, var(--rand-hell) 15%, var(--rand-hell) 85%, transparent);
|
||||
}
|
||||
|
||||
.chat__kopf {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 15px 17px;
|
||||
border-bottom: 1px solid var(--rand);
|
||||
flex: none;
|
||||
}
|
||||
.chat__kopf-text { min-width: 0; }
|
||||
.chat__titel {
|
||||
margin: 0;
|
||||
font-size: 1.02rem; font-weight: 650; color: var(--text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.chat__unter {
|
||||
margin: 2px 0 0;
|
||||
font-size: .78rem; color: var(--text-still);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.chat__kopf .knopf { margin-left: auto; flex: none; }
|
||||
|
||||
/* Der Zurück-Pfeil gehört dem Handy. Am Rechner stehen beide Spalten
|
||||
nebeneinander, da gibt es nichts, wohin man zurückkönnte. */
|
||||
.chat__zurueck {
|
||||
display: none;
|
||||
flex: none;
|
||||
width: 34px; height: 34px;
|
||||
align-items: center; justify-content: center;
|
||||
border: 1px solid var(--rand); border-radius: 10px;
|
||||
background: rgba(255, 255, 255, .04);
|
||||
color: var(--text-leise); cursor: pointer;
|
||||
}
|
||||
.chat__zurueck svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; }
|
||||
.chat__zurueck:hover { color: var(--text); border-color: var(--rand-hell); }
|
||||
|
||||
.chat__suche { padding: 10px 14px 4px; flex: none; }
|
||||
.chat__suche input { width: 100%; }
|
||||
|
||||
.chat__leer {
|
||||
margin: 0; padding: 20px 18px;
|
||||
font-size: .86rem; color: var(--text-still); line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ---------- Links: die Gespräche ----------------------------------------- */
|
||||
|
||||
.chat__liste {
|
||||
flex: 1; min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin: 0; padding: 8px; list-style: none;
|
||||
display: grid; gap: 3px; align-content: start;
|
||||
}
|
||||
|
||||
.chat-raum__knopf {
|
||||
--r: var(--akzent);
|
||||
width: 100%;
|
||||
display: grid; grid-template-columns: auto 1fr auto;
|
||||
align-items: center; gap: 11px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid transparent; border-radius: 12px;
|
||||
background: transparent;
|
||||
color: inherit; font: inherit; text-align: left; cursor: pointer;
|
||||
transition: background .16s ease, border-color .16s ease;
|
||||
}
|
||||
.chat-raum__knopf:hover { background: rgba(255, 255, 255, .04); }
|
||||
.chat-raum__knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; }
|
||||
|
||||
/* Das offene Gespräch bleibt sichtbar markiert -- sonst weiß man nach
|
||||
dem Scrollen nicht mehr, wo man ist. */
|
||||
.chat-raum[data-offen="ja"] .chat-raum__knopf {
|
||||
background: color-mix(in srgb, var(--akzent) 11%, transparent);
|
||||
border-color: color-mix(in srgb, var(--akzent) 32%, transparent);
|
||||
}
|
||||
|
||||
.chat-raum__zeichen {
|
||||
flex: none;
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
display: grid; place-items: center;
|
||||
font-size: .82rem; font-weight: 700;
|
||||
color: var(--r);
|
||||
background: color-mix(in srgb, var(--r) 13%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--r) 30%, transparent);
|
||||
}
|
||||
/* Dieselben Rollenfarben wie überall im Haus. */
|
||||
.chat-raum__zeichen[data-rolle="admin"] { --r: #e8c07d; }
|
||||
.chat-raum__zeichen[data-rolle="manager"] { --r: #8a76ff; }
|
||||
.chat-raum__zeichen[data-rolle="scout"] { --r: #5fc99a; }
|
||||
.chat-raum__zeichen[data-rolle="creator"] { --r: #3fbdf5; }
|
||||
/* Eine Gruppe zeigt ihre Größe statt einer Initiale -- der Name der
|
||||
Gruppe steht ohnehin daneben. */
|
||||
.chat-raum__zeichen[data-gruppe="ja"] {
|
||||
--r: #a0aec4;
|
||||
border-radius: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chat-raum__mitte { min-width: 0; }
|
||||
.chat-raum__oben { display: flex; align-items: baseline; gap: 8px; }
|
||||
.chat-raum__name {
|
||||
flex: 1; min-width: 0;
|
||||
font-size: .89rem; font-weight: 600; color: var(--text-leise);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.chat-raum__zeit {
|
||||
flex: none;
|
||||
font-size: .7rem; color: var(--text-still);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.chat-raum__vorschau {
|
||||
display: block; margin-top: 2px;
|
||||
font-size: .78rem; color: var(--text-still);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.chat-raum__vorschau[data-weg="ja"] { font-style: italic; opacity: .8; }
|
||||
|
||||
/* Ungelesen: die Zahl UND ein kräftigerer Name. Nur eine Zahl übersieht
|
||||
man beim Überfliegen. */
|
||||
.chat-raum[data-neu="ja"] .chat-raum__name { color: var(--text); font-weight: 700; }
|
||||
.chat-raum[data-neu="ja"] .chat-raum__vorschau { color: var(--text-leise); }
|
||||
.chat-raum__zahl {
|
||||
flex: none;
|
||||
min-width: 21px; height: 21px; padding: 0 6px;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 999px;
|
||||
font-size: .72rem; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0b0f16;
|
||||
background: var(--akzent);
|
||||
}
|
||||
|
||||
/* ---------- Rechts: der Verlauf ------------------------------------------ */
|
||||
|
||||
.chat__verlauf {
|
||||
flex: 1; min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 18px 18px 8px;
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
/* Ein weicher Schleier am oberen Rand: Der Verlauf scrollt unter den
|
||||
Kopf, statt an einer harten Kante abgeschnitten zu wirken. */
|
||||
mask-image: linear-gradient(180deg, transparent, #000 14px);
|
||||
}
|
||||
|
||||
/* KURZE GESPRÄCHE LIEGEN UNTEN AN.
|
||||
|
||||
Bei drei Nachrichten standen sie oben, und darunter klaffte der halbe
|
||||
Bildschirm -- man sucht die neueste dann am falschen Ende. Jeder
|
||||
Messenger legt sie ans untere Ende, direkt über das Eingabefeld, und
|
||||
das ist kein Geschmack: Dort schaut man hin, wenn man antworten will.
|
||||
|
||||
`margin-top: auto` auf dem ERSTEN Kind statt `justify-content:
|
||||
flex-end` am Behälter. Der naheliegende Weg hat einen bekannten
|
||||
Haken: In einem scrollbaren Flex-Behälter wird der obere Teil damit
|
||||
unerreichbar, sobald der Inhalt überläuft -- man käme an die ältesten
|
||||
Nachrichten nicht mehr heran. */
|
||||
.chat__verlauf > :first-child { margin-top: auto; }
|
||||
|
||||
/* Der Datumstrenner. */
|
||||
.chat-tag {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 14px 0 10px;
|
||||
color: var(--text-still);
|
||||
}
|
||||
.chat-tag::before, .chat-tag::after {
|
||||
content: ""; flex: 1; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--rand-hell), transparent);
|
||||
}
|
||||
.chat-tag__text {
|
||||
flex: none;
|
||||
font-size: .72rem; font-weight: 700;
|
||||
letter-spacing: .07em; text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chat-nachricht {
|
||||
display: grid;
|
||||
grid-template-columns: 30px 1fr;
|
||||
gap: 9px;
|
||||
align-items: flex-end;
|
||||
max-width: 76%;
|
||||
margin-top: 9px;
|
||||
}
|
||||
/* Folgt eine Nachricht derselben Person, rückt sie näher und lässt den
|
||||
Kreis weg -- der Platz bleibt frei, damit die Blasen fluchten. */
|
||||
.chat-nachricht[data-folge="ja"] { margin-top: 2px; }
|
||||
.chat-nachricht[data-selbst="ja"] {
|
||||
grid-template-columns: 1fr;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chat-nachricht__zeichen {
|
||||
--r: #a0aec4;
|
||||
grid-column: 1;
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
display: grid; place-items: center;
|
||||
font-size: .7rem; font-weight: 700;
|
||||
color: var(--r);
|
||||
background: color-mix(in srgb, var(--r) 13%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--r) 28%, transparent);
|
||||
}
|
||||
.chat-nachricht__zeichen[data-rolle="admin"] { --r: #e8c07d; }
|
||||
.chat-nachricht__zeichen[data-rolle="manager"] { --r: #8a76ff; }
|
||||
.chat-nachricht__zeichen[data-rolle="scout"] { --r: #5fc99a; }
|
||||
.chat-nachricht__zeichen[data-rolle="creator"] { --r: #3fbdf5; }
|
||||
/* Ohne Kreis (Folgenachricht) bleibt die Spalte trotzdem stehen. */
|
||||
.chat-nachricht[data-folge="ja"]:not([data-selbst="ja"]) .chat-nachricht__blase { grid-column: 2; }
|
||||
|
||||
.chat-nachricht__blase {
|
||||
grid-column: 2;
|
||||
padding: 10px 13px 7px;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
background: rgba(255, 255, 255, .045);
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-nachricht[data-folge="ja"] .chat-nachricht__blase { border-radius: 14px 14px 14px 8px; }
|
||||
|
||||
/* Die eigenen: gedecktes Blau, Ecke rechts. Kein Verlauf in der Fläche
|
||||
-- Text auf einem Farbverlauf liest sich unruhig. */
|
||||
.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase {
|
||||
grid-column: 1;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
background: color-mix(in srgb, var(--akzent) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--akzent) 30%, transparent);
|
||||
}
|
||||
.chat-nachricht[data-selbst="ja"][data-folge="ja"] .chat-nachricht__blase {
|
||||
border-radius: 14px 14px 8px 14px;
|
||||
}
|
||||
|
||||
.chat-nachricht__von {
|
||||
margin: 0 0 3px;
|
||||
font-size: .74rem; font-weight: 700;
|
||||
color: color-mix(in srgb, var(--text-leise) 85%, transparent);
|
||||
}
|
||||
.chat-nachricht__text {
|
||||
margin: 0;
|
||||
font-size: .92rem; color: var(--text);
|
||||
/* pre-wrap: Zeilenumbrüche aus der Eingabe bleiben erhalten, ohne
|
||||
dass dafür HTML erzeugt werden müsste (das wäre der Weg, über den
|
||||
jemand Auszeichnung einschleust). */
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere; /* eine lange Adresse sprengt nichts */
|
||||
line-height: 1.5;
|
||||
}
|
||||
.chat-nachricht__weg { color: var(--text-still); font-style: italic; }
|
||||
.chat-nachricht[data-weg="ja"] .chat-nachricht__blase {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.chat-nachricht__fuss {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.chat-nachricht__zeit {
|
||||
font-size: .68rem; color: var(--text-still);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Der Zurücknehmen-Knopf erscheint erst beim Darüberfahren -- er ist
|
||||
die seltene Handlung und soll nicht in jeder Zeile mitlaufen. Auf
|
||||
Geräten ohne Zeiger steht er immer da (siehe unten). */
|
||||
.chat-nachricht__weg-knopf {
|
||||
padding: 0; border: 0; background: none;
|
||||
font: inherit; font-size: .68rem;
|
||||
color: var(--text-still); cursor: pointer;
|
||||
opacity: 0; transition: opacity .16s ease, color .16s ease;
|
||||
}
|
||||
.chat-nachricht:hover .chat-nachricht__weg-knopf,
|
||||
.chat-nachricht__weg-knopf:focus-visible { opacity: 1; }
|
||||
.chat-nachricht__weg-knopf:hover { color: var(--warn); }
|
||||
|
||||
/* Unterwegs und gescheitert -- der Unterschied muss sichtbar sein. */
|
||||
.chat-nachricht__blase:has(+ *) { /* Platzhalter, damit :has unterstützt bleibt */ }
|
||||
|
||||
/* ---------- Das Eingabefeld ---------------------------------------------- */
|
||||
|
||||
.chat__eingabe {
|
||||
flex: none;
|
||||
display: flex; align-items: flex-end; gap: 10px;
|
||||
padding: 12px 14px 14px;
|
||||
border-top: 1px solid var(--rand);
|
||||
background: rgba(0, 0, 0, .2);
|
||||
}
|
||||
.chat__eingabe-schild {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px;
|
||||
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
|
||||
}
|
||||
.chat__eingabe textarea {
|
||||
flex: 1; min-width: 0;
|
||||
resize: none;
|
||||
min-height: 42px; max-height: 160px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.chat__senden {
|
||||
flex: none;
|
||||
width: 42px; height: 42px;
|
||||
display: grid; place-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--akzent) 40%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--akzent) 18%, transparent);
|
||||
color: color-mix(in srgb, var(--akzent) 55%, #ffffff);
|
||||
cursor: pointer;
|
||||
transition: background .16s ease, color .16s ease;
|
||||
}
|
||||
.chat__senden svg { width: 20px; height: 20px; fill: currentColor; }
|
||||
.chat__senden:hover {
|
||||
background: color-mix(in srgb, var(--akzent) 28%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
.chat__senden:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
|
||||
|
||||
/* ---------- Die Wahl im Neu-Dialog --------------------------------------- */
|
||||
|
||||
.chat-wahl { margin: 4px 0 6px; max-height: 46vh; overflow-y: auto; }
|
||||
.chat-wahl__gruppe + .chat-wahl__gruppe { margin-top: 13px; }
|
||||
.chat-wahl__titel {
|
||||
margin: 0 0 7px;
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
font-size: .68rem; font-weight: 700;
|
||||
letter-spacing: .08em; text-transform: uppercase;
|
||||
color: var(--text-still);
|
||||
}
|
||||
.chat-wahl__punkt {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--r, var(--text-still)); flex: none;
|
||||
}
|
||||
.chat-wahl__gruppe[data-rolle="admin"] { --r: #e8c07d; }
|
||||
.chat-wahl__gruppe[data-rolle="manager"] { --r: #8a76ff; }
|
||||
.chat-wahl__gruppe[data-rolle="scout"] { --r: #5fc99a; }
|
||||
.chat-wahl__gruppe[data-rolle="creator"] { --r: #3fbdf5; }
|
||||
|
||||
.chat-wahl__liste { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.chat-wahl__knopf {
|
||||
--r: rgba(255, 255, 255, .5);
|
||||
display: inline-flex; align-items: center; gap: 9px;
|
||||
min-height: 40px;
|
||||
padding: 5px 14px 5px 6px;
|
||||
border: 1px solid var(--rand); border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .035);
|
||||
color: var(--text-leise); font: inherit; cursor: pointer;
|
||||
transition: color .16s ease, border-color .16s ease, background .16s ease;
|
||||
}
|
||||
.chat-wahl__knopf[data-rolle="admin"] { --r: #e8c07d; }
|
||||
.chat-wahl__knopf[data-rolle="manager"] { --r: #8a76ff; }
|
||||
.chat-wahl__knopf[data-rolle="scout"] { --r: #5fc99a; }
|
||||
.chat-wahl__knopf[data-rolle="creator"] { --r: #3fbdf5; }
|
||||
.chat-wahl__knopf:hover { color: var(--text); border-color: rgba(255, 255, 255, .24); }
|
||||
.chat-wahl__knopf:focus-visible { outline: 2px solid var(--r); outline-offset: 2px; }
|
||||
.chat-wahl__zeichen {
|
||||
flex: none;
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
display: grid; place-items: center;
|
||||
font-size: .74rem; font-weight: 700;
|
||||
color: var(--r);
|
||||
background: color-mix(in srgb, var(--r) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--r) 34%, transparent);
|
||||
}
|
||||
.chat-wahl__name { font-size: .86rem; font-weight: 550; }
|
||||
.chat-wahl__knopf[data-an="ja"] {
|
||||
color: var(--text); font-weight: 600;
|
||||
border-color: color-mix(in srgb, var(--r) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--r) 13%, transparent);
|
||||
}
|
||||
.chat-wahl__knopf[data-an="ja"] .chat-wahl__zeichen {
|
||||
color: #12151b; background: var(--r); border-color: var(--r);
|
||||
}
|
||||
|
||||
/* ---------- Handy --------------------------------------------------------
|
||||
|
||||
EINE Spalte, umgeschaltet über data-ansicht. Zwei Spalten auf 390
|
||||
Pixeln hiessen zwei unbenutzbare Hälften. */
|
||||
@media (max-width: 860px) {
|
||||
.chat { grid-template-columns: 1fr; }
|
||||
.chat__spalte--verlauf::before { display: none; }
|
||||
.chat__zurueck { display: flex; }
|
||||
|
||||
.chat[data-ansicht="liste"] .chat__spalte--verlauf { display: none; }
|
||||
.chat[data-ansicht="verlauf"] .chat__spalte--liste { display: none; }
|
||||
|
||||
/* Mehr Breite für die Blasen -- auf einem schmalen Schirm sind 76 %
|
||||
drei Wörter je Zeile. */
|
||||
.chat-nachricht { max-width: 88%; }
|
||||
/* Ohne Zeiger gibt es kein "Darüberfahren": Der Knopf steht immer da,
|
||||
sonst wäre er auf dem Handy gar nicht erreichbar. */
|
||||
.chat-nachricht__weg-knopf { opacity: .75; }
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.chat-nachricht__weg-knopf { opacity: .75; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-raum__knopf, .chat__senden, .chat-wahl__knopf,
|
||||
.chat-nachricht__weg-knopf { transition: none; }
|
||||
}
|
||||
|
||||
/* Ohne Farbverläufe tragen Kanten und Formen allein. Die eigenen
|
||||
Nachrichten sind dann nur noch an der Ecke und der Ausrichtung zu
|
||||
erkennen -- deshalb bekommen sie hier einen kräftigeren Rand. */
|
||||
@media (forced-colors: active) {
|
||||
.chat { border: 1px solid CanvasText; }
|
||||
.chat__spalte--verlauf::before { background: CanvasText; }
|
||||
.chat-nachricht__blase { border: 1px solid CanvasText; }
|
||||
.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase { border-width: 2px; }
|
||||
.chat-raum__zeichen, .chat-nachricht__zeichen { border: 1px solid CanvasText; }
|
||||
.chat-raum__zahl { border: 1px solid CanvasText; }
|
||||
.chat-tag::before, .chat-tag::after { background: CanvasText; }
|
||||
.chat__verlauf { mask-image: none; }
|
||||
}
|
||||
@@ -3580,3 +3580,66 @@ body[data-fremde-sicht="ja"]::after {
|
||||
der Art bleibt das Merkmal für "vorbei". */
|
||||
.tagesliste__punkt[data-vorbei="ja"] { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ---------- Der Chat-Knopf in der Kopfleiste ----------------------------
|
||||
|
||||
Steht auf JEDER Seite (kopf.js), nur nicht auf der Chatseite selbst.
|
||||
Eine Nachricht, die man erst sieht, wenn man den Chat aufmacht, ist
|
||||
keine Nachricht -- sie ist ein Fundstück. */
|
||||
.chat-knopf {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
position: relative;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid var(--rand); border-radius: 11px;
|
||||
background: rgba(255, 255, 255, .04);
|
||||
color: var(--text-leise); font-size: .85rem; font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: color .16s ease, border-color .16s ease, background .16s ease;
|
||||
}
|
||||
.chat-knopf:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--rand-hell);
|
||||
background: rgba(255, 255, 255, .07);
|
||||
}
|
||||
.chat-knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
|
||||
.chat-knopf__zeichen {
|
||||
width: 17px; height: 17px; flex: none;
|
||||
fill: none; stroke: currentColor; stroke-width: 1.7;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Wartet etwas, trägt der Knopf selbst Farbe -- nicht nur die Plakette.
|
||||
Eine kleine Zahl am Rand übersieht man beim Überfliegen. */
|
||||
.chat-knopf[data-neu="ja"] {
|
||||
color: var(--akzent);
|
||||
border-color: color-mix(in srgb, var(--akzent) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--akzent) 10%, transparent);
|
||||
}
|
||||
.chat-knopf__zahl {
|
||||
min-width: 19px; height: 19px; padding: 0 5px;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 999px;
|
||||
/* 11,84 px -- unter 11,5 meldet die Geräteprüfung zu Recht "zu kleine
|
||||
Schrift". */
|
||||
font-size: .74rem; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0b0f16;
|
||||
background: var(--akzent);
|
||||
}
|
||||
|
||||
/* Schmale Schirme: nur das Zeichen. Der Text steht weiterhin im
|
||||
aria-label. Dieselbe Grenze wie bei der Glocke -- eine eigene Zahl
|
||||
daneben wäre genau die Art Wert, die beim nächsten Mal knapp
|
||||
danebenliegt. */
|
||||
@media (max-width: 900px) {
|
||||
.chat-knopf { padding: 7px 9px; }
|
||||
.chat-knopf__text { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .chat-knopf { transition: none; } }
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.chat-knopf { border: 1px solid CanvasText; }
|
||||
.chat-knopf[data-neu="ja"] { border-width: 2px; }
|
||||
.chat-knopf__zahl { border: 1px solid CanvasText; }
|
||||
}
|
||||
|
||||
@@ -74,6 +74,12 @@
|
||||
dateien: [
|
||||
'M3.5 6.5h5.5l2 2h9.5v11h-17z'
|
||||
],
|
||||
/* Chat (06.09.2026): eine Sprechblase mit Schweif nach links
|
||||
unten. Bewusst OHNE die drei Punkte darin -- bei 20 Pixeln
|
||||
werden die zu Matsch, und die Blase allein ist eindeutig. */
|
||||
chat: [
|
||||
'M4 5.5h16v11H9.5L5 20.5V16.5H4z'
|
||||
],
|
||||
steckbrief: [
|
||||
'M3.5 5.5h17v13h-17z'
|
||||
],
|
||||
@@ -276,6 +282,13 @@
|
||||
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'kalender.html', szene: 'skyline' },
|
||||
{ name: 'Calls & Protokolle', unter: 'Gespräche mit To-dos', zeichen: 'calls', ton: 9,
|
||||
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'calls.html', szene: 'lounge' },
|
||||
/* CHAT (06.09.2026). Steht neben "Calls & Protokolle", weil
|
||||
beides Gespraech ist -- das eine geplant und protokolliert,
|
||||
das andere zwischendurch. Ton 14 (Gruen), weil die taegliche
|
||||
Gruppe sonst 3, 11, 16, 9 und 2 traegt und sich der Chat
|
||||
davon abheben soll. */
|
||||
{ name: 'Chat', unter: 'Nachrichten mit deinem Team', zeichen: 'chat', ton: 14,
|
||||
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'chat.html', szene: 'lounge' },
|
||||
{ name: 'Dateien', unter: 'Ablage und Freigaben', zeichen: 'dateien', ton: 2,
|
||||
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'dateien.html', szene: 'arena' },
|
||||
],
|
||||
|
||||
@@ -403,6 +403,55 @@
|
||||
{ schluessel: 'erledigt', titel: 'Festgehalten', frage: null },
|
||||
];
|
||||
|
||||
/* ---------- Kategorien innerhalb einer Gruppe ---------------------------
|
||||
|
||||
Wunsch Filipe, 06.09.2026, mit einem Bild von "STEHT AN 27":
|
||||
*"die liste da soll kategorisiert sein und werden mit einem button
|
||||
zum auf und zu machen."*
|
||||
|
||||
WARUM NACH ZEIT und nicht nach Titel: Auf dem Bild standen
|
||||
siebenundzwanzig Karten am Stück, fast alle "Community-Talk" --
|
||||
wöchentliche Serien. Nach Titel gruppiert hätte man zwei
|
||||
Überschriften und darunter wieder dieselbe lange Liste. Die Frage,
|
||||
die man an eine Terminliste hat, ist "was kommt als Nächstes", und
|
||||
die Antwort darauf ist ein Zeitraum.
|
||||
|
||||
WAS OFFEN IST: nur die erste Kategorie, die etwas enthält. Alles
|
||||
aufzuklappen wäre wieder die Wand von vorher; alles zuzuklappen
|
||||
hiesse, dass man nach dem Laden erst einmal klicken muss, um
|
||||
überhaupt etwas zu sehen. */
|
||||
const ZEITRAEUME = [
|
||||
{ schluessel: 'heute', titel: 'Heute' },
|
||||
{ schluessel: 'woche', titel: 'Diese Woche' },
|
||||
{ schluessel: 'naechste', titel: 'Nächste Woche' },
|
||||
{ schluessel: 'spaeter', titel: 'Später' },
|
||||
];
|
||||
|
||||
/** In welchen Zeitraum fällt dieser Termin?
|
||||
*
|
||||
* ORTSZEIT durchgehend. Ein `toISOString()` läge zwischen
|
||||
* Mitternacht und zwei Uhr einen Tag daneben -- und ausgerechnet
|
||||
* dann wäre "Heute" leer, obwohl etwas ansteht. */
|
||||
function zeitraumVon(beginn) {
|
||||
const d = new Date(String(beginn).replace(' ', 'T'));
|
||||
if (Number.isNaN(d.getTime())) return 'spaeter';
|
||||
|
||||
const heute = new Date();
|
||||
heute.setHours(0, 0, 0, 0);
|
||||
const tag = new Date(d); tag.setHours(0, 0, 0, 0);
|
||||
const tage = Math.round((tag - heute) / 86400000);
|
||||
|
||||
if (tage <= 0) return 'heute';
|
||||
/* Bis zum Ende DIESER Woche (Sonntag), nicht "in sieben Tagen".
|
||||
"Diese Woche" heisst umgangssprachlich bis Sonntag -- wer am
|
||||
Freitag "diese Woche" liest, erwartet nicht den Mittwoch darauf. */
|
||||
const wochentag = (heute.getDay() + 6) % 7; // Mo=0 … So=6
|
||||
const bisSonntag = 6 - wochentag;
|
||||
if (tage <= bisSonntag) return 'woche';
|
||||
if (tage <= bisSonntag + 7) return 'naechste';
|
||||
return 'spaeter';
|
||||
}
|
||||
|
||||
function zeichne() {
|
||||
const ziel = $('listen');
|
||||
ziel.textContent = '';
|
||||
@@ -427,13 +476,73 @@
|
||||
kopf.append(el('h2', 'stufe__titel', g.titel), el('span', 'stufe__zahl', String(liste.length)));
|
||||
s.append(kopf);
|
||||
if (g.frage) s.append(el('p', 'block__frage', g.frage));
|
||||
|
||||
/* DIE ANSTEHENDEN WERDEN NACH ZEITRAUM UNTERTEILT (06.09.2026).
|
||||
Die anderen beiden Gruppen nicht: "Protokoll fehlt" ist eine
|
||||
Arbeitsliste, die man ganz sehen will, und "Festgehalten" ist
|
||||
ohnehin schon ein Archiv. Eine Unterteilung, die überall gleich
|
||||
aussieht, aber nur an einer Stelle etwas nützt, verwirrt an den
|
||||
anderen. */
|
||||
if (g.schluessel === 'anstehend' && liste.length > 4) {
|
||||
s.append(...zeitraumBloecke(liste, g.schluessel));
|
||||
} else {
|
||||
const box = el('div', 'gruppe__karten');
|
||||
for (const c of liste) box.append(karte(c, g.schluessel));
|
||||
s.append(box);
|
||||
}
|
||||
ziel.append(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut die aufklappbaren Zeitraum-Blöcke.
|
||||
*
|
||||
* <details> und nicht selbstgebaut: bringt Tastaturbedienung,
|
||||
* aria-expanded und das Verhalten der Browsersuche von sich aus mit.
|
||||
* Dasselbe Bauteil wie beim Bereich "Abgebrochen" auf dem
|
||||
* Aufgabenbrett -- wer eines kennt, kennt beide. */
|
||||
function zeitraumBloecke(liste, gruppe) {
|
||||
const nach = new Map(ZEITRAEUME.map((z) => [z.schluessel, []]));
|
||||
for (const c of liste) nach.get(zeitraumVon(c.beginn))?.push(c);
|
||||
|
||||
const raus = [];
|
||||
let ersterMitInhalt = true;
|
||||
for (const z of ZEITRAEUME) {
|
||||
const eigene = nach.get(z.schluessel);
|
||||
if (!eigene.length) continue;
|
||||
|
||||
const block = el('details', 'zeitblock');
|
||||
block.dataset.zeitraum = z.schluessel;
|
||||
/* Nur der erste gefüllte Block ist offen -- siehe Begründung
|
||||
oben bei ZEITRAEUME. */
|
||||
if (ersterMitInhalt) { block.open = true; ersterMitInhalt = false; }
|
||||
|
||||
const kopf = el('summary', 'zeitblock__kopf');
|
||||
kopf.append(el('span', 'zeitblock__name', z.titel));
|
||||
kopf.append(el('span', 'zeitblock__zahl', String(eigene.length)));
|
||||
/* Wann genau -- "Später" allein sagt nicht, ob morgen oder im
|
||||
November. Der erste Termin des Blocks beantwortet das. */
|
||||
const erster = eigene[0];
|
||||
if (erster) {
|
||||
kopf.append(el('span', 'zeitblock__ab',
|
||||
z.schluessel === 'spaeter' ? `ab ${kurzDatum(erster.beginn)}` : ''));
|
||||
}
|
||||
block.append(kopf);
|
||||
|
||||
const box = el('div', 'gruppe__karten');
|
||||
for (const c of eigene) box.append(karte(c, gruppe));
|
||||
block.append(box);
|
||||
raus.push(block);
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/** "23. Sep" -- kurz, weil es neben einer Überschrift steht. */
|
||||
function kurzDatum(beginn) {
|
||||
const d = new Date(String(beginn).replace(' ', 'T'));
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const a = await hole('/workspace/api/calls');
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
/* =====================================================================
|
||||
chat.js — Gespräche im Workspace.
|
||||
|
||||
Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht
|
||||
reinschreiben, die andere person bekommt eine benachrichtigung und
|
||||
kann dann antworten."*
|
||||
|
||||
---------------------------------------------------------------------
|
||||
DREI ENTSCHEIDUNGEN, die den Rest erklären
|
||||
|
||||
1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein
|
||||
Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei
|
||||
zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die
|
||||
es meistens nicht gibt. Reisst die Verbindung ab, verbindet der
|
||||
Browser sich von selbst neu -- und wir holen die Lücke über den
|
||||
normalen Verlauf nach.
|
||||
|
||||
2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet.
|
||||
Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins
|
||||
Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit,
|
||||
wird sie sichtbar als "nicht angekommen" markiert -- nicht still
|
||||
entfernt.
|
||||
|
||||
3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon
|
||||
beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat
|
||||
nichts gelesen, und eine Zahl, die von selbst verschwindet, ist
|
||||
schlimmer als keine.
|
||||
===================================================================== */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const el = (tag, klasse, text) => {
|
||||
const k = document.createElement(tag);
|
||||
if (klasse) k.className = klasse;
|
||||
if (text !== undefined) k.textContent = text;
|
||||
return k;
|
||||
};
|
||||
|
||||
const ROLLENKURZ = { admin: 'DogFather', manager: 'Manager', scout: 'Scout', creator: 'Creator' };
|
||||
|
||||
let ich = null;
|
||||
let raeume = [];
|
||||
let offen = null; // der gerade angesehene Raum
|
||||
let nachrichten = [];
|
||||
let strom = null; // die EventSource
|
||||
let gewaehlt = new Set(); // im Neu-Dialog
|
||||
|
||||
/* ---------- Kleinkram ---------------------------------------------------- */
|
||||
|
||||
async function hole(weg, einst) {
|
||||
const a = await fetch(weg, { credentials: 'same-origin', ...einst });
|
||||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); }
|
||||
return a;
|
||||
}
|
||||
|
||||
/** Ein bis zwei Buchstaben für den Kreis. Dieselbe Regel wie bei der
|
||||
* Teilnehmerwahl im Kalender -- Anfang UND Ende, weil "Patrick von
|
||||
* Hohenstein" sonst zu "PV" würde. */
|
||||
function initialen(name) {
|
||||
const teile = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (!teile.length) return '?';
|
||||
const erst = [...teile[0]][0] || '';
|
||||
const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : '';
|
||||
return (erst + letzt).toLocaleUpperCase('de-DE');
|
||||
}
|
||||
|
||||
/** "14:32" heute, "Gestern", sonst "3. Sep".
|
||||
*
|
||||
* ORTSZEIT, nicht UTC: Die Zeitstempel kommen als ISO-Text vom
|
||||
* Server. `new Date(text)` liest sie richtig, `slice()` darauf wäre
|
||||
* UTC und läge nachts einen Tag daneben. */
|
||||
function wann(iso, lang = false) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
if (lang) return `${d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long' })}, ${uhr}`;
|
||||
|
||||
const heute = new Date();
|
||||
const gleich = (a, b) => a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
if (gleich(d, heute)) return uhr;
|
||||
const gestern = new Date(heute.getTime() - 86400000);
|
||||
if (gleich(d, gestern)) return 'Gestern';
|
||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
const melde = (t) => { $('fehler').textContent = t || ''; };
|
||||
|
||||
/* ---------- Die Gesprächsliste ------------------------------------------- */
|
||||
|
||||
async function raeumeLaden() {
|
||||
try {
|
||||
const a = await hole('/workspace/api/chat/raeume');
|
||||
if (!a.ok) return;
|
||||
raeume = (await a.json()).raeume || [];
|
||||
} catch { return; }
|
||||
raeumeZeichnen();
|
||||
ungelesenMelden();
|
||||
}
|
||||
|
||||
function raeumeZeichnen() {
|
||||
const ziel = $('raeume');
|
||||
const suchtext = ($('suche').value || '').trim().toLowerCase();
|
||||
const zeigen = suchtext
|
||||
? raeume.filter((r) => r.name.toLowerCase().includes(suchtext))
|
||||
: raeume;
|
||||
|
||||
ziel.textContent = '';
|
||||
$('liste-leer').hidden = raeume.length > 0;
|
||||
/* Das Suchfeld erst, wenn die Liste lang genug ist, um darin zu
|
||||
suchen. Vorher ist es ein Feld ohne Zweck. */
|
||||
$('suche-zeile').hidden = raeume.length < 8;
|
||||
|
||||
for (const r of zeigen) {
|
||||
const li = el('li', 'chat-raum');
|
||||
li.dataset.id = String(r.id);
|
||||
if (offen && r.id === offen.id) li.dataset.offen = 'ja';
|
||||
if (r.ungelesen) li.dataset.neu = 'ja';
|
||||
|
||||
const knopf = el('button', 'chat-raum__knopf');
|
||||
knopf.type = 'button';
|
||||
|
||||
const zeichen = el('span', 'chat-raum__zeichen');
|
||||
zeichen.setAttribute('aria-hidden', 'true');
|
||||
if (r.art === 'gruppe') {
|
||||
zeichen.dataset.gruppe = 'ja';
|
||||
zeichen.textContent = String(r.teilnehmer.length);
|
||||
} else {
|
||||
zeichen.textContent = initialen(r.name);
|
||||
const gegen = r.teilnehmer.find((t) => t.id !== ich.id);
|
||||
if (gegen) zeichen.dataset.rolle = gegen.rolle;
|
||||
}
|
||||
knopf.append(zeichen);
|
||||
|
||||
const mitte = el('span', 'chat-raum__mitte');
|
||||
const oben = el('span', 'chat-raum__oben');
|
||||
oben.append(el('span', 'chat-raum__name', r.name));
|
||||
if (r.letzte) oben.append(el('span', 'chat-raum__zeit', wann(r.letzte.erstellt)));
|
||||
mitte.append(oben);
|
||||
|
||||
const vorschau = el('span', 'chat-raum__vorschau');
|
||||
if (r.letzte) {
|
||||
const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art === 'gruppe' ? `${r.letzte.von}: ` : '');
|
||||
vorschau.textContent = r.letzte.zurueckgenommen
|
||||
? `${wer}Nachricht zurückgenommen`
|
||||
: wer + r.letzte.text;
|
||||
if (r.letzte.zurueckgenommen) vorschau.dataset.weg = 'ja';
|
||||
} else {
|
||||
vorschau.textContent = 'Noch nichts geschrieben';
|
||||
vorschau.dataset.weg = 'ja';
|
||||
}
|
||||
mitte.append(vorschau);
|
||||
knopf.append(mitte);
|
||||
|
||||
if (r.ungelesen) {
|
||||
const zahl = el('span', 'chat-raum__zahl', String(r.ungelesen));
|
||||
zahl.setAttribute('aria-label', `${r.ungelesen} ungelesen`);
|
||||
knopf.append(zahl);
|
||||
}
|
||||
|
||||
knopf.addEventListener('click', () => raumOeffnen(r.id));
|
||||
li.append(knopf);
|
||||
ziel.append(li);
|
||||
}
|
||||
|
||||
if (suchtext && !zeigen.length) {
|
||||
const li = el('li', 'chat__leer', 'Kein Gespräch passt dazu.');
|
||||
ziel.append(li);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Ein Gespräch öffnen ------------------------------------------ */
|
||||
|
||||
async function raumOeffnen(id, { sanft = false } = {}) {
|
||||
try {
|
||||
const a = await hole(`/workspace/api/chat/raeume/${id}/nachrichten`);
|
||||
if (!a.ok) { melde('Dieses Gespräch gibt es nicht mehr.'); return; }
|
||||
const daten = await a.json();
|
||||
offen = daten.raum;
|
||||
nachrichten = daten.nachrichten;
|
||||
} catch { return; }
|
||||
|
||||
$('verlauf-titel').textContent = offen.name;
|
||||
$('verlauf-unter').textContent = offen.art === 'gruppe'
|
||||
? offen.teilnehmer.map((t) => t.name).join(' · ')
|
||||
: (ROLLENKURZ[offen.teilnehmer.find((t) => t.id !== ich.id)?.rolle] || '');
|
||||
$('schreiben').hidden = false;
|
||||
$('verlauf-leer').hidden = true;
|
||||
$('chat').dataset.ansicht = 'verlauf';
|
||||
|
||||
verlaufZeichnen();
|
||||
if (!sanft) $('text').focus();
|
||||
/* Die Adresszeile merkt sich das Gespräch -- so führt die
|
||||
Benachrichtigung direkt hierher, und ein Neuladen bleibt am
|
||||
richtigen Ort. */
|
||||
history.replaceState(null, '', `chat.html?raum=${id}`);
|
||||
gelesenMelden();
|
||||
raeumeZeichnen();
|
||||
}
|
||||
|
||||
function verlaufZeichnen() {
|
||||
const ziel = $('verlauf');
|
||||
ziel.textContent = '';
|
||||
if (!nachrichten.length) {
|
||||
ziel.append(el('p', 'chat__leer', 'Noch nichts geschrieben. Fang an.'));
|
||||
return;
|
||||
}
|
||||
|
||||
let letzterTag = '';
|
||||
let letzterVon = null;
|
||||
for (const n of nachrichten) {
|
||||
/* Ein Datumstrenner, wenn ein neuer Tag beginnt. Ohne ihn steht
|
||||
die Antwort von vorgestern direkt unter der von heute. */
|
||||
const tag = new Date(n.erstellt).toDateString();
|
||||
if (tag !== letzterTag) {
|
||||
letzterTag = tag;
|
||||
letzterVon = null;
|
||||
const trenner = el('div', 'chat-tag');
|
||||
trenner.append(el('span', 'chat-tag__text', tagName(n.erstellt)));
|
||||
ziel.append(trenner);
|
||||
}
|
||||
|
||||
const zeile = el('article', 'chat-nachricht');
|
||||
zeile.dataset.id = String(n.id);
|
||||
if (n.selbst) zeile.dataset.selbst = 'ja';
|
||||
if (n.zurueckgenommen) zeile.dataset.weg = 'ja';
|
||||
/* Mehrere Nachrichten derselben Person hintereinander rücken
|
||||
zusammen und wiederholen den Namen nicht -- sonst liest man
|
||||
fünfmal "Patrick" und dreimal dasselbe Gesicht. */
|
||||
const gleicheFolge = letzterVon === n.von_id;
|
||||
if (gleicheFolge) zeile.dataset.folge = 'ja';
|
||||
letzterVon = n.von_id;
|
||||
|
||||
if (!gleicheFolge && !n.selbst) {
|
||||
const z = el('span', 'chat-nachricht__zeichen', initialen(n.von));
|
||||
z.setAttribute('aria-hidden', 'true');
|
||||
z.dataset.rolle = n.rolle || '';
|
||||
zeile.append(z);
|
||||
}
|
||||
|
||||
const blase = el('div', 'chat-nachricht__blase');
|
||||
if (!gleicheFolge && !n.selbst && offen.art === 'gruppe') {
|
||||
blase.append(el('p', 'chat-nachricht__von', n.von));
|
||||
}
|
||||
|
||||
if (n.zurueckgenommen) {
|
||||
blase.append(el('p', 'chat-nachricht__text chat-nachricht__weg',
|
||||
'Nachricht zurückgenommen'));
|
||||
} else {
|
||||
/* textContent, nie innerHTML: Eine Nachricht darf keine
|
||||
Auszeichnung einschleusen. Zeilenumbrüche macht das CSS
|
||||
(white-space: pre-wrap). */
|
||||
blase.append(el('p', 'chat-nachricht__text', n.text));
|
||||
}
|
||||
|
||||
const fuss = el('div', 'chat-nachricht__fuss');
|
||||
const zeit = el('time', 'chat-nachricht__zeit', wann(n.erstellt));
|
||||
zeit.dateTime = n.erstellt;
|
||||
zeit.title = wann(n.erstellt, true);
|
||||
fuss.append(zeit);
|
||||
|
||||
if (n.selbst && !n.zurueckgenommen) {
|
||||
const weg = el('button', 'chat-nachricht__weg-knopf', 'zurücknehmen');
|
||||
weg.type = 'button';
|
||||
weg.addEventListener('click', () => zuruecknehmen(n.id));
|
||||
fuss.append(weg);
|
||||
}
|
||||
blase.append(fuss);
|
||||
zeile.append(blase);
|
||||
ziel.append(zeile);
|
||||
}
|
||||
ziel.scrollTop = ziel.scrollHeight;
|
||||
}
|
||||
|
||||
function tagName(iso) {
|
||||
const d = new Date(iso);
|
||||
const heute = new Date();
|
||||
const gleich = (a, b) => a.toDateString() === b.toDateString();
|
||||
if (gleich(d, heute)) return 'Heute';
|
||||
if (gleich(d, new Date(heute.getTime() - 86400000))) return 'Gestern';
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
/* ---------- Schreiben ---------------------------------------------------- */
|
||||
|
||||
async function abschicken(e) {
|
||||
e.preventDefault();
|
||||
const feld = $('text');
|
||||
const text = feld.value.trim();
|
||||
if (!text || !offen) return;
|
||||
|
||||
/* SOFORT ANZEIGEN. Die vorläufige Nachricht bekommt eine negative
|
||||
Nummer -- damit lässt sie sich später zweifelsfrei durch die
|
||||
echte ersetzen, ohne mit einer Server-Nummer zu kollidieren. */
|
||||
const vorlaeufig = {
|
||||
id: -Date.now(), von_id: ich.id, von: ich.name, rolle: ich.rolle,
|
||||
text, erstellt: new Date().toISOString(), selbst: true, unterwegs: true,
|
||||
};
|
||||
nachrichten.push(vorlaeufig);
|
||||
verlaufZeichnen();
|
||||
feld.value = '';
|
||||
hoeheAnpassen();
|
||||
melde('');
|
||||
|
||||
try {
|
||||
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/nachrichten`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (!a.ok) {
|
||||
const d = await a.json().catch(() => ({}));
|
||||
markiereAlsGescheitert(vorlaeufig.id, d.fehler || 'Nicht angekommen.');
|
||||
return;
|
||||
}
|
||||
const { nachricht } = await a.json();
|
||||
const i = nachrichten.findIndex((n) => n.id === vorlaeufig.id);
|
||||
if (i >= 0) nachrichten[i] = { ...nachricht, selbst: true };
|
||||
verlaufZeichnen();
|
||||
raeumeLaden();
|
||||
} catch {
|
||||
markiereAlsGescheitert(vorlaeufig.id, 'Keine Verbindung.');
|
||||
}
|
||||
}
|
||||
|
||||
/** NICHT STILL VERSCHWINDEN LASSEN. Wer etwas schreibt und es sieht,
|
||||
* glaubt, es sei angekommen. Verschwindet es wortlos, merkt er es
|
||||
* im schlimmsten Fall Tage später. */
|
||||
function markiereAlsGescheitert(id, grund) {
|
||||
const i = nachrichten.findIndex((n) => n.id === id);
|
||||
if (i >= 0) { nachrichten[i].gescheitert = true; delete nachrichten[i].unterwegs; }
|
||||
verlaufZeichnen();
|
||||
melde(`${grund} Die Nachricht steht noch da – kopiere sie und versuch es erneut.`);
|
||||
}
|
||||
|
||||
async function zuruecknehmen(id) {
|
||||
if (!window.confirm('Diese Nachricht zurücknehmen? Der Text ist dann weg, '
|
||||
+ 'im Verlauf bleibt ein Hinweis stehen.')) return;
|
||||
try {
|
||||
const a = await hole(`/workspace/api/chat/nachrichten/${id}`, { method: 'DELETE' });
|
||||
if (!a.ok) { melde('Ging nicht.'); return; }
|
||||
const i = nachrichten.findIndex((n) => n.id === id);
|
||||
if (i >= 0) { nachrichten[i].zurueckgenommen = true; nachrichten[i].text = null; }
|
||||
verlaufZeichnen();
|
||||
raeumeLaden();
|
||||
} catch { /* umgeleitet */ }
|
||||
}
|
||||
|
||||
/** Das Feld wächst mit dem Text -- bis zu einer Grenze, sonst
|
||||
* verdrängt eine lange Nachricht den ganzen Verlauf. */
|
||||
function hoeheAnpassen() {
|
||||
const f = $('text');
|
||||
f.style.height = 'auto';
|
||||
f.style.height = Math.min(f.scrollHeight, 160) + 'px';
|
||||
}
|
||||
|
||||
/* ---------- Gelesen ------------------------------------------------------ */
|
||||
|
||||
function gelesenMelden() {
|
||||
if (!offen || !nachrichten.length) return;
|
||||
/* NUR WENN MAN WIRKLICH HINSIEHT. Eine Seite im Hintergrund hat
|
||||
niemand gelesen. */
|
||||
if (document.hidden) return;
|
||||
const echte = nachrichten.filter((n) => n.id > 0);
|
||||
if (!echte.length) return;
|
||||
const bis = echte[echte.length - 1].id;
|
||||
|
||||
fetch(`/workspace/api/chat/raeume/${offen.id}/gelesen`, {
|
||||
method: 'PUT', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bis }),
|
||||
}).then(() => {
|
||||
const r = raeume.find((x) => x.id === offen.id);
|
||||
if (r) { r.ungelesen = 0; raeumeZeichnen(); }
|
||||
ungelesenMelden();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/** Sagt der Kopfleiste, wie viele insgesamt warten. */
|
||||
async function ungelesenMelden() {
|
||||
try {
|
||||
const a = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' });
|
||||
if (!a.ok) return;
|
||||
const { ungelesen } = await a.json();
|
||||
window.chatZahlZeigen?.(ungelesen);
|
||||
} catch { /* die Zahl ist Zugabe */ }
|
||||
}
|
||||
|
||||
/* ---------- Neue Nachrichten kommen von selbst --------------------------- */
|
||||
|
||||
function stromOeffnen() {
|
||||
if (strom) strom.close();
|
||||
strom = new EventSource('/workspace/api/chat/strom');
|
||||
|
||||
strom.addEventListener('neu', (e) => {
|
||||
let n;
|
||||
try { n = JSON.parse(e.data); } catch { return; }
|
||||
|
||||
if (offen && n.raum_id === offen.id) {
|
||||
/* SCHON DA? Zwei Wege führen hierher, und beide sind richtig:
|
||||
die Antwort auf das eigene POST und der Strom. */
|
||||
if (nachrichten.some((x) => x.id === n.id)) return;
|
||||
|
||||
/* DER WETTLAUF (gefunden 06.09.2026, im Zwei-Browser-Test):
|
||||
Beim Abschicken steht die eigene Nachricht sofort da -- mit
|
||||
einer vorläufigen negativen Nummer. Ist der Strom SCHNELLER
|
||||
als die Antwort auf das POST, greift die Prüfung darüber
|
||||
nicht (die Nummern sind verschieden), und die Nachricht
|
||||
stand zweimal im Verlauf.
|
||||
|
||||
Deshalb wird eine eigene Nachricht, die noch unterwegs ist
|
||||
und denselben Text trägt, hier ERSETZT statt ergänzt. Damit
|
||||
gewinnt, wer zuerst kommt, und das Ergebnis ist dasselbe. */
|
||||
if (n.von_id === ich.id) {
|
||||
const i = nachrichten.findIndex(
|
||||
(x) => x.unterwegs && x.text === n.text);
|
||||
if (i >= 0) {
|
||||
nachrichten[i] = { ...n, selbst: true };
|
||||
verlaufZeichnen();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
nachrichten.push({ ...n, selbst: n.von_id === ich.id });
|
||||
verlaufZeichnen();
|
||||
gelesenMelden();
|
||||
}
|
||||
raeumeLaden();
|
||||
});
|
||||
|
||||
strom.addEventListener('weg', (e) => {
|
||||
let n;
|
||||
try { n = JSON.parse(e.data); } catch { return; }
|
||||
const i = nachrichten.findIndex((x) => x.id === n.id);
|
||||
if (i >= 0) {
|
||||
nachrichten[i].zurueckgenommen = true;
|
||||
nachrichten[i].text = null;
|
||||
verlaufZeichnen();
|
||||
}
|
||||
raeumeLaden();
|
||||
});
|
||||
|
||||
/* Bei einem Abriss verbindet EventSource von selbst neu. Wir holen
|
||||
danach den Verlauf nach -- sonst fehlte, was während der Pause
|
||||
geschrieben wurde. */
|
||||
strom.addEventListener('open', () => {
|
||||
if (offen) raumOeffnen(offen.id, { sanft: true });
|
||||
else raeumeLaden();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Neues Gespräch ----------------------------------------------- */
|
||||
|
||||
async function partnerLaden() {
|
||||
const ziel = $('partner');
|
||||
ziel.textContent = '';
|
||||
gewaehlt = new Set();
|
||||
$('gruppenname-zeile').hidden = true;
|
||||
$('neu-los').disabled = true;
|
||||
$('neu-fehler').textContent = '';
|
||||
|
||||
let personen = [];
|
||||
try {
|
||||
const a = await hole('/workspace/api/chat/partner');
|
||||
if (a.ok) personen = (await a.json()).personen || [];
|
||||
} catch { return; }
|
||||
|
||||
if (!personen.length) {
|
||||
ziel.append(el('p', 'chat__leer',
|
||||
'Dir ist noch niemand zugeordnet – melde dich bei DogFather.'));
|
||||
return;
|
||||
}
|
||||
|
||||
/* Nach Rollen gruppiert, wie bei der Teilnehmerwahl im Kalender.
|
||||
Die Rolle steht einmal über der Gruppe statt an jedem Namen. */
|
||||
for (const rolle of ['admin', 'manager', 'scout', 'creator']) {
|
||||
const leute = personen.filter((p) => p.rolle === rolle);
|
||||
if (!leute.length) continue;
|
||||
|
||||
const gruppe = el('div', 'chat-wahl__gruppe');
|
||||
gruppe.dataset.rolle = rolle;
|
||||
const titel = el('p', 'chat-wahl__titel');
|
||||
const punkt = el('span', 'chat-wahl__punkt');
|
||||
punkt.setAttribute('aria-hidden', 'true');
|
||||
titel.append(punkt, document.createTextNode(
|
||||
{ admin: 'DogFather', manager: 'Manager', scout: 'Scouts', creator: 'Creator' }[rolle]));
|
||||
gruppe.append(titel);
|
||||
|
||||
const liste = el('div', 'chat-wahl__liste');
|
||||
liste.setAttribute('role', 'group');
|
||||
for (const p of leute) {
|
||||
const b = el('button', 'chat-wahl__knopf');
|
||||
b.type = 'button';
|
||||
b.dataset.id = String(p.id);
|
||||
b.dataset.rolle = p.rolle;
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
|
||||
const z = el('span', 'chat-wahl__zeichen', initialen(p.name));
|
||||
z.setAttribute('aria-hidden', 'true');
|
||||
b.append(z, el('span', 'chat-wahl__name', p.name));
|
||||
|
||||
b.addEventListener('click', () => {
|
||||
if (gewaehlt.has(p.id)) { gewaehlt.delete(p.id); delete b.dataset.an; }
|
||||
else { gewaehlt.add(p.id); b.dataset.an = 'ja'; }
|
||||
b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : 'false');
|
||||
wahlGeaendert();
|
||||
});
|
||||
liste.append(b);
|
||||
}
|
||||
gruppe.append(liste);
|
||||
ziel.append(gruppe);
|
||||
}
|
||||
}
|
||||
|
||||
function wahlGeaendert() {
|
||||
const n = gewaehlt.size;
|
||||
/* Ab zwei ist es eine Gruppe -- und die braucht einen Namen. */
|
||||
$('gruppenname-zeile').hidden = n < 2;
|
||||
$('neu-los').disabled = n === 0;
|
||||
$('neu-los').textContent = n >= 2 ? 'Gruppe anlegen' : 'Schreiben';
|
||||
}
|
||||
|
||||
async function neuAbschicken(e) {
|
||||
e.preventDefault();
|
||||
const ids = [...gewaehlt];
|
||||
if (!ids.length) return;
|
||||
const knopf = $('neu-los');
|
||||
knopf.disabled = true;
|
||||
|
||||
try {
|
||||
let raumId;
|
||||
if (ids.length === 1) {
|
||||
const a = await hole('/workspace/api/chat/direkt', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mit: ids[0] }),
|
||||
});
|
||||
if (!a.ok) {
|
||||
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
|
||||
return;
|
||||
}
|
||||
raumId = (await a.json()).raum_id;
|
||||
} else {
|
||||
const name = $('gruppenname').value.trim();
|
||||
if (name.length < 2) {
|
||||
$('neu-fehler').textContent = 'Die Gruppe braucht einen Namen.';
|
||||
$('gruppenname').focus();
|
||||
return;
|
||||
}
|
||||
const a = await hole('/workspace/api/chat/gruppe', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, mit: ids }),
|
||||
});
|
||||
if (!a.ok) {
|
||||
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
|
||||
return;
|
||||
}
|
||||
raumId = (await a.json()).raum_id;
|
||||
}
|
||||
$('neu-dialog').close();
|
||||
await raeumeLaden();
|
||||
await raumOeffnen(raumId);
|
||||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||||
}
|
||||
|
||||
/* ---------- Die Höhe der Kopfleiste MESSEN, nicht raten ------------------
|
||||
|
||||
Die Chatfläche ist so hoch wie das Fenster minus die Kopfleiste.
|
||||
Deren Höhe stand zuerst als fester Wert im CSS (62 px) -- am Handy
|
||||
ist sie zweizeilig und gut doppelt so hoch. Folge: Die Seite war zu
|
||||
lang, und das Eingabefeld stand halb unter dem Bildschirmrand.
|
||||
|
||||
Ein fester Wert ist hier immer falsch: Die Leiste wächst mit der
|
||||
Schriftgröße, mit der Glocke, mit einem längeren Namen. Also wird
|
||||
sie gemessen -- einmal beim Start und wieder, wenn sich etwas an
|
||||
der Fenstergröße ändert. */
|
||||
function kopfMessen() {
|
||||
const kopf = document.querySelector('.kopfleiste');
|
||||
if (!kopf) return;
|
||||
const h = Math.ceil(kopf.getBoundingClientRect().height);
|
||||
document.documentElement.style.setProperty('--kopf-hoehe', `${h}px`);
|
||||
}
|
||||
kopfMessen();
|
||||
window.addEventListener('resize', kopfMessen);
|
||||
/* Auch wenn die Leiste selbst sich ändert (Glocke erscheint, Name
|
||||
wird eingesetzt) -- ohne das bliebe der erste Messwert stehen. */
|
||||
if (window.ResizeObserver) {
|
||||
const kopf = document.querySelector('.kopfleiste');
|
||||
if (kopf) new ResizeObserver(kopfMessen).observe(kopf);
|
||||
}
|
||||
|
||||
/* ---------- Start -------------------------------------------------------- */
|
||||
|
||||
$('schreiben').addEventListener('submit', abschicken);
|
||||
$('text').addEventListener('input', hoeheAnpassen);
|
||||
/* Enter schickt, Umschalt+Enter macht eine neue Zeile. Am Handy
|
||||
NICHT -- dort ist Enter die Zeilenschaltung der Bildschirmtastatur,
|
||||
und wer eine zweite Zeile will, käme sonst nie dazu. */
|
||||
$('text').addEventListener('keydown', (e) => {
|
||||
const handy = window.matchMedia('(hover: none)').matches;
|
||||
if (e.key === 'Enter' && !e.shiftKey && !handy) {
|
||||
e.preventDefault();
|
||||
$('schreiben').requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
$('suche').addEventListener('input', raeumeZeichnen);
|
||||
$('neu-oeffnen').addEventListener('click', async () => {
|
||||
await partnerLaden();
|
||||
$('neu-dialog').showModal();
|
||||
});
|
||||
$('neu-zu').addEventListener('click', () => $('neu-dialog').close());
|
||||
$('neu-form').addEventListener('submit', neuAbschicken);
|
||||
$('zur-liste').addEventListener('click', () => {
|
||||
$('chat').dataset.ansicht = 'liste';
|
||||
history.replaceState(null, '', 'chat.html');
|
||||
});
|
||||
|
||||
/* Zurück an den Bildschirm: nachsehen, was inzwischen kam, und melden,
|
||||
dass jetzt wirklich jemand hinsieht. */
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) return;
|
||||
gelesenMelden();
|
||||
raeumeLaden();
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const a = await hole('/workspace/api/ich');
|
||||
if (!a.ok) { location.assign('/workspace/'); return; }
|
||||
ich = await a.json();
|
||||
} catch { return; }
|
||||
window.werZeigen?.(ich);
|
||||
|
||||
await raeumeLaden();
|
||||
stromOeffnen();
|
||||
|
||||
/* Kommt man aus einer Benachrichtigung, steht das Gespräch in der
|
||||
Adresse -- dann direkt hinein. */
|
||||
const wunsch = Number(new URLSearchParams(location.search).get('raum'));
|
||||
if (Number.isInteger(wunsch) && wunsch > 0) await raumOeffnen(wunsch);
|
||||
})();
|
||||
})();
|
||||
@@ -449,6 +449,97 @@
|
||||
|
||||
const kopfRechts = document.querySelector('.kopfleiste__rechts');
|
||||
if (kopfRechts) aufbauSuche(kopfRechts);
|
||||
if (kopfRechts) aufbauChat(kopfRechts);
|
||||
|
||||
/* ---------- Der Chat-Knopf mit Zähler -----------------------------------
|
||||
|
||||
Wunsch Filipe, 06.09.2026: *"die andere person bekommt eine
|
||||
benachrichtigung und kann dann antworten."*
|
||||
|
||||
WARUM DER ZÄHLER IN DIE KOPFLEISTE GEHÖRT und nicht nur auf die
|
||||
Chatseite: Eine Nachricht, die man erst sieht, wenn man den Chat
|
||||
aufmacht, ist keine Nachricht -- sie ist ein Fundstück. Die
|
||||
Push-Benachrichtigung erreicht nur, wer sie erlaubt hat und wer
|
||||
gerade nicht am Rechner sitzt; hier drin sieht sie jeder, auf jeder
|
||||
Seite.
|
||||
|
||||
Auf der Chatseite selbst wird der Knopf NICHT gebaut -- dort ist
|
||||
man schon, und ein Knopf, der auf die eigene Seite führt, ist eine
|
||||
Sackgasse. Die Zahl aktualisiert chat.js dann direkt. */
|
||||
function aufbauChat(wohin) {
|
||||
/* Auf der Chatseite selbst gibt es nichts zu verlinken. */
|
||||
if (/\/chat\.html$/.test(location.pathname)) return;
|
||||
|
||||
const el = (tag, klasse, text) => {
|
||||
const k = document.createElement(tag);
|
||||
if (klasse) k.className = klasse;
|
||||
if (text !== undefined) k.textContent = text;
|
||||
return k;
|
||||
};
|
||||
|
||||
const a = el('a', 'chat-knopf');
|
||||
a.href = 'chat.html';
|
||||
a.title = 'Chat';
|
||||
a.setAttribute('aria-label', 'Chat');
|
||||
|
||||
const blase = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
blase.setAttribute('viewBox', '0 0 24 24');
|
||||
blase.setAttribute('class', 'chat-knopf__zeichen');
|
||||
blase.setAttribute('aria-hidden', 'true');
|
||||
blase.innerHTML = '<path d="M4 5.5h16v11H9.5L5 20.5V16.5H4z"/>';
|
||||
|
||||
const zahl = el('span', 'chat-knopf__zahl');
|
||||
zahl.hidden = true;
|
||||
|
||||
a.append(blase, el('span', 'chat-knopf__text', 'Chat'), zahl);
|
||||
/* Vor der Suche: Der Chat ist das, was am ehesten auf eine Antwort
|
||||
wartet -- die Suche fängt man selbst an, wenn man sie braucht. */
|
||||
wohin.prepend(a);
|
||||
|
||||
/** Setzt die Zahl. Wird auch von chat.js aufgerufen, damit sie dort
|
||||
* sofort verschwindet, wenn man gelesen hat. */
|
||||
window.chatZahlZeigen = (n) => {
|
||||
const wert = Number(n) || 0;
|
||||
zahl.hidden = wert === 0;
|
||||
/* Über 99 wird die Zahl breiter als der Knopf -- und die genaue
|
||||
Menge interessiert ab da ohnehin niemanden mehr. */
|
||||
zahl.textContent = wert > 99 ? '99+' : String(wert);
|
||||
a.dataset.neu = wert > 0 ? 'ja' : '';
|
||||
a.setAttribute('aria-label', wert
|
||||
? `Chat, ${wert} ungelesene ${wert === 1 ? 'Nachricht' : 'Nachrichten'}`
|
||||
: 'Chat');
|
||||
};
|
||||
|
||||
async function holen() {
|
||||
try {
|
||||
const antwort = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' });
|
||||
if (!antwort.ok) return;
|
||||
window.chatZahlZeigen((await antwort.json()).ungelesen);
|
||||
} catch { /* die Zahl ist Zugabe, kein Grund für eine Fehlermeldung */ }
|
||||
}
|
||||
holen();
|
||||
|
||||
/* NEUE NACHRICHTEN KOMMEN VON SELBST -- derselbe Strom wie im Chat.
|
||||
Ohne ihn müsste man die Seite neu laden, um zu erfahren, dass
|
||||
jemand geschrieben hat. Kein Nachfragen im Takt: Bei zehn
|
||||
Angemeldeten wären das siebentausend Anfragen in der Stunde für
|
||||
eine Zahl, die sich selten ändert. */
|
||||
if (window.EventSource) {
|
||||
try {
|
||||
const strom = new EventSource('/workspace/api/chat/strom');
|
||||
strom.addEventListener('neu', holen);
|
||||
/* Nach einem Abriss verbindet der Browser selbst neu -- dann
|
||||
einmal nachzählen, was inzwischen kam. */
|
||||
strom.addEventListener('open', holen);
|
||||
} catch { /* dann eben nur beim Laden */ }
|
||||
}
|
||||
|
||||
/* Zurück an den Bildschirm: nachsehen. Fängt den Fall ab, dass der
|
||||
Strom währenddessen stillgelegt wurde. */
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) holen();
|
||||
});
|
||||
}
|
||||
|
||||
function aufbauSuche(wohin) {
|
||||
const el = (tag, klasse, text) => {
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -211,10 +211,10 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -134,10 +134,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-13
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -117,14 +117,14 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -76,11 +76,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Chat · Creator Workspace</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<!-- 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=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/chat.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
|
||||
<div class="schleier" aria-hidden="true"></div>
|
||||
|
||||
<header class="kopfleiste">
|
||||
<button type="button" class="zurueck-knopf" id="zurueck" hidden>
|
||||
<svg class="zurueck-knopf__pfeil" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14.5 5.5 8 12l6.5 6.5" />
|
||||
</svg>
|
||||
<span class="zurueck-knopf__text">Zurück</span>
|
||||
</button>
|
||||
<p class="marke"><span class="marke__text"><a class="zurueck" href="start.html">Creator Workspace</a> · Chat</span></p>
|
||||
<div class="kopfleiste__rechts">
|
||||
<span class="wer" id="wer">…</span>
|
||||
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="inhalt inhalt--breit chat-seite">
|
||||
|
||||
<!-- DIE GANZE SEITE IST EINE FLÄCHE, kein Dokument zum Scrollen.
|
||||
|
||||
Ein Chat ist kein Text, den man von oben nach unten liest --
|
||||
man springt zwischen Gesprächen, und das Eingabefeld muss
|
||||
immer erreichbar sein, ohne zu suchen. Deshalb: feste Höhe,
|
||||
und gescrollt wird nur INNERHALB der beiden Spalten.
|
||||
|
||||
data-ansicht steuert am Handy, welche Spalte zu sehen ist:
|
||||
"liste" oder "verlauf". Am Rechner stehen beide nebeneinander
|
||||
und das Merkmal bewirkt nichts. -->
|
||||
<div class="chat" id="chat" data-ansicht="liste">
|
||||
|
||||
<!-- ---------- Links: die Gespräche ---------- -->
|
||||
<section class="chat__spalte chat__spalte--liste" aria-labelledby="liste-titel">
|
||||
<div class="chat__kopf">
|
||||
<h1 class="chat__titel" id="liste-titel">Gespräche</h1>
|
||||
<button type="button" class="knopf knopf--klein" id="neu-oeffnen">Neu</button>
|
||||
</div>
|
||||
|
||||
<!-- Suchen erst, wenn es etwas zu suchen gibt (chat.js blendet
|
||||
es ab acht Gesprächen ein). Ein Suchfeld über drei Einträgen
|
||||
ist Zierde. -->
|
||||
<div class="chat__suche" id="suche-zeile" hidden>
|
||||
<input type="search" id="suche" placeholder="Name oder Gruppe …"
|
||||
aria-label="Gespräche durchsuchen" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<ul class="chat__liste" id="raeume" aria-live="polite"></ul>
|
||||
|
||||
<p class="chat__leer" id="liste-leer" hidden>
|
||||
Noch keine Gespräche. Mit <strong>Neu</strong> schreibst du der
|
||||
ersten Person.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ---------- Rechts: der Verlauf ---------- -->
|
||||
<section class="chat__spalte chat__spalte--verlauf" aria-labelledby="verlauf-titel">
|
||||
|
||||
<!-- Der Kopf trägt am Handy den Zurück-Weg zur Liste. -->
|
||||
<div class="chat__kopf chat__kopf--verlauf">
|
||||
<button type="button" class="chat__zurueck" id="zur-liste" aria-label="Zurück zu den Gesprächen">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14.5 5.5 8 12l6.5 6.5" /></svg>
|
||||
</button>
|
||||
<div class="chat__kopf-text">
|
||||
<h2 class="chat__titel" id="verlauf-titel">Wähle ein Gespräch</h2>
|
||||
<p class="chat__unter" id="verlauf-unter"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- aria-live="polite": Ein Vorleseprogramm sagt neue Nachrichten
|
||||
an, ohne den Nutzer mitten im Satz zu unterbrechen. -->
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Das Eingabefeld. Ein <form>, damit Enter abschickt, ohne
|
||||
dass dafür Code nötig wäre. -->
|
||||
<form class="chat__eingabe" id="schreiben" hidden>
|
||||
<label class="chat__eingabe-schild" for="text">Nachricht</label>
|
||||
<!-- KURZER PLATZHALTER. Hier stand der Hinweis "Enter schickt,
|
||||
Umschalt+Enter macht eine neue Zeile" -- zwei Fehler auf
|
||||
einmal: Am Handy brach er auf zwei Zeilen um und schob das
|
||||
Feld halb unter den Bildschirmrand, UND er stimmte dort
|
||||
nicht: Auf Geräten ohne Tastatur schickt Enter absichtlich
|
||||
nicht, sonst käme man nie zu einer zweiten Zeile.
|
||||
Die Tastenbelegung steht jetzt im title -- sie ist ein
|
||||
Hinweis für Wiederkehrer, keine Aufforderung. -->
|
||||
<textarea id="text" rows="1" maxlength="4000"
|
||||
placeholder="Nachricht schreiben …"
|
||||
title="Enter schickt ab, Umschalt+Enter macht eine neue Zeile"></textarea>
|
||||
<button type="submit" class="chat__senden" id="senden" aria-label="Abschicken">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 12l16-7-7 16-2.5-6.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Neues Gespräch. Beides in einem Dialog: Wer eine Person anklickt,
|
||||
schreibt ihr; wer mehrere anhakt, legt eine Gruppe an. Zwei
|
||||
getrennte Dialoge hätten die Frage "Einzeln oder Gruppe?" VOR die
|
||||
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">
|
||||
Eine Person antippen, um ihr zu schreiben. Mehrere auswählen ergibt
|
||||
eine Gruppe.
|
||||
</p>
|
||||
|
||||
<div class="chat-wahl" id="partner"></div>
|
||||
|
||||
<!-- Erscheint erst ab zwei Ausgewählten -- vorher wäre ein
|
||||
Gruppenname eine Frage ohne Anlass. -->
|
||||
<div class="neu__zeile" id="gruppenname-zeile" hidden>
|
||||
<label class="feld-schild" for="gruppenname">Name der Gruppe</label>
|
||||
<input id="gruppenname" maxlength="80" placeholder="z. B. Luna & Betreuung" />
|
||||
</div>
|
||||
|
||||
<p class="fehler" id="neu-fehler" role="alert" aria-live="polite"></p>
|
||||
|
||||
<div class="dialog__knoepfe">
|
||||
<button type="submit" class="knopf knopf--klein" id="neu-los" disabled>Schreiben</button>
|
||||
<button type="button" class="abmelden" id="neu-zu">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202609061424"></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424"></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424"></script>
|
||||
<script src="assets/js/chat.js?v=202609061424"></script>
|
||||
</body>
|
||||
</html>
|
||||
+11
-11
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -166,12 +166,12 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/content.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/content.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -95,10 +95,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="gate">
|
||||
@@ -145,6 +145,6 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/gate.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/gate.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -206,10 +206,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -123,10 +123,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -201,11 +201,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -93,11 +93,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/report.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/report.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -126,10 +126,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -160,10 +160,10 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/start.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/start.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -72,11 +72,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -113,10 +113,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -59,10 +59,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#0674b9" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609060056" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609061424" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609061424" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -177,10 +177,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609060056" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609061424" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609061424" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user