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,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>unibus · chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "unibus-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "SPA de chat para el bus unibus (rooms cifradas E2E, mensajes en vivo por SSE).",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
Generated
+1481
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-preset-mantine": {},
|
||||
"postcss-simple-vars": {
|
||||
variables: {
|
||||
"mantine-breakpoint-xs": "36em",
|
||||
"mantine-breakpoint-sm": "48em",
|
||||
"mantine-breakpoint-md": "62em",
|
||||
"mantine-breakpoint-lg": "75em",
|
||||
"mantine-breakpoint-xl": "88em",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { GatewayClient } from "./api";
|
||||
import type { Peer } from "./types";
|
||||
import { ConnectScreen } from "./components/ConnectScreen";
|
||||
import { ChatLayout } from "./components/ChatLayout";
|
||||
|
||||
// Connection holds the live gateway client plus the identity it connected as.
|
||||
interface Connection {
|
||||
client: GatewayClient;
|
||||
peer: Peer;
|
||||
}
|
||||
|
||||
// App is the root: it shows the connect screen until the user picks a gateway
|
||||
// URL and a peer name, then swaps to the full chat layout. Disconnecting drops
|
||||
// back to the connect screen.
|
||||
export function App() {
|
||||
const [conn, setConn] = useState<Connection | null>(null);
|
||||
|
||||
if (!conn) {
|
||||
return <ConnectScreen onConnect={(client, peer) => setConn({ client, peer })} />;
|
||||
}
|
||||
return (
|
||||
<ChatLayout
|
||||
client={conn.client}
|
||||
peer={conn.peer}
|
||||
onDisconnect={() => setConn(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// GatewayClient is the SPA's typed wrapper over the unibus gateway HTTP API.
|
||||
// Every method is a thin fetch against the gateway, which hosts one real Go bus
|
||||
// peer per name and performs all NATS + end-to-end crypto on the browser's
|
||||
// behalf. The base URL is chosen at runtime on the connect screen.
|
||||
import type { BusEvent, Member, Peer, Room } from "./types";
|
||||
|
||||
export class GatewayClient {
|
||||
constructor(public readonly baseURL: string) {
|
||||
// Normalize: drop a trailing slash so `${base}/api/...` never doubles up.
|
||||
this.baseURL = baseURL.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(this.baseURL + path, {
|
||||
method,
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
let msg = text;
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
if (j && typeof j.error === "string") msg = j.error;
|
||||
} catch {
|
||||
// not JSON: keep the raw text
|
||||
}
|
||||
throw new Error(msg || `HTTP ${res.status}`);
|
||||
}
|
||||
return (text ? JSON.parse(text) : {}) as T;
|
||||
}
|
||||
|
||||
// connect creates (or recovers) the named peer on the gateway and returns its
|
||||
// public identity. The identity persists across gateway restarts.
|
||||
connect(name: string): Promise<Peer> {
|
||||
return this.req<Peer>("POST", "/api/peer", { name });
|
||||
}
|
||||
|
||||
// peers lists every peer currently hosted by the gateway (for the invite picker
|
||||
// and to label senders by name).
|
||||
peers(): Promise<Peer[]> {
|
||||
return this.req<Peer[]>("GET", "/api/peers");
|
||||
}
|
||||
|
||||
// rooms lists the rooms the named peer knows (created or joined).
|
||||
rooms(peer: string): Promise<Room[]> {
|
||||
return this.req<Room[]>("GET", `/api/rooms?peer=${encodeURIComponent(peer)}`);
|
||||
}
|
||||
|
||||
// members lists the participants of a room.
|
||||
members(roomID: string): Promise<Member[]> {
|
||||
return this.req<Member[]>("GET", `/api/members?room_id=${encodeURIComponent(roomID)}`);
|
||||
}
|
||||
|
||||
// createRoom opens a room on the given subject. encrypt drives both E2E
|
||||
// encryption and per-message signing; the peer is auto-subscribed.
|
||||
createRoom(peer: string, subject: string, encrypt: boolean): Promise<Room & { persist: boolean }> {
|
||||
return this.req("POST", "/api/room", { peer, subject, encrypt, persist: false });
|
||||
}
|
||||
|
||||
// join subscribes the peer to an existing room (must have been invited first
|
||||
// when the room is encrypted).
|
||||
join(peer: string, roomID: string): Promise<{ subject: string; encrypt: boolean }> {
|
||||
return this.req("POST", "/api/join", { peer, room_id: roomID });
|
||||
}
|
||||
|
||||
// invite adds another connected peer (by name) to a room, sealing the room key
|
||||
// to it. Caller must be the room owner.
|
||||
invite(peer: string, roomID: string, target: string): Promise<{ status: string }> {
|
||||
return this.req("POST", "/api/invite", { peer, room_id: roomID, target });
|
||||
}
|
||||
|
||||
// publish sends a text message to a room.
|
||||
publish(peer: string, roomID: string, text: string): Promise<{ status: string }> {
|
||||
return this.req("POST", "/api/publish", { peer, room_id: roomID, text });
|
||||
}
|
||||
|
||||
// kick removes a peer (by name) from a room and rotates the key (forward
|
||||
// secrecy). Caller must be the room owner.
|
||||
kick(peer: string, roomID: string, target: string): Promise<{ status: string }> {
|
||||
return this.req("POST", "/api/kick", { peer, room_id: roomID, target });
|
||||
}
|
||||
|
||||
// stream opens the SSE channel for a peer. onEvent fires for each received bus
|
||||
// message; onError fires if the stream drops. Returns the EventSource so the
|
||||
// caller can close it.
|
||||
stream(peer: string, onEvent: (ev: BusEvent) => void, onError?: () => void): EventSource {
|
||||
const es = new EventSource(`${this.baseURL}/api/stream?peer=${encodeURIComponent(peer)}`);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
onEvent(JSON.parse(e.data) as BusEvent);
|
||||
} catch {
|
||||
// ignore malformed frames (keepalive comments never reach onmessage)
|
||||
}
|
||||
};
|
||||
if (onError) es.onerror = onError;
|
||||
return es;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AppShell,
|
||||
Group,
|
||||
Title,
|
||||
Badge,
|
||||
Button,
|
||||
CopyButton,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
ThemeIcon,
|
||||
Alert,
|
||||
Transition,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBolt,
|
||||
IconLogout,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertTriangle,
|
||||
} from "@tabler/icons-react";
|
||||
import { GatewayClient } from "../api";
|
||||
import type { Member, Message, Peer, Room } from "../types";
|
||||
import { RoomList } from "./RoomList";
|
||||
import { MessagePane } from "./MessagePane";
|
||||
import { MembersPane } from "./MembersPane";
|
||||
|
||||
interface Props {
|
||||
client: GatewayClient;
|
||||
peer: Peer;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
// short renders the first 10 chars of an endpoint id, enough to disambiguate.
|
||||
export function short(endpoint: string): string {
|
||||
return endpoint.length > 12 ? endpoint.slice(0, 10) + "…" : endpoint;
|
||||
}
|
||||
|
||||
// ChatLayout owns all chat state: the peer's rooms, the active room, the
|
||||
// per-room message log fed by the SSE stream, the directory of connected peers
|
||||
// (to label senders and pick invitees), and the active room's member list. Every
|
||||
// bus action goes through the gateway client.
|
||||
export function ChatLayout({ client, peer, onDisconnect }: Props) {
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [activeRoom, setActiveRoom] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Record<string, Message[]>>({});
|
||||
const [peers, setPeers] = useState<Peer[]>([]);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
const fail = useCallback((e: unknown) => {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}, []);
|
||||
|
||||
// ---- data refreshers ----------------------------------------------------
|
||||
|
||||
const refreshRooms = useCallback(async () => {
|
||||
try {
|
||||
setRooms(await client.rooms(peer.name));
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}, [client, peer.name, fail]);
|
||||
|
||||
const refreshPeers = useCallback(async () => {
|
||||
try {
|
||||
setPeers(await client.peers());
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}, [client, fail]);
|
||||
|
||||
const refreshMembers = useCallback(
|
||||
async (roomID: string) => {
|
||||
try {
|
||||
setMembers(await client.members(roomID));
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, fail],
|
||||
);
|
||||
|
||||
// ---- live stream (SSE) --------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const es = client.stream(
|
||||
peer.name,
|
||||
(ev) => {
|
||||
seq.current += 1;
|
||||
const msg: Message = { ...ev, id: `${ev.ts}-${seq.current}` };
|
||||
setMessages((prev) => {
|
||||
const list = prev[ev.room_id] ?? [];
|
||||
return { ...prev, [ev.room_id]: [...list, msg] };
|
||||
});
|
||||
},
|
||||
() => setError("Se perdió la conexión con el gateway (stream SSE)"),
|
||||
);
|
||||
return () => es.close();
|
||||
}, [client, peer.name]);
|
||||
|
||||
// Initial load.
|
||||
useEffect(() => {
|
||||
refreshRooms();
|
||||
refreshPeers();
|
||||
}, [refreshRooms, refreshPeers]);
|
||||
|
||||
// Refresh members whenever the active room changes.
|
||||
useEffect(() => {
|
||||
if (activeRoom) refreshMembers(activeRoom);
|
||||
else setMembers([]);
|
||||
}, [activeRoom, refreshMembers]);
|
||||
|
||||
// ---- actions ------------------------------------------------------------
|
||||
|
||||
const onCreateRoom = useCallback(
|
||||
async (subject: string, encrypt: boolean) => {
|
||||
try {
|
||||
const r = await client.createRoom(peer.name, subject, encrypt);
|
||||
await refreshRooms();
|
||||
setActiveRoom(r.room_id);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, refreshRooms, fail],
|
||||
);
|
||||
|
||||
const onJoinRoom = useCallback(
|
||||
async (roomID: string) => {
|
||||
try {
|
||||
await client.join(peer.name, roomID);
|
||||
await refreshRooms();
|
||||
setActiveRoom(roomID);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, refreshRooms, fail],
|
||||
);
|
||||
|
||||
const onInvite = useCallback(
|
||||
async (target: string) => {
|
||||
if (!activeRoom) return;
|
||||
try {
|
||||
await client.invite(peer.name, activeRoom, target);
|
||||
await refreshMembers(activeRoom);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, activeRoom, refreshMembers, fail],
|
||||
);
|
||||
|
||||
const onKick = useCallback(
|
||||
async (target: string) => {
|
||||
if (!activeRoom) return;
|
||||
try {
|
||||
await client.kick(peer.name, activeRoom, target);
|
||||
await refreshMembers(activeRoom);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, activeRoom, refreshMembers, fail],
|
||||
);
|
||||
|
||||
const onPublish = useCallback(
|
||||
async (text: string) => {
|
||||
if (!activeRoom) return;
|
||||
try {
|
||||
await client.publish(peer.name, activeRoom, text);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, activeRoom, fail],
|
||||
);
|
||||
|
||||
// endpoint -> display name, using the peer directory; falls back to a short id.
|
||||
const nameFor = useMemo(() => {
|
||||
const byEndpoint = new Map(peers.map((p) => [p.endpoint_id, p.name]));
|
||||
return (endpoint: string) =>
|
||||
endpoint === peer.endpoint_id ? peer.name : byEndpoint.get(endpoint) ?? short(endpoint);
|
||||
}, [peers, peer]);
|
||||
|
||||
const activeRoomObj = rooms.find((r) => r.room_id === activeRoom) ?? null;
|
||||
const iAmOwner = members.some((m) => m.endpoint === peer.endpoint_id && m.role === "owner");
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{ width: 300, breakpoint: "sm" }}
|
||||
aside={{ width: 300, breakpoint: "md", collapsed: { desktop: !activeRoom, mobile: true } }}
|
||||
padding={0}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="violet" radius="md">
|
||||
<IconBolt size={18} />
|
||||
</ThemeIcon>
|
||||
<Title order={4}>unibus</Title>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge variant="light" color="violet" size="lg">
|
||||
{peer.name}
|
||||
</Badge>
|
||||
<CopyButton value={peer.endpoint_id}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "¡copiado!" : peer.endpoint_id} withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={onDisconnect}
|
||||
>
|
||||
Salir
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar>
|
||||
<RoomList
|
||||
rooms={rooms}
|
||||
activeRoom={activeRoom}
|
||||
onSelect={setActiveRoom}
|
||||
onCreateRoom={onCreateRoom}
|
||||
onJoinRoom={onJoinRoom}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main h="100vh">
|
||||
{error && (
|
||||
<Transition mounted={!!error} transition="slide-down">
|
||||
{(styles) => (
|
||||
<Alert
|
||||
style={{ ...styles, position: "absolute", top: 70, left: "50%", transform: "translateX(-50%)", zIndex: 200, minWidth: 360 }}
|
||||
color="red"
|
||||
variant="filled"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
withCloseButton
|
||||
onClose={() => setError(null)}
|
||||
title="Error"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Transition>
|
||||
)}
|
||||
<MessagePane
|
||||
room={activeRoomObj}
|
||||
messages={activeRoom ? messages[activeRoom] ?? [] : []}
|
||||
myEndpoint={peer.endpoint_id}
|
||||
nameFor={nameFor}
|
||||
onPublish={onPublish}
|
||||
/>
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Aside>
|
||||
{activeRoomObj && (
|
||||
<MembersPane
|
||||
room={activeRoomObj}
|
||||
members={members}
|
||||
peers={peers}
|
||||
myEndpoint={peer.endpoint_id}
|
||||
iAmOwner={iAmOwner}
|
||||
nameFor={nameFor}
|
||||
onInvite={onInvite}
|
||||
onKick={onKick}
|
||||
onRefresh={() => activeRoom && refreshMembers(activeRoom)}
|
||||
/>
|
||||
)}
|
||||
</AppShell.Aside>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconBolt, IconPlugConnected, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { GatewayClient } from "../api";
|
||||
import type { Peer } from "../types";
|
||||
|
||||
const LS_GATEWAY = "unibus.gateway";
|
||||
const LS_PEER = "unibus.peer";
|
||||
|
||||
interface Props {
|
||||
onConnect: (client: GatewayClient, peer: Peer) => void;
|
||||
}
|
||||
|
||||
// ConnectScreen asks for the gateway URL and the identity (peer name) to connect
|
||||
// as. Both persist in localStorage so a reload reconnects with one click. The
|
||||
// gateway hosts the real Go bus peer; the browser only drives it.
|
||||
export function ConnectScreen({ onConnect }: Props) {
|
||||
const [gateway, setGateway] = useState(
|
||||
() => localStorage.getItem(LS_GATEWAY) ?? "http://localhost:7700",
|
||||
);
|
||||
const [name, setName] = useState(() => localStorage.getItem(LS_PEER) ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const connect = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setError("Elige un nombre de identidad");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new GatewayClient(gateway.trim());
|
||||
const peer = await client.connect(trimmed);
|
||||
localStorage.setItem(LS_GATEWAY, client.baseURL);
|
||||
localStorage.setItem(LS_PEER, trimmed);
|
||||
onConnect(client, peer);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center h="100vh" p="md">
|
||||
<Card withBorder shadow="md" radius="lg" p="xl" w={420} maw="100%">
|
||||
<Stack gap="lg">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="xl" radius="md" variant="light" color="violet">
|
||||
<IconBolt size={26} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>unibus</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
chat cifrado extremo a extremo sobre NATS
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="Gateway"
|
||||
description="URL del gateway web de unibus"
|
||||
placeholder="http://localhost:7700"
|
||||
value={gateway}
|
||||
onChange={(e) => setGateway(e.currentTarget.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<TextInput
|
||||
label="Identidad"
|
||||
description="Tu nombre de peer en el bus (persistente)"
|
||||
placeholder="ana"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
||||
disabled={busy}
|
||||
data-autofocus
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title="No se pudo conectar"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
leftSection={<IconPlugConnected size={18} />}
|
||||
onClick={connect}
|
||||
loading={busy}
|
||||
fullWidth
|
||||
size="md"
|
||||
>
|
||||
Conectar
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Badge,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
CopyButton,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconLock,
|
||||
IconHash,
|
||||
IconSend,
|
||||
IconMessages,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Message, Room } from "../types";
|
||||
|
||||
interface Props {
|
||||
room: Room | null;
|
||||
messages: Message[];
|
||||
myEndpoint: string;
|
||||
nameFor: (endpoint: string) => string;
|
||||
onPublish: (text: string) => void;
|
||||
}
|
||||
|
||||
// formatTime renders a message timestamp as HH:mm:ss in 24h European style.
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString("es-ES", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// MessagePane is the center column: the active room's live message log plus the
|
||||
// composer. Own messages align right; others align left and show the sender.
|
||||
export function MessagePane({ room, messages, myEndpoint, nameFor, onPublish }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to the newest message.
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight, behavior: "smooth" });
|
||||
}, [messages.length]);
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<ThemeIcon size={64} radius="xl" variant="light" color="gray">
|
||||
<IconMessages size={34} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">Elige o crea una room para empezar a chatear</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
const t = text.trim();
|
||||
if (t) {
|
||||
onPublish(t);
|
||||
setText("");
|
||||
}
|
||||
};
|
||||
|
||||
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" wrap="nowrap">
|
||||
{room.encrypt ? <IconLock size={18} /> : <IconHash size={18} />}
|
||||
<Text fw={600}>{room.subject}</Text>
|
||||
{room.encrypt && (
|
||||
<Badge size="sm" color="teal" variant="light">
|
||||
cifrada E2E
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<CopyButton value={room.room_id}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "¡copiado!" : "copiar room id"} withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} p="md">
|
||||
<Stack gap="sm">
|
||||
{messages.length === 0 && (
|
||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
||||
No hay mensajes todavía.
|
||||
</Text>
|
||||
)}
|
||||
{messages.map((m) => {
|
||||
const mine = m.sender === myEndpoint;
|
||||
return (
|
||||
<Box
|
||||
key={m.id}
|
||||
style={{ display: "flex", justifyContent: mine ? "flex-end" : "flex-start" }}
|
||||
>
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="xs"
|
||||
radius="md"
|
||||
p="xs"
|
||||
bg={mine ? "violet.9" : undefined}
|
||||
maw="75%"
|
||||
>
|
||||
{!mine && (
|
||||
<Text size="xs" fw={700} c="violet.4">
|
||||
{nameFor(m.sender)}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" style={{ wordBreak: "break-word", whiteSpace: "pre-wrap" }}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<Text size="9px" c="dimmed" ta="right" mt={2}>
|
||||
{formatTime(m.ts)}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Group p="md" gap="xs" wrap="nowrap" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
placeholder={`Mensaje a ${room.subject}…`}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
/>
|
||||
<ActionIcon size="lg" onClick={send} disabled={!text.trim()}>
|
||||
<IconSend size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import "@mantine/core/styles.css";
|
||||
import { theme } from "./theme";
|
||||
import { App } from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createTheme } from "@mantine/core";
|
||||
|
||||
// The unibus theme: a single accent color and a slightly tighter default radius.
|
||||
// Mantine generates all its CSS variables from this; the SPA never hand-writes
|
||||
// CSS or color literals.
|
||||
export const theme = createTheme({
|
||||
primaryColor: "violet",
|
||||
defaultRadius: "md",
|
||||
fontFamily:
|
||||
"Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
|
||||
headings: {
|
||||
fontWeight: "650",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Domain types shared across the SPA. They mirror the JSON the unibus gateway
|
||||
// (playground/server.go) returns; the browser never speaks NATS or crypto
|
||||
// directly — the Go peer behind the gateway does, so every type here is a plain
|
||||
// view of a gateway response.
|
||||
|
||||
// Peer is a named identity hosted by the gateway. endpoint_id is the stable bus
|
||||
// endpoint (base64url of sha256(signPub)).
|
||||
export interface Peer {
|
||||
name: string;
|
||||
endpoint_id: string;
|
||||
}
|
||||
|
||||
// Room is a channel the connected peer created or joined. encrypt true means the
|
||||
// payloads are sealed end-to-end with the room key.
|
||||
export interface Room {
|
||||
room_id: string;
|
||||
subject: string;
|
||||
encrypt: boolean;
|
||||
}
|
||||
|
||||
// Member is one participant of a room as reported by the control plane.
|
||||
export interface Member {
|
||||
endpoint: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// BusEvent is one Server-Sent Event delivered on /api/stream: a message a peer
|
||||
// received on one of its subscribed rooms, already decrypted by the Go peer.
|
||||
export interface BusEvent {
|
||||
room_id: string;
|
||||
subject: string;
|
||||
sender: string;
|
||||
text: string;
|
||||
encrypted: boolean;
|
||||
ts: number; // unix millis
|
||||
}
|
||||
|
||||
// Message is a BusEvent enriched with a stable local id for React keys.
|
||||
export interface Message extends BusEvent {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// The SPA talks to the unibus gateway over plain fetch + EventSource; the
|
||||
// gateway URL is chosen at runtime on the connect screen, so nothing is proxied
|
||||
// here. The dev server runs on a fixed port so the gateway's permissive CORS is
|
||||
// predictable.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user