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