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 (#2)
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
|
import { getTaxonomyMap, getTaxonomyPath } from "@/utils/taxonomy";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { categories, class: className = "text-sm" } = Astro.props;
|
const { categories, class: className = "text-sm" } = Astro.props;
|
||||||
|
const categoryMap = getTaxonomyMap("category");
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -13,10 +16,10 @@ const { categories, class: className = "text-sm" } = Astro.props;
|
|||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<li class="inline-block relative">
|
<li class="inline-block relative">
|
||||||
<a
|
<a
|
||||||
href={`/category/${category}`}
|
href={`/category/${getTaxonomyPath("category", 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"
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
|
import { getTaxonomyMap, getTaxonomyPath } from "@/utils/taxonomy";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
destinations: string[];
|
destinations: string[];
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { destinations, class: className = "text-sm" } = Astro.props;
|
const { destinations, class: className = "text-sm" } = Astro.props;
|
||||||
|
const destinationMap = getTaxonomyMap("destination");
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -13,7 +16,7 @@ const { destinations, class: className = "text-sm" } = Astro.props;
|
|||||||
{destinations.map((destination) => (
|
{destinations.map((destination) => (
|
||||||
<li class="inline-block relative">
|
<li class="inline-block relative">
|
||||||
<a
|
<a
|
||||||
href={`/destination/${destination}`}
|
href={`/destination/${getTaxonomyPath("destination", 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"
|
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
|
<svg
|
||||||
@@ -30,7 +33,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" />
|
<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" />
|
<circle cx="12" cy="10" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
{destination}
|
{destinationMap.get(destination)?.name ?? destination}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
---
|
---
|
||||||
import type { Page } from "astro";
|
|
||||||
import type { CollectionEntry } from "astro:content";
|
|
||||||
import { Icon } from "astro-icon/components";
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
|
export interface SimplePage {
|
||||||
|
currentPage: number;
|
||||||
|
lastPage: number;
|
||||||
|
url: { prev?: string; next?: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
page: Page<CollectionEntry<"blog">>;
|
page: SimplePage;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
|
import { getTaxonomyMap } from "@/utils/taxonomy";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tags, class: className = "text-sm" } = Astro.props;
|
const { tags, class: className = "text-sm" } = Astro.props;
|
||||||
|
const tagMap = getTaxonomyMap("tag");
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -16,7 +19,7 @@ const { tags, class: className = "text-sm" } = Astro.props;
|
|||||||
href={`/tag/${tag}`}
|
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"
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import Destinations from "@/components/ui/Destinations.astro";
|
|||||||
import { toR2Url } from "@/utils/r2";
|
import { toR2Url } from "@/utils/r2";
|
||||||
import Picture from "@/components/ui/Picture.astro";
|
import Picture from "@/components/ui/Picture.astro";
|
||||||
|
|
||||||
import { getCollection, render } from "astro:content";
|
import { getCollection, render, type CollectionEntry } from "astro:content";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
post: CollectionEntry<"blog">;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const blog = await getCollection("blog");
|
const blog = await getCollection("blog");
|
||||||
@@ -17,7 +21,7 @@ export async function getStaticPaths() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { post } = Astro.props;
|
const { post } = Astro.props as Props;
|
||||||
const { Content, remarkPluginFrontmatter } = await render(post);
|
const { Content, remarkPluginFrontmatter } = await render(post);
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||||
import { getCollection } from "astro:content";
|
import { getCollection } from "astro:content";
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
import Headline from "@/components/ui/Headline.astro";
|
import Headline from "@/components/ui/Headline.astro";
|
||||||
import Pagination from "@/components/ui/Pagination.astro";
|
import Pagination from "@/components/ui/Pagination.astro";
|
||||||
import PostItem from "@/components/blog/PostItem.astro";
|
import PostItem from "@/components/blog/PostItem.astro";
|
||||||
import type { GetStaticPathsOptions } from "astro";
|
import { buildPaginatedPaths } from "@/utils/paginate";
|
||||||
|
|
||||||
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
interface Props {
|
||||||
|
page: {
|
||||||
|
data: CollectionEntry<"blog">[];
|
||||||
|
total: number;
|
||||||
|
currentPage: number;
|
||||||
|
lastPage: number;
|
||||||
|
url: { prev?: string; next?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
const blogEntries = await getCollection("blog");
|
const blogEntries = await getCollection("blog");
|
||||||
const sortedPosts = blogEntries
|
const sortedPosts = blogEntries
|
||||||
.filter((post) => post.data && post.data.pubDate)
|
.filter((post) => post.data && post.data.pubDate)
|
||||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||||
|
|
||||||
return paginate(sortedPosts, { pageSize: 6 });
|
return buildPaginatedPaths(sortedPosts, "/blog").map(
|
||||||
|
({ pageParam, page }) => ({
|
||||||
|
params: { page: pageParam },
|
||||||
|
props: { page },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page } = Astro.props;
|
const { page } = Astro.props as Props;
|
||||||
const blog = page.data;
|
const blog = page.data;
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
import type { CollectionEntry } 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 {
|
||||||
|
getTaxonomyMap,
|
||||||
|
getTaxonomyPath,
|
||||||
|
type TaxonomyItem,
|
||||||
|
} from "@/utils/taxonomy";
|
||||||
|
import { buildPaginatedPaths } from "@/utils/paginate";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cat: TaxonomyItem;
|
||||||
|
posts: CollectionEntry<"blog">[];
|
||||||
|
currentPage: number;
|
||||||
|
lastPage: number;
|
||||||
|
total: number;
|
||||||
|
prevUrl?: string;
|
||||||
|
nextUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
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 = getTaxonomyPath("category", slug);
|
||||||
|
const cat =
|
||||||
|
getTaxonomyMap("category").get(slug) ??
|
||||||
|
({ slug, name: slug } as TaxonomyItem);
|
||||||
|
|
||||||
|
return buildPaginatedPaths(catPosts, `/category/${fullPath}`).map(
|
||||||
|
({ pageParam, page }) => ({
|
||||||
|
params: { rest: pageParam ? `${fullPath}/${pageParam}` : fullPath },
|
||||||
|
props: {
|
||||||
|
cat,
|
||||||
|
posts: page.data,
|
||||||
|
currentPage: page.currentPage,
|
||||||
|
lastPage: page.lastPage,
|
||||||
|
total: page.total,
|
||||||
|
prevUrl: page.url.prev,
|
||||||
|
nextUrl: page.url.next,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cat, posts, currentPage, lastPage, total, prevUrl, nextUrl } =
|
||||||
|
Astro.props as Props;
|
||||||
|
|
||||||
|
const page = {
|
||||||
|
currentPage,
|
||||||
|
lastPage,
|
||||||
|
url: { prev: prevUrl, next: nextUrl },
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -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 categories = new Set<string>();
|
|
||||||
posts.forEach((post) => {
|
|
||||||
post.data.categories?.forEach((cat) => categories.add(cat));
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(categories).flatMap((category) => {
|
|
||||||
const categoryPosts = posts
|
|
||||||
.filter((post) => post.data.categories?.includes(category))
|
|
||||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
|
||||||
|
|
||||||
return paginate(categoryPosts, {
|
|
||||||
params: { category },
|
|
||||||
pageSize: 6,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { page } = Astro.props;
|
|
||||||
const { category } = Astro.params;
|
|
||||||
|
|
||||||
const metadata = {
|
|
||||||
title: `Category: ${category}`,
|
|
||||||
description: `Articles in the ${category} category.`,
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<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={category}
|
|
||||||
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>
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
import type { CollectionEntry } 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 {
|
||||||
|
getTaxonomyMap,
|
||||||
|
getTaxonomyPath,
|
||||||
|
type TaxonomyItem,
|
||||||
|
} from "@/utils/taxonomy";
|
||||||
|
import { buildPaginatedPaths } from "@/utils/paginate";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dest: TaxonomyItem;
|
||||||
|
posts: CollectionEntry<"blog">[];
|
||||||
|
currentPage: number;
|
||||||
|
lastPage: number;
|
||||||
|
total: number;
|
||||||
|
prevUrl?: string;
|
||||||
|
nextUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
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 = getTaxonomyPath("destination", slug);
|
||||||
|
const dest =
|
||||||
|
getTaxonomyMap("destination").get(slug) ??
|
||||||
|
({ slug, name: slug } as TaxonomyItem);
|
||||||
|
|
||||||
|
return buildPaginatedPaths(destPosts, `/destination/${fullPath}`).map(
|
||||||
|
({ pageParam, page }) => ({
|
||||||
|
params: { rest: pageParam ? `${fullPath}/${pageParam}` : fullPath },
|
||||||
|
props: {
|
||||||
|
dest,
|
||||||
|
posts: page.data,
|
||||||
|
currentPage: page.currentPage,
|
||||||
|
lastPage: page.lastPage,
|
||||||
|
total: page.total,
|
||||||
|
prevUrl: page.url.prev,
|
||||||
|
nextUrl: page.url.next,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dest, posts, currentPage, lastPage, total, prevUrl, nextUrl } =
|
||||||
|
Astro.props as Props;
|
||||||
|
|
||||||
|
const page = {
|
||||||
|
currentPage,
|
||||||
|
lastPage,
|
||||||
|
url: { prev: prevUrl, next: nextUrl },
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||||
import { getCollection } from "astro:content";
|
import { getCollection } from "astro:content";
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
import Headline from "@/components/ui/Headline.astro";
|
import Headline from "@/components/ui/Headline.astro";
|
||||||
import PostItem from "@/components/blog/PostItem.astro";
|
import PostItem from "@/components/blog/PostItem.astro";
|
||||||
import Pagination from "@/components/ui/Pagination.astro";
|
import Pagination from "@/components/ui/Pagination.astro";
|
||||||
import type { GetStaticPathsOptions } from "astro";
|
import { getTaxonomyMap } from "@/utils/taxonomy";
|
||||||
|
import { buildPaginatedPaths } from "@/utils/paginate";
|
||||||
|
|
||||||
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
interface Props {
|
||||||
|
page: {
|
||||||
|
data: CollectionEntry<"blog">[];
|
||||||
|
total: number;
|
||||||
|
currentPage: number;
|
||||||
|
lastPage: number;
|
||||||
|
url: { prev?: string; next?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
const allPosts = await getCollection("blog");
|
const allPosts = await getCollection("blog");
|
||||||
const posts = allPosts.filter((post) => post.data && post.data.pubDate);
|
const posts = allPosts.filter((post) => post.data && post.data.pubDate);
|
||||||
|
|
||||||
@@ -20,19 +32,25 @@ export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
|||||||
.filter((post) => post.data.tags?.includes(tag))
|
.filter((post) => post.data.tags?.includes(tag))
|
||||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||||
|
|
||||||
return paginate(tagPosts, {
|
return buildPaginatedPaths(tagPosts, `/tag/${tag}`).map(
|
||||||
params: { tag },
|
({ pageParam, page }) => ({
|
||||||
pageSize: 6,
|
params: { tag, page: pageParam },
|
||||||
});
|
props: { page },
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page } = Astro.props;
|
const { page } = Astro.props as Props;
|
||||||
const { tag } = Astro.params;
|
const { tag } = Astro.params;
|
||||||
|
|
||||||
|
const tagInfo = getTaxonomyMap("tag").get(tag!);
|
||||||
|
const displayName = tagInfo?.name ?? tag!;
|
||||||
|
const description = tagInfo?.description;
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
title: `Posts tagged with '${tag}'`,
|
title: displayName,
|
||||||
description: `Explore our articles about ${tag}.`,
|
description: description ?? `Các bài viết được gắn thẻ ${displayName}.`,
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,8 +59,8 @@ const metadata = {
|
|||||||
<div class="relative mx-auto max-w-5xl">
|
<div class="relative mx-auto max-w-5xl">
|
||||||
<Headline
|
<Headline
|
||||||
tagline="Tag"
|
tagline="Tag"
|
||||||
title={`#${tag}`}
|
title={`#${displayName}`}
|
||||||
subtitle={`Showing ${page.total} article${page.total === 1 ? "" : "s"}.`}
|
subtitle={description ?? `Hiển thị ${page.total} bài viết.`}
|
||||||
classes={{
|
classes={{
|
||||||
container: "mb-12 text-center",
|
container: "mb-12 text-center",
|
||||||
title:
|
title:
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export const DEFAULT_PAGE_SIZE = 6;
|
||||||
|
|
||||||
|
export interface PageMeta {
|
||||||
|
data: unknown[];
|
||||||
|
total: number;
|
||||||
|
currentPage: number;
|
||||||
|
lastPage: number;
|
||||||
|
url: { prev?: string; next?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns paginated static paths using WordPress-style /page/{n} URLs.
|
||||||
|
*
|
||||||
|
* @param items Full sorted list of items to paginate.
|
||||||
|
* @param basePath Base URL without trailing slash, e.g. "/blog" or "/category/travel".
|
||||||
|
* @param pageSize Number of items per page (default: DEFAULT_PAGE_SIZE).
|
||||||
|
* @returns Array of { pageParam, page } objects where pageParam is the route segment
|
||||||
|
* (undefined for page 1, "page/N" for N≥2) and page is a PageMeta object.
|
||||||
|
* Callers should map these into Astro's { params, props } format.
|
||||||
|
*/
|
||||||
|
export function buildPaginatedPaths<T>(
|
||||||
|
items: T[],
|
||||||
|
basePath: string,
|
||||||
|
pageSize = DEFAULT_PAGE_SIZE,
|
||||||
|
) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||||
|
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => {
|
||||||
|
const pageNum = i + 1;
|
||||||
|
const data = items.slice(i * pageSize, (i + 1) * pageSize);
|
||||||
|
|
||||||
|
const prev =
|
||||||
|
pageNum === 1
|
||||||
|
? undefined
|
||||||
|
: pageNum === 2
|
||||||
|
? basePath
|
||||||
|
: `${basePath}/page/${pageNum - 1}`;
|
||||||
|
const next =
|
||||||
|
pageNum === totalPages ? undefined : `${basePath}/page/${pageNum + 1}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// page param: undefined for page 1 (matches the base URL), "page/N" for N≥2
|
||||||
|
pageParam: pageNum === 1 ? undefined : `page/${pageNum}`,
|
||||||
|
page: {
|
||||||
|
data,
|
||||||
|
total: items.length,
|
||||||
|
currentPage: pageNum,
|
||||||
|
lastPage: totalPages,
|
||||||
|
url: { prev, next },
|
||||||
|
} as PageMeta & { data: T[] },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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>;
|
||||||
|
|
||||||
|
export type TaxonomyType = "category" | "destination" | "tag";
|
||||||
|
|
||||||
|
function isTaxonomyItem(value: unknown): value is TaxonomyItem {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const v = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof v.slug === "string" &&
|
||||||
|
typeof v.name === "string" &&
|
||||||
|
(v.parent === undefined || typeof v.parent === "string") &&
|
||||||
|
(v.description === undefined || typeof v.description === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMap(data: TaxonomyItem[]): TaxonomyMap {
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error(`[taxonomy] expected an array but received ${typeof data}`);
|
||||||
|
}
|
||||||
|
return new Map(
|
||||||
|
data.map((item, i) => {
|
||||||
|
if (!isTaxonomyItem(item)) {
|
||||||
|
throw new Error(
|
||||||
|
`[taxonomy] invalid item at index ${i}: ${JSON.stringify(item)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [item.slug, item];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxonomies: Record<TaxonomyType, TaxonomyMap> = {
|
||||||
|
category: buildMap(categoryData as TaxonomyItem[]),
|
||||||
|
destination: buildMap(destinationData as TaxonomyItem[]),
|
||||||
|
tag: buildMap(tagData as TaxonomyItem[]),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTaxonomyMap(type: TaxonomyType): TaxonomyMap {
|
||||||
|
if (!(type in taxonomies)) {
|
||||||
|
throw new Error(
|
||||||
|
`[taxonomy] unknown taxonomy type "${type}". Expected one of: ${Object.keys(taxonomies).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return taxonomies[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
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, visited)}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaxonomyPath(type: TaxonomyType, slug: string): string {
|
||||||
|
return buildHierarchicalPath(slug, getTaxonomyMap(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaxonomyItem(
|
||||||
|
type: TaxonomyType,
|
||||||
|
slug: string,
|
||||||
|
): TaxonomyItem | undefined {
|
||||||
|
return getTaxonomyMap(type).get(slug);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user