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>
This commit is contained in:
2026-06-06 18:43:10 +02:00
parent 915f926136
commit d33ca6278a
20 changed files with 2626 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
import { useState } from "react";
import {
Stack,
Group,
Text,
Badge,
Select,
Button,
ActionIcon,
Divider,
Box,
Avatar,
Tooltip,
ScrollArea,
} from "@mantine/core";
import { IconUserPlus, IconUserMinus, IconRefresh, IconUsers } from "@tabler/icons-react";
import type { Member, Peer, Room } from "../types";
interface Props {
room: Room;
members: Member[];
peers: Peer[];
myEndpoint: string;
iAmOwner: boolean;
nameFor: (endpoint: string) => string;
onInvite: (target: string) => void;
onKick: (target: string) => void;
onRefresh: () => void;
}
// MembersPane is the right column: who is in the active room, plus invite (pick a
// connected peer) and kick (owner only). Invite/kick address peers by name; the
// gateway resolves the name to its bus endpoint.
export function MembersPane({
room,
members,
peers,
myEndpoint,
iAmOwner,
nameFor,
onInvite,
onKick,
onRefresh,
}: Props) {
const [target, setTarget] = useState<string | null>(null);
const memberEndpoints = new Set(members.map((m) => m.endpoint));
// Candidates to invite: connected peers not already in the room.
const candidates = peers
.filter((p) => !memberEndpoints.has(p.endpoint_id))
.map((p) => ({ value: p.name, label: p.name }));
const invite = () => {
if (target) {
onInvite(target);
setTarget(null);
}
};
return (
<Stack gap={0} h="100%">
<Group justify="space-between" px="md" py="sm" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
<Group gap="xs">
<IconUsers size={18} />
<Text fw={600}>Miembros</Text>
<Badge size="sm" variant="light">
{members.length}
</Badge>
</Group>
<Tooltip label="Recargar" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={onRefresh}>
<IconRefresh size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Box p="md">
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
Invitar {room.encrypt && "(reparte la clave)"}
</Text>
<Group gap="xs" wrap="nowrap" align="flex-end">
<Select
style={{ flex: 1 }}
size="xs"
placeholder="peer conectado"
data={candidates}
value={target}
onChange={setTarget}
searchable
nothingFoundMessage="sin peers libres"
comboboxProps={{ withinPortal: true }}
/>
<Button
size="xs"
leftSection={<IconUserPlus size={14} />}
onClick={invite}
disabled={!target}
>
Invitar
</Button>
</Group>
</Box>
<Divider />
<ScrollArea style={{ flex: 1 }}>
<Stack gap={4} p="md">
{members.map((m) => {
const isMe = m.endpoint === myEndpoint;
const name = nameFor(m.endpoint);
const canKick = iAmOwner && !isMe && m.role !== "owner";
return (
<Group key={m.endpoint} justify="space-between" wrap="nowrap" gap="xs">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar size="sm" radius="xl" color="violet">
{name.slice(0, 2).toUpperCase()}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={isMe ? 700 : 500} truncate>
{name} {isMe && "(tú)"}
</Text>
<Text size="9px" c="dimmed" truncate>
{m.endpoint}
</Text>
</Box>
</Group>
<Group gap={4} wrap="nowrap">
{m.role === "owner" && (
<Badge size="xs" color="yellow" variant="light">
owner
</Badge>
)}
{canKick && (
<Tooltip label="Expulsar (rota la clave)" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onKick(name)}
>
<IconUserMinus size={15} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
);
})}
</Stack>
</ScrollArea>
</Stack>
);
}