From 47269bda212c9aa15ec0aef1c14cd6f0b5c77c80 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 14 Sep 2026 13:46:31 +0200 Subject: [PATCH] Chat: auf eine Nachricht reagieren, ohne zu antworten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filipe: "ich will dass man auf die nachrichten auch reagieren kann mit einem emoji, die nachricht selbst ohne zu antworten." Neben "antworten" steht jetzt "reagieren". Ein Klick klappt sechs Zeichen auf, ein zweiter Klick setzt es -- und unter der Nachricht steht, wer mit was reagiert hat. EINE FESTE AUSWAHL, KEIN FREIES FELD (👍 ❤️ 😂 😮 🔥 🙏). Drei Gruende, der erste ist der wichtigste: Eine feste Liste laesst sich pruefen -- was dort nicht drinsteht, kommt nicht in die Datenbank, kein beliebiger Text, keine 4-KB-Zeichenketten. Zweitens sehen alle dasselbe. Drittens kann man sechs Zeichen ueberfliegen; eine volle Tafel ist eine Suchaufgabe, und dann tippt man doch wieder "ok". DIE LISTE STEHT AUF DEM SERVER und wird an die Oberflaeche geliefert. Stuende sie im Browser, gaebe es sie zweimal -- und die zweite Fassung waere die, in der ein Zeichen fehlt, das der Server noch annimmt. Faellt der Abruf aus, erscheint KEINE Auswahl statt einer mit erfundenen Zeichen. DER PRIMAERSCHLUESSEL BEANTWORTET "WIE OFT?": (nachricht, person, zeichen). Eine Person darf verschiedene Zeichen geben, jedes aber nur einmal -- und zwar durchgesetzt von der Datenbank, nicht von einer Abfrage davor, die man vergessen kann. Derselbe Klick noch einmal nimmt es zurueck; das erspart einen zweiten Weg, den man auch absichern muesste. KEIN ZAEHLER IN chat_nachrichten. Eine mitgefuehrte Zahl muesste bei jedem Hin und Her stimmen und ist genau die Art Angabe, die irgendwann nicht mehr stimmt und die niemand nachrechnet. Gezaehlt wird beim Lesen -- in EINER Abfrage fuer alle 200 Nachrichten, nicht je Zeile eine. WER ES WAR, STEHT IM TITEL. "Drei Daumen" sagt weniger als "Ayla, Ben und Cem", und es sind Leute aus demselben Raum. KEINE BENACHRICHTIGUNG AUFS HANDY. Wer im Raum ist, sieht es sofort; wer nicht, bekommt nichts. Ein Daumen ist keine Nachricht -- wer dafuer geweckt wird, schaltet Meldungen ab. Was NICHT geht: wer nicht im Raum ist (404), ein erfundenes Zeichen (400), eine zurueckgenommene Nachricht (409). Und mit der Nachricht verschwinden die Reaktionen (CASCADE). pruef-chat-aufloesen 59 -> 85. Gemessen wird der BESTAND, nicht die Antwort -- und im Browser der KNOPF, nicht nur das Recht: dass die Auswahl aufklappt, sich nach dem Klick von selbst schliesst, die Reaktion darunter erscheint, als die eigene erkennbar ist und ein Klick darauf sie wieder wegnimmt. EINE EIGENE PRUEFUNG WAR ZU SCHWACH: "bei Cem ist ueberall selbst=true" war trivial wahr, weil es nur EINE Sorte gab. Jetzt bekommt eine andere Person ein drittes Zeichen, und in derselben Antwort muss eines auf true und eines auf false stehen. Eine Angabe, die nie `false` sein kann, sagt nichts. Im CSS keine vierte Abschrift: "reagieren" haengt an derselben Regel wie "antworten" -- neben anheften und kopieren waere es die vierte fast gleiche gewesen. Co-Authored-By: Claude Opus 5 --- server/pruef-chat-aufloesen.mjs | 183 ++++++++++++++++++++++++++++++++ server/workspace-chat.js | 126 +++++++++++++++++++++- server/workspace.js | 30 ++++++ workspace/app.webmanifest | 8 +- workspace/assets/css/chat.css | 75 ++++++++++++- workspace/assets/js/chat.js | 154 +++++++++++++++++++++++++++ workspace/aufgaben.html | 24 ++--- workspace/automation.html | 26 ++--- workspace/bereich.html | 34 +++--- workspace/calls.html | 28 ++--- workspace/chat.html | 26 ++--- workspace/content.html | 30 +++--- workspace/crew-index.html | 10 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 26 ++--- workspace/entwicklung.html | 24 ++--- workspace/index.html | 8 +- workspace/kalender.html | 28 ++--- workspace/leistung.html | 26 ++--- workspace/personen.html | 26 ++--- workspace/profil.html | 28 ++--- workspace/rechte.html | 24 ++--- workspace/report.html | 28 ++--- workspace/scouting.html | 26 ++--- workspace/start.html | 24 ++--- workspace/startcheck.html | 28 ++--- workspace/steckbrief.html | 26 ++--- workspace/talente.html | 24 ++--- workspace/team.html | 26 ++--- workspace/teamlage.html | 24 ++--- workspace/treff-moderation.html | 24 ++--- workspace/treff-regeln.html | 24 ++--- workspace/uebersicht.html | 26 ++--- workspace/wissen.html | 26 ++--- 34 files changed, 909 insertions(+), 349 deletions(-) diff --git a/server/pruef-chat-aufloesen.mjs b/server/pruef-chat-aufloesen.mjs index 70db66f9..5fec354c 100644 --- a/server/pruef-chat-aufloesen.mjs +++ b/server/pruef-chat-aufloesen.mjs @@ -383,6 +383,123 @@ console.log("=== Anhaenge ==="); "der Anhang-Ordner selbst steht noch (die Pruefung misst die Datei, nicht den Ordner)"); } +/* ======================================================================= + 7b. REAKTIONEN — auf die Nachricht, ohne zu antworten (14.09.2026) + ======================================================================= + Filipe: "ich will dass man auf die nachrichten auch reagieren kann + mit einem emoji, die nachricht selbst ohne zu antworten." + + GEMESSEN WIRD DER BESTAND, nicht die Antwort. Ein "ok" sagt nur, + dass der Server nicht gestolpert ist; ob die Reaktion wirklich steht + (und beim zweiten Klick wirklich weg ist), steht in der Datenbank. + ======================================================================= */ +console.log(""); +console.log("=== Reaktionen ==="); +{ + const g = await (await rufe(eins.keks, "/workspace/api/chat/gruppe", + { mit: [idZwei, idDrei], name: "Reaktionsrunde" }, "POST")).json(); + const n = await (await rufe(eins.keks, + `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`, { text: "Moin" }, "POST")).json(); + const nid = n.nachricht?.id; + ok(Number.isInteger(nid), `eine Nachricht zum Reagieren (#${nid})`); + + const auswahl = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl"); + ok(Array.isArray(auswahl.zeichen) && auswahl.zeichen.length >= 4, + `die Auswahl kommt vom Server (${(auswahl.zeichen || []).length} Zeichen)`); + const daumen = auswahl.zeichen[0]; + const herz = auswahl.zeichen[1]; + + const zaehle = () => d.prepare( + "SELECT COUNT(*) AS n FROM chat_reaktionen WHERE nachricht_id = ?").get(nid).n; + + /* ---- Setzen ---------------------------------------------------- */ + const a1 = await (await rufe(zwei.keks, + `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT")).json(); + ok(zaehle() === 1, `Ben setzt ein Zeichen -- es steht in der Datenbank (${zaehle()})`); + ok(a1.reaktionen?.[0]?.anzahl === 1 && a1.reaktionen?.[0]?.selbst === true, + "die Antwort nennt Anzahl und dass es seines ist"); + + /* ---- Derselbe Mensch, ZWEITES Zeichen -- das geht --------------- */ + await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: herz }, "PUT"); + ok(zaehle() === 2, `dasselbe Mensch darf ein ZWEITES Zeichen geben (${zaehle()})`); + + /* ---- Derselbe Mensch, DASSELBE Zeichen -- nimmt zurueck --------- */ + const a3 = await (await rufe(zwei.keks, + `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT")).json(); + ok(zaehle() === 1, `zweimal dasselbe nimmt es wieder weg (${zaehle()})`); + ok(a3.gesetzt === false, "und die Antwort sagt, dass es weg ist"); + + /* ---- Mehrere Menschen, dasselbe Zeichen ------------------------- */ + await rufe(drei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: herz }, "PUT"); + const stand = await (await rufe(eins.keks, + `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: herz }, "PUT")).json(); + const herzEintrag = (stand.reaktionen || []).find((r) => r.zeichen === herz); + ok(herzEintrag?.anzahl === 3, `drei Menschen, ein Zeichen, Anzahl 3 (${herzEintrag?.anzahl})`); + ok(Array.isArray(herzEintrag?.wer) && herzEintrag.wer.length === 3, + `und die Namen stehen dabei (${(herzEintrag?.wer || []).join(", ")})`); + + /* ---- Was NICHT geht --------------------------------------------- */ + const fremd = await rufe(hand.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: daumen }, "PUT"); + ok(fremd.status === 404, + `wer nicht im Raum ist, kommt nicht heran (HTTP ${fremd.status})`); + + const unsinn = await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: "" }, "PUT"); + ok(unsinn.status === 400, + `ein erfundenes Zeichen wird abgewiesen (HTTP ${unsinn.status})`); + ok(zaehle() === 3, `und aendert nichts am Bestand (${zaehle()})`); + + /* ---- Eine zurueckgenommene Nachricht ----------------------------- */ + const weg = await (await rufe(eins.keks, + `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`, { text: "Weg gleich" }, "POST")).json(); + await rufe(eins.keks, `/workspace/api/chat/nachrichten/${weg.nachricht.id}`, + undefined, "DELETE"); + const drauf = await rufe(zwei.keks, + `/workspace/api/chat/nachrichten/${weg.nachricht.id}/reaktion`, { zeichen: daumen }, "PUT"); + ok(drauf.status === 409, + `auf eine zurueckgenommene Nachricht geht es nicht (HTTP ${drauf.status})`); + + /* ---- Und sie ueberleben das Neuladen ----------------------------- */ + const verlauf = await holen(zwei.keks, + `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`); + const wieder = (verlauf.nachrichten || []).find((x) => x.id === nid); + ok(Array.isArray(wieder?.reaktionen) && wieder.reaktionen.length === 1, + `beim Neuladen sind sie wieder da (${(wieder?.reaktionen || []).length} Sorte)`); + ok(wieder?.reaktionen?.[0]?.selbst === true, + "und Ben sieht, dass eines davon seines ist"); + + /* GEGENPROBE, DIE WIRKLICH UNTERSCHEIDET. + + Der erste Entwurf pruefte "bei Cem ist ueberall selbst=true" -- und + das war trivial wahr, weil es zu dem Zeitpunkt nur EINE Sorte gab. + Eine Angabe, die nie `false` sein kann, sagt nichts. + + Also bekommt Ayla ein drittes Zeichen, das Cem NICHT gegeben hat. + Jetzt muss bei Cem eines auf `true` und eines auf `false` stehen -- + in derselben Antwort. */ + const lachen = auswahl.zeichen[2]; + await rufe(eins.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: lachen }, "PUT"); + const beiDrei = await holen(drei.keks, + `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`); + const seins = (beiDrei.nachrichten || []).find((x) => x.id === nid); + const meins = (seins?.reaktionen || []).find((r) => r.zeichen === herz); + const fremdes = (seins?.reaktionen || []).find((r) => r.zeichen === lachen); + ok(meins?.selbst === true && fremdes?.selbst === false, + `bei Cem: sein Herz ist "selbst", Aylas Lachen nicht ` + + `(${meins?.selbst} / ${fremdes?.selbst})`); + + /* ---- Die Nachricht loeschen nimmt die Reaktionen mit ------------- */ + const vorherAnzahl = zaehle(); + ok(vorherAnzahl > 0, `vorher stehen ${vorherAnzahl} Reaktionen da`); + d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(nid); + ok(zaehle() === 0, + `mit der Nachricht verschwinden die Reaktionen (${zaehle()} uebrig)`); +} + /* ======================================================================= 8. Und wie es auf dem Bildschirm aussieht ======================================================================= @@ -459,9 +576,75 @@ console.log("=== Das Neu-Fenster ==="); if (process.env.SCHIRM) { await seite.screenshot({ path: join(HIER_BILD, "pruef-chat-neu.png") }); } + await seite.click("#neu-zu"); await seite.waitForTimeout(250); + /* ---- REAGIEREN AUF DEM BILDSCHIRM (14.09.2026) ------------------ + Das Recht und der Weg sind oben geprueft. Ob die Oberflaeche + beides verbindet, ist eine eigene Frage -- genau daran ist am + 11.09. der Aufloesen-Knopf gescheitert: Recht richtig, Knopf + unsichtbar. */ + const rRaum = await (await rufe(hand.keks, "/workspace/api/chat/gruppe", + { mit: [idEins], name: "Reagieren sichtbar" }, "POST")).json(); + await rufe(hand.keks, `/workspace/api/chat/raeume/${rRaum.raum_id}/nachrichten`, + { text: "Reagier mal drauf" }, "POST"); + await seite.goto(BASIS + `/workspace/chat.html?raum=${rRaum.raum_id}`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(1500); + + const knopfDa = await seite.$$eval(".chat-nachricht__reag-knopf", (b) => b.length); + ok(knopfDa > 0, `der Knopf "reagieren" steht an der Nachricht (${knopfDa})`); + + await seite.click(".chat-nachricht__reag-knopf"); + await seite.waitForTimeout(600); + const tafel = await seite.$$eval(".reagieren-tafel__zeichen", + (b) => b.map((x) => x.textContent.trim())); + ok(tafel.length >= 4, `die Auswahl klappt auf (${tafel.length} Zeichen)`); + const tafelLage = await seite.evaluate(() => { + const t = document.querySelector(".reagieren-tafel"); + const z = document.querySelector(".reagieren-tafel__zeichen"); + const k = (e) => { if (!e) return null; const r = e.getBoundingClientRect(); + const st = getComputedStyle(e); + return { b: Math.round(r.width), h: Math.round(r.height), d: st.display, + sicht: st.visibility, o: st.opacity, eltern: e.parentElement?.className }; }; + return { tafel: k(t), zeichen: k(z) }; + }); + ok(tafelLage.zeichen && tafelLage.zeichen.b > 0 && tafelLage.zeichen.h > 0, + `die Zeichen haben eine Groesse (${JSON.stringify(tafelLage)})`); + + if (process.env.SCHIRM) { + await seite.locator(".chat-nachricht").last().screenshot( + { path: join(HIER_BILD, "pruef-chat-auswahl.png") }); + } + + await seite.click(".reagieren-tafel__zeichen"); + await seite.waitForTimeout(900); + const stueck = await seite.evaluate(() => { + const b = document.querySelector(".reaktionen__stueck"); + return b ? { text: b.textContent.trim(), selbst: b.dataset.selbst, + titel: b.title } : null; + }); + ok(!!stueck, `unter der Nachricht steht die Reaktion ("${stueck?.text || "nichts"}")`); + ok(stueck?.selbst === "ja", "und sie ist als die eigene gekennzeichnet"); + ok((stueck?.titel || "").includes("Vanessa"), + `im Titel steht, wer reagiert hat ("${(stueck?.titel || "").slice(0, 40)}…")`); + ok((await seite.$$(".reagieren-tafel")).length === 0, + "die Auswahl schliesst sich nach dem Klick von selbst"); + + if (process.env.SCHIRM) { + await seite.locator(".chat-nachricht").last().screenshot( + { path: join(HIER_BILD, "pruef-chat-reaktion.png") }); + } + + /* ZWEITER KLICK NIMMT SIE ZURUECK -- an derselben Stelle, wo sie + steht. Ohne diese Zeile hiesse alles oben nur, dass man etwas + hinzufuegen kann. */ + await seite.click(".reaktionen__stueck"); + await seite.waitForTimeout(900); + ok((await seite.$$(".reaktionen__stueck")).length === 0, + "ein Klick darauf nimmt sie wieder weg"); + /* ---- UND DER KNOPF SELBST ----------------------------------------- Bis hierher ist nur das RECHT geprueft (darf_aufloesen) und der Weg dahinter. Ob die Oberflaeche beides verbindet, war unbewiesen diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 94fad61d..b4a088c3 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -874,7 +874,14 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { || (a.anhang_art === "bild" ? "Foto" : a.anhang_art === "pdf" ? "PDF" : ""), })), /* Aufsteigend zurück -- gelesen wird von oben nach unten. */ - nachrichten: reihen.reverse().map((n) => ({ + /* Die Reaktionen in EINER Abfrage fuer alle Nachrichten -- nicht + je Zeile eine. Bei 200 Zeilen waeren das 200 Abfragen fuer eine + Ansicht, die man beim Scrollen staendig neu holt. */ + nachrichten: (() => { + const gedreht = reihen.reverse(); + const reakt = reaktionenZu(gedreht.map((n) => n.id), req.person.id); + return gedreht.map((n) => ({ + reaktionen: reakt.get(n.id) || [], id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle, von_bild: n.von_bild ? `/workspace/api/steckbrief/bild/${n.von_bild}` : null, text: n.weg_am ? null : n.text, @@ -904,7 +911,8 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { breite: n.anhang_breite || 0, hoehe: n.anhang_hoehe || 0, weg: `/workspace/api/chat/anhang/${n.id}`, } : null, - })), + })); + })(), }); } catch (fehler) { console.error("[chat] Verlauf:", fehler?.message); @@ -1193,6 +1201,120 @@ function darfAufloesen(raum, person) { && !!raum.erstellt_von && raum.erstellt_von === person.id; } +/* ===================================================================== + REAKTIONEN — auf die Nachricht, ohne zu antworten (14.09.2026) + + Filipe: "ich will dass man auf die nachrichten auch reagieren kann + mit einem emoji, die nachricht selbst ohne zu antworten." + + EINE FESTE AUSWAHL, KEIN FREIES FELD. Sechs Zeichen, hier + aufgeschrieben. Drei Gruende, und der erste ist der wichtigste: + + * Eine feste Liste laesst sich pruefen. Was hier nicht drinsteht, + kommt nicht in die Datenbank -- kein beliebiger Text, keine + Zeichenketten von 4 KB, keine unsichtbaren Steuerzeichen. + * Alle sehen dasselbe. Ein selbst getipptes Zeichen sieht auf + einem anderen Geraet anders aus oder gar nicht. + * Sechs Zeichen kann man ueberfliegen. Eine volle Emoji-Tafel ist + eine Suchaufgabe, und dann tippt man doch wieder "ok". + + DIE LISTE STEHT AUF DEM SERVER und wird an die Oberflaeche geliefert + (/reaktionen/auswahl). Stuende sie im Browser, gaebe es sie zweimal + -- und die zweite Fassung waere die, in der ein Zeichen fehlt, das + der Server noch annimmt. + ===================================================================== */ +export const REAKTIONEN = ["👍", "❤️", "😂", "😮", "🔥", "🙏"]; + +chatRouter.get("/workspace/api/chat/reaktionen/auswahl", (req, res) => { + res.json({ zeichen: REAKTIONEN }); +}); + +/** Alle Reaktionen zu einer Menge von Nachrichten -- in EINER Abfrage. + * + * Je Nachricht einzeln zu fragen waere bei 200 Nachrichten 200 + * Abfragen fuer eine Ansicht. Zurueck kommt eine Karte: + * nachricht_id -> [{ zeichen, anzahl, selbst, wer }]. + * + * `wer` ist bewusst dabei: "drei Daumen" sagt weniger als "Ayla, Ben + * und Cem". Es sind Namen aus DEMSELBEN Raum -- wer die Nachricht + * sieht, sieht die Leute ohnehin. + */ +function reaktionenZu(ids, personId) { + const karte = new Map(); + if (!ids.length) return karte; + const platz = ids.map(() => "?").join(","); + const reihen = db().prepare(` + SELECT r.nachricht_id, r.zeichen, r.person_id, p.name + FROM chat_reaktionen r + LEFT JOIN personen p ON p.id = r.person_id + WHERE r.nachricht_id IN (${platz}) + ORDER BY r.wann`).all(...ids); + for (const z of reihen) { + if (!karte.has(z.nachricht_id)) karte.set(z.nachricht_id, new Map()); + const proNachricht = karte.get(z.nachricht_id); + if (!proNachricht.has(z.zeichen)) { + proNachricht.set(z.zeichen, { zeichen: z.zeichen, anzahl: 0, selbst: false, wer: [] }); + } + const eintrag = proNachricht.get(z.zeichen); + eintrag.anzahl++; + if (z.person_id === personId) eintrag.selbst = true; + eintrag.wer.push(z.name || "Gelöscht"); + } + const fertig = new Map(); + for (const [id, proNachricht] of karte) fertig.set(id, [...proNachricht.values()]); + return fertig; +} + +/* REAGIEREN IST EIN UMSCHALTER, kein Hinzufuegen. + Zweimal dasselbe Zeichen heisst: wieder weg. Das erspart einen + zweiten Weg zum Zuruecknehmen -- und einen zweiten Weg, den man + vergisst abzusichern. */ +chatRouter.put("/workspace/api/chat/nachrichten/:id/reaktion", gleicheHerkunft, + express.json({ limit: "2kb" }), (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const zeichen = String(req.body?.zeichen ?? ""); + if (!REAKTIONEN.includes(zeichen)) { + return res.status(400).json({ fehler: "unbekanntes_zeichen" }); + } + + const d = db(); + const n = d.prepare( + "SELECT id, raum_id, weg_am FROM chat_nachrichten WHERE id = ?").get(id); + if (!n) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Die Raumzugehoerigkeit ist die ganze Sicherheit dieses Wegs -- + dieselbe Pruefung wie beim Lesen des Verlaufs. */ + if (!istDrin(n.raum_id, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Auf eine zurueckgenommene Nachricht wird nicht reagiert. Dort + steht nichts mehr, worauf man reagieren koennte. */ + if (n.weg_am) return res.status(409).json({ fehler: "zurueckgenommen" }); + + const da = d.prepare(`SELECT 1 FROM chat_reaktionen + WHERE nachricht_id = ? AND person_id = ? AND zeichen = ?`) + .get(id, req.person.id, zeichen); + if (da) { + d.prepare(`DELETE FROM chat_reaktionen + WHERE nachricht_id = ? AND person_id = ? AND zeichen = ?`) + .run(id, req.person.id, zeichen); + } else { + d.prepare(`INSERT INTO chat_reaktionen (nachricht_id, person_id, zeichen, wann) + VALUES (?,?,?,?)`).run(id, req.person.id, zeichen, jetzt()); + } + + const stand = reaktionenZu([id], req.person.id).get(id) || []; + /* ALLE IM RAUM SEHEN ES SOFORT -- ohne Benachrichtigung aufs + Handy. Ein Daumen ist keine Nachricht; wer dafuer eine Meldung + bekaeme, schaltet Meldungen ab. */ + chatEreignis(n.raum_id, { id, raum_id: n.raum_id, reaktionen: stand }, [], "reaktion"); + + res.json({ id, reaktionen: stand, gesetzt: !da }); + } catch (fehler) { + console.error("[chat] Reaktion:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + /* ===================================================================== EINE GRUPPE ODER EINEN KANAL AUFLOESEN — FUER ALLE (11.09.2026) diff --git a/server/workspace.js b/server/workspace.js index a036e463..a774a9b4 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -2853,6 +2853,36 @@ export function db() { CREATE INDEX IF NOT EXISTS idx_chat_nachrichten_raum ON chat_nachrichten (raum_id, id); + /* REAKTIONEN (14.09.2026). + + Filipe: "ich will dass man auf die nachrichten auch reagieren + kann mit einem emoji, die nachricht selbst ohne zu antworten." + + DER SCHLUESSEL IST DIE ANTWORT AUF "WIE OFT?": Eine Person darf + dieselbe Nachricht mit VERSCHIEDENEN Zeichen bedenken, aber + jedes nur EINMAL. Genau das sagt der zusammengesetzte + Primaerschluessel -- und zwar so, dass die Datenbank es + durchsetzt und nicht eine Abfrage davor, die man vergessen + kann. Ein zweiter Klick auf dasselbe Zeichen nimmt es zurueck. + + ON DELETE CASCADE an beiden Enden: Wird die Nachricht geloescht + oder der Mensch entfernt, geht die Reaktion mit. Eine Reaktion + ohne Nachricht waere eine Zeile, die niemand je wiederfindet. + + WARUM KEIN ZAEHLER IN chat_nachrichten: Eine mitgefuehrte Zahl + muesste bei jedem Hin und Her stimmen -- und sie ist genau die + Art Angabe, die irgendwann nicht mehr stimmt und die niemand + nachrechnet. Gezaehlt wird beim Lesen. */ + CREATE TABLE IF NOT EXISTS chat_reaktionen ( + nachricht_id INTEGER NOT NULL REFERENCES chat_nachrichten(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + zeichen TEXT NOT NULL, + wann TEXT NOT NULL, + PRIMARY KEY (nachricht_id, person_id, zeichen) + ); + CREATE INDEX IF NOT EXISTS idx_chat_reaktionen_nachricht + ON chat_reaktionen (nachricht_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 diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index f336b67a..df6b4d39 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609141201", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609141346", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609141201", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609141346", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609141201", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609141346", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609141201", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609141346", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css index c958dc60..af1a1e47 100644 --- a/workspace/assets/css/chat.css +++ b/workspace/assets/css/chat.css @@ -826,13 +826,21 @@ /* Der Antworten-Knopf am Fuß einer Nachricht -- genauso leise wie "zurücknehmen" daneben. Er ist ein Angebot, keine Aufforderung. */ -.chat-nachricht__antwort-knopf { +/* "reagieren" steht seit dem 14.09.2026 daneben und sieht genauso aus + -- dieselbe Sorte Handgriff, dieselbe Zeile. Deshalb DIESELBE Regel + und keine vierte Abschrift: Neben antworten, anheften und kopieren + waere sie die vierte fast gleiche, und die vierte ist die, die + irgendwann eine Zeile anders hat. */ +.chat-nachricht__antwort-knopf, +.chat-nachricht__reag-knopf { border: 0; background: none; padding: 0; font: inherit; font-size: .72rem; color: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } -.chat-nachricht__antwort-knopf:hover { color: var(--text); } +.chat-nachricht__antwort-knopf:hover, +.chat-nachricht__reag-knopf:hover { color: var(--text); } +.chat-nachricht__reag-knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } /* ===================================================================== CHAT: SECHS ERGÄNZUNGEN (09.09.2026) @@ -968,6 +976,69 @@ sich zwei auto-Raender den freien Platz und reissen sie auseinander. Welche sichtbar sind, entscheidet der Server; eine Regel, die das aushaelt, ist besser als eine Zahl, die fuer einen Fall stimmt. */ +/* ===================================================================== + REAKTIONEN (14.09.2026) + + Filipe: "ich will dass man auf die nachrichten auch reagieren kann + mit einem emoji, die nachricht selbst ohne zu antworten." + + GEDECKT, NICHT BUNT. Die Zeichen bringen ihre eigene Farbe mit -- + ein farbiger Rahmen darum waere zu viel fuer eine Zeile, die unter + jeder zweiten Nachricht stehen kann. Die eigene Reaktion ist am + Rahmen zu erkennen, nicht an einem Leuchten. + ===================================================================== */ +.reaktionen { + display: flex; flex-wrap: wrap; gap: 5px; + margin: 7px 0 0; +} +.reaktionen__stueck { + display: inline-flex; align-items: center; gap: 5px; + padding: 2px 8px 3px; + border: 1px solid var(--rand); border-radius: 999px; + background: rgba(255, 255, 255, .04); + font: inherit; font-size: .8rem; line-height: 1.4; + color: var(--text-leise); cursor: pointer; + transition: border-color var(--tempo), background var(--tempo); +} +.reaktionen__stueck:hover { border-color: var(--rand-hell); } +/* Die EIGENE Reaktion: ein Rahmen in der Hausfarbe. Damit sieht man + auf einen Blick, wo man selbst schon geklickt hat -- und ein zweiter + Klick nimmt sie zurueck. */ +.reaktionen__stueck[data-selbst="ja"] { + border-color: color-mix(in srgb, var(--akzent) 55%, transparent); + background: color-mix(in srgb, var(--akzent) 12%, transparent); + color: var(--text); +} +.reaktionen__stueck:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +.reaktionen__zeichen { font-size: .95rem; line-height: 1; } +.reaktionen__zahl { font-variant-numeric: tabular-nums; } + +/* Die Auswahl klappt AN der Nachricht auf, nicht in einem Fenster. + Wer ein Zeichen anklickt, ist in zwei Sekunden fertig; ein Dialog + dafuer waere mehr Weg als Zweck. */ +.reagieren-tafel { + display: flex; gap: 2px; + margin: 7px 0 0; + padding: 4px; + border: 1px solid var(--rand); border-radius: 999px; + background: var(--flaeche-hoch, rgba(255, 255, 255, .05)); + width: max-content; max-width: 100%; + box-shadow: 0 6px 18px -10px rgba(0, 0, 0, .8); +} +.reagieren-tafel__zeichen { + appearance: none; border: 0; border-radius: 999px; + padding: 4px 7px; + background: none; cursor: pointer; + font-size: 1.05rem; line-height: 1; + transition: background var(--tempo), transform var(--tempo); +} +.reagieren-tafel__zeichen:hover { background: rgba(255, 255, 255, .09); transform: scale(1.12); } +.reagieren-tafel__zeichen:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +@media (prefers-reduced-motion: reduce) { + .reagieren-tafel__zeichen:hover { transform: none; } + .reagieren-tafel__zeichen, .reaktionen__stueck { transition: none; } +} + .chat__kopf-knoepfe { flex: none; margin-left: auto; display: flex; align-items: center; gap: 6px; diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js index f8adcb0c..ca8d2f77 100644 --- a/workspace/assets/js/chat.js +++ b/workspace/assets/js/chat.js @@ -428,6 +428,124 @@ } catch { /* umgeleitet */ } } + /* =================================================================== + REAKTIONEN (14.09.2026) + + Filipe: "ich will dass man auf die nachrichten auch reagieren kann + mit einem emoji, die nachricht selbst ohne zu antworten." + + DIE AUSWAHL KOMMT VOM SERVER. Stuende sie hier, gaebe es sie + zweimal -- und die zweite Fassung ist die, in der ein Zeichen + fehlt, das der Server noch annimmt. Faellt der Abruf aus, gibt es + keinen Ersatz aus dem Aermel: Dann erscheint die Auswahl nicht, + statt Zeichen anzubieten, die abgewiesen werden. + =================================================================== */ + let reaktionsZeichen = null; + let offeneAuswahl = null; + + async function zeichenHolen() { + if (reaktionsZeichen) return reaktionsZeichen; + try { + const a = await hole('/workspace/api/chat/reaktionen/auswahl'); + if (!a.ok) return null; + const d = await a.json(); + reaktionsZeichen = Array.isArray(d.zeichen) && d.zeichen.length ? d.zeichen : null; + return reaktionsZeichen; + } catch { return null; } + } + + function auswahlSchliessen() { + if (offeneAuswahl) { offeneAuswahl.remove(); offeneAuswahl = null; } + } + + async function auswahlZeigen(knopf, nachrichtId) { + /* Ein zweiter Klick auf denselben Knopf schliesst wieder -- sonst + staendig eine offene Tafel, die man wegklicken muss. */ + const schonOffen = offeneAuswahl && offeneAuswahl.dataset.fuer === String(nachrichtId); + auswahlSchliessen(); + if (schonOffen) return; + + const zeichen = await zeichenHolen(); + if (!zeichen) { melde('Die Auswahl ließ sich nicht laden.'); return; } + + const tafel = el('div', 'reagieren-tafel'); + tafel.dataset.fuer = String(nachrichtId); + tafel.setAttribute('role', 'group'); + tafel.setAttribute('aria-label', 'Mit einem Zeichen reagieren'); + for (const z of zeichen) { + const b = el('button', 'reagieren-tafel__zeichen', z); + b.type = 'button'; + b.setAttribute('aria-label', `Mit ${z} reagieren`); + b.addEventListener('click', () => { auswahlSchliessen(); reagieren(nachrichtId, z); }); + tafel.append(b); + } + knopf.closest('.chat-nachricht')?.append(tafel); + offeneAuswahl = tafel; + tafel.querySelector('button')?.focus(); + } + + /* Wegklicken und Escape schliessen die Tafel. Ohne das bleibt sie + stehen, wenn man es sich anders ueberlegt -- und sie sieht dann + aus wie ein Fehler. */ + document.addEventListener('click', (e) => { + if (!offeneAuswahl) return; + if (e.target.closest('.reagieren-tafel, .chat-nachricht__reag-knopf')) return; + auswahlSchliessen(); + }); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') auswahlSchliessen(); }); + + async function reagieren(nachrichtId, zeichen) { + try { + const a = await hole(`/workspace/api/chat/nachrichten/${nachrichtId}/reaktion`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ zeichen }), + }); + if (!a.ok) { + const d = await a.json().catch(() => ({})); + melde(d.fehler === 'zurueckgenommen' + ? 'Auf eine zurückgenommene Nachricht lässt sich nicht reagieren.' + : 'Das Zeichen kam nicht an.'); + setTimeout(() => melde(''), 4000); + return; + } + const d = await a.json(); + /* Sofort im Bild -- der Ereignisstrom bringt dasselbe gleich noch + einmal fuer die anderen. Wer selbst klickt, soll nicht auf das + Netz warten muessen. */ + standSetzen(d.id, d.reaktionen); + } catch { melde('Keine Verbindung.'); } + } + + /** Die Reaktionen EINER Nachricht neu setzen -- in der Liste und auf + * dem Bildschirm. An einer Stelle, damit der Klick und das Ereignis + * nicht zwei verschiedene Wege nehmen. */ + function standSetzen(id, reaktionen) { + const i = nachrichten.findIndex((x) => x.id === id); + if (i >= 0) nachrichten[i].reaktionen = reaktionen; + verlaufZeichnen(); + } + + /** Die Zeile unter der Nachricht. */ + function reaktionenBauen(n) { + const reihe = el('div', 'reaktionen'); + for (const r of n.reaktionen) { + const b = el('button', 'reaktionen__stueck'); + b.type = 'button'; + if (r.selbst) b.dataset.selbst = 'ja'; + b.append(el('span', 'reaktionen__zeichen', r.zeichen)); + b.append(el('span', 'reaktionen__zahl', String(r.anzahl))); + /* WER ES WAR, steht im Titel. "Drei Daumen" sagt weniger als die + Namen -- und es sind Leute aus demselben Raum. */ + b.title = `${r.wer.join(', ')} · ${r.selbst ? 'noch einmal klicken nimmt deins zurück' + : 'klicken, um dich anzuschließen'}`; + b.setAttribute('aria-label', `${r.zeichen} von ${r.wer.join(', ')}`); + b.addEventListener('click', () => reagieren(n.id, r.zeichen)); + reihe.append(b); + } + return reihe; + } + function verlaufZeichnen() { const ziel = $('verlauf'); /* NUR DIE NACHRICHTEN WEG — NICHT DER GANZE KASTEN. @@ -573,6 +691,21 @@ ant.type = 'button'; ant.addEventListener('click', () => antwortSetzen(n)); fuss.append(ant); + + /* REAGIEREN (14.09.2026). + Filipe: "ich will dass man auf die nachrichten auch reagieren + kann mit einem emoji, die nachricht selbst ohne zu + antworten." + + Der Knopf steht neben "antworten", weil es dieselbe Sorte + Handgriff ist -- nur der kleinere. Die Auswahl klappt an der + Nachricht auf und nicht in einem Fenster: Wer ein Zeichen + anklickt, ist in zwei Sekunden fertig; ein Dialog dafuer + waere mehr Weg als Zweck. */ + const reag = el('button', 'chat-nachricht__reag-knopf', 'reagieren'); + reag.type = 'button'; + reag.addEventListener('click', () => auswahlZeigen(reag, n.id)); + fuss.append(reag); } if (n.selbst && !n.zurueckgenommen) { const weg = el('button', 'chat-nachricht__weg-knopf', 'zurücknehmen'); @@ -616,6 +749,14 @@ fuss.append(kopie); } blase.append(fuss); + + /* DIE REAKTIONEN STEHEN UNTER DER NACHRICHT, nicht in der + Fusszeile: Sie sind Inhalt, keine Bedienung. Jedes Zeichen ein + eigener Knopf -- ein Klick darauf setzt oder nimmt die eigene + Reaktion, genau wie in der Auswahl. */ + if (Array.isArray(n.reaktionen) && n.reaktionen.length) { + blase.append(reaktionenBauen(n)); + } zeile.append(blase); ziel.append(zeile); } @@ -1027,6 +1168,19 @@ raeumeLaden(); }); + /* EINE REAKTION -- alle im Raum sehen sie sofort (14.09.2026). + + Betroffen ist nur, wer denselben Raum offen hat. Wer ihn nicht + offen hat, bekommt NICHTS: kein Ungelesen-Zeichen, keine Meldung + aufs Handy. Ein Daumen ist keine Nachricht; wer dafuer geweckt + wird, schaltet Meldungen ab. */ + strom.addEventListener('reaktion', (e) => { + let n; + try { n = JSON.parse(e.data); } catch { return; } + if (!offen || n.raum_id !== offen.id) return; + standSetzen(n.id, n.reaktionen || []); + }); + /* ANGEHEFTET ODER GELOEST -- alle im Raum sehen es sofort. Betroffen ist nur, wer denselben Raum offen hat; sonst genuegt die Liste. */ diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index e083d0bc..f4d837e8 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -252,10 +252,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index b851a851..50845d72 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -147,10 +147,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index dd9748e2..e94f8f83 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index a36a2361..b4cf3378 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 66642183..ebd90d0c 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 0fb60cff..028698b3 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 9f1be1a4..ecabcceb 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -319,6 +319,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index c39b13fd..0eab8e78 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609141201", + "src": "/assets/img/app-symbole/crew-192.png?v=202609141346", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609141201", + "src": "/assets/img/app-symbole/crew-512.png?v=202609141346", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609141201", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609141346", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609141201", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609141346", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index c9e730db..6c4e8003 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 1b5a9156..7b26dbd1 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index dc9c998f..3d55c36f 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 765479bb..a514ff20 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index d21e0dbf..917f7784 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -393,10 +393,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 9ef50a89..0b55ba81 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 75f7dfaf..0108d70d 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 5e98c93e..ec263537 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 607756f4..c690f1a7 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 3e7faa05..8a6410eb 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 78f51d46..69882746 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index c6b5f555..282f6785 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 0aa711b1..10acf156 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 0eaa18b5..14164afe 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 93e35582..2b4f1859 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 11b9c6cc..be807c91 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index f13eb55b..c56ab51f 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,9 +110,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index 016490a7..1b7ccaf0 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -190,9 +190,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 982f55ea..819d9c3c 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index e00f32e2..ade21046 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +