![]() 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/private_html/src/components/ |
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageSquare,
Reply,
Edit,
Trash2,
User,
Clock,
Send,
Image,
Paperclip,
MoreHorizontal,
Heart,
ThumbsUp,
AlertTriangle,
CheckCircle,
XCircle
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { format, formatDistanceToNow } from 'date-fns';
// Types
interface CommentUser {
id: string;
name: string;
profilePicture?: string;
role: string;
isVerified?: boolean;
}
interface CommentAttachment {
id: string;
name: string;
url: string;
type: string;
size: number;
}
interface CommentReaction {
id: string;
reactionType: string;
user: {
id: string;
name: string;
};
}
interface Comment {
id: string;
content: string;
createdAt: string;
updatedAt: string;
isEdited: boolean;
isDeleted: boolean;
user: CommentUser;
attachments: CommentAttachment[];
reactions: CommentReaction[];
_count: {
replies: number;
reactions: number;
};
}
interface CommentsSystemProps {
caseId: string;
className?: string;
}
// Utility functions
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return <Image className="w-4 h-4" />;
if (type.includes('pdf')) return <Paperclip className="w-4 h-4" />;
if (type.includes('word') || type.includes('document')) return <Paperclip className="w-4 h-4" />;
return <Paperclip className="w-4 h-4" />;
};
const CommentsSystem: React.FC<CommentsSystemProps> = ({ caseId, className = '' }) => {
const { data: session } = useSession();
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [posting, setPosting] = useState(false);
const [newComment, setNewComment] = useState('');
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyContent, setReplyContent] = useState('');
const [editingComment, setEditingComment] = useState<string | null>(null);
const [editContent, setEditContent] = useState('');
const [showReplies, setShowReplies] = useState<Set<string>>(new Set());
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [attachments, setAttachments] = useState<File[]>([]);
const [deletingId, setDeletingId] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Fetch comments
const fetchComments = useCallback(async (reset = false) => {
try {
if (reset) {
setPage(1);
setComments([]);
setHasMore(true);
}
setLoading(true);
const params = new URLSearchParams({
caseId,
page: reset ? '1' : String(page),
limit: '20'
});
const response = await fetch(`/api/comments?${params}`);
if (!response.ok) throw new Error('Failed to fetch comments');
const data = await response.json();
const newComments = data.comments;
if (reset) {
setComments(newComments);
} else {
setComments(prev => {
const existingIds = new Set(prev.map(c => c.id));
const uniqueNewComments = newComments.filter((c: Comment) => !existingIds.has(c.id));
return [...prev, ...uniqueNewComments];
});
}
setHasMore(data.pagination.page < data.pagination.pages);
} catch (error) {
console.error('Error fetching comments:', error);
toast.error('Failed to load comments');
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [caseId, page]);
// Load more comments
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
setPage(prev => prev + 1);
}, [loadingMore, hasMore]);
// Initial load
useEffect(() => {
fetchComments(true);
}, [caseId]);
// Load more when page changes
useEffect(() => {
if (page > 1) {
fetchComments(false);
}
}, [page]);
// Post comment
const handlePostComment = async () => {
if (!newComment.trim() || posting) return;
setPosting(true);
try {
const response = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: newComment.trim(),
caseId
})
});
if (!response.ok) {
const err = await response.json();
toast.error(err.error || 'Failed to post comment');
return;
}
const data = await response.json();
setComments(prev => [data.comment, ...prev]);
setNewComment('');
setAttachments([]);
toast.success('Comment posted successfully');
} catch (error) {
toast.error('Failed to post comment');
} finally {
setPosting(false);
}
};
// Post reply
const handlePostReply = async (parentId: string) => {
if (!replyContent.trim()) return;
try {
const response = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: replyContent.trim(),
caseId,
parentId
})
});
if (!response.ok) throw new Error('Failed to post reply');
const data = await response.json();
// Update comments to include the new reply
setComments(prev => prev.map(comment => {
if (comment.id === parentId) {
return {
...comment,
_count: {
...comment._count,
replies: comment._count.replies + 1
}
};
}
return comment;
}));
setReplyContent('');
setReplyingTo(null);
toast.success('Reply posted successfully');
} catch (error) {
console.error('Error posting reply:', error);
toast.error('Failed to post reply');
}
};
// Edit comment
const handleEditComment = async (commentId: string) => {
if (!editContent.trim()) return;
try {
const response = await fetch(`/api/comments?commentId=${commentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: editContent.trim()
})
});
if (!response.ok) throw new Error('Failed to update comment');
const data = await response.json();
// Update comment in state
setComments(prev => prev.map(comment =>
comment.id === commentId ? data.comment : comment
));
setEditContent('');
setEditingComment(null);
toast.success('Comment updated successfully');
} catch (error) {
console.error('Error updating comment:', error);
toast.error('Failed to update comment');
}
};
// Delete comment
const handleDeleteComment = async (commentId: string) => {
if (!confirm('Are you sure you want to delete this comment?')) return;
setDeletingId(commentId);
// Optimistically remove from UI
setComments(prev => prev.filter(comment => comment.id !== commentId));
try {
const response = await fetch(`/api/comments?commentId=${commentId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
if (
error.error && (
error.error.includes('already been deleted') ||
error.error.includes('not found')
)
) {
toast.success('Comment removed from UI.');
await fetchComments(true);
setDeletingId(null);
return;
}
throw new Error('Failed to delete comment');
}
toast.success('Comment deleted successfully');
await fetchComments(true);
} catch (error) {
toast.error('Failed to delete comment');
await fetchComments(true); // Restore correct state if error
} finally {
setDeletingId(null);
}
};
// Handle file upload
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const validFiles = files.filter(file => file.size <= 10 * 1024 * 1024); // 10MB limit
if (validFiles.length !== files.length) {
toast.error('Some files were too large (max 10MB)');
}
setAttachments(prev => [...prev, ...validFiles]);
};
// Remove attachment
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
// Comment card component
const CommentCard: React.FC<{
comment: Comment;
depth?: number;
isReply?: boolean;
}> = ({ comment, depth = 0, isReply = false }) => {
const canEdit = session?.user?.id === comment.user.id;
const canDelete = session?.user?.id === comment.user.id || session?.user?.role === 'ADMIN';
const isReplying = replyingTo === comment.id;
const isEditing = editingComment === comment.id;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className={`bg-white rounded-lg border border-gray-200 p-4 ${depth > 0 ? 'ml-8 border-l-2 border-blue-200' : ''}`}
>
{/* Comment header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
{comment.user.profilePicture ? (
<img
src={comment.user.profilePicture}
alt={comment.user.name}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<User className="w-4 h-4 text-blue-600" />
)}
</div>
<div>
<div className="flex items-center space-x-2">
<span className="font-medium text-gray-900">{comment.user.name}</span>
{comment.user.isVerified && (
<CheckCircle className="w-4 h-4 text-blue-500" />
)}
<span className="text-sm text-gray-500 capitalize">{comment.user.role}</span>
</div>
<div className="flex items-center space-x-2 text-xs text-gray-400">
<Clock className="w-3 h-3" />
<span>{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}</span>
{comment.isEdited && <span>(edited)</span>}
</div>
</div>
</div>
{/* Actions menu */}
{(canEdit || canDelete) && (
<div className="relative">
<button
onClick={() => {
if (canEdit) {
setEditingComment(comment.id);
setEditContent(comment.content);
}
}}
className="p-1 hover:bg-gray-100 rounded"
aria-label="Comment actions"
>
<MoreHorizontal className="w-4 h-4 text-gray-400" />
</button>
</div>
)}
</div>
{/* Comment content */}
{isEditing ? (
<div className="mb-3">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={3}
placeholder="Edit your comment..."
/>
<div className="flex space-x-2 mt-2">
<button
onClick={() => handleEditComment(comment.id)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Save
</button>
<button
onClick={() => {
setEditingComment(null);
setEditContent('');
}}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
) : (
<div className="mb-3">
<p className="text-gray-800 whitespace-pre-wrap">{comment.content}</p>
{/* Attachments */}
{comment.attachments.length > 0 && (
<div className="mt-3 space-y-2">
{comment.attachments.map((attachment) => (
<div key={attachment.id} className="flex items-center space-x-2 p-2 bg-gray-50 rounded">
{getFileIcon(attachment.type)}
<span className="text-sm text-gray-600">{attachment.name}</span>
<span className="text-xs text-gray-400">({formatFileSize(attachment.size)})</span>
<a
href={attachment.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm"
>
View
</a>
</div>
))}
</div>
)}
</div>
)}
{/* Comment actions */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => setReplyingTo(isReplying ? null : comment.id)}
className="flex items-center space-x-1 text-gray-500 hover:text-blue-600"
>
<Reply className="w-4 h-4" />
<span className="text-sm">Reply</span>
</button>
{canDelete && (
<button
onClick={() => handleDeleteComment(comment.id)}
className="flex items-center space-x-1 text-gray-500 hover:text-red-600"
disabled={deletingId === comment.id}
>
{deletingId === comment.id ? (
<span className="w-4 h-4 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin inline-block"></span>
) : (
<Trash2 className="w-4 h-4" />
)}
<span className="text-sm">Delete</span>
</button>
)}
</div>
<div className="flex items-center space-x-2 text-sm text-gray-400">
<span>{comment._count.replies} replies</span>
<span>{comment._count.reactions} reactions</span>
</div>
</div>
{/* Reply form */}
{isReplying && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-4 p-3 bg-gray-50 rounded-lg"
>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={3}
placeholder="Write your reply..."
/>
<div className="flex space-x-2 mt-2">
<button
onClick={() => handlePostReply(comment.id)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Reply
</button>
<button
onClick={() => {
setReplyingTo(null);
setReplyContent('');
}}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
Cancel
</button>
</div>
</motion.div>
)}
</motion.div>
);
};
if (!session) {
return (
<div className={`bg-white rounded-lg border border-gray-200 p-8 text-center ${className}`}>
<MessageSquare className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Sign in to comment</h3>
<p className="text-gray-500">You need to be signed in to view and post comments.</p>
</div>
);
}
return (
<div className={`space-y-6 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Comments</h2>
<div className="flex items-center space-x-2 text-sm text-gray-500">
<MessageSquare className="w-4 h-4" />
<span>{comments.length} comments</span>
</div>
</div>
{/* New comment form */}
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex space-x-3">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
{session.user?.image ? (
<img
src={session.user.image}
alt={session.user.name || 'User'}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<User className="w-4 h-4 text-blue-600" />
)}
</div>
<div className="flex-1">
<textarea
ref={textareaRef}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={3}
placeholder="Write a comment..."
disabled={posting}
/>
{/* Attachments */}
{attachments.length > 0 && (
<div className="mt-2 space-y-1">
{attachments.map((file, index) => (
<div key={index} className="flex items-center space-x-2 p-2 bg-gray-50 rounded">
<Paperclip className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-600">{file.name}</span>
<span className="text-xs text-gray-400">({formatFileSize(file.size)})</span>
<button
onClick={() => removeAttachment(index)}
className="text-red-500 hover:text-red-700"
>
<XCircle className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between mt-3">
<div className="flex items-center space-x-2">
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileUpload}
className="hidden"
accept="image/*,.pdf,.doc,.docx"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 text-gray-400 hover:text-gray-600"
aria-label="Add attachment"
>
<Paperclip className="w-5 h-5" />
</button>
</div>
<button
onClick={handlePostComment}
disabled={!newComment.trim() || posting}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
>
{posting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>Posting...</span>
</>
) : (
<>
<Send className="w-4 h-4" />
<span>Post Comment</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
{/* Comments list */}
<div className="space-y-4">
{loading ? (
<div className="text-center py-8">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500">Loading comments...</p>
</div>
) : comments.length === 0 ? (
<div className="text-center py-8">
<MessageSquare className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No comments yet</h3>
<p className="text-gray-500">Be the first to share your thoughts!</p>
</div>
) : (
<>
<AnimatePresence>
{comments.filter(comment => !comment.isDeleted).map((comment) => (
<CommentCard key={comment.id} comment={comment} />
))}
</AnimatePresence>
{/* Load more */}
{hasMore && (
<div className="text-center pt-4">
<button
onClick={loadMore}
disabled={loadingMore}
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50"
>
{loadingMore ? 'Loading...' : 'Load More Comments'}
</button>
</div>
)}
</>
)}
</div>
</div>
);
};
export default CommentsSystem;