feat(web): redesign chat ui in telegram-like style
All checks were successful
CI / test (push) Successful in 21s

- update overall layout for desktop/mobile chat navigation

- restyle dialogs list, message bubbles and composer

- add atmospheric background and unified panel styling
This commit is contained in:
2026-03-08 00:10:08 +03:00
parent a4d7294628
commit 0a602e4078
6 changed files with 188 additions and 98 deletions

View File

@@ -3,22 +3,53 @@ import { NewChatPanel } from "./NewChatPanel";
export function ChatList() {
const chats = useChatStore((s) => s.chats);
const messagesByChat = useChatStore((s) => s.messagesByChat);
const activeChatId = useChatStore((s) => s.activeChatId);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
return (
<aside className="w-full max-w-xs border-r border-slate-700 bg-panel">
<div className="border-b border-slate-700 p-3 text-sm font-semibold">Chats</div>
<aside className="flex h-full w-full max-w-xs flex-col bg-slate-900/60">
<div className="border-b border-slate-700/50 px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<p className="text-base font-semibold tracking-wide">Chats</p>
<span className="rounded-full bg-slate-700/70 px-2 py-1 text-[11px] text-slate-200">{chats.length}</span>
</div>
<label className="block">
<input
className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="Search chats..."
disabled
value=""
readOnly
/>
</label>
</div>
<NewChatPanel />
<div className="max-h-[calc(100vh-56px)] overflow-auto">
<div className="tg-scrollbar flex-1 overflow-auto">
{chats.map((chat) => (
<button
className={`block w-full border-b border-slate-800 px-3 py-3 text-left ${activeChatId === chat.id ? "bg-slate-800" : "hover:bg-slate-800/40"}`}
className={`block w-full border-b border-slate-800/60 px-4 py-3 text-left transition ${
activeChatId === chat.id ? "bg-sky-500/20" : "hover:bg-slate-800/65"
}`}
key={chat.id}
onClick={() => setActiveChatId(chat.id)}
>
<p className="font-medium">{chat.title || `${chat.type} #${chat.id}`}</p>
<p className="text-xs text-slate-400">{chat.type}</p>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
{(chat.title || chat.type).slice(0, 1)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-semibold">{chat.title || `${chat.type} #${chat.id}`}</p>
<span className="shrink-0 text-[11px] text-slate-400">
{messagesByChat[chat.id]?.length ? "now" : ""}
</span>
</div>
<p className="truncate text-xs text-slate-400">{chat.type}</p>
</div>
</div>
</button>
))}
</div>

View File

@@ -238,11 +238,26 @@ export function MessageComposer() {
}
return (
<div className="border-t border-slate-700 bg-panel p-3">
<div className="mb-2 flex gap-2">
<div className="border-t border-slate-700/50 bg-slate-900/55 p-3">
<div className="mb-2 flex items-center gap-2">
<label className="cursor-pointer rounded-full bg-slate-700/80 px-3 py-2 text-xs font-semibold hover:bg-slate-700">
+
<input
className="hidden"
type="file"
disabled={isUploading}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
selectFile(file);
}
e.currentTarget.value = "";
}}
/>
</label>
<input
className="flex-1 rounded bg-slate-800 px-3 py-2"
placeholder="Type message..."
className="flex-1 rounded-full border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="Write a message..."
value={text}
onChange={(e) => {
setText(e.target.value);
@@ -252,12 +267,12 @@ export function MessageComposer() {
}
}}
/>
<button className="rounded bg-accent px-3 py-2 font-semibold text-black" onClick={handleSend}>
<button className="rounded-full bg-sky-500 px-4 py-2.5 text-sm font-semibold text-slate-950 hover:bg-sky-400" onClick={handleSend}>
Send
</button>
</div>
{selectedFile ? (
<div className="mb-2 rounded border border-slate-700 bg-slate-900 p-3 text-sm">
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 p-3 text-sm">
<div className="mb-2 font-semibold">Ready to send</div>
<div className="mb-1 break-all text-slate-300">{selectedFile.name}</div>
<div className="mb-2 text-xs text-slate-400">{formatBytes(selectedFile.size)}</div>
@@ -277,13 +292,13 @@ export function MessageComposer() {
) : null}
<div className="flex gap-2">
<button
className="rounded bg-accent px-3 py-1 font-semibold text-black disabled:opacity-50"
className="rounded-lg bg-sky-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-50"
onClick={() => void sendSelectedFile()}
disabled={isUploading}
>
Send media
</button>
<button className="rounded bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={cancelSelectedFile} disabled={isUploading}>
<button className="rounded-lg bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={cancelSelectedFile} disabled={isUploading}>
Cancel
</button>
</div>
@@ -291,25 +306,10 @@ export function MessageComposer() {
) : null}
{uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</div> : null}
<div className="flex items-center gap-2 text-sm">
<label className="cursor-pointer rounded bg-slate-700 px-3 py-1">
Upload
<input
className="hidden"
type="file"
disabled={isUploading}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
selectFile(file);
}
e.currentTarget.value = "";
}}
/>
</label>
<button className="rounded bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={startRecord} disabled={isUploading || isRecording}>
<button className="rounded-lg bg-slate-700/90 px-3 py-1.5 disabled:opacity-50" onClick={startRecord} disabled={isUploading || isRecording}>
{isRecording ? "Recording..." : "Record Voice"}
</button>
<button className="rounded bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={stopRecord} disabled={!isRecording}>
<button className="rounded-lg bg-slate-700/90 px-3 py-1.5 disabled:opacity-50" onClick={stopRecord} disabled={!isRecording}>
Stop
</button>
</div>

