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