first commit
This commit is contained in:
200
src/pages/MessagesPage.tsx
Normal file
200
src/pages/MessagesPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { MessageSquare, Search, SendHorizonal } from "lucide-react"
|
||||
import { useLayoutEffect, useRef, useState } from "react"
|
||||
|
||||
import { Page } from "@/components/shared/Page"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { CONVERSATIONS } from "@/data/mockData"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Conversation } from "@/data/types"
|
||||
|
||||
/* ===========================================================================
|
||||
Messages — a real two-pane inbox. Sending is local state only.
|
||||
=========================================================================== */
|
||||
|
||||
export function MessagesPage() {
|
||||
const [threads, setThreads] = useState<Conversation[]>(() =>
|
||||
CONVERSATIONS.map((c) => ({ ...c, messages: [...c.messages] }))
|
||||
)
|
||||
const [activeId, setActiveId] = useState(threads[0].id)
|
||||
const [query, setQuery] = useState("")
|
||||
const [draft, setDraft] = useState("")
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const active = threads.find((t) => t.id === activeId) ?? threads[0]
|
||||
const visible = threads.filter((t) =>
|
||||
`${t.name} ${t.role}`.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: "end" })
|
||||
}, [active.messages.length, activeId])
|
||||
|
||||
/** Opening a thread marks it read. */
|
||||
function openThread(id: string) {
|
||||
setActiveId(id)
|
||||
setThreads((prev) =>
|
||||
prev.map((t) => (t.id === id && t.unread > 0 ? { ...t, unread: 0 } : t))
|
||||
)
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = draft.trim()
|
||||
if (!text) return
|
||||
setThreads((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === activeId
|
||||
? {
|
||||
...t,
|
||||
lastAt: "Now",
|
||||
messages: [
|
||||
...t.messages,
|
||||
{ id: `local-${t.messages.length}`, from: "me" as const, text, at: "Now" },
|
||||
],
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
setDraft("")
|
||||
}
|
||||
|
||||
return (
|
||||
<Page className="h-full">
|
||||
<div className="grid h-[calc(100dvh-8rem)] min-h-[420px] grid-cols-1 overflow-hidden rounded-[14px] border border-line bg-surface shadow-xs md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
{/* ------------------------------------------------- conversations */}
|
||||
<aside className="flex min-h-0 flex-col border-b border-line md:border-b-0 md:border-r">
|
||||
<div className="shrink-0 border-b border-line-soft p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-ink-3" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search conversations…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scroll-slim min-h-0 flex-1 overflow-y-auto">
|
||||
{visible.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => openThread(t.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 border-b border-line-soft px-3 py-2.5 text-left transition-colors",
|
||||
t.id === activeId ? "bg-primary-soft" : "hover:bg-surface-soft"
|
||||
)}
|
||||
>
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-surface-sunk text-[11px] font-bold text-ink-2">
|
||||
{t.initials}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-ink">
|
||||
{t.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-ink-3">{t.lastAt}</span>
|
||||
</div>
|
||||
<div className="truncate text-[11.5px] text-ink-3">{t.role}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">
|
||||
{t.messages[t.messages.length - 1].text}
|
||||
</span>
|
||||
{t.unread > 0 && (
|
||||
<span className="grid size-[17px] shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-white">
|
||||
{t.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{visible.length === 0 && (
|
||||
<p className="p-6 text-center text-[12.5px] text-ink-3">
|
||||
No conversations match that search.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ------------------------------------------------- active thread */}
|
||||
<section className="flex min-h-0 flex-col">
|
||||
<header className="flex shrink-0 items-center gap-2.5 border-b border-line px-4 py-3">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-primary-soft text-[11px] font-bold text-primary">
|
||||
{active.initials}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[14px] font-semibold text-ink">{active.name}</div>
|
||||
<div className="truncate text-[11.5px] text-ink-3">{active.role}</div>
|
||||
</div>
|
||||
<Badge tone="good" size="sm">
|
||||
<MessageSquare className="size-2.5" />
|
||||
Active
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<div className="scroll-slim min-h-0 flex-1 space-y-2.5 overflow-y-auto p-4">
|
||||
{active.messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn("flex", m.from === "me" ? "justify-end" : "justify-start")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[76%] rounded-[14px] px-3 py-2 text-[13px] leading-snug",
|
||||
m.from === "me"
|
||||
? "rounded-br-[5px] bg-primary text-white"
|
||||
: "rounded-bl-[5px] border border-line bg-surface-soft text-ink-2"
|
||||
)}
|
||||
>
|
||||
<p>{m.text}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-[10.5px]",
|
||||
m.from === "me" ? "text-white/70" : "text-ink-3"
|
||||
)}
|
||||
>
|
||||
{m.at}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-line p-3">
|
||||
<div className="flex items-end gap-2 rounded-[14px] border border-line bg-surface-soft px-3 py-2 transition-colors focus-within:border-primary focus-within:bg-surface">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}}
|
||||
placeholder={`Message ${active.name.split(" ")[0]}…`}
|
||||
aria-label="Message"
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] text-ink placeholder:text-ink-3 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={!draft.trim()}
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
"grid size-7 shrink-0 place-items-center rounded-[9px] transition-colors",
|
||||
draft.trim()
|
||||
? "bg-primary text-white hover:bg-[#1a45c0]"
|
||||
: "bg-surface-sunk text-ink-3"
|
||||
)}
|
||||
>
|
||||
<SendHorizonal className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user