![]() 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.quebec/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 { toast } from 'react-hot-toast';
import { format, formatDistanceToNow } from 'date-fns';
import ReactionPicker from './ReactionPicker';
import DOMPurify from 'dompurify';
import Link from 'next/link';
import RequireAuth from './RequireAuth';
import {
ThumbsUp,
ThumbsDown,
Heart,
Smiley,
SmileySad,
Fire,
Star,
Confetti,
Warning,
Info,
ChatCircle,
CheckCircle,
MagnifyingGlassPlus,
Paperclip,
ChatsCircle,
PencilSimple,
Trash,
Flag,
UserCircle,
XCircle,
PaperPlaneRight,
Question,
Trophy,
CurrencyDollar,
Scales,
Handshake,
Shield,
ShieldCheck,
Medal,
Crown,
Sword,
Target,
CheckCircle as CheckCircleIcon,
FileText,
Scroll,
Buildings,
GraduationCap
} from 'phosphor-react';
import { Gavel } from 'lucide-react';
interface CommentUser {
id: string;
name: string;
username?: string;
profilePicture?: string;
role: string;
isVerified?: boolean;
reputation?: number;
}
interface CommentReaction {
id: string;
reactionType: string;
user: {
id: string;
name: string;
username?: string;
};
}
interface Comment {
id: string;
content: string;
createdAt: string;
updatedAt: string;
likes: number;
isEdited: boolean;
isDeleted: boolean;
isPinned?: boolean;
isHighlighted?: boolean;
user: CommentUser;
replies: Comment[];
reactions?: CommentReaction[];
attachments?: Array<{
id: string;
name: string;
url: string;
type: string;
size: number;
file?: File;
}>;
_count: {
replies: number;
likedBy: number;
reactions: number;
};
}
interface EnhancedCommentsProps {
caseId: string;
initialComments?: Comment[];
onCommentAdded?: () => void;
mode?: 'public' | 'private' | 'admin';
allowAttachments?: boolean;
allowReactions?: boolean;
allowReplies?: boolean;
maxRepliesDepth?: number;
apiEndpoint?: string;
}
const EnhancedComments: React.FC<EnhancedCommentsProps> = ({
caseId,
initialComments = [],
onCommentAdded,
mode = 'public',
allowAttachments = true,
allowReactions = true,
allowReplies = true,
maxRepliesDepth = 3,
apiEndpoint
}) => {
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());
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'mostLiked' | 'mostReplies'>('newest');
const [filterBy, setFilterBy] = useState<'all' | 'lawyers' | 'clients' | 'admins'>('all');
const [searchTerm, setSearchTerm] = useState('');
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [attachments, setAttachments] = useState<{ id: string; name: string; url: string; type: string; size: number; file?: File }[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [selectedImage, setSelectedImage] = useState<{ url: string; name: string } | null>(null);
const [showImageModal, setShowImageModal] = useState(false);
const limit = 20;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const replyInputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({});
const getApiEndpoint = () => apiEndpoint || `/api/live-cases/${caseId}/comments
// Utility function to normalize comment data
const normalizeComment = (comment: any): Comment => ({
...comment,
reactions: comment.reactions ?? [],
attachments: comment.attachments ?? [],
replies: (comment.replies ?? []).map((reply: any) => ({
...reply,
reactions: reply.reactions ?? [],
attachments: reply.attachments ?? [],
_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
}
});
useEffect(() => {
fetchComments(true);
}, [caseId, sortBy, filterBy, searchTerm]);
const fetchComments = async (reset = false) => {
try {
if (reset) {
setPage(1);
setComments([]);
setHasMore(true);
}
setLoading(true);
const params = new URLSearchParams({
page: reset ? '1' : String(page),
limit: String(limit),
sortBy,
filterBy,
search: searchTerm
});
const response = await fetch(
if (response.ok) {
const data = await response.json();
const newComments = data.comments.map(normalizeComment);
if (reset) {
setComments(prev => dedupeComments([...newComments, ...prev]));
} else {
setComments(prev => dedupeComments([...newComments, ...prev]));
}
setHasMore(newComments.length === limit);
} else {
if (reset) {
setComments([]);
}
}
} catch (error) {
if (reset) {
setComments([]);
}
} finally {
setLoading(false);
setLoadingMore(false);
}
};
const handleAddComment = async () => {
if (!newComment.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
let res;
if (attachments.length > 0) {
// Use multipart/form-data for comments with attachments
const formData = new FormData();
formData.append('content', newComment);
attachments.forEach(attachment => {
// Convert attachment back to file if possible, or skip
if (attachment.file) {
formData.append('attachments', attachment.file);
}
});
res = await fetch(getApiEndpoint(), {
method: 'POST',
body: formData,
});
} else {
// Use JSON for comments without attachments
res = await fetch(getApiEndpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: newComment,
}),
});
}
if (!res.ok) throw new Error('Failed to post comment');
const data = await res.json();
setComments(prev => dedupeComments([normalizeComment(data.comment), ...prev]));
setNewComment('');
// Clean up object URLs before clearing attachments
attachments.forEach(attachment => {
if (attachment.url && attachment.url.startsWith('blob:')) {
URL.revokeObjectURL(attachment.url);
}
});
setAttachments([]);
if (onCommentAdded) onCommentAdded();
fetchComments(); // background refresh for consistency
toast.success('Comment posted!');
} catch (err) {
toast.error('Failed to post comment');
} finally {
setIsSubmitting(false);
}
};
// Add this helper before handleAddReply
function addReplyById(comments: Comment[], parentId: string, reply: Comment): Comment[] {
return comments.map(comment => {
if (comment.id === parentId) {
return {
...comment,
replies: [...comment.replies, reply],
_count: {
...comment._count,
replies: comment._count.replies + 1
}
};
}
return {
...comment,
replies: addReplyById(comment.replies, parentId, reply)
};
});
}
// Update handleAddReply to use the helper
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(getApiEndpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: replyContent[parentId].trim(),
parentId: parentId
})
});
if (response.ok) {
const data = await response.json();
setComments(prev => addReplyById(prev, parentId, normalizeComment(data.comment)));
setReplyContent(prev => ({ ...prev, [parentId]: '' }));
setReplyingTo(null);
toast.success('Reply added successfully! 💬');
} else {
toast.error('Failed to add reply');
}
} catch (error) {
toast.error('Error adding reply');
}
}, [session, replyContent, caseId]);
// Helper: Check if current user has reacted to a comment with a given type
const userHasReacted = (comment: Comment, reactionType: string) => {
const userId = session?.user?.id;
return (comment.reactions || []).some(r => r.reactionType === reactionType && r.user.id === userId);
};
// Helper: Get count for a reaction type
const getReactionCount = (comment: Comment, reactionType: string) => {
return (comment.reactions || []).filter(r => r.reactionType === reactionType).length;
};
// Robust, modern reaction handler
const handleReactionClick = useCallback(async (commentId: string, reactionType: string) => {
if (!session) {
toast.error('Please login to react to comments');
return;
}
const userId = session.user.id;
let optimisticAdd = true;
setComments(prevComments => prevComments.map(comment => {
if (comment.id !== commentId) return comment;
const reactionsArr = comment.reactions || [];
const alreadyReacted = reactionsArr.some(r => r.reactionType === reactionType && r.user.id === userId);
let newReactions;
if (alreadyReacted) {
// Remove user's reaction (toggle off)
newReactions = reactionsArr.filter(r => !(r.reactionType === reactionType && r.user.id === userId));
optimisticAdd = false;
} else {
// Add user's reaction (toggle on)
newReactions = [
...reactionsArr,
{
id: 'temp-' + Date.now(),
reactionType,
user: { id: userId, name: session.user.name || '' }
}
];
}
return { ...comment, reactions: newReactions };
}));
try {
const response = await fetch(getApiEndpoint() + '/' + commentId + '/reactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reactionType }),
});
if (!response.ok) throw new Error('Failed to update reaction');
// Optionally, update only the affected comment with the server response if available
// (Assume API returns updated comment)
// const data = await response.json();
// setComments(prev => prev.map(c => c.id === commentId ? normalizeComment(data.comment) : c));
} catch (error) {
// Revert only the affected comment's reactions
setComments(prevComments => prevComments.map(comment => {
if (comment.id !== commentId) return comment;
const reactionsArr = comment.reactions || [];
if (optimisticAdd) {
// Remove the temp reaction
return { ...comment, reactions: reactionsArr.filter(r => !(r.reactionType === reactionType && r.user.id === userId)) };
} else {
// Add back the reaction
return { ...comment, reactions: [
...reactionsArr,
{
id: 'temp-' + Date.now(),
reactionType,
user: { id: userId, name: session.user.name || '' }
}
] };
}
}));
toast.error('Failed to update reaction');
}
}, [session, getApiEndpoint, setComments]);
const updateCommentReactions = (comment: Comment, reactionType: string, isAdding: boolean): Comment => {
const existingReaction = comment.reactions?.find(r =>
r.reactionType === reactionType && r.user.id === session?.user?.id
);
if (isAdding && !existingReaction) {
return {
...comment,
reactions: [
...(comment.reactions || []),
{
id: 'temp-' + Date.now(),
reactionType,
user: { id: session?.user?.id || '', name: session?.user?.name || '' }
}
]
};
} else if (!isAdding && existingReaction) {
return {
...comment,
reactions: comment.reactions?.filter(r => r.id !== existingReaction.id) || []
};
}
return comment;
};
const getReactionData = (comment: Comment) => {
const reactionCounts: { [key: string]: number } = {};
const userReactions: { [key: string]: boolean } = {};
(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;
});
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const maxSize = 5 * 1024 * 1024; // 5MB
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'text/plain'];
for (const file of files) {
if (file.size > maxSize) {
toast.error(
continue;
}
if (!allowedTypes.includes(file.type)) {
toast.error(
continue;
}
// Store the file directly for later upload with the comment
setAttachments(prev => [
...prev,
{
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
name: file.name,
url: URL.createObjectURL(file), // Create preview URL
type: file.type,
size: file.size,
file: file, // Store the actual file
},
]);
toast.success(
}
};
const removeAttachment = (index: number) => {
setAttachments(prev => {
const newAttachments = prev.filter((_, i) => i !== index);
// Clean up the object URL for the removed attachment
if (prev[index]?.url && prev[index].url.startsWith('blob:')) {
URL.revokeObjectURL(prev[index].url);
}
return newAttachments;
});
};
const filteredComments = comments.filter(comment => {
if (filterBy === 'all') return true;
return comment.user.role.toUpperCase() === filterBy.toUpperCase();
});
const sortedComments = [...filteredComments].sort((a, b) => {
switch (sortBy) {
case 'oldest':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'mostLiked':
return b.likes - a.likes;
case 'mostReplies':
return b._count.replies - a._count.replies;
default: // newest
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
});
// Modern comment card component
const CommentCard: React.FC<{
comment: Comment;
depth: number;
onReply: (id: string) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onReact: (id: string, reaction: string) => void;
isReplying: boolean;
isEditing: boolean;
parentId?: string;
}> = ({ comment, depth, onReply, onEdit, onDelete, onReact, isReplying, isEditing, parentId }) => {
const roleColors: Record<string, string> = {
SUPERADMIN: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white',
ADMIN: 'bg-gradient-to-r from-blue-500 to-blue-700 text-white',
LAWYER: 'bg-gradient-to-r from-green-500 to-green-700 text-white',
CLIENT: 'bg-gradient-to-r from-yellow-400 to-yellow-600 text-white',
USER: 'bg-gray-200 text-gray-800',
};
const avatar = comment.user.profilePicture
? <img src={comment.user.profilePicture} alt={comment.user.name} className="h-10 w-10 rounded-full object-cover border-2 border-white shadow" />
: <div className={'h-10 w-10 rounded-full flex items-center justify-center font-bold text-lg ' + (roleColors[comment.user.role] || 'bg-gray-200 text-gray-800')}>
return (
<div className={'relative group bg-white rounded-xl shadow-md p-4 mb-4 ' + (depth > 0 ? 'ml-8 border-l-4 border-blue-100' : '')}
tabIndex={0} aria-label={'Comment by ' + comment.user.name}
>
<div className="flex items-start gap-3">
{avatar}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-gray-900">{comment.user.name}</span>
<span className={'text-xs px-2 py-1 rounded-full ml-1 ' + (roleColors[comment.user.role] || 'bg-gray-200 text-gray-800')}>
{comment.user.isVerified && <CheckCircle className="h-4 w-4 text-blue-500 ml-1" />}
<span className="text-xs text-gray-500 ml-2">{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}</span>
{comment.isEdited && <span className="text-xs text-gray-400 ml-1">(edited)</span>}
{comment.isPinned && <Star className="h-4 w-4 text-yellow-400 ml-1" />}
</div>
{isEditing ? (
<div className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white font-semibold text-sm">
{session?.user?.image ? (
<img
src={session.user.image}
alt={session.user.name || 'User'}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<UserCircle className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1 space-y-2">
<textarea
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
rows={3}
value={editContent}
onChange={e => setEditContent(e.target.value)}
aria-label="Edit comment input"
placeholder="Edit your comment..."
/>
<div className="flex gap-2">
<button
type="button"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors"
onClick={async () => {
if (!editContent.trim()) return;
setIsSubmitting(true);
try {
const res = await fetch(getApiEndpoint() + '/' + comment.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editContent, attachments: comment.attachments }),
});
if (!res.ok) throw new Error('Failed to edit comment');
setEditingComment(null);
setEditContent('');
await fetchComments();
toast.success('Comment updated!');
} catch (err) {
toast.error('Failed to update comment');
} finally {
setIsSubmitting(false);
}
}}
disabled={isSubmitting || !editContent.trim()}
aria-label="Save edited comment"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm font-medium transition-colors"
onClick={() => { setEditingComment(null); setEditContent(''); }}
disabled={isSubmitting}
aria-label="Cancel editing"
>
Cancel
</button>
</div>
</div>
</div>
</div>
) : (
<div className="prose prose-sm max-w-none text-gray-800 mb-2" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.content) }} />
)}
{comment.attachments && comment.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
<div className="w-full text-xs text-gray-500 mb-1">📎 Attachments ({comment.attachments.length}):</div>
{comment.attachments.map(att => {
const isImage = att.type.startsWith('image/');
const isPDF = att.type === 'application/pdf';
const isWord = att.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || att.type === 'application/msword';
return (
<div key={att.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded-lg border">
{isImage ? (
<div className="flex flex-col items-center">
<div
className="relative cursor-pointer group"
onClick={() => {
setSelectedImage({ url: att.url, name: att.name });
setShowImageModal(true);
}}
>
<img
src={att.url}
alt={att.name}
className="h-24 w-24 object-cover rounded border-2 border-gray-200 shadow-sm transition-transform group-hover:scale-105"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 rounded flex items-center justify-center">
<MagnifyingGlassPlus className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
</div>
<span className="text-xs text-gray-600 mt-1">{att.name}</span>
</div>
) : isPDF ? (
<div className="flex items-center gap-2">
<svg width="32" height="32" fill="none" viewBox="0 0 24 24">
<rect width="24" height="24" rx="4" fill="#E53E3E"/>
<text x="12" y="16" textAnchor="middle" fill="#fff" fontSize="12" fontWeight="bold">PDF</text>
</svg>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-700">{att.name}</span>
<span className="text-xs text-gray-500">({(att.size / 1024 / 1024).toFixed(2)} MB)</span>
</div>
</div>
) : isWord ? (
<div className="flex items-center gap-2">
<svg width="32" height="32" fill="none" viewBox="0 0 24 24">
<rect width="24" height="24" rx="4" fill="#3182CE"/>
<text x="12" y="16" textAnchor="middle" fill="#fff" fontSize="12" fontWeight="bold">DOC</text>
</svg>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-700">{att.name}</span>
<span className="text-xs text-gray-500">({(att.size / 1024 / 1024).toFixed(2)} MB)</span>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<Paperclip className="h-6 w-6 text-gray-500" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-700">{att.name}</span>
<span className="text-xs text-gray-500">({(att.size / 1024 / 1024).toFixed(2)} MB)</span>
</div>
</div>
)}
</div>
);
})}
</div>
)}
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
{/* Show top reactions inline, rest in popover */}
{EMOJI_REACTIONS.slice(0, 3).map(r => {
const count = getReactionCount(comment, r.type);
const users = (comment.reactions || []).filter(rx => rx.reactionType === r.type).map(rx => rx.user);
return (
<span
key={r.type}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: 8 }}
>
<button
type="button"
aria-label={r.label}
onClick={() => onReact(comment.id, r.type)}
className={`focus:outline-none transition-colors ${userHasReacted(comment, r.type) ? 'font-bold' : ''}
style={{ textDecoration: 'none' }}
>
<span style={{ fontSize: 22, filter: userHasReacted(comment, r.type) ? 'drop-shadow(0 0 2px ' + r.color + ')' : 'none' }}>{r.emoji}</span>
</button>
{count > 0 && <ReactionUserListPopover users={users} />}
</span>
);
})}
{/* Show top 2 justice icons */}
{ICON_REACTIONS.slice(0, 2).map(r => {
const Icon = r.icon;
const count = getReactionCount(comment, r.type);
const users = (comment.reactions || []).filter(rx => rx.reactionType === r.type).map(rx => rx.user);
return Icon ? (
<span
key={r.type}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: 8 }}
>
<button
type="button"
aria-label={r.label}
onClick={() => onReact(comment.id, r.type)}
className={`focus:outline-none transition-colors ${userHasReacted(comment, r.type) ? 'font-bold' : ''}
style={{ textDecoration: 'none' }}
>
<Icon size={22} color={r.color} weight={userHasReacted(comment, r.type) ? 'fill' : 'regular'} />
</button>
{count > 0 && <ReactionUserListPopover users={users} />}
</span>
) : null;
})}
{/* Popover for all reactions */}
<ReactionPopover onReact={onReact} comment={comment} />
</div>
{/* Comment Actions */}
<div className="flex items-center gap-2 mt-3 pt-2 border-t border-gray-100">
<button
type="button"
onClick={() => onReply(comment.id)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-blue-600 transition-colors"
disabled={isReplying}
>
<ChatsCircle className="h-3 w-3" />
Reply
</button>
{/* Edit button - only for comment owner or admins */}
{(session?.user?.id === comment.user.id ||
session?.user?.role === 'ADMIN' ||
session?.user?.role === 'SUPERADMIN') && (
<button
type="button"
onClick={() => onEdit(comment.id)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-blue-600 transition-colors"
disabled={isEditing}
>
<PencilSimple className="h-3 w-3" />
Edit
</button>
)}
{/* Delete button - only for comment owner or admins */}
{(session?.user?.id === comment.user.id ||
session?.user?.role === 'ADMIN' ||
session?.user?.role === 'SUPERADMIN') && (
<button
type="button"
onClick={() => {
if (confirm('Are you sure you want to delete this comment?')) {
onDelete(comment.id);
}
}}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-red-600 transition-colors"
>
<Trash className="h-3 w-3" />
Delete
</button>
)}
{/* Report button - for everyone except comment owner */}
{session?.user?.id !== comment.user.id && (
<button
type="button"
onClick={() => handleReportComment(comment.id)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-orange-600 transition-colors"
>
<Flag className="h-3 w-3" />
Report
</button>
)}
</div>
{isReplying && !comment.isDeleted && (
<div className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white font-semibold text-sm">
{session?.user?.image ? (
<img
src={session.user.image}
alt={session.user.name || 'User'}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<UserCircle className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1 space-y-2">
<input
type="text"
ref={(el) => {
replyInputRefs.current[comment.id] = el;
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
placeholder="Write your reply..."
defaultValue=""
aria-label="Reply input"
/>
<div className="flex gap-2">
<button
type="button"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors"
onClick={async () => {
const inputElement = replyInputRefs.current[comment.id];
const inputValue = inputElement?.value || '';
if (!inputValue.trim()) return;
setIsSubmitting(true);
try {
const res = await fetch(getApiEndpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: inputValue.trim(),
parentId: comment.id,
}),
});
if (!res.ok) throw new Error('Failed to post reply');
// Clear the input
if (inputElement) {
inputElement.value = '';
}
setReplyingTo(null);
await fetchComments();
toast.success('Reply posted!');
} catch (err) {
toast.error('Failed to post reply');
} finally {
setIsSubmitting(false);
}
}}
disabled={isSubmitting}
aria-label="Post reply"
>
{isSubmitting ? 'Posting...' : 'Post Reply'}
</button>
<button
type="button"
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm font-medium transition-colors"
onClick={() => {
setReplyingTo(null);
setReplyContent(prev => ({ ...prev, [comment.id]: '' }));
}}
disabled={isSubmitting}
aria-label="Cancel reply"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Recursive Replies */}
{(Array.isArray(comment.replies) && comment.replies.length > 0) && (
<div className="mt-4 space-y-3">
{comment.replies?.map((reply) => (
<CommentCard
key={reply.id}
comment={reply}
depth={depth + 1}
onReply={(id) => {
setReplyingTo(id);
setReplyContent({ [id]: '' });
}}
onEdit={(id) => {
setEditingComment(id);
setEditContent(reply.content);
}}
onDelete={(id) => {
if (confirm('Are you sure you want to delete this reply?')) {
handleDeleteReply(comment.id, reply.id);
}
}}
onReact={(id, reaction) => handleReactionClick(id, reaction)}
isReplying={replyingTo === reply.id}
isEditing={editingComment === reply.id}
parentId={comment.id}
/>
))}
</div>
)}
</div>
);
};
const handleLoadMore = () => {
setLoadingMore(true);
setPage(prev => prev + 1);
};
useEffect(() => {
if (page > 1) fetchComments();
// eslint-disable-next-line
}, [page]);
// Helper to recursively remove a comment by id from a nested comment tree
function removeCommentById(comments: Comment[], id: string): Comment[] {
return comments
.filter(comment => comment.id !== id)
.map(comment => ({
...comment,
replies: removeCommentById(comment.replies, id)
}));
}
const handleDeleteComment = async (id: string) => {
setIsSubmitting(true);
try {
const res = await fetch(`${getApiEndpoint()}?commentId=${id}
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete comment');
setComments(prev => removeCommentById(prev, id));
toast.success('Comment deleted!');
// Optionally, re-fetch in background for consistency
fetchComments();
} catch (err) {
toast.error('Failed to delete comment');
} finally {
setIsSubmitting(false);
}
};
const handleDeleteReply = async (parentId: string, replyId: string) => {
setIsSubmitting(true);
try {
const res = await fetch(`${getApiEndpoint()}?commentId=${replyId}
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete reply');
setComments(prev => removeCommentById(prev, replyId));
toast.success('Reply deleted!');
fetchComments();
} catch (err) {
toast.error('Failed to delete reply');
} finally {
setIsSubmitting(false);
}
};
const handleReportComment = async (commentId: string) => {
if (!session) {
toast.error('Please login to report comments');
return;
}
try {
const response = await fetch(`${getApiEndpoint()}/${commentId}/report
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: session.user?.id }),
});
if (response.ok) {
toast.success('Comment reported!');
fetchComments(); // Refresh to show updated comment
} else {
toast.error('Failed to report comment');
}
} catch (error) {
toast.error('Error reporting comment');
}
};
// Helper to deduplicate comments by id
function dedupeComments(comments: Comment[]): Comment[] {
const seen = new Set<string>();
const deduped: Comment[] = [];
for (const comment of comments) {
if (!seen.has(comment.id)) {
seen.add(comment.id);
deduped.push(comment);
}
}
return deduped;
}
// Define a new ALL_REACTIONS array with both emojis and icons, grouped
const ALL_REACTIONS = [
// Fun Emoji Reactions (matching API types)
{ type: 'like', emoji: '👍', color: '#22c55e', label: 'Like' },
{ type: 'love', emoji: '❤️', color: '#ef4444', label: 'Love' },
{ type: 'laugh', emoji: '😂', color: '#fbbf24', label: 'Laugh' },
{ type: 'wow', emoji: '😮', color: '#3b82f6', label: 'Wow' },
{ type: 'sad', emoji: '😢', color: '#3b82f6', label: 'Sad' },
{ type: 'angry', emoji: '😠', color: '#dc2626', label: 'Angry' },
{ type: 'dislike', emoji: '👎', color: '#6b7280', label: 'Dislike' },
{ type: 'star', emoji: '⭐', color: '#facc15', label: 'Star' },
// Justice & Law Icons (matching API types)
{ type: 'justice', icon: Scales, color: '#14b8a6', label: 'Justice' },
{ type: 'gavel', icon: Gavel, color: '#6b7280', label: 'Gavel' },
{ type: 'shield', icon: Shield, color: '#dc2626', label: 'Protection' },
// Additional fun reactions
{ type: 'party', emoji: '🎉', color: '#a21caf', label: 'Party' },
{ type: 'celebrate', emoji: '🥳', color: '#a21caf', label: 'Celebrate' },
{ type: 'think', emoji: '🤔', color: '#38bdf8', label: 'Think' },
{ type: 'pray', emoji: '🙏', color: '#2563eb', label: 'Pray' },
{ type: 'clap', emoji: '👏', color: '#22c55e', label: 'Clap' },
// Additional justice icons
{ type: 'trophy', icon: Trophy, color: '#eab308', label: 'Victory' },
{ type: 'handshake', icon: Handshake, color: '#2563eb', label: 'Agreement' },
{ type: 'money', icon: CurrencyDollar, color: '#22c55e', label: 'Money' },
{ type: 'shield-check', icon: ShieldCheck, color: '#22c55e', label: 'Verified' },
{ type: 'medal', icon: Medal, color: '#eab308', label: 'Achievement' },
{ type: 'crown', icon: Crown, color: '#facc15', label: 'Authority' },
{ type: 'sword', icon: Sword, color: '#dc2626', label: 'Justice' },
{ type: 'target', icon: Target, color: '#ef4444', label: 'Target' },
{ type: 'check', icon: CheckCircleIcon, color: '#22c55e', label: 'Approved' },
{ type: 'document', icon: FileText, color: '#3b82f6', label: 'Document' },
{ type: 'scroll', icon: Scroll, color: '#8b5cf6', label: 'Legal' },
{ type: 'court', icon: Buildings, color: '#6b7280', label: 'Court' },
{ type: 'graduation', icon: GraduationCap, color: '#2563eb', label: 'Expert' }
];
// Separate emoji and icon reactions for easier rendering
const EMOJI_REACTIONS = ALL_REACTIONS.filter(r => r.emoji);
const ICON_REACTIONS = ALL_REACTIONS.filter(r => r.icon);
// Add a color map for each reaction type
const REACTION_COLORS = {
like: "#22c55e",
love: "#ef4444",
laugh: "#fbbf24",
sad: "#3b82f6",
fire: "#f97316",
star: "#facc15",
celebrate: "#a21caf",
angry: "#dc2626",
dislike: "#6b7280",
curious: "#38bdf8",
confused: "#ec4899",
trophy: "#eab308",
money: "#22c55e",
gavel: "#6b7280",
scales: "#14b8a6",
handshake: "#2563eb"
};
// Replace ReactionUserListPopover with a custom dropdown
const ReactionUserListPopover = ({ users }: { users: { id: string; name: string; username?: string; profilePicture?: string }[] }) => {
const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setOpen(true);
};
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
setOpen(false);
}, 300); // 300ms delay before closing
};
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<span style={{ position: 'relative', display: 'inline-block' }}>
<span
style={{
cursor: 'pointer',
textDecoration: 'underline',
color: '#2563eb',
padding: '2px 4px',
borderRadius: '4px',
transition: 'background-color 0.2s ease'
}}
onClick={() => setOpen(v => !v)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#f0f9ff';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{users.length} 👥
</span>
{open && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e5e7eb',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 100,
minWidth: 180,
padding: 12,
marginTop: 4
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{users.map(user => (
<div key={user.id} style={{
display: 'flex',
alignItems: 'center',
marginBottom: 8,
padding: '4px 8px',
borderRadius: '4px',
transition: 'background-color 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f3f4f6';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{user.profilePicture && (
<img src={user.profilePicture} alt={user.name} style={{ width: 24, height: 24, borderRadius: '50%', marginRight: 10 }} />
)}
<Link
href={user.username ? `/profile/${user.username}` : `/profiles/${user.id}
style={{
color: '#2563eb',
textDecoration: 'none',
fontWeight: 500,
fontSize: '14px',
padding: '4px 0',
display: 'block',
width: '100%'
}}
className="hover:text-blue-700 hover:underline transition-colors"
onClick={(e) => {
e.stopPropagation();
// Add a small delay to ensure the link is processed
setTimeout(() => {
window.location.href = user.username ? `/profile/${user.username}` : `/profiles/${user.id}
}, 50);
}}
>
{user.name}
</Link>
</div>
))}
</div>
)}
</span>
);
};
// Replace ReactionPopover with a custom dropdown
const ReactionPopover = ({ onReact, comment }: { onReact: (id: string, type: string) => void, comment: Comment }) => {
const [open, setOpen] = React.useState(false);
const [position, setPosition] = React.useState<'bottom' | 'top'>('bottom');
const buttonRef = React.useRef<HTMLButtonElement>(null);
const popoverRef = React.useRef<HTMLDivElement>(null);
const handleClick = () => {
if (!open) {
// Check if there's enough space below, if not, position above
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
setPosition(spaceBelow < 300 && spaceAbove > spaceBelow ? 'top' : 'bottom');
}
}
setOpen(v => !v);
};
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node) &&
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<span style={{ position: 'relative', display: 'inline-block' }}>
<button
ref={buttonRef}
type="button"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#6b7280',
fontSize: 18,
padding: 4,
marginLeft: 4,
borderRadius: '4px',
transition: 'background-color 0.2s ease'
}}
aria-label="Add Reaction"
onClick={handleClick}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f3f4f6';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<span style={{ fontSize: 18, marginRight: 2 }}>+</span>
<span role="img" aria-label="Add Reaction">🙂</span>
</button>
{open && (
<div
ref={popoverRef}
style={{
position: 'absolute',
[position === 'bottom' ? 'top' : 'bottom']: '100%',
left: position === 'bottom' ? 0 : 'auto',
right: position === 'top' ? 0 : 'auto',
background: 'white',
border: '1px solid #e5e7eb',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 1000,
minWidth: 280,
maxWidth: 320,
padding: 12,
marginTop: position === 'bottom' ? 4 : 0,
marginBottom: position === 'top' ? 4 : 0,
display: 'flex',
flexDirection: 'column',
gap: 8
}}
>
<div style={{
marginBottom: 8,
borderBottom: '1px solid #eee',
paddingBottom: 4,
fontWeight: 600,
color: '#888',
fontSize: '14px'
}}>
😊 Fun Reactions
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 6,
marginBottom: 12
}}>
{ALL_REACTIONS.filter(r => r.emoji).map(r => (
<button
key={r.type}
type="button"
style={{
background: userHasReacted(comment, r.type) ? r.color : 'white',
color: userHasReacted(comment, r.type) ? 'white' : '#374151',
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: '8px 6px',
cursor: 'pointer',
fontWeight: userHasReacted(comment, r.type) ? 700 : 400,
fontSize: 20,
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
flexDirection: 'column',
gap: 2
}}
onClick={() => { onReact(comment.id, r.type); setOpen(false); }}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
aria-label={r.label}
>
<span style={{
filter: userHasReacted(comment, r.type) ? 'drop-shadow(0 0 2px ' + r.color + ')' : 'none',
fontSize: '18px'
}}>
{r.emoji}
</span>
<span style={{
fontSize: '10px',
fontWeight: 500,
color: userHasReacted(comment, r.type) ? 'white' : '#6b7280'
}}>
{r.label}
</span>
</button>
))}
</div>
<div style={{
marginBottom: 8,
borderBottom: '1px solid #eee',
paddingBottom: 4,
fontWeight: 600,
color: '#888',
fontSize: '14px'
}}>
⚖️ Justice & Law
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 6
}}>
{ALL_REACTIONS.filter(r => r.icon).map(r => {
const Icon = r.icon;
return Icon ? (
<button
key={r.type}
type="button"
style={{
background: userHasReacted(comment, r.type) ? r.color : 'white',
color: userHasReacted(comment, r.type) ? 'white' : '#374151',
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: '8px 6px',
cursor: 'pointer',
fontWeight: userHasReacted(comment, r.type) ? 700 : 400,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
minHeight: '40px',
transition: 'all 0.2s ease',
flexDirection: 'column'
}}
onClick={() => { onReact(comment.id, r.type); setOpen(false); }}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
aria-label={r.label}
>
<Icon size={16} color={userHasReacted(comment, r.type) ? 'white' : r.color} weight={userHasReacted(comment, r.type) ? 'fill' : 'regular'} />
<span style={{
fontSize: '10px',
fontWeight: 500,
color: userHasReacted(comment, r.type) ? 'white' : '#6b7280'
}}>
{r.label}
</span>
</button>
) : null;
})}
</div>
</div>
)}
</span>
);
};
// Memoize CommentCard to avoid unnecessary re-renders
const MemoizedCommentCard = React.memo(CommentCard);
// Wrap the comment input area with RequireAuth
return (
<div className="space-y-6">
{/* Header with Stats and Controls */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<ChatCircle className="h-5 w-5 text-blue-600" />
Comments & Discussion
</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<UserCircle className="h-4 w-4" />
{comments.length} comments
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
className="flex items-center gap-1 px-3 py-1 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
>
<MagnifyingGlassPlus className="h-4 w-4" />
Filters
</button>
</div>
</div>
{/* Advanced Options */}
<AnimatePresence>
{showAdvancedOptions && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="border-t border-gray-200 pt-4 space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Sort */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sort by</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="mostLiked">Most liked</option>
<option value="mostReplies">Most replies</option>
</select>
</div>
{/* Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Filter by</label>
<select
value={filterBy}
onChange={(e) => setFilterBy(e.target.value as any)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
>
<option value="all">All users</option>
<option value="lawyers">Lawyers only</option>
<option value="clients">Clients only</option>
<option value="admins">Admins only</option>
</select>
</div>
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search comments..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Comment Input */}
<RequireAuth>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<ChatsCircle className="h-5 w-5 text-blue-600" />
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"
/>
) : (
<UserCircle className="h-5 w-5" />
)}
</div>
</div>
<div className="flex-1 space-y-3">
<form onSubmit={(e) => e.preventDefault()} role="form" aria-label="Add a comment">
<textarea
ref={textareaRef}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
handleAddComment();
}
}}
placeholder="Share your thoughts on this case... (Ctrl+Enter to submit)"
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}
aria-label="Comment input"
/>
</form>
{/* Attachments */}
{allowAttachments && (
<div className="space-y-2">
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileUpload}
className="hidden"
accept="image/*,.pdf,.txt"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1 px-3 py-1 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
>
<Paperclip className="h-4 w-4" />
Attach files
</button>
<span className="text-xs text-gray-500">Max 5MB per file</span>
</div>
{attachments.length > 0 && (
<div className="space-y-2">
{attachments.map((file, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-gray-50 rounded-lg">
<Paperclip className="h-4 w-4 text-gray-500" />
<span className="text-sm text-gray-700 flex-1">{file.name}</span>
<span className="text-xs text-gray-500">
({(file.size / 1024 / 1024).toFixed(2)} MB)
</span>
<button
type="button"
onClick={() => removeAttachment(index)}
className="text-red-500 hover:text-red-700"
>
<XCircle className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
)}
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">
{newComment.length}/1000 characters
</span>
<button
type="button"
onClick={handleAddComment}
disabled={!newComment.trim() || isSubmitting}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Posting...
</>
) : (
<>
<PaperPlaneRight className="h-4 w-4" />
Post Comment
</>
)}
</button>
</div>
</div>
</div>
</div>
</RequireAuth>
{/* Comments List */}
<div className="space-y-4">
{loading ? (
<div className="text-center py-12">
<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>
) : sortedComments.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<ChatsCircle 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">
{sortedComments.map((comment) => (
<MemoizedCommentCard
key={comment.id}
comment={comment}
depth={0}
onReply={(id) => {
setReplyingTo(id);
setReplyContent({ [id]: '' });
}}
onEdit={(id) => {
setEditingComment(id);
setEditContent(comment.content);
}}
onDelete={(id) => {
if (confirm('Are you sure you want to delete this comment?')) {
handleDeleteComment(id);
}
}}
onReact={handleReactionClick}
isReplying={replyingTo === comment.id}
isEditing={editingComment === comment.id}
/>
))}
{hasMore && !loading && (
<div className="text-center mt-4">
<button
type="button"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
onClick={handleLoadMore}
disabled={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load More'}
</button>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default EnhancedComments;