diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index e402afb..bffbd6e 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -6,8 +6,9 @@ import { useSharedData } from '@/app/context/ProjectContext'; import { useMountTransition } from '@/hooks/useAnimations'; import "../../../styles/markdown-container.css"; import Link from 'next/link'; -import { BLOG_POSTS } from '@/data/content'; +import { BLOG_POSTS, categoryLabel } from '@/data/content'; import SimpleGallery from '@/components/SimpleGallery'; +import "../../../styles/blog.css"; export default function BlogPostPage({params}: { params: Promise<{ slug: string }> }) { @@ -32,6 +33,16 @@ export default function BlogPostPage({params}: { params: Promise<{ slug: string }} > + {hasValidPost.category && ( + + {categoryLabel(hasValidPost.category)} + + )} + { hasValidDetails ? ( diff --git a/app/blog/category/[category]/page.tsx b/app/blog/category/[category]/page.tsx new file mode 100644 index 0000000..fe73064 --- /dev/null +++ b/app/blog/category/[category]/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { blogCategories } from "@/data/content"; +import BlogList from "@/components/BlogList"; + +/** + * Shareable category URL: /blog/category/meta. Thin wrapper around the same + * client-side BlogList the /blog?category=… form renders -- the slug pins the + * category, the ?page= search param still drives pagination, and the chip row + * links use the ?category= form so navigation stays consistent across both. + * Unknown slugs 404: a real route can notFound(), a query param cannot. + */ +export default function BlogCategoryPage({ params }: { params: Promise<{ category: string }> }) { + const { category } = await params; + if (!category || !blogCategories().includes(category)) notFound(); + + return ( +
+ +
+ ); +} diff --git a/blog-pagination-and-categories-plan.md b/blog-pagination-and-categories-plan.md new file mode 100644 index 0000000..f1df0ff --- /dev/null +++ b/blog-pagination-and-categories-plan.md @@ -0,0 +1,107 @@ +# Plan: Blog Pagination, Post Categories & Category Filters + +## Context / current state + +- The blog is a data-only CMS: `BLOG_POSTS` in `data/content.ts` is the single source of truth (currently 2 posts). Posts are typed `BlogPost` objects with `tags: string[]` — there is no category concept today. +- Route flow: `app/blog/page.tsx` (thin server component) → `components/BlogList.tsx` (client component) → `components/BlogPostCard.tsx`. `BlogList` renders every post, newest first (`sortPostsNewestFirst`), with a staggered reveal animation (`useStaggerReveal`). +- Post detail: `app/blog/[slug]/page.tsx` renders markdown via the shared remark pipeline, with "Back To Top" / "All Posts" links. +- Styling conventions: inline styles using CSS variables (`var(--mono)`, `var(--accent)`, `var(--accent-bg)`, …) plus `styles/blog.css`. `ModList` has an established empty-state pattern ("Nothing published here yet."). Tag chips in `BlogPostCard` define the visual language for small mono chips. +- Stack: Next.js 16 (App Router) + React 19. No `useSearchParams` / `Suspense` usage anywhere yet. + +## Goals + +1. Paginate the blog directory (`/blog` list view). +2. Give each blog post a single `category` (free-form `tags` stay as-is). +3. Filter the blog directory by category: chip row + URL param, with a stable URL shape. + +## URL design + +| URL | Meaning | +|---|---| +| `/blog` | All posts, page 1 | +| `/blog?page=2` | Page 2 of all posts | +| `/blog?category=meta` | All `meta` posts, page 1 | +| `/blog?category=meta&page=2` | Page 2 of `meta` posts | + +- Category values are stored as slugs (kebab-case, lowercase) in the data, e.g. `meta`, `development`. A `categoryLabel(slug)` helper derives the display string ("meta", "Development"). +- All URL building goes through one helper (`blogHref({ page?, category? })`) so the shape has a single source of truth. + +## Changes by file + +### 1. `data/content.ts` +- Extend the `BlogPost` type: add `category: string; // slug, e.g. "meta" — one category per post` (keep `tags` untouched). +- Update existing posts: "Adding a blog tab to a portfolio site" → `category: "development"`; "Hello, world. This is the new blog." → `category: "meta"`. +- New exports: + - `POSTS_PER_PAGE = 3` — small enough that pagination is visible as soon as a few posts exist. + - `blogCategories(): string[]` — deduplicated, alphabetically sorted categories in use (derived from `BLOG_POSTS`, no new config). + - `categoryLabel(slug: string): string` — slug → display string. + - `postsForCategory(slug?: string): BlogPost[]` — all posts when omitted, otherwise posts whose `category` matches. + - `paginate(items: T[], page: number, perPage: number): { items: T[]; page: number; totalPages: number; total: number; startIndex: number; endIndex: number }` — pure helper that also **clamps** `page` into `[1, totalPages]` (so `?page=99` lands on the last page instead of erroring). + +### 2. `components/BlogList.tsx` (main change) +- Keep it a client component, but split it: default export `BlogList` wraps an inner `BlogListInner` (which calls `useSearchParams()`) in ``. **This is required** — `useSearchParams` on a statically rendered page makes `next build` fail without a Suspense boundary. +- Read params: `page = Number(searchParams.get("page") ?? 1)`, `category = searchParams.get("category") ?? undefined`. +- Data pipeline: `postsForCategory(category)` → `sortPostsNewestFirst` → `paginate(…, POSTS_PER_PAGE)`. +- **Category filter row** (chips, above the post stack): + - `all` + one chip per entry from `blogCategories()`, styled like the tag chips in `BlogPostCard` (mono 11px, padded, 4px radius); active chip uses `var(--accent)` / `var(--accent-bg)`. + - Each chip is a `next/link` to `blogHref({ category })` — switching categories **drops `page`** from the URL. +- **Pagination controls** (below the stack): + - `‹ Prev` / `Next ›` plus numbered page buttons (fine at this scale; revisit if we ever exceed ~9 pages). + - Reuse the existing `expand-on-hover-button` class so links match the site's look; render disabled states as non-links on first/last page (no dead hrefs). + - Mono/muted count line: `showing 1–3 of 5`. +- **Empty state**: when the filtered list is empty (e.g. unknown category slug) render "No posts in this category yet." in the `ModList` empty-state style. A query param shouldn't 404 — degrade gracefully. +- **Stagger animation**: `useStaggerReveal`'s effect only re-runs when its `count` changes, so re-trigger it per page by keying the card container with `${category ?? "all"}-${page}`. +- `BlogPostCard` stays unchanged (out of scope). + +### 3. `app/blog/page.tsx` +- No change: still renders ``; filtering/pagination are self-managed via search params. + +### 4. (Optional stretch) `app/blog/category/[category]/page.tsx` +- Thin server component: `notFound()` if the slug is not in `blogCategories()`, otherwise render ``. +- This requires `BlogList` to accept an optional `category` prop that takes precedence over the search param (the param still controls `page`). Chip links on that page use the `/blog?category=…` form so navigation stays consistent. +- Marked optional: the chips + `?category=` param already deliver the category feature; this route is only for cleaner shareable URLs/SEO. If time is short, skip it. + +### 5. `styles/blog.css` +- Add, matching the file's existing conventions: + - `.blog-category-filters` — flex row, wrap, gap, margin below the heading. + - `.blog-category-chip` (+ `.is-active`) — mono chip look, accent when active. + - `.blog-pagination` — flex row, `space-between`, mono ~12px, top margin. + - `.blog-count` — muted mono count line. + - Narrow-screen + `prefers-reduced-motion` tweaks, mirroring the existing media queries. + +### 6. (Optional, low priority) `app/blog/[slug]/page.tsx` +- Show the post's category as a small chip above the content, linking to `/blog?category=`, mirroring the tag-chip look. Nice-to-have for discoverability; doesn't block the core feature. + +## Out of scope + +- Tag-based or multi-select filtering (tags remain display-only metadata on cards). +- Custom metadata (`generateMetadata`) per category — `/blog?category=…` reuses the existing `/blog` metadata. +- Infinite scroll — explicit pagination was chosen for keyboard access and stable URLs. +- Editing tooling for posts — the data array remains the CMS, per the site's existing architecture. + +## Implementation order + +1. `data/content.ts` — type, data updates, helpers. (Build should still pass; nothing consumes the new fields yet.) +2. `BlogList` — category filtering + chips (no pagination yet). +3. `BlogList` — pagination: controls, count line, clamping, stagger re-trigger key. +4. `styles/blog.css` — chip + pagination + count styles. +5. Optional: `/blog/category/[category]` route; optional post-page category chip. +6. Verification (below). + +## Verification + +- `npm run build` — catches the `useSearchParams`/`Suspense` issue and any type errors. Next 16 is strict about static-rendering requirements. +- `npm run lint`. +- Manual pass: + - `/blog` shows all posts, page 1 active, `Next` disabled while `POSTS_PER_PAGE` covers everything. + - Temporarily add a 3rd–4th fixture post → page 2 appears; `?page=2` shows the rest; `?page=99` clamps to the last page. Remove fixtures afterwards. + - Chips: `?category=meta` shows only meta posts; Prev/Next keep the category in the URL; switching chips drops `page`. + - Unknown category slug → empty state, no crash. + - Post pages: "Back To Top" / "All Posts" still work; (if implemented) category chip links correctly. + +## Risks / notes + +- `useSearchParams` + static page ⇒ Suspense boundary is mandatory at build time (handled in step 2 of the component change). +- `useStaggerReveal` only re-runs when `count` changes ⇒ use the container key to re-trigger per page. +- Keep the design language: CSS variables, inline styles, existing button classes, no new dependencies. +- Dates are ISO and compare lexicographically — `sortPostsNewestFirst` keeps working unchanged. diff --git a/components/BlogList.tsx b/components/BlogList.tsx index fcd9adb..50a1c86 100644 --- a/components/BlogList.tsx +++ b/components/BlogList.tsx @@ -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 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 (
@@ -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"} -
- {posts.map((post, i) => ( - - ))} +
+ {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"; +} diff --git a/data/content.ts b/data/content.ts index 5aeebd3..6045f2a 100644 --- a/data/content.ts +++ b/data/content.ts @@ -50,6 +50,7 @@ export type BlogPost = { title: string; description: string; // 1-2 sentence excerpt shown on cards date: string; // display date, e.g. "2026-09-14" + category: string; // slug, e.g. "meta" — one category per post tags: string[]; images?: GalleryImage[]; // optional, rendered via SimpleGallery details: string; // markdown body @@ -227,6 +228,7 @@ export const BLOG_POSTS: BlogPost[] = [ title: "Adding a blog tab to a portfolio site", description: "A portfolio is a static-ish site with a lot of moving parts. Here's how I added a fourth tab to this very site without adding a single dependency.", date: "2026-10-26", + category: "development", tags: ["next.js", "react", "typescript"], details: `# Adding a blog tab to a portfolio site @@ -266,6 +268,7 @@ Editing a post from here on out is editing the array, which is the same as editi title: "Hello, world. This is the new blog.", description: "A short note about why this site grew a fourth tab, how the posts are written, and what the rest of this page demonstrates.", date: "2026-09-14", + category: "meta", tags: ["meta", "blog"], details: `# Hello, world. This is the new blog. @@ -294,6 +297,67 @@ This page is a dynamic route. The slug comes from the URL, the layout resolves i } ]; +/* ---- blog directory helpers ---- + * Pagination and category filtering for the /blog list. Everything derives + * from BLOG_POSTS above, which remains the single source of truth. */ + +/** Small enough that pagination is visible as soon as a few posts exist. */ +export const POSTS_PER_PAGE = 3; + +/** Deduplicated, alphabetically sorted categories currently in use. */ +export function blogCategories(): string[] { + return [...new Set(BLOG_POSTS.map((p) => p.category))].sort(); +} + +/** Slug -> display string: "development" -> "Development", "game-mods" -> "Game Mods". */ +export function categoryLabel(slug: string): string { + return slug + .split(/[-_]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** All posts when omitted, otherwise the posts in the given category. */ +export function postsForCategory(slug?: string): BlogPost[] { + if (!slug) return BLOG_POSTS; + return BLOG_POSTS.filter((p) => p.category === slug); +} + +/** + * Pure pagination over an already-sorted array. Clamps `page` into + * [1, totalPages], so out-of-range values (?page=99) land on the last page + * instead of producing an empty or error state. + */ +export function paginate( + items: T[], + page: number, + perPage: number +): { + items: T[]; + page: number; + totalPages: number; + total: number; + startIndex: number; + endIndex: number; +} { + const total = items.length; + const safePerPage = Math.max(1, Math.floor(perPage)); + const totalPages = Math.max(1, Math.ceil(total / safePerPage)); + const requested = Number.isFinite(page) ? Math.floor(page) : NaN; + const clamped = Math.min(Math.max(1, requested || 1), totalPages); + const startIndex = total === 0 ? 0 : (clamped - 1) * safePerPage + 1; + const endIndex = Math.min(total, clamped * safePerPage); + + return { + items: items.slice((clamped - 1) * safePerPage, clamped * safePerPage), + page: clamped, + totalPages, + total, + startIndex, + endIndex, + }; +} export const MOD_GAMES: ModdedGame[] = [{ id: 1, diff --git a/styles/blog.css b/styles/blog.css index 4ca7126..63afc19 100644 --- a/styles/blog.css +++ b/styles/blog.css @@ -6,6 +6,7 @@ display: flex; flex-direction: column; gap: 16px; + margin-top: 24px; } /* Card hover/entrance states are driven by inline styles in BlogPostCard, @@ -14,15 +15,154 @@ min-width: 0; } -/* Narrow layout: the same card, just tighter. */ +/* Empty state for an unknown or not-yet-written category, mirroring the + ModList pattern ("Nothing published here yet.") -- a query param should + degrade gracefully, not 404. */ +.blog-list-empty { + font-family: var(--mono); + font-size: var(--text-xs); + color: var(--muted); + margin: 24px 0 0; +} + +/* Suspense fallback while useSearchParams resolves during static rendering: + keeps the column's rhythm so the list paints in without a layout jump. */ +.blog-list-fallback { + padding: 48px 0; + min-width: 0; +} + +/* "showing 1–3 of 5" -- muted mono count line above the filters. */ +.blog-count { + font-family: var(--mono); + font-size: var(--text-xs); + color: var(--muted); + margin: 0 0 12px; +} + +/* Category chip row, styled after the tag chips in BlogPostCard. */ +.blog-category-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 0; +} + +.blog-category-chip { + font-family: var(--mono); + font-size: 11px; + font-weight: 500; + color: var(--muted); + padding: 3px 10px; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + letter-spacing: 0.02em; + text-decoration: none; + white-space: nowrap; + transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease; +} + +.blog-category-chip:hover { + color: var(--fg); + border-color: var(--border-strong); +} + +.blog-category-chip.is-active { + color: var(--accent); + background: var(--accent-bg); + border-color: var(--accent); +} + +/* Prev/Next + numbered pages, below the stack. */ +.blog-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-top: 28px; +} + +/* Page number: small mono chip, accent when it is the current page. */ +.blog-page-number { + font-family: var(--mono); + font-size: 11px; + font-weight: 500; + color: var(--fg-secondary); + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 4px; + text-decoration: none; + transition: color 0.2s ease, border-color 0.2s ease; +} + +.blog-page-number:hover { + color: var(--fg); + border-color: var(--border-strong); +} + +.blog-page-number.is-active { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-bg); +} + +/* Prev/Next: the site's existing button class carries the size, shape and + hover-scale; this pins its content and keeps the disabled span aligned. */ +.blog-page-link { + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + font-size: 12px; +} + +/* Disabled Prev/Next: non-link span styled identically, dimmed. */ +.blog-pagination span.blog-page-link { + color: var(--faint); + border-color: var(--border-subtle); + background: transparent; + scale: 1; + cursor: default; + user-select: none; + pointer-events: none; +} + +/* Blog post page: the category chip above the content reuses the filter + chip's look (is-active state) and links to /blog?category=. */ +.blog-post-category-chip { + margin-bottom: 12px; +} + +/* Narrow layout: the same controls, just tighter. */ @media (max-width: 768px) { .blog-post-card { padding: 20px; } + + .blog-pagination { + margin-top: 20px; + } + + .blog-category-chip, + .blog-page-number { + padding: 4px 8px; + } } @media (prefers-reduced-motion: reduce) { .blog-post-card { transition: none; } + + .blog-category-chip, + .blog-page-number, + .blog-page-link { + transition: none; + } + + .blog-pagination span.blog-page-link { + scale: none; + } }