d33ca6278a
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>
120 lines
3.4 KiB
TypeScript
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>
|
|
);
|
|
}
|