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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user