feat: update ui
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ui</title>
|
<title>WhatsApp Bridge UI</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@tanstack/react-query": "^5.102.8",
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.3.0",
|
"cn": "^0.3.0",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
"lucide-react": "^1.46.0",
|
"lucide-react": "^1.46.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
Generated
+8
@@ -26,6 +26,9 @@ importers:
|
|||||||
cn:
|
cn:
|
||||||
specifier: ^0.3.0
|
specifier: ^0.3.0
|
||||||
version: 0.3.0
|
version: 0.3.0
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.23
|
||||||
|
version: 1.11.23
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.46.0
|
specifier: ^1.46.0
|
||||||
version: 1.46.0([email protected])
|
version: 1.46.0([email protected])
|
||||||
@@ -833,6 +836,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
|
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2674,6 +2680,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-fn: 3.1.0
|
mimic-fn: 3.1.0
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,13 +3,12 @@ import { getFormattedName, getId, getInitial } from "@/lib/utils";
|
|||||||
import type { ChatDTO } from "@shared/dto";
|
import type { ChatDTO } from "@shared/dto";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { cn } from "cn";
|
import { cn } from "cn";
|
||||||
import {
|
import { ArrowLeftIcon, PaperclipIcon, SendIcon } from "lucide-react";
|
||||||
ArrowLeftIcon,
|
|
||||||
CircleHelpIcon,
|
|
||||||
PaperclipIcon,
|
|
||||||
SendIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
chat: ChatDTO;
|
chat: ChatDTO;
|
||||||
@@ -200,7 +199,7 @@ export default function MessagesSection({
|
|||||||
<div
|
<div
|
||||||
className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`}
|
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.fromMe &&
|
||||||
(item.read ? (
|
(item.read ? (
|
||||||
<CheckCheck size={14} />
|
<CheckCheck size={14} />
|
||||||
@@ -258,9 +257,9 @@ export default function MessagesSection({
|
|||||||
<SendIcon size={17} />
|
<SendIcon size={17} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<CircleHelpIcon size={12} /> Press Enter to send
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
+19
-128
@@ -1,57 +1,19 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import ChatsSection from "./chats";
|
||||||
ChevronDown,
|
import type { ChatDTO } from "@shared/dto";
|
||||||
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 MessagesSection from "./messages";
|
import MessagesSection from "./messages";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [selectedId, setSelectedId] = useState<string>();
|
const [chat, setChat] = useState<ChatDTO | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
|
||||||
const [mobileChat, setMobileChat] = useState(false);
|
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 (
|
return (
|
||||||
<main className="min-h-dvh bg-background p-0 text-foreground sm:p-4 lg:p-6">
|
<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)]">
|
<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
|
<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"}`}
|
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="flex items-center gap-3">
|
||||||
<div className="grid size-10 place-items-center rounded-xl bg-accent text-accent-foreground">
|
<div className="grid size-10 place-items-center rounded-xl bg-accent text-accent-foreground">
|
||||||
<Sparkles aria-hidden="true" />
|
<Sparkles aria-hidden="true" />
|
||||||
@@ -77,88 +39,17 @@ export default function Page() {
|
|||||||
<EllipsisVertical size={19} />
|
<EllipsisVertical size={19} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header> */}
|
||||||
<div className="px-4 py-3">
|
|
||||||
{showSearch ? (
|
<ChatsSection
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-muted px-3 py-2">
|
selected={chat}
|
||||||
<Search size={17} className="text-muted-foreground" />
|
onSelect={(c) => {
|
||||||
<input
|
setChat(c);
|
||||||
autoFocus
|
setMobileChat(true);
|
||||||
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"
|
{/* <div className="border-t border-border px-5 py-4">
|
||||||
/>
|
|
||||||
<button
|
|
||||||
aria-label="Close search"
|
|
||||||
onClick={() => {
|
|
||||||
setShowSearch(false);
|
|
||||||
setQuery("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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="flex items-center gap-3">
|
<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">
|
<div className="grid size-9 place-items-center rounded-full bg-border text-xs font-semibold text-muted-foreground">
|
||||||
JD
|
JD
|
||||||
@@ -176,12 +67,12 @@ export default function Page() {
|
|||||||
<ChevronDown size={17} />
|
<ChevronDown size={17} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{selectedChat ? (
|
{chat ? (
|
||||||
<MessagesSection
|
<MessagesSection
|
||||||
chat={selectedChat}
|
chat={chat}
|
||||||
mobileChat={mobileChat}
|
mobileChat={mobileChat}
|
||||||
setMobileChat={setMobileChat}
|
setMobileChat={setMobileChat}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user