"use client";
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";
/** Sort posts newest-first by date (ISO display dates compare lexicographically). */
function sortPostsNewestFirst(posts: BlogPost[]): BlogPost[] {
return [...posts].sort((a, b) => b.date.localeCompare(a.date));
}
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 boundary -- /blog is statically rendered, which
* is exactly why the default export wraps the inner component.
*/
export default function BlogList({ category: categoryProp }: BlogListProps) {
return (
}
>
);
}
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 (
{"# blog"}
{"Writing"}
{category ? `In ${activeCategoryLabel}: ` : ""}
{total > 0 ? `showing ${startIndex}–${endIndex} of ${total}` : "no posts"}
{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.
{items.map((post, i) => (
))}
) : (
No posts in this category yet.
)}
);
}
/**
* 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";
}