diff --git a/src/components/ui/Categories.astro b/src/components/ui/Categories.astro index 8fc8979..33d0248 100644 --- a/src/components/ui/Categories.astro +++ b/src/components/ui/Categories.astro @@ -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) => (
  • - {category} + {categoryMap.get(category)?.name ?? category}
  • ))} diff --git a/src/components/ui/Destinations.astro b/src/components/ui/Destinations.astro index 4ab9c09..fce439c 100644 --- a/src/components/ui/Destinations.astro +++ b/src/components/ui/Destinations.astro @@ -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) => (
  • - {destination} + {destinationMap.get(destination)?.name ?? destination}
  • ))} diff --git a/src/components/ui/Tags.astro b/src/components/ui/Tags.astro index 6432375..889b8b5 100644 --- a/src/components/ui/Tags.astro +++ b/src/components/ui/Tags.astro @@ -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} ))} diff --git a/src/content/taxonomies/category.json b/src/content/taxonomies/category.json new file mode 100644 index 0000000..98b6c3a --- /dev/null +++ b/src/content/taxonomies/category.json @@ -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." + } +] diff --git a/src/content/taxonomies/destination.json b/src/content/taxonomies/destination.json new file mode 100644 index 0000000..ccdc491 --- /dev/null +++ b/src/content/taxonomies/destination.json @@ -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" + } +] diff --git a/src/content/taxonomies/tag.json b/src/content/taxonomies/tag.json new file mode 100644 index 0000000..9e1406e --- /dev/null +++ b/src/content/taxonomies/tag.json @@ -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" + } +] diff --git a/src/pages/category/[...rest].astro b/src/pages/category/[...rest].astro new file mode 100644 index 0000000..6e0ec49 --- /dev/null +++ b/src/pages/category/[...rest].astro @@ -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(); + 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>; + +const metadata = { + title: cat.name, + description: cat.description ?? `Các bài viết trong danh mục ${cat.name}.`, +}; +--- + + +
    +
    + + + + +
    + {posts.map((post) => )} +
    + +
    +
    +
    diff --git a/src/pages/category/[category]/[...page].astro b/src/pages/category/[category]/[...page].astro index ed319ac..8de289f 100644 --- a/src/pages/category/[category]/[...page].astro +++ b/src/pages/category/[category]/[...page].astro @@ -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 = {
    post.data && post.data.pubDate); + + const destSlugs = new Set(); + 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>; + +const metadata = { + title: dest.name, + description: dest.description ?? `Khám phá các bài viết về ${dest.name}.`, +}; +--- + + +
    +
    + + + + +
    + {posts.map((post) => )} +
    + +
    +
    +
    diff --git a/src/pages/destination/[destination]/[...page].astro b/src/pages/destination/[destination]/[...page].astro deleted file mode 100644 index bc948cf..0000000 --- a/src/pages/destination/[destination]/[...page].astro +++ /dev/null @@ -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(); - 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}.`, -}; ---- - - -
    -
    - - - - -
    - {page.data.map((post) => )} -
    - -
    -
    -
    diff --git a/src/pages/tag/[tag]/[...page].astro b/src/pages/tag/[tag]/[...page].astro index bffeb93..2065677 100644 --- a/src/pages/tag/[tag]/[...page].astro +++ b/src/pages/tag/[tag]/[...page].astro @@ -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 = {
    ; + +function buildMap(data: unknown[]): TaxonomyMap { + return new Map((data as TaxonomyItem[]).map((item) => [item.slug, item])); +} + +const taxonomies: Record = { + 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 { + 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);