From 9a71d39ebf376428dcc613e593cbdfd3f5de90b0 Mon Sep 17 00:00:00 2001 From: Wini_Fy Date: Wed, 4 Feb 2026 15:33:02 +0700 Subject: [PATCH 1/2] feat: Implement blog API service and refactor components for improved data fetching --- api/{blog.ts => blogsApi.ts} | 37 ++- api/index.ts | 2 +- app/blog/[slug]/components/CommentForm.tsx | 78 ++++- .../[slug]/components/CommentsSection.tsx | 294 +++++++++++++----- .../[slug]/components/NewsDetailsContent.tsx | 65 ++-- app/blog/[slug]/page.tsx | 58 +++- app/blog/category/[slug]/page.tsx | 33 +- app/blog/components/NewsSection.tsx | 8 +- app/blog/components/Pagination.tsx | 10 +- app/blog/components/Sidebar.tsx | 8 +- app/blog/page.tsx | 4 +- app/blog/tag/[slug]/page.tsx | 28 +- public/assets/css/main.css | 284 ++++++++++++++++- public/assets/scss/_news.scss | 4 +- utils/editorjsToHtml.ts | 22 +- utils/image.ts | 4 +- 16 files changed, 790 insertions(+), 149 deletions(-) rename api/{blog.ts => blogsApi.ts} (92%) diff --git a/api/blog.ts b/api/blogsApi.ts similarity index 92% rename from api/blog.ts rename to api/blogsApi.ts index dc2ef72..0f8b43a 100644 --- a/api/blog.ts +++ b/api/blogsApi.ts @@ -53,8 +53,10 @@ export const fetchBlogList = async ( headers: { 'Content-Type': 'application/json', }, - // Next.js: cache và revalidate - next: { revalidate: 60 }, // Revalidate mỗi 60 giây + // Next.js: cache và revalidate (disabled) + // next: { revalidate: 60 }, // Revalidate mỗi 60 giây + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -89,7 +91,8 @@ export const fetchBlogDetail = async ( headers: { 'Content-Type': 'application/json', }, - // No cache for blog detail so comments/replies show immediately after submit + refresh + // No cache for blog detail (disabled caching) + // no-cache cache: 'no-store', }); @@ -128,7 +131,9 @@ export const fetchFeaturedBlogs = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 60 }, + // next: { revalidate: 60 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -163,7 +168,9 @@ export const fetchRecentBlogs = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 60 }, + // next: { revalidate: 60 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -195,7 +202,9 @@ export const fetchCategories = async (): Promise => { headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, // Categories ít thay đổi, cache lâu hơn + // next: { revalidate: 300 }, // Categories ít thay đổi, cache lâu hơn + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -230,7 +239,9 @@ export const fetchCategoryDetail = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -265,7 +276,9 @@ export const fetchTags = async (): Promise => { headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -300,7 +313,9 @@ export const fetchPopularTags = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -335,7 +350,9 @@ export const fetchTagDetail = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { diff --git a/api/index.ts b/api/index.ts index 36d466b..e576090 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,4 +1,4 @@ /** * Export all API functions */ -export * from './blog'; +export * from './blogsApi'; diff --git a/app/blog/[slug]/components/CommentForm.tsx b/app/blog/[slug]/components/CommentForm.tsx index c1a95ec..3a4b580 100644 --- a/app/blog/[slug]/components/CommentForm.tsx +++ b/app/blog/[slug]/components/CommentForm.tsx @@ -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(null); const [success, setSuccess] = useState(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({
-
+
Your Name setAuthorName(e.target.value)} disabled={isPending} + required + /> +
+
+ +
+
+ Your Email + setAuthorEmail(e.target.value)} + disabled={isPending} + /> +
+
+ +
+
+ Your Phone + setAuthorPhone(e.target.value)} + disabled={isPending} + /> +
+
+ +
+
+ Your Address + setAuthorAddress(e.target.value)} + disabled={isPending} + /> +
+
+ +
+
+ Your Date + setAuthorDate(e.target.value)} + disabled={isPending} />
@@ -105,6 +180,7 @@ export default function CommentForm({ value={content} onChange={(e) => setContent(e.target.value)} disabled={isPending} + required >
diff --git a/app/blog/[slug]/components/CommentsSection.tsx b/app/blog/[slug]/components/CommentsSection.tsx index 54a2237..b74fdc6 100644 --- a/app/blog/[slug]/components/CommentsSection.tsx +++ b/app/blog/[slug]/components/CommentsSection.tsx @@ -11,15 +11,41 @@ interface CommentsSectionProps { } export default function CommentsSection({ slug, comments }: CommentsSectionProps) { + const [showAllComments, setShowAllComments] = useState(false); + const [expandedCommentKeys, setExpandedCommentKeys] = useState>( + () => 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 && ( + <> + {" "} + + + )} + + ); + }; + + // 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(); + + 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 (
@@ -78,51 +190,57 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
- {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 (
- {comment.authorName} +
+ {getInitials(comment.authorName)} +
-
+
{formatLongDate(comment.createdAt)}

{comment.authorName}

- - {comment._id && ( - - )}
-

{renderContentWithMention(comment.content)}

+

+ {renderTruncatedContent( + comment.content, + makeCommentKey("p", comment._id, index) + )} +

+ + {comment._id && ( + + )} {isReplyingHere && comment._id && (
@@ -145,54 +263,56 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
)} - {/* Replies */} + {/* Các reply */} {replies.length > 0 && (
- {replies.map((reply, replyIndex) => ( + {replies.map((reply: BlogComment, replyIndex: number) => (
- {reply.authorName} +
+ {getInitials(reply.authorName)} +
-
+
{formatLongDate(reply.createdAt)}

{reply.authorName}

- {reply._id && comment._id && ( - - )}
-

{renderContentWithMention(reply.content)}

+

+ {renderTruncatedContent( + reply.content, + makeCommentKey("r", reply._id, replyIndex) + )} +

+ + {reply._id && comment._id && ( + + )}
- {/* 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 && (
+
+ )} + + {/* Nút thu gọn */} + {hasMoreComments && showAllComments && ( +
+ +
+ )} + + {/* Form comment mới (top-level) */}
diff --git a/app/blog/[slug]/components/NewsDetailsContent.tsx b/app/blog/[slug]/components/NewsDetailsContent.tsx index 904ea36..dc915a8 100644 --- a/app/blog/[slug]/components/NewsDetailsContent.tsx +++ b/app/blog/[slug]/components/NewsDetailsContent.tsx @@ -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) {

{post.title}

-
+
- {/* Gallery Images */} + {/* Hình ảnh gallery */} {post.galleryImages && post.galleryImages.length > 0 && ( -
+
{post.galleryImages.map((image, index) => ( -
+
{`${post.title}
@@ -71,18 +77,21 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
)} - {/* Content After Quote */} + {/* Nội dung sau Quote */} {post.contentAfterQuote && ( -
+
)} - {/* Tags and Social Share */} + {/* Tags và Social Share */}
Tags: {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 ( @@ -94,17 +103,37 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 937a767..4216b3f 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -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 { + 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; diff --git a/app/blog/category/[slug]/page.tsx b/app/blog/category/[slug]/page.tsx index e95fdad..4eebb6e 100644 --- a/app/blog/category/[slug]/page.tsx +++ b/app/blog/category/[slug]/page.tsx @@ -1,27 +1,39 @@ import Breadcrumb from "@/app/components/Breadcrumb"; import NewsSection from "@/app/blog/components/NewsSection"; import Sidebar from "@/app/blog/components/Sidebar"; -import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blog"; +import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blogsApi"; interface CategoryPageProps { - params: Promise<{ + params: + | Promise<{ slug: string; - }> | { + }> + | { slug: string; - }; + }; + searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string }; } -export default async function CategoryPage({ params }: CategoryPageProps) { +export default async function CategoryPage({ params, searchParams }: CategoryPageProps) { // Handle both Promise and direct object const resolvedParams = params instanceof Promise ? await params : params; const slug = resolvedParams.slug; + const resolvedSearchParams = + searchParams instanceof Promise ? await searchParams : searchParams; + const searchQuery = resolvedSearchParams?.search?.toString() || ""; + const pageParam = resolvedSearchParams?.page?.toString() || "1"; + const currentPage = Number.parseInt(pageParam, 10) || 1; // Fetch category detail and blogs let categoryResponse, blogsResponse; try { [categoryResponse, blogsResponse] = await Promise.all([ fetchCategoryDetail(slug), - fetchBlogsByCategory(slug, { page: 1, limit: 10 }), + fetchBlogsByCategory(slug, { + page: currentPage, + limit: 3, + ...(searchQuery ? { search: searchQuery } : {}), + }), ]); } catch { return ( @@ -47,12 +59,17 @@ export default async function CategoryPage({ params }: CategoryPageProps) { } const category = categoryResponse.data; - const blogs = blogsResponse.data.blogs; + const { blogs, pagination } = blogsResponse.data; return ( <> - + ); } diff --git a/app/blog/components/NewsSection.tsx b/app/blog/components/NewsSection.tsx index 64a8ddf..34c3138 100644 --- a/app/blog/components/NewsSection.tsx +++ b/app/blog/components/NewsSection.tsx @@ -18,6 +18,12 @@ export default function NewsSection({ searchQuery, pagination, }: NewsSectionProps) { + const basePath = categorySlug + ? `/blog/category/${categorySlug}` + : tagSlug + ? `/blog/tag/${tagSlug}` + : "/blog"; + return (
@@ -29,7 +35,7 @@ export default function NewsSection({ {pagination && pagination.total > 1 && (
- +
)} diff --git a/app/blog/components/Pagination.tsx b/app/blog/components/Pagination.tsx index 78c3dbc..c1a815e 100644 --- a/app/blog/components/Pagination.tsx +++ b/app/blog/components/Pagination.tsx @@ -38,11 +38,11 @@ export default function Pagination({ basePath, pagination, searchQuery }: Pagina return (