385 lines
13 KiB
TypeScript
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;
|