748 lines
29 KiB
HTML
748 lines
29 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>PSP Chat</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
:root{
|
|
--bg: #17212b;
|
|
--sidebar: #232e3c;
|
|
--panel: #17212b;
|
|
--bubble-me:#2b5278;
|
|
--bubble-other:#182533;
|
|
--accent: #5288c1;
|
|
--accent2: #64b5f6;
|
|
--text: #e8f1f9;
|
|
--text2: #7d9db7;
|
|
--text3: #4a6580;
|
|
--border: #2a3b4c;
|
|
--hover: #2a3a4a;
|
|
--green: #4caf88;
|
|
--red: #e05060;
|
|
--header-h: 56px;
|
|
--sidebar-w:300px;
|
|
}
|
|
|
|
html,body{height:100%;overflow:hidden;font-family:'Manrope',sans-serif;background:var(--bg);color:var(--text)}
|
|
|
|
/* ── LOGIN SCREEN ─────────────────────────────────────────────────────────── */
|
|
#login-screen{
|
|
position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
|
|
background:var(--bg);z-index:50;
|
|
animation:fadeIn .4s ease;
|
|
}
|
|
.login-box{
|
|
background:var(--sidebar);border-radius:16px;padding:40px 48px;width:360px;
|
|
box-shadow:0 24px 64px rgba(0,0,0,.5);
|
|
animation:slideUp .4s cubic-bezier(.22,1,.36,1);
|
|
}
|
|
.login-logo{text-align:center;margin-bottom:28px}
|
|
.login-logo .plane{font-size:56px;filter:drop-shadow(0 4px 12px rgba(82,136,193,.5));animation:float 3s ease-in-out infinite}
|
|
.login-logo h1{font-size:26px;font-weight:700;letter-spacing:-.5px;margin-top:8px}
|
|
.login-logo span{color:var(--accent2)}
|
|
.login-box p{color:var(--text2);font-size:14px;text-align:center;margin-bottom:28px}
|
|
.input-group{position:relative;margin-bottom:16px}
|
|
.input-group label{display:block;font-size:11px;font-weight:600;color:var(--text2);letter-spacing:.5px;text-transform:uppercase;margin-bottom:6px}
|
|
.input-group input{
|
|
width:100%;padding:12px 16px;background:var(--bg);border:1.5px solid var(--border);
|
|
border-radius:10px;color:var(--text);font-size:15px;font-family:'Manrope',sans-serif;outline:none;transition:.2s
|
|
}
|
|
.input-group input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(82,136,193,.15)}
|
|
.input-group input::placeholder{color:var(--text3)}
|
|
.btn-connect{
|
|
width:100%;padding:13px;background:var(--accent);color:#fff;border:none;border-radius:10px;
|
|
font-size:15px;font-weight:600;font-family:'Manrope',sans-serif;cursor:pointer;
|
|
transition:.2s;margin-top:4px;
|
|
}
|
|
.btn-connect:hover{background:var(--accent2);transform:translateY(-1px);box-shadow:0 6px 20px rgba(82,136,193,.35)}
|
|
.btn-connect:active{transform:translateY(0)}
|
|
.login-status{text-align:center;font-size:13px;color:var(--red);margin-top:12px;min-height:18px}
|
|
|
|
/* ── MAIN LAYOUT ──────────────────────────────────────────────────────────── */
|
|
#app{display:none;height:100%;flex-direction:row}
|
|
#app.visible{display:flex;animation:fadeIn .3s ease}
|
|
|
|
/* ── SIDEBAR ──────────────────────────────────────────────────────────────── */
|
|
.sidebar{
|
|
width:var(--sidebar-w);flex-shrink:0;background:var(--sidebar);
|
|
display:flex;flex-direction:column;border-right:1px solid var(--border);
|
|
}
|
|
.sidebar-header{
|
|
height:var(--header-h);display:flex;align-items:center;padding:0 16px;gap:10px;
|
|
border-bottom:1px solid var(--border);
|
|
}
|
|
.sidebar-header .logo{font-size:22px;font-weight:800;letter-spacing:-1px;flex:1}
|
|
.sidebar-header .logo span{color:var(--accent2)}
|
|
.sidebar-header .user-chip{
|
|
display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg);
|
|
border-radius:20px;font-size:13px;font-weight:600;
|
|
}
|
|
.avatar{
|
|
width:28px;height:28px;border-radius:50%;background:var(--accent);
|
|
display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff;
|
|
flex-shrink:0;
|
|
}
|
|
.avatar.sm{width:36px;height:36px;font-size:14px}
|
|
|
|
.sidebar-section{padding:12px 16px 4px;font-size:10px;font-weight:700;letter-spacing:1px;color:var(--text3);text-transform:uppercase;display:flex;align-items:center;gap:8px}
|
|
.sidebar-section button{margin-left:auto;background:none;border:none;cursor:pointer;color:var(--accent2);font-size:16px;padding:0;line-height:1;transition:.15s}
|
|
.sidebar-section button:hover{color:var(--text)}
|
|
|
|
.room-item{
|
|
display:flex;align-items:center;padding:10px 16px;cursor:pointer;
|
|
gap:10px;border-radius:0;transition:.15s;position:relative;
|
|
}
|
|
.room-item:hover{background:var(--hover)}
|
|
.room-item.active{background:var(--hover)}
|
|
.room-item.active::before{content:'';position:absolute;left:0;top:4px;bottom:4px;width:3px;background:var(--accent2);border-radius:0 2px 2px 0}
|
|
.room-icon{width:36px;height:36px;border-radius:50%;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
|
|
.room-info{flex:1;min-width:0}
|
|
.room-name{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.room-preview{font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.room-badge{
|
|
background:var(--accent);color:#fff;border-radius:10px;
|
|
font-size:11px;font-weight:700;padding:2px 7px;flex-shrink:0;
|
|
}
|
|
|
|
.user-item{display:flex;align-items:center;padding:8px 16px;gap:10px;cursor:pointer;transition:.15s}
|
|
.user-item:hover{background:var(--hover)}
|
|
.user-item .uname{font-size:14px;font-weight:500}
|
|
.online-dot{width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0}
|
|
|
|
.sidebar-footer{padding:12px 16px;border-top:1px solid var(--border)}
|
|
.sidebar-footer button{
|
|
width:100%;padding:10px;background:var(--red);color:#fff;border:none;border-radius:8px;
|
|
font-size:13px;font-weight:600;font-family:'Manrope',sans-serif;cursor:pointer;transition:.2s
|
|
}
|
|
.sidebar-footer button:hover{filter:brightness(1.1)}
|
|
|
|
/* ── CHAT PANEL ───────────────────────────────────────────────────────────── */
|
|
.chat-panel{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
|
|
|
.chat-header{
|
|
height:var(--header-h);display:flex;align-items:center;padding:0 20px;gap:12px;
|
|
background:var(--sidebar);border-bottom:1px solid var(--border);
|
|
}
|
|
.chat-header .room-title{font-size:16px;font-weight:700;flex:1}
|
|
.chat-header .room-meta{font-size:12px;color:var(--text2)}
|
|
.chat-header .actions button{background:none;border:none;cursor:pointer;color:var(--text2);font-size:18px;padding:6px;border-radius:6px;transition:.15s}
|
|
.chat-header .actions button:hover{background:var(--hover);color:var(--text)}
|
|
|
|
/* Messages area */
|
|
.messages{
|
|
flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:2px;
|
|
background:var(--panel);
|
|
background-image:
|
|
radial-gradient(circle at 20% 80%, rgba(82,136,193,.04) 0%, transparent 50%),
|
|
radial-gradient(circle at 80% 20%, rgba(82,136,193,.04) 0%, transparent 50%);
|
|
}
|
|
.messages::-webkit-scrollbar{width:4px}
|
|
.messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
|
|
|
/* Message bubbles */
|
|
.msg-wrap{display:flex;align-items:flex-end;gap:8px;margin-bottom:2px;animation:msgIn .2s cubic-bezier(.22,1,.36,1)}
|
|
.msg-wrap.mine{flex-direction:row-reverse}
|
|
.msg-wrap .av{flex-shrink:0;margin-bottom:4px}
|
|
.bubble{
|
|
max-width:520px;padding:8px 12px;border-radius:16px;position:relative;
|
|
font-size:14px;line-height:1.5;word-break:break-word;
|
|
}
|
|
.msg-wrap:not(.mine) .bubble{
|
|
background:var(--bubble-other);border-bottom-left-radius:4px;
|
|
}
|
|
.msg-wrap.mine .bubble{
|
|
background:var(--bubble-me);border-bottom-right-radius:4px;
|
|
}
|
|
.bubble .sender{font-size:12px;font-weight:700;color:var(--accent2);margin-bottom:2px}
|
|
.bubble .text{color:var(--text)}
|
|
.bubble .meta{display:flex;align-items:center;justify-content:flex-end;gap:4px;margin-top:4px}
|
|
.bubble .ts{font-size:10px;color:var(--text2)}
|
|
.bubble .ticks{font-size:11px;color:var(--accent2)}
|
|
|
|
/* Event messages */
|
|
.event-msg{
|
|
text-align:center;font-size:12px;color:var(--text3);
|
|
padding:4px 12px;background:rgba(0,0,0,.2);border-radius:12px;
|
|
margin:6px auto;max-width:400px;animation:msgIn .2s ease;
|
|
}
|
|
|
|
/* PM badge */
|
|
.pm-bubble{
|
|
border:1px solid var(--accent);background:rgba(82,136,193,.1) !important;
|
|
}
|
|
.pm-label{font-size:10px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px}
|
|
|
|
/* Input area */
|
|
.input-area{
|
|
display:flex;align-items:flex-end;padding:12px 16px;gap:10px;
|
|
background:var(--sidebar);border-top:1px solid var(--border);
|
|
}
|
|
.input-area textarea{
|
|
flex:1;padding:12px 16px;background:var(--bg);border:none;border-radius:12px;
|
|
color:var(--text);font-size:14px;font-family:'Manrope',sans-serif;outline:none;
|
|
resize:none;max-height:120px;line-height:1.5;transition:.2s;
|
|
}
|
|
.input-area textarea::placeholder{color:var(--text3)}
|
|
.send-btn{
|
|
width:44px;height:44px;border-radius:50%;background:var(--accent);border:none;
|
|
cursor:pointer;display:flex;align-items:center;justify-content:center;
|
|
transition:.2s;flex-shrink:0;font-size:18px;
|
|
}
|
|
.send-btn:hover{background:var(--accent2);transform:scale(1.05)}
|
|
.send-btn:active{transform:scale(.96)}
|
|
|
|
/* Welcome / empty */
|
|
.welcome-screen{
|
|
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
color:var(--text2);gap:16px;background:var(--panel);
|
|
}
|
|
.welcome-screen .big-icon{font-size:72px;opacity:.3;animation:float 4s ease-in-out infinite}
|
|
.welcome-screen h2{font-size:22px;font-weight:600;color:var(--text)}
|
|
.welcome-screen p{font-size:14px;max-width:320px;text-align:center;line-height:1.6}
|
|
|
|
/* Modal */
|
|
.modal-overlay{
|
|
position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;
|
|
align-items:center;justify-content:center;z-index:100;
|
|
animation:fadeIn .2s ease;
|
|
}
|
|
.modal{
|
|
background:var(--sidebar);border-radius:14px;padding:28px;width:320px;
|
|
box-shadow:0 16px 48px rgba(0,0,0,.5);
|
|
animation:slideUp .3s cubic-bezier(.22,1,.36,1);
|
|
}
|
|
.modal h3{font-size:18px;font-weight:700;margin-bottom:16px}
|
|
.modal input{
|
|
width:100%;padding:10px 14px;background:var(--bg);border:1.5px solid var(--border);
|
|
border-radius:8px;color:var(--text);font-size:14px;font-family:'Manrope',sans-serif;outline:none;margin-bottom:16px;
|
|
}
|
|
.modal input:focus{border-color:var(--accent)}
|
|
.modal-btns{display:flex;gap:8px;justify-content:flex-end}
|
|
.modal-btns button{padding:8px 20px;border-radius:8px;font-family:'Manrope',sans-serif;font-size:14px;font-weight:600;cursor:pointer;border:none;transition:.15s}
|
|
.modal-btns .ok{background:var(--accent);color:#fff}
|
|
.modal-btns .ok:hover{background:var(--accent2)}
|
|
.modal-btns .cancel{background:var(--bg);color:var(--text2)}
|
|
|
|
/* PM input area */
|
|
.pm-banner{
|
|
display:flex;align-items:center;padding:8px 16px;background:rgba(82,136,193,.1);
|
|
border-top:1px solid var(--accent);font-size:13px;gap:8px;
|
|
}
|
|
.pm-banner span{flex:1;color:var(--accent2)}
|
|
.pm-banner button{background:none;border:none;cursor:pointer;color:var(--text2);font-size:16px;padding:2px}
|
|
|
|
/* Animations */
|
|
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
|
@keyframes slideUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
|
|
@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
|
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
|
|
|
|
/* Notifications badge */
|
|
.notif{position:relative}
|
|
.notif::after{
|
|
content:attr(data-count);position:absolute;top:-4px;right:-8px;
|
|
background:var(--red);color:#fff;border-radius:10px;font-size:10px;
|
|
font-weight:700;padding:1px 5px;display:none;
|
|
}
|
|
.notif[data-count]:not([data-count="0"])::after{display:block}
|
|
|
|
/* Typing indicator */
|
|
.typing{font-size:12px;color:var(--text2);padding:4px 20px;min-height:20px;font-style:italic}
|
|
|
|
@media(max-width:700px){
|
|
.sidebar{position:absolute;z-index:10;height:100%;transform:translateX(-100%);transition:.3s}
|
|
.sidebar.open{transform:translateX(0)}
|
|
:root{--sidebar-w:280px}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ── LOGIN ─────────────────────────────────────────────────────────────── -->
|
|
<div id="login-screen">
|
|
<div class="login-box">
|
|
<div class="login-logo">
|
|
<div class="plane">✈️</div>
|
|
<h1>PSP <span>Chat</span></h1>
|
|
</div>
|
|
<p>Servidor TCP {{ server_host }}:{{ server_port }}</p>
|
|
<div class="input-group">
|
|
<label>Tu nickname</label>
|
|
<input id="nick-input" type="text" placeholder="Ej: javier" maxlength="20"
|
|
onkeydown="if(event.key==='Enter')connect()"/>
|
|
</div>
|
|
<button class="btn-connect" onclick="connect()">Conectar al servidor →</button>
|
|
<div id="login-status" class="login-status"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── APP ───────────────────────────────────────────────────────────────── -->
|
|
<div id="app">
|
|
<!-- SIDEBAR -->
|
|
<div class="sidebar" id="sidebar">
|
|
<div class="sidebar-header">
|
|
<div class="logo">PSP <span>Chat</span></div>
|
|
<div class="user-chip">
|
|
<div class="avatar" id="my-avatar">?</div>
|
|
<span id="my-nick">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Rooms -->
|
|
<div class="sidebar-section">
|
|
Salas
|
|
<button onclick="showCreateRoom()" title="Crear sala">+</button>
|
|
</div>
|
|
<div id="room-list"></div>
|
|
|
|
<!-- Users -->
|
|
<div class="sidebar-section">
|
|
En línea
|
|
<span id="user-count" style="color:var(--green);font-size:11px;margin-left:auto;font-weight:700"></span>
|
|
</div>
|
|
<div id="user-list" style="flex:1;overflow-y:auto"></div>
|
|
|
|
<div class="sidebar-footer">
|
|
<button onclick="disconnect()">Desconectar</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CHAT PANEL -->
|
|
<div class="chat-panel" id="chat-panel">
|
|
<div class="welcome-screen" id="welcome-screen">
|
|
<div class="big-icon">💬</div>
|
|
<h2>¡Bienvenido al chat!</h2>
|
|
<p>Selecciona una sala o haz clic en un usuario para enviarle un mensaje privado.</p>
|
|
</div>
|
|
|
|
<div id="active-chat" style="display:none;flex:1;flex-direction:column;overflow:hidden">
|
|
<div class="chat-header">
|
|
<div class="room-icon" id="chat-icon">💬</div>
|
|
<div style="flex:1">
|
|
<div class="room-title" id="chat-title">—</div>
|
|
<div class="room-meta" id="chat-meta"></div>
|
|
</div>
|
|
<div class="actions">
|
|
<button onclick="leaveCurrentRoom()" id="btn-leave" title="Salir de la sala" style="display:none">🚪</button>
|
|
</div>
|
|
</div>
|
|
<div class="messages" id="messages"></div>
|
|
<div class="typing" id="typing-indicator"></div>
|
|
<div id="pm-banner" class="pm-banner" style="display:none">
|
|
<span>📩 Mensaje privado a <strong id="pm-target-label"></strong></span>
|
|
<button onclick="clearPM()" title="Cancelar">✖</button>
|
|
</div>
|
|
<div class="input-area">
|
|
<textarea id="msg-input" rows="1" placeholder="Escribe un mensaje..."
|
|
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
|
<button class="send-btn" onclick="sendMessage()" title="Enviar">➤</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CREATE ROOM MODAL -->
|
|
<div class="modal-overlay" id="create-modal" style="display:none" onclick="if(event.target===this)hideCreateRoom()">
|
|
<div class="modal">
|
|
<h3>Nueva sala</h3>
|
|
<input id="new-room-input" type="text" placeholder="nombre_sala" maxlength="30"
|
|
onkeydown="if(event.key==='Enter')createRoom()"/>
|
|
<div class="modal-btns">
|
|
<button class="cancel" onclick="hideCreateRoom()">Cancelar</button>
|
|
<button class="ok" onclick="createRoom()">Crear</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ── State ─────────────────────────────────────────────────────────────────
|
|
const socket = io();
|
|
let myNick = '';
|
|
let currentRoom = null; // room name or null
|
|
let pmTarget = null; // nick for private message
|
|
let myRooms = new Set(['general']);
|
|
let unread = {}; // room/pm → count
|
|
let msgHistory = {}; // room → [{...}]
|
|
let onlineUsers = [];
|
|
|
|
// ── Connect / Disconnect ──────────────────────────────────────────────────
|
|
function connect() {
|
|
const nick = document.getElementById('nick-input').value.trim();
|
|
if (!nick) { setLoginStatus('Escribe tu nickname'); return; }
|
|
if (!/^[a-zA-Z0-9_]{1,20}$/.test(nick)) {
|
|
setLoginStatus('Solo letras, números y _ (máx 20)'); return;
|
|
}
|
|
setLoginStatus('Conectando...');
|
|
socket.emit('join', {nick});
|
|
}
|
|
|
|
function disconnect() {
|
|
location.reload();
|
|
}
|
|
|
|
function setLoginStatus(msg, isError=true) {
|
|
const el = document.getElementById('login-status');
|
|
el.textContent = msg;
|
|
el.style.color = isError ? 'var(--red)' : 'var(--green)';
|
|
}
|
|
|
|
// ── Socket events ─────────────────────────────────────────────────────────
|
|
socket.on('srv_msg', msg => {
|
|
console.log('←', msg);
|
|
switch(msg.type) {
|
|
case 'welcome': handleWelcome(msg); break;
|
|
case 'msg': handleMsg(msg); break;
|
|
case 'pm': handlePM(msg); break;
|
|
case 'event': handleEvent(msg); break;
|
|
case 'users': handleUsers(msg); break;
|
|
case 'rooms': handleRooms(msg); break;
|
|
case 'joined_room':handleJoinedRoom(msg); break;
|
|
case 'error': handleError(msg); break;
|
|
}
|
|
});
|
|
|
|
function handleWelcome(msg) {
|
|
myNick = msg.nick;
|
|
document.getElementById('my-nick').textContent = myNick;
|
|
document.getElementById('my-avatar').textContent = myNick[0].toUpperCase();
|
|
|
|
// Show app
|
|
document.getElementById('login-screen').style.display = 'none';
|
|
document.getElementById('app').classList.add('visible');
|
|
|
|
// Init rooms
|
|
(msg.rooms || ['general']).forEach(r => myRooms.add(r));
|
|
refreshRoomList();
|
|
|
|
// Auto-join general
|
|
switchToRoom('general');
|
|
}
|
|
|
|
function handleMsg(msg) {
|
|
if (!msgHistory[msg.room]) msgHistory[msg.room] = [];
|
|
msgHistory[msg.room].push(msg);
|
|
|
|
if (currentRoom === msg.room) {
|
|
appendBubble(msg);
|
|
scrollBottom();
|
|
} else {
|
|
unread[msg.room] = (unread[msg.room] || 0) + 1;
|
|
refreshRoomList();
|
|
notifyTitle();
|
|
}
|
|
|
|
// Update preview
|
|
updateRoomPreview(msg.room, msg.from + ': ' + msg.text);
|
|
}
|
|
|
|
function handlePM(msg) {
|
|
const key = msg.from === myNick ? 'pm:' + msg.to : 'pm:' + msg.from;
|
|
if (!msgHistory[key]) msgHistory[key] = [];
|
|
msgHistory[key].push(msg);
|
|
|
|
if (pmTarget && (msg.from === pmTarget || (msg.from === myNick && msg.to === pmTarget))) {
|
|
appendPMBubble(msg);
|
|
scrollBottom();
|
|
} else if (msg.from !== myNick) {
|
|
unread[key] = (unread[key] || 0) + 1;
|
|
// Add/refresh DM in user list
|
|
refreshUserList();
|
|
notifyTitle();
|
|
}
|
|
}
|
|
|
|
function handleEvent(msg) {
|
|
if (!msgHistory[msg.room]) msgHistory[msg.room] = [];
|
|
const ev = {...msg, isEvent: true};
|
|
msgHistory[msg.room].push(ev);
|
|
if (currentRoom === msg.room) {
|
|
appendEvent(msg.text);
|
|
scrollBottom();
|
|
}
|
|
}
|
|
|
|
function handleUsers(msg) {
|
|
onlineUsers = msg.users || [];
|
|
refreshUserList();
|
|
document.getElementById('user-count').textContent = onlineUsers.length;
|
|
}
|
|
|
|
function handleRooms(msg) {
|
|
// rooms is an object room→members
|
|
refreshRoomList();
|
|
}
|
|
|
|
function handleJoinedRoom(msg) {
|
|
myRooms.add(msg.room);
|
|
refreshRoomList();
|
|
switchToRoom(msg.room);
|
|
}
|
|
|
|
function handleError(msg) {
|
|
if (!document.getElementById('app').classList.contains('visible')) {
|
|
setLoginStatus(msg.text);
|
|
} else {
|
|
showToast('❌ ' + msg.text, true);
|
|
}
|
|
}
|
|
|
|
// ── Room logic ────────────────────────────────────────────────────────────
|
|
function switchToRoom(room) {
|
|
currentRoom = room;
|
|
pmTarget = null;
|
|
clearPMBanner();
|
|
|
|
document.getElementById('welcome-screen').style.display = 'none';
|
|
const ac = document.getElementById('active-chat');
|
|
ac.style.display = 'flex';
|
|
|
|
document.getElementById('chat-icon').textContent = room === 'general' ? '🌐' : '💬';
|
|
document.getElementById('chat-title').textContent = '#' + room;
|
|
document.getElementById('chat-meta').textContent = 'Sala pública';
|
|
document.getElementById('btn-leave').style.display = room === 'general' ? 'none' : '';
|
|
|
|
unread[room] = 0;
|
|
refreshRoomList();
|
|
renderHistory(room, false);
|
|
scrollBottom();
|
|
}
|
|
|
|
function switchToPM(nick) {
|
|
pmTarget = nick;
|
|
currentRoom = null;
|
|
clearPMBanner();
|
|
|
|
document.getElementById('welcome-screen').style.display = 'none';
|
|
const ac = document.getElementById('active-chat');
|
|
ac.style.display = 'flex';
|
|
|
|
document.getElementById('chat-icon').textContent = '👤';
|
|
document.getElementById('chat-title').textContent = nick;
|
|
document.getElementById('chat-meta').textContent = 'Mensaje privado';
|
|
document.getElementById('btn-leave').style.display = 'none';
|
|
|
|
// PM banner
|
|
document.getElementById('pm-banner').style.display = 'flex';
|
|
document.getElementById('pm-target-label').textContent = nick;
|
|
|
|
const key = 'pm:' + nick;
|
|
unread[key] = 0;
|
|
refreshUserList();
|
|
renderHistory(key, true);
|
|
scrollBottom();
|
|
}
|
|
|
|
function clearPM() {
|
|
pmTarget = null;
|
|
clearPMBanner();
|
|
if (myRooms.has('general')) switchToRoom('general');
|
|
}
|
|
|
|
function clearPMBanner() {
|
|
document.getElementById('pm-banner').style.display = 'none';
|
|
}
|
|
|
|
function leaveCurrentRoom() {
|
|
if (!currentRoom || currentRoom === 'general') return;
|
|
socket.emit('cmd', {type:'leave_room', room:currentRoom});
|
|
myRooms.delete(currentRoom);
|
|
switchToRoom('general');
|
|
refreshRoomList();
|
|
}
|
|
|
|
function updateRoomPreview(room, text) {
|
|
const el = document.querySelector(`.room-item[data-room="${room}"] .room-preview`);
|
|
if (el) el.textContent = text;
|
|
}
|
|
|
|
// ── Send ──────────────────────────────────────────────────────────────────
|
|
function sendMessage() {
|
|
const ta = document.getElementById('msg-input');
|
|
const text = ta.value.trim();
|
|
if (!text) return;
|
|
ta.value = '';
|
|
autoResize(ta);
|
|
|
|
if (pmTarget) {
|
|
socket.emit('send_pm', {type:'pm', to:pmTarget, text});
|
|
} else if (currentRoom) {
|
|
socket.emit('send_msg', {type:'msg', room:currentRoom, text});
|
|
}
|
|
}
|
|
|
|
function handleKey(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
}
|
|
|
|
function autoResize(el) {
|
|
el.style.height = 'auto';
|
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
|
}
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
function renderHistory(key, isPM) {
|
|
const msgs = msgHistory[key] || [];
|
|
const container = document.getElementById('messages');
|
|
container.innerHTML = '';
|
|
msgs.forEach(m => {
|
|
if (m.isEvent) appendEvent(m.text);
|
|
else if (isPM) appendPMBubble(m);
|
|
else appendBubble(m);
|
|
});
|
|
}
|
|
|
|
function appendBubble(msg) {
|
|
const container = document.getElementById('messages');
|
|
const isMe = msg.from === myNick;
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'msg-wrap' + (isMe ? ' mine' : '');
|
|
wrap.innerHTML = `
|
|
${!isMe ? `<div class="av"><div class="avatar sm" style="background:${nickColor(msg.from)}">${msg.from[0].toUpperCase()}</div></div>` : ''}
|
|
<div class="bubble">
|
|
${!isMe ? `<div class="sender">${esc(msg.from)}</div>` : ''}
|
|
<div class="text">${esc(msg.text)}</div>
|
|
<div class="meta"><span class="ts">${msg.ts}</span>${isMe ? '<span class="ticks">✓✓</span>' : ''}</div>
|
|
</div>
|
|
${isMe ? `<div class="av"><div class="avatar sm" style="background:${nickColor(msg.from)}">${msg.from[0].toUpperCase()}</div></div>` : ''}
|
|
`;
|
|
container.appendChild(wrap);
|
|
}
|
|
|
|
function appendPMBubble(msg) {
|
|
const container = document.getElementById('messages');
|
|
const isMe = msg.from === myNick;
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'msg-wrap' + (isMe ? ' mine' : '');
|
|
wrap.innerHTML = `
|
|
${!isMe ? `<div class="av"><div class="avatar sm" style="background:${nickColor(msg.from)}">${msg.from[0].toUpperCase()}</div></div>` : ''}
|
|
<div class="bubble pm-bubble">
|
|
<div class="pm-label">🔒 Privado</div>
|
|
<div class="text">${esc(msg.text)}</div>
|
|
<div class="meta"><span class="ts">${msg.ts}</span>${isMe ? '<span class="ticks">✓✓</span>' : ''}</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(wrap);
|
|
}
|
|
|
|
function appendEvent(text) {
|
|
const container = document.getElementById('messages');
|
|
const el = document.createElement('div');
|
|
el.className = 'event-msg';
|
|
el.textContent = text;
|
|
container.appendChild(el);
|
|
}
|
|
|
|
function scrollBottom() {
|
|
const c = document.getElementById('messages');
|
|
requestAnimationFrame(() => c.scrollTop = c.scrollHeight);
|
|
}
|
|
|
|
// ── Sidebar renders ───────────────────────────────────────────────────────
|
|
function refreshRoomList() {
|
|
const container = document.getElementById('room-list');
|
|
const rooms = [...myRooms].sort((a,b) => a === 'general' ? -1 : 1);
|
|
container.innerHTML = rooms.map(r => {
|
|
const u = unread[r] || 0;
|
|
const isActive = currentRoom === r && !pmTarget;
|
|
const icon = r === 'general' ? '🌐' : r === 'offtopic' ? '💬' : '📁';
|
|
const preview = (msgHistory[r] || []).filter(m => !m.isEvent).slice(-1)[0];
|
|
return `<div class="room-item${isActive?' active':''}" data-room="${r}" onclick="switchToRoom('${r}')">
|
|
<div class="room-icon">${icon}</div>
|
|
<div class="room-info">
|
|
<div class="room-name">#${r}</div>
|
|
<div class="room-preview">${preview ? esc(preview.from+': '+preview.text.slice(0,40)) : 'Sin mensajes'}</div>
|
|
</div>
|
|
${u > 0 ? `<div class="room-badge">${u}</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function refreshUserList() {
|
|
const container = document.getElementById('user-list');
|
|
container.innerHTML = onlineUsers.filter(n => n !== myNick).map(n => {
|
|
const key = 'pm:' + n;
|
|
const u = unread[key] || 0;
|
|
const isActive = pmTarget === n;
|
|
return `<div class="user-item${isActive?' active':''}" onclick="switchToPM('${n}')">
|
|
<div class="avatar" style="background:${nickColor(n)}">${n[0].toUpperCase()}</div>
|
|
<span class="uname">${esc(n)}</span>
|
|
<div class="online-dot" style="margin-left:auto"></div>
|
|
${u > 0 ? `<div class="room-badge">${u}</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ── Create room ───────────────────────────────────────────────────────────
|
|
function showCreateRoom() {
|
|
document.getElementById('create-modal').style.display = 'flex';
|
|
setTimeout(() => document.getElementById('new-room-input').focus(), 50);
|
|
}
|
|
function hideCreateRoom() {
|
|
document.getElementById('create-modal').style.display = 'none';
|
|
document.getElementById('new-room-input').value = '';
|
|
}
|
|
function createRoom() {
|
|
const name = document.getElementById('new-room-input').value.trim();
|
|
if (!name) return;
|
|
socket.emit('cmd', {type:'create', room:name});
|
|
hideCreateRoom();
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
function esc(s) {
|
|
return String(s)
|
|
.replace(/&/g,'&').replace(/</g,'<')
|
|
.replace(/>/g,'>').replace(/"/g,'"')
|
|
.replace(/\n/g,'<br/>');
|
|
}
|
|
|
|
function nickColor(nick) {
|
|
const colors = ['#e07b39','#5288c1','#4caf88','#9c64d4','#d44f7a','#3fa8b0','#c7a020'];
|
|
let h = 0;
|
|
for (let i=0; i<nick.length; i++) h = (h*31 + nick.charCodeAt(i)) & 0xffffffff;
|
|
return colors[Math.abs(h) % colors.length];
|
|
}
|
|
|
|
let titleTimer;
|
|
function notifyTitle() {
|
|
clearInterval(titleTimer);
|
|
let toggle = true;
|
|
titleTimer = setInterval(() => {
|
|
document.title = toggle ? '💬 Nuevo mensaje!' : 'PSP Chat';
|
|
toggle = !toggle;
|
|
}, 1000);
|
|
setTimeout(() => { clearInterval(titleTimer); document.title = 'PSP Chat'; }, 8000);
|
|
}
|
|
|
|
function showToast(msg, isError=false) {
|
|
let t = document.getElementById('toast-el');
|
|
if (!t) {
|
|
t = document.createElement('div');
|
|
t.id = 'toast-el';
|
|
t.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#323232;color:#fff;padding:10px 20px;border-radius:8px;font-size:13px;z-index:200;opacity:0;transition:.3s;pointer-events:none';
|
|
document.body.appendChild(t);
|
|
}
|
|
t.textContent = msg;
|
|
if (isError) t.style.background = 'var(--red)';
|
|
else t.style.background = '#323232';
|
|
t.style.opacity = '1';
|
|
setTimeout(() => t.style.opacity = '0', 3000);
|
|
}
|
|
|
|
// Focus input when clicking in panel
|
|
document.addEventListener('keydown', e => {
|
|
if (e.target === document.body && e.key.length === 1) {
|
|
document.getElementById('msg-input').focus();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|