Assir_ASM_ui_app/asm_app/src/components/CommentSection.tsx

385 lines
13 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import {
FaComments,
FaUser,
FaTrash,
FaClock,
FaSpinner,
FaSync,
FaChevronDown,
FaChevronUp,
FaExclamationTriangle,
FaCheckCircle,
FaTimesCircle,
FaInfoCircle,
FaPaperclip,
FaThumbsUp,
FaEdit,
} from 'react-icons/fa';
import { toast } from 'react-toastify';
import { useComments } from '../hooks/useComments';
import MentionInput from './MentionInput';
interface CommentSectionProps {
referenceDoctype: string;
referenceName: string | null;
title?: string;
pollInterval?: number;
initialLimit?: number;
collapsible?: boolean;
startCollapsed?: boolean;
}
const commentTypeMeta: Record<
string,
{ icon: React.ReactNode; color: string; label: string }
> = {
Comment: {
icon: <FaComments size={10} />,
color: 'text-blue-600 dark:text-blue-400',
label: 'Comment',
},
Info: {
icon: <FaInfoCircle size={10} />,
color: 'text-gray-500 dark:text-gray-400',
label: 'Info',
},
Edit: {
icon: <FaEdit size={10} />,
color: 'text-orange-500 dark:text-orange-400',
label: 'Edit',
},
Attachment: {
icon: <FaPaperclip size={10} />,
color: 'text-purple-500 dark:text-purple-400',
label: 'Attachment',
},
Like: {
icon: <FaThumbsUp size={10} />,
color: 'text-pink-500 dark:text-pink-400',
label: 'Like',
},
};
const timeAgo = (dateStr: string): string => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const mins = Math.floor(diffMs / 60000);
const hrs = Math.floor(diffMs / 3600000);
const days = Math.floor(diffMs / 86400000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
if (hrs < 24) return `${hrs}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
};
const emailToName = (email: string): string => {
if (!email) return 'Unknown';
const at = email.indexOf('@');
if (at === -1) return email;
return email
.substring(0, at)
.replace(/[._-]/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
};
const CommentContent: React.FC<{ html: string }> = ({ html }) => {
const cleaned = html
.replace(/<div class="ql-editor[^"]*">/g, '')
.replace(/<\/div>$/g, '');
return (
<div
className="comment-content text-sm text-gray-800 dark:text-gray-200 leading-relaxed
[&_a]:text-teal-600 [&_a]:dark:text-teal-400 [&_a]:underline [&_a]:font-medium
[&_.mention]:text-teal-700 [&_.mention]:dark:text-teal-300 [&_.mention]:font-semibold
[&_.mention]:bg-teal-50 [&_.mention]:dark:bg-teal-900/30 [&_.mention]:px-1 [&_.mention]:py-0.5
[&_.mention]:rounded [&_.mention]:pointer-events-none [&_.mention]:cursor-default
[&_.mention_a]:no-underline [&_.mention_a]:text-inherit
[&_p]:my-0"
dangerouslySetInnerHTML={{ __html: cleaned }}
/>
);
};
const CommentSection: React.FC<CommentSectionProps> = ({
referenceDoctype,
referenceName,
title = 'Comments & Discussion',
pollInterval = 30000,
initialLimit = 5,
collapsible = true,
startCollapsed = false,
}) => {
const {
comments,
loading,
posting,
error,
currentUser,
refetch,
postComment,
deleteComment,
mentionUsers,
mentionLoading,
searchMentionUsers,
} = useComments({
referenceDoctype,
referenceName,
pollInterval,
});
const [expanded, setExpanded] = useState(!startCollapsed);
const [showAll, setShowAll] = useState(false);
const [draftText, setDraftText] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const userComments = useMemo(
() => comments.filter((c) => c.comment_type === 'Comment'),
[comments]
);
const visibleComments = useMemo(() => {
if (showAll) return comments;
return comments.slice(-initialLimit);
}, [comments, showAll, initialLimit]);
const handlePost = async (html: string) => {
try {
await postComment(html);
setDraftText('');
toast.success('Comment posted!', {
position: 'top-right',
autoClose: 2000,
icon: <FaCheckCircle />,
});
} catch (err: any) {
toast.error(`Failed to post comment: ${err.message || 'Unknown error'}`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
}
};
const handleDelete = async (commentName: string) => {
setDeletingId(commentName);
try {
await deleteComment(commentName);
toast.success('Comment deleted', {
position: 'top-right',
autoClose: 2000,
icon: <FaCheckCircle />,
});
} catch (err: any) {
toast.error(`Failed to delete: ${err.message || 'Unknown error'}`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
} finally {
setDeletingId(null);
}
};
if (!referenceName) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<FaComments className="text-gray-400" />
<span className="text-sm">Save the document first to enable comments.</span>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
<div
className={`flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700 ${
collapsible ? 'cursor-pointer select-none' : ''
}`}
onClick={() => collapsible && setExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<FaComments className="text-teal-500" size={16} />
<h2 className="text-base font-semibold text-gray-800 dark:text-white">
{title}
</h2>
{userComments.length > 0 && (
<span className="px-2 py-0.5 bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 rounded-full text-xs font-medium">
{userComments.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
refetch();
}}
disabled={loading}
className="p-1.5 text-gray-400 hover:text-teal-500 hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded transition-colors disabled:opacity-50"
title="Refresh comments"
>
<FaSync className={loading ? 'animate-spin' : ''} size={11} />
</button>
{collapsible && (
<span className="text-gray-400 dark:text-gray-500">
{expanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
</span>
)}
</div>
</div>
{expanded && (
<div className="p-5 space-y-5">
{error && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-sm bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<FaExclamationTriangle size={12} />
{error}
</div>
)}
{loading && comments.length === 0 && (
<div className="flex items-center justify-center py-8">
<FaSpinner className="animate-spin text-teal-500 mr-2" size={16} />
<span className="text-sm text-gray-500 dark:text-gray-400">Loading comments…</span>
</div>
)}
{comments.length > 0 && (
<>
{!showAll && comments.length > initialLimit && (
<button
type="button"
onClick={() => setShowAll(true)}
className="w-full text-center py-2 text-xs text-teal-600 dark:text-teal-400
hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors font-medium"
>
Show {comments.length - initialLimit} older comment{comments.length - initialLimit !== 1 ? 's' : ''}
</button>
)}
<div className="space-y-3">
{visibleComments.map((comment) => {
const meta = commentTypeMeta[comment.comment_type] || commentTypeMeta.Comment;
const isOwn = comment.comment_email === currentUser || comment.owner === currentUser;
const isDeleting = deletingId === comment.name;
return (
<div
key={comment.name}
className={`group relative rounded-lg border transition-colors ${
comment.comment_type === 'Comment'
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-100 dark:border-gray-700/50'
}`}
>
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-teal-100 dark:bg-teal-900/40 flex items-center justify-center flex-shrink-0">
<FaUser className="text-teal-600 dark:text-teal-400" size={10} />
</div>
<div className="min-w-0">
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{comment.comment_by || emailToName(comment.comment_email || comment.owner)}
</span>
{comment.comment_type !== 'Comment' && (
<span className={`ml-2 inline-flex items-center gap-1 text-[10px] font-medium ${meta.color}`}>
{meta.icon}
{meta.label}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span
className="text-[11px] text-gray-400 dark:text-gray-500 flex items-center gap-1"
title={new Date(comment.creation).toLocaleString()}
>
<FaClock size={9} />
{timeAgo(comment.creation)}
</span>
{isOwn && comment.comment_type === 'Comment' && (
<button
type="button"
onClick={() => handleDelete(comment.name)}
disabled={isDeleting}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500
hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-all disabled:opacity-50"
title="Delete comment"
>
{isDeleting ? (
<FaSpinner className="animate-spin" size={10} />
) : (
<FaTrash size={10} />
)}
</button>
)}
</div>
</div>
<div className="ml-9">
<CommentContent html={comment.content} />
</div>
</div>
</div>
);
})}
</div>
{showAll && comments.length > initialLimit && (
<button
type="button"
onClick={() => setShowAll(false)}
className="w-full text-center py-2 text-xs text-teal-600 dark:text-teal-400
hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors font-medium"
>
Show fewer comments
</button>
)}
</>
)}
{!loading && comments.length === 0 && (
<div className="text-center py-8">
<FaComments className="mx-auto text-gray-300 dark:text-gray-600 mb-2" size={28} />
<p className="text-sm text-gray-500 dark:text-gray-400">
No comments yet. Start the discussion!
</p>
</div>
)}
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<MentionInput
value={draftText}
onChange={setDraftText}
onSubmit={handlePost}
disabled={!referenceName}
posting={posting}
mentionUsers={mentionUsers}
mentionLoading={mentionLoading}
onMentionSearch={searchMentionUsers}
/>
</div>
</div>
)}
</div>
);
};
export default CommentSection;