feat: update ui

This commit is contained in:
2026-09-16 11:26:58 +08:00
parent d45c78a5f8
commit 3fe95e925e
6 changed files with 164 additions and 138 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ui</title>
<title>WhatsApp Bridge UI</title>
</head>
<body>
+1
View File
@@ -16,6 +16,7 @@
"@tanstack/react-query": "^5.102.8",
"class-variance-authority": "^0.7.1",
"cn": "^0.3.0",
"dayjs": "^1.11.23",
"lucide-react": "^1.46.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+8
View File
@@ -26,6 +26,9 @@ importers:
cn:
specifier: ^0.3.0
version: 0.3.0
dayjs:
specifier: ^1.11.23
version: 1.11.23
lucide-react:
specifier: ^1.46.0
version: 1.46.0([email protected])
@@ -833,6 +836,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
[email protected]:
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
[email protected]:
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
engines: {node: '>=10'}
@@ -2674,6 +2680,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
mimic-fn: 3.1.0
+127
View File
@@ -0,0 +1,127 @@
import { useWAClient } from "@/lib/context";
import { cn, getId, getInitial } from "@/lib/utils";
import type { ChatDTO } from "@shared/dto";
import { useQuery } from "@tanstack/react-query";
import { SearchIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
type Props = {
selected: ChatDTO | null;
onSelect: (chat: ChatDTO) => void;
};
export default function ChatsSection({ selected, onSelect }: Props) {
const [query, setQuery] = useState("");
const [showSearch, setShowSearch] = useState(false);
const { client, isReady } = useWAClient();
const { data: chats } = useQuery({
queryKey: ["chats"],
queryFn: () =>
client
.listChats({ limit: 500 })
.then((res) => res.map((i) => ({ ...i, id: getId(i.id) }))),
enabled: isReady,
refetchInterval: 10_000,
});
const filteredChats = useMemo(() => {
if (!query.trim()) {
return chats?.slice(0, 100);
}
return chats
?.filter(
(chat) =>
chat.formattedTitle?.toLowerCase().includes(query.toLowerCase()) ||
chat.lastMessage?.body?.toLowerCase().includes(query.toLowerCase()),
)
?.slice(0, 100);
}, [chats, query]);
return (
<>
<div className="px-4 py-3">
{showSearch ? (
<div className="flex items-center gap-2 rounded-xl bg-muted px-3 py-2">
<SearchIcon size={17} className="text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search conversations"
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
aria-label="Close search"
onClick={() => {
setShowSearch(false);
setQuery("");
}}
>
<XIcon size={16} className="text-muted-foreground" />
</button>
</div>
) : (
<button
onClick={() => setShowSearch(true)}
className="flex w-full items-center gap-3 rounded-xl bg-muted px-3 py-2.5 text-left text-sm text-muted-foreground transition hover:bg-border"
>
<SearchIcon size={17} /> Search conversations
</button>
)}
</div>
{/* <div className="flex items-center gap-2 px-5 pb-2 text-xs font-medium text-muted-foreground">
<span className="text-primary">All</span>
<span>·</span>
<span>Unread</span>
<span>·</span>
<span>Groups</span>
</div> */}
<div className="flex-1 overflow-y-auto px-2 pb-3">
{filteredChats?.map((chat) => (
<button
key={chat.id}
onClick={() => onSelect(chat)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition ${selected?.id === chat.id ? "bg-accent" : "hover:bg-muted/70"}`}
>
<div
className={cn(
"relative grid size-11 shrink-0 place-items-center rounded-full text-sm font-semibold bg-rose-200 text-rose-800",
)}
>
{getInitial(chat.formattedTitle)}
{/* {chat.initials}
{chat.online && (
<span className="absolute bottom-0 right-0 size-3 rounded-full border-2 border-card bg-primary" />
)} */}
</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.formattedTitle || chat.name}
</p>
{/* <span className="shrink-0 text-[11px] text-muted-foreground">
{chat.timestamp}
</span> */}
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<p className="truncate text-xs text-muted-foreground">
{chat.previewMessage?.type === "chat"
? chat.previewMessage?.body?.substring(0, 50)
: null}
</p>
{/* {chat.unread && (
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{chat.unread}
</span>
)} */}
</div>
</div>
</button>
))}
</div>
</>
);
}
+8 -9
View File
@@ -3,13 +3,12 @@ import { getFormattedName, getId, getInitial } from "@/lib/utils";
import type { ChatDTO } from "@shared/dto";
import { useQuery } from "@tanstack/react-query";
import { cn } from "cn";
import {
ArrowLeftIcon,
CircleHelpIcon,
PaperclipIcon,
SendIcon,
} from "lucide-react";
import { ArrowLeftIcon, PaperclipIcon, SendIcon } from "lucide-react";
import React, { useMemo, useState } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
type Props = {
chat: ChatDTO;
@@ -200,7 +199,7 @@ export default function MessagesSection({
<div
className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`}
>
<span>{item.timestamp}</span>
<span>{dayjs((item.timestamp ?? 0) * 1000).fromNow()}</span>
{/* {item.fromMe &&
(item.read ? (
<CheckCheck size={14} />
@@ -258,9 +257,9 @@ export default function MessagesSection({
<SendIcon size={17} />
</button>
</div>
<div className="mx-auto mt-2 hidden max-w-2xl items-center justify-center gap-1 text-[10px] text-muted-foreground sm:flex">
{/* <div className="mx-auto mt-2 hidden max-w-2xl items-center justify-center gap-1 text-[10px] text-muted-foreground sm:flex">
<CircleHelpIcon size={12} /> Press Enter to send
</div>
</div> */}
</div>
</section>
);
+18 -127
View File
@@ -1,57 +1,19 @@
import { useMemo, useState } from "react";
import {
ChevronDown,
Edit3,
EllipsisVertical,
Search,
Sparkles,
X,
} from "lucide-react";
import { useWAClient } from "@/lib/context";
import { useQuery } from "@tanstack/react-query";
import { cn, getId, getInitial } from "@/lib/utils";
import { useState } from "react";
import ChatsSection from "./chats";
import type { ChatDTO } from "@shared/dto";
import MessagesSection from "./messages";
export default function Page() {
const [selectedId, setSelectedId] = useState<string>();
const [query, setQuery] = useState("");
const [showSearch, setShowSearch] = useState(false);
const [chat, setChat] = useState<ChatDTO | null>(null);
const [mobileChat, setMobileChat] = useState(false);
const { client, isReady } = useWAClient();
const { data: chats } = useQuery({
queryKey: ["chats"],
queryFn: () =>
client
.listChats({ limit: 500 })
.then((res) => res.map((i) => ({ ...i, id: getId(i.id) }))),
enabled: isReady,
});
const filteredChats = useMemo(
() =>
chats?.filter(
(chat) =>
chat.formattedTitle?.toLowerCase().includes(query.toLowerCase()) ||
chat.lastMessage?.body?.toLowerCase().includes(query.toLowerCase()),
),
[chats, query],
);
function openChat(id: string) {
setSelectedId(id);
setMobileChat(true);
}
const selectedChat = chats?.find((chat) => chat.id === selectedId);
return (
<main className="min-h-dvh bg-background p-0 text-foreground sm:p-4 lg:p-6">
<div className="mx-auto flex h-dvh max-w-360 overflow-hidden bg-card shadow-xl sm:h-[calc(100dvh-2rem)] sm:rounded-2xl lg:h-[calc(100dvh-3rem)]">
<aside
className={`flex w-full shrink-0 flex-col border-r border-border bg-card md:w-85 lg:w-95 ${mobileChat ? "hidden md:flex" : "flex"}`}
>
<header className="flex items-center justify-between border-b border-border px-5 py-5">
{/* <header className="flex items-center justify-between border-b border-border px-5 py-5">
<div className="flex items-center gap-3">
<div className="grid size-10 place-items-center rounded-xl bg-accent text-accent-foreground">
<Sparkles aria-hidden="true" />
@@ -77,88 +39,17 @@ export default function Page() {
<EllipsisVertical size={19} />
</button>
</div>
</header>
<div className="px-4 py-3">
{showSearch ? (
<div className="flex items-center gap-2 rounded-xl bg-muted px-3 py-2">
<Search size={17} className="text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search conversations"
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
aria-label="Close search"
onClick={() => {
setShowSearch(false);
setQuery("");
</header> */}
<ChatsSection
selected={chat}
onSelect={(c) => {
setChat(c);
setMobileChat(true);
}}
>
<X size={16} className="text-muted-foreground" />
</button>
</div>
) : (
<button
onClick={() => setShowSearch(true)}
className="flex w-full items-center gap-3 rounded-xl bg-muted px-3 py-2.5 text-left text-sm text-muted-foreground transition hover:bg-border"
>
<Search size={17} /> Search conversations
</button>
)}
</div>
<div className="flex items-center gap-2 px-5 pb-2 text-xs font-medium text-muted-foreground">
<span className="text-primary">All</span>
<span>·</span>
<span>Unread</span>
<span>·</span>
<span>Groups</span>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-3">
{filteredChats?.map((chat) => (
<button
key={chat.id}
onClick={() => openChat(chat.id)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition ${selectedId === chat.id ? "bg-accent" : "hover:bg-muted/70"}`}
>
<div
className={cn(
"relative grid size-11 shrink-0 place-items-center rounded-full text-sm font-semibold bg-rose-200 text-rose-800",
)}
>
{getInitial(chat.formattedTitle)}
{/* {chat.initials}
{chat.online && (
<span className="absolute bottom-0 right-0 size-3 rounded-full border-2 border-card bg-primary" />
)} */}
</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.formattedTitle || chat.name}
</p>
{/* <span className="shrink-0 text-[11px] text-muted-foreground">
{chat.timestamp}
</span> */}
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<p className="truncate text-xs text-muted-foreground">
{chat.previewMessage?.type === "chat"
? chat.previewMessage?.body?.substring(0, 50)
: null}
</p>
{/* {chat.unread && (
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{chat.unread}
</span>
)} */}
</div>
</div>
</button>
))}
</div>
<div className="border-t border-border px-5 py-4">
/>
{/* <div className="border-t border-border px-5 py-4">
<div className="flex items-center gap-3">
<div className="grid size-9 place-items-center rounded-full bg-border text-xs font-semibold text-muted-foreground">
JD
@@ -176,12 +67,12 @@ export default function Page() {
<ChevronDown size={17} />
</button>
</div>
</div>
</div> */}
</aside>
{selectedChat ? (
{chat ? (
<MessagesSection
chat={selectedChat}
chat={chat}
mobileChat={mobileChat}
setMobileChat={setMobileChat}
/>