Initial commit
This commit is contained in:
+25
-1
@@ -1,23 +1,47 @@
|
||||
# Multi-Stage Build: Zwei separate Phasen in einem Dockerfile.
|
||||
# Vorteil: Der finale Container enthält nur das Minimum — kein Node.js, kein Source-Code, keine node_modules.
|
||||
# Kleineres und sichereres Image als wenn alles in einem Stage wäre.
|
||||
|
||||
# Stage 1: Builder — zuständig für das Bauen der React-App.
|
||||
# "AS builder" gibt diesem Stage einen Namen damit Stage 2 darauf zugreifen kann.
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Arbeitsverzeichnis setzen — alle folgenden Befehle laufen hier.
|
||||
WORKDIR /app
|
||||
|
||||
# Zuerst nur package.json kopieren für optimales Layer Caching.
|
||||
# npm ci läuft nur neu wenn sich die Dependencies ändern, nicht bei jedem Code-Change.
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Code kopieren — nach npm ci damit der Cache optimal genutzt wird.
|
||||
COPY . .
|
||||
|
||||
|
||||
# VITE_API_URL als Build-Argument — wird von docker-compose.yml übergeben.
|
||||
# ARG ist nur während des Builds verfügbar, ENV macht sie für Vite lesbar.
|
||||
# Warum beide: Vite läuft als Node.js-Prozess und liest ENV-Variablen — nicht ARG direkt.
|
||||
# Leerer Default "" bedeutet relative URLs werden verwendet — Nginx leitet intern weiter.
|
||||
ARG VITE_API_URL=""
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# Vite baut die React-App in den /app/dist Ordner — optimiertes, minifiziertes Bundle.
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Nginx — zuständig für das Ausliefern der gebauten Dateien.
|
||||
# Node.js aus Stage 1 wird komplett weggeworfen — nur der dist-Ordner wird übernommen.
|
||||
FROM nginx:alpine
|
||||
|
||||
# Nur den fertigen dist-Ordner aus Stage 1 kopieren — kein Source-Code, kein Node.js.
|
||||
# --from=builder verweist auf Stage 1 per Name.
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Eigene Nginx-Konfiguration kopieren — überschreibt die Standard-Konfiguration.
|
||||
# Enthält Reverse Proxy Regeln für /rooms und /socket.io sowie SPA-Fallback.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Port 80 dokumentieren — Standard HTTP-Port.
|
||||
EXPOSE 80
|
||||
|
||||
# Nginx im Vordergrund starten — ohne "daemon off" würde Nginx in den Hintergrund gehen
|
||||
# und Docker würde denken der Container ist fertig und ihn stoppen.
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
+28
-5
@@ -1,37 +1,60 @@
|
||||
server {
|
||||
# Nginx lauscht auf Port 80 (Standard HTTP-Port).
|
||||
# Der Browser spricht Nginx an — Nginx entscheidet dann wohin die Anfrage geht (Reverse Proxy).
|
||||
listen 80;
|
||||
|
||||
# Serve the static React build
|
||||
# Statische React-Dateien ausliefern (HTML, CSS, JS).
|
||||
# Vite baut die App in einen dist-Ordner — dieser wird beim Docker-Build nach /usr/share/nginx/html kopiert.
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
# Fallback to index.html for client-side routing
|
||||
|
||||
# try_files: Nginx sucht zuerst nach einer echten Datei ($uri),
|
||||
# dann nach einem Ordner ($uri/), und fällt schliesslich auf index.html zurück.
|
||||
# Warum nötig: React ist eine Single Page Application (SPA) — alle Routen
|
||||
# existieren nur im Browser, nicht als echte Dateien auf dem Server.
|
||||
# Ohne diesen Fallback würde ein direkter Aufruf von z.B. /chat einen 404 liefern.
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy REST API requests to the backend
|
||||
# Reverse Proxy: Alle Anfragen an /rooms werden an das Backend weitergeleitet.
|
||||
# Warum Reverse Proxy statt direkter Backend-URL: Der Browser kennt nur Nginx auf Port 80.
|
||||
# Das Backend ist im Docker-Netzwerk unter dem Hostnamen "backend" erreichbar — nicht von aussen.
|
||||
# Vorteil: Backend muss keinen öffentlichen Port exponieren, alles läuft über Nginx.
|
||||
location /rooms {
|
||||
proxy_pass http://backend:4000;
|
||||
proxy_http_version 1.1;
|
||||
# Host-Header weiterleiten damit das Backend weiss von welcher Domain die Anfrage kommt.
|
||||
proxy_set_header Host $host;
|
||||
# X-Real-IP: Echte IP-Adresse des Clients weitergeben — sonst sieht das Backend nur Nginx als Absender.
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# X-Forwarded-For: Liste aller Proxys durch die die Anfrage gelaufen ist (Audit Trail).
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Proxy the health check endpoint to the backend
|
||||
# Health-Check-Endpoint wird ebenfalls ans Backend weitergeleitet.
|
||||
# Wird von Docker genutzt um zu prüfen ob das Backend läuft (siehe healthcheck in docker-compose.yml).
|
||||
location /health {
|
||||
proxy_pass http://backend:4000;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
# Proxy Socket.IO — requires WebSocket upgrade headers
|
||||
# WebSocket-Proxy für Socket.IO — braucht spezielle Upgrade-Header.
|
||||
# HTTP und WebSocket sind unterschiedliche Protokolle. Ein WebSocket startet als HTTP-Anfrage
|
||||
# und wird dann per "Upgrade" auf eine dauerhafte bidirektionale Verbindung umgestellt.
|
||||
# Ohne diese Header würde Nginx die Verbindung als normale HTTP-Anfrage behandeln
|
||||
# und der WebSocket-Handshake würde fehlschlagen.
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:4000;
|
||||
proxy_http_version 1.1;
|
||||
# Siehe Zeile 72 in App.jsx
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_cache_bypass: Verhindert dass Nginx WebSocket-Verbindungen cached —
|
||||
# eine dauerhafte Verbindung darf nicht gecacht werden.
|
||||
# da websocket keine fixe Antwort gibt und es ein laufender Kommunikationskanal ist, kann man es nicht sinnvoll cachen
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,65 @@ button:disabled {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.rooms-list {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.rooms-list__label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.rooms-list__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.room-chip {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.room-chip:hover {
|
||||
border-color: rgba(94, 234, 212, 0.6);
|
||||
}
|
||||
|
||||
.room-chip--active {
|
||||
border-color: rgba(94, 234, 212, 0.8);
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
.room-chip__count {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
padding: 0.4rem 1.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
min-height: 1.6rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Centralised error style classes */
|
||||
|
||||
/* Inline text error (e.g. below a field) */
|
||||
|
||||
+164
-16
@@ -5,32 +5,47 @@ import { ERROR_MESSAGES } from './constants/errorMessages'
|
||||
import { validateJoinForm } from './utils/validators'
|
||||
import './App.css'
|
||||
|
||||
// VITE_API_URL wird beim Build von Vite eingebettet — so muss die URL nicht hardcoded stehen.
|
||||
// ?? (Nullish Coalescing): Fallback auf localhost:4000 nur wenn die Variable nicht gesetzt ist.
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
||||
|
||||
|
||||
function App() {
|
||||
// t('key') gibt den übersetzten Text aus der aktiven Sprachdatei zurück (z.B. de/translation.json).
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Jede State-Variable ist einzeln deklariert statt in einem Objekt.
|
||||
// So rendert React nur neu wenn sich der jeweilige Wert ändert — nicht bei jeder State-Änderung.
|
||||
const [username, setUsername] = useState('')
|
||||
const [room, setRoom] = useState('general')
|
||||
const [session, setSession] = useState(null)
|
||||
const [status, setStatus] = useState('idle')
|
||||
const [room, setRoom] = useState('general') // Standardroom beim ersten Laden
|
||||
const [session, setSession] = useState(null) // null = nicht eingeloggt; sonst { id, username, room }
|
||||
const [status, setStatus] = useState('idle') // idle | connecting | connected | reconnecting | error
|
||||
const [messages, setMessages] = useState([])
|
||||
const [participants, setParticipants] = useState([])
|
||||
const [messageInput, setMessageInput] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [typingUsers, setTypingUsers] = useState([]) // Usernamen der User die gerade tippen
|
||||
const [rooms, setRooms] = useState([]) // Alle aktiven Rooms für die Chip-Auswahl
|
||||
|
||||
const socketRef = useRef(null)
|
||||
const messagesEndRef = useRef(null)
|
||||
// useRef speichert Werte ohne ein Re-Render auszulösen — ideal für Socket, DOM-Elemente und Timer.
|
||||
const socketRef = useRef(null) // Aktive Socket.IO-Verbindung
|
||||
const messagesEndRef = useRef(null) // Leeres div am Ende der Nachrichtenliste für Auto-Scroll
|
||||
const typingTimerRef = useRef(null) // Timer-ID für den typing:stop Debounce
|
||||
|
||||
// Abgeleitete Werte direkt aus State berechnet — kein eigener useState nötig.
|
||||
const connectionLabel = t(`status.${status}`, { defaultValue: status })
|
||||
const isConnected = status === 'connected'
|
||||
|
||||
// Dieser Effekt verwaltet den gesamten WebSocket-Lebenszyklus.
|
||||
// Dependency-Array [session?.id, session?.room]: Effekt läuft neu wenn User oder Room wechselt.
|
||||
useEffect(() => {
|
||||
// Kein Session → Verbindung trennen. Die Cleanup-Funktion am Ende erledigt das.
|
||||
if (!session?.id || !session?.room) {
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
// isCancelled verhindert State-Updates nach dem Unmount der Komponente (Memory Leak).
|
||||
let isCancelled = false
|
||||
let retryCount = 0
|
||||
let reconnectTimer
|
||||
@@ -38,6 +53,11 @@ function App() {
|
||||
const connect = () => {
|
||||
if (isCancelled) return
|
||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting')
|
||||
|
||||
// auth wird beim WebSocket-Handshake mitgeschickt — der Server prüft ob userId in der DB existiert
|
||||
// und ob sie zum angegebenen Room gehört. Wer vorher nicht POST /join aufgerufen hat,
|
||||
// hat keine gültige userId und wird mit "User not authorized for this room" abgelehnt.
|
||||
// transports: ['websocket'] erzwingt reines WebSocket, kein HTTP-Polling als Fallback.
|
||||
const socket = io(API_URL, {
|
||||
auth: {
|
||||
room: session.room,
|
||||
@@ -46,6 +66,8 @@ function App() {
|
||||
reconnectionAttempts: 5,
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
// Socket in Ref speichern damit handleSendMessage und andere Handler darauf zugreifen können.
|
||||
socketRef.current = socket
|
||||
|
||||
socket.on('connect', () => {
|
||||
@@ -53,16 +75,15 @@ function App() {
|
||||
setStatus('connected')
|
||||
})
|
||||
|
||||
// socket.io ist der Manager (Verbindungsebene), socket der Namespace (Anwendungsebene).
|
||||
// Reconnect-Events kommen vom Manager, nicht vom Socket selbst.
|
||||
socket.io.on('reconnect_attempt', () => {
|
||||
if (!isCancelled) {
|
||||
setStatus('reconnecting')
|
||||
}
|
||||
if (!isCancelled) setStatus('reconnecting')
|
||||
})
|
||||
|
||||
// Alle 5 Reconnect-Versuche gescheitert — User muss manuell neu verbinden.
|
||||
socket.io.on('reconnect_failed', () => {
|
||||
if (!isCancelled) {
|
||||
setStatus('error')
|
||||
}
|
||||
if (!isCancelled) setStatus('error')
|
||||
})
|
||||
|
||||
socket.on('connect_error', (socketError) => {
|
||||
@@ -73,6 +94,9 @@ function App() {
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
if (isCancelled) return
|
||||
setTypingUsers([])
|
||||
// 'io client disconnect': Der Client hat selbst getrennt (handleLeave) → kein Reconnect.
|
||||
// Bei allen anderen Gründen (Netzwerkfehler, Server-Neustart) → erneut versuchen.
|
||||
if (reason !== 'io client disconnect' && retryCount < 5) {
|
||||
retryCount += 1
|
||||
setStatus('reconnecting')
|
||||
@@ -81,25 +105,36 @@ function App() {
|
||||
}
|
||||
})
|
||||
|
||||
// Server schickt beim Verbinden die letzten N Nachrichten des Rooms.
|
||||
socket.on('history:init', (payload) => {
|
||||
setMessages(Array.isArray(payload) ? payload : [])
|
||||
})
|
||||
|
||||
// [...prev, payload] erstellt ein neues Array — React erkennt Änderungen nur per Referenzvergleich.
|
||||
// prev.push() würde dieselbe Referenz behalten → kein Re-Render.
|
||||
socket.on('message:new', (payload) => {
|
||||
if (payload) {
|
||||
setMessages((prev) => [...prev, payload])
|
||||
}
|
||||
})
|
||||
|
||||
// Wird bei jedem Join/Leave eines Users im Room gesendet.
|
||||
socket.on('participants:update', (payload) => {
|
||||
setParticipants(Array.isArray(payload) ? payload : [])
|
||||
})
|
||||
|
||||
// Eigener User wird herausgefiltert — man sieht sich selbst nicht als "tippt gerade".
|
||||
socket.on('typing:update', (payload) => {
|
||||
if (!Array.isArray(payload)) return
|
||||
setTypingUsers(payload.filter((u) => u.userId !== session.id).map((u) => u.username))
|
||||
})
|
||||
|
||||
socket.on('connection:ready', () => {
|
||||
setError('')
|
||||
})
|
||||
}
|
||||
|
||||
// setTimeout 0ms: Gibt React Zeit das Rendering abzuschliessen bevor der Status gesetzt wird.
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (!socketRef.current?.connected && !isCancelled) {
|
||||
setStatus('connecting')
|
||||
@@ -108,22 +143,55 @@ function App() {
|
||||
|
||||
connect()
|
||||
|
||||
// Cleanup: Wird ausgeführt wenn die Komponente unmountet oder der Effekt neu startet.
|
||||
// Ohne Cleanup würden mehrere Sockets gleichzeitig aktiv sein → Memory Leak.
|
||||
return () => {
|
||||
isCancelled = true
|
||||
clearTimeout(reconnectTimer)
|
||||
clearTimeout(typingTimerRef.current)
|
||||
socketRef.current?.disconnect()
|
||||
socketRef.current = null
|
||||
setTypingUsers([])
|
||||
}
|
||||
}, [session?.id, session?.room])
|
||||
|
||||
// Scrollt nach jeder neuen Nachricht ans Ende der Liste — smooth für sanfte Animation.
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Leeres Dependency-Array []: Läuft nur einmal beim ersten Laden der Seite.
|
||||
useEffect(() => {
|
||||
fetchRooms()
|
||||
}, [])
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/rooms`)
|
||||
if (res.ok) {
|
||||
const { rooms: list } = await res.json()
|
||||
setRooms(list)
|
||||
}
|
||||
} catch {
|
||||
// Rooms sind nicht kritisch — App funktioniert auch ohne die Chip-Navigation.
|
||||
}
|
||||
}
|
||||
|
||||
const participantCount = participants.length
|
||||
|
||||
// useMemo: Text wird nur neu berechnet wenn sich typingUsers ändert — nicht bei jedem Render.
|
||||
const typingLabel = useMemo(() => {
|
||||
if (typingUsers.length === 0) return ''
|
||||
if (typingUsers.length === 1) return `${typingUsers[0]} schreibt…`
|
||||
if (typingUsers.length === 2) return `${typingUsers[0]} und ${typingUsers[1]} schreiben…`
|
||||
return `${typingUsers.length} Personen schreiben…`
|
||||
}, [typingUsers])
|
||||
|
||||
const handleJoin = async (event) => {
|
||||
// Verhindert das Standard-Browserverhalten: Seite neu laden beim Formular-Submit.
|
||||
event.preventDefault()
|
||||
|
||||
// Client-seitige Validierung vor dem API-Call — spart einen unnötigen Netzwerk-Request.
|
||||
const validationError = validateJoinForm(username, room)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
@@ -133,12 +201,14 @@ function App() {
|
||||
setError('')
|
||||
setStatus(session ? 'reconnecting' : 'connecting')
|
||||
try {
|
||||
// encodeURIComponent kodiert Sonderzeichen im Room-Namen — z.B. Leerzeichen → %20.
|
||||
// Ohne Kodierung wäre die URL ungültig und der Request würde scheitern.
|
||||
const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username.trim(),
|
||||
userId: session?.id,
|
||||
userId: session?.id, // Bei Room-Wechsel: bestehende userId mitschicken statt neue zu erstellen.
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -150,10 +220,19 @@ function App() {
|
||||
if (!data?.user) {
|
||||
throw new Error(ERROR_MESSAGES.api.noUserReturned)
|
||||
}
|
||||
|
||||
// setSession ändert session?.id → WebSocket-useEffect läuft neu → baut neue Verbindung auf.
|
||||
setSession(data.user)
|
||||
setUsername(data.user.username)
|
||||
setRoom(data.user.room)
|
||||
|
||||
// Snapshot per HTTP laden bevor der WebSocket verbunden ist — UI ist nicht leer.
|
||||
// Der WebSocket überschreibt den Snapshot später mit history:init.
|
||||
await fetchRoomSnapshots(data.user.room)
|
||||
fetchRooms()
|
||||
if (socketRef.current?.connected) {
|
||||
setStatus('connected')
|
||||
}
|
||||
} catch (joinError) {
|
||||
console.error(joinError)
|
||||
setError(joinError.message)
|
||||
@@ -161,6 +240,7 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Promise.all führt beide Requests parallel aus — Gesamtdauer = max(t1, t2) statt t1 + t2.
|
||||
const fetchRoomSnapshots = async (targetRoom) => {
|
||||
const encodedRoom = encodeURIComponent(targetRoom)
|
||||
try {
|
||||
@@ -177,20 +257,38 @@ function App() {
|
||||
setParticipants(list)
|
||||
}
|
||||
} catch (snapshotError) {
|
||||
// Nicht kritisch — WebSocket liefert history:init und participants:update nach.
|
||||
console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError)
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce-Muster: typing:start sofort senden, typing:stop erst nach 1500ms Pause.
|
||||
// clearTimeout vor jedem neuen Timeout — Timer wird bei jedem Tastendruck zurückgesetzt.
|
||||
const handleMessageInputChange = (event) => {
|
||||
setMessageInput(event.target.value)
|
||||
const socket = socketRef.current
|
||||
if (!socket?.connected) return
|
||||
socket.emit('typing:start')
|
||||
clearTimeout(typingTimerRef.current)
|
||||
typingTimerRef.current = setTimeout(() => {
|
||||
socket.emit('typing:stop')
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// Nachricht per WebSocket senden — nicht per HTTP.
|
||||
// Vorteil: Keine neue Verbindung pro Nachricht, Server broadcastet direkt an alle im Room.
|
||||
// Datenfluss: emit('message:send') → Server speichert → Server sendet 'message:new' an alle.
|
||||
const handleSendMessage = (event) => {
|
||||
event.preventDefault()
|
||||
if (!session || !messageInput.trim()) {
|
||||
return
|
||||
}
|
||||
if (!session || !messageInput.trim()) return
|
||||
const socket = socketRef.current
|
||||
if (!socket || !socket.connected) {
|
||||
setError(ERROR_MESSAGES.connection.noActiveConnection)
|
||||
return
|
||||
}
|
||||
// Tipp-Indikator sofort beenden — nicht auf die 1500ms warten.
|
||||
clearTimeout(typingTimerRef.current)
|
||||
socket.emit('typing:stop')
|
||||
try {
|
||||
socket.emit('message:send', {
|
||||
text: messageInput.trim(),
|
||||
@@ -203,8 +301,12 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// finally läuft immer — egal ob HTTP-Request erfolgreich war oder nicht.
|
||||
// setSession(null) löst den WebSocket-useEffect aus → Verbindung wird sauber getrennt.
|
||||
const handleLeave = async () => {
|
||||
if (!session) return
|
||||
clearTimeout(typingTimerRef.current)
|
||||
socketRef.current?.emit('typing:stop')
|
||||
try {
|
||||
await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, {
|
||||
method: 'POST',
|
||||
@@ -212,15 +314,19 @@ function App() {
|
||||
body: JSON.stringify({ userId: session.id }),
|
||||
})
|
||||
} catch (leaveError) {
|
||||
// HTTP-Fehler ignorieren — lokaler State wird in finally trotzdem zurückgesetzt.
|
||||
console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError)
|
||||
} finally {
|
||||
setSession(null)
|
||||
setMessages([])
|
||||
setParticipants([])
|
||||
setTypingUsers([])
|
||||
setStatus('idle')
|
||||
fetchRooms()
|
||||
}
|
||||
}
|
||||
|
||||
// useMemo: Kontextsensitiver Hilfstext — wird nur neu berechnet wenn session, status oder Sprache wechselt.
|
||||
const connectionHint = useMemo(() => {
|
||||
if (!session) return t('hint.noSession')
|
||||
switch (status) {
|
||||
@@ -239,6 +345,7 @@ function App() {
|
||||
<h1>{t('app.headline')}</h1>
|
||||
<p>{connectionHint}</p>
|
||||
</div>
|
||||
{/* CSS-Klasse wird dynamisch gesetzt: status-connected, status-error etc. */}
|
||||
<div className={`status-badge status-${status}`}>
|
||||
<span className="status-dot" aria-hidden="true"></span>
|
||||
{connectionLabel}
|
||||
@@ -249,6 +356,7 @@ function App() {
|
||||
<form className="session-form" onSubmit={handleJoin}>
|
||||
<div className="field-group">
|
||||
<label htmlFor="username">{t('form.username')}</label>
|
||||
{/* Controlled Input: value kommt aus State, onChange aktualisiert State. */}
|
||||
<input
|
||||
id="username"
|
||||
value={username}
|
||||
@@ -263,12 +371,15 @@ function App() {
|
||||
value={room}
|
||||
placeholder={t('form.roomPlaceholder')}
|
||||
onChange={(event) => setRoom(event.target.value.toLowerCase())}
|
||||
// toLowerCase() verhindert doppelte Rooms durch unterschiedliche Schreibweise ("General" vs "general").
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="submit">
|
||||
{/* Button-Text wechselt: "Verbinden" ohne Session, "Aktualisieren" wenn bereits eingeloggt. */}
|
||||
{session ? t('form.update') : t('form.connect')}
|
||||
</button>
|
||||
{/* Leave-Button nur anzeigen wenn eine Session aktiv ist (Conditional Rendering). */}
|
||||
{session ? (
|
||||
<button type="button" className="ghost" onClick={handleLeave}>
|
||||
{t('form.leave')}
|
||||
@@ -277,6 +388,25 @@ function App() {
|
||||
</div>
|
||||
</form>
|
||||
{error ? <p className="form-error">{t(error)}</p> : null}
|
||||
{rooms.length > 0 && (
|
||||
<div className="rooms-list">
|
||||
<p className="rooms-list__label">{t('rooms.title')}</p>
|
||||
<div className="rooms-list__chips">
|
||||
{rooms.map((r) => (
|
||||
<button
|
||||
key={r.name}
|
||||
// key ist Pflicht bei React-Listen — React nutzt es beim Re-Render um Elemente zu identifizieren.
|
||||
type="button"
|
||||
className={`room-chip ${session?.room === r.name ? 'room-chip--active' : ''}`}
|
||||
onClick={() => setRoom(r.name)}
|
||||
>
|
||||
#{r.name}
|
||||
<span className="room-chip__count">{r.participantCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<main className="chat-grid">
|
||||
@@ -284,6 +414,7 @@ function App() {
|
||||
<header className="chat-panel__header">
|
||||
<div>
|
||||
<h2>{t('chat.title')}</h2>
|
||||
{/* ?? zeigt session?.room wenn vorhanden, sonst den Wert aus dem Input-Feld. */}
|
||||
<p>{t('chat.liveFeed', { room: session?.room ?? room })}</p>
|
||||
</div>
|
||||
<span className="muted">{t('chat.messageCount', { count: messages.length })}</span>
|
||||
@@ -296,6 +427,7 @@ function App() {
|
||||
<article
|
||||
key={message.id}
|
||||
className={`message ${message.userId === session?.id ? 'me' : ''}`}
|
||||
// CSS-Klasse 'me': Eigene Nachrichten werden rechts ausgerichtet (Chat-Bubble-Stil).
|
||||
>
|
||||
<header>
|
||||
<strong>{message.username}</strong>
|
||||
@@ -305,23 +437,31 @@ function App() {
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
{/* Leeres div als Scroll-Anker — scrollIntoView() hier = ans Ende scrollen. */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{/* Tipp-Indikator nur anzeigen wenn typingLabel nicht leer ist. */}
|
||||
{typingLabel && (
|
||||
<p className="typing-indicator">{typingLabel}</p>
|
||||
)}
|
||||
<form className="composer" onSubmit={handleSendMessage}>
|
||||
<input
|
||||
value={messageInput}
|
||||
onChange={(event) => setMessageInput(event.target.value)}
|
||||
onChange={handleMessageInputChange}
|
||||
placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')}
|
||||
disabled={!session || !isConnected}
|
||||
// Deaktiviert wenn kein Session oder WebSocket nicht verbunden — kein Senden ohne Verbindung.
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!session || !messageInput.trim() || !isConnected}
|
||||
// Aktiv nur wenn: Session vorhanden + Text eingegeben + WebSocket verbunden.
|
||||
>
|
||||
{t('chat.send')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside className="participants-panel">
|
||||
<header>
|
||||
<h2>{t('participants.title')}</h2>
|
||||
@@ -333,6 +473,7 @@ function App() {
|
||||
) : (
|
||||
participants.map((participant) => (
|
||||
<li key={participant.id}>
|
||||
{/* Presence-Punkt: CSS-Klasse 'online' oder 'away' je nach isOnline-Status. */}
|
||||
<span
|
||||
className={`presence ${participant.isOnline ? 'online' : 'away'}`}
|
||||
aria-label={participant.isOnline ? t('participants.online') : 'offline'}
|
||||
@@ -355,6 +496,10 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// Ausserhalb der Komponente — wird nicht bei jedem Render neu erstellt.
|
||||
|
||||
// Intl.DateTimeFormat: Eingebaute Browser-API, kein extra Paket nötig.
|
||||
// 'de-DE' formatiert im 24-Stunden-Format (13:45).
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return ''
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
@@ -363,6 +508,9 @@ function formatTime(timestamp) {
|
||||
}).format(new Date(timestamp))
|
||||
}
|
||||
|
||||
// Intl.RelativeTimeFormat: Gibt "vor 5 Minuten", "gestern" etc. aus.
|
||||
// numeric: 'auto' → "gestern" statt "vor 1 Tag".
|
||||
// Berechnung: Minuten → Stunden → Tage (von klein nach gross).
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return ''
|
||||
const formatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' })
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"placeholderDisabled": "Verbinde dich, um zu schreiben",
|
||||
"send": "Senden"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Aktive Räume"
|
||||
},
|
||||
"participants": {
|
||||
"title": "Aktive Teilnehmer",
|
||||
"empty": "Niemand ist aktiv",
|
||||
|
||||
Reference in New Issue
Block a user