![]() Server : Apache/2 System : Linux server-15-235-50-60 5.15.0-164-generic #174-Ubuntu SMP Fri Nov 14 20:25:16 UTC 2025 x86_64 User : gositeme ( 1004) PHP Version : 8.2.29 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname Directory : /home/gositeme/domains/lavocat.ca/public_html/src/components/ |
import React, { useState, useEffect, useRef, memo, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageSquare,
ThumbsUp,
Reply,
MoreHorizontal,
Edit,
Trash2,
User,
Clock,
Heart
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { format } from 'date-fns';
import ReactionPicker from './ReactionPicker';
interface CommentUser {
id: string;
name: string;
profilePicture?: string;
role: string;
}
interface CommentReaction {
id: string;
reactionType: string;
user: {
id: string;
name: string;
};
}
interface Comment {
id: string;
content: string;
createdAt: string;
updatedAt: string;
likes: number;
isEdited: boolean;
isDeleted: boolean;
user: CommentUser;
replies: Comment[];
reactions?: CommentReaction[];
_count: {
replies: number;
likedBy: number;
reactions: number;
};
}
interface ThreadedCommentsProps {
caseId: string;
initialComments?: Comment[];
onCommentAdded?: () => void;
}
const ThreadedComments: React.FC<ThreadedCommentsProps> = ({
caseId,
initialComments = [],
onCommentAdded
}) => {
const { data: session } = useSession();
const [comments, setComments] = useState<Comment[]>(initialComments);
const [loading, setLoading] = useState(false);
const [posting, setPosting] = useState(false);
const [newComment, setNewComment] = useState('');
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyContent, setReplyContent] = useState<{ [commentId: string]: string }>({});
const [editingComment, setEditingComment] = useState<string | null>(null);
const [editContent, setEditContent] = useState('');
const [showReplies, setShowReplies] = useState<Set<string>>(new Set());
const [likedComments, setLikedComments] = useState<Set<string>>(new Set());
// Refs to preserve state across fetches
const replyingToRef = useRef<string | null>(null);
const replyContentRef = useRef<{ [commentId: string]: string }>({});
// Utility function to normalize comment data
const normalizeComment = (comment: any): Comment => ({
...comment,
reactions: comment.reactions ?? [],
replies: (comment.replies ?? []).map((reply: any) => ({
...reply,
reactions: reply.reactions ?? [],
_count: {
replies: reply._count?.replies || reply.replies?.length || 0,
likedBy: reply._count?.likedBy || 0,
reactions: reply._count?.reactions || reply.reactions?.length || 0
}
})),
_count: {
replies: comment._count?.replies || comment.replies?.length || 0,
likedBy: comment._count?.likedBy || 0,
reactions: comment._count?.reactions || comment.reactions?.length || 0
}
});
// Keep refs in sync - but only when actually needed
useEffect(() => {
if (replyingTo !== replyingToRef.current) {
replyingToRef.current = replyingTo;
}
}, [replyingTo]);
useEffect(() => {
if (JSON.stringify(replyContent) !== JSON.stringify(replyContentRef.current)) {
replyContentRef.current = replyContent;
}
}, [replyContent]);
useEffect(() => {
fetchComments();
}, [caseId]);
const fetchComments = async () => {
try {
setLoading(true);
const response = await fetch(`/api/live-cases/${caseId}/comments`);
if (response.ok) {
const data = await response.json();
// Normalize comments to ensure reactions are always present
setComments(data.comments.map(normalizeComment));
// Restore reply state after fetch
setReplyingTo(replyingToRef.current);
setReplyContent(replyContentRef.current);
} else {
console.error('Failed to fetch comments');
}
} catch (error) {
console.error('Error fetching comments:', error);
} finally {
setLoading(false);
}
};
const handleAddComment = useCallback(async () => {
if (!session) {
toast.error('Please login to comment');
return;
}
if (!newComment.trim()) {
toast.error('Please enter a comment');
return;
}
try {
setPosting(true);
const response = await fetch(`/api/live-cases/${caseId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newComment })
});
if (response.ok) {
const data = await response.json();
setComments(prev => [normalizeComment(data.comment), ...prev]);
setNewComment('');
toast.success('Comment added! 💬');
onCommentAdded?.();
} else {
toast.error('Failed to add comment');
}
} catch (error) {
toast.error('Error adding comment');
} finally {
setPosting(false);
}
}, [session, newComment, caseId, onCommentAdded]);
const handleAddReply = useCallback(async (parentId: string) => {
if (!session) {
toast.error('Please login to reply');
return;
}
if (!replyContent[parentId] || !replyContent[parentId].trim()) {
toast.error('Please enter a reply');
return;
}
try {
const response = await fetch(`/api/live-cases/${caseId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: replyContent[parentId],
parentId: parentId
})
});
if (response.ok) {
const data = await response.json();
setComments(prev => prev.map(comment => {
if (comment.id === parentId) {
return {
...comment,
replies: [...comment.replies, normalizeComment(data.comment)],
_count: {
...comment._count,
replies: (comment._count?.replies || comment.replies?.length || 0) + 1
}
};
}
return comment;
}));
setReplyContent(prev => ({ ...prev, [parentId]: '' }));
setReplyingTo(null);
// Ensure replies are shown after adding a reply
setShowReplies(prev => new Set(Array.from(prev).concat(parentId)));
toast.success('Reply added! 💬');
onCommentAdded?.();
} else {
toast.error('Failed to add reply');
}
} catch (error) {
toast.error('Error adding reply');
}
}, [session, replyContent, caseId, onCommentAdded]);
const handleReaction = useCallback(async (commentId: string, reactionType: string) => {
if (!session) {
toast.error('Please login to react to comments');
return;
}
try {
const response = await fetch(`/api/live-cases/${caseId}/comments/${commentId}/reactions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reactionType })
});
if (response.ok) {
const data = await response.json();
// Determine if reaction was added or removed based on message
const wasAdded = data.message === 'Reaction added';
// Update the comment's reactions
setComments(prev => prev.map(comment => {
if (comment.id === commentId) {
return updateCommentReactions(comment, reactionType, wasAdded);
}
// Also update replies
return {
...comment,
replies: comment.replies.map(reply => {
if (reply.id === commentId) {
return updateCommentReactions(reply, reactionType, wasAdded);
}
return reply;
})
};
}));
const reactionEmojis: { [key: string]: string } = {
like: '👍', love: '❤️', laugh: '😂', wow: '😮', sad: '😢', angry: '😠',
dislike: '👎', star: '⭐', justice: '⚖️', gavel: '🔨', shield: '🛡️'
};
toast.success(wasAdded
? `Reacted with ${reactionEmojis[reactionType]}!`
: 'Reaction removed'
);
} else {
toast.error('Failed to react to comment');
}
} catch (error) {
toast.error('Error reacting to comment');
}
}, [session, caseId]);
const updateCommentReactions = (comment: Comment, reactionType: string, isAdding: boolean): Comment => {
// Safely handle undefined reactions
const reactions = comment.reactions ?? [];
const existingReaction = reactions.find(r =>
r.reactionType === reactionType && r.user.id === session?.user?.id
);
let newReactions = [...reactions];
if (isAdding && !existingReaction) {
// Add new reaction
newReactions.push({
id: `temp-${Date.now()}`,
reactionType,
user: {
id: session?.user?.id || '',
name: session?.user?.name || ''
}
});
} else if (!isAdding && existingReaction) {
// Remove reaction
newReactions = newReactions.filter(r =>
!(r.reactionType === reactionType && r.user.id === session?.user?.id)
);
}
return {
...comment,
reactions: newReactions,
_count: {
...comment._count,
reactions: newReactions.length
}
};
};
const getReactionData = (comment: Comment) => {
const reactionCounts: { [key: string]: number } = {};
const userReactions: { [key: string]: boolean } = {};
// Safely handle undefined reactions
(comment.reactions ?? []).forEach(reaction => {
reactionCounts[reaction.reactionType] = (reactionCounts[reaction.reactionType] || 0) + 1;
if (reaction.user.id === session?.user?.id) {
userReactions[reaction.reactionType] = true;
}
});
return Object.keys(reactionCounts).map(type => ({
reactionType: type,
count: reactionCounts[type],
userReacted: userReactions[type] || false
}));
};
const toggleReplies = (commentId: string) => {
setShowReplies(prev => {
const newSet = new Set(prev);
if (newSet.has(commentId)) {
newSet.delete(commentId);
} else {
newSet.add(commentId);
}
return newSet;
});
};
// Memoized CommentItem to prevent unnecessary re-renders
const CommentItem: React.FC<{
comment: Comment;
isReply?: boolean;
depth?: number;
parentId?: string;
}> = memo(({ comment, isReply = false, depth = 0, parentId }) => {
const isLiked = likedComments.has(comment.id);
const showReplyInput = replyingTo === comment.id;
const isOwner = comment.user.id === session?.user?.id;
// Memoized event handlers to prevent re-renders
const handleReplyClick = useCallback(() => {
setReplyingTo(comment.id);
}, [comment.id]);
const handleCancelReply = useCallback(() => {
setReplyingTo(null);
setReplyContent(prev => ({ ...prev, [comment.id]: '' }));
}, [comment.id]);
const handleAddReplyClick = useCallback(() => {
handleAddReply(comment.id);
}, [comment.id]);
const handleToggleReplies = useCallback(() => {
toggleReplies(comment.id);
}, [comment.id]);
const handleReactionClick = useCallback((reactionType: string) => {
handleReaction(comment.id, reactionType);
}, [comment.id]);
const handleDeleteComment = useCallback(async () => {
if (!confirm('Are you sure you want to delete this comment? This action cannot be undone.')) {
return;
}
try {
console.log('Attempting to delete comment:', comment.id, 'from case:', caseId);
const response = await fetch(`/api/live-cases/${caseId}/comments?commentId=${comment.id}`, {
method: 'DELETE',
});
console.log('Delete response status:', response.status);
if (response.ok) {
const result = await response.json();
console.log('Delete successful:', result);
// Remove the comment from state
setComments(prev => {
if (parentId) {
// Remove reply from parent comment
return prev.map(c => {
if (c.id === parentId) {
return {
...c,
replies: c.replies.filter(r => r.id !== comment.id),
_count: {
...c._count,
replies: c._count.replies - 1
}
};
}
return c;
});
} else {
// Remove top-level comment
return prev.filter(c => c.id !== comment.id);
}
});
toast.success('Comment deleted successfully');
} else {
const errorData = await response.json().catch(() => ({}));
console.error('Delete failed:', response.status, errorData);
toast.error(`Failed to delete comment: ${errorData.message || response.statusText}`);
}
} catch (error) {
console.error('Error deleting comment:', error);
toast.error('Error deleting comment');
}
}, [comment.id, parentId, caseId]);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`${isReply ? 'ml-8 border-l-2 border-gray-100 pl-4' : ''}`}
>
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
<div className="flex items-start gap-3">
{/* User Avatar */}
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white font-semibold">
{comment.user.profilePicture ? (
<img
src={comment.user.profilePicture}
alt={comment.user.name}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<User className="h-5 w-5" />
)}
</div>
</div>
{/* Comment Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="font-semibold text-gray-900">{comment.user.name}</span>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
{comment.user.role}
</span>
<span className="text-xs text-gray-500 flex items-center gap-1">
<Clock className="h-3 w-3" />
{format(new Date(comment.createdAt), 'MMM d, yyyy • h:mm a')}
</span>
{comment.isEdited && (
<span className="text-xs text-gray-400">(edited)</span>
)}
</div>
<div className="text-gray-700 mb-3">
{comment.isDeleted ? (
<em className="text-gray-400">This comment has been deleted</em>
) : (
<p className="whitespace-pre-wrap">{comment.content}</p>
)}
</div>
{/* Comment Actions */}
{!comment.isDeleted && (
<div className="flex items-center gap-4 text-sm">
<ReactionPicker
onReaction={handleReactionClick}
currentReactions={getReactionData(comment)}
commentId={comment.id}
/>
{!isReply && (
<button
type="button"
onClick={handleReplyClick}
className="flex items-center gap-1 px-3 py-1 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<Reply className="h-4 w-4" />
<span>Reply</span>
</button>
)}
{(comment._count?.replies || 0) > 0 && !isReply && (
<button
type="button"
onClick={handleToggleReplies}
className="flex items-center gap-1 px-3 py-1 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<MessageSquare className="h-4 w-4" />
<span>
{showReplies.has(comment.id) ? 'Hide' : 'Show'} {comment._count?.replies || 0} replies
</span>
</button>
)}
{/* Delete button for comment owner */}
{isOwner && (
<button
type="button"
onClick={handleDeleteComment}
className="flex items-center gap-1 px-3 py-1 rounded-full text-red-500 hover:text-red-700 hover:bg-red-50 transition-colors"
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</button>
)}
</div>
)}
</div>
</div>
{/* Reply Input */}
{showReplyInput && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-4 ml-12"
>
<textarea
value={replyContent[comment.id] || ''}
onChange={(e) => {
const value = e.target.value;
setReplyContent(prev => ({ ...prev, [comment.id]: value }));
}}
placeholder="Write a reply..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={3}
/>
<div className="flex justify-end gap-2 mt-2">
<button
type="button"
onClick={handleCancelReply}
className="px-3 py-1 text-gray-600 hover:text-gray-800 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleAddReplyClick}
className="px-4 py-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Reply
</button>
</div>
</motion.div>
)}
{/* Replies */}
{showReplies.has(comment.id) && comment.replies.length > 0 && (
<div className="mt-4 space-y-3">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
isReply={true}
depth={depth + 1}
parentId={comment.id}
/>
))}
</div>
)}
</div>
</motion.div>
);
});
return (
<div className="space-y-6">
{/* Comment Input */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Join the Discussion
</h3>
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white font-semibold">
{session?.user?.image ? (
<img
src={session.user.image}
alt={session.user.name || 'User'}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<User className="h-5 w-5" />
)}
</div>
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Share your thoughts on this case..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={4}
/>
<div className="flex justify-between items-center mt-3">
<span className="text-sm text-gray-500">
{newComment.length}/1000 characters
</span>
<button
type="button"
onClick={handleAddComment}
disabled={!newComment.trim() || posting}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{posting ? 'Posting...' : 'Post Comment'}
</button>
</div>
</div>
</div>
</div>
{/* Comments List */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Comments ({comments.length})
</h3>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-gray-500 mt-2">Loading comments...</p>
</div>
) : comments.length === 0 ? (
<div className="text-center py-8 bg-gray-50 rounded-lg">
<MessageSquare className="h-12 w-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-500">No comments yet. Be the first to share your thoughts!</p>
</div>
) : (
<div className="space-y-4">
{comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))}
</div>
)}
</div>
</div>
);
};
export default ThreadedComments;