diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 000000000..84616cbd1 --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,581 @@ +/** + * Design System — Glassmorphism Dark Theme para Chat. + * + * Baseado no tema do projeto node_auth-app. + * Paleta: Indigo (#6366f1) + Dark (#0a0a0f) + Slate (#e2e8f0) + */ + +/* === Google Fonts === */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +/* === Reset & Base === */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #0a0a0f; + --bg-card: rgba(18, 18, 26, 0.7); + --bg-card-solid: #12121a; + --bg-input: rgba(255, 255, 255, 0.05); + --bg-hover: rgba(255, 255, 255, 0.08); + --fg: #e2e8f0; + --fg-muted: #64748b; + --primary: #6366f1; + --primary-hover: #818cf8; + --primary-glow: rgba(99, 102, 241, 0.3); + --border: rgba(255, 255, 255, 0.08); + --border-active: rgba(99, 102, 241, 0.4); + --success: #10b981; + --danger: #ef4444; + --danger-hover: #dc2626; + --radius: 12px; + --radius-sm: 8px; + --radius-lg: 16px; + --shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + --transition: 0.2s ease; +} + +html, body { + height: 100%; + font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-size: 16px; + color: var(--fg); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +body { + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +/* === Background Gradient === */ +body::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: + radial-gradient(ellipse at 20% 50%, rgba(99, 102, 241, 0.08) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.06) 0%, transparent 50%), + radial-gradient(ellipse at 50% 80%, rgba(34, 211, 238, 0.04) 0%, transparent 50%); + z-index: -1; + animation: gradientShift 20s ease-in-out infinite; +} + +@keyframes gradientShift { + 0%, 100% { transform: translate(0, 0) rotate(0deg); } + 33% { transform: translate(2%, -1%) rotate(1deg); } + 66% { transform: translate(-1%, 2%) rotate(-1deg); } +} + +/* === Username Screen === */ +.username-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 32px; + text-align: center; + animation: fadeIn 0.5s ease; +} + +.username-screen h1 { + font-size: 2.5rem; + font-weight: 700; + letter-spacing: -0.03em; +} + +.username-screen h1 span { + background: linear-gradient(135deg, var(--primary), #a78bfa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.username-screen p { + color: var(--fg-muted); + font-size: 1.05rem; + max-width: 400px; +} + +.username-form { + display: flex; + gap: 12px; + width: 100%; + max-width: 400px; +} + +.username-error { + color: var(--danger); + font-size: 0.875rem; + min-height: 20px; +} + +/* === Chat Layout === */ +.chat-app { + display: none; + width: 100vw; + height: 100vh; +} + +.chat-app.active { + display: grid; + grid-template-columns: 280px 1fr; + animation: fadeIn 0.3s ease; +} + +/* === Sidebar (Rooms) === */ +.sidebar { + background: var(--bg-card); + backdrop-filter: blur(20px); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-header h2 { + font-size: 1.1rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.room-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.room-list::-webkit-scrollbar { + width: 4px; +} + +.room-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.room-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition); + margin-bottom: 2px; + border: 1px solid transparent; +} + +.room-item:hover { + background: var(--bg-hover); +} + +.room-item.active { + background: rgba(99, 102, 241, 0.12); + border-color: var(--border-active); +} + +.room-item .room-name { + font-size: 0.9rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.room-actions { + display: flex; + gap: 4px; + opacity: 0; + transition: opacity var(--transition); +} + +.room-item:hover .room-actions { + opacity: 1; +} + +.room-actions button { + background: none; + border: none; + color: var(--fg-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + font-size: 0.75rem; + transition: all var(--transition); +} + +.room-actions button:hover { + color: var(--fg); + background: var(--bg-hover); +} + +.room-actions button.delete:hover { + color: var(--danger); +} + +/* Sidebar footer (create room) */ +.sidebar-footer { + padding: 12px; + border-top: 1px solid var(--border); +} + +.create-room-form { + display: flex; + gap: 8px; +} + +/* === Main Chat Area === */ +.chat-main { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg); +} + +.chat-header { + padding: 16px 24px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-card); + backdrop-filter: blur(20px); +} + +.chat-header-info h3 { + font-size: 1.05rem; + font-weight: 600; +} + +.chat-header-info span { + font-size: 0.8rem; + color: var(--fg-muted); +} + +.online-users { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: var(--fg-muted); +} + +.online-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 8px var(--success); +} + +/* === Messages Area === */ +.messages-container { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.messages-container::-webkit-scrollbar { + width: 6px; +} + +.messages-container::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 6px; +} + +.message { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 70%; + animation: slideUp 0.2s ease; +} + +.message.own { + align-self: flex-end; +} + +.message-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.75rem; +} + +.message-author { + font-weight: 600; + color: var(--primary); +} + +.message.own .message-author { + color: var(--success); +} + +.message-time { + color: var(--fg-muted); +} + +.message-bubble { + padding: 10px 16px; + border-radius: var(--radius); + background: var(--bg-card); + border: 1px solid var(--border); + font-size: 0.9rem; + line-height: 1.5; + word-break: break-word; +} + +.message.own .message-bubble { + background: rgba(99, 102, 241, 0.15); + border-color: var(--border-active); +} + +/* System message (join/leave/etc) */ +.message-system { + text-align: center; + color: var(--fg-muted); + font-size: 0.8rem; + font-style: italic; + padding: 8px 0; +} + +/* Empty state */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--fg-muted); + gap: 12px; +} + +.empty-state .icon { + font-size: 3rem; + opacity: 0.3; +} + +/* === Message Input === */ +.message-input-area { + padding: 16px 24px; + border-top: 1px solid var(--border); + background: var(--bg-card); + backdrop-filter: blur(20px); +} + +.message-form { + display: flex; + gap: 12px; +} + +/* === Shared Input Styles === */ +input[type="text"] { + flex: 1; + padding: 10px 16px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--fg); + font-family: inherit; + font-size: 0.9rem; + outline: none; + transition: all var(--transition); +} + +input[type="text"]::placeholder { + color: var(--fg-muted); +} + +input[type="text"]:focus { + border-color: var(--border-active); + box-shadow: 0 0 0 3px var(--primary-glow); + background: rgba(255, 255, 255, 0.08); +} + +/* === Button Styles === */ +.btn { + padding: 10px 20px; + border: none; + border-radius: var(--radius-sm); + font-family: inherit; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 4px 14px var(--primary-glow); +} + +.btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 6px 20px var(--primary-glow); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-sm { + padding: 6px 12px; + font-size: 0.8rem; +} + +.btn-icon { + background: none; + border: 1px solid var(--border); + color: var(--fg); + width: 38px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition); + padding: 0; + font-size: 1.1rem; +} + +.btn-icon:hover { + background: var(--bg-hover); + border-color: var(--border-active); +} + +/* === Animations === */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* === Responsive === */ +@media (max-width: 768px) { + .chat-app.active { + grid-template-columns: 1fr; + } + + .sidebar { + position: fixed; + top: 0; + left: -280px; + width: 280px; + z-index: 100; + transition: left var(--transition); + box-shadow: var(--shadow); + } + + .sidebar.open { + left: 0; + } + + .sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 99; + } + + .sidebar-overlay.open { + display: block; + } + + .mobile-menu-btn { + display: flex !important; + } +} + +@media (min-width: 769px) { + .mobile-menu-btn, + .sidebar-overlay { + display: none !important; + } +} + +/* === Modal (rename) === */ +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 200; + align-items: center; + justify-content: center; +} + +.modal-overlay.active { + display: flex; +} + +.modal { + background: var(--bg-card-solid); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 24px; + width: 90%; + max-width: 400px; + box-shadow: var(--shadow); + animation: fadeIn 0.2s ease; +} + +.modal h3 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 16px; +} + +.modal-actions { + display: flex; + gap: 8px; + margin-top: 16px; + justify-content: flex-end; +} + +.btn-ghost { + background: none; + border: 1px solid var(--border); + color: var(--fg); +} + +.btn-ghost:hover { + background: var(--bg-hover); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 000000000..1c9e4ce27 --- /dev/null +++ b/public/index.html @@ -0,0 +1,130 @@ + + + + + + + 💬 Chat em Tempo Real + + + + + +
+
+

