forked from UKSOURCE/hailearning.edu.vn
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import type { BlogComment } from "@/types/blog";
|
|
import { useMemo, useState } from "react";
|
|
import CommentForm from "./CommentForm";
|
|
import { formatLongDate } from "@/utils";
|
|
|
|
interface CommentsSectionProps {
|
|
slug: string;
|
|
comments: BlogComment[];
|
|
}
|
|
|
|
export default function CommentsSection({ slug, comments }: CommentsSectionProps) {
|
|
const [showAllComments, setShowAllComments] = useState(false);
|
|
const [expandedCommentKeys, setExpandedCommentKeys] = useState<Set<string>>(
|
|
() => new Set()
|
|
);
|
|
const [replyTarget, setReplyTarget] = useState<{
|
|
// ID của comment gốc để lưu làm parentId (giữ thread 1 cấp trên backend)
|
|
parentId: string;
|
|
// Tên tác giả mà chúng ta đang reply (dùng cho UI @mention)
|
|
replyToName: string;
|
|
// ID của item trong UI đang hiển thị form reply bên dưới
|
|
anchorId: string;
|
|
} | null>(null);
|
|
|
|
// Helper function để lấy chữ cái đầu của tên
|
|
const getInitials = (name?: string): string => {
|
|
if (!name) return "?";
|
|
const trimmed = name.trim();
|
|
if (trimmed.length === 0) return "?";
|
|
return trimmed.charAt(0).toUpperCase();
|
|
};
|
|
|
|
const makeCommentKey = (prefix: "p" | "r", id: string | undefined, index: number) =>
|
|
id ? `${prefix}:${id}` : `${prefix}:idx:${index}`;
|
|
|
|
const isExpanded = (key: string) => expandedCommentKeys.has(key);
|
|
|
|
const toggleExpanded = (key: string) => {
|
|
setExpandedCommentKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const { parents, repliesByParent } = useMemo(() => {
|
|
const parentsLocal = (comments || []).filter((c) => !c.parentId);
|
|
const replies = (comments || []).filter((c) => !!c.parentId);
|
|
const map = new Map<string, BlogComment[]>();
|
|
for (const r of replies) {
|
|
const pid = r.parentId as string;
|
|
map.set(pid, [...(map.get(pid) || []), r]);
|
|
}
|
|
return { parents: parentsLocal, repliesByParent: map };
|
|
}, [comments]);
|
|
// Thu thập tất cả tên tác giả để phát hiện @mention đầy đủ (bao gồm tên nhiều từ)
|
|
const authorNames = useMemo(() => {
|
|
return (comments || [])
|
|
.map((c) => c.authorName)
|
|
.filter((n): n is string => !!n)
|
|
// Sắp xếp theo độ dài giảm dần để khớp tên dài nhất trước
|
|
.sort((a, b) => b.length - a.length);
|
|
}, [comments]);
|
|
|
|
const renderContentWithMention = (text?: string) => {
|
|
if (!text) return null;
|
|
if (!text.startsWith("@")) return text;
|
|
|
|
// Thử khớp @mention với tên tác giả đã biết
|
|
// Hỗ trợ tên nhiều từ như "@Bạn Cũng Thấy Thế Hà"
|
|
const matchedName = authorNames.find((name) => {
|
|
const candidate = `@${name}`;
|
|
if (!text.startsWith(candidate)) return false;
|
|
|
|
// Đảm bảo khớp kết thúc ở cuối chuỗi hoặc theo sau bởi khoảng trắng
|
|
const nextChar = text.charAt(candidate.length);
|
|
return candidate.length === text.length || nextChar === " ";
|
|
});
|
|
|
|
if (!matchedName) {
|
|
// Fallback: trả về text gốc nếu không thể khớp tên một cách chắc chắn
|
|
return text;
|
|
}
|
|
|
|
const mention = `@${matchedName}`;
|
|
const rest = text.slice(mention.length); // bao gồm khoảng trắng đầu nếu có
|
|
|
|
return (
|
|
<>
|
|
<strong>{mention}</strong>
|
|
{rest}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const splitWords = (s: string) =>
|
|
s
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
|
|
const renderTruncatedContent = (text: string | undefined, key: string) => {
|
|
if (!text) return null;
|
|
|
|
const maxWords = 35;
|
|
const words = splitWords(text);
|
|
const needsTruncate = words.length > maxWords;
|
|
const expanded = isExpanded(key);
|
|
|
|
const displayText =
|
|
!needsTruncate || expanded ? text : `${words.slice(0, maxWords).join(" ")}...`;
|
|
|
|
return (
|
|
<>
|
|
{renderContentWithMention(displayText)}
|
|
{needsTruncate && (
|
|
<>
|
|
{" "}
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleExpanded(key)}
|
|
style={{
|
|
border: "none",
|
|
background: "transparent",
|
|
padding: 0,
|
|
color: "var(--primary-color, #0d6efd)",
|
|
cursor: "pointer",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{expanded ? "less" : "more"}
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
// Tính toán các parent comment cần hiển thị (bao gồm children) để hiển thị tối đa 5 comments tổng cộng
|
|
const getDisplayedParents = () => {
|
|
if (showAllComments) return { parents: parents, repliesMap: new Map() };
|
|
|
|
let totalCount = 0;
|
|
const result: typeof parents = [];
|
|
const limitedRepliesMap = new Map<string, BlogComment[]>();
|
|
|
|
for (const parent of parents) {
|
|
const allReplies = parent._id ? repliesByParent.get(parent._id) || [] : [];
|
|
const remaining = 5 - totalCount;
|
|
|
|
if (remaining <= 0) break;
|
|
|
|
// Tính toán số lượng replies có thể hiển thị
|
|
const repliesToShow = Math.min(allReplies.length, remaining - 1); // -1 cho chính parent comment
|
|
|
|
if (repliesToShow >= 0) {
|
|
result.push(parent);
|
|
if (parent._id && repliesToShow > 0) {
|
|
limitedRepliesMap.set(parent._id, allReplies.slice(0, repliesToShow));
|
|
}
|
|
totalCount += 1 + repliesToShow;
|
|
} else {
|
|
// Không thể hiển thị cả parent, dừng lại
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { parents: result, repliesMap: limitedRepliesMap };
|
|
};
|
|
|
|
const { parents: displayedParents, repliesMap: displayedRepliesMap } = getDisplayedParents();
|
|
|
|
// Tính số lượng comments hiển thị ban đầu (khi thu gọn, tối đa 5)
|
|
const initialDisplayCount = displayedParents.reduce((sum, parent) => {
|
|
// Khi thu gọn, sử dụng replies giới hạn từ displayedRepliesMap
|
|
const replies = parent._id ? (displayedRepliesMap.get(parent._id) || []) : [];
|
|
return sum + 1 + replies.length;
|
|
}, 0);
|
|
|
|
const hasMoreComments = (comments || []).length > initialDisplayCount;
|
|
|
|
return (
|
|
<div className="comments-area">
|
|
<div className="comments-heading">
|
|
<h3>
|
|
{(comments || []).length} {(comments || []).length === 1 ? "Comment" : "Comments"}
|
|
</h3>
|
|
</div>
|
|
|
|
{displayedParents.map((comment, index) => {
|
|
// Khi showAllComments là false, chỉ sử dụng replies giới hạn từ displayedRepliesMap
|
|
// Khi showAllComments là true, sử dụng tất cả replies từ repliesByParent
|
|
const replies = comment._id
|
|
? (showAllComments
|
|
? (repliesByParent.get(comment._id) || [])
|
|
: (displayedRepliesMap.get(comment._id) || []))
|
|
: [];
|
|
const isReplyingHere = !!comment._id && replyTarget?.anchorId === comment._id;
|
|
|
|
return (
|
|
<div key={comment._id || index}>
|
|
<div
|
|
className="news-single-comment d-flex pt-4 pb-0 position-relative"
|
|
>
|
|
<div className="image">
|
|
<div className="comment-avatar-box">
|
|
{getInitials(comment.authorName)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="content">
|
|
<div className="head d-flex gap-2 gap-md-2 align-items-center">
|
|
<div className="con">
|
|
<span>{formatLongDate(comment.createdAt)}</span>
|
|
<h4>{comment.authorName}</h4>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="mt-30">
|
|
{renderTruncatedContent(
|
|
comment.content,
|
|
makeCommentKey("p", comment._id, index)
|
|
)}
|
|
</p>
|
|
|
|
{comment._id && (
|
|
<button
|
|
type="button"
|
|
className="reply reply-absolute"
|
|
onClick={() =>
|
|
setReplyTarget({
|
|
parentId: comment._id!,
|
|
replyToName: comment.authorName,
|
|
anchorId: comment._id!,
|
|
})
|
|
}
|
|
>
|
|
Reply
|
|
</button>
|
|
)}
|
|
|
|
{isReplyingHere && comment._id && (
|
|
<div className="mb-4">
|
|
<div className="d-flex justify-content-end mb-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() => setReplyTarget(null)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<CommentForm
|
|
slug={slug}
|
|
parentId={replyTarget?.parentId || comment._id}
|
|
replyToName={replyTarget?.replyToName || comment.authorName}
|
|
initialContent={`@${replyTarget?.replyToName || comment.authorName} `}
|
|
onSubmitted={() => setReplyTarget(null)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Các reply */}
|
|
{replies.length > 0 && (
|
|
<div className="replies">
|
|
{replies.map((reply: BlogComment, replyIndex: number) => (
|
|
<div key={reply._id || replyIndex}>
|
|
<div
|
|
className="news-single-comment d-flex pt-4 pb-0 position-relative"
|
|
>
|
|
<div className="image">
|
|
<div className="comment-avatar-box">
|
|
{getInitials(reply.authorName)}
|
|
</div>
|
|
</div>
|
|
<div className="content">
|
|
<div className="head d-flex gap-2 gap-md-2 align-items-center">
|
|
<div className="con">
|
|
<span>{formatLongDate(reply.createdAt)}</span>
|
|
<h4>{reply.authorName}</h4>
|
|
</div>
|
|
</div>
|
|
<p className="mt-30">
|
|
{renderTruncatedContent(
|
|
reply.content,
|
|
makeCommentKey("r", reply._id, replyIndex)
|
|
)}
|
|
</p>
|
|
|
|
{reply._id && comment._id && (
|
|
<button
|
|
type="button"
|
|
className="reply reply-absolute"
|
|
onClick={() =>
|
|
setReplyTarget({
|
|
// Giữ parentId trên backend là ID của comment gốc (comment._id)
|
|
parentId: comment._id!,
|
|
// Mention tác giả mà chúng ta click reply (tác giả của child comment)
|
|
replyToName: reply.authorName,
|
|
// Hiển thị form bên dưới child comment này
|
|
anchorId: reply._id!,
|
|
})
|
|
}
|
|
>
|
|
Reply
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Form reply bên dưới child comment */}
|
|
{reply._id && replyTarget && replyTarget.anchorId === reply._id && (
|
|
<div className="mb-4 mt-3">
|
|
<div className="d-flex justify-content-end mb-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() => setReplyTarget(null)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<CommentForm
|
|
slug={slug}
|
|
parentId={replyTarget.parentId}
|
|
replyToName={replyTarget.replyToName}
|
|
initialContent={`@${replyTarget.replyToName} `}
|
|
onSubmitted={() => setReplyTarget(null)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Nút hiển thị thêm comments */}
|
|
{hasMoreComments && !showAllComments && (
|
|
<div className="text-center mt-4 mb-4">
|
|
<button
|
|
type="button"
|
|
className="theme-btn"
|
|
onClick={() => setShowAllComments(true)}
|
|
>
|
|
Show More Comments ({(comments || []).length - initialDisplayCount} more)
|
|
<i className="fa-solid fa-arrow-down"></i>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Nút thu gọn */}
|
|
{hasMoreComments && showAllComments && (
|
|
<div className="text-center mt-4 mb-4">
|
|
<button
|
|
type="button"
|
|
className="theme-btn"
|
|
onClick={() => {
|
|
setShowAllComments(false);
|
|
}}
|
|
>
|
|
Show Less
|
|
<i className="fa-solid fa-arrow-up"></i>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Form comment mới (top-level) */}
|
|
<div className="mt-5">
|
|
<CommentForm slug={slug} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|