feat(chat-ui): add feature components with xtablo theming

This commit is contained in:
Arthur Belleville 2026-04-12 14:10:04 +02:00
parent 26db9b1adf
commit 95cbca1b27
No known key found for this signature in database

View file

@ -0,0 +1,508 @@
import * as React from "react"
import { cn } from "@xtablo/shared"
import {
X,
Search,
Pin,
ChevronDown,
ChevronRight,
ArrowUp,
ArrowDown,
Trash2,
Check,
} from "lucide-react"
import type { ChatMessageData } from "../types"
import { formatTimestamp } from "../hooks"
// ─── ChatForwardDialog ────────────────────────────────────────────────────────
interface Conversation {
id: string
title: string
avatar?: string
}
interface ChatForwardDialogProps {
message: ChatMessageData
conversations: Conversation[]
onForward: (targetIds: string[]) => void
onCancel: () => void
className?: string
}
function ChatForwardDialog({
message,
conversations,
onForward,
onCancel,
className,
}: ChatForwardDialogProps) {
const [query, setQuery] = React.useState("")
const [selected, setSelected] = React.useState<Set<string>>(new Set())
const filtered = conversations.filter((c) =>
c.title.toLowerCase().includes(query.toLowerCase())
)
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className={cn("fixed inset-0 z-50 flex items-center justify-center bg-black/50", className)}>
<div className="w-full max-w-sm overflow-hidden rounded-xl border border-border bg-card shadow-lg">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-[14px] font-semibold text-foreground">Forward message</span>
<button onClick={onCancel} className="text-muted-foreground/60 hover:text-foreground">
<X className="size-4" />
</button>
</div>
{/* Preview */}
<div className="border-b border-border px-4 py-2">
<span className="text-[12px] font-semibold text-muted-foreground">{message.senderName}</span>
<p className="truncate text-[13px] text-muted-foreground/60">{message.text}</p>
</div>
{/* Search */}
<div className="border-b border-border px-4 py-2">
<div className="flex items-center gap-2 rounded-lg bg-background px-3 py-1.5">
<Search className="size-3.5 text-muted-foreground/60" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search conversations..."
className="flex-1 bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 outline-none"
/>
</div>
</div>
{/* Conversation list */}
<div className="max-h-60 overflow-y-auto">
{filtered.map((c) => (
<button
key={c.id}
onClick={() => toggle(c.id)}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors",
selected.has(c.id) ? "bg-accent" : "hover:bg-accent"
)}
>
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground">
{c.title.charAt(0).toUpperCase()}
</div>
<span className="flex-1 text-[14px] text-foreground">{c.title}</span>
{selected.has(c.id) && <Check className="size-4 text-primary" />}
</button>
))}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
<button onClick={onCancel} className="rounded-lg px-3 py-1.5 text-[13px] font-medium text-muted-foreground hover:bg-accent">
Cancel
</button>
<button
onClick={() => onForward(Array.from(selected))}
disabled={selected.size === 0}
className="rounded-lg bg-primary px-3 py-1.5 text-[13px] font-medium text-white disabled:opacity-40"
>
Forward{selected.size > 0 ? ` (${selected.size})` : ""}
</button>
</div>
</div>
</div>
)
}
// ─── ChatEditComposer (inline edit mode) ──────────────────────────────────────
interface ChatEditComposerProps {
message: ChatMessageData
onSave: (messageId: string, newText: string) => void
onCancel: () => void
className?: string
}
function ChatEditComposer({
message,
onSave,
onCancel,
className,
}: ChatEditComposerProps) {
const [value, setValue] = React.useState(message.text || "")
return (
<div className={cn("border-t border-border", className)}>
{/* Edit bar */}
<div className="flex items-center gap-2 bg-amber-500/10 px-4 py-1.5">
<span className="text-[12px] font-semibold text-amber-500">Editing message</span>
<button onClick={onCancel} className="ml-auto text-[12px] text-muted-foreground hover:text-foreground">
Cancel
</button>
</div>
<div className="flex items-end gap-2 bg-card px-3 py-2">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSave(message.id, value.trim()) }
if (e.key === "Escape") onCancel()
}}
rows={1}
autoFocus
className="flex-1 resize-none rounded-[20px] border border-border bg-card px-4 py-2 text-[15px] text-foreground outline-none"
/>
<button
onClick={() => onSave(message.id, value.trim())}
disabled={!value.trim()}
className="flex size-8 items-center justify-center rounded-full bg-primary text-white disabled:opacity-40"
>
<Check className="size-4" />
</button>
</div>
</div>
)
}
// ─── ChatDeletedMessage (placeholder) ─────────────────────────────────────────
interface ChatDeletedMessageProps {
deletedBy?: string
className?: string
}
function ChatDeletedMessage({ deletedBy, className }: ChatDeletedMessageProps) {
return (
<div className={cn("my-2 flex justify-center", className)}>
<span className="rounded-lg bg-card px-3 py-1.5 text-[13px] italic text-muted-foreground/60">
<Trash2 className="mr-1.5 inline size-3" />
{deletedBy ? `${deletedBy} deleted this message` : "This message was deleted"}
</span>
</div>
)
}
// ─── ChatPinnedPanel ──────────────────────────────────────────────────────────
interface ChatPinnedPanelProps {
pinnedMessages: ChatMessageData[]
onUnpin: (messageId: string) => void
onJumpTo: (messageId: string) => void
onClose: () => void
className?: string
}
function ChatPinnedPanel({
pinnedMessages,
onUnpin,
onJumpTo,
onClose,
className,
}: ChatPinnedPanelProps) {
return (
<div className={cn("flex h-full w-80 flex-col border-l border-border bg-card", className)}>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<Pin className="size-4 text-orange-500" />
<span className="text-[14px] font-semibold text-foreground">
Pinned Messages ({pinnedMessages.length})
</span>
</div>
<button onClick={onClose} className="text-muted-foreground/60 hover:text-foreground">
<X className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{pinnedMessages.map((msg) => (
<div
key={msg.id}
className="border-b border-border px-4 py-3 transition-colors hover:bg-accent"
>
<div className="flex items-center justify-between">
<span className="text-[12px] font-semibold text-muted-foreground">{msg.senderName}</span>
<span className="text-[10px] text-muted-foreground/60">
{formatTimestamp(new Date(msg.timestamp))}
</span>
</div>
<p className="mt-0.5 line-clamp-2 text-[13px] text-foreground">{msg.text}</p>
<div className="mt-1.5 flex items-center gap-2">
<button onClick={() => onJumpTo(msg.id)} className="text-[11px] text-primary hover:underline">
Jump to message
</button>
<button onClick={() => onUnpin(msg.id)} className="text-[11px] text-muted-foreground/60 hover:text-destructive">
Unpin
</button>
</div>
</div>
))}
{pinnedMessages.length === 0 && (
<div className="flex h-32 items-center justify-center text-[13px] text-muted-foreground/60">
No pinned messages
</div>
)}
</div>
</div>
)
}
// ─── ChatNestedThread ─────────────────────────────────────────────────────────
interface ThreadedMessage extends ChatMessageData {
parentId: string | null
children: ThreadedMessage[]
depth: number
votes?: number
userVote?: "up" | "down" | null
isCollapsed?: boolean
}
interface ChatNestedThreadProps {
messages: ThreadedMessage[]
maxDepth?: number
onReply?: (parentId: string) => void
onVote?: (messageId: string, direction: "up" | "down") => void
showVotes?: boolean
className?: string
}
function ChatNestedThread({
messages,
maxDepth = 5,
onReply,
onVote,
showVotes = false,
className,
}: ChatNestedThreadProps) {
return (
<div className={cn("space-y-1", className)}>
{messages.map((msg) => (
<ThreadMessage
key={msg.id}
message={msg}
maxDepth={maxDepth}
onReply={onReply}
onVote={onVote}
showVotes={showVotes}
/>
))}
</div>
)
}
function ThreadMessage({
message,
maxDepth,
onReply,
onVote,
showVotes,
}: {
message: ThreadedMessage
maxDepth: number
onReply?: (parentId: string) => void
onVote?: (messageId: string, direction: "up" | "down") => void
showVotes: boolean
}) {
const [collapsed, setCollapsed] = React.useState(message.isCollapsed ?? false)
const atMaxDepth = message.depth >= maxDepth
return (
<div style={{ paddingLeft: Math.min(message.depth, maxDepth) * 24 }}>
<div className="group flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-accent">
{/* Thread connector line */}
{message.depth > 0 && (
<div className="mt-1 h-full w-0.5 shrink-0 bg-border" />
)}
{/* Vote buttons */}
{showVotes && (
<div className="flex shrink-0 flex-col items-center gap-0.5">
<button
onClick={() => onVote?.(message.id, "up")}
className={cn("size-5 rounded text-muted-foreground/60 hover:text-primary", message.userVote === "up" && "text-primary")}
>
<ArrowUp className="size-3.5 mx-auto" />
</button>
<span className="text-[11px] font-semibold tabular-nums text-muted-foreground">
{message.votes ?? 0}
</span>
<button
onClick={() => onVote?.(message.id, "down")}
className={cn("size-5 rounded text-muted-foreground/60 hover:text-destructive", message.userVote === "down" && "text-destructive")}
>
<ArrowDown className="size-3.5 mx-auto" />
</button>
</div>
)}
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-foreground">{message.senderName}</span>
<span className="text-[11px] text-muted-foreground/60">
{formatTimestamp(new Date(message.timestamp))}
</span>
</div>
<p className="text-[14px] leading-relaxed text-foreground">{message.text}</p>
<div className="mt-1 flex items-center gap-3">
{!atMaxDepth && (
<button onClick={() => onReply?.(message.id)} className="text-[11px] font-medium text-muted-foreground hover:text-primary">
Reply
</button>
)}
{message.children.length > 0 && (
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-0.5 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
{collapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
{message.children.length} {message.children.length === 1 ? "reply" : "replies"}
</button>
)}
</div>
</div>
</div>
{/* Children */}
{!collapsed && message.children.length > 0 && (
<div className="border-l-2 border-border ml-3">
{message.children.map((child) => (
<ThreadMessage
key={child.id}
message={child}
maxDepth={maxDepth}
onReply={onReply}
onVote={onVote}
showVotes={showVotes}
/>
))}
</div>
)}
{atMaxDepth && message.children.length > 0 && (
<button className="ml-6 mt-1 text-[11px] text-primary hover:underline">
Continue thread
</button>
)}
</div>
)
}
// ─── ChatSearch ───────────────────────────────────────────────────────────────
interface SearchResult {
messageId: string
conversationId?: string
conversationName?: string
senderName: string
snippet: string
timestamp: Date | number
}
interface ChatSearchProps {
onSearch: (query: string) => SearchResult[] | Promise<SearchResult[]>
onSelect: (result: SearchResult) => void
onClose: () => void
className?: string
}
function ChatSearch({ onSearch, onSelect, onClose, className }: ChatSearchProps) {
const [query, setQuery] = React.useState("")
const [results, setResults] = React.useState<SearchResult[]>([])
const inputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
inputRef.current?.focus()
}, [])
React.useEffect(() => {
if (!query.trim()) { setResults([]); return }
const timeout = setTimeout(async () => {
const r = await onSearch(query)
setResults(r)
}, 200)
return () => clearTimeout(timeout)
}, [query, onSearch])
React.useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }
document.addEventListener("keydown", handler)
return () => document.removeEventListener("keydown", handler)
}, [onClose])
return (
<div className={cn("fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/50", className)}>
<div className="w-full max-w-lg overflow-hidden rounded-xl border border-border bg-card shadow-lg">
{/* Search input */}
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Search className="size-4 text-muted-foreground/60" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..."
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground/60 outline-none"
/>
<kbd className="rounded border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground/60">ESC</kbd>
</div>
{/* Results */}
<div className="max-h-80 overflow-y-auto">
{results.map((r) => (
<button
key={r.messageId}
onClick={() => { onSelect(r); onClose() }}
className="flex w-full flex-col gap-0.5 border-b border-border px-4 py-2.5 text-left transition-colors hover:bg-accent"
>
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-foreground">{r.senderName}</span>
{r.conversationName && (
<span className="text-[11px] text-muted-foreground/60">in {r.conversationName}</span>
)}
<span className="ml-auto text-[11px] text-muted-foreground/60">
{formatTimestamp(new Date(r.timestamp))}
</span>
</div>
<p className="truncate text-[13px] text-muted-foreground">{r.snippet}</p>
</button>
))}
{query.trim() && results.length === 0 && (
<div className="flex h-20 items-center justify-center text-[13px] text-muted-foreground/60">
No results found
</div>
)}
</div>
</div>
</div>
)
}
// ─── Exports ──────────────────────────────────────────────────────────────────
export {
ChatForwardDialog,
ChatEditComposer,
ChatDeletedMessage,
ChatPinnedPanel,
ChatNestedThread,
ChatSearch,
}
export type {
Conversation,
ChatForwardDialogProps,
ChatEditComposerProps,
ChatDeletedMessageProps,
ChatPinnedPanelProps,
ThreadedMessage,
ChatNestedThreadProps,
SearchResult,
ChatSearchProps,
}