This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
'use client';
import { FormEvent, useEffect, useRef } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import type { Attachment, Conversation } from '../utils/types';
import { ChatHeader } from './chat-header';
import { MessageBubble } from './message-bubble';
import { MessageComposer } from './message-composer';
interface ChatAreaProps {
conversation: Conversation;
draft: string;
onDraftChange: (text: string) => void;
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
attachments: Attachment[];
onAddAttachments: (files: FileList) => void;
onRemoveAttachment: (id: string) => void;
}
export function ChatArea({
conversation,
draft,
onDraftChange,
onSubmit,
attachments,
onAddAttachments,
onRemoveAttachment
}: ChatAreaProps) {
const shouldReduceMotion = useReducedMotion();
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const liveRegionRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!messagesContainerRef.current) return;
const container = messagesContainerRef.current;
const behavior = shouldReduceMotion ? 'auto' : 'smooth';
const scrollToBottom = () => {
container.scrollTo({ top: container.scrollHeight, behavior });
};
if (behavior === 'smooth') {
requestAnimationFrame(scrollToBottom);
} else {
scrollToBottom();
}
}, [conversation.messages, conversation.id, shouldReduceMotion]);
useEffect(() => {
if (!liveRegionRef.current) return;
const lastMessage = conversation.messages[conversation.messages.length - 1];
if (!lastMessage) return;
liveRegionRef.current.textContent =
lastMessage.author + ' at ' + lastMessage.timestamp + ': ' + lastMessage.text;
}, [conversation.messages]);
return (
<>
<AnimatePresence initial={false} mode='wait'>
<motion.div
key={conversation.id}
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 12 }}
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
transition={{ duration: 0.32, ease: 'easeOut' }}
className='border-border/40 bg-background/80 flex min-h-0 flex-col gap-3 overflow-hidden rounded-2xl border p-3 backdrop-blur sm:gap-4 sm:p-4 lg:col-start-2 lg:col-end-3 lg:rounded-3xl'
>
<ChatHeader conversation={conversation} />
<div
ref={messagesContainerRef}
className='[&::-webkit-scrollbar-thumb]:bg-muted relative min-h-0 flex-1 space-y-3 overflow-y-auto pr-2 sm:space-y-4 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full'
aria-live='off'
aria-label={'Message thread with ' + conversation.name}
>
<AnimatePresence initial={false}>
{conversation.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</AnimatePresence>
</div>
<MessageComposer
draft={draft}
onDraftChange={onDraftChange}
onSubmit={onSubmit}
contactName={conversation.name}
quickReplies={conversation.quickReplies}
attachments={attachments}
onAddAttachments={onAddAttachments}
onRemoveAttachment={onRemoveAttachment}
/>
</motion.div>
</AnimatePresence>
<div ref={liveRegionRef} className='sr-only' aria-live='polite' aria-atomic='true' />
</>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { Icons } from '@/components/icons';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Conversation } from '../utils/types';
const statusDotColor = {
online: 'bg-green-500',
offline: 'bg-red-500'
} as const;
interface ChatHeaderProps {
conversation: Conversation;
}
export function ChatHeader({ conversation }: ChatHeaderProps) {
return (
<header className='flex flex-wrap items-center justify-between gap-3 sm:gap-4'>
<div className='flex items-center gap-2 sm:gap-3'>
<div className='relative'>
<Avatar className='border-border/40 bg-card/80 text-foreground h-10 w-10 rounded-2xl border sm:h-12 sm:w-12 sm:rounded-3xl'>
<AvatarFallback className='bg-primary/20 text-primary rounded-2xl text-sm font-semibold sm:rounded-3xl sm:text-base'>
{conversation.initials}
</AvatarFallback>
</Avatar>
<span
className={cn(
'border-background absolute right-0 bottom-0 inline-flex h-3 w-3 rounded-full border-2 sm:h-3.5 sm:w-3.5',
statusDotColor[conversation.status]
)}
aria-label={conversation.status === 'online' ? 'Online' : 'Offline'}
/>
</div>
<div>
<p className='text-foreground text-sm font-semibold sm:text-base'>{conversation.name}</p>
<p className='text-muted-foreground text-xs sm:text-sm'>{conversation.title}</p>
</div>
</div>
<div className='flex items-center gap-1.5 sm:gap-2'>
<Button
type='button'
variant='ghost'
size='icon'
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
aria-label='Start audio call'
>
<Icons.phone className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
aria-label='Start video call'
>
<Icons.video className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
aria-label='Open conversation menu'
>
<Icons.ellipsis className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
</header>
);
}

