|
|
|
@ -8,26 +8,28 @@ import { ScrollArea } from "@/components/ui/scroll-area"; |
|
|
|
|
import { MessageCircle, Reply, X } from "lucide-react"; |
|
|
|
|
import { APP_BASE_URL } from '@/utils/constants'; |
|
|
|
|
import { getEduConnectAccessToken } from '@/helpers/token.helper'; |
|
|
|
|
import { usePathname } from 'next/navigation'; |
|
|
|
|
import { fetchHeader } from '@/helpers/fetch.helper'; |
|
|
|
|
|
|
|
|
|
// Updated Types
|
|
|
|
|
interface Message { |
|
|
|
|
id: string; |
|
|
|
|
isSelf: number; |
|
|
|
|
text: string; |
|
|
|
|
|
|
|
|
|
// Types
|
|
|
|
|
interface Reply { |
|
|
|
|
id: number; |
|
|
|
|
user: string; |
|
|
|
|
avatar: string; |
|
|
|
|
content: string; |
|
|
|
|
timestamp: string; |
|
|
|
|
userId: string; |
|
|
|
|
username: string; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
interface ApiResponse { |
|
|
|
|
count: number; |
|
|
|
|
messages: Message[]; |
|
|
|
|
interface Discussion { |
|
|
|
|
id: number; |
|
|
|
|
user: string; |
|
|
|
|
avatar: string; |
|
|
|
|
content: string; |
|
|
|
|
likes: number; |
|
|
|
|
replies: Reply[]; |
|
|
|
|
timestamp: string; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
interface PageData { |
|
|
|
|
messages: Message[]; |
|
|
|
|
discussions: Discussion[]; |
|
|
|
|
hasMore: boolean; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
@ -37,20 +39,20 @@ interface DiscussionSectionProps { |
|
|
|
|
|
|
|
|
|
// Key generator for SWR
|
|
|
|
|
const getKey = (courseUuid: string) => (pageIndex: number, previousPageData: PageData | null) => { |
|
|
|
|
if (previousPageData && !previousPageData.messages.length) return null; |
|
|
|
|
return `${APP_BASE_URL}/api/chat/get`; |
|
|
|
|
if (previousPageData && !previousPageData.discussions.length) return null; |
|
|
|
|
return `${APP_BASE_URL}/api/chat/get?page=${pageIndex}`; |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
// Updated fetcher function
|
|
|
|
|
// Fetcher function
|
|
|
|
|
const fetcher = async (url: string, courseUuid: string): Promise<PageData> => { |
|
|
|
|
const formData = new FormData(); |
|
|
|
|
formData.append('course_id', courseUuid); |
|
|
|
|
formData.append('course_uuid', courseUuid); |
|
|
|
|
|
|
|
|
|
const response = await fetch(url, { |
|
|
|
|
method: 'POST', |
|
|
|
|
body: formData, |
|
|
|
|
headers: { |
|
|
|
|
Authorization: `Bearer ${getEduConnectAccessToken()}` |
|
|
|
|
body: formData ,
|
|
|
|
|
headers : { |
|
|
|
|
Authorization : `Bearer ${getEduConnectAccessToken()}` |
|
|
|
|
} |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
@ -58,22 +60,21 @@ const fetcher = async (url: string, courseUuid: string): Promise<PageData> => { |
|
|
|
|
throw new Error('Failed to fetch discussions'); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const data: ApiResponse = await response.json(); |
|
|
|
|
const data = await response.json(); |
|
|
|
|
return { |
|
|
|
|
messages: data.messages.map(message => ({ |
|
|
|
|
...message, |
|
|
|
|
timestamp: new Date(message.timestamp).toLocaleString() |
|
|
|
|
discussions: data.discussions.map((discussion: any) => ({ |
|
|
|
|
...discussion, |
|
|
|
|
timestamp: new Date(discussion.timestamp).toLocaleString() |
|
|
|
|
})), |
|
|
|
|
hasMore: data.count > data.messages.length |
|
|
|
|
hasMore: data.hasMore |
|
|
|
|
}; |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const EnhancedDiscussionSection: React.FC<DiscussionSectionProps> = ({ courseUuid }) => { |
|
|
|
|
const [comment, setComment] = useState(''); |
|
|
|
|
const [replyingTo, setReplyingTo] = useState<any>(null); |
|
|
|
|
const [replyingTo, setReplyingTo] = useState<{ commentId: number; userName: string } | null>(null); |
|
|
|
|
const loadingRef = useRef<HTMLDivElement>(null); |
|
|
|
|
const pathname = usePathname(); |
|
|
|
|
const id = pathname.split('/')?.at(-1); |
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null); |
|
|
|
|
|
|
|
|
|
const { |
|
|
|
|
data, |
|
|
|
@ -84,7 +85,7 @@ const EnhancedDiscussionSection: React.FC<DiscussionSectionProps> = ({ courseUui |
|
|
|
|
mutate |
|
|
|
|
} = useSWRInfinite( |
|
|
|
|
getKey(courseUuid), |
|
|
|
|
(url) => fetcher(url, id!), |
|
|
|
|
(url) => fetcher(url, courseUuid), |
|
|
|
|
{ |
|
|
|
|
revalidateFirstPage: false, |
|
|
|
|
revalidateOnFocus: false, |
|
|
|
@ -92,7 +93,7 @@ const EnhancedDiscussionSection: React.FC<DiscussionSectionProps> = ({ courseUui |
|
|
|
|
} |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
const allMessages = data ? data.flatMap(page => page.messages) : []; |
|
|
|
|
const allDiscussions = data ? data.flatMap(page => page.discussions) : []; |
|
|
|
|
const hasMore = data ? data[data.length - 1]?.hasMore : true; |
|
|
|
|
const isLoadingMore = !data && !error || (size > 0 && data && typeof data[size - 1] === "undefined"); |
|
|
|
|
|
|
|
|
@ -114,79 +115,137 @@ const EnhancedDiscussionSection: React.FC<DiscussionSectionProps> = ({ courseUui |
|
|
|
|
return () => observer.disconnect(); |
|
|
|
|
}, [hasMore, isValidating, setSize, size]); |
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => { |
|
|
|
|
if (comment.trim()) { |
|
|
|
|
const formData = new FormData(); |
|
|
|
|
formData.append('course_id', courseUuid); |
|
|
|
|
formData.append('message', comment); |
|
|
|
|
// if (replyingTo) {
|
|
|
|
|
// formData.append('parent_id', replyingTo.commentId.toString());
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
const response = await fetch(`${APP_BASE_URL}/api/chat/send`, { |
|
|
|
|
method: 'POST', |
|
|
|
|
body: formData, |
|
|
|
|
headers : fetchHeader() |
|
|
|
|
}); |
|
|
|
|
const handleSubmit = async () => { |
|
|
|
|
if (comment.trim()) { |
|
|
|
|
const formData = new FormData(); |
|
|
|
|
formData.append('course_uuid', courseUuid); |
|
|
|
|
formData.append('content', comment); |
|
|
|
|
if (replyingTo) { |
|
|
|
|
formData.append('parent_id', replyingTo.commentId.toString()); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (!response.ok) { |
|
|
|
|
throw new Error('Failed to post comment'); |
|
|
|
|
} |
|
|
|
|
try { |
|
|
|
|
const response = await fetch('/api/chat/post', { |
|
|
|
|
method: 'POST', |
|
|
|
|
body: formData |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
if (!response.ok) { |
|
|
|
|
throw new Error('Failed to post comment'); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Optimistically update the UI
|
|
|
|
|
|
|
|
|
|
const newComment = await response.json(); |
|
|
|
|
|
|
|
|
|
await mutate(); |
|
|
|
|
setComment(''); |
|
|
|
|
setReplyingTo(null); |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error('Failed to post comment:', error); |
|
|
|
|
// You might want to show an error message to the user here
|
|
|
|
|
// Optimistically update the UI
|
|
|
|
|
const updatedData = [...(data || [])]; |
|
|
|
|
if (replyingTo) { |
|
|
|
|
// Update the discussion with the new reply
|
|
|
|
|
updatedData.forEach(page => { |
|
|
|
|
page.discussions = page.discussions.map(discussion => { |
|
|
|
|
if (discussion.id === replyingTo.commentId) { |
|
|
|
|
return { |
|
|
|
|
...discussion, |
|
|
|
|
replies: [...discussion.replies, { |
|
|
|
|
...newComment, |
|
|
|
|
timestamp: 'Just now' |
|
|
|
|
}] |
|
|
|
|
}; |
|
|
|
|
} |
|
|
|
|
return discussion; |
|
|
|
|
}); |
|
|
|
|
}); |
|
|
|
|
} else { |
|
|
|
|
// Add new top-level comment
|
|
|
|
|
if (updatedData[0]) { |
|
|
|
|
updatedData[0].discussions = [{ |
|
|
|
|
...newComment, |
|
|
|
|
replies: [], |
|
|
|
|
timestamp: 'Just now' |
|
|
|
|
}, ...updatedData[0].discussions]; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
await mutate(updatedData, false); |
|
|
|
|
setComment(''); |
|
|
|
|
setReplyingTo(null); |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error('Failed to post comment:', error); |
|
|
|
|
// You might want to show an error message to the user here
|
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const handleReply = (commentId: number, userName: string) => { |
|
|
|
|
setReplyingTo({ commentId, userName }); |
|
|
|
|
setComment(`@${userName} `); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const cancelReply = () => { |
|
|
|
|
setReplyingTo(null); |
|
|
|
|
setComment(''); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
if (error) { |
|
|
|
|
return ( |
|
|
|
|
<div className="text-red-500 p-4 text-center"> |
|
|
|
|
Error loading discussions. Please try again later. |
|
|
|
|
</div> |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const handleReply = (commentId: number, userName: string) => { |
|
|
|
|
setReplyingTo({ commentId, userName }); |
|
|
|
|
setComment(`@${userName} `); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const cancelReply = () => { |
|
|
|
|
setReplyingTo(null); |
|
|
|
|
setComment(''); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
if (error) { |
|
|
|
|
return ( |
|
|
|
|
<div className="text-red-500 p-4 text-center"> |
|
|
|
|
Error loading discussions. Please try again later. |
|
|
|
|
</div> |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Rest of your component implementation remains similar, just update the rendering logic:
|
|
|
|
|
return ( |
|
|
|
|
<div className="w-full max-w-3xl mx-auto"> |
|
|
|
|
<ScrollArea className="h-[600px]"> |
|
|
|
|
<div className="space-y-6 p-4"> |
|
|
|
|
{allMessages.map((message) => ( |
|
|
|
|
<Card key={message.id} className="p-4"> |
|
|
|
|
{allDiscussions.map((discussion) => ( |
|
|
|
|
<Card key={discussion.id} className="p-4"> |
|
|
|
|
<div className="space-y-4"> |
|
|
|
|
<div className="flex gap-4"> |
|
|
|
|
<Avatar className="w-8 h-8"> |
|
|
|
|
<AvatarImage src="/api/placeholder/32/32" alt={message.username} /> |
|
|
|
|
<AvatarFallback>{message.username[0]}</AvatarFallback> |
|
|
|
|
<AvatarImage src={discussion.avatar} alt={discussion.user} /> |
|
|
|
|
<AvatarFallback>{discussion.user[0]}</AvatarFallback> |
|
|
|
|
</Avatar> |
|
|
|
|
<div className="flex-1"> |
|
|
|
|
<div className="flex items-center gap-2 mb-1"> |
|
|
|
|
<span className="font-medium">{message.username}</span> |
|
|
|
|
<span className="text-sm text-gray-500">{message.timestamp}</span> |
|
|
|
|
<span className="font-medium">{discussion.user}</span> |
|
|
|
|
<span className="text-sm text-gray-500">{discussion.timestamp}</span> |
|
|
|
|
</div> |
|
|
|
|
<p className="text-gray-700 mb-2">{discussion.content}</p> |
|
|
|
|
<div className="flex items-center gap-4"> |
|
|
|
|
<button
|
|
|
|
|
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700" |
|
|
|
|
onClick={() => handleReply(discussion.id, discussion.user)} |
|
|
|
|
> |
|
|
|
|
<Reply className="w-4 h-4" /> |
|
|
|
|
Reply |
|
|
|
|
</button> |
|
|
|
|
<div className="flex items-center gap-1 text-sm text-gray-500"> |
|
|
|
|
<MessageCircle className="w-4 h-4" /> |
|
|
|
|
{discussion.replies.length} replies |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
<p className="text-gray-700 mb-2">{message.text}</p> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
{discussion.replies.length > 0 && ( |
|
|
|
|
<div className="ml-12 space-y-4"> |
|
|
|
|
{discussion.replies.map((reply) => ( |
|
|
|
|
<div key={reply.id} className="flex gap-4"> |
|
|
|
|
<Avatar className="w-8 h-8"> |
|
|
|
|
<AvatarImage src={reply.avatar} alt={reply.user} /> |
|
|
|
|
<AvatarFallback>{reply.user[0]}</AvatarFallback> |
|
|
|
|
</Avatar> |
|
|
|
|
<div className="flex-1"> |
|
|
|
|
<div className="flex items-center gap-2 mb-1"> |
|
|
|
|
<span className="font-medium">{reply.user}</span> |
|
|
|
|
<span className="text-sm text-gray-500">{reply.timestamp}</span> |
|
|
|
|
</div> |
|
|
|
|
<p className="text-gray-700">{reply.content}</p> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
))} |
|
|
|
|
</div> |
|
|
|
|
)} |
|
|
|
|
</div> |
|
|
|
|
</Card> |
|
|
|
|
))} |
|
|
|
@ -195,8 +254,8 @@ const EnhancedDiscussionSection: React.FC<DiscussionSectionProps> = ({ courseUui |
|
|
|
|
{isLoadingMore && ( |
|
|
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900" /> |
|
|
|
|
)} |
|
|
|
|
{!hasMore && allMessages.length > 0 && ( |
|
|
|
|
<div className="text-gray-500">No more messages</div> |
|
|
|
|
{!hasMore && allDiscussions.length > 0 && ( |
|
|
|
|
<div className="text-gray-500">No more discussions</div> |
|
|
|
|
)} |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
@ -209,15 +268,26 @@ const EnhancedDiscussionSection: React.FC<DiscussionSectionProps> = ({ courseUui |
|
|
|
|
<AvatarFallback>U</AvatarFallback> |
|
|
|
|
</Avatar> |
|
|
|
|
<div className="flex-1"> |
|
|
|
|
{replyingTo && ( |
|
|
|
|
<div className="flex items-center gap-2 mb-2 text-sm text-gray-500"> |
|
|
|
|
<span>Replying to {replyingTo.userName}</span> |
|
|
|
|
<button
|
|
|
|
|
onClick={cancelReply} |
|
|
|
|
className="p-1 hover:bg-gray-100 rounded-full" |
|
|
|
|
> |
|
|
|
|
<X className="w-4 h-4" /> |
|
|
|
|
</button> |
|
|
|
|
</div> |
|
|
|
|
)} |
|
|
|
|
<Textarea |
|
|
|
|
placeholder="Add to the discussion..." |
|
|
|
|
placeholder={replyingTo ? `Reply to ${replyingTo.userName}...` : "Add to the discussion..."} |
|
|
|
|
value={comment} |
|
|
|
|
onChange={(e) => setComment(e.target.value)} |
|
|
|
|
className="min-h-24 mb-6" |
|
|
|
|
/> |
|
|
|
|
<div className="w-fit ml-auto"> |
|
|
|
|
<Button onClick={handleSubmit} className="bg-purple-700"> |
|
|
|
|
Post Message |
|
|
|
|
{replyingTo ? 'Post Reply' : 'Post Comment'} |
|
|
|
|
</Button> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|