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