View File

@@ -0,0 +1,11 @@
'use client';
import { Messenger } from './messenger';
export default function ChatViewPage() {
return (
<div className='flex min-h-0 flex-1 px-4 py-2 md:px-6'>
<Messenger />
</div>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { useMemo, useState } from 'react';
import { Icons } from '@/components/icons';
import { motion } from 'motion/react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import type { Conversation } from '../utils/types';
const statusDotColor = {
online: 'bg-green-500',
offline: 'bg-red-500'
} as const;
interface ConversationListProps {
conversations: Conversation[];
selectedId: string;
onSelect: (id: string) => void;
}
export function ConversationList({ conversations, selectedId, onSelect }: ConversationListProps) {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search.trim()) return conversations;
const q = search.toLowerCase();
return conversations.filter(
(c) => c.name.toLowerCase().includes(q) || c.title.toLowerCase().includes(q)
);
}, [conversations, search]);
return (
<div className='border-border/40 bg-background/75 hidden h-full flex-col gap-4 overflow-hidden rounded-2xl border p-3 backdrop-blur lg:col-start-1 lg:col-end-2 lg:flex lg:rounded-3xl lg:p-4'>
<div className='flex items-center justify-between gap-3'>
<div>
<p className='text-foreground text-sm font-semibold'>Messenger</p>
<p className='text-muted-foreground text-xs'>
{conversations.length} active conversation
{conversations.length === 1 ? '' : 's'}
</p>
</div>
<Badge
variant='outline'
className='bg-primary/15 text-primary hover:bg-primary/15 hover:text-primary border-border/50 rounded-full border px-3 py-1 text-[0.7rem] tracking-[0.24em] uppercase'
>
Live
</Badge>
</div>
<label htmlFor='messenger-search' className='sr-only'>
Search conversations
</label>
<div className='relative'>
<Icons.search
className='text-muted-foreground/70 pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2'
aria-hidden='true'
/>
<Input
id='messenger-search'
type='search'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder='Search conversations'
className='border-border/40 bg-background/60 text-foreground placeholder:text-muted-foreground/70 focus-visible:ring-primary/40 w-full rounded-2xl pl-10 text-sm focus-visible:ring-2'
/>
</div>
<div
className='flex-1 space-y-2 overflow-y-auto pr-1'
aria-label='Conversation list'
role='list'
>
{filtered.length === 0 ? (
<p className='text-muted-foreground py-8 text-center text-xs'>No conversations found</p>
) : null}
{filtered.map((conversation) => {
const isActive = conversation.id === selectedId;
const lastMessage = conversation.messages[conversation.messages.length - 1];
return (
<motion.button
key={conversation.id}
type='button'
onClick={() => onSelect(conversation.id)}
aria-current={isActive ? 'true' : undefined}
className={cn(
'focus-visible:ring-primary/50 group focus-visible:ring-offset-background relative flex w-full items-start gap-3 rounded-2xl border border-transparent p-3 text-left transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
isActive
? 'border-primary/40 bg-primary/10'
: 'bg-background/70 hover:border-border/40 hover:bg-muted/40'
)}
role='listitem'
>
<div className='relative shrink-0'>
<Avatar className='border-border/40 bg-background/80 text-foreground h-10 w-10 rounded-2xl border'>
<AvatarFallback className='bg-primary/15 text-primary rounded-2xl text-sm font-medium'>
{conversation.initials}
</AvatarFallback>
</Avatar>
<span
className={cn(
'border-background absolute right-0 bottom-0 inline-flex h-3 w-3 rounded-full border-2',
statusDotColor[conversation.status]
)}
aria-label={conversation.status === 'online' ? 'Online' : 'Offline'}
/>
</div>
<div className='min-w-0 flex-1 space-y-1'>
<div className='flex items-start justify-between gap-2'>
<div className='min-w-0 flex-1'>
<p className='text-foreground text-sm font-semibold'>{conversation.name}</p>
<p className='text-muted-foreground text-xs'>{conversation.title}</p>
</div>
{lastMessage && (
<span className='text-muted-foreground shrink-0 text-[0.65rem]'>
{lastMessage.timestamp}
</span>
)}
</div>
{lastMessage ? (
<p className='text-muted-foreground line-clamp-2 text-xs'>
{lastMessage.author}: {lastMessage.text}
</p>
) : (
<p className='text-muted-foreground text-xs'>No messages yet</p>
)}
</div>
{conversation.unread > 0 && (
<span className='bg-primary text-primary-foreground ml-2 inline-flex min-h-[1.5rem] min-w-[1.5rem] items-center justify-center rounded-full text-[0.7rem] font-semibold shadow-lg'>
{conversation.unread}
</span>
)}
</motion.button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { Badge } from '@/components/ui/badge';
import type { Conversation } from '../utils/types';
interface ConversationSelectProps {
conversations: Conversation[];
selectedId: string;
onSelect: (id: string) => void;
}
export function ConversationSelect({
conversations,
selectedId,
onSelect
}: ConversationSelectProps) {
return (
<div className='border-border/40 bg-background/75 flex flex-col gap-3 rounded-2xl border p-3 backdrop-blur sm:gap-4 sm:rounded-3xl sm:p-4 lg:hidden'>
<div className='flex items-center justify-between gap-2 sm:gap-3'>
<div>
<p className='text-foreground text-xs font-semibold sm:text-sm'>Messenger</p>
<p className='text-muted-foreground text-[0.65rem] sm:text-xs'>
{conversations.length} active conversation
{conversations.length === 1 ? '' : 's'}
</p>
</div>
<Badge
variant='outline'
className='bg-primary/15 text-primary hover:bg-primary/15 hover:text-primary border-border/50 rounded-full border px-2 py-0.5 text-[0.65rem] tracking-[0.2em] uppercase sm:px-3 sm:py-1 sm:text-[0.7rem] sm:tracking-[0.24em]'
>
Live
</Badge>
</div>
<div className='space-y-1.5 sm:space-y-2'>
<label
htmlFor='messenger-conversation'
className='text-muted-foreground text-[0.65rem] font-medium sm:text-xs'
>
Conversation
</label>
<select
id='messenger-conversation'
value={selectedId}
onChange={(e) => onSelect(e.target.value)}
className='border-border/40 bg-background/70 text-foreground focus:border-primary/40 focus:ring-primary/30 w-full rounded-xl border px-2.5 py-1.5 text-xs focus:ring-2 focus:outline-none sm:rounded-2xl sm:px-3 sm:py-2 sm:text-sm'
>
{conversations.map((conversation) => (
<option key={conversation.id} value={conversation.id}>
{conversation.name}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { Icons } from '@/components/icons';
import { motion, useReducedMotion } from 'motion/react';
import { cn } from '@/lib/utils';
import { FilePreview } from '@/components/ui/file-preview';
import type { Message } from '../utils/types';
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const shouldReduceMotion = useReducedMotion();
const isUser = message.sender === 'user';
return (
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 12, scale: 0.98 }}
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 0.28, ease: 'easeOut' }}
className='flex flex-col gap-1'
role='group'
aria-label={message.author + ' at ' + message.timestamp}
>
<div
className={cn(
'relative max-w-[85%] rounded-xl border px-3 py-2 text-xs leading-relaxed sm:max-w-[82%] sm:rounded-2xl sm:px-4 sm:py-3 sm:text-sm',
isUser
? 'border-primary/40 bg-primary text-primary-foreground ml-auto'
: 'bg-muted border-transparent'
)}
>
<p
className={cn(
'font-medium sm:text-sm',
isUser ? 'text-primary-foreground/80' : 'text-foreground/80'
)}
>
{message.author}
</p>
{message.text && (
<p
className={cn(
'mt-1 text-[0.875rem] sm:text-[0.95rem]',
isUser ? 'text-primary-foreground/90' : 'text-foreground/90'
)}
>
{message.text}
</p>
)}
{message.attachments && message.attachments.length > 0 && (
<FilePreview
files={message.attachments.map((a) => ({
id: a.id,
name: a.name,
type: a.type
}))}
variant={isUser ? 'inverted' : 'default'}
className='mt-1 p-0'
/>
)}
<div className='mt-2 flex items-center justify-end gap-1.5 text-[0.65rem] sm:mt-3 sm:gap-2 sm:text-[0.7rem]'>
<span className={cn('text-muted-foreground', isUser && 'text-primary-foreground/80')}>
{message.timestamp}
</span>
{isUser && (
<Icons.checks
className='text-primary-foreground/80 h-3 w-3 sm:h-3.5 sm:w-3.5'
aria-hidden='true'
/>
)}
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { FormEvent, useRef } from 'react';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { FilePreview } from '@/components/ui/file-preview';
import type { Attachment } from '../utils/types';
interface MessageComposerProps {
draft: string;
onDraftChange: (text: string) => void;
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
contactName: string;
quickReplies: string[];
attachments: Attachment[];
onAddAttachments: (files: FileList) => void;
onRemoveAttachment: (id: string) => void;
}
export function MessageComposer({
draft,
onDraftChange,
onSubmit,
contactName,
quickReplies,
attachments,
onAddAttachments,
onRemoveAttachment
}: MessageComposerProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<form onSubmit={onSubmit} className='space-y-2 sm:space-y-3' aria-label='Reply composer'>
<label htmlFor='messenger-editor' className='sr-only'>
Write a message
</label>
<div className='border-border/40 bg-background/80 flex items-end gap-2 rounded-2xl border p-3 backdrop-blur sm:gap-3 sm:rounded-3xl sm:p-4'>
<div className='min-w-0 flex-1'>
{attachments.length > 0 && (
<FilePreview
files={attachments.map((a) => ({
id: a.id,
name: a.name,
type: a.type
}))}
onRemove={onRemoveAttachment}
className='mb-1 p-0'
/>
)}
<Textarea
id='messenger-editor'
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (draft.trim() || attachments.length > 0) {
const form = e.currentTarget.closest('form');
form?.requestSubmit();
}
}
}}
placeholder={'Message ' + contactName + ' (Enter to send, Shift+Enter for newline)'}
rows={2}
className='text-foreground placeholder:text-muted-foreground/70 min-h-[3rem] w-full resize-none border-none bg-transparent text-xs focus-visible:ring-0 focus-visible:outline-none sm:min-h-[4rem] sm:text-sm'
aria-label={'Message ' + contactName}
/>
<div className='mt-2 flex flex-wrap gap-1.5 sm:mt-3 sm:gap-2'>
{quickReplies.map((reply) => (
<button
key={reply}
type='button'
onClick={() => onDraftChange(reply)}
className='border-border/50 bg-background/70 text-muted-foreground hover:border-primary/40 hover:text-foreground focus-visible:ring-primary/40 focus-visible:ring-offset-background rounded-full border px-2.5 py-0.5 text-[0.65rem] transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:px-3 sm:py-1 sm:text-xs'
>
{reply}
</button>
))}
</div>
</div>
<div className='flex shrink-0 flex-col items-end gap-1.5 sm:w-24 sm:gap-2'>
<input
ref={fileInputRef}
type='file'
multiple
className='hidden'
onChange={(e) => {
if (e.target.files?.length) {
onAddAttachments(e.target.files);
}
// Reset so same file can be re-selected
e.target.value = '';
}}
/>
<Button
type='button'
variant='ghost'
size='icon'
className='border-border/40 bg-background/70 text-muted-foreground hover:bg-muted/50 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
aria-label='Attach a file'
onClick={() => fileInputRef.current?.click()}
>
<Icons.paperclip className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<Button
type='submit'
size='icon'
className='bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full shadow-lg transition focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 sm:size-10'
disabled={!draft.trim() && attachments.length === 0}
aria-label='Send message'
>
<Icons.send className='h-3.5 w-3.5 sm:h-4 sm:w-4' aria-hidden='true' />
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useReducedMotion } from 'motion/react';
import { useChatStore } from '../utils/store';
import type { Attachment, Message } from '../utils/types';
import { ConversationList } from './conversation-list';
import { ConversationSelect } from './conversation-select';
import { ChatArea } from './chat-area';
export function Messenger() {
const {
conversations,
selectedConversationId,
draft,
replyCursor,
selectConversation,
setDraft,
sendMessage,
addIncomingMessage,
advanceReplyCursor,
getActiveConversation
} = useChatStore();
const [attachments, setAttachments] = useState<Attachment[]>([]);
const shouldReduceMotion = useReducedMotion();
const replyTimeoutRef = useRef<number | null>(null);
const selectedRef = useRef(selectedConversationId);
useEffect(() => {
selectedRef.current = selectedConversationId;
setAttachments([]);
}, [selectedConversationId]);
useEffect(() => {
return () => {
if (replyTimeoutRef.current) {
window.clearTimeout(replyTimeoutRef.current);
}
};
}, []);
const handleAddAttachments = useCallback((files: FileList) => {
const newAttachments: Attachment[] = Array.from(files).map((file) => ({
id: 'file-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
name: file.name,
size: file.size,
type: file.type
}));
setAttachments((prev) => [...prev, ...newAttachments]);
}, []);
const handleRemoveAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const active = getActiveConversation();
if ((!draft.trim() && attachments.length === 0) || !active) return;
const conversationId = active.id;
sendMessage(draft, attachments.length > 0 ? attachments : undefined);
setAttachments([]);
const autoReplies = active.autoReplies;
if (!autoReplies.length) return;
const cursor = replyCursor[conversationId] ?? 0;
const nextReply = autoReplies[cursor % autoReplies.length];
const delay = shouldReduceMotion ? 0 : 900;
replyTimeoutRef.current = window.setTimeout(() => {
const timestamp = new Date().toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
const incoming: Message = {
id: 'incoming-' + Date.now().toString(),
sender: 'contact',
author: active.name,
text: nextReply,
timestamp
};
addIncomingMessage(conversationId, incoming);
advanceReplyCursor(conversationId);
}, delay);
},
[
draft,
attachments,
replyCursor,
shouldReduceMotion,
getActiveConversation,
sendMessage,
addIncomingMessage,
advanceReplyCursor
]
);
const activeConversation = getActiveConversation();
if (!activeConversation) return null;
return (
<div className='border-border/50 bg-background/70 relative grid h-[calc(100dvh-5.5rem)] w-full grid-rows-[auto,1fr] gap-3 overflow-hidden rounded-2xl border p-3 backdrop-blur-xl sm:gap-4 sm:p-4 lg:[grid-template-columns:30%_1fr] lg:grid-rows-[1fr] lg:gap-4 lg:rounded-3xl lg:p-5'>
<ConversationSelect
conversations={conversations}
selectedId={selectedConversationId}
onSelect={selectConversation}
/>
<ConversationList
conversations={conversations}
selectedId={selectedConversationId}
onSelect={selectConversation}
/>
<ChatArea
conversation={activeConversation}
draft={draft}
onDraftChange={setDraft}
onSubmit={handleSubmit}
attachments={attachments}
onAddAttachments={handleAddAttachments}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
);
}