diff --git a/src/index.js b/src/index.js index ad9a93a7c..4e2ffef06 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,156 @@ 'use strict'; + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { Server } = require('socket.io'); + +const PORT = process.env.PORT || 3001; +const PUBLIC_DIR = path.join(__dirname, 'public'); + +const rooms = new Map(); +const DEFAULT_ROOM = 'general'; + +rooms.set(DEFAULT_ROOM, { + id: DEFAULT_ROOM, + name: 'General', + messages: [], +}); + +function createRoom(id, roomName) { + const room = { id, name: roomName, messages: [] }; + + rooms.set(id, room); + + return room; +} + +function getRoomsData() { + return Array.from(rooms.values()).map((room) => ({ + id: room.id, + name: room.name, + })); +} + +const server = http.createServer((req, res) => { + const filePath = path.join( + PUBLIC_DIR, + req.url === '/' ? 'index.html' : req.url, + ); + + const ext = path.extname(filePath); + const contentTypes = { + '.html': 'text/html', + '.js': 'application/javascript', + '.jsx': 'application/javascript', + '.css': 'text/css', + }; + + const contentType = contentTypes[ext] || 'text/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); + }); +}); + +const io = new Server(server); + +io.on('connection', (socket) => { + let currentRoom = null; + let username = null; + + socket.emit('rooms:list', getRoomsData()); + + socket.on('user:set', (userName) => { + username = userName; + }); + + socket.on('room:join', (roomId) => { + if (!rooms.has(roomId)) { + socket.emit('error', `Room "${roomId}" does not exist`); + + return; + } + + if (currentRoom) { + socket.leave(currentRoom); + } + + currentRoom = roomId; + socket.join(roomId); + + const room = rooms.get(roomId); + + socket.emit('room:history', { roomId, messages: room.messages }); + }); + + socket.on('room:create', ({ id, name: roomName }) => { + if (rooms.has(id)) { + socket.emit('error', `Room "${id}" already exists`); + + return; + } + + const room = createRoom(id, roomName); + + io.emit('rooms:list', getRoomsData()); + socket.emit('room:created', { id: room.id, name: room.name }); + }); + + socket.on('room:rename', ({ id, name: newName }) => { + if (!rooms.has(id)) { + socket.emit('error', `Room "${id}" does not exist`); + + return; + } + + rooms.get(id).name = newName; + io.emit('rooms:list', getRoomsData()); + }); + + socket.on('room:delete', (id) => { + if (id === DEFAULT_ROOM) { + socket.emit('error', 'Cannot delete the default room'); + + return; + } + + if (!rooms.has(id)) { + socket.emit('error', `Room "${id}" does not exist`); + + return; + } + + rooms.delete(id); + io.emit('rooms:list', getRoomsData()); + io.to(id).emit('room:deleted', id); + }); + + socket.on('message:send', ({ roomId, text }) => { + if (!rooms.has(roomId)) { + return; + } + + const message = { + id: Date.now() + Math.random().toString(36).slice(2), + author: username || 'Anonymous', + text, + time: new Date().toISOString(), + }; + + rooms.get(roomId).messages.push(message); + io.to(roomId).emit('message:new', { roomId, message }); + }); +}); + +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Chat server running at http://localhost:${PORT}`); +}); diff --git a/src/public/app.jsx b/src/public/app.jsx new file mode 100644 index 000000000..d397eba25 --- /dev/null +++ b/src/public/app.jsx @@ -0,0 +1,356 @@ +const { useState, useEffect, useRef, useCallback } = React; + +const socket = io(); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function formatTime(iso) { + const d = new Date(iso); + + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function slugify(str) { + return ( + str + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .slice(0, 32) || 'room' + ); +} + +function Modal({ + title, + initialValue = '', + onConfirm, + onCancel, + placeholder, + requireInput = false, +}) { + const [value, setValue] = useState(initialValue); + const inputRef = useRef(null); + const confirmDisabled = requireInput && !value.trim(); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + function handleKeyDown(e) { + if (e.key === 'Enter' && !confirmDisabled) onConfirm(value.trim()); + if (e.key === 'Escape') onCancel(); + } + + return ( +
+
e.stopPropagation()}> +

{title}

+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + /> +
+ + +
+
+
+ ); +} + +function LoginScreen({ onLogin }) { + const [name, setName] = useState(''); + + function handleSubmit(e) { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + onLogin(trimmed); + } + + return ( +
+

💬 Node Chat

+

Enter your name to start chatting

+
+ setName(e.target.value)} + placeholder="Your name..." + maxLength={32} + autoFocus + /> + +
+
+ ); +} + +// ── Room Item ───────────────────────────────────────────────────────────────── + +function RoomItem({ room, active, onJoin, onRename, onDelete }) { + return ( +
onJoin(room.id)} + > + # {room.name} +
e.stopPropagation()}> + + +
+
+ ); +} + +// ── Main App ────────────────────────────────────────────────────────────────── + +function App() { + const [username, setUsername] = useState( + () => localStorage.getItem('chat_username') || '', + ); + const [rooms, setRooms] = useState([]); + const [currentRoomId, setCurrentRoomId] = useState(null); + // messages: { [roomId]: Message[] } + const [messages, setMessages] = useState({}); + const [inputText, setInputText] = useState(''); + const [modal, setModal] = useState(null); // null | { type, room? } + const messagesEndRef = useRef(null); + + // ── Actions ── + + const joinRoom = useCallback((roomId) => { + setCurrentRoomId(roomId); + socket.emit('room:join', roomId); + }, []); + + // ── Socket events ── + + useEffect(() => { + socket.on('rooms:list', (list) => { + setRooms(list); + }); + + socket.on('room:history', ({ roomId, messages: msgs }) => { + setMessages((prev) => ({ ...prev, [roomId]: msgs })); + }); + + socket.on('message:new', ({ roomId, message }) => { + setMessages((prev) => ({ + ...prev, + [roomId]: [...(prev[roomId] || []), message], + })); + }); + + socket.on('room:deleted', (roomId) => { + setCurrentRoomId((prev) => (prev === roomId ? null : prev)); + setMessages((prev) => { + const copy = { ...prev }; + delete copy[roomId]; + return copy; + }); + }); + + socket.on('room:created', ({ id }) => { + joinRoom(id); + }); + + return () => { + socket.off('rooms:list'); + socket.off('room:history'); + socket.off('message:new'); + socket.off('room:deleted'); + socket.off('room:created'); + }; + }, [joinRoom]); + + // Sync username to server on login and after page reload + useEffect(() => { + if (!username) return; + + socket.emit('user:set', username); + joinRoom('general'); + }, [username, joinRoom]); + + // Auto-scroll to bottom when messages update + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, currentRoomId]); + + function handleLogin(newUsername) { + localStorage.setItem('chat_username', newUsername); + setUsername(newUsername); + } + + function sendMessage(e) { + e.preventDefault(); + const text = inputText.trim(); + if (!text || !currentRoomId) return; + socket.emit('message:send', { roomId: currentRoomId, text }); + setInputText(''); + } + + function handleCreateRoom(name) { + if (!name) return; + const id = slugify(name) + '-' + Date.now().toString(36); + socket.emit('room:create', { id, name }); + setModal(null); + } + + function handleRenameRoom(name) { + if (!name || !modal?.room) return; + socket.emit('room:rename', { id: modal.room.id, name }); + setModal(null); + } + + function handleDeleteRoom(room) { + socket.emit('room:delete', room.id); + setModal(null); + } + + // ── Render ── + + if (!username) { + return ; + } + + const currentMessages = messages[currentRoomId] || []; + const currentRoom = rooms.find((r) => r.id === currentRoomId); + + return ( +
+ {/* Sidebar */} +
+
+

