Files
hwilliams-dev/components/BlogList.tsx
2026-11-01 05:15:25 +00:00

183 lines
5.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 <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 }}>
<div
style={{
marginBottom: 8,
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
{"# blog"}
</div>
<h2
style={{
fontFamily: "var(--sans)",
fontSize: 28,
fontWeight: 700,
color: "var(--fg)",
margin: "0 0 16px 0",
letterSpacing: "-0.01em",
}}
>
{"Writing"}
</h2>
<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";
}