veza/apps/web/desy/components/ui/chat.tsx
2026-01-22 17:23:11 +01:00

276 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 }