feat: Implement blog API service and refactor components for improved data fetching

This commit is contained in:
Wini_Fy
2026-02-04 15:33:02 +07:00
parent d46c420aaf
commit 9a71d39ebf
16 changed files with 790 additions and 149 deletions

View File

@@ -11,15 +11,41 @@ interface CommentsSectionProps {
}
export default function CommentsSection({ slug, comments }: CommentsSectionProps) {
const [showAllComments, setShowAllComments] = useState(false);
const [expandedCommentKeys, setExpandedCommentKeys] = useState<Set<string>>(
() => new Set()
);
const [replyTarget, setReplyTarget] = useState<{
// Root comment id to store as parentId (keeps 1-level threading on backend)
// ID của comment gốc để lưu làm parentId (giữ thread 1 cấp trên backend)
parentId: string;
// The author we are replying to (used for @mention UI)
// Tên tác giả mà chúng ta đang reply (dùng cho UI @mention)
replyToName: string;
// Which item in the UI is showing the reply form under it
// 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);
@@ -30,12 +56,12 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
}
return { parents: parentsLocal, repliesByParent: map };
}, [comments]);
// Collect all author names to detect full @mentions (including multi-word names)
// 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)
// Sort by length desc so we match the longest possible name first
// 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]);
@@ -43,24 +69,24 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
if (!text) return null;
if (!text.startsWith("@")) return text;
// Try to match an @mention that corresponds to a known author name.
// This supports multi-word names like "@Bạn Cũng Thấy Thế Hà".
// 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;
// Ensure the match ends at string end or is followed by a space
// Đả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: just return the original text if we can't confidently match a name
// 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); // includes leading space if present
const rest = text.slice(mention.length); // bao gồm khoảng trắng đầu nếu có
return (
<>
@@ -70,6 +96,92 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
);
};
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">
@@ -78,51 +190,57 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
</h3>
</div>
{parents.map((comment, index) => {
const replies = comment._id ? repliesByParent.get(comment._id) || [] : [];
{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 ${index % 2 === 1 ? "style-2" : ""
} d-flex gap-4 pt-4 pb-0`}
className="news-single-comment d-flex pt-4 pb-0 position-relative"
>
<div className="image">
<img
src={
comment.authorAvatar ||
`/assets/img/inner-page/news-details/comment-${(index % 3) + 1}.png`
}
alt={comment.authorName}
/>
<div className="comment-avatar-box">
{getInitials(comment.authorName)}
</div>
</div>
<div className="content">
<div className="head d-flex flex-wrap gap-2 align-items-center justify-content-between">
<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>
{comment._id && (
<button
type="button"
className="reply"
onClick={() =>
setReplyTarget({
parentId: comment._id!,
replyToName: comment.authorName,
anchorId: comment._id!,
})
}
>
Reply
</button>
)}
</div>
<p className="mt-30 mb-4">{renderContentWithMention(comment.content)}</p>
<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">
@@ -145,54 +263,56 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
</div>
)}
{/* Replies */}
{/* Các reply */}
{replies.length > 0 && (
<div className="replies">
{replies.map((reply, replyIndex) => (
{replies.map((reply: BlogComment, replyIndex: number) => (
<div key={reply._id || replyIndex}>
<div
className="news-single-comment d-flex gap-4 pt-4 pb-0"
className="news-single-comment d-flex pt-4 pb-0 position-relative"
>
<div className="image">
<img
src={
reply.authorAvatar ||
`/assets/img/inner-page/news-details/comment-${(replyIndex % 3) + 1}.png`
}
alt={reply.authorName}
/>
<div className="comment-avatar-box">
{getInitials(reply.authorName)}
</div>
</div>
<div className="content">
<div className="head d-flex flex-wrap gap-2 align-items-center justify-content-between">
<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>
{reply._id && comment._id && (
<button
type="button"
className="reply"
onClick={() =>
setReplyTarget({
// Keep backend parentId as the root comment id (comment._id)
parentId: comment._id!,
// Mention the author we clicked reply on (child comment author)
replyToName: reply.authorName,
// Show form under this child comment
anchorId: reply._id!,
})
}
>
Reply
</button>
)}
</div>
<p className="mt-30 mb-4">{renderContentWithMention(reply.content)}</p>
<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>
{/* Reply form under child comment */}
{reply._id && replyTarget?.anchorId === reply._id && (
{/* 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
@@ -222,7 +342,37 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
);
})}
{/* New top-level comment */}
{/* 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>