View File

@@ -17,56 +17,60 @@ export function MessageList() {
}, [activeChatId, messagesByChat]);
if (!activeChatId) {
return <div className="flex h-full items-center justify-center text-slate-400">Select a chat</div>;
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
}
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-auto p-4">
{messages.map((message) => (
<div className={`mb-3 flex ${message.sender_id === me?.id ? "justify-end" : "justify-start"}`} key={message.id}>
<div className="max-w-[80%] rounded-lg bg-slate-800 px-3 py-2">
{message.type === "voice" && message.text ? (
renderContent(message.type, message.text)
) : (
renderContent(message.type, message.text)
)}
<p className="mt-1 flex items-center justify-end gap-1 text-right text-[11px] text-slate-400">
<span>{formatTime(message.created_at)}</span>
{message.sender_id === me?.id ? <span className={message.delivery_status === "read" ? "text-sky-400" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p>
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
{messages.map((message) => {
const own = message.sender_id === me?.id;
return (
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`} key={`${message.id}-${message.client_message_id ?? ""}`}>
<div
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
own
? "rounded-br-md bg-sky-500/90 text-slate-950"
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
}`}
>
{renderContent(message.type, message.text)}
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p>
</div>
</div>
</div>
))}
</div>
<div className="px-4 pb-2 text-xs text-slate-400">
{(typingByChat[activeChatId] ?? []).length > 0 ? "Someone is typing..." : ""}
);
})}
</div>
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
</div>
);
}
function renderContent(messageType: string, text: string | null) {
if (!text) {
return <p className="text-slate-300">[empty]</p>;
}
if (messageType === "image") {
return <img alt="attachment" className="max-h-64 rounded" src={text} />;
}
if (messageType === "video" || messageType === "circle_video") {
return <video className="max-h-64 rounded" controls src={text} />;
}
if (messageType === "audio" || messageType === "voice") {
return <audio controls src={text} />;
}
if (messageType === "file") {
return (
<a className="text-accent underline" href={text} rel="noreferrer" target="_blank">
Open file
</a>
);
function renderContent(messageType: string, text: string | null) {
if (!text) {
return <p className="opacity-80">[empty]</p>;
}
return <p className="whitespace-pre-wrap break-words">{text}</p>;
if (messageType === "image") {
return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
}
if (messageType === "video" || messageType === "circle_video") {
return <video className="max-h-72 rounded-lg" controls src={text} />;
}
if (messageType === "audio" || messageType === "voice") {
return <audio controls src={text} />;
}
if (messageType === "file") {
return (
<a className="underline" href={text} rel="noreferrer" target="_blank">
Open file
</a>
);
}
return <p className="whitespace-pre-wrap break-words">{text}</p>;
}
function renderStatus(status: string | undefined): string {
if (status === "sending") {

View File

@@ -75,12 +75,18 @@ export function NewChatPanel() {
}
return (
<div className="border-b border-slate-700 p-3">
<div className="border-b border-slate-700/50 bg-slate-900/45 p-3">
<div className="mb-2 flex gap-2 text-xs">
<button className={`rounded px-2 py-1 ${mode === "group" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("group")}>
<button
className={`rounded-lg px-2.5 py-1.5 ${mode === "group" ? "bg-sky-500 text-slate-950" : "bg-slate-700/70 hover:bg-slate-700"}`}
onClick={() => setMode("group")}
>
Group
</button>
<button className={`rounded px-2 py-1 ${mode === "channel" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("channel")}>
<button
className={`rounded-lg px-2.5 py-1.5 ${mode === "channel" ? "bg-sky-500 text-slate-950" : "bg-slate-700/70 hover:bg-slate-700"}`}
onClick={() => setMode("channel")}
>
Channel
</button>
</div>
@@ -88,15 +94,15 @@ export function NewChatPanel() {
<div className="mb-3 space-y-2">
<p className="text-xs text-slate-400">Новый диалог</p>
<input
className="w-full rounded bg-slate-800 px-2 py-1 text-sm"
className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="@username"
value={query}
onChange={(e) => void handleSearch(e.target.value)}
/>
<div className="max-h-32 overflow-auto">
<div className="tg-scrollbar max-h-32 overflow-auto">
{results.map((user) => (
<button
className="mb-1 block w-full rounded bg-slate-800 px-2 py-1 text-left text-sm hover:bg-slate-700"
className="mb-1 block w-full rounded-lg bg-slate-800/90 px-2 py-1.5 text-left text-sm hover:bg-slate-700/90"
key={user.id}
onClick={() => void createPrivate(user.id)}
>
@@ -108,12 +114,12 @@ export function NewChatPanel() {
</div>
<form className="space-y-2" onSubmit={(e) => void createByType(e)}>
<input
className="w-full rounded bg-slate-800 px-2 py-1 text-sm"
className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder={mode === "group" ? "Group title" : "Channel title"}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button className="w-full rounded bg-slate-700 px-2 py-1 text-sm hover:bg-slate-600" disabled={loading} type="submit">
<button className="w-full rounded-lg bg-sky-500 px-2 py-2 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60" disabled={loading} type="submit">
Create {mode}
</button>
</form>

View File

@@ -1,3 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -10,5 +12,36 @@ body,
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-family: "Manrope", "Segoe UI", sans-serif;
background:
radial-gradient(circle at 22% 18%, rgba(36, 68, 117, 0.35), transparent 26%),
radial-gradient(circle at 82% 12%, rgba(24, 95, 102, 0.25), transparent 30%),
linear-gradient(180deg, #101e30 0%, #162233 55%, #19283a 100%);
color: #e5edf9;
}
* {
box-sizing: border-box;
}
.tg-panel {
background: rgba(19, 31, 47, 0.9);
border: 1px solid rgba(146, 174, 208, 0.14);
backdrop-filter: blur(8px);
}
.tg-chat-wallpaper {
background:
radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.09), transparent 30%),
radial-gradient(circle at 86% 74%, rgba(34, 197, 94, 0.07), transparent 33%),
linear-gradient(160deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.015) 100%);
}
.tg-scrollbar::-webkit-scrollbar {
width: 8px;
}
.tg-scrollbar::-webkit-scrollbar-thumb {
background: rgba(126, 159, 201, 0.35);
border-radius: 999px;
}

View File

@@ -11,7 +11,10 @@ export function ChatsPage() {
const logout = useAuthStore((s) => s.logout);
const loadChats = useChatStore((s) => s.loadChats);
const activeChatId = useChatStore((s) => s.activeChatId);
const chats = useChatStore((s) => s.chats);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const loadMessages = useChatStore((s) => s.loadMessages);
const activeChat = chats.find((chat) => chat.id === activeChatId);
useRealtime();
@@ -26,26 +29,39 @@ export function ChatsPage() {
}, [activeChatId, loadMessages]);
return (
<main className="flex h-screen w-full bg-bg text-text">
<ChatList />
<section className="flex flex-1 flex-col">
<div className="flex items-center justify-between border-b border-slate-700 bg-panel px-4 py-3">
<p className="text-sm">Signed in as {me?.username}</p>
<button className="rounded bg-slate-700 px-3 py-1 text-sm" onClick={logout}>
Logout
</button>
</div>
<div className="min-h-0 flex-1">
<MessageList />
</div>
{activeChatId ? (
<MessageComposer />
) : (
<div className="border-t border-slate-700 bg-panel p-4 text-center text-sm text-slate-400">
Выберите чат, чтобы начать переписку
<main className="h-screen w-full p-2 text-text md:p-4">
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-4">
<section className={`tg-panel overflow-hidden rounded-2xl ${activeChatId ? "hidden md:block md:w-[360px]" : "w-full md:w-[360px]"}`}>
<ChatList />
</section>
<section className={`tg-panel tg-chat-wallpaper min-w-0 flex-1 overflow-hidden rounded-2xl ${activeChatId ? "flex" : "hidden md:flex"} flex-col`}>
<div className="flex items-center justify-between border-b border-slate-700/50 bg-slate-900/55 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs md:hidden" onClick={() => setActiveChatId(null)}>
Back
</button>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{activeChat?.title || `@${me?.username}`}</p>
<p className="truncate text-xs text-slate-300/80">{activeChat ? activeChat.type : "Select a chat"}</p>
</div>
</div>
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
Logout
</button>
</div>
)}
</section>
<div className="min-h-0 flex-1">
<MessageList />
</div>
{activeChatId ? (
<MessageComposer />
) : (
<div className="border-t border-slate-700/50 bg-slate-900/40 p-4 text-center text-sm text-slate-300/80">
Выберите чат, чтобы начать переписку
</div>
)}
</section>
</div>
</main>
);
}