Merge pull request 'fea/thanh-02022026-news' (#12) from fea/thanh-02022026-news into main

Reviewed-on: UKSOURCE/hailearning.edu.vn#12
This commit is contained in:
2026-02-04 09:23:02 +00:00
17 changed files with 791 additions and 164 deletions

View File

@@ -53,8 +53,10 @@ export const fetchBlogList = async (
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
// Next.js: cache và revalidate // Next.js: cache và revalidate (disabled)
next: { revalidate: 60 }, // Revalidate mỗi 60 giây // next: { revalidate: 60 }, // Revalidate mỗi 60 giây
// no-cache
cache: 'no-store',
}); });
if (!response.ok) { if (!response.ok) {
@@ -89,7 +91,8 @@ export const fetchBlogDetail = async (
headers: { headers: {
'Content-Type': 'application/json', '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', cache: 'no-store',
}); });
@@ -128,7 +131,9 @@ export const fetchFeaturedBlogs = async (
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
next: { revalidate: 60 }, // next: { revalidate: 60 },
// no-cache
cache: 'no-store',
}); });
if (!response.ok) { if (!response.ok) {
@@ -163,7 +168,9 @@ export const fetchRecentBlogs = async (
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
next: { revalidate: 60 }, // next: { revalidate: 60 },
// no-cache
cache: 'no-store',
}); });
if (!response.ok) { if (!response.ok) {
@@ -195,7 +202,9 @@ export const fetchCategories = async (): Promise<CategoryListResponse> => {
headers: { headers: {
'Content-Type': 'application/json', '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) { if (!response.ok) {
@@ -230,7 +239,9 @@ export const fetchCategoryDetail = async (
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
next: { revalidate: 300 }, // next: { revalidate: 300 },
// no-cache
cache: 'no-store',
}); });
if (!response.ok) { if (!response.ok) {
@@ -265,7 +276,9 @@ export const fetchTags = async (): Promise<TagListResponse> => {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
next: { revalidate: 300 }, // next: { revalidate: 300 },
// no-cache
cache: 'no-store',
}); });
if (!response.ok) { if (!response.ok) {
@@ -300,7 +313,9 @@ export const fetchPopularTags = async (
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
next: { revalidate: 300 }, // next: { revalidate: 300 },
// no-cache
cache: 'no-store',
}); });
if (!response.ok) { if (!response.ok) {
@@ -335,7 +350,9 @@ export const fetchTagDetail = async (
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
next: { revalidate: 300 }, // next: { revalidate: 300 },
// no-cache
cache: 'no-store',
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -1,4 +1,4 @@
/** /**
* Export all API functions * Export all API functions
*/ */
export * from './blog'; export * from './blogsApi';

View File

@@ -21,6 +21,10 @@ export default function CommentForm({
const router = useRouter(); const router = useRouter();
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
const [authorName, setAuthorName] = useState(""); 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 [content, setContent] = useState(initialContent);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
@@ -37,6 +41,12 @@ export default function CommentForm({
return; return;
} }
// Basic email validation if provided
if (authorEmail.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail.trim())) {
setError("Please enter a valid email address.");
return;
}
try { try {
setIsPending(true); setIsPending(true);
const res = await fetch(`${apiUrl}/api/blog/${slug}/comments`, { const res = await fetch(`${apiUrl}/api/blog/${slug}/comments`, {
@@ -47,6 +57,10 @@ export default function CommentForm({
}, },
body: JSON.stringify({ body: JSON.stringify({
authorName: authorName.trim(), 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(), content: content.trim(),
...(parentId ? { parentId } : {}), ...(parentId ? { parentId } : {}),
}), }),
@@ -58,6 +72,10 @@ export default function CommentForm({
} }
setAuthorName(""); setAuthorName("");
setAuthorEmail("");
setAuthorPhone("");
setAuthorAddress("");
setAuthorDate("");
setContent(""); setContent("");
setSuccess("Comment submitted."); setSuccess("Comment submitted.");
@@ -83,7 +101,7 @@ export default function CommentForm({
<form onSubmit={onSubmit} className="contact-form-items"> <form onSubmit={onSubmit} className="contact-form-items">
<div className="row g-4"> <div className="row g-4">
<div className="col-lg-6"> <div className="col-lg-4">
<div className="form-clt"> <div className="form-clt">
<span>Your Name</span> <span>Your Name</span>
<input <input
@@ -93,6 +111,63 @@ export default function CommentForm({
value={authorName} value={authorName}
onChange={(e) => setAuthorName(e.target.value)} onChange={(e) => setAuthorName(e.target.value)}
disabled={isPending} 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>
</div> </div>
@@ -105,6 +180,7 @@ export default function CommentForm({
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
disabled={isPending} disabled={isPending}
required
></textarea> ></textarea>
</div> </div>
</div> </div>

View File

@@ -11,15 +11,41 @@ interface CommentsSectionProps {
} }
export default function CommentsSection({ slug, comments }: 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<{ 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; 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; 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; anchorId: string;
} | null>(null); } | 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 { parents, repliesByParent } = useMemo(() => {
const parentsLocal = (comments || []).filter((c) => !c.parentId); const parentsLocal = (comments || []).filter((c) => !c.parentId);
const replies = (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 }; return { parents: parentsLocal, repliesByParent: map };
}, [comments]); }, [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(() => { const authorNames = useMemo(() => {
return (comments || []) return (comments || [])
.map((c) => c.authorName) .map((c) => c.authorName)
.filter((n): n is string => !!n) .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); .sort((a, b) => b.length - a.length);
}, [comments]); }, [comments]);
@@ -43,24 +69,24 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
if (!text) return null; if (!text) return null;
if (!text.startsWith("@")) return text; if (!text.startsWith("@")) return text;
// Try to match an @mention that corresponds to a known author name. // Thử khớp @mention với tên tác giả đã biết
// This supports multi-word names like "@Bạn Cũng Thấy Thế Hà". // Hỗ trợ tên nhiều từ như "@Bạn Cũng Thấy Thế Hà"
const matchedName = authorNames.find((name) => { const matchedName = authorNames.find((name) => {
const candidate = `@${name}`; const candidate = `@${name}`;
if (!text.startsWith(candidate)) return false; 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); const nextChar = text.charAt(candidate.length);
return candidate.length === text.length || nextChar === " "; return candidate.length === text.length || nextChar === " ";
}); });
if (!matchedName) { 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; return text;
} }
const mention = `@${matchedName}`; 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 ( 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 ( return (
<div className="comments-area"> <div className="comments-area">
<div className="comments-heading"> <div className="comments-heading">
@@ -78,37 +190,46 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
</h3> </h3>
</div> </div>
{parents.map((comment, index) => { {displayedParents.map((comment, index) => {
const replies = comment._id ? repliesByParent.get(comment._id) || [] : []; // 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; const isReplyingHere = !!comment._id && replyTarget?.anchorId === comment._id;
return ( return (
<div key={comment._id || index}> <div key={comment._id || index}>
<div <div
className={`news-single-comment ${index % 2 === 1 ? "style-2" : "" className="news-single-comment d-flex pt-4 pb-0 position-relative"
} d-flex gap-4 pt-4 pb-0`}
> >
<div className="image"> <div className="image">
<img <div className="comment-avatar-box">
src={ {getInitials(comment.authorName)}
comment.authorAvatar || </div>
`/assets/img/inner-page/news-details/comment-${(index % 3) + 1}.png`
}
alt={comment.authorName}
/>
</div> </div>
<div className="content"> <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"> <div className="con">
<span>{formatLongDate(comment.createdAt)}</span> <span>{formatLongDate(comment.createdAt)}</span>
<h4>{comment.authorName}</h4> <h4>{comment.authorName}</h4>
</div> </div>
</div>
<p className="mt-30">
{renderTruncatedContent(
comment.content,
makeCommentKey("p", comment._id, index)
)}
</p>
{comment._id && ( {comment._id && (
<button <button
type="button" type="button"
className="reply" className="reply reply-absolute"
onClick={() => onClick={() =>
setReplyTarget({ setReplyTarget({
parentId: comment._id!, parentId: comment._id!,
@@ -120,9 +241,6 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
Reply Reply
</button> </button>
)} )}
</div>
<p className="mt-30 mb-4">{renderContentWithMention(comment.content)}</p>
{isReplyingHere && comment._id && ( {isReplyingHere && comment._id && (
<div className="mb-4"> <div className="mb-4">
@@ -145,40 +263,44 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
</div> </div>
)} )}
{/* Replies */} {/* Các reply */}
{replies.length > 0 && ( {replies.length > 0 && (
<div className="replies"> <div className="replies">
{replies.map((reply, replyIndex) => ( {replies.map((reply: BlogComment, replyIndex: number) => (
<div key={reply._id || replyIndex}> <div key={reply._id || replyIndex}>
<div <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"> <div className="image">
<img <div className="comment-avatar-box">
src={ {getInitials(reply.authorName)}
reply.authorAvatar || </div>
`/assets/img/inner-page/news-details/comment-${(replyIndex % 3) + 1}.png`
}
alt={reply.authorName}
/>
</div> </div>
<div className="content"> <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"> <div className="con">
<span>{formatLongDate(reply.createdAt)}</span> <span>{formatLongDate(reply.createdAt)}</span>
<h4>{reply.authorName}</h4> <h4>{reply.authorName}</h4>
</div> </div>
</div>
<p className="mt-30">
{renderTruncatedContent(
reply.content,
makeCommentKey("r", reply._id, replyIndex)
)}
</p>
{reply._id && comment._id && ( {reply._id && comment._id && (
<button <button
type="button" type="button"
className="reply" className="reply reply-absolute"
onClick={() => onClick={() =>
setReplyTarget({ setReplyTarget({
// Keep backend parentId as the root comment id (comment._id) // Giữ parentId trên backend là ID của comment gốc (comment._id)
parentId: comment._id!, parentId: comment._id!,
// Mention the author we clicked reply on (child comment author) // Mention tác giả mà chúng ta click reply (tác giả của child comment)
replyToName: reply.authorName, replyToName: reply.authorName,
// Show form under this child comment // Hiển thị form bên dưới child comment này
anchorId: reply._id!, anchorId: reply._id!,
}) })
} }
@@ -187,12 +309,10 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
</button> </button>
)} )}
</div> </div>
<p className="mt-30 mb-4">{renderContentWithMention(reply.content)}</p>
</div>
</div> </div>
{/* Reply form under child comment */} {/* Form reply bên dưới child comment */}
{reply._id && replyTarget?.anchorId === reply._id && ( {reply._id && replyTarget && replyTarget.anchorId === reply._id && (
<div className="mb-4 mt-3"> <div className="mb-4 mt-3">
<div className="d-flex justify-content-end mb-2"> <div className="d-flex justify-content-end mb-2">
<button <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"> <div className="mt-5">
<CommentForm slug={slug} /> <CommentForm slug={slug} />
</div> </div>

View File

@@ -9,19 +9,25 @@ interface NewsDetailsContentProps {
} }
export default function NewsDetailsContent({ post }: 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 || []; 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"; 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 renderContent = () => {
const html = editorjsToHtml(post.content, baseUrl); const html = editorjsToHtml(post.content, baseUrl);
return { __html: html }; return { __html: html };
}; };
// Convert EditorJS contentAfterQuote to HTML // Chuyển đổi EditorJS contentAfterQuote sang HTML
const renderContentAfterQuote = () => { const renderContentAfterQuote = () => {
const html = editorjsToHtml(post.contentAfterQuote, baseUrl); const html = editorjsToHtml(post.contentAfterQuote, baseUrl);
return { __html: html }; return { __html: html };
@@ -49,13 +55,13 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
</li> </li>
</ul> </ul>
<h2>{post.title}</h2> <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 && ( {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) => ( {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"> <div className="thumb">
<img src={getCmsImageUrl(image)} alt={`${post.title} - Image ${index + 1}`} /> <img src={getCmsImageUrl(image)} alt={`${post.title} - Image ${index + 1}`} />
</div> </div>
@@ -71,18 +77,21 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
</div> </div>
)} )}
{/* Content After Quote */} {/* Nội dung sau Quote */}
{post.contentAfterQuote && ( {post.contentAfterQuote && (
<div dangerouslySetInnerHTML={renderContentAfterQuote()} /> <div
className="editorjs-render"
dangerouslySetInnerHTML={renderContentAfterQuote()}
/>
)} )}
{/* Tags and Social Share */} {/* Tags Social Share */}
<div className="row tag-share-wrap mt-4 mb-5"> <div className="row tag-share-wrap mt-4 mb-5">
<div className="col-lg-8 col-12"> <div className="col-lg-8 col-12">
<div className="tagcloud"> <div className="tagcloud">
<span>Tags:</span> <span>Tags:</span>
{post.tags.map((tagName) => { {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); const tagSlug = toSlug(tagName);
return ( return (
<Link key={tagName} href={`/blog/tag/${tagSlug}`}> <Link key={tagName} href={`/blog/tag/${tagSlug}`}>
@@ -94,17 +103,37 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
</div> </div>
<div className="col-lg-4 col-12 mt-3 mt-lg-0 text-lg-end"> <div className="col-lg-4 col-12 mt-3 mt-lg-0 text-lg-end">
<div className="social-share"> <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> <i className="fab fa-twitter"></i>
</a> </a>
<a href="#" aria-label="Share on YouTube"> <a
<i className="fa-brands fa-youtube"></i> 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>
<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> <i className="fab fa-linkedin-in"></i>
</a> </a>
<a href="#" aria-label="Share on Facebook"> <a
<i className="fab fa-facebook-f"></i> href={postUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Open blog post"
>
<i className="fa-solid fa-link"></i>
</a> </a>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,10 @@
import Link from "next/link"; import Link from "next/link";
import type { Metadata } from "next";
import Breadcrumb from "@/app/components/Breadcrumb"; import Breadcrumb from "@/app/components/Breadcrumb";
import NewsDetailsSection from "./components/NewsDetailsSection"; 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 Sidebar from "@/app/blog/components/Sidebar";
import { getCmsImageUrl } from "@/utils";
// Generate static params for all blog posts // Generate static params for all blog posts
export async function generateStaticParams() { 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) { export default async function BlogDetailsPage({ params }: BlogDetailsPageProps) {
// Handle both Promise and direct object // Handle both Promise and direct object
const resolvedParams = params instanceof Promise ? await params : params; const resolvedParams = params instanceof Promise ? await params : params;

View File

@@ -1,27 +1,39 @@
import Breadcrumb from "@/app/components/Breadcrumb"; import Breadcrumb from "@/app/components/Breadcrumb";
import NewsSection from "@/app/blog/components/NewsSection"; import NewsSection from "@/app/blog/components/NewsSection";
import Sidebar from "@/app/blog/components/Sidebar"; import Sidebar from "@/app/blog/components/Sidebar";
import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blog"; import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blogsApi";
interface CategoryPageProps { interface CategoryPageProps {
params: Promise<{ params:
| Promise<{
slug: string; slug: string;
}> | { }>
| {
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 // Handle both Promise and direct object
const resolvedParams = params instanceof Promise ? await params : params; const resolvedParams = params instanceof Promise ? await params : params;
const slug = resolvedParams.slug; 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 // Fetch category detail and blogs
let categoryResponse, blogsResponse; let categoryResponse, blogsResponse;
try { try {
[categoryResponse, blogsResponse] = await Promise.all([ [categoryResponse, blogsResponse] = await Promise.all([
fetchCategoryDetail(slug), fetchCategoryDetail(slug),
fetchBlogsByCategory(slug, { page: 1, limit: 10 }), fetchBlogsByCategory(slug, {
page: currentPage,
limit: 3,
...(searchQuery ? { search: searchQuery } : {}),
}),
]); ]);
} catch { } catch {
return ( return (
@@ -47,12 +59,17 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
} }
const category = categoryResponse.data; const category = categoryResponse.data;
const blogs = blogsResponse.data.blogs; const { blogs, pagination } = blogsResponse.data;
return ( return (
<> <>
<Breadcrumb title={category.name} current="Blog Category" /> <Breadcrumb title={category.name} current="Blog Category" />
<NewsSection blogs={blogs} categorySlug={slug} /> <NewsSection
blogs={blogs}
categorySlug={slug}
searchQuery={searchQuery}
pagination={pagination}
/>
</> </>
); );
} }

View File

@@ -18,6 +18,12 @@ export default function NewsSection({
searchQuery, searchQuery,
pagination, pagination,
}: NewsSectionProps) { }: NewsSectionProps) {
const basePath = categorySlug
? `/blog/category/${categorySlug}`
: tagSlug
? `/blog/tag/${tagSlug}`
: "/blog";
return ( return (
<section className="news-standard-section section-padding fix"> <section className="news-standard-section section-padding fix">
<div className="container"> <div className="container">
@@ -29,7 +35,7 @@ export default function NewsSection({
{pagination && pagination.total > 1 && ( {pagination && pagination.total > 1 && (
<div className="row g-4 mt-4"> <div className="row g-4 mt-4">
<div className="col-12"> <div className="col-12">
<Pagination basePath="/blog" pagination={pagination} searchQuery={searchQuery} /> <Pagination basePath={basePath} pagination={pagination} searchQuery={searchQuery} />
</div> </div>
</div> </div>
)} )}

View File

@@ -38,11 +38,11 @@ export default function Pagination({ basePath, pagination, searchQuery }: Pagina
return ( return (
<nav aria-label="Blog pagination" className="mt-4"> <nav aria-label="Blog pagination" className="mt-4">
<ul className="pagination justify-content-center"> <ul className="pagination justify-content-center hai-pagination">
{current > 1 && ( {current > 1 && (
<li className="page-item"> <li className="page-item">
<Link className="page-link" href={makeHref(current - 1)}> <Link className="page-link" href={makeHref(current - 1)} aria-label="Previous page">
Previous <i className="fa-solid fa-arrow-left" aria-hidden="true"></i>
</Link> </Link>
</li> </li>
)} )}
@@ -67,8 +67,8 @@ export default function Pagination({ basePath, pagination, searchQuery }: Pagina
{current < total && ( {current < total && (
<li className="page-item"> <li className="page-item">
<Link className="page-link" href={makeHref(current + 1)}> <Link className="page-link" href={makeHref(current + 1)} aria-label="Next page">
Next <i className="fa-solid fa-arrow-right" aria-hidden="true"></i>
</Link> </Link>
</li> </li>
)} )}

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { fetchCategories, fetchRecentBlogs, fetchPopularTags } from "@/api/blog"; import { fetchCategories, fetchRecentBlogs, fetchPopularTags } from "@/api/blogsApi";
import { getCmsImageUrl } from "@/utils";
interface SidebarProps { interface SidebarProps {
searchQuery?: string; searchQuery?: string;
@@ -67,11 +68,8 @@ export default async function Sidebar({ searchQuery }: SidebarProps) {
<div key={post.slug} className="recent-items"> <div key={post.slug} className="recent-items">
<div className="recent-thumb"> <div className="recent-thumb">
<img <img
src={post.thumbnail || "/assets/img/inner-page/news-details/details-1.jpg"} src={getCmsImageUrl(post.thumbnail) || "/assets/img/inner-page/news-details/details-1.jpg"}
alt={post.title} alt={post.title}
width={88}
height={80}
style={{ objectFit: 'cover' }}
/> />
</div> </div>
<div className="recent-content"> <div className="recent-content">

View File

@@ -1,6 +1,6 @@
import Breadcrumb from "@/app/components/Breadcrumb"; import Breadcrumb from "@/app/components/Breadcrumb";
import NewsSection from "./components/NewsSection"; import NewsSection from "./components/NewsSection";
import { fetchBlogList } from "@/api/blog"; import { fetchBlogList } from "@/api/blogsApi";
interface NewsPageProps { interface NewsPageProps {
searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string }; searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string };
@@ -16,7 +16,7 @@ export default async function NewsPage({ searchParams }: NewsPageProps) {
// Fetch blog list from API // Fetch blog list from API
const blogResponse = await fetchBlogList({ const blogResponse = await fetchBlogList({
page: currentPage, page: currentPage,
limit: 10, limit: 3,
...(searchQuery ? { search: searchQuery } : {}), ...(searchQuery ? { search: searchQuery } : {}),
}); });
const { blogs, pagination } = blogResponse.data; const { blogs, pagination } = blogResponse.data;

View File

@@ -1,27 +1,39 @@
import Breadcrumb from "@/app/components/Breadcrumb"; import Breadcrumb from "@/app/components/Breadcrumb";
import NewsSection from "@/app/blog/components/NewsSection"; import NewsSection from "@/app/blog/components/NewsSection";
import Sidebar from "@/app/blog/components/Sidebar"; import Sidebar from "@/app/blog/components/Sidebar";
import { fetchBlogsByTag, fetchTagDetail } from "@/api/blog"; import { fetchBlogsByTag, fetchTagDetail } from "@/api/blogsApi";
interface TagPageProps { interface TagPageProps {
params: Promise<{ params:
| Promise<{
slug: string; slug: string;
}> | { }>
| {
slug: string; slug: string;
}; };
searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string };
} }
export default async function TagPage({ params }: TagPageProps) { export default async function TagPage({ params, searchParams }: TagPageProps) {
// Handle both Promise and direct object // Handle both Promise and direct object
const resolvedParams = params instanceof Promise ? await params : params; const resolvedParams = params instanceof Promise ? await params : params;
const slug = resolvedParams.slug; 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 tag detail and blogs // Fetch tag detail and blogs
let tagResponse, blogsResponse; let tagResponse, blogsResponse;
try { try {
[tagResponse, blogsResponse] = await Promise.all([ [tagResponse, blogsResponse] = await Promise.all([
fetchTagDetail(slug), fetchTagDetail(slug),
fetchBlogsByTag(slug, { page: 1, limit: 10 }), fetchBlogsByTag(slug, {
page: currentPage,
limit: 3,
...(searchQuery ? { search: searchQuery } : {}),
}),
]); ]);
} catch { } catch {
return ( return (
@@ -47,12 +59,12 @@ export default async function TagPage({ params }: TagPageProps) {
} }
const tag = tagResponse.data; const tag = tagResponse.data;
const blogs = blogsResponse.data.blogs; const { blogs, pagination } = blogsResponse.data;
return ( return (
<> <>
<Breadcrumb title={tag.name} current="Blog Tag" /> <Breadcrumb title={tag.name} current="Blog Tag" />
<NewsSection blogs={blogs} tagSlug={slug} /> <NewsSection blogs={blogs} tagSlug={slug} searchQuery={searchQuery} pagination={pagination} />
</> </>
); );
} }

View File

@@ -118,22 +118,8 @@
}, },
{ {
"label": "Blog", "label": "Blog",
"href": "#",
"children": [
{
"label": "Blog Grid",
"href": "/blog-grid"
},
{
"label": "Blog Standard",
"href": "/blog" "href": "/blog"
}, },
{
"label": "Blog Details",
"href": "/blog-details"
}
]
},
{ {
"label": "Contact Us", "label": "Contact Us",
"href": "/contact" "href": "/contact"

View File

@@ -99,6 +99,54 @@ Version: 1.0.0
color: var(--header); color: var(--header);
} }
/* --------------------------------------------
Pagination (match theme-btn style: round, red/white)
---------------------------------------------- */
.hai-pagination {
gap: 10px;
}
.hai-pagination .page-item {
margin: 0;
}
.hai-pagination .page-link {
width: 48px;
height: 48px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50% !important;
border: 1px solid var(--border);
background: var(--white);
color: var(--header);
font-family: "Space Grotesk", sans-serif;
font-weight: 500;
line-height: 1;
transition: all 0.3s ease-in-out;
box-shadow: none;
}
.hai-pagination .page-link:focus {
box-shadow: none;
}
.hai-pagination .page-link:hover {
background-color: var(--theme);
border-color: var(--theme);
color: var(--white);
}
.hai-pagination .page-item.active .page-link,
.hai-pagination .page-item.active span.page-link {
background-color: var(--theme);
border-color: var(--theme);
color: var(--white);
}
.hai-pagination .page-item.disabled .page-link,
.hai-pagination .page-item.disabled span.page-link {
opacity: 0.6;
background: var(--white);
border-color: var(--border);
color: var(--header);
}
.link-btn { .link-btn {
color: var(--theme-2); color: var(--theme-2);
text-transform: capitalize; text-transform: capitalize;
@@ -6548,6 +6596,10 @@ html.lenis body {
.recent-thumb .recent-thumb
img { img {
border-radius: 8px; border-radius: 8px;
width: 88px;
min-width: 88px;
height: 80px;
object-fit: cover;
} }
.main-sideber .main-sideber
.news-sideber-box .news-sideber-box
@@ -6633,10 +6685,28 @@ html.lenis body {
} }
.news-details-wrapper .news-details-post .news-details-image img { .news-details-wrapper .news-details-post .news-details-image img {
width: 100%; width: 852px;
height: 100%; height: 400px;
object-fit: cover;
border-radius: 16px; border-radius: 16px;
} }
@media (max-width: 1399px) {
.news-details-wrapper .news-details-post .news-details-image img {
width: 100%;
height: 300px;
object-fit: cover;
}
}
@media (max-width: 991px) {
.news-details-wrapper .news-details-post .news-details-image img {
width: 100%;
height: 200px;
object-fit: cover;
}
}
.news-details-wrapper .news-details-post .details-content { .news-details-wrapper .news-details-post .details-content {
margin-top: 24px; margin-top: 24px;
} }
@@ -6669,6 +6739,32 @@ html.lenis body {
height: 100%; height: 100%;
border-radius: 16px; border-radius: 16px;
} }
/* Gallery images responsive for mobile */
@media (max-width: 991px) {
.news-details-wrapper .news-details-post .details-content .gallery-images-row {
justify-content: space-between;
margin-left: 0;
margin-right: 0;
margin-top: 1.5rem;
gap: 0.5rem;
}
.news-details-wrapper .news-details-post .details-content .gallery-images-row .gallery-item {
flex: 0 0 47.5%;
max-width: 47.5%;
padding-left: 0;
padding-right: 0;
margin: 0;
}
.news-details-wrapper .news-details-post .details-content .gallery-images-row .gallery-item .thumb {
margin: 0;
}
.news-details-wrapper .news-details-post .details-content .gallery-images-row .gallery-item .thumb img {
width: 100%;
height: auto;
max-height: 200px;
object-fit: cover;
}
}
.news-details-wrapper .news-details-post .details-content .sideber { .news-details-wrapper .news-details-post .details-content .sideber {
background-color: var(--theme-2); background-color: var(--theme-2);
padding: 24px 30px; padding: 24px 30px;
@@ -6813,9 +6909,19 @@ html.lenis body {
.comments-area .comments-area
.news-single-comment { .news-single-comment {
flex-wrap: wrap; flex-wrap: wrap;
gap: 20px; gap: 10px;
} }
} }
.news-details-wrapper
.news-details-post
.details-content
.comments-area
.news-single-comment
.image {
flex-shrink: 0;
width: 80px;
min-width: 80px;
}
.news-details-wrapper .news-details-wrapper
.news-details-post .news-details-post
.details-content .details-content
@@ -6823,7 +6929,12 @@ html.lenis body {
.news-single-comment .news-single-comment
.image .image
img { img {
width: 80px;
height: 80px;
min-width: 80px;
min-height: 80px;
border-radius: 12px; border-radius: 12px;
object-fit: cover;
} }
.news-details-wrapper .news-details-wrapper
.news-details-post .news-details-post
@@ -6836,6 +6947,33 @@ html.lenis body {
h4 { h4 {
margin-bottom: 5px; margin-bottom: 5px;
} }
.news-details-wrapper
.news-details-post
.details-content
.comments-area
.news-single-comment {
position: relative;
}
.news-details-wrapper
.news-details-post
.details-content
.comments-area
.news-single-comment
.content
.head {
flex-wrap: nowrap;
}
.news-details-wrapper
.news-details-post
.details-content
.comments-area
.news-single-comment
.content
.head
.con {
flex: 1;
min-width: 0;
}
.news-details-wrapper .news-details-wrapper
.news-details-post .news-details-post
.details-content .details-content
@@ -6852,6 +6990,8 @@ html.lenis body {
color: var(--white); color: var(--white);
background-color: var(--header); background-color: var(--header);
border-radius: 100px; border-radius: 100px;
flex-shrink: 0;
margin-left: auto;
} }
.news-details-wrapper .news-details-wrapper
.news-details-post .news-details-post
@@ -6863,6 +7003,34 @@ html.lenis body {
.reply:hover { .reply:hover {
background-color: var(--theme); background-color: var(--theme);
} }
.news-details-wrapper
.news-details-post
.details-content
.comments-area
.news-single-comment
.reply-absolute {
position: absolute;
top: 16px;
right: 0;
font-weight: 500;
font-size: 16px;
color: var(--white);
background-color: var(--header);
border-radius: 100px;
padding: 6px 14px;
text-transform: uppercase;
border: none;
cursor: pointer;
z-index: 10;
}
.news-details-wrapper
.news-details-post
.details-content
.comments-area
.news-single-comment
.reply-absolute:hover {
background-color: var(--theme);
}
.news-details-wrapper .news-details-wrapper
.news-details-post .news-details-post
.details-content .details-content
@@ -6879,6 +7047,116 @@ html.lenis body {
margin-left: 0; margin-left: 0;
} }
} }
/* Comments responsive for mobile */
@media (max-width: 767px) {
.news-details-wrapper .news-details-post .details-content .comments-area {
margin-top: 30px;
padding-top: 20px;
margin-bottom: 30px;
}
.news-details-wrapper .news-details-post .details-content .comments-area .comments-heading {
margin-bottom: 20px;
}
.news-details-wrapper .news-details-post .details-content .comments-area .comments-heading h3 {
font-size: 20px;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment {
gap: 12px !important;
padding-top: 16px !important;
padding-bottom: 16px !important;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image {
flex-shrink: 0;
width: 50px;
min-width: 50px;
max-width: 50px;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image img {
width: 50px;
height: 50px;
object-fit: cover;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image .comment-avatar-box {
width: 50px;
height: 50px;
font-size: 18px;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content {
flex: 1;
min-width: 0;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head {
gap: 8px !important;
margin-bottom: 8px;
flex-wrap: nowrap !important;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head .con {
flex: 1;
min-width: 0;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .reply-absolute {
top: 12px;
right: 0;
font-size: 12px;
padding: 4px 10px;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head .con span {
font-size: 12px;
display: block;
margin-bottom: 4px;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head .con h4 {
font-size: 14px;
margin-bottom: 0;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head .reply {
font-size: 12px;
padding: 4px 10px;
white-space: nowrap;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content p {
font-size: 14px;
line-height: 1.5;
margin-top: 8px !important;
margin-bottom: 12px !important;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .replies {
margin-top: 8px;
}
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .replies .news-single-comment {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
.news-details-wrapper .news-details-post .details-content .comments-area .btn-outline-primary,
.news-details-wrapper .news-details-post .details-content .comments-area .btn-outline-secondary {
font-size: 14px;
padding: 10px 20px;
}
}
/* Comment avatar (initial letter) */
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image .comment-avatar-box {
width: 60px;
height: 60px;
border-radius: 14px;
background: #6c757d;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 24px;
font-weight: 700;
user-select: none;
}
@media (max-width: 767px) {
.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image .comment-avatar-box {
width: 50px;
height: 50px;
font-size: 18px;
}
}
.news-details-wrapper .news-details-post .details-content .form-clt { .news-details-wrapper .news-details-post .details-content .form-clt {
position: relative; position: relative;
} }

View File

@@ -644,9 +644,7 @@
.news-details-image { .news-details-image {
img { img {
width: 852px; @include imgw;
height: 400px;
object-fit: cover;
border-radius: 16px; border-radius: 16px;
} }
} }

View File

@@ -60,7 +60,7 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string {
case 'header': { case 'header': {
const level = data.level || 2; const level = data.level || 2;
const text = escapeHtml(data.text || ''); const text = escapeHtml(data.text || '');
return `<h${level}>${text}</h${level}>`; return `<h${level} style="margin:0 0 14px 0;">${text}</h${level}>`;
} }
case 'paragraph': { case 'paragraph': {
@@ -78,7 +78,7 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string {
text = text.replace(/`([^`]+)`/g, '<code>$1</code>'); text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// Convert line breaks // Convert line breaks
text = text.replace(/\n/g, '<br>'); text = text.replace(/\n/g, '<br>');
return `<p>${text}</p>`; return `<p style="margin:0 0 14px 0;">${text}</p>`;
} }
case 'list': { case 'list': {
@@ -115,11 +115,15 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string {
if (withBackground) classes.push('with-background'); if (withBackground) classes.push('with-background');
if (stretched) classes.push('stretched'); if (stretched) classes.push('stretched');
const classAttr = classes.length > 0 ? ` class="${classes.join(' ')}"` : ''; const classAttr =
const captionHtml = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''; classes.length > 0 ? ` class="${classes.join(' ')} editorjs-image"` : ' class="editorjs-image"';
return `<figure${classAttr}> const captionHtml = caption
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(caption)}" /> ? `<figcaption class="editorjs-image__caption" style="text-align:center;margin-top:0.5rem;margin-bottom:0;">${escapeHtml(caption)}</figcaption>`
: '';
return `<figure${classAttr} style="margin-top:1rem;margin-bottom:1rem;">
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(caption)}" style="display:block;margin-left:auto;margin-right:auto;" />
${captionHtml} ${captionHtml}
</figure>`; </figure>`;
} }
@@ -172,7 +176,7 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string {
} }
case 'delimiter': { case 'delimiter': {
return '<div class="delimiter">***</div>'; return '<hr style="border:0;border-top:2px solid rgba(0,0,0,0.75);margin:16px 0;" />';
} }
case 'table': { case 'table': {

View File

@@ -3,7 +3,7 @@
* *
* Rules: * Rules:
* - If already a full URL (http/https) → return as is * - If already a full URL (http/https) → return as is
* - If starts with `/uploads/` → prepend API URL (NEXT_PUBLIC_API_URL or default localhost) * - If starts with `/uploads/` or `/img/` → prepend API URL (NEXT_PUBLIC_API_URL or default localhost)
* - If starts with `/` → use as-is (served by Next/public) * - If starts with `/` → use as-is (served by Next/public)
* - Otherwise → treat as relative path under `/` * - Otherwise → treat as relative path under `/`
*/ */
@@ -14,7 +14,7 @@ export function getCmsImageUrl(imagePath: string | undefined): string {
return imagePath; return imagePath;
} }
if (imagePath.startsWith("/uploads/")) { if (imagePath.startsWith("/uploads/") || imagePath.startsWith("/img/")) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
return `${apiUrl}${imagePath}`; return `${apiUrl}${imagePath}`;
} }