forked from UKSOURCE/hailearning.edu.vn
feat: Implement blog API service and refactor components for improved data fetching
This commit is contained in:
@@ -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) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Export all API functions
|
* Export all API functions
|
||||||
*/
|
*/
|
||||||
export * from './blog';
|
export * from './blogsApi';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 và 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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': {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user