Compare commits
2 Commits
bbb5efeb72
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef1cc9144d | ||
|
|
806125f5e2 |
@@ -6,8 +6,9 @@ import { useSharedData } from '@/app/context/ProjectContext';
|
|||||||
import { useMountTransition } from '@/hooks/useAnimations';
|
import { useMountTransition } from '@/hooks/useAnimations';
|
||||||
import "../../../styles/markdown-container.css";
|
import "../../../styles/markdown-container.css";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { BLOG_POSTS } from '@/data/content';
|
import { BLOG_POSTS, categoryLabel } from '@/data/content';
|
||||||
import SimpleGallery from '@/components/SimpleGallery';
|
import SimpleGallery from '@/components/SimpleGallery';
|
||||||
|
import "../../../styles/blog.css";
|
||||||
|
|
||||||
export default function BlogPostPage({params}: { params: Promise<{ slug: string }> })
|
export default function BlogPostPage({params}: { params: Promise<{ slug: string }> })
|
||||||
{
|
{
|
||||||
@@ -32,6 +33,16 @@ export default function BlogPostPage({params}: { params: Promise<{ slug: string
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
{hasValidPost.category && (
|
||||||
|
<Link
|
||||||
|
className="blog-category-chip is-active blog-post-category-chip"
|
||||||
|
href={`/blog?category=${hasValidPost.category}`}
|
||||||
|
title={`All ${categoryLabel(hasValidPost.category)} posts`}
|
||||||
|
>
|
||||||
|
{categoryLabel(hasValidPost.category)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{
|
{
|
||||||
hasValidDetails ?
|
hasValidDetails ?
|
||||||
(
|
(
|
||||||
|
|||||||
21
app/blog/category/[category]/page.tsx
Normal file
21
app/blog/category/[category]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<main>
|
||||||
|
<BlogList category={category} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
blog-pagination-and-categories-plan.md
Normal file
107
blog-pagination-and-categories-plan.md
Normal file
@@ -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<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.
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BLOG_POSTS, type BlogPost } from "@/data/content";
|
import { Suspense } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
POSTS_PER_PAGE,
|
||||||
|
blogCategories,
|
||||||
|
categoryLabel,
|
||||||
|
paginate,
|
||||||
|
postsForCategory,
|
||||||
|
type BlogPost,
|
||||||
|
} from "@/data/content";
|
||||||
import { useStaggerReveal } from "@/hooks/useAnimations";
|
import { useStaggerReveal } from "@/hooks/useAnimations";
|
||||||
import BlogPostCard from "@/components/BlogPostCard";
|
import BlogPostCard from "@/components/BlogPostCard";
|
||||||
import "../styles/blog.css";
|
import "../styles/blog.css";
|
||||||
@@ -10,10 +20,49 @@ function sortPostsNewestFirst(posts: BlogPost[]): BlogPost[] {
|
|||||||
return [...posts].sort((a, b) => b.date.localeCompare(a.date));
|
return [...posts].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The blog tab: every post, newest first, in a single vertical stack. */
|
type BlogListProps = {
|
||||||
export default function BlogList() {
|
/**
|
||||||
const posts = sortPostsNewestFirst(BLOG_POSTS);
|
* Category slug (e.g. from /blog/category/[category]) that takes precedence
|
||||||
const visible = useStaggerReveal(posts.length, 100);
|
* 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 (
|
return (
|
||||||
<div style={{ padding: "48px 0", minWidth: 0 }}>
|
<div style={{ padding: "48px 0", minWidth: 0 }}>
|
||||||
@@ -35,18 +84,100 @@ export default function BlogList() {
|
|||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "var(--fg)",
|
color: "var(--fg)",
|
||||||
margin: "0 0 32px 0",
|
margin: "0 0 16px 0",
|
||||||
letterSpacing: "-0.01em",
|
letterSpacing: "-0.01em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{"Writing"}
|
{"Writing"}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="blog-list">
|
<div className="blog-count" aria-live="polite">
|
||||||
{posts.map((post, i) => (
|
{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)} />
|
<BlogPostCard key={post.id} post={post} visible={visible.has(i)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export type BlogPost = {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string; // 1-2 sentence excerpt shown on cards
|
description: string; // 1-2 sentence excerpt shown on cards
|
||||||
date: string; // display date, e.g. "2026-09-14"
|
date: string; // display date, e.g. "2026-09-14"
|
||||||
|
category: string; // slug, e.g. "meta" — one category per post
|
||||||
tags: string[];
|
tags: string[];
|
||||||
images?: GalleryImage[]; // optional, rendered via SimpleGallery
|
images?: GalleryImage[]; // optional, rendered via SimpleGallery
|
||||||
details: string; // markdown body
|
details: string; // markdown body
|
||||||
@@ -227,6 +228,7 @@ export const BLOG_POSTS: BlogPost[] = [
|
|||||||
title: "Adding a blog tab to a portfolio site",
|
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.",
|
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",
|
date: "2026-10-26",
|
||||||
|
category: "development",
|
||||||
tags: ["next.js", "react", "typescript"],
|
tags: ["next.js", "react", "typescript"],
|
||||||
details: `# Adding a blog tab to a portfolio site
|
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.",
|
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.",
|
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",
|
date: "2026-09-14",
|
||||||
|
category: "meta",
|
||||||
tags: ["meta", "blog"],
|
tags: ["meta", "blog"],
|
||||||
details: `# Hello, world. This is the new 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<T>(
|
||||||
|
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[] = [{
|
export const MOD_GAMES: ModdedGame[] = [{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
142
styles/blog.css
142
styles/blog.css
@@ -6,6 +6,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card hover/entrance states are driven by inline styles in BlogPostCard,
|
/* Card hover/entrance states are driven by inline styles in BlogPostCard,
|
||||||
@@ -14,15 +15,154 @@
|
|||||||
min-width: 0;
|
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=<slug>. */
|
||||||
|
.blog-post-category-chip {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Narrow layout: the same controls, just tighter. */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.blog-post-card {
|
.blog-post-card {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-pagination {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-category-chip,
|
||||||
|
.blog-page-number {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.blog-post-card {
|
.blog-post-card {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-category-chip,
|
||||||
|
.blog-page-number,
|
||||||
|
.blog-page-link {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-pagination span.blog-page-link {
|
||||||
|
scale: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user