108 lines
8.0 KiB
Markdown
108 lines
8.0 KiB
Markdown
# 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.
|