@wakastellar/ui 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,611 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../utils"
5
+ import { Card, CardContent, CardHeader, CardTitle } from "../../components/card"
6
+ import { Button } from "../../components/button"
7
+ import { Input } from "../../components/input"
8
+ import { Avatar, AvatarFallback, AvatarImage } from "../../components/avatar"
9
+ import { Badge } from "../../components/badge"
10
+ import { ScrollArea } from "../../components/scroll-area"
11
+ import { Separator } from "../../components/separator"
12
+ import {
13
+ Send,
14
+ Paperclip,
15
+ Smile,
16
+ MoreVertical,
17
+ Phone,
18
+ Video,
19
+ Search,
20
+ Image,
21
+ File,
22
+ Mic,
23
+ Check,
24
+ CheckCheck,
25
+ Clock,
26
+ Circle,
27
+ Reply,
28
+ Forward,
29
+ Trash2,
30
+ Copy,
31
+ Pin,
32
+ Star,
33
+ } from "lucide-react"
34
+
35
+ // ============================================
36
+ // TYPES
37
+ // ============================================
38
+
39
+ export interface ChatUser {
40
+ id: string
41
+ name: string
42
+ avatar?: string
43
+ status?: "online" | "offline" | "away" | "busy"
44
+ lastSeen?: Date | string
45
+ }
46
+
47
+ export interface ChatMessage {
48
+ id: string
49
+ content: string
50
+ senderId: string
51
+ timestamp: Date | string
52
+ status?: "sending" | "sent" | "delivered" | "read"
53
+ replyTo?: string
54
+ attachments?: ChatAttachment[]
55
+ reactions?: ChatReaction[]
56
+ }
57
+
58
+ export interface ChatAttachment {
59
+ id: string
60
+ type: "image" | "file" | "audio"
61
+ name: string
62
+ url: string
63
+ size?: string
64
+ thumbnail?: string
65
+ }
66
+
67
+ export interface ChatReaction {
68
+ emoji: string
69
+ userId: string
70
+ }
71
+
72
+ export interface ChatConversation {
73
+ id: string
74
+ participants: ChatUser[]
75
+ lastMessage?: ChatMessage
76
+ unreadCount?: number
77
+ isPinned?: boolean
78
+ isGroup?: boolean
79
+ groupName?: string
80
+ groupAvatar?: string
81
+ }
82
+
83
+ export interface ChatInterfaceProps {
84
+ /** Current user */
85
+ currentUser: ChatUser
86
+ /** List of conversations */
87
+ conversations?: ChatConversation[]
88
+ /** Currently selected conversation */
89
+ selectedConversation?: ChatConversation
90
+ /** Messages in selected conversation */
91
+ messages?: ChatMessage[]
92
+ /** Handler for sending message */
93
+ onSendMessage?: (content: string, attachments?: File[]) => void
94
+ /** Handler for selecting conversation */
95
+ onSelectConversation?: (conversation: ChatConversation) => void
96
+ /** Handler for typing indicator */
97
+ onTyping?: () => void
98
+ /** Users currently typing */
99
+ typingUsers?: ChatUser[]
100
+ /** Show sidebar */
101
+ showSidebar?: boolean
102
+ /** Custom className */
103
+ className?: string
104
+ }
105
+
106
+ // ============================================
107
+ // SUBCOMPONENTS
108
+ // ============================================
109
+
110
+ const statusColors = {
111
+ online: "bg-green-500",
112
+ offline: "bg-gray-400",
113
+ away: "bg-yellow-500",
114
+ busy: "bg-red-500",
115
+ }
116
+
117
+ function UserAvatar({ user, size = "md" }: { user: ChatUser; size?: "sm" | "md" | "lg" }) {
118
+ const sizeClasses = {
119
+ sm: "h-8 w-8",
120
+ md: "h-10 w-10",
121
+ lg: "h-12 w-12",
122
+ }
123
+
124
+ return (
125
+ <div className="relative">
126
+ <Avatar className={sizeClasses[size]}>
127
+ <AvatarImage src={user.avatar} alt={user.name} />
128
+ <AvatarFallback>{user.name.slice(0, 2).toUpperCase()}</AvatarFallback>
129
+ </Avatar>
130
+ {user.status && (
131
+ <div
132
+ className={cn(
133
+ "absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
134
+ statusColors[user.status]
135
+ )}
136
+ />
137
+ )}
138
+ </div>
139
+ )
140
+ }
141
+
142
+ function ConversationItem({
143
+ conversation,
144
+ currentUserId,
145
+ isSelected,
146
+ onClick,
147
+ }: {
148
+ conversation: ChatConversation
149
+ currentUserId: string
150
+ isSelected: boolean
151
+ onClick: () => void
152
+ }) {
153
+ const otherParticipant = conversation.participants.find((p) => p.id !== currentUserId)
154
+ const displayName = conversation.isGroup ? conversation.groupName : otherParticipant?.name
155
+ const displayAvatar = conversation.isGroup ? conversation.groupAvatar : otherParticipant?.avatar
156
+
157
+ return (
158
+ <button
159
+ onClick={onClick}
160
+ className={cn(
161
+ "w-full flex items-center gap-3 p-3 rounded-lg transition-colors text-left",
162
+ isSelected ? "bg-primary/10" : "hover:bg-muted/50"
163
+ )}
164
+ >
165
+ <div className="relative">
166
+ <Avatar>
167
+ <AvatarImage src={displayAvatar} />
168
+ <AvatarFallback>{displayName?.slice(0, 2).toUpperCase()}</AvatarFallback>
169
+ </Avatar>
170
+ {!conversation.isGroup && otherParticipant?.status && (
171
+ <div
172
+ className={cn(
173
+ "absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
174
+ statusColors[otherParticipant.status]
175
+ )}
176
+ />
177
+ )}
178
+ </div>
179
+ <div className="flex-1 min-w-0">
180
+ <div className="flex items-center justify-between">
181
+ <span className="font-medium truncate">{displayName}</span>
182
+ {conversation.lastMessage && (
183
+ <span className="text-xs text-muted-foreground">
184
+ {typeof conversation.lastMessage.timestamp === "string"
185
+ ? conversation.lastMessage.timestamp
186
+ : formatTime(conversation.lastMessage.timestamp)}
187
+ </span>
188
+ )}
189
+ </div>
190
+ {conversation.lastMessage && (
191
+ <p className="text-sm text-muted-foreground truncate">
192
+ {conversation.lastMessage.content}
193
+ </p>
194
+ )}
195
+ </div>
196
+ {conversation.unreadCount && conversation.unreadCount > 0 && (
197
+ <Badge className="h-5 w-5 p-0 flex items-center justify-center rounded-full">
198
+ {conversation.unreadCount}
199
+ </Badge>
200
+ )}
201
+ {conversation.isPinned && (
202
+ <Pin className="h-3 w-3 text-muted-foreground" />
203
+ )}
204
+ </button>
205
+ )
206
+ }
207
+
208
+ function MessageBubble({
209
+ message,
210
+ isOwn,
211
+ sender,
212
+ showAvatar,
213
+ }: {
214
+ message: ChatMessage
215
+ isOwn: boolean
216
+ sender?: ChatUser
217
+ showAvatar: boolean
218
+ }) {
219
+ const statusIcons = {
220
+ sending: <Clock className="h-3 w-3" />,
221
+ sent: <Check className="h-3 w-3" />,
222
+ delivered: <CheckCheck className="h-3 w-3" />,
223
+ read: <CheckCheck className="h-3 w-3 text-blue-500" />,
224
+ }
225
+
226
+ return (
227
+ <div className={cn("flex gap-2 max-w-[80%]", isOwn ? "ml-auto flex-row-reverse" : "")}>
228
+ {showAvatar && sender && !isOwn && (
229
+ <UserAvatar user={sender} size="sm" />
230
+ )}
231
+ {!showAvatar && !isOwn && <div className="w-8" />}
232
+
233
+ <div className="space-y-1">
234
+ {!isOwn && showAvatar && sender && (
235
+ <span className="text-xs text-muted-foreground ml-1">{sender.name}</span>
236
+ )}
237
+
238
+ <div
239
+ className={cn(
240
+ "rounded-2xl px-4 py-2",
241
+ isOwn
242
+ ? "bg-primary text-primary-foreground rounded-br-md"
243
+ : "bg-muted rounded-bl-md"
244
+ )}
245
+ >
246
+ {message.replyTo && (
247
+ <div className={cn(
248
+ "text-xs mb-1 pb-1 border-b",
249
+ isOwn ? "border-primary-foreground/20" : "border-border"
250
+ )}>
251
+ <Reply className="h-3 w-3 inline mr-1" />
252
+ Reply to message
253
+ </div>
254
+ )}
255
+
256
+ <p className="text-sm whitespace-pre-wrap">{message.content}</p>
257
+
258
+ {message.attachments && message.attachments.length > 0 && (
259
+ <div className="mt-2 space-y-2">
260
+ {message.attachments.map((attachment) => (
261
+ <div key={attachment.id} className="flex items-center gap-2">
262
+ {attachment.type === "image" ? (
263
+ <Image className="h-4 w-4" />
264
+ ) : (
265
+ <File className="h-4 w-4" />
266
+ )}
267
+ <span className="text-xs truncate">{attachment.name}</span>
268
+ </div>
269
+ ))}
270
+ </div>
271
+ )}
272
+ </div>
273
+
274
+ <div className={cn("flex items-center gap-1 text-xs text-muted-foreground", isOwn ? "justify-end" : "")}>
275
+ <span>
276
+ {typeof message.timestamp === "string"
277
+ ? message.timestamp
278
+ : formatTime(message.timestamp)}
279
+ </span>
280
+ {isOwn && message.status && statusIcons[message.status]}
281
+ </div>
282
+
283
+ {message.reactions && message.reactions.length > 0 && (
284
+ <div className="flex gap-1 mt-1">
285
+ {Array.from(new Set(message.reactions.map((r) => r.emoji))).map((emoji) => (
286
+ <span key={emoji} className="text-xs bg-muted rounded-full px-2 py-0.5">
287
+ {emoji} {message.reactions!.filter((r) => r.emoji === emoji).length}
288
+ </span>
289
+ ))}
290
+ </div>
291
+ )}
292
+ </div>
293
+ </div>
294
+ )
295
+ }
296
+
297
+ function TypingIndicator({ users }: { users: ChatUser[] }) {
298
+ if (users.length === 0) return null
299
+
300
+ const names = users.map((u) => u.name).join(", ")
301
+
302
+ return (
303
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
304
+ <div className="flex gap-1">
305
+ <Circle className="h-2 w-2 animate-bounce" style={{ animationDelay: "0ms" }} />
306
+ <Circle className="h-2 w-2 animate-bounce" style={{ animationDelay: "150ms" }} />
307
+ <Circle className="h-2 w-2 animate-bounce" style={{ animationDelay: "300ms" }} />
308
+ </div>
309
+ <span>{names} {users.length === 1 ? "is" : "are"} typing...</span>
310
+ </div>
311
+ )
312
+ }
313
+
314
+ function formatTime(date: Date): string {
315
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
316
+ }
317
+
318
+ // ============================================
319
+ // MAIN COMPONENT
320
+ // ============================================
321
+
322
+ export function ChatInterface({
323
+ currentUser,
324
+ conversations = [],
325
+ selectedConversation,
326
+ messages = [],
327
+ onSendMessage,
328
+ onSelectConversation,
329
+ onTyping,
330
+ typingUsers = [],
331
+ showSidebar = true,
332
+ className,
333
+ }: ChatInterfaceProps) {
334
+ const [inputValue, setInputValue] = React.useState("")
335
+ const [searchQuery, setSearchQuery] = React.useState("")
336
+ const messagesEndRef = React.useRef<HTMLDivElement>(null)
337
+
338
+ const scrollToBottom = () => {
339
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
340
+ }
341
+
342
+ React.useEffect(() => {
343
+ scrollToBottom()
344
+ }, [messages])
345
+
346
+ const handleSend = () => {
347
+ if (!inputValue.trim() || !onSendMessage) return
348
+ onSendMessage(inputValue.trim())
349
+ setInputValue("")
350
+ }
351
+
352
+ const handleKeyDown = (e: React.KeyboardEvent) => {
353
+ if (e.key === "Enter" && !e.shiftKey) {
354
+ e.preventDefault()
355
+ handleSend()
356
+ }
357
+ }
358
+
359
+ const filteredConversations = conversations.filter((conv) => {
360
+ if (!searchQuery) return true
361
+ const otherParticipant = conv.participants.find((p) => p.id !== currentUser.id)
362
+ const name = conv.isGroup ? conv.groupName : otherParticipant?.name
363
+ return name?.toLowerCase().includes(searchQuery.toLowerCase())
364
+ })
365
+
366
+ const otherParticipant = selectedConversation?.participants.find((p) => p.id !== currentUser.id)
367
+ const chatTitle = selectedConversation?.isGroup
368
+ ? selectedConversation.groupName
369
+ : otherParticipant?.name
370
+
371
+ // Group messages by sender for avatar display
372
+ const groupedMessages = messages.reduce((acc, message, index) => {
373
+ const prevMessage = messages[index - 1]
374
+ const showAvatar = !prevMessage || prevMessage.senderId !== message.senderId
375
+ acc.push({ message, showAvatar })
376
+ return acc
377
+ }, [] as { message: ChatMessage; showAvatar: boolean }[])
378
+
379
+ return (
380
+ <div className={cn("flex h-[600px] rounded-lg border overflow-hidden", className)}>
381
+ {/* Sidebar */}
382
+ {showSidebar && (
383
+ <div className="w-80 border-r flex flex-col bg-card">
384
+ {/* Sidebar Header */}
385
+ <div className="p-4 border-b">
386
+ <div className="flex items-center justify-between mb-4">
387
+ <h2 className="font-semibold text-lg">Messages</h2>
388
+ <Button variant="ghost" size="icon">
389
+ <MoreVertical className="h-4 w-4" />
390
+ </Button>
391
+ </div>
392
+ <div className="relative">
393
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
394
+ <Input
395
+ placeholder="Search conversations..."
396
+ value={searchQuery}
397
+ onChange={(e) => setSearchQuery(e.target.value)}
398
+ className="pl-9"
399
+ />
400
+ </div>
401
+ </div>
402
+
403
+ {/* Conversations List */}
404
+ <ScrollArea className="flex-1">
405
+ <div className="p-2 space-y-1">
406
+ {filteredConversations.map((conversation) => (
407
+ <ConversationItem
408
+ key={conversation.id}
409
+ conversation={conversation}
410
+ currentUserId={currentUser.id}
411
+ isSelected={selectedConversation?.id === conversation.id}
412
+ onClick={() => onSelectConversation?.(conversation)}
413
+ />
414
+ ))}
415
+ </div>
416
+ </ScrollArea>
417
+ </div>
418
+ )}
419
+
420
+ {/* Chat Area */}
421
+ <div className="flex-1 flex flex-col">
422
+ {selectedConversation ? (
423
+ <>
424
+ {/* Chat Header */}
425
+ <div className="h-16 border-b flex items-center justify-between px-4 bg-card">
426
+ <div className="flex items-center gap-3">
427
+ {otherParticipant && <UserAvatar user={otherParticipant} />}
428
+ <div>
429
+ <h3 className="font-medium">{chatTitle}</h3>
430
+ {otherParticipant?.status && (
431
+ <p className="text-xs text-muted-foreground capitalize">
432
+ {otherParticipant.status === "online"
433
+ ? "Online"
434
+ : `Last seen ${otherParticipant.lastSeen || "recently"}`}
435
+ </p>
436
+ )}
437
+ </div>
438
+ </div>
439
+ <div className="flex items-center gap-1">
440
+ <Button variant="ghost" size="icon">
441
+ <Phone className="h-4 w-4" />
442
+ </Button>
443
+ <Button variant="ghost" size="icon">
444
+ <Video className="h-4 w-4" />
445
+ </Button>
446
+ <Button variant="ghost" size="icon">
447
+ <MoreVertical className="h-4 w-4" />
448
+ </Button>
449
+ </div>
450
+ </div>
451
+
452
+ {/* Messages Area */}
453
+ <ScrollArea className="flex-1 p-4">
454
+ <div className="space-y-4">
455
+ {groupedMessages.map(({ message, showAvatar }) => (
456
+ <MessageBubble
457
+ key={message.id}
458
+ message={message}
459
+ isOwn={message.senderId === currentUser.id}
460
+ sender={selectedConversation.participants.find(
461
+ (p) => p.id === message.senderId
462
+ )}
463
+ showAvatar={showAvatar}
464
+ />
465
+ ))}
466
+ <div ref={messagesEndRef} />
467
+ </div>
468
+ {typingUsers.length > 0 && (
469
+ <div className="mt-4">
470
+ <TypingIndicator users={typingUsers} />
471
+ </div>
472
+ )}
473
+ </ScrollArea>
474
+
475
+ {/* Input Area */}
476
+ <div className="p-4 border-t bg-card">
477
+ <div className="flex items-end gap-2">
478
+ <Button variant="ghost" size="icon">
479
+ <Paperclip className="h-4 w-4" />
480
+ </Button>
481
+ <div className="flex-1">
482
+ <Input
483
+ placeholder="Type a message..."
484
+ value={inputValue}
485
+ onChange={(e) => {
486
+ setInputValue(e.target.value)
487
+ onTyping?.()
488
+ }}
489
+ onKeyDown={handleKeyDown}
490
+ className="rounded-full"
491
+ />
492
+ </div>
493
+ <Button variant="ghost" size="icon">
494
+ <Smile className="h-4 w-4" />
495
+ </Button>
496
+ <Button size="icon" onClick={handleSend} disabled={!inputValue.trim()}>
497
+ <Send className="h-4 w-4" />
498
+ </Button>
499
+ </div>
500
+ </div>
501
+ </>
502
+ ) : (
503
+ /* No Conversation Selected */
504
+ <div className="flex-1 flex items-center justify-center">
505
+ <div className="text-center">
506
+ <div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-4">
507
+ <Send className="h-8 w-8 text-muted-foreground" />
508
+ </div>
509
+ <h3 className="font-medium text-lg">Your Messages</h3>
510
+ <p className="text-muted-foreground text-sm mt-1">
511
+ Select a conversation to start chatting
512
+ </p>
513
+ </div>
514
+ </div>
515
+ )}
516
+ </div>
517
+ </div>
518
+ )
519
+ }
520
+
521
+ // ============================================
522
+ // PRESET DATA
523
+ // ============================================
524
+
525
+ export const defaultUsers: ChatUser[] = [
526
+ { id: "1", name: "John Doe", avatar: "", status: "online" },
527
+ { id: "2", name: "Jane Smith", avatar: "", status: "away", lastSeen: "2 hours ago" },
528
+ { id: "3", name: "Bob Wilson", avatar: "", status: "offline", lastSeen: "yesterday" },
529
+ { id: "4", name: "Alice Brown", avatar: "", status: "busy" },
530
+ ]
531
+
532
+ export const defaultConversations: ChatConversation[] = [
533
+ {
534
+ id: "conv1",
535
+ participants: [defaultUsers[0], defaultUsers[1]],
536
+ lastMessage: {
537
+ id: "m1",
538
+ content: "Hey, how's the project going?",
539
+ senderId: "2",
540
+ timestamp: new Date(),
541
+ status: "read",
542
+ },
543
+ unreadCount: 2,
544
+ isPinned: true,
545
+ },
546
+ {
547
+ id: "conv2",
548
+ participants: [defaultUsers[0], defaultUsers[2]],
549
+ lastMessage: {
550
+ id: "m2",
551
+ content: "See you tomorrow!",
552
+ senderId: "1",
553
+ timestamp: new Date(Date.now() - 3600000),
554
+ status: "delivered",
555
+ },
556
+ },
557
+ {
558
+ id: "conv3",
559
+ isGroup: true,
560
+ groupName: "Project Team",
561
+ participants: [defaultUsers[0], defaultUsers[1], defaultUsers[2], defaultUsers[3]],
562
+ lastMessage: {
563
+ id: "m3",
564
+ content: "Meeting at 3pm",
565
+ senderId: "4",
566
+ timestamp: new Date(Date.now() - 7200000),
567
+ status: "read",
568
+ },
569
+ unreadCount: 5,
570
+ },
571
+ ]
572
+
573
+ export const defaultMessages: ChatMessage[] = [
574
+ {
575
+ id: "msg1",
576
+ content: "Hey! How are you doing?",
577
+ senderId: "2",
578
+ timestamp: new Date(Date.now() - 3600000),
579
+ status: "read",
580
+ },
581
+ {
582
+ id: "msg2",
583
+ content: "I'm doing great, thanks for asking! Just finished the new feature.",
584
+ senderId: "1",
585
+ timestamp: new Date(Date.now() - 3500000),
586
+ status: "read",
587
+ },
588
+ {
589
+ id: "msg3",
590
+ content: "That's awesome! Can you show me a demo?",
591
+ senderId: "2",
592
+ timestamp: new Date(Date.now() - 3400000),
593
+ status: "read",
594
+ },
595
+ {
596
+ id: "msg4",
597
+ content: "Sure! Let me schedule a call for tomorrow. Does 2pm work for you?",
598
+ senderId: "1",
599
+ timestamp: new Date(Date.now() - 3300000),
600
+ status: "delivered",
601
+ },
602
+ {
603
+ id: "msg5",
604
+ content: "Perfect, 2pm works great!",
605
+ senderId: "2",
606
+ timestamp: new Date(Date.now() - 3200000),
607
+ status: "read",
608
+ },
609
+ ]
610
+
611
+ export default ChatInterface