![]() 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 } from 'react';
import {
Box,
Flex,
Badge,
Button,
Textarea,
Text,
Input,
Spinner,
Skeleton,
Alert,
} from '@chakra-ui/react';
import { FaReply, FaEdit, FaTrash, FaThumbsUp, FaFlag, FaPaperclip, FaSync } from 'react-icons/fa';
import { useSession } from 'next-auth/react';
import { useWebSocket } from '../context/StableWebSocketContext';
const roleColors: Record<string, string> = {
SUPERADMIN: 'purple',
MODERATOR: 'blue',
USER: 'gray',
};
const ALLOWED_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain'
];
const MAX_FILES = 3;
const MAX_SIZE = 5 * 1024 * 1024;
function CommentCard({ comment, isReply = false, onReply, replyingId, onSubmitReply, replyValue, onReplyChange, isReplying, isLoggedIn, postingReply, onDelete, deletingId, onLike, likingId, currentUser, replyFileInputRef, replyFiles, replyFileError, handleReplyFileChange, handleRemoveReplyFile }: any) {
const isAuthor = currentUser && comment.user?.id === currentUser.id;
const isAdmin = currentUser && (currentUser.role === 'SUPERADMIN' || currentUser.role === 'ADMIN');
const canDelete = isAuthor || isAdmin;
const hasLiked = comment.reactions?.some((r: any) => r.user?.id === currentUser?.id);
const isDeleting = deletingId === comment.id;
const isLiking = likingId === comment.id;
return (
<Box
bg="white"
borderRadius="lg"
boxShadow="md"
p={4}
mt={isReply ? 2 : 4}
ml={isReply ? 8 : 0}
w="full"
>
<Flex align="center" mb={2}>
<Box mr={3} w="32px" h="32px" borderRadius="full" bg="gray.200" display="flex" alignItems="center" justifyContent="center" overflow="hidden">
{comment.user?.profilePicture ? (
<img src={comment.user.profilePicture} alt={comment.user.name} style={{ width: 32, height: 32, objectFit: 'cover' }} />
) : (
<Text fontSize="md" fontWeight="bold" color="gray.600">{comment.user?.name?.split(' ').map((n: string) => n[0]).join('').slice(0,2) || '?'}</Text>
)}
</Box>
<Text fontWeight="bold" mr={2}>{comment.user?.name}</Text>
{comment.user?.role && (
<Badge colorScheme={roleColors[comment.user.role] || 'gray'} mr={2}>
{comment.user.role}
</Badge>
)}
</Flex>
<Text mb={2}>{comment.content}</Text>
{/* Attachments display */}
{comment.attachments && comment.attachments.length > 0 && (
<Box mt={2}>
<Text fontSize="xs" color="gray.500" mb={1}>Attachments:</Text>
<Flex gap={2} wrap="wrap">
{comment.attachments.map((att: any) => (
<Box key={att.id} p={1} borderWidth={1} borderRadius="md" bg="gray.50">
{att.type.startsWith('image/') ? (
<a href={att.url} target="_blank" rel="noopener noreferrer">
<img src={att.url} alt={att.name} style={{ maxWidth: 80, maxHeight: 80, borderRadius: 4 }} />
</a>
) : (
<a href={att.url} target="_blank" rel="noopener noreferrer">
<Text fontSize="xs">{att.name}</Text>
</a>
)}
</Box>
))}
</Flex>
</Box>
)}
<Flex gap="8px" align="center" mt={3}>
<Button
size="sm"
variant="ghost"
onClick={() => onReply(comment.id)}
disabled={!isLoggedIn}
>
<FaReply style={{marginRight: 8}} />
Reply
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onLike(comment.id)}
disabled={!isLoggedIn || isLiking}
>
{isLiking ? <Spinner size="xs" /> : <FaThumbsUp style={{marginRight: 8}} />}
{comment.likes || 0}
</Button>
{canDelete && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(comment.id)}
disabled={isDeleting}
>
{isDeleting ? <Spinner size="xs" /> : <FaTrash style={{marginRight: 8}} />}
Delete
</Button>
)}
<Text fontSize="xs" color="gray.400" ml="auto">
{new Date(comment.createdAt).toLocaleDateString()} at {new Date(comment.createdAt).toLocaleTimeString()}
</Text>
</Flex>
{/* Reply box */}
{replyingId === comment.id && (
<Box mt={3} w="full">
<Textarea
placeholder="Write a reply... (Ctrl+Enter to submit)"
value={replyValue}
onChange={e => onReplyChange(e.target.value)}
minH="40px"
maxLength={1000}
disabled={postingReply}
onKeyDown={e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
onSubmitReply(comment.id);
}
}}
/>
{/* Reply file picker and preview */}
<Flex align="center" mt={2} mb={2} gap={2}>
<Button
size="sm"
variant="ghost"
onClick={() => replyFileInputRef.current?.click()}
disabled={replyFiles.length >= MAX_FILES || postingReply}
>
<FaPaperclip style={{marginRight: 8}} />
Attach files
</Button>
<input
type="file"
multiple
accept={ALLOWED_TYPES.join(',')}
style={{ display: 'none' }}
ref={replyFileInputRef}
onChange={handleReplyFileChange}
disabled={postingReply}
/>
<Text fontSize="xs" color="gray.500">Max 5MB per file, {MAX_FILES} files</Text>
</Flex>
{replyFileError && <Text color="red.500" fontSize="sm">{replyFileError}</Text>}
<Flex gap={2} wrap="wrap" mb={2}>
{replyFiles.map((file: File, idx: number) => (
<Box key={idx} p={1} borderWidth={1} borderRadius="md" bg="gray.50">
<Text fontSize="xs">{file.name}</Text>
<Button size="xs" colorScheme="red" variant="ghost" onClick={() => handleRemoveReplyFile(idx)}>Remove</Button>
</Box>
))}
</Flex>
<Flex mt={2} justify="flex-end" gap={2}>
<Button size="sm" onClick={() => onSubmitReply(comment.id)} colorScheme="blue" loading={postingReply} disabled={!replyValue.trim() || postingReply}>
Reply
</Button>
<Button size="sm" variant="ghost" onClick={() => onReply(null)} disabled={postingReply}>Cancel</Button>
</Flex>
</Box>
)}
{comment.replies && comment.replies.length > 0 && (
<Flex direction="column" gap="8px" align="start" mt={2}>
{comment.replies.map((reply: any) => (
<CommentCard
key={reply.id}
comment={reply}
isReply
onReply={onReply}
replyingId={replyingId}
onSubmitReply={onSubmitReply}
replyValue={replyValue}
onReplyChange={onReplyChange}
isReplying={isReplying}
isLoggedIn={isLoggedIn}
postingReply={postingReply}
onDelete={onDelete}
deletingId={deletingId}
onLike={onLike}
likingId={likingId}
currentUser={currentUser}
replyFileInputRef={replyFileInputRef}
replyFiles={replyFiles}
replyFileError={replyFileError}
handleReplyFileChange={handleReplyFileChange}
handleRemoveReplyFile={handleRemoveReplyFile}
/>
))}
</Flex>
)}
</Box>
);
}
const CommentsSection = ({ caseId, apiEndpoint }: { caseId: string; apiEndpoint?: string }) => {
const { data: session, status } = useSession();
const { ws } = useWebSocket();
const [comment, setComment] = useState('');
const [comments, setComments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [posting, setPosting] = useState(false);
const [postError, setPostError] = useState<string | null>(null);
const [replyingId, setReplyingId] = useState<string | null>(null);
const [replyValue, setReplyValue] = useState('');
const [postingReply, setPostingReply] = useState(false);
const [replyError, setReplyError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [likingId, setLikingId] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [fileError, setFileError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [replyFiles, setReplyFiles] = useState<File[]>([]);
const [replyFileError, setReplyFileError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const replyFileInputRef = useRef<HTMLInputElement>(null);
const fetchComments = () => {
if (!caseId) return;
setLoading(true);
setError(null);
const endpoint = apiEndpoint || `/api/live-cases/${caseId}/comments`;
fetch(endpoint)
.then(res => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then(data => {
setComments(data.comments || []);
setLoading(false);
setLastUpdate(new Date());
})
.catch((err) => {
console.error('Error fetching comments:', err);
setError('Failed to load comments. Please try again.');
setLoading(false);
});
};
useEffect(() => {
fetchComments();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caseId]);
// WebSocket event listeners for real-time updates
useEffect(() => {
if (!ws || !caseId) return;
const handleNewComment = (data: any) => {
if (data.caseId === caseId) {
fetchComments();
}
};
const handleCommentUpdate = (data: any) => {
if (data.caseId === caseId) {
fetchComments();
}
};
// Native WebSocket event handling
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'comment:new') handleNewComment(data);
if (data.type === 'comment:updated') handleCommentUpdate(data);
if (data.type === 'comment:deleted') handleCommentUpdate(data);
} catch (e) {
// Ignore invalid JSON
}
};
return () => {
ws.onmessage = null;
};
}, [ws, caseId]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFileError(null);
const selected = Array.from(e.target.files || []);
if (selected.length + files.length > MAX_FILES) {
setFileError(`Max ${MAX_FILES} files allowed.`);
return;
}
for (const file of selected) {
if (!ALLOWED_TYPES.includes(file.type)) {
setFileError('Unsupported file type.');
return;
}
if (file.size > MAX_SIZE) {
setFileError('File too large (max 5MB each).');
return;
}
}
setFiles([...files, ...selected]);
};
const handleRemoveFile = (idx: number) => {
setFiles(files.filter((_, i) => i !== idx));
};
const handleReplyFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setReplyFileError(null);
const selected = Array.from(e.target.files || []);
if (selected.length + replyFiles.length > MAX_FILES) {
setReplyFileError(`Max ${MAX_FILES} files allowed.`);
return;
}
for (const file of selected) {
if (!ALLOWED_TYPES.includes(file.type)) {
setReplyFileError('Unsupported file type.');
return;
}
if (file.size > MAX_SIZE) {
setReplyFileError('File too large (max 5MB each).');
return;
}
}
setReplyFiles([...replyFiles, ...selected]);
};
const handleRemoveReplyFile = (idx: number) => {
setReplyFiles(replyFiles.filter((_, i) => i !== idx));
};
const handlePost = async () => {
if (!comment.trim()) return;
setPosting(true);
setPostError(null);
try {
const endpoint = apiEndpoint || `/api/live-cases/${caseId}/comments`;
let res;
if (files.length > 0) {
const formData = new FormData();
formData.append('content', comment);
files.forEach(f => formData.append('attachments', f));
res = await fetch(endpoint, {
method: 'POST',
body: formData,
});
} else {
res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: comment }),
});
}
if (res.ok) {
setComment('');
setFiles([]);
setSuccessMessage('Comment posted successfully!');
setPostError(null);
setTimeout(() => setSuccessMessage(null), 3000);
fetchComments();
} else {
const data = await res.json();
const errorMessage = data.message || 'Failed to post comment';
setPostError(errorMessage);
setSuccessMessage(null);
}
} catch (e) {
const errorMessage = 'Failed to post comment. Please check your connection.';
setPostError(errorMessage);
setSuccessMessage(null);
}
setPosting(false);
};
const handleReply = (id: string | null) => {
setReplyingId(id);
setReplyValue('');
setReplyError(null);
};
const handleSubmitReply = async (parentId: string) => {
if (!replyValue.trim()) return;
setPostingReply(true);
setReplyError(null);
try {
const endpoint = apiEndpoint || `/api/live-cases/${caseId}/comments`;
let res;
if (replyFiles.length > 0) {
const formData = new FormData();
formData.append('content', replyValue);
formData.append('parentId', parentId);
replyFiles.forEach(f => formData.append('attachments', f));
res = await fetch(endpoint, {
method: 'POST',
body: formData,
});
} else {
res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: replyValue, parentId }),
});
}
if (res.ok) {
setReplyingId(null);
setReplyValue('');
setReplyFiles([]);
fetchComments();
} else {
const data = await res.json();
setReplyError(data.message || 'Failed to post reply');
}
} catch (e) {
setReplyError('Failed to post reply');
}
setPostingReply(false);
};
const handleDelete = async (commentId: string) => {
if (!window.confirm('Are you sure you want to delete this comment and all its replies?')) return;
setDeletingId(commentId);
try {
const endpoint = apiEndpoint || `/api/live-cases/${caseId}/comments`;
const res = await fetch(`${endpoint}?commentId=${commentId}`, {
method: 'DELETE',
});
if (res.ok) {
fetchComments();
} else {
alert('Failed to delete comment.');
}
} catch {
alert('Failed to delete comment.');
}
setDeletingId(null);
};
const handleLike = async (commentId: string) => {
setLikingId(commentId);
try {
const endpoint = apiEndpoint || `/api/live-cases/${caseId}/comments`;
await fetch(`${endpoint}/${commentId}/like`, {
method: 'POST',
});
fetchComments();
} catch {}
setLikingId(null);
};
const isLoggedIn = status === 'authenticated';
const currentUser = session?.user;
return (
<Box maxW="2xl" mx="auto" mt={8}>
{/* Header with refresh button */}
<Flex justify="space-between" align="center" mb={4}>
<Text fontSize="lg" fontWeight="semibold">Comments & Discussion</Text>
<Button
size="sm"
variant="ghost"
onClick={fetchComments}
disabled={loading}
>
<FaSync style={{marginRight: 8}} />
Refresh
</Button>
</Flex>
{/* Success Alert */}
{successMessage && (
<Box bg="green.100" border="1px solid" borderColor="green.300" color="green.800" mb={4} borderRadius="md" p={3}>
<Text>{successMessage}</Text>
</Box>
)}
{/* Error Alert */}
{error && (
<Box bg="red.100" border="1px solid" borderColor="red.300" color="red.800" mb={4} borderRadius="md" p={3}>
<Box flex="1">
<Text fontWeight="medium">Failed to load comments</Text>
</Box>
</Box>
)}
{/* Post Comment Box */}
<Box
bg="white"
borderRadius="lg"
boxShadow="md"
p={4}
mb={6}
>
<Flex align="flex-start">
<Box mr={4} w="40px" h="40px" borderRadius="full" bg="gray.200" display="flex" alignItems="center" justifyContent="center" overflow="hidden">
{session?.user?.image ? (
<img src={session.user.image} alt={session.user.name} style={{ width: 40, height: 40, objectFit: 'cover' }} />
) : (
<Text fontSize="lg" fontWeight="bold" color="gray.600">{session?.user?.name?.split(' ').map((n: string) => n[0]).join('').slice(0,2) || '?'}</Text>
)}
</Box>
<Box flex="1">
<Textarea
placeholder={isLoggedIn ? "Share your thoughts on this case... (Ctrl+Enter to submit)" : "Please sign in to comment."}
value={comment}
onChange={e => setComment(e.target.value)}
resize="vertical"
mb={2}
minH="60px"
maxLength={1000}
disabled={!isLoggedIn || posting}
onKeyDown={e => {
if (isLoggedIn && (e.ctrlKey || e.metaKey) && e.key === 'Enter') {
handlePost();
}
}}
/>
{/* File picker and preview */}
<Flex align="center" mt={2} mb={2} gap={2}>
<Button
size="sm"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={files.length >= MAX_FILES || posting}
>
<FaPaperclip style={{marginRight: 8}} />
Attach files
</Button>
<input
type="file"
multiple
accept={ALLOWED_TYPES.join(',')}
style={{ display: 'none' }}
ref={fileInputRef}
onChange={handleFileChange}
disabled={posting}
/>
<Text fontSize="xs" color="gray.500">Max 5MB per file, {MAX_FILES} files</Text>
</Flex>
{fileError && <Text color="red.500" fontSize="sm">{fileError}</Text>}
<Flex gap={2} wrap="wrap" mb={2}>
{files.map((file, idx) => (
<Box key={idx} p={1} borderWidth={1} borderRadius="md" bg="gray.50">
<Text fontSize="xs">{file.name}</Text>
<Button size="xs" colorScheme="red" variant="ghost" onClick={() => handleRemoveFile(idx)}>Remove</Button>
</Box>
))}
</Flex>
<Flex justify="space-between" align="center">
<Button colorScheme="blue" px={6} onClick={handlePost} loading={posting} disabled={!isLoggedIn || !comment.trim() || posting}>
Post Comment
</Button>
</Flex>
<Text fontSize="xs" color="gray.400" mt={1}>
{comment.length}/1000 characters
</Text>
{postError && <Text color="red.500" fontSize="sm" mt={1}>{postError}</Text>}
{!isLoggedIn && <Text color="gray.500" fontSize="sm" mt={1}>You must be signed in to post a comment.</Text>}
</Box>
</Flex>
</Box>
{/* Comments List */}
{loading ? (
<Flex direction="column" gap="16px" align="stretch">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} height="120px" borderRadius="lg" />
))}
</Flex>
) : (
<Flex direction="column" gap="16px" align="stretch">
{comments.length === 0 ? (
<Box textAlign="center" py={8} bg="gray.50" borderRadius="lg">
<Text color="gray.500" fontSize="lg" mb={2}>No comments yet</Text>
<Text color="gray.400" fontSize="sm">Be the first to share your thoughts on this case!</Text>
</Box>
) : (
<>
<Text fontSize="sm" color="gray.500" mb={2}>
{comments.length} comment{comments.length !== 1 ? 's' : ''} • Last updated {lastUpdate.toLocaleTimeString()}
</Text>
{comments.map(comment => (
<CommentCard
key={comment.id}
comment={comment}
onReply={handleReply}
replyingId={replyingId}
onSubmitReply={handleSubmitReply}
replyValue={replyValue}
onReplyChange={setReplyValue}
isReplying={replyingId === comment.id}
isLoggedIn={isLoggedIn}
postingReply={postingReply}
onDelete={handleDelete}
deletingId={deletingId}
onLike={handleLike}
likingId={likingId}
currentUser={currentUser}
replyFileInputRef={replyFileInputRef}
replyFiles={replyFiles}
replyFileError={replyFileError}
handleReplyFileChange={handleReplyFileChange}
handleRemoveReplyFile={handleRemoveReplyFile}
/>
))}
</>
)}
</Flex>
)}
{replyError && (
<Box bg="red.100" border="1px solid" borderColor="red.300" color="red.800" mt={4} borderRadius="md" p={3}>
<Text fontSize="sm">{replyError}</Text>
</Box>
)}
</Box>
);
};
export default CommentsSection;