forked from UKSOURCE/hailearning.edu.vn
feat: Refactor blog components and add pagination
This commit is contained in:
232
app/blog/[slug]/components/CommentsSection.tsx
Normal file
232
app/blog/[slug]/components/CommentsSection.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"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 [replyTarget, setReplyTarget] = useState<{
|
||||
// Root comment id to store as parentId (keeps 1-level threading on backend)
|
||||
parentId: string;
|
||||
// The author we are replying to (used for @mention UI)
|
||||
replyToName: string;
|
||||
// Which item in the UI is showing the reply form under it
|
||||
anchorId: string;
|
||||
} | null>(null);
|
||||
|
||||
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]);
|
||||
// Collect all author names to detect full @mentions (including multi-word names)
|
||||
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
|
||||
.sort((a, b) => b.length - a.length);
|
||||
}, [comments]);
|
||||
|
||||
const renderContentWithMention = (text?: string) => {
|
||||
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à".
|
||||
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
|
||||
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
|
||||
return text;
|
||||
}
|
||||
|
||||
const mention = `@${matchedName}`;
|
||||
const rest = text.slice(mention.length); // includes leading space if present
|
||||
|
||||
return (
|
||||
<>
|
||||
<strong>{mention}</strong>
|
||||
{rest}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="comments-area">
|
||||
<div className="comments-heading">
|
||||
<h3>
|
||||
{(comments || []).length} {(comments || []).length === 1 ? "Comment" : "Comments"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{parents.map((comment, index) => {
|
||||
const replies = comment._id ? repliesByParent.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`}
|
||||
>
|
||||
<div className="image">
|
||||
<img
|
||||
src={
|
||||
comment.authorAvatar ||
|
||||
`/assets/img/inner-page/news-details/comment-${(index % 3) + 1}.png`
|
||||
}
|
||||
alt={comment.authorName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<div className="head d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Replies */}
|
||||
{replies.length > 0 && (
|
||||
<div className="replies">
|
||||
{replies.map((reply, replyIndex) => (
|
||||
<div key={reply._id || replyIndex}>
|
||||
<div
|
||||
className="news-single-comment d-flex gap-4 pt-4 pb-0"
|
||||
>
|
||||
<div className="image">
|
||||
<img
|
||||
src={
|
||||
reply.authorAvatar ||
|
||||
`/assets/img/inner-page/news-details/comment-${(replyIndex % 3) + 1}.png`
|
||||
}
|
||||
alt={reply.authorName}
|
||||
/>
|
||||
</div>
|
||||
<div className="content">
|
||||
<div className="head d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reply form under child comment */}
|
||||
{reply._id && 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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* New top-level comment */}
|
||||
<div className="mt-5">
|
||||
<CommentForm slug={slug} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user