This commit is contained in:
2026-03-13 13:54:45 +07:00
parent a8e30f32a0
commit 25111ff10e
120 changed files with 4213 additions and 4859 deletions

View File

@@ -0,0 +1,113 @@
'use client';
import { useMemo } from 'react';
type HeadingItem = {
id: string;
text: string;
level: number;
children?: HeadingItem[];
};
function convertToSlug(text: string) {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/đ/g, 'd')
.replace(/[^\w ]+/g, '')
.trim()
.replace(/\s+/g, '-');
}
// Hàm xây dựng cây TOC từ danh sách heading
function buildTree(headings: HeadingItem[]): HeadingItem[] {
const root: HeadingItem[] = [];
const stack: HeadingItem[] = [];
headings.forEach((h) => {
const node = { ...h, children: [] };
while (stack.length && stack[stack.length - 1].level >= node.level) {
stack.pop();
}
if (stack.length === 0) {
root.push(node);
} else {
stack[stack.length - 1].children!.push(node);
}
stack.push(node);
});
return root;
}
function renderTree(nodes: HeadingItem[]) {
return (
<ol>
{nodes.map((n) => (
<li key={n.id}>
<a
href={`#${n.id}`}
onClick={(e) => {
e.preventDefault();
const el = document.getElementById(n.id);
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - 120;
window.scrollTo({ top: y, behavior: 'smooth' });
}
}}
className="text-blue-600 hover:underline"
>
{n.text}
</a>
{n.children && n.children.length > 0 && renderTree(n.children)}
</li>
))}
</ol>
);
}
export default function TocBox({ htmlContent }: { htmlContent: string }) {
const { headingsTree, contentWithIds } = useMemo(() => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const nodes = doc.querySelectorAll('h1,h2,h3,h4,h5,h6');
const flat: HeadingItem[] = Array.from(nodes).map((node) => {
const text = node.textContent || '';
const id = convertToSlug(text);
node.setAttribute('id', id);
return {
id,
text,
level: parseInt(node.tagName.substring(1)),
};
});
return {
headingsTree: buildTree(flat),
contentWithIds: doc.body.innerHTML,
};
}, [htmlContent]);
if (!headingsTree.length) return null;
return (
<>
<div className="archor-text-group">
<div className="toc_title flex items-center justify-between gap-2">
<b className="text-fint-toc flex items-center text-base font-bold">
<span>Nội dung chính</span>
</b>
</div>
<div id="js-outp">{renderTree(headingsTree)}</div>
</div>
<div
className="box-article-detail-ct nd js_find"
dangerouslySetInnerHTML={{ __html: contentWithIds }}
/>
</>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import type { TypeArticleDetailPage } from '@/types/article/TypeArticleDetailPage';
import { ErrorLink } from '@components/Common/Error';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import TocBox from './TocBox';
import PreLoader from '@/components/Common/PreLoader';
import { getArticleCategories, getArticleDetail } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
interface DetailPageProps {
slug: string;
}
const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
const { data: page, isLoading } = useApiData(
() => getArticleDetail(slug),
[slug],
{ initialData: null as TypeArticleDetailPage | null },
);
const { data: categories } = useApiData(
() => getArticleCategories(),
[],
{ initialData: [] as TypeArticleCategory[] },
);
if (isLoading) {
return <PreLoader />;
}
if (!page) {
return <ErrorLink />;
}
const breadcrumbItems = [
{ name: 'Tin tức', url: '/tin-tuc' },
{ name: page.article_detail.title, url: page.article_detail.url },
];
const listRelayNew = Object.values(page.article_other_same_category.new);
const listRelayOld = Object.values(page.article_other_same_category.old);
const combinedList = [...listRelayNew.slice(0, 6), ...listRelayOld.slice(0, 6)];
return (
<>
<div className="container">
<Breadcrumb items={breadcrumbItems} />
</div>
<section className="page-article box-article-detail container">
<div className="tabs-category-article flex items-center">
{categories.map((item, index) => (
<Link
href={item.url}
key={`${item.id}-${index}`}
className={`item-tab-article ${page.article_detail.categoryInfo[0].id === item.id ? 'active' : ''}`}
>
<h2 className="title-cate-article font-[400]">{item.title}</h2>
</Link>
))}
</div>
<div className="row article-detail-page mt-5">
<div className="col-md-8">
<div className="box-article-detail-title">
<h1 className="font-weight-700">{page.article_detail.title}</h1>
<div className="post__user border-bottom my-5 flex items-center gap-2">
<span className="author-name">{page.article_detail.author}</span>
<span className="post-time">{page.article_detail.createDate}</span>
</div>
<TocBox htmlContent={page.article_detail.content} />
</div>
</div>
{page.article_other_same_category && (
<div className="col-md-4">
<div className="box-article-relay">
<p className="title-ar">
Bài viết <span>liên quan</span>
</p>
<div className="article-list list-article-relative flex flex-wrap gap-3">
{combinedList.map((item) => (
<div className="item-article d-flex flex-column gap-12" key={item.id}>
<Link href={item.url} className="img-article boder-radius-10">
<Image
className="boder-radius-10"
src={item.image.original}
fill
alt={item.title}
/>
</Link>
<div className="content-article flex-1">
<a href={item.url} className="title-article">
<h3 className="font-weight-400 line-clamp-2">{item.title}</h3>
</a>
<p className="time-article d-flex align-items-center gap-4">
<i className="sprite sprite-clock-item-article"></i>
<span>{item.createDate}</span>
</p>
<p className="descreption-article line-clamp-2">{item.summary}</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</section>
</>
);
};
export default ArticleDetailPage;