From 9e01d9b7543599eba4d012f6e019364ac6c3112f Mon Sep 17 00:00:00 2001 From: achmad Date: Thu, 28 May 2026 22:32:06 +0700 Subject: [PATCH] feat: category listing page with load-more pagination --- app/[category]/page.tsx | 78 +++++++++++++++++++++++++++ components/article/LoadMoreButton.tsx | 66 +++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 app/[category]/page.tsx create mode 100644 components/article/LoadMoreButton.tsx diff --git a/app/[category]/page.tsx b/app/[category]/page.tsx new file mode 100644 index 0000000..79f9d0f --- /dev/null +++ b/app/[category]/page.tsx @@ -0,0 +1,78 @@ +import { notFound } from 'next/navigation' +import type { Metadata } from 'next' +import { getCategoryBySlug, getArticles, getAllCategories } from '@/lib/directus' +import ArticleCard from '@/components/article/ArticleCard' +import LoadMoreButton from '@/components/article/LoadMoreButton' + +interface Props { + params: Promise<{ category: string }> +} + +export const revalidate = false + +export async function generateStaticParams() { + try { + const categories = await getAllCategories() + return categories.map((cat) => ({ category: cat.slug })) + } catch { + return [] + } +} + +export async function generateMetadata({ params }: Props): Promise { + const { category: categorySlug } = await params + try { + const category = await getCategoryBySlug(categorySlug) + if (!category) return {} + return { + title: category.name, + description: + category.description ?? `Latest ${category.name} news and articles on Kotobane.`, + } + } catch { + return {} + } +} + +export default async function CategoryPage({ params }: Props) { + const { category: categorySlug } = await params + let category = null + let articles: import('@/lib/types').Article[] = [] + + try { + const [cat, arts] = await Promise.all([ + getCategoryBySlug(categorySlug), + getArticles({ categorySlug, limit: 24 }), + ]) + category = cat + articles = arts + } catch { + category = null + } + + if (!category) notFound() + + return ( +
+
+

{category.name}

+ {category.description && ( +

{category.description}

+ )} +
+ + {articles.length === 0 ? ( +

No articles yet.

+ ) : ( + <> +
+ {articles.map((article) => ( + + ))} +
+ + + )} +
+ ) +} diff --git a/components/article/LoadMoreButton.tsx b/components/article/LoadMoreButton.tsx new file mode 100644 index 0000000..70bbb17 --- /dev/null +++ b/components/article/LoadMoreButton.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useState } from 'react' +import type { Article } from '@/lib/types' +import ArticleCard from './ArticleCard' + +interface Props { + categorySlug: string + initialCount: number +} + +export default function LoadMoreButton({ categorySlug, initialCount }: Props) { + const [articles, setArticles] = useState([]) + const [offset, setOffset] = useState(initialCount) + const [loading, setLoading] = useState(false) + const [hasMore, setHasMore] = useState(true) + + async function loadMore() { + setLoading(true) + try { + const params = new URLSearchParams({ + 'filter[status][_eq]': 'published', + 'filter[category][slug][_eq]': categorySlug, + sort: '-published_at', + limit: '12', + offset: String(offset), + fields: + 'id,title,slug,excerpt,featured_image,published_at,is_featured,category.id,category.name,category.slug', + access_token: process.env.NEXT_PUBLIC_DIRECTUS_TOKEN ?? '', + }) + const res = await fetch( + `${process.env.NEXT_PUBLIC_DIRECTUS_URL}/items/articles?${params}` + ) + const data = await res.json() + const newArticles: Article[] = data.data ?? [] + setArticles((prev) => [...prev, ...newArticles]) + setOffset((prev) => prev + newArticles.length) + if (newArticles.length < 12) setHasMore(false) + } finally { + setLoading(false) + } + } + + return ( + <> + {articles.length > 0 && ( +
+ {articles.map((article) => ( + + ))} +
+ )} + {hasMore && ( +
+ +
+ )} + + ) +}