403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useRef, useCallback, useEffect } from "react"
|
|
import { cn } from "@/lib/utils"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import { conversations as conversationsData } from "@/data/chats"
|
|
import {
|
|
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
|
Smile, Flag, Ban, Trash2, Image, File, X,
|
|
} from "lucide-react"
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
|
DialogFooter, DialogClose,
|
|
} from "@/components/ui/dialog"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { useTheme } from "next-themes"
|
|
import { useUser } from "@/providers/user-provider"
|
|
import { toast } from "sonner"
|
|
import data from "@emoji-mart/data"
|
|
import Picker from "@emoji-mart/react"
|
|
|
|
export default function ChatsPage() {
|
|
const { theme } = useTheme()
|
|
const { user } = useUser()
|
|
const [activeChat, setActiveChat] = useState(conversationsData[0]?.id ?? null)
|
|
const [messageInput, setMessageInput] = useState("")
|
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
|
const [attachments, setAttachments] = useState<File[]>([])
|
|
const [panelWidth, setPanelWidth] = useState(320)
|
|
const [isResizing, setIsResizing] = useState(false)
|
|
const [reportDialogOpen, setReportDialogOpen] = useState(false)
|
|
const [reportReason, setReportReason] = useState("")
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
|
const resizeStartRef = useRef({ x: 0, width: 0 })
|
|
|
|
const [conversations, setConversations] = useState(conversationsData)
|
|
if (!user) return null
|
|
const conversation = conversations.find((c) => c.id === activeChat)
|
|
const otherParticipant = (conv: typeof conversationsData[0]) =>
|
|
conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0]
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
|
|
setShowEmojiPicker(false)
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside)
|
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
}, [])
|
|
|
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
setIsResizing(true)
|
|
resizeStartRef.current = { x: e.clientX, width: panelWidth }
|
|
}, [panelWidth])
|
|
|
|
useEffect(() => {
|
|
if (!isResizing) return
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const delta = e.clientX - resizeStartRef.current.x
|
|
const newWidth = Math.max(240, Math.min(560, resizeStartRef.current.width + delta))
|
|
setPanelWidth(newWidth)
|
|
}
|
|
const handleMouseUp = () => setIsResizing(false)
|
|
document.addEventListener("mousemove", handleMouseMove)
|
|
document.addEventListener("mouseup", handleMouseUp)
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove)
|
|
document.removeEventListener("mouseup", handleMouseUp)
|
|
}
|
|
}, [isResizing])
|
|
|
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
|
setMessageInput((prev) => prev + emoji.native)
|
|
setShowEmojiPicker(false)
|
|
}
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files ?? [])
|
|
setAttachments((prev) => [...prev, ...files])
|
|
if (e.target) e.target.value = ""
|
|
}
|
|
|
|
const removeAttachment = (index: number) => {
|
|
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
|
}
|
|
|
|
const handleSend = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!messageInput.trim() && attachments.length === 0) return
|
|
const newMessage = {
|
|
id: crypto.randomUUID(),
|
|
conversationId: activeChat!,
|
|
senderId: "user1",
|
|
senderName: "Sarah Chen",
|
|
senderAvatar: "SC",
|
|
content: messageInput.trim(),
|
|
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
|
}
|
|
setConversations((prev) =>
|
|
prev.map((conv) =>
|
|
conv.id === activeChat
|
|
? {
|
|
...conv,
|
|
messages: [...conv.messages, newMessage],
|
|
lastMessage: newMessage.content,
|
|
lastMessageTime: "Just now",
|
|
unread: 0,
|
|
}
|
|
: conv
|
|
)
|
|
)
|
|
setMessageInput("")
|
|
setAttachments([])
|
|
}
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes < 1024) return bytes + " B"
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
|
|
}
|
|
|
|
const isImageFile = (file: File) => file.type.startsWith("image/")
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
|
{/* Conversations list - left panel */}
|
|
<div
|
|
className="flex flex-col border-r shrink-0 overflow-hidden"
|
|
style={{ width: panelWidth }}
|
|
>
|
|
<div className="p-4 border-b space-y-3">
|
|
<h2 className="text-lg font-semibold">Chats</h2>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input placeholder="Search conversations..." className="h-9 pl-9" />
|
|
</div>
|
|
</div>
|
|
<ScrollArea className="flex-1">
|
|
{conversations.map((conv) => {
|
|
const person = otherParticipant(conv)
|
|
const isActive = conv.id === activeChat
|
|
return (
|
|
<button
|
|
key={conv.id}
|
|
onClick={() => setActiveChat(conv.id)}
|
|
className={cn(
|
|
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50",
|
|
isActive && "bg-muted"
|
|
)}
|
|
>
|
|
<Avatar className="h-10 w-10 shrink-0">
|
|
<AvatarFallback>{person.avatar}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium truncate">{person.name}</span>
|
|
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p>
|
|
</div>
|
|
{conv.unread > 0 && (
|
|
<span className="shrink-0 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-medium text-primary-foreground">
|
|
{conv.unread}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* Resize handle */}
|
|
<div
|
|
className="w-1.5 cursor-col-resize shrink-0 relative group hover:bg-primary/20 transition-colors"
|
|
onMouseDown={handleResizeStart}
|
|
>
|
|
<div className="absolute inset-y-0 -left-1 -right-1" />
|
|
</div>
|
|
|
|
{/* Chat area - right panel */}
|
|
{conversation ? (
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
{/* Chat header */}
|
|
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<Avatar className="h-9 w-9 shrink-0">
|
|
<AvatarFallback>{otherParticipant(conversation).avatar}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{otherParticipant(conversation).name}</p>
|
|
<p className="text-xs text-muted-foreground truncate">{otherParticipant(conversation).role}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
variant="ghost" size="icon" className="h-8 w-8"
|
|
onClick={() => toast.info("Voice calling coming soon")}
|
|
>
|
|
<Phone className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost" size="icon" className="h-8 w-8"
|
|
onClick={() => toast.info("Video calling coming soon")}
|
|
>
|
|
<Video className="h-4 w-4" />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-44">
|
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => setReportDialogOpen(true)}>
|
|
<Flag className="mr-2 h-4 w-4" /> Report
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => toast.info("Blocked")}>
|
|
<Ban className="mr-2 h-4 w-4" /> Block
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onClick={() => toast.info("Conversation deleted")}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<ScrollArea className="flex-1 p-6">
|
|
<div className="space-y-4">
|
|
{conversation.messages.map((msg) => {
|
|
const isMe = msg.senderId === "user1"
|
|
return (
|
|
<div key={msg.id} className={cn("flex gap-3", isMe && "flex-row-reverse")}>
|
|
<Avatar className="h-8 w-8 mt-0.5 shrink-0">
|
|
{isMe ? <AvatarImage src={user.avatar} /> : null}
|
|
<AvatarFallback className={cn("text-xs", isMe && "bg-primary text-primary-foreground")}>
|
|
{msg.senderAvatar}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className={cn("max-w-[70%]", isMe && "items-end flex flex-col")}>
|
|
<div
|
|
className={cn(
|
|
"rounded-2xl px-4 py-2.5 text-sm",
|
|
isMe ? "bg-primary text-primary-foreground rounded-tr-sm" : "bg-muted rounded-tl-sm"
|
|
)}
|
|
>
|
|
{msg.content}
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground mt-1 px-1">{msg.timestamp}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Input */}
|
|
<div className="p-4 border-t shrink-0 space-y-2">
|
|
{attachments.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{attachments.map((file, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-1.5 text-sm max-w-[200px]"
|
|
>
|
|
{isImageFile(file) ? (
|
|
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="truncate">{file.name}</span>
|
|
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(file.size)}</span>
|
|
<Button
|
|
variant="ghost" size="icon" className="h-5 w-5 -mr-1 shrink-0"
|
|
onClick={() => removeAttachment(i)}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<form onSubmit={handleSend} className="flex items-center gap-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
|
className="hidden"
|
|
onChange={handleFileSelect}
|
|
/>
|
|
<Button
|
|
type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
<div className="relative flex-1">
|
|
<Input
|
|
value={messageInput}
|
|
onChange={(e) => setMessageInput(e.target.value)}
|
|
placeholder="Type a message..."
|
|
className="h-9 w-full pr-9"
|
|
/>
|
|
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
|
<Button
|
|
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0"
|
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
|
>
|
|
<Smile className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
{showEmojiPicker && (
|
|
<div className="absolute bottom-full right-0 mb-2 z-50">
|
|
<Picker
|
|
data={data}
|
|
onEmojiSelect={handleEmojiSelect}
|
|
theme={theme === "dark" ? "dark" : "light"}
|
|
previewPosition="none"
|
|
skinTonePosition="none"
|
|
set="native"
|
|
maxFrequentRows={2}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
|
|
<Send className="h-4 w-4" />
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
<p>Select a conversation to start chatting</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Resize overlay */}
|
|
{isResizing && <div className="fixed inset-0 z-50 cursor-col-resize" />}
|
|
|
|
{/* Report dialog */}
|
|
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Report {conversation ? otherParticipant(conversation).name : "User"}</DialogTitle>
|
|
<DialogDescription>
|
|
Let us know why you're reporting this conversation.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 py-2">
|
|
<Label htmlFor="reason">Reason</Label>
|
|
<Textarea
|
|
id="reason"
|
|
placeholder="Describe the issue..."
|
|
value={reportReason}
|
|
onChange={(e) => setReportReason(e.target.value)}
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant="outline">Cancel</Button>
|
|
</DialogClose>
|
|
<Button
|
|
onClick={() => {
|
|
toast.success("Report submitted")
|
|
setReportReason("")
|
|
setReportDialogOpen(false)
|
|
}}
|
|
disabled={!reportReason.trim()}
|
|
>
|
|
Submit Report
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|