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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user