mirror of
https://github.com/10h30/blog-balodeplao.git
synced 2026-05-12 15:21:15 +09:00
feat: implement taxonomy data for categories, destinations, and tags with updated routing
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
---
|
||||
import { categoryMap, getCategoryPath } from "@/utils/taxonomy";
|
||||
|
||||
export interface Props {
|
||||
categories: string[];
|
||||
class?: string;
|
||||
@@ -13,10 +15,10 @@ const { categories, class: className = "text-sm" } = Astro.props;
|
||||
{categories.map((category) => (
|
||||
<li class="inline-block relative">
|
||||
<a
|
||||
href={`/category/${category}`}
|
||||
href={`/category/${getCategoryPath(category)}`}
|
||||
class="inline-block bg-primary/10 hover:bg-primary/20 text-primary hover:text-primary px-3 py-1 rounded-full border border-primary/20 transition-colors duration-200"
|
||||
>
|
||||
{category}
|
||||
{categoryMap.get(category)?.name ?? category}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
---
|
||||
import { destinationMap, getDestinationPath } from "@/utils/taxonomy";
|
||||
|
||||
export interface Props {
|
||||
destinations: string[];
|
||||
class?: string;
|
||||
@@ -13,7 +15,7 @@ const { destinations, class: className = "text-sm" } = Astro.props;
|
||||
{destinations.map((destination) => (
|
||||
<li class="inline-block relative">
|
||||
<a
|
||||
href={`/destination/${destination}`}
|
||||
href={`/destination/${getDestinationPath(destination)}`}
|
||||
class="inline-flex items-center gap-1 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 hover:text-emerald-800 dark:hover:text-emerald-300 px-3 py-1 rounded-full border border-emerald-500/20 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
@@ -30,7 +32,7 @@ const { destinations, class: className = "text-sm" } = Astro.props;
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
{destination}
|
||||
{destinationMap.get(destination)?.name ?? destination}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
---
|
||||
import { tagMap } from "@/utils/taxonomy";
|
||||
|
||||
export interface Props {
|
||||
tags: string[];
|
||||
class?: string;
|
||||
@@ -16,7 +18,7 @@ const { tags, class: className = "text-sm" } = Astro.props;
|
||||
href={`/tag/${tag}`}
|
||||
class="inline-block bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground px-3 py-1 rounded-full border border-border transition-colors duration-200"
|
||||
>
|
||||
#{tag}
|
||||
#{tagMap.get(tag)?.name ?? tag}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
[
|
||||
{
|
||||
"slug": "an-uong",
|
||||
"name": "Ẩm Thực",
|
||||
"description": "Cẩm nang trải nghiệm ẩm thực, giới thiệu các quán ăn ngon của Ba Lô Dép Lào"
|
||||
},
|
||||
{
|
||||
"slug": "cong-viec",
|
||||
"name": "Công Việc"
|
||||
},
|
||||
{
|
||||
"slug": "du-lich",
|
||||
"name": "Du Lịch",
|
||||
"description": "Chia sẻ trải nghiệm, kinh nghiệm du lịch khám phá thế giới của Ba Lô & Dép Lào"
|
||||
},
|
||||
{
|
||||
"slug": "gia-dinh",
|
||||
"name": "Gia Đình"
|
||||
},
|
||||
{
|
||||
"slug": "kham-pha-cuoi-tuan",
|
||||
"name": "Khám Phá Cuối Tuần"
|
||||
},
|
||||
{
|
||||
"slug": "khuyen-mai",
|
||||
"name": "Khuyến Mãi",
|
||||
"description": "Tổng hợp các thông tin khuyến mãi khách sạn, vé máy bay, ăn uống,..."
|
||||
},
|
||||
{
|
||||
"slug": "lam-dep",
|
||||
"name": "Làm Đẹp",
|
||||
"description": "Chia sẻ kinh nghiệm làm đẹp từ các nguyên liệu tự nhiên :)"
|
||||
},
|
||||
{
|
||||
"slug": "me-con",
|
||||
"name": "Mẹ và Con",
|
||||
"description": "Các bài viết chia sẻ kinh nghiệm làm mẹ và nuôi con"
|
||||
},
|
||||
{
|
||||
"slug": "meo-hay",
|
||||
"name": "Mẹo Hay"
|
||||
},
|
||||
{
|
||||
"slug": "qua-tang",
|
||||
"name": "Quà Tặng"
|
||||
},
|
||||
{
|
||||
"slug": "tam-su",
|
||||
"name": "Tâm Sự"
|
||||
},
|
||||
{
|
||||
"slug": "thu-cung",
|
||||
"name": "Thú Cưng",
|
||||
"description": "Các bài viết về cách huấn luyện, nuôi dưỡng và vui chơi cùng thú cưng"
|
||||
},
|
||||
{
|
||||
"slug": "tin-tuc",
|
||||
"name": "Tin Tức"
|
||||
},
|
||||
{
|
||||
"slug": "yoga",
|
||||
"name": "Yoga",
|
||||
"description": "Chia sẻ kinh nghiệm tập Yoga."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,254 @@
|
||||
[
|
||||
{
|
||||
"slug": "dai-loan",
|
||||
"name": "Đài Loan"
|
||||
},
|
||||
{
|
||||
"slug": "dai-nam",
|
||||
"name": "Đài Nam",
|
||||
"parent": "dai-loan"
|
||||
},
|
||||
{
|
||||
"slug": "dai-trung",
|
||||
"name": "Đài Trung",
|
||||
"parent": "dai-loan"
|
||||
},
|
||||
{
|
||||
"slug": "han-quoc",
|
||||
"name": "Hàn Quốc"
|
||||
},
|
||||
{
|
||||
"slug": "indonesia",
|
||||
"name": "Indonesia"
|
||||
},
|
||||
{
|
||||
"slug": "malaysia",
|
||||
"name": "Malaysia"
|
||||
},
|
||||
{
|
||||
"slug": "nam-dau",
|
||||
"name": "Nam Đầu",
|
||||
"parent": "dai-loan"
|
||||
},
|
||||
{
|
||||
"slug": "nhat-ban",
|
||||
"name": "Nhật Bản",
|
||||
"description": "[show-map id=\"2\"]"
|
||||
},
|
||||
{
|
||||
"slug": "osaka",
|
||||
"name": "Osaka",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "penang",
|
||||
"name": "Penang",
|
||||
"parent": "malaysia"
|
||||
},
|
||||
{
|
||||
"slug": "philippines",
|
||||
"name": "Philippines",
|
||||
"description": "Tổng hợp kinh nghiệm du lịch bụi, các địa điểm ăn uống, các điểm tham quan hấp dẫn ở Philippins"
|
||||
},
|
||||
{
|
||||
"slug": "sapporo",
|
||||
"name": "Sapporo",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "shirakawago",
|
||||
"name": "Shirakawago",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "singapore",
|
||||
"name": "Singapore"
|
||||
},
|
||||
{
|
||||
"slug": "takayama",
|
||||
"name": "Takayama",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "thailand",
|
||||
"name": "Thái Lan",
|
||||
"description": "Tổng hợp kinh nghiệm du lịch, khám phá Thái Lan"
|
||||
},
|
||||
{
|
||||
"slug": "tokyo",
|
||||
"name": "Tokyo",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "toyama",
|
||||
"name": "Toyama",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "viet-nam",
|
||||
"name": "Việt Nam"
|
||||
},
|
||||
{
|
||||
"slug": "bali",
|
||||
"name": "Bali",
|
||||
"parent": "indonesia"
|
||||
},
|
||||
{
|
||||
"slug": "bangkok",
|
||||
"name": "Bangkok",
|
||||
"parent": "thailand"
|
||||
},
|
||||
{
|
||||
"slug": "boracay",
|
||||
"name": "Boracay",
|
||||
"parent": "philippines"
|
||||
},
|
||||
{
|
||||
"slug": "busan",
|
||||
"name": "Busan",
|
||||
"parent": "han-quoc"
|
||||
},
|
||||
{
|
||||
"slug": "cao-hung",
|
||||
"name": "Cao Hùng",
|
||||
"parent": "dai-loan"
|
||||
},
|
||||
{
|
||||
"slug": "chiang-mai",
|
||||
"name": "Chiang Mai",
|
||||
"parent": "thailand"
|
||||
},
|
||||
{
|
||||
"slug": "da-lat",
|
||||
"name": "Đà Lạt",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "da-nang",
|
||||
"name": "Đà Nẵng",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "dai-bac",
|
||||
"name": "Đài Bắc",
|
||||
"parent": "dai-loan"
|
||||
},
|
||||
{
|
||||
"slug": "dong-nai",
|
||||
"name": "Đồng Nai",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "himeiji",
|
||||
"name": "Himeiji",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "hiroshima",
|
||||
"name": "Hiroshima",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "hoi-an",
|
||||
"name": "Hội An",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "hokkaido",
|
||||
"name": "Hokkaido",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "hokuryu",
|
||||
"name": "Hokuryu",
|
||||
"parent": "hokkaido"
|
||||
},
|
||||
{
|
||||
"slug": "kawasaki",
|
||||
"name": "Kawasaki",
|
||||
"parent": "nhat-ban"
|
||||
},
|
||||
{
|
||||
"slug": "kuala-lumpur",
|
||||
"name": "Kuala Lumpur",
|
||||
"parent": "malaysia"
|
||||
},
|
||||
{
|
||||
"slug": "kyoto",
|
||||
"name": "Kyoto",
|
||||
"parent": "nhat-ban",
|
||||
"description": "Kinh nghiệm du lịch Kyoto, Nhật Bản"
|
||||
},
|
||||
{
|
||||
"slug": "lagi",
|
||||
"name": "Lagi",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "manila",
|
||||
"name": "Manila",
|
||||
"parent": "philippines"
|
||||
},
|
||||
{
|
||||
"slug": "mui-ne",
|
||||
"name": "Mũi Né",
|
||||
"parent": "viet-nam",
|
||||
"description": "Kinh nghiệm khám phá, ăn chơi, tắm biển Mũi Né. \r\nTổng hợp đánh giá các resort, nhà hàng ở Mũi Né"
|
||||
},
|
||||
{
|
||||
"slug": "nara",
|
||||
"name": "Nara",
|
||||
"parent": "nhat-ban",
|
||||
"description": "Chia sẻ kinh nghiệm du lịch Nara, Nhật Bản"
|
||||
},
|
||||
{
|
||||
"slug": "nha-trang",
|
||||
"name": "Nha Trang",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "noboribetsu",
|
||||
"name": "Noboribetsu",
|
||||
"parent": "hokkaido"
|
||||
},
|
||||
{
|
||||
"slug": "otaru",
|
||||
"name": "Otaru",
|
||||
"parent": "hokkaido"
|
||||
},
|
||||
{
|
||||
"slug": "phu-quoc",
|
||||
"name": "Phú Quốc",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "phu-yen",
|
||||
"name": "Phú Yên",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "quy-nhon",
|
||||
"name": "Quy Nhơn",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "sa-pa",
|
||||
"name": "Sa Pa",
|
||||
"parent": "viet-nam"
|
||||
},
|
||||
{
|
||||
"slug": "toyako",
|
||||
"name": "Toyako",
|
||||
"parent": "hokkaido"
|
||||
},
|
||||
{
|
||||
"slug": "biei",
|
||||
"name": "Biei",
|
||||
"parent": "hokkaido"
|
||||
},
|
||||
{
|
||||
"slug": "furano",
|
||||
"name": "Furano",
|
||||
"parent": "hokkaido"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,166 @@
|
||||
[
|
||||
{
|
||||
"slug": "air-asia",
|
||||
"name": "Air Asia"
|
||||
},
|
||||
{
|
||||
"slug": "airbnb",
|
||||
"name": "airbnb"
|
||||
},
|
||||
{
|
||||
"slug": "airlines",
|
||||
"name": "airlines"
|
||||
},
|
||||
{
|
||||
"slug": "an-chay",
|
||||
"name": "ăn chay"
|
||||
},
|
||||
{
|
||||
"slug": "bao-tang",
|
||||
"name": "bảo tàng"
|
||||
},
|
||||
{
|
||||
"slug": "book-a-bee",
|
||||
"name": "Book a Bee"
|
||||
},
|
||||
{
|
||||
"slug": "chon-truong-mau-giao",
|
||||
"name": "chọn trường mẫu giáo"
|
||||
},
|
||||
{
|
||||
"slug": "citibank",
|
||||
"name": "Citibank"
|
||||
},
|
||||
{
|
||||
"slug": "cuoc-song-o-nhat",
|
||||
"name": "cuộc sống ở Nhật"
|
||||
},
|
||||
{
|
||||
"slug": "dimsum",
|
||||
"name": "dimsum"
|
||||
},
|
||||
{
|
||||
"slug": "ghtk-lua-dao",
|
||||
"name": "GHTK lừa đảo"
|
||||
},
|
||||
{
|
||||
"slug": "ghtk-quyt-tien",
|
||||
"name": "GHTK quỵt tiền"
|
||||
},
|
||||
{
|
||||
"slug": "google-maps",
|
||||
"name": "google maps"
|
||||
},
|
||||
{
|
||||
"slug": "hotelquickly",
|
||||
"name": "HotelQuickly"
|
||||
},
|
||||
{
|
||||
"slug": "hsr",
|
||||
"name": "HSR"
|
||||
},
|
||||
{
|
||||
"slug": "japan-rail-pass",
|
||||
"name": "Japan Rail Pass"
|
||||
},
|
||||
{
|
||||
"slug": "jcb",
|
||||
"name": "JCB"
|
||||
},
|
||||
{
|
||||
"slug": "jr-pass",
|
||||
"name": "JR Pass"
|
||||
},
|
||||
{
|
||||
"slug": "kem",
|
||||
"name": "kem"
|
||||
},
|
||||
{
|
||||
"slug": "khach-san",
|
||||
"name": "khách sạn"
|
||||
},
|
||||
{
|
||||
"slug": "mytour-vn",
|
||||
"name": "mytour.vn"
|
||||
},
|
||||
{
|
||||
"slug": "nha-hang-chay",
|
||||
"name": "nhà hàng chay"
|
||||
},
|
||||
{
|
||||
"slug": "phuket",
|
||||
"name": "Phuket"
|
||||
},
|
||||
{
|
||||
"slug": "pizza",
|
||||
"name": "pizza"
|
||||
},
|
||||
{
|
||||
"slug": "tattoo",
|
||||
"name": "tattoo"
|
||||
},
|
||||
{
|
||||
"slug": "the-tin-dung",
|
||||
"name": "thẻ tín dụng"
|
||||
},
|
||||
{
|
||||
"slug": "tiet-kiem",
|
||||
"name": "tiết kiệm"
|
||||
},
|
||||
{
|
||||
"slug": "topas-ecolodge",
|
||||
"name": "Topas Ecolodge"
|
||||
},
|
||||
{
|
||||
"slug": "tra",
|
||||
"name": "TRA"
|
||||
},
|
||||
{
|
||||
"slug": "trai-nghiem-toi-te",
|
||||
"name": "trải nghiệm tồi tệ"
|
||||
},
|
||||
{
|
||||
"slug": "uber",
|
||||
"name": "UBER"
|
||||
},
|
||||
{
|
||||
"slug": "ung-dung",
|
||||
"name": "ứng dụng"
|
||||
},
|
||||
{
|
||||
"slug": "ung-dung-du-lich",
|
||||
"name": "ứng dụng du lịch"
|
||||
},
|
||||
{
|
||||
"slug": "ve-may-bay",
|
||||
"name": "vé máy bay"
|
||||
},
|
||||
{
|
||||
"slug": "vegetarian",
|
||||
"name": "vegetarian"
|
||||
},
|
||||
{
|
||||
"slug": "visa-du-lich",
|
||||
"name": "visa du lịch"
|
||||
},
|
||||
{
|
||||
"slug": "visa-dai-loan",
|
||||
"name": "visa Đài Loan"
|
||||
},
|
||||
{
|
||||
"slug": "visa-nhat-ban",
|
||||
"name": "visa Nhật Bản"
|
||||
},
|
||||
{
|
||||
"slug": "vuon-thu",
|
||||
"name": "Vườn thú"
|
||||
},
|
||||
{
|
||||
"slug": "xe-dien",
|
||||
"name": "xe điện"
|
||||
},
|
||||
{
|
||||
"slug": "zoo",
|
||||
"name": "Zoo"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
import Headline from "@/components/ui/Headline.astro";
|
||||
import PostItem from "@/components/blog/PostItem.astro";
|
||||
import Pagination from "@/components/ui/Pagination.astro";
|
||||
import type { Page } from "astro";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import {
|
||||
categoryMap,
|
||||
getCategoryPath,
|
||||
type TaxonomyItem,
|
||||
} from "@/utils/taxonomy";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const PAGE_SIZE = 6;
|
||||
const allPosts = await getCollection("blog");
|
||||
const posts = allPosts.filter((post) => post.data && post.data.pubDate);
|
||||
|
||||
const catSlugs = new Set<string>();
|
||||
posts.forEach((post) =>
|
||||
post.data.categories?.forEach((c) => catSlugs.add(c)),
|
||||
);
|
||||
|
||||
return Array.from(catSlugs).flatMap((slug) => {
|
||||
const catPosts = posts
|
||||
.filter((post) => post.data.categories?.includes(slug))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
|
||||
const fullPath = getCategoryPath(slug);
|
||||
const cat = categoryMap.get(slug) ?? ({ slug, name: slug } as TaxonomyItem);
|
||||
const totalPages = Math.max(1, Math.ceil(catPosts.length / PAGE_SIZE));
|
||||
|
||||
return Array.from({ length: totalPages }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return {
|
||||
params: { rest: pageNum === 1 ? fullPath : `${fullPath}/${pageNum}` },
|
||||
props: {
|
||||
cat,
|
||||
fullPath,
|
||||
posts: catPosts.slice(i * PAGE_SIZE, (i + 1) * PAGE_SIZE),
|
||||
currentPage: pageNum,
|
||||
lastPage: totalPages,
|
||||
total: catPosts.length,
|
||||
prevUrl:
|
||||
pageNum === 1
|
||||
? undefined
|
||||
: pageNum === 2
|
||||
? `/category/${fullPath}`
|
||||
: `/category/${fullPath}/${pageNum - 1}`,
|
||||
nextUrl:
|
||||
pageNum === totalPages
|
||||
? undefined
|
||||
: `/category/${fullPath}/${pageNum + 1}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const { cat, posts, currentPage, lastPage, total, prevUrl, nextUrl } =
|
||||
Astro.props;
|
||||
|
||||
const page = {
|
||||
currentPage,
|
||||
lastPage,
|
||||
url: { prev: prevUrl, next: nextUrl },
|
||||
} as unknown as Page<CollectionEntry<"blog">>;
|
||||
|
||||
const metadata = {
|
||||
title: cat.name,
|
||||
description: cat.description ?? `Các bài viết trong danh mục ${cat.name}.`,
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout metadata={metadata}>
|
||||
<section class="relative px-4 py-8 sm:px-6 lg:px-8 md:py-12">
|
||||
<div class="relative mx-auto max-w-5xl">
|
||||
<Headline
|
||||
tagline="Category"
|
||||
title={cat.name}
|
||||
subtitle={cat.description ?? `Hiển thị ${total} bài viết.`}
|
||||
classes={{
|
||||
container: "mb-12 text-center",
|
||||
title:
|
||||
"font-heading mb-4 text-4xl font-bold tracking-tight text-foreground md:text-6xl capitalized",
|
||||
subtitle: "mx-auto max-w-3xl text-xl text-muted-foreground",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mb-8 text-center">
|
||||
<a href="/blog" class="text-primary hover:underline">← Quay lại blog</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((post) => <PostItem post={post} />)}
|
||||
</div>
|
||||
<Pagination page={page} />
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -5,6 +5,7 @@ import Headline from "@/components/ui/Headline.astro";
|
||||
import PostItem from "@/components/blog/PostItem.astro";
|
||||
import Pagination from "@/components/ui/Pagination.astro";
|
||||
import type { GetStaticPathsOptions } from "astro";
|
||||
import { categoryMap } from "@/utils/taxonomy";
|
||||
|
||||
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
||||
const allPosts = await getCollection("blog");
|
||||
@@ -30,9 +31,13 @@ export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
||||
const { page } = Astro.props;
|
||||
const { category } = Astro.params;
|
||||
|
||||
const categoryInfo = categoryMap.get(category!);
|
||||
const displayName = categoryInfo?.name ?? category!;
|
||||
const description = categoryInfo?.description;
|
||||
|
||||
const metadata = {
|
||||
title: `Category: ${category}`,
|
||||
description: `Articles in the ${category} category.`,
|
||||
title: displayName,
|
||||
description: description ?? `Các bài viết trong danh mục ${displayName}.`,
|
||||
};
|
||||
---
|
||||
|
||||
@@ -41,8 +46,8 @@ const metadata = {
|
||||
<div class="relative mx-auto max-w-5xl">
|
||||
<Headline
|
||||
tagline="Category"
|
||||
title={category}
|
||||
subtitle={`Showing ${page.total} article${page.total === 1 ? "" : "s"}.`}
|
||||
title={displayName}
|
||||
subtitle={description ?? `Hiển thị ${page.total} bài viết.`}
|
||||
classes={{
|
||||
container: "mb-12 text-center",
|
||||
title:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
import Headline from "@/components/ui/Headline.astro";
|
||||
import PostItem from "@/components/blog/PostItem.astro";
|
||||
import Pagination from "@/components/ui/Pagination.astro";
|
||||
import type { Page } from "astro";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import {
|
||||
destinationMap,
|
||||
getDestinationPath,
|
||||
type TaxonomyItem,
|
||||
} from "@/utils/taxonomy";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const PAGE_SIZE = 6;
|
||||
const allPosts = await getCollection("blog");
|
||||
const posts = allPosts.filter((post) => post.data && post.data.pubDate);
|
||||
|
||||
const destSlugs = new Set<string>();
|
||||
posts.forEach((post) =>
|
||||
post.data.destination?.forEach((d) => destSlugs.add(d)),
|
||||
);
|
||||
|
||||
return Array.from(destSlugs).flatMap((slug) => {
|
||||
const destPosts = posts
|
||||
.filter((post) => post.data.destination?.includes(slug))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
|
||||
const fullPath = getDestinationPath(slug);
|
||||
const dest =
|
||||
destinationMap.get(slug) ?? ({ slug, name: slug } as TaxonomyItem);
|
||||
const totalPages = Math.max(1, Math.ceil(destPosts.length / PAGE_SIZE));
|
||||
|
||||
return Array.from({ length: totalPages }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return {
|
||||
params: { rest: pageNum === 1 ? fullPath : `${fullPath}/${pageNum}` },
|
||||
props: {
|
||||
dest,
|
||||
fullPath,
|
||||
posts: destPosts.slice(i * PAGE_SIZE, (i + 1) * PAGE_SIZE),
|
||||
currentPage: pageNum,
|
||||
lastPage: totalPages,
|
||||
total: destPosts.length,
|
||||
prevUrl:
|
||||
pageNum === 1
|
||||
? undefined
|
||||
: pageNum === 2
|
||||
? `/destination/${fullPath}`
|
||||
: `/destination/${fullPath}/${pageNum - 1}`,
|
||||
nextUrl:
|
||||
pageNum === totalPages
|
||||
? undefined
|
||||
: `/destination/${fullPath}/${pageNum + 1}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const { dest, posts, currentPage, lastPage, total, prevUrl, nextUrl } =
|
||||
Astro.props;
|
||||
|
||||
const page = {
|
||||
currentPage,
|
||||
lastPage,
|
||||
url: { prev: prevUrl, next: nextUrl },
|
||||
} as unknown as Page<CollectionEntry<"blog">>;
|
||||
|
||||
const metadata = {
|
||||
title: dest.name,
|
||||
description: dest.description ?? `Khám phá các bài viết về ${dest.name}.`,
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout metadata={metadata}>
|
||||
<section class="relative px-4 py-8 sm:px-6 lg:px-8 md:py-12">
|
||||
<div class="relative mx-auto max-w-5xl">
|
||||
<Headline
|
||||
tagline="Destination"
|
||||
title={dest.name}
|
||||
subtitle={dest.description ?? `Hiển thị ${total} bài viết.`}
|
||||
classes={{
|
||||
container: "mb-12 text-center",
|
||||
title:
|
||||
"font-heading mb-4 text-4xl font-bold tracking-tight text-foreground md:text-6xl capitalized",
|
||||
subtitle: "mx-auto max-w-3xl text-xl text-muted-foreground",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mb-8 text-center">
|
||||
<a href="/blog" class="text-primary hover:underline">← Quay lại blog</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((post) => <PostItem post={post} />)}
|
||||
</div>
|
||||
<Pagination page={page} />
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
import Headline from "@/components/ui/Headline.astro";
|
||||
import PostItem from "@/components/blog/PostItem.astro";
|
||||
import Pagination from "@/components/ui/Pagination.astro";
|
||||
import type { GetStaticPathsOptions } from "astro";
|
||||
|
||||
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
||||
const allPosts = await getCollection("blog");
|
||||
const posts = allPosts.filter((post) => post.data && post.data.pubDate);
|
||||
|
||||
const destinations = new Set<string>();
|
||||
posts.forEach((post) => {
|
||||
post.data.destination?.forEach((d) => destinations.add(d));
|
||||
});
|
||||
|
||||
return Array.from(destinations).flatMap((destination) => {
|
||||
const destinationPosts = posts
|
||||
.filter((post) => post.data.destination?.includes(destination))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
|
||||
return paginate(destinationPosts, {
|
||||
params: { destination },
|
||||
pageSize: 6,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const { page } = Astro.props;
|
||||
const { destination } = Astro.params;
|
||||
|
||||
const metadata = {
|
||||
title: `Destination: ${destination}`,
|
||||
description: `Travel articles about ${destination}.`,
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout metadata={metadata}>
|
||||
<section class="relative px-4 py-8 sm:px-6 lg:px-8 md:py-12">
|
||||
<div class="relative mx-auto max-w-5xl">
|
||||
<Headline
|
||||
tagline="Destination"
|
||||
title={destination}
|
||||
subtitle={`Showing ${page.total} article${page.total === 1 ? "" : "s"}.`}
|
||||
classes={{
|
||||
container: "mb-12 text-center",
|
||||
title:
|
||||
"font-heading mb-4 text-4xl font-bold tracking-tight text-foreground md:text-6xl capitalized",
|
||||
subtitle: "mx-auto max-w-3xl text-xl text-muted-foreground",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mb-8 text-center">
|
||||
<a href="/blog" class="text-primary hover:underline">← Quay lại blog</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{page.data.map((post) => <PostItem post={post} />)}
|
||||
</div>
|
||||
<Pagination page={page} />
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -5,6 +5,7 @@ import Headline from "@/components/ui/Headline.astro";
|
||||
import PostItem from "@/components/blog/PostItem.astro";
|
||||
import Pagination from "@/components/ui/Pagination.astro";
|
||||
import type { GetStaticPathsOptions } from "astro";
|
||||
import { tagMap } from "@/utils/taxonomy";
|
||||
|
||||
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
||||
const allPosts = await getCollection("blog");
|
||||
@@ -30,9 +31,13 @@ export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
||||
const { page } = Astro.props;
|
||||
const { tag } = Astro.params;
|
||||
|
||||
const tagInfo = tagMap.get(tag!);
|
||||
const displayName = tagInfo?.name ?? tag!;
|
||||
const description = tagInfo?.description;
|
||||
|
||||
const metadata = {
|
||||
title: `Posts tagged with '${tag}'`,
|
||||
description: `Explore our articles about ${tag}.`,
|
||||
title: displayName,
|
||||
description: description ?? `Các bài viết được gắn thẻ ${displayName}.`,
|
||||
};
|
||||
---
|
||||
|
||||
@@ -41,8 +46,8 @@ const metadata = {
|
||||
<div class="relative mx-auto max-w-5xl">
|
||||
<Headline
|
||||
tagline="Tag"
|
||||
title={`#${tag}`}
|
||||
subtitle={`Showing ${page.total} article${page.total === 1 ? "" : "s"}.`}
|
||||
title={`#${displayName}`}
|
||||
subtitle={description ?? `Hiển thị ${page.total} bài viết.`}
|
||||
classes={{
|
||||
container: "mb-12 text-center",
|
||||
title:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import categoryData from "@/content/taxonomies/category.json";
|
||||
import destinationData from "@/content/taxonomies/destination.json";
|
||||
import tagData from "@/content/taxonomies/tag.json";
|
||||
|
||||
export type TaxonomyItem = {
|
||||
slug: string;
|
||||
name: string;
|
||||
parent?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type TaxonomyMap = Map<string, TaxonomyItem>;
|
||||
|
||||
function buildMap(data: unknown[]): TaxonomyMap {
|
||||
return new Map((data as TaxonomyItem[]).map((item) => [item.slug, item]));
|
||||
}
|
||||
|
||||
const taxonomies: Record<string, TaxonomyMap> = {
|
||||
category: buildMap(categoryData),
|
||||
destination: buildMap(destinationData),
|
||||
tag: buildMap(tagData),
|
||||
};
|
||||
|
||||
export function getTaxonomyMap(type: string): TaxonomyMap {
|
||||
return taxonomies[type] ?? new Map();
|
||||
}
|
||||
|
||||
export function registerTaxonomy(type: string, data: TaxonomyItem[]): void {
|
||||
taxonomies[type] = buildMap(data);
|
||||
}
|
||||
|
||||
function buildHierarchicalPath(
|
||||
slug: string,
|
||||
map: TaxonomyMap,
|
||||
visited = new Set<string>(),
|
||||
): string {
|
||||
if (visited.has(slug)) return slug; // cycle protection
|
||||
visited.add(slug);
|
||||
const item = map.get(slug);
|
||||
if (!item?.parent) return slug;
|
||||
return `${buildHierarchicalPath(item.parent, map, new Set(visited))}/${slug}`;
|
||||
}
|
||||
|
||||
export function getTaxonomyPath(type: string, slug: string): string {
|
||||
return buildHierarchicalPath(slug, getTaxonomyMap(type));
|
||||
}
|
||||
|
||||
export function getTaxonomyItem(
|
||||
type: string,
|
||||
slug: string,
|
||||
): TaxonomyItem | undefined {
|
||||
return getTaxonomyMap(type).get(slug);
|
||||
}
|
||||
|
||||
// Convenience aliases kept for backward compatibility
|
||||
export const categoryMap = getTaxonomyMap("category");
|
||||
export const destinationMap = getTaxonomyMap("destination");
|
||||
export const tagMap = getTaxonomyMap("tag");
|
||||
|
||||
export const getCategoryPath = (slug: string) =>
|
||||
getTaxonomyPath("category", slug);
|
||||
export const getDestinationPath = (slug: string) =>
|
||||
getTaxonomyPath("destination", slug);
|
||||
export const getTagPath = (slug: string) => getTaxonomyPath("tag", slug);
|
||||
Reference in New Issue
Block a user