feat(web): frontend v1 — login (handle+contraseña), sidebar rooms+buscador, chat estilo Element
SPA React 19 + Vite + Mantine v9 en modo oscuro (acento índigo), datos mock para iterar el diseño antes de cablear el gateway. Login con identidad + contraseña (la contraseña desbloqueará la identidad Ed25519 cifrada en el dispositivo). Sidebar: avatar de usuario, buscador (rooms/usuarios/mensajes) y lista de rooms con candado E2E / hash cleartext / badges de no leídos. Panel de chat estilo Element (avatar+nombre+hora+texto) con composer interactivo.
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</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "unibus-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"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,11 @@
|
||||
import { useState } from "react";
|
||||
import { Login } from "./Login";
|
||||
import { ChatShell } from "./ChatShell";
|
||||
import type { User } from "./types";
|
||||
|
||||
export function App() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
if (!user) return <Login onLogin={setUser} />;
|
||||
return <ChatShell user={user} onLogout={() => setUser(null)} />;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconSend,
|
||||
IconLock,
|
||||
IconHash,
|
||||
IconDotsVertical,
|
||||
IconPaperclip,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Message, Room, User } from "./types";
|
||||
|
||||
function initials(s: string) {
|
||||
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
||||
}
|
||||
function timeShort(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(
|
||||
d.getMinutes(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function MessageRow({ msg }: { msg: Message }) {
|
||||
return (
|
||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
|
||||
{initials(msg.sender)}
|
||||
</Avatar>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} align="baseline">
|
||||
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
|
||||
{msg.sender}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{timeShort(msg.ts)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||
{msg.body}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
room,
|
||||
user,
|
||||
}: {
|
||||
room: Room | undefined;
|
||||
user: User;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [extra, setExtra] = useState<Record<string, Message[]>>({});
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
|
||||
const msgs = room ? [...room.messages, ...(extra[room.id] ?? [])] : [];
|
||||
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [room?.id, msgs.length]);
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed">Selecciona una conversación</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
const msg: Message = {
|
||||
id: `local-${Date.now()}`,
|
||||
sender: user.handle,
|
||||
body,
|
||||
ts: Date.now(),
|
||||
mine: true,
|
||||
};
|
||||
setExtra((e) => ({ ...e, [room.id]: [...(e[room.id] ?? []), msg] }));
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack h="100vh" gap={0}>
|
||||
<Group justify="space-between" px="md" py="xs" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Avatar radius="md" size={38} color="brand">
|
||||
{initials(room.name)}
|
||||
</Avatar>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fw={650} truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
{room.encrypted ? (
|
||||
<Tooltip label="Cifrada de extremo a extremo">
|
||||
<IconLock size={14} style={{ opacity: 0.6 }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<IconHash size={14} style={{ opacity: 0.6 }} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{room.encrypted ? "cifrada · E2E" : "abierta · cleartext"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDotsVertical size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Divider color="dark.4" />
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport}>
|
||||
<Stack gap="lg" p="md">
|
||||
{msgs.map((m) => (
|
||||
<MessageRow key={m.id} msg={m} />
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Divider color="dark.4" />
|
||||
<Group p="sm" gap="xs" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="gray" size="lg">
|
||||
<IconPaperclip size={18} />
|
||||
</ActionIcon>
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
radius="xl"
|
||||
placeholder={`Mensaje a ${room.name}`}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
color="brand"
|
||||
onClick={send}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
<IconSend size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { Flex, Box } from "@mantine/core";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
import { MOCK_ROOMS } from "./mock";
|
||||
import type { User } from "./types";
|
||||
|
||||
export function ChatShell({
|
||||
user,
|
||||
onLogout,
|
||||
}: {
|
||||
user: User;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [rooms] = useState(MOCK_ROOMS);
|
||||
const [activeId, setActiveId] = useState<string>(rooms[0]?.id ?? "");
|
||||
const active = rooms.find((r) => r.id === activeId);
|
||||
|
||||
return (
|
||||
<Flex h="100vh" w="100vw" style={{ overflow: "hidden" }}>
|
||||
<Box
|
||||
w={320}
|
||||
h="100%"
|
||||
bg="dark.8"
|
||||
style={{
|
||||
borderRight: "1px solid var(--mantine-color-dark-4)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
user={user}
|
||||
rooms={rooms}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
|
||||
<ChatPanel room={active} user={user} />
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldLock, IconKey } from "@tabler/icons-react";
|
||||
import type { User } from "./types";
|
||||
|
||||
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
|
||||
const [handle, setHandle] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const ready = handle.trim().length > 0 && password.length > 0;
|
||||
const connect = () => {
|
||||
const h = handle.trim();
|
||||
if (ready) onLogin({ id: h, handle: h });
|
||||
};
|
||||
|
||||
return (
|
||||
<Center h="100vh" bg="dark.9">
|
||||
<Card w={380} p="xl" radius="lg" withBorder bg="dark.7">
|
||||
<Stack align="center" gap="lg">
|
||||
<ThemeIcon size={60} radius="xl" variant="light" color="brand">
|
||||
<IconShieldLock size={32} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} align="center">
|
||||
<Title order={2}>unibus</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Mensajería cifrada de extremo a extremo
|
||||
</Text>
|
||||
</Stack>
|
||||
<TextInput
|
||||
w="100%"
|
||||
label="Identidad"
|
||||
placeholder="tu-handle"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
w="100%"
|
||||
label="Contraseña"
|
||||
description="Desbloquea tu identidad cifrada en este dispositivo"
|
||||
placeholder="••••••••"
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
||||
/>
|
||||
<Button w="100%" size="md" onClick={connect} disabled={!ready}>
|
||||
Conectar
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconSearch,
|
||||
IconLogout,
|
||||
IconDots,
|
||||
IconLock,
|
||||
IconHash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Room, User } from "./types";
|
||||
|
||||
function initials(s: string) {
|
||||
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
function timeShort(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(
|
||||
d.getMinutes(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function RoomItem({
|
||||
room,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
room: Room;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
p="xs"
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
backgroundColor: active
|
||||
? "var(--mantine-color-dark-6)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar radius="md" size={42} color={active ? "brand" : "gray"}>
|
||||
{initials(room.name)}
|
||||
</Avatar>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{room.encrypted ? (
|
||||
<IconLock size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
) : (
|
||||
<IconHash size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
)}
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{timeShort(room.lastTs)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{room.lastMessage}
|
||||
</Text>
|
||||
{room.unread > 0 && (
|
||||
<Badge size="sm" circle variant="filled" color="brand">
|
||||
{room.unread}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
user,
|
||||
rooms,
|
||||
activeId,
|
||||
onSelect,
|
||||
onLogout,
|
||||
}: {
|
||||
user: User;
|
||||
rooms: Room[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const query = q.trim().toLowerCase();
|
||||
const filtered = query
|
||||
? rooms.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(query) ||
|
||||
r.messages.some((m) => m.body.toLowerCase().includes(query)),
|
||||
)
|
||||
: rooms;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
<Group justify="space-between" px="sm" py="xs" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Avatar radius="xl" size={34} color="brand">
|
||||
{initials(user.handle)}
|
||||
</Avatar>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{user.handle}
|
||||
</Text>
|
||||
</Group>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<UnstyledButton c="dimmed">
|
||||
<IconDots size={18} />
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLogout size={15} />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
Desconectar
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
|
||||
<Box px="sm" pb="sm">
|
||||
<TextInput
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.currentTarget.value)}
|
||||
placeholder="Buscar rooms, usuarios, mensajes…"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
radius="md"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
<Divider color="dark.4" />
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} type="scroll">
|
||||
<Stack gap={2} p={6}>
|
||||
{filtered.map((room) => (
|
||||
<RoomItem
|
||||
key={room.id}
|
||||
room={room}
|
||||
active={room.id === activeId}
|
||||
onClick={() => onSelect(room.id)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Text c="dimmed" size="sm" ta="center" mt="md">
|
||||
Sin resultados
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import "@mantine/core/styles.css";
|
||||
import { theme } from "./theme";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<MantineProvider theme={theme} forceColorScheme="dark">
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Room } from "./types";
|
||||
|
||||
// Datos de muestra para iterar el diseño sin el bus conectado.
|
||||
const now = 1749300000000;
|
||||
const m = (n: number) => now - n * 60_000;
|
||||
|
||||
export const MOCK_ROOMS: Room[] = [
|
||||
{
|
||||
id: "general",
|
||||
name: "general",
|
||||
encrypted: true,
|
||||
lastMessage: "¿Lo desplegamos hoy?",
|
||||
lastTs: m(2),
|
||||
unread: 3,
|
||||
messages: [
|
||||
{ id: "1", sender: "ana", body: "Buenas, ¿cómo va el cluster?", ts: m(40) },
|
||||
{ id: "2", sender: "lucas", body: "Los 3 nodos en R3, quorum verde", ts: m(38), mine: true },
|
||||
{ id: "3", sender: "ana", body: "Brutal. ¿Y el frontend?", ts: m(30) },
|
||||
{ id: "4", sender: "leo", body: "Primera iteración lista, estilo Element", ts: m(6) },
|
||||
{ id: "5", sender: "ana", body: "¿Lo desplegamos hoy?", ts: m(2) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
name: "board · privado",
|
||||
encrypted: true,
|
||||
lastMessage: "Os paso el acta cifrada",
|
||||
lastTs: m(95),
|
||||
unread: 0,
|
||||
messages: [
|
||||
{ id: "1", sender: "ceo", body: "Reunión a las 18:00", ts: m(120) },
|
||||
{ id: "2", sender: "lucas", body: "Anotado", ts: m(96), mine: true },
|
||||
{ id: "3", sender: "ceo", body: "Os paso el acta cifrada", ts: m(95) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bots",
|
||||
name: "bots",
|
||||
encrypted: false,
|
||||
lastMessage: "echo: ping",
|
||||
lastTs: m(210),
|
||||
unread: 0,
|
||||
messages: [
|
||||
{ id: "1", sender: "lucas", body: "!ping", ts: m(212), mine: true },
|
||||
{ id: "2", sender: "echobot", body: "echo: ping", ts: m(210) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "infra",
|
||||
name: "infra",
|
||||
encrypted: true,
|
||||
lastMessage: "magnus + homer + datardos OK",
|
||||
lastTs: m(330),
|
||||
unread: 1,
|
||||
messages: [
|
||||
{ id: "1", sender: "leo", body: "magnus + homer + datardos OK", ts: m(330) },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createTheme, type MantineColorsTuple } from "@mantine/core";
|
||||
|
||||
// Acento de marca de unibus — un violeta-índigo moderno.
|
||||
const brand: MantineColorsTuple = [
|
||||
"#f1edff",
|
||||
"#dcd3ff",
|
||||
"#b5a3f5",
|
||||
"#8d70ed",
|
||||
"#6c47e6",
|
||||
"#5a2fe2",
|
||||
"#5023e0",
|
||||
"#4119c7",
|
||||
"#3915b3",
|
||||
"#2f0f9e",
|
||||
];
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: "brand",
|
||||
colors: { brand },
|
||||
fontFamily:
|
||||
"Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
defaultRadius: "md",
|
||||
headings: { fontWeight: "650" },
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Tipos de dominio de la UI. En la iteración 1 se llenan con datos mock;
|
||||
// más adelante vendrán del gateway (REST/SSE) que es un peer del bus.
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
sender: string; // handle
|
||||
body: string;
|
||||
ts: number; // epoch ms
|
||||
mine?: boolean;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
encrypted: boolean;
|
||||
lastMessage: string;
|
||||
lastTs: number;
|
||||
unread: number;
|
||||
messages: Message[];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { host: true, port: 5181 },
|
||||
});
|
||||
Reference in New Issue
Block a user