From b798bcdb3615b28fea88b90addb0b323e0f10024 Mon Sep 17 00:00:00 2001 From: Ivan Date: Tue, 17 Mar 2026 19:48:00 +0100 Subject: [PATCH] solution --- .claude/settings.local.json | 8 + .eslintrc.js | 6 +- .github/workflows/test.yml-template | 23 ++ package-lock.json | 33 ++- package.json | 5 +- src/index.js | 266 +++++++++++++++++++ src/public/.eslintrc.js | 5 + src/public/client.js | 335 ++++++++++++++++++++++++ src/public/index.html | 72 ++++++ src/public/style.css | 387 ++++++++++++++++++++++++++++ 10 files changed, 1132 insertions(+), 8 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .github/workflows/test.yml-template create mode 100644 src/public/.eslintrc.js create mode 100644 src/public/client.js create mode 100644 src/public/index.html create mode 100644 src/public/style.css diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..5800cc59b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(ls C:/Users/Hanna/projects/node/node_chat/.eslint* 2>/dev/null; ls C:/Users/Hanna/projects/node/node_chat/src/public/.eslint* 2>/dev/null; cat C:/Users/Hanna/projects/node/node_chat/package.json | grep -A 20 eslint)", + "Bash(cd C:/Users/Hanna/projects/node/node_chat && node -e \"\nconst WebSocket = require\\('ws'\\);\n\nprocess.env.PORT = 3002;\nrequire\\('./src/index.js'\\);\n\nsetTimeout\\(\\(\\) => {\n const ws = new WebSocket\\('ws://localhost:3002'\\);\n let step = 0;\n ws.on\\('open', \\(\\) => {\n ws.send\\(JSON.stringify\\({ type: 'set_username', username: 'testuser' }\\)\\);\n }\\);\n ws.on\\('message', \\(data\\) => {\n const msg = JSON.parse\\(data\\);\n console.log\\('MSG:', JSON.stringify\\(msg\\)\\);\n if \\(msg.type === 'username_confirmed'\\) {\n // Test: send create_room with empty name \\(simulate the bug\\)\n ws.send\\(JSON.stringify\\({ type: 'create_room', name: '' }\\)\\);\n }\n if \\(msg.type === 'error' || \\(msg.type === 'room_created' && step === 0\\)\\) {\n step++;\n // Now test with actual name\n ws.send\\(JSON.stringify\\({ type: 'create_room', name: 'test' }\\)\\);\n }\n if \\(msg.type === 'room_created' && step === 1\\) {\n setTimeout\\(\\(\\) => process.exit\\(0\\), 100\\);\n }\n }\\);\n}, 200\\);\n\" 2>&1)" + ] + } +} diff --git a/.eslintrc.js b/.eslintrc.js index f44c7a1df..3ff8c2346 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,10 +1,10 @@ module.exports = { extends: '@mate-academy/eslint-config', env: { - jest: true + jest: true, }, rules: { - 'no-proto': 0 + 'no-proto': 0, }, - plugins: ['jest'] + plugins: ['jest'], }; diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 000000000..bb13dfc45 --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,23 @@ +name: Test + +on: + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/package-lock.json b/package-lock.json index ce07e1dca..1c3f71ac5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "hasInstallScript": true, "license": "GPL-3.0", + "dependencies": { + "ws": "^8.18.0" + }, "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", @@ -1467,10 +1470,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.6.tgz", - "integrity": "sha512-b4om/whj4G9emyi84ORE3FRZzCRwRIesr8tJHXa8EvJdOaAPDpzcJ8A0sFfMsWH9NUOVmOwkBtOXDu5eZZ00Ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -8658,6 +8662,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 4f64337fe..56e646b04 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,12 @@ }, "author": "Mate academy", "license": "GPL-3.0", + "dependencies": { + "ws": "^8.18.0" + }, "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", diff --git a/src/index.js b/src/index.js index ad9a93a7c..9bd5a3b36 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,267 @@ 'use strict'; + +const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const { WebSocketServer } = require('ws'); + +const PORT = process.env.PORT || 3000; +const PUBLIC_DIR = path.join(__dirname, 'public'); + +// ─── In-memory state ───────────────────────────────────────────────────────── + +const rooms = new Map(); // roomId → { id, name, messages[] } +const clients = new Map(); // ws → { username, roomId } + +let nextRoomId = 1; + +function createRoom(roomName) { + const id = String(nextRoomId++); + + rooms.set(id, { id, name: roomName, messages: [] }); + + return rooms.get(id); +} + +// Seed a default room so the app is usable right away +createRoom('general'); + +// ─── HTTP server (serves static client files) ──────────────────────────────── + +const MIME = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.ico': 'image/x-icon', +}; + +const server = http.createServer((req, res) => { + const filePath = path.join( + PUBLIC_DIR, + req.url === '/' ? 'index.html' : req.url, + ); + + const ext = path.extname(filePath); + const contentType = MIME[ext] || 'tsxt/plain'; + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not found'); + + return; + } + + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); +}); + +// ─── WebSocket helpers ─────────────────────────────────────────────────────── + +function send(ws, type, payload = {}) { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ type, ...payload })); + } +} + +function broadcast(roomId, type, payload, excludeWs = null) { + for (const [ws, client] of clients) { + if (client.roomId === roomId && ws !== excludeWs) { + send(ws, type, payload); + } + } +} + +function broadcastRoomList() { + // eslint-disable-next-line + const roomList = [...rooms.values()].map(({ id, name }) => ({ + id, + name, + })); + + for (const ws of clients.keys()) { + send(ws, 'room_list', { rooms: roomList }); + } +} + +// ─── Message handlers ──────────────────────────────────────────────────────── + +const handlers = { + set_username(ws, { username }) { + if (!username || !username.trim()) { + send(ws, 'error', { message: 'User name cannot be empty' }); + + return; + } + + clients.get(ws).username = username.trim(); + send(ws, 'username_confirmed', { username: username.trim() }); + + send(ws, 'room_list', { + rooms: [...rooms.values()].map(({ id, name }) => ({ id, name })), + }); + }, + + create_room(ws, { name }) { + if (!name || !name.trim()) { + send(ws, 'error', { message: 'Room name cannot be empty' }); + + return; + } + + const room = createRoom(name.trim()); + + broadcastRoomList(); + + send(ws, 'room_created', { room: { id: room.id, name: room.name } }); + }, + + rename_room(ws, { roomId, name }) { + const room = rooms.get(roomId); + + if (!room) { + send(ws, 'error', { message: 'Room not found' }); + + return; + } + + if (!name || !name.trim()) { + send(ws, 'error', { message: 'Room name cannot be empty' }); + + return; + } + + room.name = name.trim(); + + broadcastRoomList(); + + broadcast(roomId, 'room_renamed', { roomId, name: room.name }); + }, + + join_room(ws, { roomId }) { + const room = rooms.get(roomId); + const client = clients.get(ws); + + if (!room) { + send(ws, 'error', { message: 'Room not found.' }); + + return; + } + + if (!client.username) { + send(ws, 'error', { message: 'Set a username first' }); + + return; + } + + client.roomId = roomId; + + send(ws, 'room_joined', { + room: { id: room.id, name: room.name }, + messages: room.messages, + }); + + broadcast(roomId, 'user_joined', { username: client.username }, ws); + }, + + delete_room(ws, { roomId }) { + if (!rooms.has(roomId)) { + send(ws, 'error', { message: 'Room not found.' }); + + return; + } + + rooms.delete(roomId); + + // Move everyone in that room out + for (const [clientWs, client] of clients) { + if (client.roomId === roomId) { + client.roomId = null; + send(clientWs, 'room_deleted', { roomId }); + } + } + + broadcastRoomList(); + }, + + send_message(ws, { text }) { + const client = clients.get(ws); + + if (!client.roomId) { + send(ws, 'error', { message: 'Join a room first.' }); + + return; + } + + if (!text || !text.trim()) { + send(ws, 'error', { message: 'Message cannot be empty.' }); + + return; + } + + const room = rooms.get(client.roomId); + + if (!room) { + send(ws, 'error', { message: 'Room not found.' }); + + return; + } + + const message = { + id: Date.now(), + author: client.username, + text: text.trim(), + time: new Date().toISOString(), + }; + + room.messages.push(message); + broadcast(client.roomId, 'new_message', { message }); + }, +}; + +// ─── WebSocket server ──────────────────────────────────────────────────────── + +const wss = new WebSocketServer({ server }); + +wss.on('connection', (ws) => { + clients.set(ws, { username: null, roomId: null }); + + ws.on('message', (raw) => { + let parsed; + + try { + parsed = JSON.parse(raw); + } catch { + send(ws, 'error', { message: 'Invalid JSON.' }); + + return; + } + + const { type, ...payload } = parsed; + const handler = handlers[type]; + + if (handler) { + handler(ws, payload); + } else { + send(ws, 'error', { message: `Unknown message type: ${type}` }); + } + }); + + ws.on('close', () => { + const client = clients.get(ws); + + if (client && client.roomId && client.username) { + broadcast(client.roomId, 'user_left', { username: client.username }); + } + + clients.delete(ws); + }); +}); + +// ─── Start ─────────────────────────────────────────────────────────────────── + +server.listen(PORT, () => { + // eslint-disable-next-line + console.log(`Chat server running at http://localhost:${PORT}`); +}); diff --git a/src/public/.eslintrc.js b/src/public/.eslintrc.js new file mode 100644 index 000000000..c38aa9759 --- /dev/null +++ b/src/public/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + env: { + browser: true, + }, +}; diff --git a/src/public/client.js b/src/public/client.js new file mode 100644 index 000000000..5da972094 --- /dev/null +++ b/src/public/client.js @@ -0,0 +1,335 @@ +'use strict'; + +// ── DOM refs ───────────────────────────────────────────────────────────────── +const loginScreen = document.getElementById('login-screen'); +const app = document.getElementById('app'); +const usernameInput = document.getElementById('username-input'); +const loginBtn = document.getElementById('login-btn'); +const userAvatar = document.getElementById('user-avatar'); +const usernameDisplay = document.getElementById('username-display'); +const roomsList = document.getElementById('rooms-list'); +const createRoomBtn = document.getElementById('create-room-btn'); +const roomTitle = document.getElementById('room-title'); +const noRoom = document.getElementById('no-room'); +const messages = document.getElementById('messages'); +const messageForm = document.getElementById('message-form'); +const messageInput = document.getElementById('message-input'); +const sendBtn = document.getElementById('send-btn'); +const connDot = document.getElementById('connection-dot'); +const modalOverlay = document.getElementById('modal-overlay'); +const modalTitleEl = document.getElementById('modal-title'); +const modalInput = document.getElementById('modal-input'); +const modalOk = document.getElementById('modal-ok'); +const modalCancel = document.getElementById('modal-cancel'); +const toast = document.getElementById('toast'); + +// ── State ──────────────────────────────────────────────────────────────────── +let ws = null; +let currentUsername = localStorage.getItem('chat_username') || ''; +let currentRoomId = null; +let knownRooms = []; // [{ id, name }] + +// ── Toast ──────────────────────────────────────────────────────────────────── +let toastTimer = null; + +function showToast(msg) { + toast.textContent = msg; + toast.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.classList.remove('show'), 3500); +} + +// ── Modal helpers ──────────────────────────────────────────────────────────── +let modalResolve = null; + +function openModal(title, defaultValue = '') { + modalTitleEl.textContent = title; + modalInput.value = defaultValue; + modalOverlay.classList.add('visible'); + modalInput.focus(); + modalInput.select(); + + return new Promise((resolve) => { + modalResolve = resolve; + }); +} + +function closeModal(value) { + modalOverlay.classList.remove('visible'); + + if (modalResolve) { + modalResolve(value); + modalResolve = null; + } +} + +modalOk.addEventListener('click', () => closeModal(modalInput.value.trim())); +modalCancel.addEventListener('click', () => closeModal(null)); + +modalInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + closeModal(modalInput.value.trim()); + } + + if (e.key === 'Escape') { + closeModal(null); + } +}); + +// ── WebSocket ──────────────────────────────────────────────────────────────── +function connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + + ws = new WebSocket(`${proto}://${location.host}`); + + ws.addEventListener('open', () => { + connDot.classList.remove('disconnected'); + + if (currentUsername) { + send('set_username', { username: currentUsername }); + } + }); + + ws.addEventListener('close', () => { + connDot.classList.add('disconnected'); + setTimeout(connect, 2000); // auto-reconnect + }); + + ws.addEventListener('message', (evt) => { + const { type, ...payload } = JSON.parse(evt.data); + const handler = messageHandlers[type]; + + if (handler) { + handler(payload); + } + }); +} + +function send(type, payload = {}) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type, ...payload })); + } +} + +// ── Message handlers (server → client) ─────────────────────────────────────── +const messageHandlers = { + username_confirmed({ username }) { + currentUsername = username; + localStorage.setItem('chat_username', username); + userAvatar.textContent = username[0].toUpperCase(); + usernameDisplay.textContent = username; + + loginScreen.style.display = 'none'; + app.classList.add('visible'); + }, + + room_list({ rooms }) { + knownRooms = rooms; + renderRoomList(); + }, + + room_created({ room }) { + send('join_room', { roomId: room.id }); + }, + + room_joined({ room, messages: msgHistory }) { + currentRoomId = room.id; + roomTitle.textContent = '# ' + room.name; + + noRoom.style.display = 'none'; + messages.style.display = 'flex'; + messageForm.style.display = 'flex'; + + messages.innerHTML = ''; + + msgHistory.forEach(renderMessage); + scrollToBottom(); + renderRoomList(); + messageInput.focus(); + }, + + // FIX: server sends { roomId, name } — was destructuring roomName + room_renamed({ roomId, name: roomName }) { + if (roomId === currentRoomId) { + roomTitle.textContent = '# ' + roomName; + addSystemMessage(`Room renamed to "${roomName}"`); + } + }, + + room_deleted({ roomId }) { + if (roomId === currentRoomId) { + currentRoomId = null; + roomTitle.textContent = 'Select a room'; + noRoom.style.display = 'flex'; + messages.style.display = 'none'; + messageForm.style.display = 'none'; + addSystemMessage('This room was deleted.'); + } + }, + + new_message({ message }) { + renderMessage(message); + scrollToBottom(); + }, + + user_joined({ username }) { + addSystemMessage(`${username} joined the room`); + }, + + user_left({ username }) { + addSystemMessage(`${username} left the room`); + }, + + error({ message }) { + showToast(message); + }, +}; + +// ── Rendering ──────────────────────────────────────────────────────────────── +function renderRoomList() { + roomsList.innerHTML = ''; + + knownRooms.forEach(({ id, name: roomName }) => { + const li = document.createElement('li'); + + if (id === currentRoomId) { + li.classList.add('active'); + } + + li.innerHTML = ` + # ${escHtml(roomName)} + + + + + `; + + li.querySelector('.room-name').addEventListener('click', () => { + send('join_room', { roomId: id }); + }); + + li.querySelector('.rename-btn').addEventListener('click', async (e) => { + e.stopPropagation(); + + const newName = await openModal('Rename room', roomName); + + if (newName) { + send('rename_room', { roomId: id, name: newName }); + } + }); + + li.querySelector('.delete-btn').addEventListener('click', async (e) => { + e.stopPropagation(); + + const confirmed = await openModal( + `Delete "${roomName}"? Type "delete" to confirm`, + '', + ); + + if (confirmed === 'delete') { + send('delete_room', { roomId: id }); + } + }); + + roomsList.appendChild(li); + }); +} + +function renderMessage(msg) { + const isOwn = msg.author === currentUsername; + const div = document.createElement('div'); + + div.className = `message ${isOwn ? 'own' : 'other'}`; + + const time = new Date(msg.time).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + + div.innerHTML = ` +
+ ${escHtml(msg.author)} + ${time} +
+
${escHtml(msg.text)}
+ `; + + messages.appendChild(div); +} + +function addSystemMessage(text) { + const div = document.createElement('div'); + + div.className = 'message system'; + div.innerHTML = `
${escHtml(text)}
`; + messages.appendChild(div); + scrollToBottom(); +} + +function scrollToBottom() { + messages.scrollTop = messages.scrollHeight; +} + +function escHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ── Event listeners ────────────────────────────────────────────────────────── + +// Login +function tryLogin() { + const userName = usernameInput.value.trim(); + + if (!userName) { + return; + } + send('set_username', { username: userName }); +} + +loginBtn.addEventListener('click', tryLogin); +// eslint-disable-next-line +usernameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + tryLogin(); + } +}); + +// Send message +sendBtn.addEventListener('click', () => { + const text = messageInput.value.trim(); + + if (!text) { + return; + } + send('send_message', { text }); + messageInput.value = ''; + messageInput.focus(); +}); + +messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendBtn.click(); + } +}); + +// Create room — FIX: was sending { roomName }, server expects { name } +createRoomBtn.addEventListener('click', async () => { + const roomName = await openModal('New room name'); + + if (roomName) { + send('create_room', { name: roomName }); + } +}); + +// ── Boot ──────────────────────────────────────────────────────────────────── +connect(); + +// Pre-fill username if saved +if (currentUsername) { + usernameInput.value = currentUsername; +} diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 000000000..765f5702d --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,72 @@ + + + + + + Node Chat + + + + + +
+

💬 Node Chat

+

Enter a username to start chatting

+ + +
+ + +
+ + +
+
+

Select a room

+
+
+ +
+ 💬 +

Join a room to start chatting

+
+ + + + +
+
+ + + + + +
+ + + + diff --git a/src/public/style.css b/src/public/style.css new file mode 100644 index 000000000..108528ba2 --- /dev/null +++ b/src/public/style.css @@ -0,0 +1,387 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface2: #22263a; + --border: #2e3250; + --accent: #6c8fff; + --accent2: #a78bfa; + --text: #e8eaf6; + --text-muted: #7880a4; + --danger: #f87171; + --success: #4ade80; + --radius: 10px; + --font: 'Segoe UI', system-ui, sans-serif; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +/* ── Login screen ── */ +#login-screen { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 48px 40px; + width: 360px; + display: flex; + flex-direction: column; + gap: 20px; + box-shadow: 0 20px 60px rgba(0,0,0,.5); +} + +#login-screen h1 { + font-size: 1.6rem; + font-weight: 700; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-align: center; +} + +#login-screen p { + color: var(--text-muted); + font-size: .875rem; + text-align: center; +} + +/* ── App layout ── */ +#app { + display: none; + width: 100vw; + height: 100vh; + max-width: 1200px; + margin: 0 auto; +} + +#app.visible { display: grid; grid-template-columns: 260px 1fr; } + +/* ── Sidebar ── */ +#sidebar { + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +#sidebar-header { + padding: 18px 16px 12px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; +} + +#user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: .8rem; + flex-shrink: 0; +} + +#username-display { + font-weight: 600; + font-size: .9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#rooms-section { flex: 1; overflow-y: auto; padding: 12px 0; } + +#rooms-title { + padding: 4px 16px 8px; + font-size: .7rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: space-between; +} + +#rooms-list { list-style: none; } + +#rooms-list li { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 8px 7px 16px; + cursor: pointer; + border-radius: 6px; + margin: 1px 8px; + color: var(--text-muted); + font-size: .88rem; + transition: background .15s, color .15s; +} + +#rooms-list li:hover { background: var(--surface2); color: var(--text); } + +#rooms-list li.active { + background: rgba(108,143,255,.15); + color: var(--accent); + font-weight: 600; +} + +#rooms-list li .room-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +#rooms-list li .room-actions { display: none; gap: 2px; } +#rooms-list li:hover .room-actions, +#rooms-list li.active .room-actions { display: flex; } + +.icon-btn { + background: none; + border: none; + cursor: pointer; + padding: 3px 5px; + border-radius: 4px; + color: var(--text-muted); + font-size: .85rem; + transition: background .15s, color .15s; + line-height: 1; +} + +.icon-btn:hover { background: var(--border); color: var(--text); } +.icon-btn.danger:hover { background: rgba(248,113,113,.15); color: var(--danger); } + +#create-room-btn { + margin: 8px 12px; + padding: 8px; + background: none; + border: 1px dashed var(--border); + border-radius: 6px; + color: var(--text-muted); + font-size: .82rem; + cursor: pointer; + transition: border-color .15s, color .15s; + display: flex; + align-items: center; + gap: 6px; + justify-content: center; +} + +#create-room-btn:hover { border-color: var(--accent); color: var(--accent); } + +/* ── Chat panel ── */ +#chat-panel { + display: flex; + flex-direction: column; + background: var(--bg); +} + +#chat-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; + background: var(--surface); +} + +#chat-header h2 { font-size: 1rem; font-weight: 600; } + +#connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + margin-left: auto; +} + +#connection-dot.disconnected { background: var(--danger); } + +#messages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.message { + max-width: 72%; + display: flex; + flex-direction: column; + gap: 2px; +} + +.message.own { align-self: flex-end; align-items: flex-end; } +.message.other { align-self: flex-start; } +.message.system { align-self: center; } + +.message-meta { + font-size: .72rem; + color: var(--text-muted); + display: flex; + gap: 6px; + align-items: center; +} + +.message.own .message-meta { flex-direction: row-reverse; } + +.message-author { font-weight: 600; color: var(--accent); } +.message.own .message-author { color: var(--accent2); } + +.message-bubble { + padding: 9px 14px; + border-radius: 16px; + font-size: .9rem; + line-height: 1.45; + word-break: break-word; +} + +.message.own .message-bubble { + background: linear-gradient(135deg, var(--accent), var(--accent2)); + color: #fff; + border-bottom-right-radius: 4px; +} + +.message.other .message-bubble { + background: var(--surface2); + border-bottom-left-radius: 4px; +} + +.message.system .message-bubble { + background: none; + color: var(--text-muted); + font-size: .78rem; + font-style: italic; + padding: 4px 0; +} + +#no-room { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-muted); +} + +#no-room .big { font-size: 3rem; } +#no-room p { font-size: .9rem; } + +#message-form { + padding: 14px 20px; + border-top: 1px solid var(--border); + display: flex; + gap: 10px; +} + +/* ── Shared input/button styles ── */ +input[type="text"] { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + padding: 9px 14px; + font-size: .9rem; + outline: none; + transition: border-color .15s; + width: 100%; + font-family: var(--font); +} + +input[type="text"]:focus { border-color: var(--accent); } +input[type="text"]::placeholder { color: var(--text-muted); } + +button.primary { + background: linear-gradient(135deg, var(--accent), var(--accent2)); + border: none; + border-radius: 8px; + color: #fff; + padding: 9px 20px; + font-size: .9rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + font-family: var(--font); + transition: opacity .15s; +} + +button.primary:hover { opacity: .9; } +button.primary:disabled { opacity: .4; cursor: not-allowed; } + +/* ── Modal ── */ +#modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.6); + align-items: center; + justify-content: center; + z-index: 100; +} + +#modal-overlay.visible { display: flex; } + +#modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px 28px 24px; + width: 340px; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: 0 20px 60px rgba(0,0,0,.5); +} + +#modal h3 { font-size: 1rem; font-weight: 700; } + +#modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; } + +#modal .cancel-btn { + background: none; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-muted); + padding: 8px 16px; + font-size: .88rem; + cursor: pointer; + font-family: var(--font); + transition: border-color .15s, color .15s; +} + +#modal .cancel-btn:hover { border-color: var(--text-muted); color: var(--text); } + +/* ── Toast ── */ +#toast { + position: fixed; + bottom: 24px; + right: 24px; + background: var(--danger); + color: #fff; + padding: 10px 18px; + border-radius: 8px; + font-size: .85rem; + font-weight: 500; + opacity: 0; + transition: opacity .2s; + pointer-events: none; + z-index: 200; +} + +#toast.show { opacity: 1; } + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }