feat(web): redesign chat ui in telegram-like style
All checks were successful
CI / test (push) Successful in 21s
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user