💬 Node Chat

+
+ Logged in as {username} +
+
+ +
+ {rooms.map((room) => ( + setModal({ type: 'rename', room: r })} + onDelete={(r) => setModal({ type: 'delete', room: r })} + /> + ))} +
+ +
+ +
+
+ + {/* Chat main */} +
+
+ {currentRoom ? `# ${currentRoom.name}` : 'Select a room'} +
+ + {currentRoomId ? ( + <> +
+ {currentMessages.map((msg) => ( +
+
+ {msg.author} + {formatTime(msg.time)} +
+
{msg.text}
+
+ ))} +
+
+ +
+ setInputText(e.target.value)} + placeholder="Type a message..." + autoFocus + /> + +
+ + ) : ( +
+ Pick a room from the sidebar to start chatting +
+ )} +
+ + {/* Modals */} + {modal?.type === 'create' && ( + setModal(null)} + /> + )} + + {modal?.type === 'rename' && ( + setModal(null)} + /> + )} + + {modal?.type === 'delete' && ( + handleDeleteRoom(modal.room)} + onCancel={() => setModal(null)} + requireInput + /> + )} +
+ ); +} + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 000000000..ddafcf8b5 --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,18 @@ + + + + + + Node Chat + + + +
+ + + + + + + + diff --git a/src/public/styles.css b/src/public/styles.css new file mode 100644 index 000000000..9ccfc412b --- /dev/null +++ b/src/public/styles.css @@ -0,0 +1,420 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #1a1a2e; + color: #e0e0e0; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +/* ── Login Screen ── */ +.login-screen { + background: #16213e; + border-radius: 12px; + padding: 40px; + width: 360px; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + text-align: center; +} + +.login-screen h1 { + font-size: 28px; + margin-bottom: 8px; + color: #a78bfa; +} + +.login-screen p { + color: #8888aa; + margin-bottom: 28px; + font-size: 14px; +} + +.login-screen input { + width: 100%; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #334; + background: #0f3460; + color: #e0e0e0; + font-size: 16px; + margin-bottom: 14px; + outline: none; +} + +.login-screen input:focus { + border-color: #a78bfa; +} + +.login-screen button { + width: 100%; + padding: 12px; + border-radius: 8px; + border: none; + background: #a78bfa; + color: #fff; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.login-screen button:hover { + background: #7c3aed; +} + +/* ── Chat Layout ── */ +.chat-layout { + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; +} + +/* ── Sidebar ── */ +.sidebar { + width: 260px; + background: #16213e; + display: flex; + flex-direction: column; + border-right: 1px solid #223; + flex-shrink: 0; +} + +.sidebar-header { + padding: 18px 16px 12px; + border-bottom: 1px solid #223; +} + +.sidebar-header h2 { + font-size: 18px; + color: #a78bfa; + margin-bottom: 10px; +} + +.sidebar-header .user-info { + font-size: 13px; + color: #8888aa; +} + +.sidebar-header .user-info span { + color: #c4b5fd; + font-weight: 600; +} + +.rooms-list { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.room-item { + display: flex; + align-items: center; + padding: 10px 16px; + cursor: pointer; + transition: background 0.15s; + gap: 8px; +} + +.room-item:hover { + background: #1e2f5a; +} + +.room-item.active { + background: #2d1b69; + border-left: 3px solid #a78bfa; +} + +.room-item .room-name { + flex: 1; + font-size: 14px; + color: #ccc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.room-item.active .room-name { + color: #e0d0ff; + font-weight: 600; +} + +.room-actions { + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s; +} + +.room-item:hover .room-actions { + opacity: 1; +} + +.icon-btn { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + font-size: 13px; + line-height: 1; + transition: background 0.15s; + color: #aaa; +} + +.icon-btn:hover { + background: #3a2e6e; + color: #fff; +} + +.icon-btn.danger:hover { + background: #7f1d1d; + color: #fca5a5; +} + +.sidebar-footer { + padding: 12px 16px; + border-top: 1px solid #223; +} + +.new-room-btn { + width: 100%; + padding: 10px; + border-radius: 8px; + border: 1px dashed #4c1d95; + background: transparent; + color: #a78bfa; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} + +.new-room-btn:hover { + background: #2d1b69; +} + +/* ── Main Chat Area ── */ +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + background: #1a1a2e; + overflow: hidden; +} + +.chat-header { + padding: 16px 24px; + background: #16213e; + border-bottom: 1px solid #223; + font-size: 17px; + font-weight: 600; + color: #c4b5fd; +} + +.messages-area { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.messages-area::-webkit-scrollbar { + width: 6px; +} + +.messages-area::-webkit-scrollbar-track { + background: transparent; +} + +.messages-area::-webkit-scrollbar-thumb { + background: #334; + border-radius: 3px; +} + +.message { + display: flex; + flex-direction: column; + max-width: 680px; +} + +.message.own { + align-self: flex-end; + align-items: flex-end; +} + +.message-meta { + font-size: 12px; + color: #666; + margin-bottom: 4px; +} + +.message.own .message-meta { + color: #8888cc; +} + +.message-meta .author { + font-weight: 600; + color: #a78bfa; + margin-right: 6px; +} + +.message.own .message-meta .author { + color: #c4b5fd; +} + +.message-bubble { + background: #16213e; + border-radius: 12px 12px 12px 2px; + padding: 10px 14px; + font-size: 14px; + line-height: 1.5; + color: #ddd; + max-width: 100%; + word-break: break-word; +} + +.message.own .message-bubble { + background: #3b1f7a; + border-radius: 12px 12px 2px 12px; + color: #e8d5ff; +} + +.no-room { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: #555; + font-size: 16px; +} + +.message-input-area { + padding: 16px 24px; + background: #16213e; + border-top: 1px solid #223; + display: flex; + gap: 10px; +} + +.message-input-area input { + flex: 1; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #334; + background: #0f3460; + color: #e0e0e0; + font-size: 14px; + outline: none; +} + +.message-input-area input:focus { + border-color: #a78bfa; +} + +.message-input-area button { + padding: 12px 20px; + border-radius: 8px; + border: none; + background: #a78bfa; + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.message-input-area button:hover { + background: #7c3aed; +} + +.message-input-area button:disabled { + background: #444; + cursor: not-allowed; +} + +/* ── Modal ── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal { + background: #16213e; + border-radius: 12px; + padding: 28px; + width: 360px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} + +.modal h3 { + font-size: 18px; + color: #c4b5fd; + margin-bottom: 18px; +} + +.modal input { + width: 100%; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid #334; + background: #0f3460; + color: #e0e0e0; + font-size: 14px; + outline: none; + margin-bottom: 10px; +} + +.modal input:focus { + border-color: #a78bfa; +} + +.modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 6px; +} + +.modal-actions button { + padding: 9px 18px; + border-radius: 8px; + border: none; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.btn-primary { + background: #a78bfa; + color: #fff; +} + +.btn-primary:hover { + background: #7c3aed; +} + +.btn-secondary { + background: #223; + color: #aaa; +} + +.btn-secondary:hover { + background: #334; + color: #eee; +}