Skip to content

Commit adc16cb

Browse files
Merge pull request #62 from Queenode/main
Implement Real-time Messaging System #10
2 parents e7343ba + 3bbddb7 commit adc16cb

File tree

9 files changed

+2187
-991
lines changed

9 files changed

+2187
-991
lines changed

package-lock.json

Lines changed: 345 additions & 830 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 207 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,207 @@
1-
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { formatDistanceToNow } from 'date-fns';
5+
import {
6+
FiSearch,
7+
FiMessageCircle,
8+
} from 'react-icons/fi';
9+
import type { Conversation } from '@/app/store/messagingStore';
10+
11+
interface ConversationListProps {
12+
conversations: Conversation[];
13+
currentConversationId: string | null;
14+
isLoading: boolean;
15+
searchQuery: string;
16+
onSelectConversation: (id: string) => void;
17+
onSearchChange: (query: string) => void;
18+
}
19+
20+
function UnreadIndicator({ count }: { count: number }) {
21+
if (count === 0) return null;
22+
return (
23+
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-bold text-white bg-gradient-to-r from-violet-500 to-purple-600 rounded-full shadow-sm shadow-purple-500/30 animate-pulse">
24+
{count > 99 ? '99+' : count}
25+
</span>
26+
);
27+
}
28+
29+
function getInitials(name: string) {
30+
return name
31+
.split(' ')
32+
.map((n) => n[0])
33+
.join('')
34+
.toUpperCase()
35+
.slice(0, 2);
36+
}
37+
38+
function getAvatarColor(name: string) {
39+
const colors = [
40+
'from-violet-500 to-purple-600',
41+
'from-blue-500 to-cyan-500',
42+
'from-emerald-500 to-teal-500',
43+
'from-amber-500 to-orange-500',
44+
'from-rose-500 to-pink-500',
45+
'from-indigo-500 to-blue-600',
46+
];
47+
const index = name.charCodeAt(0) % colors.length;
48+
return colors[index];
49+
}
50+
51+
export default function ConversationList({
52+
conversations,
53+
currentConversationId,
54+
isLoading,
55+
searchQuery,
56+
onSelectConversation,
57+
onSearchChange,
58+
}: ConversationListProps) {
59+
const [isFocused, setIsFocused] = useState(false);
60+
61+
return (
62+
<div className="flex flex-col h-full">
63+
{/* Search Bar */}
64+
<div className="p-3">
65+
<div
66+
className={`relative flex items-center rounded-xl transition-all duration-300 ${isFocused
67+
? 'bg-white dark:bg-gray-700 shadow-md shadow-purple-500/10 ring-2 ring-purple-500/30'
68+
: 'bg-gray-100 dark:bg-gray-800'
69+
}`}
70+
>
71+
<FiSearch className="absolute left-3 w-4 h-4 text-gray-400" />
72+
<input
73+
type="text"
74+
placeholder="Search conversations..."
75+
value={searchQuery}
76+
onChange={(e) => onSearchChange(e.target.value)}
77+
onFocus={() => setIsFocused(true)}
78+
onBlur={() => setIsFocused(false)}
79+
className="w-full py-2.5 pl-10 pr-4 bg-transparent text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none"
80+
id="conversation-search"
81+
/>
82+
</div>
83+
</div>
84+
85+
{/* Conversations List */}
86+
<div className="flex-1 overflow-y-auto">
87+
{isLoading ? (
88+
<div className="space-y-2 p-3">
89+
{[...Array(5)].map((_, i) => (
90+
<div
91+
key={i}
92+
className="flex items-center gap-3 p-3 rounded-xl animate-pulse"
93+
>
94+
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
95+
<div className="flex-1 space-y-2">
96+
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
97+
<div className="h-3 w-36 bg-gray-200 dark:bg-gray-700 rounded" />
98+
</div>
99+
</div>
100+
))}
101+
</div>
102+
) : conversations.length === 0 ? (
103+
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
104+
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-violet-100 to-purple-100 dark:from-violet-900/30 dark:to-purple-900/30 flex items-center justify-center mb-4">
105+
<FiMessageCircle className="w-7 h-7 text-violet-500" />
106+
</div>
107+
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
108+
{searchQuery ? 'No conversations found' : 'No conversations yet'}
109+
</p>
110+
<p className="text-xs text-gray-400">
111+
{searchQuery
112+
? 'Try a different search term'
113+
: 'Start a new conversation to begin messaging'}
114+
</p>
115+
</div>
116+
) : (
117+
<div className="space-y-0.5 px-2">
118+
{conversations.map((conversation) => {
119+
const otherParticipant = conversation.participants.find(
120+
(p) => p.id !== 'current-user'
121+
);
122+
const isActive = currentConversationId === conversation.id;
123+
const hasUnread = conversation.unreadCount > 0;
124+
125+
if (!otherParticipant) return null;
126+
127+
return (
128+
<button
129+
key={conversation.id}
130+
onClick={() => onSelectConversation(conversation.id)}
131+
className={`w-full flex items-center gap-3 p-3 rounded-xl transition-all duration-200 text-left group ${isActive
132+
? 'bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-950/40 dark:to-purple-950/40 shadow-sm ring-1 ring-violet-200/50 dark:ring-violet-800/30'
133+
: hasUnread
134+
? 'bg-white dark:bg-gray-800/50 hover:bg-gray-50 dark:hover:bg-gray-800'
135+
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
136+
}`}
137+
id={`conversation-${conversation.id}`}
138+
>
139+
{/* Avatar */}
140+
<div className="relative flex-shrink-0">
141+
<div
142+
className={`w-12 h-12 rounded-full bg-gradient-to-br ${getAvatarColor(
143+
otherParticipant.name
144+
)} flex items-center justify-center text-white text-sm font-semibold shadow-sm`}
145+
>
146+
{getInitials(otherParticipant.name)}
147+
</div>
148+
{/* Online indicator */}
149+
{otherParticipant.online && (
150+
<div className="absolute bottom-0.5 right-0.5 w-3 h-3 rounded-full bg-emerald-400 border-2 border-white dark:border-gray-900 shadow-sm" />
151+
)}
152+
</div>
153+
154+
{/* Content */}
155+
<div className="flex-1 min-w-0">
156+
<div className="flex items-center justify-between mb-1">
157+
<span
158+
className={`text-sm truncate ${hasUnread
159+
? 'font-bold text-gray-900 dark:text-white'
160+
: 'font-medium text-gray-700 dark:text-gray-200'
161+
}`}
162+
>
163+
{otherParticipant.name}
164+
</span>
165+
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0 ml-2">
166+
{conversation.lastMessage
167+
? formatDistanceToNow(
168+
new Date(conversation.lastMessage.timestamp),
169+
{ addSuffix: false }
170+
)
171+
: ''}
172+
</span>
173+
</div>
174+
<div className="flex items-center justify-between gap-2">
175+
<p
176+
className={`text-xs truncate ${hasUnread
177+
? 'text-gray-700 dark:text-gray-300 font-medium'
178+
: 'text-gray-400 dark:text-gray-500'
179+
}`}
180+
>
181+
{conversation.lastMessage
182+
? conversation.lastMessage.senderId === 'current-user'
183+
? `You: ${conversation.lastMessage.content.replace(/<[^>]*>/g, '')}`
184+
: conversation.lastMessage.content.replace(/<[^>]*>/g, '')
185+
: 'Start a conversation...'}
186+
</p>
187+
<UnreadIndicator count={conversation.unreadCount} />
188+
</div>
189+
</div>
190+
191+
{/* Role Badge */}
192+
{otherParticipant.role === 'instructor' && (
193+
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
194+
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-medium">
195+
Instructor
196+
</span>
197+
</div>
198+
)}
199+
</button>
200+
);
201+
})}
202+
</div>
203+
)}
204+
</div>
205+
</div>
206+
);
207+
}

0 commit comments

Comments
 (0)