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() { export function ChatList() {
const chats = useChatStore((s) => s.chats); const chats = useChatStore((s) => s.chats);
const messagesByChat = useChatStore((s) => s.messagesByChat);
const activeChatId = useChatStore((s) => s.activeChatId); const activeChatId = useChatStore((s) => s.activeChatId);
const setActiveChatId = useChatStore((s) => s.setActiveChatId); const setActiveChatId = useChatStore((s) => s.setActiveChatId);
return ( return (
<aside className="w-full max-w-xs border-r border-slate-700 bg-panel"> <aside className="flex h-full w-full max-w-xs flex-col bg-slate-900/60">
<div className="border-b border-slate-700 p-3 text-sm font-semibold">Chats</div> <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 /> <NewChatPanel />
<div className="max-h-[calc(100vh-56px)] overflow-auto">
<div className="tg-scrollbar flex-1 overflow-auto">
{chats.map((chat) => ( {chats.map((chat) => (
<button <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} key={chat.id}
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
> >
<p className="font-medium">{chat.title || `${chat.type} #${chat.id}`}</p> <div className="flex items-start gap-3">
<p className="text-xs text-slate-400">{chat.type}</p> <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> </button>
))} ))}
</div> </div>

View File

@@ -238,11 +238,26 @@ export function MessageComposer() {
} }
return ( return (
<div className="border-t border-slate-700 bg-panel p-3"> <div className="border-t border-slate-700/50 bg-slate-900/55 p-3">
<div className="mb-2 flex gap-2"> <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 <input
className="flex-1 rounded bg-slate-800 px-3 py-2" className="hidden"
placeholder="Type message..." 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-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} value={text}
onChange={(e) => { onChange={(e) => {
setText(e.target.value); 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 Send
</button> </button>
</div> </div>
{selectedFile ? ( {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-2 font-semibold">Ready to send</div>
<div className="mb-1 break-all text-slate-300">{selectedFile.name}</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> <div className="mb-2 text-xs text-slate-400">{formatBytes(selectedFile.size)}</div>
@@ -277,13 +292,13 @@ export function MessageComposer() {
) : null} ) : null}
<div className="flex gap-2"> <div className="flex gap-2">
<button <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()} onClick={() => void sendSelectedFile()}
disabled={isUploading} disabled={isUploading}
> >
Send media Send media
</button> </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 Cancel
</button> </button>
</div> </div>
@@ -291,25 +306,10 @@ export function MessageComposer() {
) : null} ) : null}
{uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</div> : null} {uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</div> : null}
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<label className="cursor-pointer rounded bg-slate-700 px-3 py-1"> <button className="rounded-lg bg-slate-700/90 px-3 py-1.5 disabled:opacity-50" onClick={startRecord} disabled={isUploading || isRecording}>
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}>
{isRecording ? "Recording..." : "Record Voice"} {isRecording ? "Recording..." : "Record Voice"}
</button> </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 Stop
</button> </button>
</div> </div>

View File

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

View File

@@ -75,12 +75,18 @@ export function NewChatPanel() {
} }
return ( 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"> <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 Group
</button> </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 Channel
</button> </button>
</div> </div>
@@ -88,15 +94,15 @@ export function NewChatPanel() {
<div className="mb-3 space-y-2"> <div className="mb-3 space-y-2">
<p className="text-xs text-slate-400">Новый диалог</p> <p className="text-xs text-slate-400">Новый диалог</p>
<input <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" placeholder="@username"
value={query} value={query}
onChange={(e) => void handleSearch(e.target.value)} 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) => ( {results.map((user) => (
<button <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} key={user.id}
onClick={() => void createPrivate(user.id)} onClick={() => void createPrivate(user.id)}
> >
@@ -108,12 +114,12 @@ export function NewChatPanel() {
</div> </div>
<form className="space-y-2" onSubmit={(e) => void createByType(e)}> <form className="space-y-2" onSubmit={(e) => void createByType(e)}>
<input <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"} placeholder={mode === "group" ? "Group title" : "Channel title"}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} 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} Create {mode}
</button> </button>
</form> </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 base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -10,5 +12,36 @@ body,
body { body {
margin: 0; 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 logout = useAuthStore((s) => s.logout);
const loadChats = useChatStore((s) => s.loadChats); const loadChats = useChatStore((s) => s.loadChats);
const activeChatId = useChatStore((s) => s.activeChatId); 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 loadMessages = useChatStore((s) => s.loadMessages);
const activeChat = chats.find((chat) => chat.id === activeChatId);
useRealtime(); useRealtime();
@@ -26,12 +29,24 @@ export function ChatsPage() {
}, [activeChatId, loadMessages]); }, [activeChatId, loadMessages]);
return ( return (
<main className="flex h-screen w-full bg-bg text-text"> <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 /> <ChatList />
<section className="flex flex-1 flex-col"> </section>
<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> <section className={`tg-panel tg-chat-wallpaper min-w-0 flex-1 overflow-hidden rounded-2xl ${activeChatId ? "flex" : "hidden md:flex"} flex-col`}>
<button className="rounded bg-slate-700 px-3 py-1 text-sm" onClick={logout}> <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 Logout
</button> </button>
</div> </div>
@@ -41,11 +56,12 @@ export function ChatsPage() {
{activeChatId ? ( {activeChatId ? (
<MessageComposer /> <MessageComposer />
) : ( ) : (
<div className="border-t border-slate-700 bg-panel p-4 text-center text-sm text-slate-400"> <div className="border-t border-slate-700/50 bg-slate-900/40 p-4 text-center text-sm text-slate-300/80">
Выберите чат, чтобы начать переписку Выберите чат, чтобы начать переписку
</div> </div>
)} )}
</section> </section>
</div>
</main> </main>
); );
} }