Files
unibus/web/src/components/RoomList.tsx
T
egutierrez d33ca6278a feat(web): SPA de chat (React + Vite + Mantine v9)
Cliente web sobre el gateway (REST + SSE). El navegador no habla NATS ni
cripto: el peer Go del gateway lo hace.

- Pantalla de conexión: gateway URL + identidad (persistidas en localStorage).
- Navbar: crear room (con toggle de cifrado E2E), unirse por id, lista de rooms.
- Centro: mensajes en vivo por SSE, burbujas con autor y hora, composer.
- Lateral: miembros (rol owner), invitar por peer conectado, expulsar (owner).
- Mantine v9 (createTheme + MantineProvider), @tabler/icons-react, layout con
  AppShell/Stack/Group; sin Tailwind ni CSS manual. React 19 (peer dep de v9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:43:10 +02:00

120 lines
3.4 KiB
TypeScript

import { useState } from "react";
import {
Stack,
TextInput,
Checkbox,
Button,
Divider,
Text,
NavLink,
ScrollArea,
Group,
Box,
} from "@mantine/core";
import { IconLock, IconHash, IconPlus, IconDoorEnter } from "@tabler/icons-react";
import type { Room } from "../types";
interface Props {
rooms: Room[];
activeRoom: string | null;
onSelect: (roomID: string) => void;
onCreateRoom: (subject: string, encrypt: boolean) => void;
onJoinRoom: (roomID: string) => void;
}
// RoomList is the navbar: create a room, join one by id, and pick the active
// room from the peer's known rooms.
export function RoomList({ rooms, activeRoom, onSelect, onCreateRoom, onJoinRoom }: Props) {
const [subject, setSubject] = useState("room.general");
const [encrypt, setEncrypt] = useState(true);
const [joinID, setJoinID] = useState("");
const create = () => {
if (subject.trim()) onCreateRoom(subject.trim(), encrypt);
};
const join = () => {
if (joinID.trim()) {
onJoinRoom(joinID.trim());
setJoinID("");
}
};
return (
<Stack gap={0} h="100%">
<Box p="md">
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
Crear room
</Text>
<Stack gap="xs">
<TextInput
size="xs"
placeholder="subject (room.general)"
leftSection={<IconHash size={14} />}
value={subject}
onChange={(e) => setSubject(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
/>
<Checkbox
size="xs"
label="Cifrado extremo a extremo"
checked={encrypt}
onChange={(e) => setEncrypt(e.currentTarget.checked)}
/>
<Button size="xs" leftSection={<IconPlus size={14} />} onClick={create}>
Crear
</Button>
</Stack>
</Box>
<Divider />
<Box p="md">
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
Unirse por id
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
size="xs"
placeholder="room id"
value={joinID}
onChange={(e) => setJoinID(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && join()}
style={{ flex: 1 }}
/>
<Button size="xs" variant="light" onClick={join} px="sm">
<IconDoorEnter size={16} />
</Button>
</Group>
</Box>
<Divider />
<Text size="xs" fw={700} c="dimmed" tt="uppercase" px="md" pt="md" pb="xs">
Rooms ({rooms.length})
</Text>
<ScrollArea style={{ flex: 1 }}>
<Stack gap={2} px="xs" pb="md">
{rooms.length === 0 && (
<Text size="sm" c="dimmed" px="sm" py="lg" ta="center">
Aún no hay rooms. Crea o únete a una.
</Text>
)}
{rooms.map((r) => (
<NavLink
key={r.room_id}
active={r.room_id === activeRoom}
onClick={() => onSelect(r.room_id)}
label={r.subject}
description={r.room_id.slice(0, 14) + "…"}
leftSection={
r.encrypt ? <IconLock size={16} /> : <IconHash size={16} />
}
variant="filled"
/>
))}
</Stack>
</ScrollArea>
</Stack>
);
}