forked from UKSOURCE/hailearning.edu.vn
feat: Implement blog API service and refactor components for improved data fetching
This commit is contained in:
@@ -21,6 +21,10 @@ export default function CommentForm({
|
||||
const router = useRouter();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [authorName, setAuthorName] = useState("");
|
||||
const [authorEmail, setAuthorEmail] = useState("");
|
||||
const [authorPhone, setAuthorPhone] = useState("");
|
||||
const [authorAddress, setAuthorAddress] = useState("");
|
||||
const [authorDate, setAuthorDate] = useState("");
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
@@ -37,6 +41,12 @@ export default function CommentForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic email validation if provided
|
||||
if (authorEmail.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail.trim())) {
|
||||
setError("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPending(true);
|
||||
const res = await fetch(`${apiUrl}/api/blog/${slug}/comments`, {
|
||||
@@ -47,6 +57,10 @@ export default function CommentForm({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
authorName: authorName.trim(),
|
||||
...(authorEmail.trim() ? { authorEmail: authorEmail.trim() } : {}),
|
||||
...(authorPhone.trim() ? { authorPhone: authorPhone.trim() } : {}),
|
||||
...(authorAddress.trim() ? { authorAddress: authorAddress.trim() } : {}),
|
||||
...(authorDate.trim() ? { authorDate: authorDate.trim() } : {}),
|
||||
content: content.trim(),
|
||||
...(parentId ? { parentId } : {}),
|
||||
}),
|
||||
@@ -58,6 +72,10 @@ export default function CommentForm({
|
||||
}
|
||||
|
||||
setAuthorName("");
|
||||
setAuthorEmail("");
|
||||
setAuthorPhone("");
|
||||
setAuthorAddress("");
|
||||
setAuthorDate("");
|
||||
setContent("");
|
||||
setSuccess("Comment submitted.");
|
||||
|
||||
@@ -83,7 +101,7 @@ export default function CommentForm({
|
||||
|
||||
<form onSubmit={onSubmit} className="contact-form-items">
|
||||
<div className="row g-4">
|
||||
<div className="col-lg-6">
|
||||
<div className="col-lg-4">
|
||||
<div className="form-clt">
|
||||
<span>Your Name</span>
|
||||
<input
|
||||
@@ -93,6 +111,63 @@ export default function CommentForm({
|
||||
value={authorName}
|
||||
onChange={(e) => setAuthorName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-4">
|
||||
<div className="form-clt">
|
||||
<span>Your Email</span>
|
||||
<input
|
||||
type="email"
|
||||
name="authorEmail"
|
||||
placeholder="Your email"
|
||||
value={authorEmail}
|
||||
onChange={(e) => setAuthorEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-4">
|
||||
<div className="form-clt">
|
||||
<span>Your Phone</span>
|
||||
<input
|
||||
type="text"
|
||||
name="authorPhone"
|
||||
placeholder="Phone Number"
|
||||
value={authorPhone}
|
||||
onChange={(e) => setAuthorPhone(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-6">
|
||||
<div className="form-clt">
|
||||
<span>Your Address</span>
|
||||
<input
|
||||
type="text"
|
||||
name="authorAddress"
|
||||
placeholder="Address Now"
|
||||
value={authorAddress}
|
||||
onChange={(e) => setAuthorAddress(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-6">
|
||||
<div className="form-clt">
|
||||
<span>Your Date</span>
|
||||
<input
|
||||
type="text"
|
||||
name="authorDate"
|
||||
placeholder="Date"
|
||||
value={authorDate}
|
||||
onChange={(e) => setAuthorDate(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,6 +180,7 @@ export default function CommentForm({
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,19 +9,25 @@ interface NewsDetailsContentProps {
|
||||
}
|
||||
|
||||
export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
|
||||
// Get comments from post (already included in API response)
|
||||
// Lấy comments từ post (đã được bao gồm trong API response)
|
||||
const postComments = post.comments || [];
|
||||
|
||||
// Get base URL for EditorJS images
|
||||
// Lấy base URL cho EditorJS images
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
// Convert EditorJS content to HTML
|
||||
// URL tuyệt đối của bài viết để share lên mạng xã hội
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
|
||||
const postUrl = `${siteUrl}/blog/${post.slug}`;
|
||||
const encodedPostUrl = encodeURIComponent(postUrl);
|
||||
const encodedTitle = encodeURIComponent(post.title);
|
||||
|
||||
// Chuyển đổi EditorJS content sang HTML
|
||||
const renderContent = () => {
|
||||
const html = editorjsToHtml(post.content, baseUrl);
|
||||
return { __html: html };
|
||||
};
|
||||
|
||||
// Convert EditorJS contentAfterQuote to HTML
|
||||
// Chuyển đổi EditorJS contentAfterQuote sang HTML
|
||||
const renderContentAfterQuote = () => {
|
||||
const html = editorjsToHtml(post.contentAfterQuote, baseUrl);
|
||||
return { __html: html };
|
||||
@@ -49,13 +55,13 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
|
||||
</li>
|
||||
</ul>
|
||||
<h2>{post.title}</h2>
|
||||
<div dangerouslySetInnerHTML={renderContent()} />
|
||||
<div className="editorjs-render" dangerouslySetInnerHTML={renderContent()} />
|
||||
|
||||
{/* Gallery Images */}
|
||||
{/* Hình ảnh gallery */}
|
||||
{post.galleryImages && post.galleryImages.length > 0 && (
|
||||
<div className="row g-4 mt-4">
|
||||
<div className="row g-4 gallery-images-row">
|
||||
{post.galleryImages.map((image, index) => (
|
||||
<div key={index} className={post.galleryImages!.length === 1 ? "col-12" : "col-lg-6"}>
|
||||
<div key={index} className={post.galleryImages!.length === 1 ? "col-12" : "col-lg-6 gallery-item"}>
|
||||
<div className="thumb">
|
||||
<img src={getCmsImageUrl(image)} alt={`${post.title} - Image ${index + 1}`} />
|
||||
</div>
|
||||
@@ -71,18 +77,21 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content After Quote */}
|
||||
{/* Nội dung sau Quote */}
|
||||
{post.contentAfterQuote && (
|
||||
<div dangerouslySetInnerHTML={renderContentAfterQuote()} />
|
||||
<div
|
||||
className="editorjs-render"
|
||||
dangerouslySetInnerHTML={renderContentAfterQuote()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags and Social Share */}
|
||||
{/* Tags và Social Share */}
|
||||
<div className="row tag-share-wrap mt-4 mb-5">
|
||||
<div className="col-lg-8 col-12">
|
||||
<div className="tagcloud">
|
||||
<span>Tags:</span>
|
||||
{post.tags.map((tagName) => {
|
||||
// Generate slug from tag name (Vietnamese-friendly)
|
||||
// Tạo slug từ tên tag (hỗ trợ tiếng Việt)
|
||||
const tagSlug = toSlug(tagName);
|
||||
return (
|
||||
<Link key={tagName} href={`/blog/tag/${tagSlug}`}>
|
||||
@@ -94,17 +103,37 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
|
||||
</div>
|
||||
<div className="col-lg-4 col-12 mt-3 mt-lg-0 text-lg-end">
|
||||
<div className="social-share">
|
||||
<a href="#" aria-label="Share on Twitter">
|
||||
<a
|
||||
href={`https://twitter.com/intent/tweet?url=${encodedPostUrl}&text=${encodedTitle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Share on Twitter"
|
||||
>
|
||||
<i className="fab fa-twitter"></i>
|
||||
</a>
|
||||
<a href="#" aria-label="Share on YouTube">
|
||||
<i className="fa-brands fa-youtube"></i>
|
||||
<a
|
||||
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedPostUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Share on Facebook"
|
||||
>
|
||||
<i className="fab fa-facebook-f"></i>
|
||||
</a>
|
||||
<a href="#" aria-label="Share on LinkedIn">
|
||||
<a
|
||||
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodedPostUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Share on LinkedIn"
|
||||
>
|
||||
<i className="fab fa-linkedin-in"></i>
|
||||
</a>
|
||||
<a href="#" aria-label="Share on Facebook">
|
||||
<i className="fab fa-facebook-f"></i>
|
||||
<a
|
||||
href={postUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open blog post"
|
||||
>
|
||||
<i className="fa-solid fa-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import Breadcrumb from "@/app/components/Breadcrumb";
|
||||
import NewsDetailsSection from "./components/NewsDetailsSection";
|
||||
import { fetchBlogList, fetchBlogDetail } from "@/api/blog";
|
||||
import { fetchBlogList, fetchBlogDetail } from "@/api/blogsApi";
|
||||
import Sidebar from "@/app/blog/components/Sidebar";
|
||||
import { getCmsImageUrl } from "@/utils";
|
||||
|
||||
// Generate static params for all blog posts
|
||||
export async function generateStaticParams() {
|
||||
@@ -25,6 +27,60 @@ interface BlogDetailsPageProps {
|
||||
};
|
||||
}
|
||||
|
||||
// SEO metadata cho từng bài blog (Open Graph / thumbnail khi share)
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params:
|
||||
| Promise<{
|
||||
slug: string;
|
||||
}>
|
||||
| {
|
||||
slug: string;
|
||||
};
|
||||
}): Promise<Metadata> {
|
||||
const resolvedParams = params instanceof Promise ? await params : params;
|
||||
const slug = resolvedParams.slug;
|
||||
|
||||
try {
|
||||
const blogResponse = await fetchBlogDetail(slug);
|
||||
const post = blogResponse.data;
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
|
||||
const url = `${siteUrl}/blog/${post.slug}`;
|
||||
const imageUrl = post.featuredImage
|
||||
? getCmsImageUrl(post.featuredImage)
|
||||
: `${siteUrl}/assets/img/inner-page/news-details/details-1.jpg`;
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
url,
|
||||
type: "article",
|
||||
images: [
|
||||
{
|
||||
url: imageUrl,
|
||||
alt: post.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
images: [imageUrl],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: "Blog Details",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogDetailsPage({ params }: BlogDetailsPageProps) {
|
||||
// Handle both Promise and direct object
|
||||
const resolvedParams = params instanceof Promise ? await params : params;
|
||||
|
||||
Reference in New Issue
Block a user