This commit is contained in:
Your Name 2026-02-23 20:16:19 +01:00
parent 21922a11df
commit a1cd5613e4
5 changed files with 1336 additions and 0 deletions

Binary file not shown.

265
client_web.py Normal file
View File

@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
PSP Chat Cliente Web
Flask + Socket.IO que hace de PUENTE entre el navegador y el servidor TCP.
Arquitectura:
Navegador Socket.IO (WebSocket) client_web.py TCP server.py
Uso:
python client_web.py [--server 127.0.0.1] [--port 9000] [--web-port 5001]
"""
# ── Importaciones ────────────────────────────────────────────────────────────
import argparse # Para leer argumentos de la línea de comandos (--server, --port, etc.)
import json # Para convertir diccionarios Python a texto JSON y viceversa
import socket # Para crear conexiones TCP hacia el servidor de chat
import threading # Para leer del servidor TCP en paralelo sin bloquear el servidor web
import logging # Para mostrar mensajes informativos en la consola
from flask import Flask, render_template, request # Framework web para servir la página HTML
from flask_socketio import SocketIO, emit # WebSockets: comunicación en tiempo real con el navegador
# ── Configuración del sistema de logs ──────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%H:%M:%S'
)
log = logging.getLogger('ChatClient')
# ── Aplicación Flask y Socket.IO ────────────────────────────────────────────
# Flask sirve la página web (HTML/CSS/JS)
app = Flask(__name__)
app.config['SECRET_KEY'] = 'psp_chat_secret' # Clave para firmar cookies de sesión
# SocketIO añade soporte WebSocket sobre Flask
# cors_allowed_origins='*' permite conexiones desde cualquier dirección IP
# async_mode='threading' usa hilos de Python (compatible con nuestro código TCP)
socketio = SocketIO(app, cors_allowed_origins='*', async_mode='threading')
# ── Configuración del servidor TCP destino (se puede cambiar por argumentos CLI) ──
SERVER_HOST = '127.0.0.1' # IP del servidor TCP de chat
SERVER_PORT = 9000 # Puerto del servidor TCP de chat
# ── Sesiones activas ────────────────────────────────────────────────────────────
# Cada navegador que se conecta tiene su propia sesión con su propio socket TCP.
# Clave: sid (Socket.IO session ID) del navegador
# Valor: {'sock': socket TCP, 'nick': nombre del usuario}
sessions = {}
sess_lock = threading.Lock() # Candado para acceder a 'sessions' de forma segura
def tcp_reader(sid, sock):
"""
Hilo que escucha continuamente el socket TCP del servidor de chat
y reenvía cada mensaje al navegador mediante Socket.IO.
Esto es necesario porque TCP y WebSocket son protocolos distintos;
este puente los une de forma transparente para el usuario.
"""
buf = ''
try:
while True:
# Leemos hasta 4096 bytes del servidor TCP
chunk = sock.recv(4096).decode('utf-8', errors='replace')
if not chunk:
break # El servidor cerró la conexión
buf += chunk
# Procesamos cada línea completa del buffer (cada mensaje JSON)
while '\n' in buf:
line, buf = buf.split('\n', 1)
line = line.strip()
if not line:
continue
try:
msg = json.loads(line) # Convertir el texto JSON a diccionario
except json.JSONDecodeError:
continue # Línea no válida, la ignoramos
log.debug(f'TCP→Browser [{sid[:6]}]: {msg}')
# Enviar el mensaje al navegador por WebSocket
socketio.emit('srv_msg', msg, to=sid)
except Exception as e:
log.info(f'TCP reader ended for {sid[:6]}: {e}')
finally:
# Si se pierde la conexión TCP, notificamos al navegador
socketio.emit('srv_msg', {
'type': 'error',
'text': 'Conexión con el servidor perdida'
}, to=sid)
with sess_lock:
sessions.pop(sid, None) # Limpiar la sesión de la memoria
def tcp_send(sid, data: dict):
"""
Envía un mensaje JSON al servidor TCP desde la sesión indicada.
Se usa solo para el JOIN inicial, que requiere formato JSON.
Devuelve True si se envió correctamente, False si hubo error.
"""
with sess_lock:
s = sessions.get(sid)
if not s:
return False # Sesión no encontrada (el usuario no está conectado al TCP)
try:
s['sock'].sendall((json.dumps(data, ensure_ascii=False) + '\n').encode('utf-8'))
return True
except Exception as e:
log.error(f'TCP send error: {e}')
return False
def tcp_send_text(sid, text: str):
"""
Envía una línea de texto plano al servidor TCP.
El servidor, tras el JOIN, trata cada línea como un mensaje de chat,
así que debemos enviar solo el texto, no JSON.
Esto garantiza que los mensajes del cliente web sean idénticos
a los que envía un usuario de netcat o terminal.
"""
with sess_lock:
s = sessions.get(sid)
if not s:
return False
try:
s['sock'].sendall((text + '\n').encode('utf-8'))
return True
except Exception as e:
log.error(f'TCP send error: {e}')
return False
# ── Rutas Flask (HTTP) ──────────────────────────────────────────────────
@app.route('/')
def index():
"""
Sirve la página principal del chat (templates/index.html).
Pasa al HTML la IP y puerto del servidor TCP para mostrarlos en el login.
"""
return render_template('index.html',
server_host=SERVER_HOST,
server_port=SERVER_PORT)
# ── Eventos Socket.IO (WebSocket entre navegador y este cliente web) ─────────
@socketio.on('connect')
def on_connect():
"""Se dispara cuando un navegador abre la página. Solo lo registramos en el log."""
log.info(f'Browser conectado: {request.sid[:8]}')
@socketio.on('disconnect')
def on_disconnect():
"""
Se dispara cuando el navegador cierra la pestaña o pierde la conexión.
Cerramos el socket TCP asociado para no dejar conexiones huérfanas abiertas.
"""
sid = request.sid
log.info(f'Browser desconectado: {sid[:8]}')
with sess_lock:
s = sessions.pop(sid, None) # Eliminar la sesión de la memoria
if s:
try:
s['sock'].close() # Cerrar el socket TCP liberando el recurso
except Exception:
pass
@socketio.on('join')
def on_join(data):
"""
El navegador quiere unirse al chat. Recibe: {nick: 'Alice'}
Pasos:
1. Crea una nueva conexión TCP al servidor de chat.
2. Guarda el socket en la tabla de sesiones.
3. Lanza el hilo tcp_reader para escuchar respuestas del servidor.
4. Envía el mensaje JOIN al servidor TCP.
"""
sid = request.sid
nick = str(data.get('nick', '')).strip()
# Evitar que el mismo navegador se conecte dos veces
with sess_lock:
if sid in sessions:
emit('srv_msg', {'type': 'error', 'text': 'Ya estás conectado'})
return
# Intentar conectar al servidor TCP
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10) # Timeout de 10 s para la conexión inicial
sock.connect((SERVER_HOST, SERVER_PORT))
sock.settimeout(None) # Sin timeout una vez conectado
except Exception as e:
emit('srv_msg', {'type': 'error', 'text': f'No se puede conectar al servidor TCP: {e}'})
return
# Guardar la sesión: asociar el sid del navegador con su socket TCP
with sess_lock:
sessions[sid] = {'sock': sock, 'nick': nick}
# Lanzar el hilo lector: escuchará el TCP y retransmitirá mensajes al navegador
t = threading.Thread(target=tcp_reader, args=(sid, sock), daemon=True)
t.start()
# Enviar el mensaje de join al servidor TCP
tcp_send(sid, {'type': 'join', 'nick': nick})
log.info(f'{nick} ({sid[:8]}) conectado al servidor TCP')
@socketio.on('send_msg')
def on_send_msg(data):
"""
El navegador envía un mensaje a la sala. Recibe: {type:'msg', room:'general', text:'...'}
Enviamos Solo el texto plano al servidor TCP (no el JSON completo),
porque el servidor tras el JOIN trata cada línea como texto de chat.
Así los mensajes del cliente web son idénticos a los de netcat.
"""
text = str(data.get('text', '')).strip()
if text:
tcp_send_text(request.sid, text)
@socketio.on('send_pm')
def on_send_pm(data):
"""
El navegador envía un mensaje privado. Recibe: {type:'pm', to:'Bob', text:'...'}
Lo reenvía al servidor TCP.
"""
tcp_send(request.sid, data)
@socketio.on('cmd')
def on_cmd(data):
"""
Cualquier otro comando del navegador (crear sala, unirse a sala, etc.).
Se reenvía directamente al servidor TCP sin modificaciones.
"""
tcp_send(request.sid, data)
# ── Punto de entrada principal ────────────────────────────────────────────────
if __name__ == '__main__':
# Configurar argumentos de línea de comandos para poder personalizar
# la IP/puerto del servidor TCP y el puerto web sin editar el código
parser = argparse.ArgumentParser(description='PSP Chat Web Client')
parser.add_argument('--server', default='127.0.0.1', help='IP del servidor TCP')
parser.add_argument('--port', type=int, default=9000, help='Puerto del servidor TCP')
parser.add_argument('--web-port', type=int, default=5001, help='Puerto del servidor web')
args = parser.parse_args()
# Actualizamos las variables globales con los valores recibidos
SERVER_HOST = args.server
SERVER_PORT = args.port
log.info(f'╔══════════════════════════════════════╗')
log.info(f'║ PSP Chat Cliente Web ║')
log.info(f'║ Servidor TCP : {SERVER_HOST}:{SERVER_PORT}')
log.info(f'║ Interfaz web : http://0.0.0.0:{args.web_port}')
log.info(f'╚══════════════════════════════════════╝')
# Arrancar el servidor web Flask+SocketIO en todas las interfaces
# debug=False para no mostrar información sensible en producción
socketio.run(app, host='0.0.0.0', port=args.web_port, debug=False)

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask>=3.0.0
flask-socketio>=5.0.0
eventlet>=0.35.0

321
server.py Normal file
View File

@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
PSP Chat Server TCP puro
Puerto por defecto: 9000
Protocolo simplificado (todo el tráfico es texto plano):
- El primer mensaje que envía el cliente se considera el nick con el que
se va a unir al chat. No hace falta JSON; basta con escribir el nombre
y pulsar Enter.
- A partir de entonces cada línea que llegue se considera un mensaje
público que se retransmite a **la sala general**.
- No existen mensajes privados ni salas adicionales; todo el mundo ve
todo.
Salidas del servidor (conservadas por compatibilidad interna):
{"type":"welcome","nick":"Alice","rooms":["general"],"ts":"..."}
{"type":"msg","room":"general","from":"Bob","text":"Hola","ts":"..."}
{"type":"event","text":"Alice se ha unido al chat","room":"general","ts":"..."}
{"type":"users","users":["Alice","Bob"],"ts":"..."}
{"type":"error","text":"..."}
"""
# ── Importaciones ────────────────────────────────────────────────────────────
import socket # Para crear el servidor TCP y aceptar conexiones de red
import threading # Para atender varios clientes a la vez (un hilo por cliente)
import json # Para convertir diccionarios Python a texto JSON y viceversa
import logging # Para mostrar mensajes informativos en la consola del servidor
from datetime import datetime # Para añadir la hora actual a cada mensaje
# ── Configuración del sistema de logs (mensajes en consola) ───────────────────
# Cada línea mostrará: hora [NIVEL] mensaje
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%H:%M:%S'
)
log = logging.getLogger('ChatServer')
# ── Configuración de red ──────────────────────────────────────────────────────
HOST = '0.0.0.0' # Escuchar en todas las interfaces de red del equipo
PORT = 9000 # Puerto TCP en el que esperamos conexiones
# ── Estado compartido entre todos los hilos ───────────────────────────────────
# Un Lock es un "candado" que evita que dos hilos modifiquen los datos
# al mismo tiempo y provoquen errores (race conditions).
lock = threading.Lock()
# Diccionario de clientes conectados: clave=nick, valor=socket y metadatos
# Ejemplo: {'Alice': {'sock': <socket>, 'addr': ('192.168.1.5', 52341), 'rooms': {'general'}}}
clients = {}
# Salas disponibles: clave=nombre_sala, valor=conjunto de nicks dentro de esa sala
rooms = {'general': set(), 'offtopic': set()}
def now():
"""Devuelve la hora actual en formato HH:MM para añadirla a los mensajes."""
return datetime.now().strftime('%H:%M')
# ── Funciones de envío ───────────────────────────────────────────────────────
def send(sock, data: dict):
"""
Envía UN mensaje JSON a un socket concreto.
Convierte el diccionario a texto JSON, añade '\n' como separador
(el receptor sabrá que el mensaje terminó al ver el salto de línea)
y lo manda por la red en UTF-8. Ignora errores si el cliente se desconectó.
"""
try:
# json.dumps → convierte dict a string | + '\n' → separador de mensajes
# .encode('utf-8') → convierte el texto a bytes para enviarlo por la red
sock.sendall((json.dumps(data, ensure_ascii=False) + '\n').encode('utf-8'))
except Exception:
pass # Si el cliente se cortó, ignoramos el error
def broadcast_room(room: str, data: dict, exclude=None):
"""
Envía un mensaje a TODOS los usuarios dentro de una sala.
Parámetros:
- room: nombre de la sala destino (ej: 'general')
- data: el mensaje como diccionario Python
- exclude: nick que NO recibirá el mensaje (normalmente el propio emisor)
"""
# Copiamos la lista de miembros dentro del candado para evitar cambios mientras iteramos
with lock:
members = list(rooms.get(room, set()))
for nick in members:
if nick == exclude:
continue # No enviamos el mensaje al propio emisor
with lock:
c = clients.get(nick)
if c:
send(c['sock'], data)
def broadcast_all(data: dict, exclude=None):
"""
Envía un mensaje a TODOS los clientes conectados al servidor,
independientemente de la sala. Se usa para actualizar la lista de usuarios.
"""
with lock:
nicks = list(clients.keys())
for nick in nicks:
if nick == exclude:
continue
with lock:
c = clients.get(nick)
if c:
send(c['sock'], data)
# ── Manejador de cada cliente ─────────────────────────────────────────────────
def handle_client(sock: socket.socket, addr):
"""
Se ejecuta en un hilo separado para cada cliente que se conecta.
Flujo:
1. Espera el primer mensaje (el nick).
2. Valida el nick y comprueba que no esté repetido.
3. Registra al cliente, manda bienvenida y avisa al resto.
4. Bucle infinito: lee mensajes y los retransmite a la sala general.
5. Al desconectarse, limpia todo y notifica al resto del chat.
"""
nick = None # Nombre del usuario (se asigna tras validar el primer mensaje)
buf = '' # Buffer: acumula bytes hasta tener una línea completa ('\n')
try:
# ── PASO 1: Recibir el primer mensaje (el nick) ───────────────────────
# Timeout de 30 s: si no llega el nick en ese tiempo, cerramos la conexión.
sock.settimeout(30)
raw = ''
# TCP puede partir los datos; seguimos leyendo hasta encontrar '\n'
while '\n' not in raw:
chunk = sock.recv(1024).decode('utf-8', errors='replace')
if not chunk:
return # El cliente cerró antes de enviar nada
raw += chunk
# Separamos la primera línea del resto del buffer
line, buf = raw.split('\n', 1)
# ── PASO 2: Interpretar el primer mensaje ─────────────────────────────
# Admitimos dos formatos:
# - JSON: {"type":"join","nick":"Alice"} (cliente web)
# - Texto plano: "Alice" (cliente de terminal)
try:
msg = json.loads(line) # Intentamos parsear como JSON
except json.JSONDecodeError:
# No es JSON → asumimos que es el nick directamente en texto plano
requested = line.strip()[:20]
msg = {'type': 'join', 'nick': requested}
else:
# Es JSON, pero verificamos que sea un mensaje de tipo 'join'
if not isinstance(msg, dict) or msg.get('type') != 'join':
requested = line.strip()[:20]
msg = {'type': 'join', 'nick': requested}
else:
requested = str(msg.get('nick', '')).strip()[:20]
# ── PASO 3: Validar el nick ───────────────────────────────────────────
# Solo letras, números y guion bajo; máximo 20 caracteres
if not requested or not requested.replace('_', '').isalnum():
send(sock, {'type': 'error', 'text': 'Nick inválido (solo letras, números, _)'})
return
# El Lock evita que dos clientes elijan el mismo nick al mismo tiempo
with lock:
if requested in clients:
send(sock, {'type': 'error', 'text': 'Nick ya en uso'})
return
# Registrar al cliente en el estado global
nick = requested
clients[nick] = {'sock': sock, 'addr': addr, 'rooms': {'general'}}
rooms['general'].add(nick) # Añadir a la sala general
log.info(f'{nick} conectado desde {addr}')
sock.settimeout(None) # Sin límite de tiempo a partir de aquí
# ── PASO 4: Enviar bienvenida y notificar al resto ────────────────────
with lock:
room_list = list(rooms.keys())
# Mensaje solo para el recién llegado: confirma su nick y le da las salas
send(sock, {'type': 'welcome', 'nick': nick, 'rooms': room_list, 'ts': now()})
# A todos los demás: evento informativo de que alguien entró
broadcast_room('general', {
'type': 'event', 'room': 'general',
'text': f'{nick} se ha unido al chat', 'ts': now()
}, exclude=nick)
# Actualizar la lista de usuarios online para todos
broadcast_users()
# ── PASO 5: Bucle principal leer y retransmitir mensajes ────────────
# A partir de aquí, cada línea recibida es un mensaje de chat.
while True:
chunk = sock.recv(4096).decode('utf-8', errors='replace')
if not chunk:
break # El cliente cerró la conexión → salimos
buf += chunk # Acumulamos en el buffer
# Procesamos todas las líneas completas del buffer
while '\n' in buf:
line, buf = buf.split('\n', 1)
text = line.strip()
if not text:
continue # Ignoramos líneas vacías
log.info(f'[general] {nick}: {text}')
# Reenviar el mensaje a todos en la sala general
broadcast_room('general', {
'type': 'msg',
'room': 'general',
'from': nick,
'text': text,
'ts': now()
})
except (ConnectionResetError, BrokenPipeError, OSError):
# Desconexión abrupta (cerró la app, se cortó la red, etc.)
pass
finally:
# ── LIMPIEZA: se ejecuta SIEMPRE, haya error o no ────────────────────
if nick:
with lock:
clients.pop(nick, None) # Quitar del diccionario de clientes
for r in rooms.values():
r.discard(nick) # Quitar de todas las salas
log.info(f'{nick} desconectado')
# Avisar a todos de que este usuario se fue
broadcast_room('general', {
'type': 'event', 'room': 'general',
'text': f'{nick} ha abandonado el chat', 'ts': now()
})
# Enviar lista de usuarios actualizada (sin este nick)
broadcast_users()
sock.close() # Liberar el socket y recursos del sistema operativo
# ── Funciones auxiliares de usuarios ─────────────────────────────────────────
def broadcast_users():
"""
Recoge la lista de todos los nicks conectados y la envía a TODOS los clientes.
Se llama cada vez que alguien entra o sale para que el sidebar se actualice.
"""
with lock:
user_list = list(clients.keys())
broadcast_all({'type': 'users', 'users': user_list, 'ts': now()})
def broadcast_users_to(nick, sock):
"""
Envía la lista de usuarios únicamente a un cliente concreto.
(Función auxiliar reservada para uso futuro.)
"""
with lock:
user_list = list(clients.keys())
send(sock, {'type': 'users', 'users': user_list, 'ts': now()})
# ── Punto de entrada principal ────────────────────────────────────────────────
def main():
"""
Arranca el servidor TCP:
1. Crea el socket del servidor.
2. Lo asocia a HOST:PORT.
3. Se pone a escuchar conexiones entrantes.
4. Por cada cliente, lanza un hilo independiente con handle_client().
"""
# AF_INET = IPv4 | SOCK_STREAM = TCP (orientado a conexión, fiable, ordenado)
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# SO_REUSEADDR permite reutilizar el puerto inmediatamente al reiniciar
# (sin esto habría que esperar ~1 minuto entre reinicios)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((HOST, PORT)) # Asociar el socket a la IP y puerto configurados
srv.listen(100) # Cola de hasta 100 conexiones pendientes de aceptar
log.info(f'╔══════════════════════════════════╗')
log.info(f'║ PSP Chat Server listo ║')
log.info(f'║ Escuchando en {HOST}:{PORT}')
log.info(f'╚══════════════════════════════════╝')
try:
# Bucle infinito: el servidor siempre está listo para nuevos clientes
while True:
# .accept() se bloquea aquí hasta que alguien se conecta
# Devuelve el socket del cliente y su dirección (IP, puerto)
sock, addr = srv.accept()
log.info(f'Nueva conexión desde {addr}')
# Creamos un hilo separado para este cliente.
# daemon=True → el hilo muere automáticamente si el programa principal termina.
t = threading.Thread(target=handle_client, args=(sock, addr), daemon=True)
t.start()
except KeyboardInterrupt:
# Ctrl+C → apagar el servidor limpiamente
log.info('Servidor detenido')
finally:
srv.close() # Liberar el puerto del sistema operativo
# Solo arrancamos el servidor si ejecutamos este archivo directamente
# (no si se importa como módulo desde otro script)
if __name__ == '__main__':
main()

747
templates/index.html Normal file
View File

@ -0,0 +1,747 @@
<!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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;')
.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>