diff --git a/src/client/index.html b/src/client/index.html new file mode 100644 index 000000000..e49457f7d --- /dev/null +++ b/src/client/index.html @@ -0,0 +1,50 @@ + + + + + + Node Chat + + + +
+

Node Chat

+ +
+ + +
+ +

+

Connecting...

+ +
+ +
+

Rooms

+ +
+ + +
+ + +

Current room: none

+
+ +
+ +
+

Messages

+ + +
+ + +
+
+
+ + + + diff --git a/src/client/main.js b/src/client/main.js new file mode 100644 index 000000000..196954b55 --- /dev/null +++ b/src/client/main.js @@ -0,0 +1,234 @@ +/* eslint-env browser */ + +import { io } from 'socket.io-client'; +import './style.css'; + +const connectionStatusEl = document.querySelector('#status'); +const usernameInputEl = document.querySelector('#username-input'); +const saveBtnEl = document.querySelector('#save-btn'); +const currentUserEl = document.querySelector('#current-user'); + +const roomInputEl = document.querySelector('#room-input'); +const createRoomBtnEl = document.querySelector('#create-room-btn'); +const roomsListEl = document.querySelector('#rooms-list'); +const currentRoomEl = document.querySelector('#current-room'); + +const messagesListEl = document.querySelector('#messages-list'); +const messageInputEl = document.querySelector('#message-input'); +const sendBtnEl = document.querySelector('#send-btn'); + +const socket = io('http://localhost:3000'); + +let currentUsername = localStorage.getItem('username') || ''; +let currentRoomId = null; +let rooms = []; + +function renderRooms() { + roomsListEl.innerHTML = ''; + + rooms.forEach((room) => { + const li = document.createElement('li'); + + li.className = 'room-item'; + + const joinButton = document.createElement('button'); + + joinButton.textContent = room.name; + joinButton.type = 'button'; + joinButton.className = 'room-join-btn'; + + if (room.id === currentRoomId) { + joinButton.disabled = true; + } + + joinButton.addEventListener('click', () => { + socket.emit('room:join', { roomId: room.id }); + }); + + const renameButton = document.createElement('button'); + + renameButton.textContent = 'Rename'; + renameButton.type = 'button'; + renameButton.className = 'room-action-btn'; + + renameButton.addEventListener('click', () => { + const newName = prompt('Enter new room name', room.name); + + if (!newName) { + return; + } + + socket.emit('room:rename', { + roomId: room.id, + name: newName.trim(), + }); + }); + + li.append(joinButton, renameButton); + + if (room.id !== 'general') { + const deleteButton = document.createElement('button'); + + deleteButton.textContent = 'Delete'; + deleteButton.type = 'button'; + deleteButton.className = 'room-action-btn'; + + deleteButton.addEventListener('click', () => { + const shouldDelete = confirm(`Delete room "${room.name}"?`); + + if (!shouldDelete) { + return; + } + + socket.emit('room:delete', { roomId: room.id }); + }); + + li.append(deleteButton); + } + + roomsListEl.append(li); + }); +} + +function updateCurrentRoomText() { + const activeRoom = rooms.find((room) => room.id === currentRoomId); + + currentRoomEl.textContent = activeRoom + ? `Current room: ${activeRoom.name}` + : 'Current room: none'; +} + +function renderMessage(message) { + const li = document.createElement('li'); + + li.className = 'message-item'; + li.textContent = `[${message.time}] ${message.author}: ${message.text}`; + + messagesListEl.append(li); +} + +function renderMessages(messages) { + messagesListEl.innerHTML = ''; + messages.forEach(renderMessage); +} + +function setUser(username) { + currentUsername = username; + localStorage.setItem('username', username); + + currentUserEl.textContent = `You are: ${username}`; + + usernameInputEl.style.display = 'none'; + saveBtnEl.style.display = 'none'; + + socket.emit('user:set', { username }); +} + +const savedUsername = localStorage.getItem('username'); + +if (savedUsername) { + setUser(savedUsername); +} + +socket.on('connect', () => { + connectionStatusEl.textContent = `Connected: ${socket.id}`; + + if (currentUsername) { + socket.emit('user:set', { username: currentUsername }); + } + + socket.emit('rooms:get'); +}); + +socket.on('disconnect', () => { + connectionStatusEl.textContent = 'Disconnected'; +}); + +socket.on('rooms:list', (serverRooms) => { + rooms = serverRooms; + + const roomStillExists = rooms.some((room) => room.id === currentRoomId); + + if (!roomStillExists) { + currentRoomId = null; + messagesListEl.innerHTML = ''; + } + + renderRooms(); + updateCurrentRoomText(); +}); + +socket.on('room:joined', ({ roomId }) => { + currentRoomId = roomId; + renderRooms(); + updateCurrentRoomText(); +}); + +socket.on('room:history', ({ roomId, messages }) => { + if (roomId !== currentRoomId) { + return; + } + + renderMessages(messages); +}); + +socket.on('message:new', ({ roomId, message }) => { + if (roomId !== currentRoomId) { + return; + } + + renderMessage(message); +}); + +socket.on('error:message', ({ message }) => { + alert(message); +}); + +saveBtnEl.addEventListener('click', () => { + const username = usernameInputEl.value.trim(); + + if (!username) { + return; + } + + setUser(username); +}); + +createRoomBtnEl.addEventListener('click', () => { + const roomName = roomInputEl.value.trim(); + + if (!roomName) { + return; + } + + socket.emit('room:create', { name: roomName }); + roomInputEl.value = ''; +}); + +roomInputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + createRoomBtnEl.click(); + } +}); + +sendBtnEl.addEventListener('click', () => { + const text = messageInputEl.value.trim(); + + if (!text || !currentUsername || !currentRoomId) { + return; + } + + socket.emit('message:send', { + roomId: currentRoomId, + author: currentUsername, + text, + }); + + messageInputEl.value = ''; +}); + +messageInputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + sendBtnEl.click(); + } +}); diff --git a/src/client/style.css b/src/client/style.css new file mode 100644 index 000000000..cd3fef5a9 --- /dev/null +++ b/src/client/style.css @@ -0,0 +1,64 @@ +body { + margin: 0; + font-family: Arial, sans-serif; + background: #f4f4f4; +} + +#app { + max-width: 700px; + margin: 40px auto; + padding: 24px; + background: #fff; + border-radius: 12px; +} + +#user-block, +#room-form, +#message-form { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +input, +button { + padding: 8px 12px; + font-size: 16px; +} + +#rooms-list, +#messages-list { + list-style: none; + padding: 0; + margin: 0; +} + +#messages-list li { + margin-bottom: 8px; +} + +#room-input, +#message-input { + flex: 1; +} + +.room-item { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.room-join-btn { + flex: 1; + text-align: left; +} + +.room-action-btn { + white-space: nowrap; +} + +.message-item { + padding: 10px 12px; + background: #f1f1f1; + border-radius: 8px; +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index ad9a93a7c..000000000 --- a/src/index.js +++ /dev/null @@ -1 +0,0 @@ -'use strict'; diff --git a/src/server.js b/src/server.js new file mode 100644 index 000000000..c28436036 --- /dev/null +++ b/src/server.js @@ -0,0 +1,212 @@ +const http = require('http'); +const { Server } = require('socket.io'); + +const server = http.createServer(); + +const io = new Server(server, { + cors: { + origin: 'http://localhost:5173', + methods: ['GET', 'POST'], + }, +}); + +const PORT = 3000; + +const rooms = [ + { + id: 'general', + name: 'General', + }, +]; + +const messages = []; + +function getCurrentTime() { + return new Date().toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); +} + +function broadcastRooms() { + io.emit('rooms:list', rooms); +} + +function getRoomMessages(roomId) { + return messages.filter((message) => message.roomId === roomId); +} + +io.on('connection', (socket) => { + socket.on('user:set', ({ username }) => { + socket.username = username; + }); + + socket.on('rooms:get', () => { + socket.emit('rooms:list', rooms); + }); + + socket.on('room:create', ({ name }) => { + const trimmedName = name.trim(); + + if (!trimmedName) { + return; + } + + const existingRoom = rooms.find( + (room) => room.name.toLowerCase() === trimmedName.toLowerCase(), + ); + + if (existingRoom) { + socket.emit('error:message', { + message: 'Room with this name already exists', + }); + + return; + } + + const newRoom = { + id: Date.now().toString(), + name: trimmedName, + }; + + rooms.push(newRoom); + broadcastRooms(); + }); + + socket.on('room:rename', ({ roomId, name }) => { + const trimmedName = name.trim(); + const room = rooms.find((item) => item.id === roomId); + + if (!room) { + socket.emit('error:message', { message: 'Room not found' }); + + return; + } + + if (!trimmedName) { + socket.emit('error:message', { message: 'Room name cannot be empty' }); + + return; + } + + const existingRoom = rooms.find( + (item) => + item.id !== roomId && + item.name.toLowerCase() === trimmedName.toLowerCase(), + ); + + if (existingRoom) { + socket.emit('error:message', { + message: 'Room with this name already exists', + }); + + return; + } + + room.name = trimmedName; + broadcastRooms(); + }); + + socket.on('room:delete', async ({ roomId }) => { + if (roomId === 'general') { + socket.emit('error:message', { + message: 'General room cannot be deleted', + }); + + return; + } + + const roomIndex = rooms.findIndex((room) => room.id === roomId); + + if (roomIndex === -1) { + socket.emit('error:message', { message: 'Room not found' }); + + return; + } + + rooms.splice(roomIndex, 1); + + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index].roomId === roomId) { + messages.splice(index, 1); + } + } + + const deletedRoomSockets = await io.in(roomId).fetchSockets(); + + for (const currentSocket of deletedRoomSockets) { + currentSocket.leave(roomId); + currentSocket.join('general'); + currentSocket.currentRoomId = 'general'; + + currentSocket.emit('room:joined', { roomId: 'general' }); + + currentSocket.emit('room:history', { + roomId: 'general', + messages: getRoomMessages('general'), + }); + } + + broadcastRooms(); + }); + + socket.on('room:join', ({ roomId }) => { + const room = rooms.find((item) => item.id === roomId); + + if (!room) { + socket.emit('error:message', { message: 'Room not found' }); + + return; + } + + if (socket.currentRoomId) { + socket.leave(socket.currentRoomId); + } + + socket.join(roomId); + socket.currentRoomId = roomId; + + socket.emit('room:joined', { roomId }); + + socket.emit('room:history', { + roomId, + messages: getRoomMessages(roomId), + }); + }); + + socket.on('message:send', ({ roomId, author, text }) => { + const room = rooms.find((item) => item.id === roomId); + + if (!room) { + socket.emit('error:message', { message: 'Room not found' }); + + return; + } + + if (!author || !text) { + return; + } + + const message = { + id: Date.now().toString(), + roomId, + author, + text, + time: getCurrentTime(), + }; + + messages.push(message); + + io.to(roomId).emit('message:new', { + roomId, + message, + }); + }); + + socket.on('disconnect', () => {}); +}); + +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Server is running on http://localhost:${PORT}`); +});