diff --git a/server/pruef-chat-aufloesen.mjs b/server/pruef-chat-aufloesen.mjs index 5fec354c..2dc70d6a 100644 --- a/server/pruef-chat-aufloesen.mjs +++ b/server/pruef-chat-aufloesen.mjs @@ -412,6 +412,96 @@ console.log("=== Reaktionen ==="); const zaehle = () => d.prepare( "SELECT COUNT(*) AS n FROM chat_reaktionen WHERE nachricht_id = ?").get(nid).n; + /* ---- KATALOG UND FAVORITEN (14.09.2026) ------------------------- + Filipe: "paar als vorschlaege mit der moeglichkeit andere + auszuwaehlen und als favoriten zu speichern." + + DREI LISTEN, DREI FRAGEN -- und sie werden gern verwechselt: + Katalog (was gibt es?), Vorschlag (was steht vorn?), Favoriten + (was hat DIESER Mensch gewaehlt?). Geprueft wird, dass sie + zusammenhaengen, ohne dasselbe zu sein. */ + const aus1 = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl"); + ok(Array.isArray(aus1.katalog) && aus1.katalog.length >= 4, + `der Katalog hat ${(aus1.katalog || []).length} Gruppen`); + const alleZeichen = (aus1.katalog || []).flatMap((g) => g.zeichen.map((z) => z[0])); + ok(alleZeichen.length >= 50, `und ${alleZeichen.length} Zeichen insgesamt`); + ok(new Set(alleZeichen).size === alleZeichen.length, + "keines davon doppelt -- ein doppeltes waere in der Tafel still"); + ok((aus1.katalog || []).every((g) => g.zeichen.every(([, w]) => typeof w === "string" && w.length > 2)), + "jedes Zeichen hat deutsche Suchwoerter"); + ok(aus1.eigene === false && aus1.zeichen.length === 6, + `ohne eigene Favoriten stehen die sechs Vorgaben vorn (${aus1.zeichen.length})`); + ok(aus1.zeichen.every((z) => alleZeichen.includes(z)), + "und jeder Vorschlag steht auch im Katalog"); + + /* ---- Einen Favoriten merken ------------------------------------- */ + const exot = alleZeichen.find((z) => !aus1.zeichen.includes(z)); + const f1 = await (await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", + { zeichen: exot }, "PUT")).json(); + ok(f1.favoriten?.length === 1 && f1.favoriten[0] === exot, + `ein Zeichen wird gemerkt (${f1.favoriten?.join(" ")})`); + ok(f1.zeichen?.length === 1, + "und steht ab jetzt ALLEIN vorn -- Favoriten ersetzen die Vorgabe"); + + /* ---- Sie bleiben, auch nach dem Neuladen ------------------------- */ + const aus2 = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl"); + ok(aus2.eigene === true && aus2.favoriten[0] === exot, + "beim naechsten Aufruf sind sie noch da"); + + /* ---- Und sie gehoeren EINEM Menschen ----------------------------- */ + const ausBen = await holen(zwei.keks, "/workspace/api/chat/reaktionen/auswahl"); + ok(ausBen.eigene === false, + "Ben hat davon nichts -- Favoriten sind persoenlich"); + + /* ---- Zweimal dasselbe nimmt es weg ------------------------------- */ + const f2 = await (await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", + { zeichen: exot }, "PUT")).json(); + ok(f2.favoriten?.length === 0 && f2.gesetzt === false, + "noch einmal darauf nimmt den Favoriten wieder weg"); + const aus3 = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl"); + ok(aus3.eigene === false && aus3.zeichen.length === 6, + "und die Vorgabe steht wieder vorn"); + + /* ---- Die Grenze sagt Bescheid, statt still zu schlucken ---------- */ + const acht = alleZeichen.slice(0, aus1.favoriten_max); + for (const z of acht) { + await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", { zeichen: z }, "PUT"); + } + const voll = await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", + { zeichen: alleZeichen[aus1.favoriten_max] }, "PUT"); + const vollD = await voll.json(); + ok(voll.status === 409 && /Favoriten/.test(vollD.fehler || ""), + `der neunte wird abgelehnt und begruendet (HTTP ${voll.status})`); + ok((vollD.favoriten || []).length === aus1.favoriten_max, + `die acht bleiben unangetastet (${(vollD.favoriten || []).length})`); + + /* GEGENPROBE: Nach dem Wegnehmen eines Favoriten geht wieder einer + hinein. Ohne sie hiesse das oben nur, dass ab acht gar nichts mehr + geht. */ + await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", + { zeichen: acht[0] }, "PUT"); + const platzFrei = await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", + { zeichen: alleZeichen[aus1.favoriten_max] }, "PUT"); + ok(platzFrei.status === 200, + `nach dem Wegnehmen passt wieder einer (HTTP ${platzFrei.status})`); + + /* ---- Ein Favorit ist keine Erlaubnis ----------------------------- */ + const erfunden = await rufe(eins.keks, "/workspace/api/chat/reaktionen/favorit", + { zeichen: "nicht-im-katalog" }, "PUT"); + ok(erfunden.status === 400, + `ein Zeichen ausserhalb des Katalogs wird nicht gemerkt (HTTP ${erfunden.status})`); + + /* ---- Der ganze Katalog ist auch zum Reagieren erlaubt ------------ */ + const weit = alleZeichen[alleZeichen.length - 1]; + const gr = await (await rufe(eins.keks, "/workspace/api/chat/gruppe", + { mit: [idZwei], name: "Katalogtest" }, "POST")).json(); + const nn = await (await rufe(eins.keks, + `/workspace/api/chat/raeume/${gr.raum_id}/nachrichten`, { text: "x" }, "POST")).json(); + const mitExot = await rufe(eins.keks, + `/workspace/api/chat/nachrichten/${nn.nachricht.id}/reaktion`, { zeichen: weit }, "PUT"); + ok(mitExot.status === 200, + `auch das letzte Zeichen des Katalogs laesst sich geben (${weit})`); + /* ---- Setzen ---------------------------------------------------- */ const a1 = await (await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT")).json(); @@ -601,6 +691,101 @@ console.log("=== Das Neu-Fenster ==="); 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)`); + + /* ---- DIE TAFEL: Vorschlaege, Suche, Katalog, Favoriten ---------- + (14.09.2026) Filipe: "soll viel besser und geiler sein und man + soll auch auswaehlen koennen." + + Gemessen wird, was auf dem Bildschirm PASSIERT -- nicht, dass es + Elemente gibt. Eine Tafel, die aufklappt und deren Suche nichts + tut, haette alle Anwesenheitspruefungen bestanden. */ + const teile = await seite.evaluate(() => ({ + schnell: document.querySelectorAll(".reagieren-tafel__schnell .reagieren-tafel__zeichen").length, + gruppen: document.querySelectorAll(".reagieren-tafel__gruppe").length, + katalog: document.querySelectorAll(".reagieren-tafel__katalog .reagieren-tafel__zeichen").length, + suche: !!document.querySelector(".reagieren-tafel__suche"), + stern: !!document.querySelector(".reagieren-tafel__stern"), + })); + ok(teile.schnell === 6, `sechs Vorschlaege stehen oben (${teile.schnell})`); + ok(teile.gruppen >= 4 && teile.katalog >= 50, + `darunter der Katalog: ${teile.gruppen} Gruppen, ${teile.katalog} Zeichen`); + ok(teile.suche && teile.stern, "Suchfeld und Favoriten-Schalter sind da"); + + if (process.env.SCHIRM) { + await seite.locator(".chat-nachricht").last().screenshot( + { path: join(HIER_BILD, "pruef-chat-tafel.png") }); + } + + /* ---- Die Suche muss SUCHEN --------------------------------------- */ + await seite.fill(".reagieren-tafel__suche", "feuer"); + await seite.waitForTimeout(400); + const gefunden = await seite.evaluate(() => ({ + zahl: document.querySelectorAll(".reagieren-tafel__katalog .reagieren-tafel__zeichen").length, + erstes: document.querySelector(".reagieren-tafel__katalog .reagieren-tafel__zeichen")?.textContent, + })); + ok(gefunden.zahl >= 1 && gefunden.zahl < teile.katalog, + `"feuer" grenzt ein: ${gefunden.zahl} statt ${teile.katalog}`); + ok(gefunden.erstes === "\u{1F525}", `und findet das Richtige (${gefunden.erstes})`); + + /* Ein Wort, das es nicht gibt, sagt das -- statt eine leere Flaeche + zu zeigen, die wie ein Fehler aussieht. */ + await seite.fill(".reagieren-tafel__suche", "zwiebelkuchen"); + await seite.waitForTimeout(400); + const leer = await seite.evaluate(() => + document.querySelector(".reagieren-tafel__leer")?.textContent || ""); + ok(leer.includes("Nichts zu"), `ein Fehlgriff wird gesagt ("${leer.slice(0, 40)}")`); + await seite.fill(".reagieren-tafel__suche", ""); + await seite.waitForTimeout(400); + + /* ---- Der Favoriten-Modus ----------------------------------------- */ + await seite.click(".reagieren-tafel__stern"); + await seite.waitForTimeout(300); + const anModus = await seite.evaluate(() => ({ + merken: document.querySelector(".reagieren-tafel")?.dataset.merken, + hinweis: document.querySelector(".reagieren-tafel__hinweis")?.textContent || "", + })); + ok(anModus.merken === "ja", "der Stern schaltet den Merk-Modus ein"); + ok(anModus.hinweis.includes("merkt"), + `und sagt, was ein Antippen jetzt tut ("${anModus.hinweis.slice(0, 44)}…")`); + + /* Ein Zeichen im Merk-Modus antippen REAGIERT NICHT, es merkt. Das + ist der Unterschied, der schiefgehen kann -- und er wird hier an + der Zahl der Reaktionen gemessen, nicht an der Absicht. */ + const vorher = await seite.$$eval(".reaktionen__stueck", (b) => b.length); + await seite.click(".reagieren-tafel__katalog .reagieren-tafel__zeichen"); + await seite.waitForTimeout(700); + const nachher = await seite.evaluate(() => ({ + reaktionen: document.querySelectorAll(".reaktionen__stueck").length, + gemerkt: document.querySelectorAll('.reagieren-tafel__zeichen[data-favorit="ja"]').length, + offen: !!document.querySelector(".reagieren-tafel"), + hinweis: document.querySelector(".reagieren-tafel__hinweis")?.textContent || "", + })); + ok(nachher.reaktionen === vorher, + `im Merk-Modus entsteht KEINE Reaktion (${vorher} -> ${nachher.reaktionen})`); + ok(nachher.gemerkt >= 1, `sondern ein Favorit (${nachher.gemerkt} markiert)`); + ok(nachher.offen, "und die Tafel bleibt offen -- sonst achtmal aufmachen"); + ok(nachher.hinweis.includes("gemerkt"), + `der Hinweis zaehlt mit ("${nachher.hinweis.slice(0, 34)}…")`); + + if (process.env.SCHIRM) { + await seite.locator(".chat-nachricht").last().screenshot( + { path: join(HIER_BILD, "pruef-chat-favoriten.png") }); + } + + /* Und der Favorit steht danach VORN -- ohne die Tafel neu zu oeffnen. */ + const vornDrin = await seite.evaluate(() => { + const merk = [...document.querySelectorAll('.reagieren-tafel__katalog .reagieren-tafel__zeichen[data-favorit="ja"]')] + .map((b) => b.dataset.zeichen); + const vorn = [...document.querySelectorAll(".reagieren-tafel__schnell .reagieren-tafel__zeichen")] + .map((b) => b.dataset.zeichen); + return merk.every((z) => vorn.includes(z)) && vorn.length > 0; + }); + ok(vornDrin, "der frisch gemerkte steht sofort in der Vorschlagszeile"); + + /* Modus wieder aus, dann wirklich reagieren. */ + await seite.click(".reagieren-tafel__stern"); + await seite.waitForTimeout(250); + const tafelLage = await seite.evaluate(() => { const t = document.querySelector(".reagieren-tafel"); const z = document.querySelector(".reagieren-tafel__zeichen"); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index b4a088c3..8111798a 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -47,6 +47,8 @@ import { fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE, } from "./workspace.js"; +import { KATALOG, ERLAUBT, VORSCHLAG, FAVORITEN_MAX } from "./workspace-reaktionen.js"; + export const chatRouter = express.Router(); const TEXT_MAX = 4000; @@ -1223,13 +1225,97 @@ function darfAufloesen(raum, person) { -- und die zweite Fassung waere die, in der ein Zeichen fehlt, das der Server noch annimmt. ===================================================================== */ -export const REAKTIONEN = ["👍", "❤️", "😂", "😮", "🔥", "🙏"]; +/* Die drei Listen stehen in workspace-reaktionen.js -- Katalog, + Vorschlag, erlaubte Zeichen. Hier stehen nur die Wege dorthin. */ +/** Die Favoriten EINES Menschen, in seiner Reihenfolge. */ +function favoritenVon(personId) { + return db().prepare( + "SELECT zeichen FROM chat_reaktion_favoriten WHERE person_id = ? ORDER BY platz, zeichen") + .all(personId).map((z) => z.zeichen) + /* Was nicht mehr im Katalog steht, faellt still weg -- aber nur + beim LESEN. Die Zeile bleibt stehen; kaeme das Zeichen zurueck, + ist der Favorit wieder da. Ein Loeschen waere eine Entscheidung + ueber fremde Daten wegen einer Katalogaenderung. */ + .filter((z) => ERLAUBT.has(z)); +} + +/* WAS DIE OBERFLAECHE BRAUCHT, IN EINER ANTWORT: die Vorschlagszeile, + den ganzen Katalog mit Suchwoertern und die eigenen Favoriten. + + Drei Abrufe daraus zu machen waere sparsamer aussehend und in + Wahrheit teurer: Die Tafel kann erst erscheinen, wenn alle drei da + sind. */ chatRouter.get("/workspace/api/chat/reaktionen/auswahl", (req, res) => { - res.json({ zeichen: REAKTIONEN }); + try { + const favoriten = favoritenVon(req.person.id); + res.json({ + /* Die Vorschlagszeile sind die FAVORITEN, sobald es welche gibt + -- sonst die Vorgabe. Beides an einer Stelle entschieden, + damit die Oberflaeche nicht selbst waehlen muss. */ + zeichen: favoriten.length ? favoriten : VORSCHLAG, + eigene: favoriten.length > 0, + favoriten, + favoriten_max: FAVORITEN_MAX, + katalog: KATALOG, + }); + } catch (fehler) { + console.error("[chat] Auswahl:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } }); -/** Alle Reaktionen zu einer Menge von Nachrichten -- in EINER Abfrage. +/* EINEN FAVORITEN SETZEN ODER WEGNEHMEN -- wieder ein Umschalter, + wie das Reagieren selbst. Ein eigener Weg zum Entfernen waere ein + zweiter Weg, den man absichern muss. */ +chatRouter.put("/workspace/api/chat/reaktionen/favorit", gleicheHerkunft, + express.json({ limit: "2kb" }), (req, res) => { + try { + const zeichen = String(req.body?.zeichen ?? ""); + if (!ERLAUBT.has(zeichen)) return res.status(400).json({ fehler: "unbekanntes_zeichen" }); + + const d = db(); + const da = d.prepare( + "SELECT 1 FROM chat_reaktion_favoriten WHERE person_id = ? AND zeichen = ?") + .get(req.person.id, zeichen); + + if (da) { + d.prepare("DELETE FROM chat_reaktion_favoriten WHERE person_id = ? AND zeichen = ?") + .run(req.person.id, zeichen); + } else { + const wieViele = d.prepare( + "SELECT COUNT(*) AS n FROM chat_reaktion_favoriten WHERE person_id = ?") + .get(req.person.id).n; + if (wieViele >= FAVORITEN_MAX) { + /* NICHT STILL DEN AELTESTEN WEGWERFEN. Wer neun ablegen will, + soll erfahren, dass acht das Maximum sind -- und selbst + entscheiden, welcher geht. Ein Favorit, der ohne Ansage + verschwindet, sieht wie ein Fehler aus. */ + return res.status(409).json({ + fehler: `Mehr als ${FAVORITEN_MAX} Favoriten passen nicht in die Zeile. ` + + "Nimm erst einen weg.", + favoriten: favoritenVon(req.person.id), + }); + } + /* Ans ENDE, nicht an den Anfang: Die vorhandenen sollen nicht + wandern -- man greift sonst daneben. */ + const platz = (d.prepare( + "SELECT COALESCE(MAX(platz), 0) AS p FROM chat_reaktion_favoriten WHERE person_id = ?") + .get(req.person.id).p) + 1; + d.prepare(`INSERT INTO chat_reaktion_favoriten (person_id, zeichen, platz) + VALUES (?,?,?)`).run(req.person.id, zeichen, platz); + } + + const favoriten = favoritenVon(req.person.id); + res.json({ favoriten, gesetzt: !da, + zeichen: favoriten.length ? favoriten : VORSCHLAG }); + } catch (fehler) { + console.error("[chat] Favorit:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Alle Reaktionen zu einer Menge von Nachrichten/** 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: @@ -1275,7 +1361,7 @@ chatRouter.put("/workspace/api/chat/nachrichten/:id/reaktion", gleicheHerkunft, 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)) { + if (!ERLAUBT.has(zeichen)) { return res.status(400).json({ fehler: "unbekanntes_zeichen" }); } diff --git a/server/workspace-reaktionen.js b/server/workspace-reaktionen.js new file mode 100644 index 00000000..862c0b0f --- /dev/null +++ b/server/workspace-reaktionen.js @@ -0,0 +1,187 @@ +/* ===================================================================== + DIE ZEICHEN ZUM REAGIEREN — Katalog, Vorschläge, Favoriten + (14.09.2026) + + Filipe: "soll viel besser und geiler sein und man soll auch auswählen + können, also paar als vorschläge mit der möglichkeit andere + auszuwählen und als favoriten zu speichern." + + --------------------------------------------------------------------- + DREI LISTEN, DREI FRAGEN — und sie werden gern verwechselt: + + KATALOG Was gibt es zur Auswahl? (fest, hier) + VORSCHLAG Was steht vorn, wenn ich (fest, hier — bis jemand + noch nichts gewählt habe? eigene Favoriten hat) + FAVORITEN Was hat DIESER Mensch (in der Datenbank) + sich gemerkt? + + Nur der Katalog entscheidet, was angenommen wird. Vorschläge und + Favoriten sind Reihenfolge, keine Erlaubnis — sonst könnte man sich + über einen Favoriten etwas erlauben, das im Katalog nicht steht. + + --------------------------------------------------------------------- + WARUM EIN KATALOG UND NICHT "JEDES EMOJI" + + Ein freies Feld nähme alles: 4-KB-Zeichenketten, unsichtbare + Steuerzeichen, Flaggen aus zwei zusammengesetzten Zeichen, die auf + dem einen Gerät eine Flagge sind und auf dem anderen zwei Buchstaben. + Was hier nicht drinsteht, kommt nicht in die Datenbank — und was + drinsteht, sieht überall gleich aus. + + Der Katalog ist trotzdem groß genug, dass niemand etwas vermisst: + über hundert Zeichen in sechs Gruppen, jedes mit deutschen + Suchwörtern. Die Suche ist der eigentliche Unterschied zu einer + Tafel: Wer "feuer" tippt, muss nicht scrollen. + ===================================================================== */ + +/** Jede Zeile: [Zeichen, Suchwörter (deutsch, durch Leerzeichen)]. + * Die Suchwörter sind das, wonach jemand WIRKLICH sucht — nicht der + * offizielle Unicode-Name ("grinsendes Gesicht mit lachenden Augen"). */ +export const KATALOG = [ + { + name: "Zustimmung", + zeichen: [ + ["\u{1F44D}", "daumen hoch gut ok super ja"], + ["\u{1F44E}", "daumen runter schlecht nein"], + ["\u{1F44C}", "ok perfekt passt"], + ["\u{1F64C}", "hurra jubel super geschafft"], + ["\u{1F44F}", "klatschen applaus respekt bravo"], + ["\u{1F91D}", "handschlag deal abgemacht"], + ["✊", "faust power stark"], + ["\u{1F4AA}", "muskel stark kraft"], + ["\u{1F64F}", "danke bitte gebet respekt"], + ["✅", "haken erledigt fertig"], + ["\u{1F195}", "neu new"], + ["\u{1F3AF}", "treffer ziel genau"], + ], + }, + { + name: "Gefühl", + zeichen: [ + ["❤️", "herz liebe lieb"], + ["\u{1F9E1}", "herz orange"], + ["\u{1F49B}", "herz gelb"], + ["\u{1F49A}", "herz gruen"], + ["\u{1F499}", "herz blau"], + ["\u{1F49C}", "herz lila"], + ["\u{1F5A4}", "herz schwarz"], + ["\u{1F60D}", "verliebt herzaugen toll"], + ["\u{1F970}", "suess niedlich lieb"], + ["\u{1F618}", "kuss bussi"], + ["\u{1F917}", "umarmung knuddel"], + ["✨", "funkeln glanz besonders"], + ], + }, + { + name: "Lachen", + zeichen: [ + ["\u{1F602}", "lachen traenen lustig"], + ["\u{1F923}", "totlachen rollen lustig"], + ["\u{1F604}", "lachen froh"], + ["\u{1F609}", "zwinkern augenzwinkern"], + ["\u{1F60E}", "sonnenbrille cool"], + ["\u{1F973}", "party feiern geburtstag"], + ["\u{1F92A}", "quatsch albern zunge"], + ["\u{1F643}", "umgedreht ironie"], + ["\u{1F60F}", "schmunzeln frech"], + ["\u{1F642}", "leicht laecheln"], + ["\u{1F60A}", "froh zufrieden"], + ["\u{1F92D}", "ups kichern hand vor mund"], + ], + }, + { + name: "Staunen", + zeichen: [ + ["\u{1F62E}", "oh staunen ueberrascht"], + ["\u{1F631}", "schock erschrocken"], + ["\u{1F92F}", "wow kopf explodiert krass"], + ["\u{1F440}", "augen schauen hinsehen"], + ["\u{1F914}", "nachdenken ueberlegen hm"], + ["\u{1F928}", "skeptisch zweifel augenbraue"], + ["\u{1F644}", "augenrollen genervt"], + ["\u{1F925}", "luege pinocchio"], + ["\u{1F633}", "peinlich rot"], + ["\u{1F971}", "muede gaehnen"], + ["\u{1F634}", "schlafen muede"], + ["❓", "frage unklar"], + ], + }, + { + name: "Arbeit", + zeichen: [ + ["\u{1F525}", "feuer krass stark lit"], + ["\u{1F680}", "rakete start schnell wachstum"], + ["\u{1F4C8}", "diagramm hoch wachstum zahlen"], + ["\u{1F4C9}", "diagramm runter weniger"], + ["\u{1F4A1}", "idee gluehbirne einfall"], + ["\u{1F4CC}", "pinnadel wichtig merken"], + ["⚠️", "achtung warnung vorsicht"], + ["\u{1F6A8}", "alarm dringend wichtig"], + ["⏰", "wecker zeit termin"], + ["\u{1F4C5}", "kalender termin datum"], + ["\u{1F4B0}", "geld diamanten umsatz"], + ["\u{1F48E}", "diamant edelstein wert"], + ["\u{1F3C6}", "pokal sieg bester"], + ["⭐", "stern gut bewertung"], + ["\u{1F4DD}", "notiz schreiben aufgabe"], + ["\u{1F50D}", "lupe suchen pruefen"], + ["\u{1F4CE}", "anhang datei bueroklammer"], + ["⚙️", "zahnrad technik einstellung"], + ], + }, + { + name: "Sonst", + zeichen: [ + ["\u{1F44B}", "winken hallo tschuess"], + ["\u{1F3B5}", "musik ton sound"], + ["\u{1F3A5}", "kamera video dreh"], + ["\u{1F4F1}", "handy telefon app"], + ["\u{1F4AC}", "sprechblase chat reden"], + ["\u{1F37B}", "prost feiern bier"], + ["☕", "kaffee pause"], + ["\u{1F355}", "pizza essen"], + ["\u{1F382}", "geburtstag torte"], + ["\u{1F381}", "geschenk ueberraschung"], + ["\u{1F340}", "kleeblatt glueck"], + ["\u{1F308}", "regenbogen bunt"], + ["☀️", "sonne schoen wetter"], + ["\u{1F319}", "mond nacht spaet"], + ["\u{1F415}", "hund dog dogi"], + ["\u{1F336}️", "chili scharf spicy"], + ], + }, +]; + +/** Alles, was angenommen wird -- aus dem Katalog ABGELEITET. + * Eine zweite Liste daneben wäre die, in der ein Zeichen fehlt, das + * die Oberfläche anbietet. */ +export const ERLAUBT = new Set( + KATALOG.flatMap((g) => g.zeichen.map(([z]) => z))); + +/** Was vorn steht, solange jemand nichts Eigenes gewählt hat. + * Bewusst dieselben sechs wie am 14.09. früh: Wer sich daran gewöhnt + * hat, findet sie an derselben Stelle wieder. */ +export const VORSCHLAG = ["\u{1F44D}", "❤️", "\u{1F602}", + "\u{1F62E}", "\u{1F525}", "\u{1F64F}"]; + +/** Wie viele Favoriten jemand ablegen darf. + * + * EINE OBERE GRENZE MUSS SEIN, sonst ist die Vorschlagszeile + * irgendwann die ganze Tafel -- und der Sinn der Vorschläge ist, dass + * man sie nicht lesen muss. Acht passt in eine Zeile, auch am Handy. */ +export const FAVORITEN_MAX = 8; + +/* Eine Selbstprüfung beim Laden: Doppelte im Katalog wären still -- + sie erschienen zweimal in der Tafel, und niemand sucht danach. + Lieber beim Start laut als im Betrieb leise. */ +{ + const alle = KATALOG.flatMap((g) => g.zeichen.map(([z]) => z)); + const doppelt = alle.filter((z, i) => alle.indexOf(z) !== i); + if (doppelt.length) { + console.error("[reaktionen] Doppelte Zeichen im Katalog:", doppelt.join(" ")); + } + const fehlt = VORSCHLAG.filter((z) => !ERLAUBT.has(z)); + if (fehlt.length) { + console.error("[reaktionen] Vorschlag steht nicht im Katalog:", fehlt.join(" ")); + } +} diff --git a/server/workspace.js b/server/workspace.js index a774a9b4..0cdcb332 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -2883,6 +2883,29 @@ export function db() { CREATE INDEX IF NOT EXISTS idx_chat_reaktionen_nachricht ON chat_reaktionen (nachricht_id); + /* FAVORITEN (14.09.2026). + + Filipe: "man soll auch auswaehlen koennen, also paar als + vorschlaege mit der moeglichkeit andere auszuwaehlen und als + favoriten zu speichern." + + AM MENSCHEN, NICHT AM GERAET. Im Browser abgelegt waeren sie + am Handy andere als am Rechner -- und beim naechsten Loeschen + der Seitendaten weg. Wer sich etwas merkt, will es ueberall + wiederfinden. + + Die Spalte "platz" haelt die REIHENFOLGE. Ohne sie waere die + Vorschlagszeile bei jedem Laden anders sortiert, und man + greift daneben, weil das Zeichen gewandert ist. */ + CREATE TABLE IF NOT EXISTS chat_reaktion_favoriten ( + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + zeichen TEXT NOT NULL, + platz INTEGER NOT NULL, + PRIMARY KEY (person_id, zeichen) + ); + CREATE INDEX IF NOT EXISTS idx_chat_favoriten_person + ON chat_reaktion_favoriten (person_id, platz); + /* 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 df6b4d39..f36d014c 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=202609141346", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609141404", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609141346", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609141404", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609141346", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609141404", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609141346", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609141404", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css index af1a1e47..f4b5a9c4 100644 --- a/workspace/assets/css/chat.css +++ b/workspace/assets/css/chat.css @@ -1013,30 +1013,155 @@ .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. */ +/* Die Tafel: eine Karte, kein Pillenstreifen (14.09.2026). + + Filipe: "soll viel besser und geiler sein und man soll auch + auswaehlen koennen, also paar als vorschlaege mit der moeglichkeit + andere auszuwaehlen und als favoriten zu speichern." + + Die erste Fassung war eine Reihe von sechs Zeichen in einer Pille. + Das sah nach Notloesung aus -- und es WAR eine: Mehr ging nicht + hinein. Jetzt ist es eine Karte mit drei Teilen: Vorschlaege oben, + Suche, Katalog. Der Katalog rollt, die Vorschlaege bleiben stehen. + + GEDECKT, NICHT GRELL: Die Zeichen bringen ihre eigene Farbe mit. Die + Karte darum ist dunkel und ruhig, damit die Zeichen die einzigen + bunten Punkte sind. */ .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); + position: relative; z-index: 5; + margin: 8px 0 2px; + width: min(330px, 100%); + border: 1px solid var(--rand-hell, var(--rand)); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, .05), rgba(255, 255, 255, .015)), + var(--flaeche-tief, #131a24); + box-shadow: 0 18px 40px -22px rgba(0, 0, 0, .95), + inset 0 1px 0 rgba(255, 255, 255, .06); + padding: 9px 9px 7px; + animation: tafel-auf .13s ease-out; } -.reagieren-tafel__zeichen { - appearance: none; border: 0; border-radius: 999px; - padding: 4px 7px; +@keyframes tafel-auf { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: none; } +} +@media (prefers-reduced-motion: reduce) { + .reagieren-tafel { animation: none; } +} + +/* Kopf: die Vorschlaege nehmen den Platz, der Stern bleibt rechts. */ +.reagieren-tafel__kopf { + display: flex; align-items: center; gap: 6px; +} +.reagieren-tafel__schnell { + flex: 1; min-width: 0; + display: flex; flex-wrap: wrap; gap: 2px; +} +.reagieren-tafel__stern { + flex: none; + appearance: none; border: 1px solid transparent; border-radius: 10px; + width: 30px; height: 30px; background: none; cursor: pointer; - font-size: 1.05rem; line-height: 1; - transition: background var(--tempo), transform var(--tempo); + font-size: .95rem; line-height: 1; + color: var(--text-still); + transition: color var(--tempo), background var(--tempo), border-color 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; } +.reagieren-tafel__stern:hover { color: var(--text-leise); background: rgba(255, 255, 255, .06); } +.reagieren-tafel__stern:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +/* Im Merk-Modus ist der Stern AN -- und die ganze Tafel sagt es mit, + damit niemand aus Versehen reagiert, wenn er merken wollte. */ +.reagieren-tafel[data-merken="ja"] .reagieren-tafel__stern { + color: var(--akzent); + border-color: color-mix(in srgb, var(--akzent) 45%, transparent); + background: color-mix(in srgb, var(--akzent) 14%, transparent); +} +.reagieren-tafel[data-merken="ja"] { + border-color: color-mix(in srgb, var(--akzent) 40%, var(--rand)); +} + +.reagieren-tafel__hinweis { + margin: 0; padding: 0 2px; + font-size: .72rem; line-height: 1.45; + color: var(--text-still); +} +.reagieren-tafel__hinweis:empty { display: none; } + +.reagieren-tafel__suche { + width: 100%; box-sizing: border-box; + margin: 7px 0 6px; + padding: 6px 10px; + border: 1px solid var(--rand); border-radius: 10px; + background: rgba(0, 0, 0, .25); + font: inherit; font-size: .78rem; + color: var(--text); +} +.reagieren-tafel__suche::placeholder { color: var(--text-still); } +.reagieren-tafel__suche:focus-visible { outline: 2px solid var(--akzent); outline-offset: 1px; } + +/* Der Katalog rollt INNEN. Eine Tafel, die laenger wird als der + Bildschirm, schiebt den Chat weg und ihre eigene Suche nach oben + hinaus. */ +.reagieren-tafel__katalog { + max-height: 210px; overflow-y: auto; + padding-right: 2px; + overscroll-behavior: contain; +} +.reagieren-tafel__gruppe { + margin: 6px 0 3px; padding: 0 2px; + /* .72rem = 11.52 px und damit knapp ueber der Lesbarkeitsgrenze von + 11.5 px, die pruef-css-klassen zieht. Mein erster Wert (.66rem = + 10.56 px) ist dort aufgeschlagen -- richtig so: Eine gesperrte + Versalzeile ist ohnehin schon schwerer zu lesen als normale + Schrift, da darf sie nicht auch noch die kleinste sein. */ + font-size: .72rem; letter-spacing: .09em; text-transform: uppercase; + color: var(--text-still); +} +.reagieren-tafel__gruppe:first-child { margin-top: 0; } +.reagieren-tafel__reihe { display: flex; flex-wrap: wrap; gap: 2px; } +.reagieren-tafel__leer { + margin: 10px 2px; font-size: .76rem; color: var(--text-still); +} + +.reagieren-tafel__zeichen { + position: relative; + appearance: none; border: 1px solid transparent; border-radius: 10px; + width: 34px; height: 32px; + padding: 0; + background: none; cursor: pointer; + font-size: 1.15rem; line-height: 1; + transition: background var(--tempo), transform var(--tempo), border-color var(--tempo); +} +.reagieren-tafel__zeichen:hover { + background: rgba(255, 255, 255, .09); + transform: scale(1.14); +} +.reagieren-tafel__zeichen:focus-visible { outline: 2px solid var(--akzent); outline-offset: 1px; } + +/* EIN FAVORIT TRAEGT EINEN PUNKT, keinen Rahmen: Der Rahmen gehoert im + Merk-Modus dem, was man gerade anfasst. Ein kleiner Punkt unten sagt + "gemerkt", ohne mit dem Zeichen zu konkurrieren. */ +.reagieren-tafel__zeichen[data-favorit="ja"]::after { + content: ""; position: absolute; + left: 50%; bottom: 2px; transform: translateX(-50%); + width: 4px; height: 4px; border-radius: 50%; + background: var(--akzent); +} +/* Im Merk-Modus wird der Punkt zum Rahmen -- dort geht es genau darum. */ +.reagieren-tafel[data-merken="ja"] .reagieren-tafel__zeichen[data-favorit="ja"] { + border-color: color-mix(in srgb, var(--akzent) 55%, transparent); + background: color-mix(in srgb, var(--akzent) 12%, transparent); +} + @media (prefers-reduced-motion: reduce) { .reagieren-tafel__zeichen:hover { transform: none; } - .reagieren-tafel__zeichen, .reaktionen__stueck { transition: none; } + .reagieren-tafel__zeichen, .reagieren-tafel__stern { transition: none; } +} + +/* Am Handy darf die Tafel die ganze Breite nehmen -- 330 px neben + einer Sprechblase waeren dort ein Streifen. */ +@media (max-width: 560px) { + .reagieren-tafel { width: 100%; } + .reagieren-tafel__katalog { max-height: 44vh; } } .chat__kopf-knoepfe { diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js index ca8d2f77..f3b23229 100644 --- a/workspace/assets/js/chat.js +++ b/workspace/assets/js/chat.js @@ -440,48 +440,169 @@ keinen Ersatz aus dem Aermel: Dann erscheint die Auswahl nicht, statt Zeichen anzubieten, die abgewiesen werden. =================================================================== */ - let reaktionsZeichen = null; + let auswahlDaten = null; /* vom Server: Vorschlag, Katalog, Favoriten */ let offeneAuswahl = null; + let favoritenModus = false; - async function zeichenHolen() { - if (reaktionsZeichen) return reaktionsZeichen; + async function auswahlHolen(neuLaden = false) { + if (auswahlDaten && !neuLaden) return auswahlDaten; 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; + if (!Array.isArray(d.zeichen) || !d.zeichen.length) return null; + auswahlDaten = d; + return d; } catch { return null; } } function auswahlSchliessen() { if (offeneAuswahl) { offeneAuswahl.remove(); offeneAuswahl = null; } + favoritenModus = false; } + /** Ein Zeichen in der Tafel. Was ein Klick tut, haengt am Modus -- + * reagieren oder merken. */ + function zeichenKnopf(z, nachrichtId) { + const b = el('button', 'reagieren-tafel__zeichen', z); + b.type = 'button'; + b.dataset.zeichen = z; + if (auswahlDaten?.favoriten?.includes(z)) b.dataset.favorit = 'ja'; + b.setAttribute('aria-label', `Mit ${z} reagieren`); + b.addEventListener('click', async () => { + if (favoritenModus) { await favoritSetzen(z); return; } + auswahlSchliessen(); + reagieren(nachrichtId, z); + }); + return b; + } + + async function favoritSetzen(zeichen) { + try { + const a = await hole('/workspace/api/chat/reaktionen/favorit', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ zeichen }), + }); + const d = await a.json().catch(() => ({})); + const h = offeneAuswahl?.querySelector('.reagieren-tafel__hinweis'); + if (!a.ok) { + /* DIE ABSAGE STEHT IN DER TAFEL, nicht irgendwo anders: Wer + gerade Favoriten festlegt, schaut genau dorthin. */ + if (h) h.textContent = d.fehler || 'Ging nicht.'; + return; + } + auswahlDaten.favoriten = d.favoriten; + auswahlDaten.zeichen = d.zeichen; + if (h && favoritenModus) { + h.textContent = `${d.favoriten.length} von ${auswahlDaten.favoriten_max} gemerkt.`; + } + /* Die Tafel bleibt offen und zeigt den neuen Stand. Sie zu + schliessen waere bei acht Favoriten acht Mal Aufmachen. */ + for (const b of offeneAuswahl?.querySelectorAll('.reagieren-tafel__zeichen') || []) { + if (d.favoriten.includes(b.dataset.zeichen)) b.dataset.favorit = 'ja'; + else delete b.dataset.favorit; + } + offeneAuswahl?._schnellBauen?.(); + } catch { /* stumm -- die Tafel bleibt, wie sie ist */ } + } + + /** Die Tafel. Oben die Vorschlagszeile, darunter Suche und Katalog. + * + * WARUM ALLES IN EINER TAFEL und nicht "sechs Zeichen, dann ein + * Fenster fuer den Rest": Wer etwas anderes will, soll nicht erst + * ein zweites Mal klicken. Die Vorschlagszeile ist die Abkuerzung, + * nicht die Grenze. */ 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 daten = await auswahlHolen(); + if (!daten) { 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); - } + tafel.setAttribute('role', 'dialog'); + tafel.setAttribute('aria-label', 'Reagieren'); + + const hinweis = el('p', 'reagieren-tafel__hinweis'); + + /* ---- Kopf: Vorschlaege + Favoriten-Schalter --------------------- */ + const kopf = el('div', 'reagieren-tafel__kopf'); + const schnell = el('div', 'reagieren-tafel__schnell'); + const schnellBauen = () => { + schnell.textContent = ''; + for (const z of auswahlDaten.zeichen) schnell.append(zeichenKnopf(z, nachrichtId)); + }; + schnellBauen(); + kopf.append(schnell); + + /* Der Stern schaltet um: Klicken heisst dann MERKEN statt + REAGIEREN. Ein Modus statt eines zweiten Knopfes an jedem + Zeichen -- am Handy gibt es kein "daneben zeigen". */ + const stern = el('button', 'reagieren-tafel__stern', '★'); + stern.type = 'button'; + stern.title = 'Favoriten festlegen'; + stern.setAttribute('aria-pressed', 'false'); + stern.addEventListener('click', () => { + favoritenModus = !favoritenModus; + tafel.dataset.merken = favoritenModus ? 'ja' : ''; + stern.setAttribute('aria-pressed', favoritenModus ? 'true' : 'false'); + hinweis.textContent = favoritenModus + ? `Antippen merkt ein Zeichen (bis ${auswahlDaten.favoriten_max}). Noch einmal nimmt es weg.` + : ''; + }); + kopf.append(stern); + tafel.append(kopf); + tafel.append(hinweis); + + /* ---- Suche ------------------------------------------------------ */ + const suche = document.createElement('input'); + suche.type = 'search'; + suche.className = 'reagieren-tafel__suche'; + suche.placeholder = 'Suchen … z. B. feuer, herz, danke'; + suche.setAttribute('aria-label', 'Zeichen suchen'); + tafel.append(suche); + + /* ---- Katalog ---------------------------------------------------- */ + const gitter = el('div', 'reagieren-tafel__katalog'); + tafel.append(gitter); + + const katalogBauen = (suchwort) => { + gitter.textContent = ''; + const w = String(suchwort || '').trim().toLowerCase(); + let gefunden = 0; + for (const gruppe of auswahlDaten.katalog) { + const treffer = gruppe.zeichen.filter(([z, woerter]) => + !w || woerter.includes(w) || z === w); + if (!treffer.length) continue; + gefunden += treffer.length; + gitter.append(el('p', 'reagieren-tafel__gruppe', gruppe.name)); + const reihe = el('div', 'reagieren-tafel__reihe'); + for (const [z] of treffer) reihe.append(zeichenKnopf(z, nachrichtId)); + gitter.append(reihe); + } + if (!gefunden) { + /* KEIN LEERES FELD. "Nichts gefunden" ist eine Auskunft, eine + leere Flaeche sieht nach einem Fehler aus. */ + gitter.append(el('p', 'reagieren-tafel__leer', + `Nichts zu „${suchwort}“ gefunden.`)); + } + }; + katalogBauen(''); + let tippTakt = null; + suche.addEventListener('input', () => { + clearTimeout(tippTakt); + tippTakt = setTimeout(() => katalogBauen(suche.value), 90); + }); + knopf.closest('.chat-nachricht')?.append(tafel); offeneAuswahl = tafel; - tafel.querySelector('button')?.focus(); + /* Die Tafel merkt sich, wie sie ihre Schnellzeile neu baut -- nach + einem Favoriten-Klick muss sie sich aendern, ohne dass man sie + schliesst und wieder oeffnet. */ + tafel._schnellBauen = schnellBauen; } /* Wegklicken und Escape schliessen die Tafel. Ohne das bleibt sie diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index f4d837e8..6af2eb0d 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 @@ - - - - - + + + + +