added pagination controls, test
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { BLOG_POSTS, type BlogPost } from "@/data/content";
|
||||
import { Suspense, useSearchParams } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
POSTS_PER_PAGE,
|
||||
blogCategories,
|
||||
categoryLabel,
|
||||
paginate,
|
||||
postsForCategory,
|
||||
type BlogPost,
|
||||
} from "@/data/content";
|
||||
import { useStaggerReveal } from "@/hooks/useAnimations";
|
||||
import BlogPostCard from "@/components/BlogPostCard";
|
||||
import "../styles/blog.css";
|
||||
@@ -10,10 +19,49 @@ function sortPostsNewestFirst(posts: BlogPost[]): BlogPost[] {
|
||||
return [...posts].sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
/** The blog tab: every post, newest first, in a single vertical stack. */
|
||||
export default function BlogList() {
|
||||
const posts = sortPostsNewestFirst(BLOG_POSTS);
|
||||
const visible = useStaggerReveal(posts.length, 100);
|
||||
type BlogListProps = {
|
||||
/**
|
||||
* Category slug (e.g. from /blog/category/[category]) that takes precedence
|
||||
* over the ?category= search param. The param still controls ?page=.
|
||||
*/
|
||||
category?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The blog tab: a paginated, category-filterable stack of posts, newest first.
|
||||
*
|
||||
* useSearchParams() suspends during static rendering, so Next.js requires it
|
||||
* to sit inside a <Suspense> boundary -- /blog is statically rendered, which
|
||||
* is exactly why the default export wraps the inner component.
|
||||
*/
|
||||
export default function BlogList({ category: categoryProp }: BlogListProps) {
|
||||
return (
|
||||
<Suspense fallback={<div className="blog-list-fallback" />}
|
||||
>
|
||||
<BlogListInner categoryProp={categoryProp} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogListInner({ categoryProp }: { categoryProp?: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const rawPage = searchParams.get("page") ?? "";
|
||||
const requestedPage = rawPage === "" ? 1 : Number(rawPage);
|
||||
const paramCategory = searchParams.get("category") ?? undefined;
|
||||
const category = categoryProp ?? paramCategory;
|
||||
|
||||
const categories = blogCategories();
|
||||
const posts = sortPostsNewestFirst(postsForCategory(category));
|
||||
const { items, page, totalPages, total, startIndex, endIndex } = paginate(
|
||||
posts,
|
||||
requestedPage,
|
||||
POSTS_PER_PAGE
|
||||
);
|
||||
const visible = useStaggerReveal(items.length, 100);
|
||||
|
||||
const activeCategory = category ?? "all";
|
||||
const activeCategoryLabel = category ? categoryLabel(category) : "All";
|
||||
|
||||
return (
|
||||
<div style={{ padding: "48px 0", minWidth: 0 }}>
|
||||
@@ -35,18 +83,100 @@ export default function BlogList() {
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: "var(--fg)",
|
||||
margin: "0 0 32px 0",
|
||||
margin: "0 0 16px 0",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{"Writing"}
|
||||
</h2>
|
||||
|
||||
<div className="blog-list">
|
||||
{posts.map((post, i) => (
|
||||
<BlogPostCard key={post.id} post={post} visible={visible.has(i)} />
|
||||
))}
|
||||
<div className="blog-count" aria-live="polite">
|
||||
{category ? `In ${activeCategoryLabel}: ` : ""}
|
||||
{total > 0 ? `showing ${startIndex}–${endIndex} of ${total}` : "no posts"}
|
||||
</div>
|
||||
|
||||
<nav className="blog-category-filters" aria-label="Filter posts by category">
|
||||
<Link
|
||||
href={blogHref({})}
|
||||
className={"blog-category-chip" + (activeCategory === "all" ? " is-active" : "")}
|
||||
aria-current={activeCategory === "all" ? "page" : undefined}
|
||||
>
|
||||
{"all"}
|
||||
</Link>
|
||||
{categories.map((slug) => (
|
||||
<Link
|
||||
key={slug}
|
||||
href={blogHref({ category: slug })}
|
||||
className={"blog-category-chip" + (category === slug ? " is-active" : "")}
|
||||
aria-current={category === slug ? "page" : undefined}
|
||||
>
|
||||
{categoryLabel(slug)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{items.length > 0 ? (
|
||||
// Keyed per category+page: useStaggerReveal only re-runs when its
|
||||
// count changes, so remounting the container re-triggers the reveal
|
||||
// even when consecutive pages hold the same number of posts.
|
||||
<div className="blog-list" key={`${activeCategory}-${page}`}>
|
||||
{items.map((post, i) => (
|
||||
<BlogPostCard key={post.id} post={post} visible={visible.has(i)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="blog-list-empty">No posts in this category yet.</p>
|
||||
)}
|
||||
|
||||
<nav className="blog-pagination" aria-label="Blog pagination">
|
||||
{page > 1 ? (
|
||||
<Link className="blog-page-link expand-on-hover-button" href={blogHref({ page: page - 1, category })}>
|
||||
{"‹ Prev"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="blog-page-link expand-on-hover-button" aria-disabled="true" tabIndex={-1}>
|
||||
{"‹ Prev"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="blog-page-numbers">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) =>
|
||||
n === page ? (
|
||||
<span key={n} className="blog-page-number is-active" aria-current="page">
|
||||
{n}
|
||||
</span>
|
||||
) : (
|
||||
<Link key={n} className="blog-page-number" href={blogHref({ page: n, category })}>
|
||||
{n}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{page < totalPages ? (
|
||||
<Link className="blog-page-link expand-on-hover-button" href={blogHref({ page: page + 1, category })}>
|
||||
{"Next ›"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="blog-page-link expand-on-hover-button" aria-disabled="true" tabIndex={-1}>
|
||||
{"Next ›"}
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for /blog URLs: /blog, /blog?page=2,
|
||||
* /blog?category=meta, /blog?category=meta&page=2. Omitted values are
|
||||
* dropped (page 1 is the default page, the missing category is "all"),
|
||||
* so chip links never carry a stale ?page=.
|
||||
*/
|
||||
function blogHref({ page, category }: { page?: number; category?: string } = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.set("category", category);
|
||||
if (page !== undefined && page > 1) params.set("page", String(page));
|
||||
const qs = params.toString();
|
||||
return qs ? `/blog?${qs}` : "/blog";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user