💬 Chat

+

Digite seu nome para entrar no chat em tempo real

+
+
+ + +
+
+
+ + +
+ + + + + + + +
+ +
+
+ +
+

Geral

+ +
+
+
+ + 0 online +
+
+ + +
+
+
💬
+

Nenhuma mensagem ainda. Seja o primeiro!

+
+
+ + +
+
+ + +
+
+
+
+ + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 000000000..a79887709 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,89 @@ +/** + * app.js — Orquestrador principal do Chat. + * + * Módulo raiz que importa e inicializa todos os sub-módulos. + * Responsabilidades: + * - Gerenciar fluxo de username (localStorage + join) + * - Reconexão automática via Socket.IO + * - Inicializar rooms, messages, modal e mobile + * + * @module app + */ + +/* eslint-env browser */ + +import { socket, state } from './state.js'; +import { initRoomHandlers } from './rooms.js'; +import { initMessageHandlers } from './messages.js'; +import { initModalHandlers } from './modal.js'; +import { initMobileHandlers } from './mobile.js'; + +// === Referências DOM (apenas as do fluxo de username) === +const usernameScreen = document.getElementById('usernameScreen'); +const usernameForm = document.getElementById('usernameForm'); +const usernameInput = document.getElementById('usernameInput'); +const usernameError = document.getElementById('usernameError'); +const chatApp = document.getElementById('chatApp'); +const messageInput = document.getElementById('messageInput'); + +// === Fluxo de Username === + +/** + * Envia o username ao servidor e aguarda resposta. + * Se sucesso → mostra chat. Se erro → mostra mensagem. + * @param {string} userName - Nome de usuário desejado + */ +function joinWithUsername(userName) { + socket.emit('user:join', { username: userName }, (response) => { + if (response && response.success) { + state.username = userName; + localStorage.setItem('chat_username', userName); + usernameScreen.style.display = 'none'; + chatApp.classList.add('active'); + messageInput.focus(); + usernameError.textContent = ''; + } else { + usernameError.textContent = response + ? response.error + : 'Erro ao conectar'; + usernameScreen.style.display = ''; + chatApp.classList.remove('active'); + } + }); +} + +// Formulário de username +usernameForm.addEventListener('submit', (e) => { + e.preventDefault(); + + const userName = usernameInput.value.trim(); + + if (!userName) { + return; + } + + joinWithUsername(userName); +}); + +// === Reconexão automática === +socket.on('connect', () => { + if (state.username && !usernameScreen.style.display) { + joinWithUsername(state.username); + + if (state.currentRoomId) { + socket.emit('room:join', { roomId: state.currentRoomId }); + } + } +}); + +// === Inicializar todos os módulos === +initRoomHandlers(); +initMessageHandlers(); +initModalHandlers(); +initMobileHandlers(); + +// === Auto-login se já tiver username salvo === +if (state.username) { + usernameInput.value = state.username; + joinWithUsername(state.username); +} diff --git a/public/js/messages.js b/public/js/messages.js new file mode 100644 index 000000000..8a3793ccc --- /dev/null +++ b/public/js/messages.js @@ -0,0 +1,56 @@ +/** + * messages.js — Handlers de mensagens (Socket.IO + UI). + * + * Responsabilidades: + * - Receber histórico e novas mensagens do servidor + * - Enviar mensagens pelo formulário + * - Atualizar contagem de usuários online + * + * @module messages + */ + +/* eslint-env browser */ + +import { socket } from './state.js'; +import { renderMessages, appendMessage, scrollToBottom } from './ui.js'; + +/** + * Inicializa todos os handlers de mensagem. + * Chamado uma única vez pelo app.js na inicialização. + */ +export function initMessageHandlers() { + const messageForm = document.getElementById('messageForm'); + const messageInput = document.getElementById('messageInput'); + const onlineCount = document.getElementById('onlineCount'); + + // room:history — Recebe histórico ao entrar na sala + socket.on('room:history', (messages) => { + renderMessages(messages); + }); + + // message:new — Nova mensagem em tempo real + socket.on('message:new', (message) => { + appendMessage(message); + scrollToBottom(); + }); + + // user:list — Atualiza contagem de online + socket.on('user:list', (users) => { + onlineCount.textContent = users.length + ' online'; + }); + + // Enviar mensagem via formulário + messageForm.addEventListener('submit', (e) => { + e.preventDefault(); + + const text = messageInput.value.trim(); + + if (!text) { + return; + } + + socket.emit('message:send', { text }); + messageInput.value = ''; + messageInput.focus(); + }); +} diff --git a/public/js/mobile.js b/public/js/mobile.js new file mode 100644 index 000000000..eac328a26 --- /dev/null +++ b/public/js/mobile.js @@ -0,0 +1,37 @@ +/** + * mobile.js — Menu responsivo da sidebar. + * + * Responsabilidades: + * - Abrir/fechar sidebar em telas pequenas + * - Controlar overlay de fundo + * + * @module mobile + */ + +/* eslint-env browser */ + +const sidebar = document.getElementById('sidebar'); +const sidebarOverlay = document.getElementById('sidebarOverlay'); + +/** + * Fecha a sidebar mobile e o overlay. + */ +export function closeMobileSidebar() { + sidebar.classList.remove('open'); + sidebarOverlay.classList.remove('open'); +} + +/** + * Inicializa os event listeners do menu mobile. + * Chamado uma única vez pelo app.js na inicialização. + */ +export function initMobileHandlers() { + const mobileMenuBtn = document.getElementById('mobileMenuBtn'); + + mobileMenuBtn.addEventListener('click', () => { + sidebar.classList.toggle('open'); + sidebarOverlay.classList.toggle('open'); + }); + + sidebarOverlay.addEventListener('click', closeMobileSidebar); +} diff --git a/public/js/modal.js b/public/js/modal.js new file mode 100644 index 000000000..81203fb80 --- /dev/null +++ b/public/js/modal.js @@ -0,0 +1,85 @@ +/** + * modal.js — Modal de renomear sala. + * + * Responsabilidades: + * - Abrir/fechar o modal de rename + * - Enviar evento room:rename ao confirmar + * - Atalhos de teclado (Enter para confirmar, Escape para fechar) + * + * @module modal + */ + +/* eslint-env browser */ + +import { socket, state } from './state.js'; + +const renameModal = document.getElementById('renameModal'); +const renameInput = document.getElementById('renameInput'); +const renameCancelBtn = document.getElementById('renameCancelBtn'); +const renameConfirmBtn = document.getElementById('renameConfirmBtn'); + +/** + * Abre o modal de renomear preenchido com o nome atual. + * @param {string} roomId - ID da sala a renomear + * @param {string} currentName - Nome atual da sala + */ +export function openRenameModal(roomId, currentName) { + state.renameTargetId = roomId; + renameInput.value = currentName; + renameModal.classList.add('active'); + renameInput.focus(); +} + +/** + * Fecha o modal de renomear e limpa o estado. + */ +export function closeRenameModal() { + renameModal.classList.remove('active'); + state.renameTargetId = null; +} + +/** + * Inicializa os event listeners do modal. + * Chamado uma única vez pelo app.js na inicialização. + */ +export function initModalHandlers() { + const currentRoomName = document.getElementById('currentRoomName'); + + // Botão cancelar + renameCancelBtn.addEventListener('click', closeRenameModal); + + // Botão confirmar + renameConfirmBtn.addEventListener('click', () => { + const newName = renameInput.value.trim(); + + if (!newName || !state.renameTargetId) { + return; + } + + socket.emit('room:rename', { + roomId: state.renameTargetId, + name: newName, + }); + + // Se é a sala ativa, atualiza o header + if (state.renameTargetId === state.currentRoomId) { + currentRoomName.textContent = newName; + } + + closeRenameModal(); + }); + + // Enter para confirmar rename + renameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + renameConfirmBtn.click(); + } + }); + + // Escape para fechar modal + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && renameModal.classList.contains('active')) { + closeRenameModal(); + } + }); +} diff --git a/public/js/rooms.js b/public/js/rooms.js new file mode 100644 index 000000000..f4ec74177 --- /dev/null +++ b/public/js/rooms.js @@ -0,0 +1,143 @@ +/** + * rooms.js — Handlers de salas (Socket.IO + UI). + * + * Responsabilidades: + * - Renderizar a lista de salas na sidebar + * - Lidar com eventos room:list, room:default, room:destroyed, room:created + * - Criar salas via formulário + * - Entrar em salas ao clicar + * + * @module rooms + */ + +/* eslint-env browser */ + +import { socket, state } from './state.js'; +import { openRenameModal } from './modal.js'; +import { closeMobileSidebar } from './mobile.js'; + +/** + * Inicializa todos os handlers de sala. + * Chamado uma única vez pelo app.js na inicialização. + */ +export function initRoomHandlers() { + const roomList = document.getElementById('roomList'); + const currentRoomName = document.getElementById('currentRoomName'); + const createRoomForm = document.getElementById('createRoomForm'); + const newRoomInput = document.getElementById('newRoomInput'); + + // room:default — Recebe o ID da sala padrão "Geral" + socket.on('room:default', ({ roomId }) => { + state.defaultRoomId = roomId; + }); + + // room:list — Renderiza a sidebar com destaque na sala ativa + socket.on('room:list', (rooms) => { + roomList.innerHTML = ''; + + rooms.forEach((room) => { + const item = document.createElement('div'); + const isActive = room.id === state.currentRoomId; + + item.className = 'room-item' + (isActive ? ' active' : ''); + item.dataset.roomId = room.id; + + // Nome da sala + const nameSpan = document.createElement('span'); + + nameSpan.className = 'room-name'; + nameSpan.textContent = room.name; + + // Botões de ação (renomear, excluir) + const actions = document.createElement('div'); + + actions.className = 'room-actions'; + + // Não mostra ações na sala padrão "Geral" + if (room.id !== state.defaultRoomId) { + const renameBtn = document.createElement('button'); + + renameBtn.textContent = '✏️'; + renameBtn.title = 'Renomear'; + + renameBtn.addEventListener('click', (e) => { + e.stopPropagation(); + openRenameModal(room.id, room.name); + }); + + const deleteBtn = document.createElement('button'); + + deleteBtn.textContent = '🗑️'; + deleteBtn.title = 'Excluir'; + deleteBtn.className = 'delete'; + + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + socket.emit('room:delete', { roomId: room.id }); + }); + + actions.appendChild(renameBtn); + actions.appendChild(deleteBtn); + } + + item.appendChild(nameSpan); + item.appendChild(actions); + + // Click para entrar na sala + item.addEventListener('click', () => { + if (room.id !== state.currentRoomId) { + socket.emit('room:join', { roomId: room.id }); + state.currentRoomId = room.id; + currentRoomName.textContent = room.name; + closeMobileSidebar(); + + // Atualiza destaque visual + document.querySelectorAll('.room-item').forEach((el) => { + el.classList.remove('active'); + }); + item.classList.add('active'); + } + }); + + roomList.appendChild(item); + }); + + // Se ainda não está em nenhuma sala, entra na padrão + if (!state.currentRoomId && state.defaultRoomId) { + state.currentRoomId = state.defaultRoomId; + + const defaultRoom = rooms.find((r) => r.id === state.defaultRoomId); + + if (defaultRoom) { + currentRoomName.textContent = defaultRoom.name; + } + } + }); + + // room:destroyed — Sala excluída, redireciona para "Geral" + socket.on('room:destroyed', ({ redirectTo }) => { + state.currentRoomId = redirectTo; + currentRoomName.textContent = 'Geral'; + }); + + // room:created — Sala criada com sucesso, entra automaticamente + socket.on('room:created', (room) => { + state.currentRoomId = room.id; + currentRoomName.textContent = room.name; + socket.emit('room:join', { roomId: room.id }); + }); + + // Criar sala via formulário + createRoomForm.addEventListener('submit', (e) => { + e.preventDefault(); + + const roomName = newRoomInput.value.trim(); + + if (!roomName) { + return; + } + + socket.emit('room:create', { name: roomName }); + newRoomInput.value = ''; + }); +} diff --git a/public/js/state.js b/public/js/state.js new file mode 100644 index 000000000..e25b18088 --- /dev/null +++ b/public/js/state.js @@ -0,0 +1,23 @@ +/** + * state.js — Estado global compartilhado e conexão Socket.IO. + * + * Centraliza todas as variáveis de estado do cliente + * e a instância do socket para que os demais módulos + * importem a mesma referência. + * + * @module state + */ + +/* eslint-env browser */ +/* global io */ + +// === Conexão Socket.IO === +export const socket = io(); + +// === Estado mutável do cliente === +export const state = { + username: localStorage.getItem('chat_username') || '', + currentRoomId: null, + defaultRoomId: null, + renameTargetId: null, +}; diff --git a/public/js/ui.js b/public/js/ui.js new file mode 100644 index 000000000..3cf322e58 --- /dev/null +++ b/public/js/ui.js @@ -0,0 +1,96 @@ +/** + * ui.js — Funções de renderização de mensagens. + * + * Responsabilidades: + * - Renderizar histórico de mensagens + * - Adicionar mensagens individuais ao DOM + * - Scroll automático para o final + * - Prevenção de XSS (escapeHtml) + * + * @module ui + */ + +/* eslint-env browser */ + +import { state } from './state.js'; + +/** + * Escapa caracteres HTML para prevenir XSS. + * @param {string} text - Texto a ser escapado + * @returns {string} Texto seguro para innerHTML + */ +export function escapeHtml(text) { + const div = document.createElement('div'); + + div.textContent = text; + + return div.innerHTML; +} + +/** + * Rola o container de mensagens para o final. + */ +export function scrollToBottom() { + const container = document.getElementById('messagesContainer'); + + container.scrollTop = container.scrollHeight; +} + +/** + * Adiciona uma mensagem ao container. + * @param {{ author: string, text: string, time: string }} msg + */ +export function appendMessage(msg) { + const container = document.getElementById('messagesContainer'); + + // Remove o empty state se existir + const empty = container.querySelector('.empty-state'); + + if (empty) { + empty.remove(); + } + + const isOwn = msg.author === state.username; + const div = document.createElement('div'); + + div.className = 'message' + (isOwn ? ' own' : ''); + + const time = new Date(msg.time); + const timeStr = time.toLocaleTimeString('pt-BR', { + hour: '2-digit', + minute: '2-digit', + }); + + div.innerHTML = ` +
+ ${escapeHtml(msg.author)} + ${timeStr} +
+
${escapeHtml(msg.text)}
+ `; + + container.appendChild(div); +} + +/** + * Renderiza todas as mensagens de uma sala (histórico completo). + * @param {{ author: string, text: string, time: string }[]} messages + */ +export function renderMessages(messages) { + const container = document.getElementById('messagesContainer'); + + if (messages.length === 0) { + container.innerHTML = ` +
+
💬
+

Nenhuma mensagem ainda. Seja o primeiro!

+
+ `; + + return; + } + + container.innerHTML = ''; + messages.forEach((msg) => appendMessage(msg)); + scrollToBottom(); +}