Files
hwilliams-dev/blog-pagination-and-categories-plan.md
2026-11-01 05:15:25 +00:00

108 lines
8.0 KiB
Markdown
Raw 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.
# 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<T>(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 `<Suspense fallback={…}>`. **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 `<BlogList />`; 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 `<BlogList category={slug} />`.
- 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=<slug>`, 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.