Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { Historial } from "./Historial.js";
import { BackCanvas, type Endpoint } from "./BackCanvas.js";
import { KeyPanel, loadStoredCredencial, type Credencial } from "./KeyPanel.js";
import { useTextos } from "./i18n.js";
import { MenuSalas } from "./MenuSalas.js";
import { recordarSala } from "./historial-salas.js";

// El roomId vive en el hash de la URL: #/sala/taco-fiesta-42
function readRoomFromHash(): string | null {
Expand Down Expand Up @@ -49,6 +51,9 @@ export function App() {
setName={setName}
onEnter={() => {
localStorage.setItem("multi-name", name || "anónimo");
// Se anota AQUÍ y no al leer el hash: abrir un link que no llegaste a
// usar no debería llenarte el historial.
recordarSala(roomId);
setEntered(true);
}}
/>
Expand Down Expand Up @@ -437,8 +442,11 @@ function Sala({ roomId, name }: { roomId: string; name: string }) {
{/* Chat izquierda */}
<aside className="chat">
<div className="sala-cab">
<div className="sala-nombre">{roomId}</div>
<div className="sala-meta">{t.enLaSala(members.length)}</div>
<MenuSalas actual={roomId} />
<div className="sala-titulo">
<div className="sala-nombre">{roomId}</div>
<div className="sala-meta">{t.enLaSala(members.length)}</div>
</div>
</div>

{/* Los agentes de la sala, como jugadores visibles */}
Expand Down
116 changes: 116 additions & 0 deletions web/src/MenuSalas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from "react";
import { salasVisitadas, olvidarSala, type SalaVisitada } from "./historial-salas.js";
import { useTextos } from "./i18n.js";

/**
* El menú de salas: dónde has estado.
*
* Va arriba a la izquierda, encima del nombre de la sala, porque es navegación
* y no una acción de la sala. La lista NO separa "mías" de "compartidas
* conmigo": esa distinción se borra sola en cuanto invitas a alguien o alguien
* te invita, y sin login no hay forma de saber quién creó qué. Lo que sí
* distingue es cuándo entraste, y por eso van ordenadas por eso.
*/
export function MenuSalas({ actual }: { actual?: string }) {
const { t, idioma } = useTextos();
const [abierto, setAbierto] = useState(false);
const [salas, setSalas] = useState<SalaVisitada[]>([]);
const cajaRef = useRef<HTMLDivElement>(null);

// Se lee al abrir, no al montar: así refleja lo que haya pasado en otra
// pestaña sin tener que escuchar el evento `storage`.
useEffect(() => {
if (abierto) setSalas(salasVisitadas());
}, [abierto]);

// Cerrar al hacer click fuera o con Escape, como cualquier menú.
useEffect(() => {
if (!abierto) return;
const fuera = (e: MouseEvent) => {
if (cajaRef.current && !cajaRef.current.contains(e.target as Node)) setAbierto(false);
};
const escape = (e: KeyboardEvent) => {
if (e.key === "Escape") setAbierto(false);
};
document.addEventListener("mousedown", fuera);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("mousedown", fuera);
document.removeEventListener("keydown", escape);
};
}, [abierto]);

const otras = salas.filter((s) => s.id !== actual);

return (
<div className="menu-salas" ref={cajaRef}>
<button
className="menu-btn"
onClick={() => setAbierto((v) => !v)}
title={t.tusSalas}
aria-label={t.tusSalas}
aria-expanded={abierto}
>
<span className="menu-icono" aria-hidden="true" />
</button>

{abierto && (
<div className="menu-panel">
<div className="menu-cab">{t.tusSalas}</div>

{otras.length === 0 ? (
<p className="menu-vacio">{t.sinOtrasSalas}</p>
) : (
<ul className="menu-lista">
{otras.map((s) => (
<li key={s.id} className="menu-item">
<a className="menu-link" href={`#/sala/${s.id}`} onClick={() => setAbierto(false)}>
<span className="menu-sala-id">{s.id}</span>
<span className="menu-sala-fecha">{cuando(s.visitadaEn, idioma)}</span>
</a>
<button
className="menu-olvidar"
title={t.quitarDeLaLista}
aria-label={`${t.quitarDeLaLista}: ${s.id}`}
onClick={() => {
olvidarSala(s.id);
setSalas(salasVisitadas());
}}
>
×
</button>
</li>
))}
</ul>
)}

<a className="menu-nueva" href="#/" onClick={() => setAbierto(false)}>
{t.crearOtraSala}
</a>
</div>
)}
</div>
);
}

/**
* Cuánto hace que entraste, en palabras.
*
* Una fecha exacta no dice nada útil aquí ("2 de agosto" no te ayuda a
* reconocer una sala); "hace 10 min" sí.
*/
function cuando(ms: number, idioma: string): string {
const minutos = Math.floor((Date.now() - ms) / 60000);
const rtf = new Intl.RelativeTimeFormat(idioma, { numeric: "auto" });

if (minutos < 1) return rtf.format(0, "minute");
if (minutos < 60) return rtf.format(-minutos, "minute");

const horas = Math.floor(minutos / 60);
if (horas < 24) return rtf.format(-horas, "hour");

const dias = Math.floor(horas / 24);
if (dias < 30) return rtf.format(-dias, "day");

return rtf.format(-Math.floor(dias / 30), "month");
}
64 changes: 64 additions & 0 deletions web/src/historial-salas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Las salas donde has entrado, guardadas en este navegador.
*
* El problema que resuelve: hoy si cierras la pestaña y no guardaste el link,
* la sala se perdió. Los nombres son legibles a propósito (compa-zona-49, no un
* UUID) justo para poder dictarlos por teléfono, pero nadie se los aprende.
*
* Sin cuenta y sin servidor: vive en localStorage, atado a este navegador. No
* sincroniza entre dispositivos, y eso está bien — un login contra el server de
* quien levantó Multi guardaría una identidad que solo existe mientras esa
* máquina esté prendida.
*
* NO se separa "mis salas" de "compartidas conmigo". La distinción se borra
* sola: entras a una que te pasaron, invitas a alguien, y ya es tuya también.
* Lo que sí distingue es cuándo entraste, y por eso van ordenadas por eso.
*/

const CLAVE = "multi.salas";

/** Cuántas se recuerdan. Más allá de esto la lista deja de ser útil. */
const MAXIMO = 20;

export interface SalaVisitada {
id: string;
/** Última vez que entraste, en milisegundos. Es el orden de la lista. */
visitadaEn: number;
}

/** Las salas donde entraste, de la más reciente a la más vieja. */
export function salasVisitadas(): SalaVisitada[] {
try {
const crudo = localStorage.getItem(CLAVE);
if (!crudo) return [];
const lista = JSON.parse(crudo);
if (!Array.isArray(lista)) return [];
return lista
.filter((s): s is SalaVisitada => typeof s?.id === "string" && typeof s?.visitadaEn === "number")
.sort((a, b) => b.visitadaEn - a.visitadaEn);
} catch {
// Un localStorage corrupto no debe impedirte entrar a una sala.
return [];
}
}

/** Anota que entraste a una sala. Si ya estaba, sube al principio. */
export function recordarSala(id: string): void {
try {
const otras = salasVisitadas().filter((s) => s.id !== id);
const lista = [{ id, visitadaEn: Date.now() }, ...otras].slice(0, MAXIMO);
localStorage.setItem(CLAVE, JSON.stringify(lista));
} catch {
// Modo incógnito o almacenamiento lleno: se pierde el historial, no la sala.
}
}

/** Saca una sala de la lista. */
export function olvidarSala(id: string): void {
try {
const lista = salasVisitadas().filter((s) => s.id !== id);
localStorage.setItem(CLAVE, JSON.stringify(lista));
} catch {
// Igual que arriba: no vale la pena romper la interfaz por esto.
}
}
12 changes: 12 additions & 0 deletions web/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ const TEXTOS = {
loLlama: "lo llama",
nadieLoLlama: "nadie lo llama desde el front",

// Menú de salas
tusSalas: "Tus salas",
sinOtrasSalas: "Todavía no has entrado a otra sala.",
crearOtraSala: "Crear otra sala",
quitarDeLaLista: "Quitar de la lista",

// Historial
historial: "Historial",
sinCambios: "aún no hay cambios guardados",
Expand Down Expand Up @@ -189,6 +195,12 @@ const TEXTOS = {
loLlama: "calls it",
nadieLoLlama: "nothing calls it from the front end",

// Menú de salas
tusSalas: "Your rooms",
sinOtrasSalas: "You haven't joined another room yet.",
crearOtraSala: "Create another room",
quitarDeLaLista: "Remove from the list",

// Historial
historial: "History",
sinCambios: "no saved changes yet",
Expand Down
150 changes: 150 additions & 0 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,156 @@ body::after {
.sala-cab {
padding: 16px 18px 12px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.18);
display: flex;
align-items: flex-start;
gap: 10px;
}
.sala-titulo {
min-width: 0;
}

/* ── Menú de salas ────────────────────────────────────────────────────── */
/* Desplegable y no sidebar: la lista se abre una o dos veces por sesión, y un
panel permanente le quitaría ancho al preview, que es lo que todos miran. */
.menu-salas {
position: relative;
flex-shrink: 0;
}
.menu-btn {
background: none;
border: none;
cursor: pointer;
padding: 6px 4px;
display: flex;
align-items: center;
justify-content: center;
}
/* Las tres rayas, con pseudo-elementos: sin SVG ni fuente de iconos. */
.menu-icono,
.menu-icono::before,
.menu-icono::after {
display: block;
width: 15px;
height: 1.5px;
background: var(--dim);
border-radius: 1px;
transition: background 0.15s;
}
.menu-icono {
position: relative;
}
.menu-icono::before,
.menu-icono::after {
content: "";
position: absolute;
left: 0;
}
.menu-icono::before {
top: -5px;
}
.menu-icono::after {
top: 5px;
}
.menu-btn:hover .menu-icono,
.menu-btn:hover .menu-icono::before,
.menu-btn:hover .menu-icono::after {
background: var(--texto);
}

.menu-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 60;
width: 268px;
max-height: 380px;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--linea);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
padding: 8px;
}
.menu-cab {
font-size: 9px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--dim);
padding: 4px 8px 8px;
}
.menu-vacio {
font-size: 11.5px;
color: var(--dim);
font-style: italic;
padding: 4px 8px 10px;
line-height: 1.5;
}
.menu-lista {
list-style: none;
display: flex;
flex-direction: column;
gap: 1px;
}
.menu-item {
display: flex;
align-items: center;
border-radius: 5px;
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.menu-link {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
padding: 7px 8px;
text-decoration: none;
color: var(--texto);
}
.menu-sala-id {
font-size: 12.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-sala-fecha {
font-size: 10px;
color: var(--dim);
}
/* La × aparece al pasar el cursor: quitar una sala es raro, y un botón visible
en cada fila compite con el link, que es lo que sí quieres tocar. */
.menu-olvidar {
background: none;
border: none;
color: var(--dim);
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 6px 9px;
opacity: 0;
transition: opacity 0.12s, color 0.12s;
}
.menu-item:hover .menu-olvidar,
.menu-olvidar:focus-visible {
opacity: 1;
}
.menu-olvidar:hover {
color: var(--rojo);
}

.menu-nueva {
display: block;
margin-top: 8px;
padding: 9px 8px 4px;
border-top: 1px solid var(--linea);
font-size: 11.5px;
color: var(--dim);
text-decoration: none;
}
.menu-nueva:hover {
color: var(--texto);
}
.sala-nombre {
font-family: "Bodoni MT", "Didot", Georgia, serif;
Expand Down
Loading