diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..86bd753
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+* text=auto
+
+Dockerfile text eol=lf
+docker-compose.yml text eol=lf
+
+*.sh text eol=lf
+*.js text eol=lf
+*.jsx text eol=lf
+*.json text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+*.conf text eol=lf
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
deleted file mode 100644
index 5adac47..0000000
--- a/.idea/workspace.xml
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1751619297246
-
-
- 1751619297246
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Backend/Dockerfile b/Backend/Dockerfile
index 0f7b4f9..6d1290e 100644
--- a/Backend/Dockerfile
+++ b/Backend/Dockerfile
@@ -1,15 +1,32 @@
+# Docker lädt ein fertiges Image mit Linux Alpine und Node.js Version 20.
+# Alpine ist eine sehr kleine Linux-Distribution — kleineres Image, schnellere Downloads.
FROM node:20-alpine
+# Docker erstellt Verzeichnis /app falls noch nicht vorhanden.
+# Alle folgenden Befehle laufen in diesem Verzeichnis.
WORKDIR /app
+# Zuerst nur package.json kopieren — noch nicht den ganzen Code.
+# Warum: Docker cached jeden Schritt. Wenn sich nur der Code ändert aber nicht die
+# Dependencies, muss npm ci nicht nochmal laufen — Build wird schneller (Layer Caching).
COPY package*.json ./
+
+# npm ci installiert exakt die Versionen aus package-lock.json — reproduzierbarer Build.
+# --omit=dev: Keine Entwicklungs-Dependencies in Produktion — kleineres und sichereres Image.
RUN npm ci --omit=dev
+# Jetzt erst den restlichen Code kopieren — nach npm ci damit der Cache optimal genutzt wird.
COPY . .
+# NODE_ENV=production aktiviert Optimierungen in Express (z.B. bessere Performance, weniger Logs).
+# PORT=4000 definiert auf welchem Port der Server lauscht.
ENV NODE_ENV=production
ENV PORT=4000
+# Dokumentiert welchen Port der Container verwendet — öffnet ihn aber nicht wirklich.
+# Das passiert in docker-compose.yml.
EXPOSE 4000
+# Startbefehl wenn der Container läuft.
+# Array-Form statt String damit Node.js direkt als Prozess gestartet wird — kein Shell-Prozess dazwischen.
CMD ["node", "index.js"]
diff --git a/Backend/lib/simpleWebSocket.js b/Backend/lib/simpleWebSocket.js
index 4a1f082..5448b33 100644
--- a/Backend/lib/simpleWebSocket.js
+++ b/Backend/lib/simpleWebSocket.js
@@ -9,6 +9,11 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
},
})
+ const typingByRoom = new Map()
+
+ // io.use: Middleware die bei jeder eingehenden WebSocket-Verbindung läuft.
+ // Prüft ob room und userId im Handshake vorhanden sind und ob der User in der DB existiert.
+ // Wer vorher nicht POST /join aufgerufen hat, hat keine gültige userId und wird abgelehnt.
io.use(async (socket, next) => {
try {
const auth = socket.handshake.auth || {}
@@ -25,6 +30,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
return next(new Error('User not authorized for this room'))
}
+ // room und userId in socket.data speichern — so sind sie in registerConnection verfügbar.
socket.data.room = room
socket.data.userId = userId
next()
@@ -34,7 +40,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
})
io.on('connection', (socket) => {
- registerConnection(io, socket, database, onError).catch((error) => {
+ registerConnection(io, socket, database, onError, typingByRoom).catch((error) => {
onError('Socket connection setup failed', error)
socket.disconnect(true)
})
@@ -43,8 +49,10 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
return io
}
-async function registerConnection(io, socket, database, onError) {
+async function registerConnection(io, socket, database, onError, typingByRoom) {
const { room, userId } = socket.data
+
+ // socket.join: Fügt den Socket einem Room-Kanal hinzu — io.to(room).emit() erreicht nur diese Sockets.
socket.join(room)
const user = await database.setUserOnline(userId, room)
@@ -53,10 +61,25 @@ async function registerConnection(io, socket, database, onError) {
return
}
+ socket.data.username = user.username
+
socket.emit('connection:ready', { userId, room })
- socket.emit('history:init', await database.getMessages(room))
+
+ // Teilnehmerliste an alle im Room senden — User sieht sofort wer online ist.
await broadcastParticipants(io, database, room)
+ // typingByRoom: Map pro Room — speichert wer gerade tippt { userId → { userId, username } }.
+ socket.on('typing:start', () => {
+ if (!typingByRoom.has(room)) typingByRoom.set(room, new Map())
+ typingByRoom.get(room).set(userId, { userId, username: socket.data.username })
+ io.to(room).emit('typing:update', [...typingByRoom.get(room).values()])
+ })
+
+ socket.on('typing:stop', () => {
+ typingByRoom.get(room)?.delete(userId)
+ io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
+ })
+
socket.on('message:send', async (payload) => {
try {
const text =
@@ -65,11 +88,16 @@ async function registerConnection(io, socket, database, onError) {
return
}
+ // Nochmal prüfen ob der User noch im richtigen Room ist — könnte sich seit dem Verbinden geändert haben.
const activeUser = await database.getUser(userId)
if (!activeUser || activeUser.room !== room) {
return
}
+ // Tipp-Indikator sofort entfernen wenn eine Nachricht gesendet wird.
+ typingByRoom.get(room)?.delete(userId)
+ io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
+
const message = await database.addMessage({
id: randomUUID(),
room,
@@ -77,6 +105,7 @@ async function registerConnection(io, socket, database, onError) {
username: activeUser.username,
text: text.trim(),
})
+ // Nachricht an alle im Room senden — inkl. Sender selbst.
io.to(room).emit('message:new', message)
} catch (error) {
onError('Failed to handle socket message', error)
@@ -84,6 +113,9 @@ async function registerConnection(io, socket, database, onError) {
})
socket.on('disconnect', () => {
+ typingByRoom.get(room)?.delete(userId)
+ io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
+ // User als offline markieren und Teilnehmerliste aktualisieren.
database
.setUserOffline(userId)
.then(() => broadcastParticipants(io, database, room))
@@ -100,6 +132,8 @@ async function broadcastParticipants(io, database, room) {
io.to(room).emit('participants:update', participants)
}
+// Liest einen Parameter zuerst aus der primären Quelle (auth), dann aus dem Fallback (query).
+// Schützt gegen leere Strings — nur echte Werte werden akzeptiert.
function readParam(primary, fallback) {
if (typeof primary === 'string' && primary.trim()) return primary
if (typeof fallback === 'string' && fallback.trim()) return fallback
diff --git a/Backend/lib/stores/jsonStore.js b/Backend/lib/stores/jsonStore.js
index f21cf83..2efa7f2 100644
--- a/Backend/lib/stores/jsonStore.js
+++ b/Backend/lib/stores/jsonStore.js
@@ -1,33 +1,44 @@
const fs = require('fs')
const path = require('path')
+// JsonStore: Dateibasierter Fallback-Store wenn kein MongoDB vorhanden ist.
+// Alle Daten liegen als JSON-Datei auf der Festplatte — kein externer Datenbankserver nötig.
+// Nachteil gegenüber MongoDB: Kein Concurrency-Schutz bei mehreren gleichzeitigen Schreiboperationen.
class JsonStore {
constructor(filePath) {
this.filePath = filePath
- this.state = { users: [], messages: [] }
+ this.state = { users: [], messages: [] } // In-Memory-State — alle Operationen laufen darauf.
this._ensureStore()
}
+ // Stellt sicher dass die JSON-Datei und das Verzeichnis existieren.
+ // Wenn die Datei beschädigt ist (kein gültiges JSON), wird sie neu erstellt.
_ensureStore() {
const dir = path.dirname(this.filePath)
- fs.mkdirSync(dir, { recursive: true })
+ fs.mkdirSync(dir, { recursive: true }) // Verzeichnis anlegen falls nicht vorhanden.
if (!fs.existsSync(this.filePath)) {
this._persist()
return
}
try {
const contents = fs.readFileSync(this.filePath, 'utf-8')
- this.state = JSON.parse(contents)
+ this.state = JSON.parse(contents) // Datei in den In-Memory-State laden.
} catch (error) {
+ // Datei vorhanden aber nicht lesbar oder kein gültiges JSON — neu erstellen.
console.warn('JSON store damaged, recreating', error)
this._persist()
}
}
+ // Schreibt den aktuellen In-Memory-State in die JSON-Datei.
+ // null, 2 formatiert das JSON mit Einrückung — besser lesbar beim Debugging.
_persist() {
fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8')
}
+ // Upsert: User aktualisieren wenn vorhanden, sonst neu anlegen.
+ // Spread-Operator {...this.state.users[index], ...} übernimmt alle bestehenden Felder
+ // und überschreibt nur die geänderten — joinedAt bleibt beim Update erhalten.
async upsertUser({ id, username, room }) {
const trimmed = username?.trim()
if (!trimmed) {
@@ -60,6 +71,7 @@ class JsonStore {
return user
}
+ // Object.assign mutiert das gefundene Objekt direkt im Array — kein Index-Lookup nötig.
async setUserOnline(userId, room) {
const user = this.state.users.find((entry) => entry.id === userId)
if (!user) {
@@ -74,6 +86,7 @@ class JsonStore {
return user
}
+ // lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war.
async setUserOffline(userId) {
const user = this.state.users.find((entry) => entry.id === userId)
if (!user) {
@@ -96,6 +109,8 @@ class JsonStore {
return user
}
+ // splice(index, 1) entfernt genau ein Element an der gefundenen Position.
+ // Destructuring [removed] holt das gelöschte Element aus dem zurückgegebenen Array.
async removeUser(userId) {
const index = this.state.users.findIndex((entry) => entry.id === userId)
if (index === -1) return null
@@ -108,6 +123,8 @@ class JsonStore {
return this.state.users.find((entry) => entry.id === userId) || null
}
+ // .map() gibt nur die öffentlichen Felder zurück — interne Felder werden nicht nach außen gegeben.
+ // Sortierung: Online-User zuerst, dann nach letzter Aktivität absteigend.
async getParticipants(room) {
return this.state.users
.filter((entry) => entry.room === room)
@@ -126,6 +143,7 @@ class JsonStore {
})
}
+ // createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut.
async addMessage({ id, room, userId, username, text }) {
const message = {
id,
@@ -140,11 +158,25 @@ class JsonStore {
return message
}
+ // slice(-limit) gibt die letzten N Nachrichten zurück — neueste am Ende, älteste zuerst.
async getMessages(room, limit = 100) {
return this.state.messages
.filter((message) => message.room === room)
.slice(-limit)
}
+
+ // roomMap: Objekt das pro Room die Teilnehmerzahl zählt — schneller als mehrere filter()-Aufrufe.
+ // localeCompare: Alphabetische Sortierung mit Sprachunterstützung (Umlaute etc.).
+ async getRooms() {
+ const roomMap = {}
+ for (const user of this.state.users) {
+ if (!roomMap[user.room]) {
+ roomMap[user.room] = { name: user.room, participantCount: 0 }
+ }
+ if (user.isOnline) roomMap[user.room].participantCount++
+ }
+ return Object.values(roomMap).sort((a, b) => a.name.localeCompare(b.name))
+ }
}
module.exports = JsonStore
diff --git a/Backend/lib/stores/mongoStore.js b/Backend/lib/stores/mongoStore.js
index 8584df5..2c30977 100644
--- a/Backend/lib/stores/mongoStore.js
+++ b/Backend/lib/stores/mongoStore.js
@@ -1,6 +1,7 @@
const config = require('../../config/database')
-
+// Sicheres Laden des MongoDB-Treibers — wenn das Paket nicht installiert ist,
+// wird der Fehler erst in createMongoStore() mit einer klaren Meldung geworfen.
let MongoClient
try {
;({ MongoClient } = require('mongodb'))
@@ -8,12 +9,14 @@ try {
MongoClient = null
}
-
+// Collection-Namen als Konstanten — wenn sich ein Name ändert, muss er nur hier angepasst werden.
const collectionNames = {
users: 'users',
messages: 'messages',
}
+// MongoDB speichert den Primärschlüssel intern als "_id".
+// Diese Funktion übersetzt das in "id" damit der Rest der App nichts von MongoDB-Interna wissen muss.
function normalizeUser(doc) {
if (!doc) return null
const {
@@ -34,6 +37,8 @@ function normalizeUser(doc) {
}
}
+// Gleiche Logik wie normalizeUser — username wird redundant in der Nachricht gespeichert
+// damit beim Laden keine extra DB-Abfrage für den Usernamen nötig ist.
function normalizeMessage(doc) {
if (!doc) return null
const {
@@ -54,6 +59,8 @@ function normalizeMessage(doc) {
}
}
+// Kompatibilitätsfunktion für ältere MongoDB-Treiber (< 4.0).
+// findOneAndUpdate gibt dort das Ergebnis in einem "value"-Wrapper zurück — ab 4.0 direkt.
function extractDocument(result) {
if (!result) return null
return Object.prototype.hasOwnProperty.call(result, 'value')
@@ -61,8 +68,9 @@ function extractDocument(result) {
: result
}
+// async damit der Start der App nicht blockiert während MongoDB-Verbindung und Indizes aufgebaut werden.
async function createMongoStore() {
- // Guard: fail fast with an actionable message if the driver is missing.
+
if (!MongoClient) {
const message =
'MongoDB driver is not installed. Run `npm install mongodb` in Backend/.'
@@ -73,18 +81,20 @@ async function createMongoStore() {
const client = new MongoClient(config.uri, {
...config.options,
- appName: 'chat-app',
+ appName: 'chat-app', // Wird in MongoDB Atlas angezeigt um zu sehen welche App verbunden ist.
})
await client.connect()
- const db = client.db(config.dbName)
+ const db = client.db(config.dbName) // Entspricht "USE chatapp" in SQL.
const users = db.collection(collectionNames.users)
const messages = db.collection(collectionNames.messages)
+ // Indizes werden einmalig beim Start angelegt — ohne Index durchsucht MongoDB jede Abfrage
+ // alle Dokumente (Full Collection Scan). Promise.all legt beide parallel an.
await Promise.all([
- users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }),
- messages.createIndex({ room: 1, createdAt: 1 }),
+ users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }), // Für getParticipants()
+ messages.createIndex({ room: 1, createdAt: 1 }), // Für getMessages()
])
console.log(
@@ -93,7 +103,12 @@ async function createMongoStore() {
}/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`,
)
+ // Öffentliches Interface des Stores — der Rest der App kennt keine MongoDB-Details (Repository-Pattern).
return {
+
+ // Upsert = Update + Insert in einem Schritt (atomare Operation).
+ // Wenn User existiert: Felder aktualisieren. Wenn nicht: neu anlegen.
+ // $setOnInsert stellt sicher dass joinedAt nur beim ersten Anlegen gesetzt wird, nicht bei Updates.
async upsertUser({ id, username, room }) {
const now = new Date()
const result = await users.findOneAndUpdate(
@@ -112,7 +127,7 @@ async function createMongoStore() {
},
{
upsert: true,
- returnDocument: 'after',
+ returnDocument: 'after', // Aktualisiertes Dokument zurückgeben, nicht das alte.
},
)
const document = extractDocument(result)
@@ -120,11 +135,12 @@ async function createMongoStore() {
return normalizeUser(document)
}
-
+ // Fallback: Manche MongoDB-Versionen geben bei einem Upsert-Insert kein Dokument zurück.
const inserted = await users.findOne({ _id: id })
return normalizeUser(inserted)
},
+ // $set aktualisiert nur die angegebenen Felder — der Rest (z.B. username) bleibt unberührt.
async setUserOnline(userId, room) {
const result = await users.findOneAndUpdate(
{ _id: userId },
@@ -140,6 +156,7 @@ async function createMongoStore() {
return normalizeUser(extractDocument(result))
},
+ // lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war.
async setUserOffline(userId) {
const result = await users.findOneAndUpdate(
{ _id: userId },
@@ -154,6 +171,7 @@ async function createMongoStore() {
return normalizeUser(extractDocument(result))
},
+ // trim() auf Datenbankebene — auch wenn die Frontend-Validierung fehlt, landen keine schmutzigen Daten in der DB.
async updateUsername(userId, username) {
const result = await users.findOneAndUpdate(
{ _id: userId },
@@ -168,6 +186,7 @@ async function createMongoStore() {
return normalizeUser(extractDocument(result))
},
+ // findOneAndDelete löscht und gibt das Dokument in einem Schritt zurück — kein extra findOne() nötig.
async removeUser(userId) {
const result = await users.findOneAndDelete({ _id: userId })
return normalizeUser(extractDocument(result))
@@ -178,14 +197,19 @@ async function createMongoStore() {
return normalizeUser(doc)
},
+ // Online-User zuerst, dann nach letzter Aktivität sortiert.
+ // toArray() lädt alle Cursor-Ergebnisse in den Speicher — bei sehr großen Datenmengen wäre ein Cursor besser.
async getParticipants(room) {
const docs = await users
.find({ room })
- .sort({ isOnline: -1, lastActiveAt: -1 })
+ .sort({ isOnline: -1, lastActiveAt: -1 }) // -1 = absteigend, 1 = aufsteigend
.toArray()
return docs.map(normalizeUser)
},
+ // _id wird von außen als UUID übergeben statt von MongoDB generiert —
+ // so ist die ID bereits bekannt bevor die DB antwortet (z.B. für den WebSocket-Broadcast).
+ // createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut.
async addMessage({ id, room, userId, username, text }) {
const message = {
_id: id,
@@ -196,9 +220,11 @@ async function createMongoStore() {
createdAt: new Date(),
}
await messages.insertOne(message)
+ // Lokales Objekt zurückgeben statt nochmal aus der DB lesen — spart einen Query.
return normalizeMessage(message)
},
+ // limit = 100 verhindert dass bei einem langen Chatverlauf alles auf einmal geladen wird.
async getMessages(room, limit = 100) {
const docs = await messages
.find({ room })
@@ -208,6 +234,27 @@ async function createMongoStore() {
return docs.map(normalizeMessage)
},
+ // Aggregation Pipeline: MongoDB berechnet das Ergebnis serverseitig — effizienter als
+ // alle Daten laden und in JavaScript auswerten.
+ // $group: Gruppiert User nach Room (wie GROUP BY in SQL).
+ // $cond: Zählt nur Online-User pro Room.
+ // $project: Blendet _id aus und benennt es in "name" um.
+ async getRooms() {
+ const result = await users.aggregate([
+ {
+ $group: {
+ _id: '$room',
+ participantCount: { $sum: { $cond: ['$isOnline', 1, 0] } },
+ },
+ },
+ { $project: { _id: 0, name: '$_id', participantCount: 1 } },
+ { $sort: { name: 1 } },
+ ]).toArray()
+ return result
+ },
+
+ // Verbindung sauber trennen beim Herunterfahren der App — ohne close() bleiben
+ // offene Verbindungen auf dem MongoDB-Server hängen.
async disconnect() {
await client.close()
},
diff --git a/Backend/routes/rooms.js b/Backend/routes/rooms.js
index ae02de1..1031f45 100644
--- a/Backend/routes/rooms.js
+++ b/Backend/routes/rooms.js
@@ -5,6 +5,14 @@ const asyncHandler = require('../utils/asyncHandler')
function createRoomsRouter({ database, io }) {
const router = express.Router()
+ router.get(
+ '/',
+ asyncHandler(async (_req, res) => {
+ const rooms = await database.getRooms()
+ res.json({ rooms })
+ }),
+ )
+
router.post(
'/:room/join',
asyncHandler(async (req, res) => {
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..dff87fd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,49 @@
+## Docker Setup
+
+Run the full stack with:
+
+```bash
+docker compose up --build
+```
+
+Services:
+
+- Frontend: `http://localhost:5173`
+- Backend: `http://localhost:4000`
+- MongoDB: `mongodb://localhost:27017/chatapp`
+
+The Compose file starts all required services together:
+
+- `frontend` as a Vite container
+- `backend` as a Node/Express container
+- `mongo` with a persistent `mongo-data` volume
+
+Stop everything with:
+
+```bash
+docker compose down
+```
+
+Remove containers and database volume with:
+
+```bash
+docker compose down -v
+```
+
+## Local Development Without Docker
+
+1. Copy the backend environment file:
+
+ ```bash
+ cp Backend/.env.example Backend/.env
+ ```
+
+2. Start MongoDB locally or with Docker.
+
+3. Start the backend:
+
+ ```bash
+ cd Backend && npm run dev
+ ```
+
+The backend tries MongoDB at `mongodb://127.0.0.1:27017/chatapp` first and falls back to `Backend/data/db.json` only if MongoDB is unavailable. Use `DATA_STORE=mongo` to fail fast on MongoDB errors, or `DATA_STORE=json` to force the JSON store.
diff --git a/docker-compose.yml b/docker-compose.yml
index 4430181..de008da 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,59 +1,98 @@
-services:
- frontend:
- image: francesco448/chat-app-frontend:latest
- build:
- context: ./frontend
- args:
- VITE_API_URL: ""
- restart: unless-stopped
- depends_on:
- - backend
- ports:
- - "80:80"
- networks:
- - chat-network
-
- backend:
- image: francesco448/chat-app-backend:latest
- build:
- context: ./Backend
- restart: unless-stopped
- depends_on:
- mongo:
- condition: service_healthy
- environment:
- - PORT=4000
- - CLIENT_ORIGIN=http://localhost
- - DATA_STORE=mongo
- - MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:-chatpass}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin
- - MONGO_DB=${MONGO_DB:-chatapp}
- networks:
- - chat-network
-
- mongo:
- image: mongo:7
- container_name: chat-app-mongo
- restart: unless-stopped
- environment:
- - MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
- - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:-chatpass}
- - MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp}
- volumes:
- - mongo-data:/data/db
- ports:
- - "27017:27017"
- networks:
- - chat-network
- healthcheck:
- test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
- interval: 10s
- timeout: 5s
- retries: 5
-
networks:
chat-network:
driver: bridge
-volumes:
- mongo-data:
- driver: local
+services:
+ frontend:
+ # Fertiges Frontend-Image von Docker Hub.
+ image: francesco448/chat-app-frontend:latest
+ container_name: chat-app-frontend
+ restart: unless-stopped
+
+ # Frontend startet erst, nachdem der Backend-Container gestartet wurde.
+ depends_on:
+ - backend
+
+ # Nur das Frontend wird nach außen veröffentlicht.
+ # Standardmäßig TrueNAS-Port 9004 -> Nginx-Port 80.
+ # FRONTEND_PORT kann optional über .env geändert werden.
+ ports:
+ - "${FRONTEND_PORT:-9004}:80"
+
+ networks:
+ - chat-network
+
+
+ backend:
+ image: francesco448/chat-app-backend:latest
+ container_name: chat-app-backend
+ restart: unless-stopped
+
+ depends_on:
+ mongo:
+ condition: service_healthy
+
+ environment:
+ - PORT=4000
+ - NODE_ENV=production
+ - CLIENT_ORIGIN=${CLIENT_ORIGIN:?CLIENT_ORIGIN muss gesetzt werden}
+ - DATA_STORE=mongo
+ - MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss gesetzt werden}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin
+ - MONGO_DB=${MONGO_DB:-chatapp}
+
+ networks:
+ - chat-network
+
+ healthcheck:
+ test:
+ - CMD
+ - node
+ - -e
+ - "fetch('http://localhost:4000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+
+ mongo:
+ # Feste Major-Version verwenden statt "latest".
+ image: mongo:7
+ container_name: chat-app-mongo
+ restart: unless-stopped
+
+ environment:
+ # Benutzer kann über .env überschrieben werden.
+ - MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
+
+ # Für Production kein unsicheres Default-Passwort wie "chatpass".
+ # Der Stack startet nicht, solange das Passwort in .env fehlt.
+ - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss in .env gesetzt werden}
+
+ # Datenbankname kann über .env überschrieben werden.
+ - MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp}
+
+ # Persistenz direkt auf deinem TrueNAS-Dataset.
+ volumes:
+ - /mnt/PoolHDD/AppData/Chat-App/mongodb:/data/db
+
+ # Kein Port 27017 nach außen.
+ # MongoDB ist ausschließlich im internen Docker-Netzwerk erreichbar.
+ networks:
+ - chat-network
+
+ # Authentifizierter MongoDB-Healthcheck.
+ # $$ verhindert, dass Docker Compose die Variablen bereits auf dem Host ersetzt.
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - >-
+ mongosh --quiet
+ --host 127.0.0.1
+ --username "$$MONGO_INITDB_ROOT_USERNAME"
+ --password "$$MONGO_INITDB_ROOT_PASSWORD"
+ --authenticationDatabase admin
+ --eval "quit(db.adminCommand('ping').ok ? 0 : 2)"
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 20s
\ No newline at end of file
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index ab67f07..c7a70d9 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -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;"]
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index c39a185..cb4b7f9 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -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;
}
}
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 29d1649..61cc4ae 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -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) */
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index d17795e..c9001b1 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
{t('app.headline')}
{connectionHint}
+ {/* CSS-Klasse wird dynamisch gesetzt: status-connected, status-error etc. */}
{connectionLabel}
@@ -249,6 +356,7 @@ function App() {
{error ?
{t(error)}
: null}
+ {rooms.length > 0 && (
+
+
{t('rooms.title')}
+
+ {rooms.map((r) => (
+
+ ))}
+
+
+ )}
@@ -284,6 +414,7 @@ function App() {
{t('chat.title')}
+ {/* ?? zeigt session?.room wenn vorhanden, sonst den Wert aus dem Input-Feld. */}