277 lines
7.7 KiB
TypeScript
277 lines
7.7 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import * as React from 'react'
|
||
|
|
import { cn } from '@/lib/utils'
|
||
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
|
||
|
|
// Icons
|
||
|
|
const SendIcon = () => (
|
||
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||
|
|
</svg>
|
||
|
|
)
|
||
|
|
|
||
|
|
const EmojiIcon = () => (
|
||
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
|
|
<circle cx="12" cy="12" r="10" />
|
||
|
|
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||
|
|
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||
|
|
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||
|
|
</svg>
|
||
|
|
)
|
||
|
|
|
||
|
|
const AttachIcon = () => (
|
||
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
|
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||
|
|
</svg>
|
||
|
|
)
|
||
|
|
|
||
|
|
// Typing indicator dots
|
||
|
|
function TypingIndicator({ className }: { className?: string }) {
|
||
|
|
return (
|
||
|
|
<div className={cn('flex items-center gap-1 px-3 py-2 bg-muted rounded-2xl rounded-bl-sm w-fit', className)}>
|
||
|
|
{[0, 1, 2].map((i) => (
|
||
|
|
<span
|
||
|
|
key={i}
|
||
|
|
className="size-1.5 bg-muted-foreground rounded-full animate-[typing_1.4s_ease-in-out_infinite]"
|
||
|
|
style={{ animationDelay: `${i * 0.2}s` }}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Single message bubble
|
||
|
|
interface MessageProps {
|
||
|
|
content: string
|
||
|
|
sender: {
|
||
|
|
name: string
|
||
|
|
avatar?: string
|
||
|
|
initials: string
|
||
|
|
}
|
||
|
|
timestamp?: string
|
||
|
|
isOwn?: boolean
|
||
|
|
status?: 'sent' | 'delivered' | 'read'
|
||
|
|
reactions?: { emoji: string; count: number }[]
|
||
|
|
className?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
function Message({
|
||
|
|
content,
|
||
|
|
sender,
|
||
|
|
timestamp,
|
||
|
|
isOwn = false,
|
||
|
|
status,
|
||
|
|
reactions,
|
||
|
|
className,
|
||
|
|
}: MessageProps) {
|
||
|
|
return (
|
||
|
|
<div className={cn(
|
||
|
|
'flex gap-2.5 max-w-[80%]',
|
||
|
|
isOwn && 'flex-row-reverse self-end',
|
||
|
|
className
|
||
|
|
)}>
|
||
|
|
{!isOwn && (
|
||
|
|
<Avatar size="sm" className="flex-shrink-0">
|
||
|
|
{sender.avatar ? (
|
||
|
|
<AvatarImage src={sender.avatar || "/placeholder.svg"} alt={sender.name} />
|
||
|
|
) : null}
|
||
|
|
<AvatarFallback>{sender.initials}</AvatarFallback>
|
||
|
|
</Avatar>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className={cn('flex flex-col gap-1', isOwn && 'items-end')}>
|
||
|
|
{!isOwn && (
|
||
|
|
<span className="text-xs font-medium text-muted-foreground">
|
||
|
|
{sender.name}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className={cn(
|
||
|
|
'px-3.5 py-2 rounded-2xl text-sm leading-relaxed',
|
||
|
|
isOwn
|
||
|
|
? 'bg-primary text-primary-foreground rounded-br-sm'
|
||
|
|
: 'bg-muted text-foreground rounded-bl-sm'
|
||
|
|
)}>
|
||
|
|
{content}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{reactions && reactions.length > 0 && (
|
||
|
|
<div className="flex items-center gap-1 -mt-1">
|
||
|
|
{reactions.map((reaction, i) => (
|
||
|
|
<span
|
||
|
|
key={i}
|
||
|
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs bg-muted rounded-full"
|
||
|
|
>
|
||
|
|
{reaction.emoji} {reaction.count > 1 && reaction.count}
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{(timestamp || status) && (
|
||
|
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||
|
|
{timestamp && <span>{timestamp}</span>}
|
||
|
|
{isOwn && status && (
|
||
|
|
<span className={cn(
|
||
|
|
status === 'read' && 'text-primary'
|
||
|
|
)}>
|
||
|
|
{status === 'sent' && '✓'}
|
||
|
|
{status === 'delivered' && '✓✓'}
|
||
|
|
{status === 'read' && '✓✓'}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Chat container
|
||
|
|
interface ChatProps {
|
||
|
|
title?: string
|
||
|
|
subtitle?: string
|
||
|
|
messages?: MessageProps[]
|
||
|
|
className?: string
|
||
|
|
onSend?: (message: string) => void
|
||
|
|
showTyping?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
function Chat({
|
||
|
|
title = 'Chat',
|
||
|
|
subtitle,
|
||
|
|
messages = [],
|
||
|
|
className,
|
||
|
|
onSend,
|
||
|
|
showTyping = false,
|
||
|
|
}: ChatProps) {
|
||
|
|
const [inputValue, setInputValue] = React.useState('')
|
||
|
|
const messagesEndRef = React.useRef<HTMLDivElement>(null)
|
||
|
|
|
||
|
|
const handleSend = () => {
|
||
|
|
if (inputValue.trim() && onSend) {
|
||
|
|
onSend(inputValue.trim())
|
||
|
|
setInputValue('')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||
|
|
e.preventDefault()
|
||
|
|
handleSend()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn(
|
||
|
|
'flex flex-col h-[450px] bg-card border border-border rounded-xl overflow-hidden',
|
||
|
|
className
|
||
|
|
)}>
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/30">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<div className="relative">
|
||
|
|
<div className="size-2 bg-success rounded-full absolute -top-0.5 -right-0.5 shadow-[0_0_8px_oklch(0.72_0.19_145_/_0.6)]" />
|
||
|
|
<span className="text-lg">💬</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||
|
|
{subtitle && (
|
||
|
|
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Button size="icon-sm" variant="ghost">
|
||
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<circle cx="12" cy="12" r="1.5" />
|
||
|
|
<circle cx="6" cy="12" r="1.5" />
|
||
|
|
<circle cx="18" cy="12" r="1.5" />
|
||
|
|
</svg>
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Messages area */}
|
||
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||
|
|
{messages.map((message, i) => (
|
||
|
|
<Message key={i} {...message} />
|
||
|
|
))}
|
||
|
|
|
||
|
|
{showTyping && <TypingIndicator />}
|
||
|
|
|
||
|
|
<div ref={messagesEndRef} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Input area */}
|
||
|
|
<div className="p-3 border-t border-border bg-muted/30">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button size="icon-sm" variant="ghost" className="flex-shrink-0">
|
||
|
|
<AttachIcon />
|
||
|
|
</Button>
|
||
|
|
<Button size="icon-sm" variant="ghost" className="flex-shrink-0">
|
||
|
|
<EmojiIcon />
|
||
|
|
</Button>
|
||
|
|
<Input
|
||
|
|
placeholder="Type a message..."
|
||
|
|
value={inputValue}
|
||
|
|
onChange={(e) => setInputValue(e.target.value)}
|
||
|
|
onKeyDown={handleKeyDown}
|
||
|
|
className="flex-1 h-9 bg-background"
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
size="icon-sm"
|
||
|
|
className="flex-shrink-0"
|
||
|
|
disabled={!inputValue.trim()}
|
||
|
|
onClick={handleSend}
|
||
|
|
>
|
||
|
|
<SendIcon />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Presence indicator
|
||
|
|
interface PresenceProps {
|
||
|
|
status: 'online' | 'away' | 'busy' | 'offline'
|
||
|
|
label?: boolean
|
||
|
|
className?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
function Presence({ status, label = false, className }: PresenceProps) {
|
||
|
|
const statusConfig = {
|
||
|
|
online: {
|
||
|
|
color: 'bg-success shadow-[0_0_8px_oklch(0.72_0.19_145_/_0.6)]',
|
||
|
|
text: 'Online',
|
||
|
|
},
|
||
|
|
away: {
|
||
|
|
color: 'bg-warning',
|
||
|
|
text: 'Away',
|
||
|
|
},
|
||
|
|
busy: {
|
||
|
|
color: 'bg-destructive',
|
||
|
|
text: 'Do not disturb',
|
||
|
|
},
|
||
|
|
offline: {
|
||
|
|
color: 'bg-muted-foreground',
|
||
|
|
text: 'Offline',
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
const config = statusConfig[status]
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn('flex items-center gap-1.5', className)}>
|
||
|
|
<span className={cn('size-2 rounded-full', config.color)} />
|
||
|
|
{label && (
|
||
|
|
<span className="text-xs text-muted-foreground">{config.text}</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export { Chat, Message, TypingIndicator, Presence }
|