Chat zwischen Creator, Scouts und Managern
Wunsch Filipe, 06.09.2026: *"eine kategorie chat, wo die creator mit
ihren manager nachrichten austauschen koennen, wie erinnerungen, fragen
und noch vieles mehr."* Auf Nachfrage entschieden: Zweier-Gespraeche UND
Gruppen; Manager und Scouts duerfen sich auch ohne gemeinsamen Creator
schreiben.
WER MIT WEM steht an EINER Stelle: schreibbareIds() in workspace.js.
Bewusst getrennt von einladbareIds() (Terminwahl): Dort geht es darum,
wen man zu einem Kalendereintrag dazustellen darf -- harmlos. Ein Chat
legt ein dauerhaftes Gespraech an, schickt eine Benachrichtigung und
macht den eigenen Namen sichtbar. Zwei Fragen, zwei Funktionen.
Ein Creator erreicht seine Betreuer und DogFather, KEINEN fremden
Creator. Eine Gruppe ist kein Schlupfloch: Jede Nummer wird einzeln
gegen dieselbe Regel geprueft.
SOFORT STATT NACHFRAGEN IM TAKT (SSE). Ein Chat, der alle fuenf
Sekunden fragt, ist fuenf Sekunden langsam und stellt bei zehn
Angemeldeten 7200 Anfragen in der Stunde fuer Nachrichten, die es
meistens nicht gibt. Kein WebSocket: Hier fliesst alles in eine
Richtung, und SSE uebersteht einen Verbindungsabriss von selbst.
DIE ZAHL STEHT AUF JEDER SEITE, nicht nur im Chat. Eine Nachricht, die
man erst sieht, wenn man den Chat aufmacht, ist keine Nachricht --
sie ist ein Fundstueck.
Push aufs Handy nur an die, die NICHT gerade zusehen. Wer die Seite
offen hat, sieht es ohnehin; ihm zusaetzlich etwas aufs Handy zu
schicken ist der schnellste Weg, dass er Benachrichtigungen abschaltet.
WAS BEWUSST NICHT GEHT:
* Niemand liest fremde Gespraeche mit -- auch DogFather nicht. Ein
Chat, in dem der Chef stillschweigend mitliest, ist kein Chat.
Er kann jederzeit jedem schreiben, aber sichtbar.
* Eine abgeschickte Nachricht laesst sich nicht aendern, nur
zuruecknehmen. Wer Absprachen nachtraeglich umschreiben kann, macht
den Verlauf wertlos. Zurueckgenommenes hinterlaesst einen Hinweis
statt eines Lochs.
DAZU: CALL-LISTE NACH ZEIT GEGLIEDERT
Wunsch mit Bild von "STEHT AN 27": *"die liste soll kategorisiert sein
und werden mit einem button zum auf und zu machen."* Jetzt Heute /
Diese Woche / Naechste Woche / Spaeter, offen nur der erste gefuellte
Block. Nach ZEIT und nicht nach Titel: Auf dem Bild waren fast alle
"Community-Talk" -- nach Titel gruppiert haette man zwei Ueberschriften
und darunter dieselbe lange Liste.
DER FUND, DER DIE FUNKTION GERETTET HAT: Ein zugeklappter Block zeigte
seinen Inhalt trotzdem -- 367 Pixel Hoehe bei open=false. <details>
versteckt seinen Inhalt naemlich nur, solange niemand dem Inhalt eine
eigene display-Angabe gibt, und .gruppe__karten traegt display:grid.
Der Knopf haette sich bewegt und sonst nichts getan. Im Bildschirmfoto
fiel es nicht auf, weil der Ausschnitt genau darueber endete.
Ausserdem gefunden und behoben:
* Ein Wettlauf beim Abschicken: War der Ereignisstrom schneller als
die Antwort auf das POST, stand die eigene Nachricht zweimal im
Verlauf.
* Die Chatseite war zu hoch -- die Kopfleistenhoehe stand als fester
Wert (62 px) im CSS, am Handy ist sie doppelt so hoch. Das
Eingabefeld stand halb unter dem Bildschirmrand. Jetzt gemessen.
* Der Platzhalter "Enter schickt ..." brach am Handy um UND stimmte
dort nicht: Ohne Tastatur schickt Enter absichtlich nicht.
* Der Dialog benutzte Klassen aus aufgaben.css, das chat.html nicht
lud -- gemeldet von pruef-css-klassen, bevor jemand einen
ungestylten Dialog zu sehen bekam.
Pruefungen: pruef-chat (44 Punkte, Rechte und Sichtbarkeit),
pruef-chat-optik (24, zwei echte Browser schreiben sich),
pruef-call-kategorien (16).
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -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);
|
||||
@@ -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));
|
||||
const box = el('div', 'gruppe__karten');
|
||||
for (const c of liste) box.append(karte(c, g.schluessel));
|
||||
s.append(box);
|
||||
|
||||
/* 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