Compare commits
1 Commits
pagination
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c521907512 |
@@ -1 +0,0 @@
|
|||||||
{"extra_extensions": {"package.json": "json", ".json": "json"}}
|
|
||||||
@@ -6,9 +6,8 @@ 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, categoryLabel } from '@/data/content';
|
import { BLOG_POSTS } 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 }> })
|
||||||
{
|
{
|
||||||
@@ -33,16 +32,6 @@ 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 ?
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# 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,182 +0,0 @@
|
|||||||
"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";
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { type BlogPost, imageSrc, imageDescription } from "@/data/content";
|
|
||||||
import "../styles/blog.css";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
post: BlogPost;
|
|
||||||
visible: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One blog post: the card in the blog list. Unlike ProjectCard, clicking
|
|
||||||
* navigates to the post instead of expanding in place. */
|
|
||||||
export default function BlogPostCard({ post, visible }: Props) {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
function postTagsComponent() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 8,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{post.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--accent)",
|
|
||||||
padding: "3px 10px",
|
|
||||||
background: "var(--accent-bg)",
|
|
||||||
borderRadius: 4,
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function coverImageComponent() {
|
|
||||||
if (!post.images || post.images.length === 0) return null;
|
|
||||||
const image = post.images![0];
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={`/${imageSrc(image)}`}
|
|
||||||
alt={imageDescription(image) ?? `${post.title} cover image`}
|
|
||||||
width={800}
|
|
||||||
height={180}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: 16,
|
|
||||||
objectFit: "cover",
|
|
||||||
height: 180,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
className="blog-post-card"
|
|
||||||
title={`Read: ${post.title}`}
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
width: "100%",
|
|
||||||
textDecoration: "none",
|
|
||||||
padding: 28,
|
|
||||||
overflow: "hidden",
|
|
||||||
background: hovered ? "var(--surface-hover)" : "var(--surface)",
|
|
||||||
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
|
|
||||||
borderRadius: 8,
|
|
||||||
transition: "all 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
||||||
transform: visible
|
|
||||||
? hovered
|
|
||||||
? "translateY(-3px)"
|
|
||||||
: "translateY(0)"
|
|
||||||
: "translateY(12px)",
|
|
||||||
opacity: visible ? 1 : 0,
|
|
||||||
boxShadow: hovered ? "0 8px 32px rgba(0,0,0,0.12)" : "none",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: hovered ? "var(--accent)" : "var(--fg)",
|
|
||||||
transition: "color 0.2s",
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{post.title}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--muted)",
|
|
||||||
padding: "2px 8px",
|
|
||||||
background: "var(--bg)",
|
|
||||||
borderRadius: 4,
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{post.date}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{postTagsComponent()}
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--sans)",
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 1.6,
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
margin: "0 0 16px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{post.description}
|
|
||||||
</p>
|
|
||||||
{post.images && post.images.length > 0 && coverImageComponent()}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ type Props = {
|
|||||||
section: string;
|
section: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = ["profile", "project", "blog", "mods"];
|
const items = ["profile", "project", "mods"];
|
||||||
|
|
||||||
export default function Nav({ section }: Props) {
|
export default function Nav({ section }: Props) {
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export default function Nav({ section }: Props) {
|
|||||||
color: section === item ? "var(--accent)" : "var(--muted)",
|
color: section === item ? "var(--accent)" : "var(--muted)",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderBottom: section === item ? "2px solid var(--accent)" : "2px solid transparent",
|
borderBottom: section === item ? "2px solid var(--accent)" : "2px solid transparent",
|
||||||
padding: "12px 16px",
|
padding: "12px 24px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontFamily: "inherit",
|
fontFamily: "inherit",
|
||||||
fontSize: "inherit",
|
fontSize: "inherit",
|
||||||
|
|||||||
178
data/content.ts
178
data/content.ts
@@ -44,18 +44,6 @@ export type Project = {
|
|||||||
year: string;
|
year: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BlogPost = {
|
|
||||||
id: number;
|
|
||||||
slug: string; // kebab-case, URL-safe
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Mod = {
|
export type Mod = {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -221,144 +209,6 @@ A real-world solution for event staff and volunteer management.
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
|
|
||||||
export const BLOG_POSTS: BlogPost[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
slug: "adding-a-blog-tab",
|
|
||||||
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
|
|
||||||
|
|
||||||
This little corner of the site started as a question: **how does long-form content fit into a site that was built for short-form stuff?** Portfolio sites are grids of cards, and a blog is a column of words. The answer turned out to be \"don't treat them differently at all.\"
|
|
||||||
|
|
||||||
<blockquote>
|
|
||||||
A blog is just another collection of cards. The difference is what happens when you click one.
|
|
||||||
</blockquote>
|
|
||||||
|
|
||||||
## The shape of the problem
|
|
||||||
|
|
||||||
Every tab on this site follows the same recipe:
|
|
||||||
|
|
||||||
- A typed data array in a single content file, which acts as the whole CMS
|
|
||||||
- A shared layout that provides the nav chrome and the footer
|
|
||||||
- A thin client page that renders one list component
|
|
||||||
- A detail page that reuses the same layout, minus the nav, plus a context provider
|
|
||||||
|
|
||||||
A post that fits that shape is just another entry in an array. A post that doesn't fit it is a content management system in disguise, and that's a whole other project.
|
|
||||||
|
|
||||||
## Why the markdown was already there
|
|
||||||
|
|
||||||
The project detail pages render markdown through a small remark pipeline that has been sitting in the codebase for a while, used by the project pages. The blog needed exactly the same thing, which meant the data layer for a blog post is mostly just a type and a lookup function. The pipeline is shared, so a post and a project detail are rendered by the same code, with the same quirks, and the same fixes.
|
|
||||||
|
|
||||||
That is the whole trick. The feature felt like it would need infrastructure, but the infrastructure already existed for a different feature. When a new feature turns out to be a clone of an old one, the new feature is cheap, and the codebase gets a little more regular as a side effect.
|
|
||||||
|
|
||||||
## What actually changed
|
|
||||||
|
|
||||||
A new type, a new data array, a new lookup function, a new tab in the nav, two layouts, a list page, a card, and a detail page. No new dependencies, no new build steps, no new configuration. The tab order now reads profile, project, blog, mods, and the blog sits in the middle of the two portfolio tabs.
|
|
||||||
|
|
||||||
Editing a post from here on out is editing the array, which is the same as editing a project. That's the part I wanted.
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
slug: "hello-world",
|
|
||||||
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.
|
|
||||||
|
|
||||||
This is a blog now. There's a fourth tab up top, and it works the same way the rest of the site works: content lives in the repository as plain data, the page renders it, and the markdown is processed by the same pipeline that renders the project detail pages.
|
|
||||||
|
|
||||||
<blockquote>
|
|
||||||
If a site is only ever going to have three tabs, it's a portfolio. The fourth tab is where it starts being a site.
|
|
||||||
</blockquote>
|
|
||||||
|
|
||||||
## Why it exists
|
|
||||||
|
|
||||||
The portfolio is for finished things, and the mods page is for the things I ship to other people's games. This is for the middle: write-ups, notes, and the occasional \"here's how I did that\" that doesn't belong anywhere else. It's also the first place on this site where I write in the first person at length, which is a change.
|
|
||||||
|
|
||||||
## How posts are written
|
|
||||||
|
|
||||||
Each post is a typed object in the content file, with a slug, a date, a short description for the card, and a markdown body. The body goes through the site's existing remark pipeline, so any markdown that works on the project pages works here, including raw HTML blocks when I want a specific layout.
|
|
||||||
|
|
||||||
- Headings render in the mono face, like the rest of the site
|
|
||||||
- Blockquotes get the raised, bordered treatment you can see above
|
|
||||||
- Images are kept out of this post on purpose. The pipeline supports them, and a future post will use one
|
|
||||||
|
|
||||||
## What this page is doing while you read it
|
|
||||||
|
|
||||||
This page is a dynamic route. The slug comes from the URL, the layout resolves it to rendered HTML on the server, and the page on the client side reads that and paints it in. That's the same architecture the project detail pages use, which is the point of reusing it: a new page type that can't do anything the old page types can't already do.
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ---- 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,
|
||||||
slug: "palworld",
|
slug: "palworld",
|
||||||
@@ -385,29 +235,13 @@ import rehypeStringify from "rehype-stringify";
|
|||||||
import remarkRehype from "remark-rehype";
|
import remarkRehype from "remark-rehype";
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared remark -> rehype pipeline. Content is authored in-repo and reviewed
|
|
||||||
* via git, and allowDangerousHtml is intentional: post/project markdown may
|
|
||||||
* embed raw HTML blocks (see the blockquote-with-image idiom).
|
|
||||||
*/
|
|
||||||
async function renderMarkdown(source: string): Promise<string> {
|
|
||||||
const re = await remark()
|
|
||||||
.use(remarkParse)
|
|
||||||
.use(remarkRehype, { allowDangerousHtml: true })
|
|
||||||
.use(rehypeRaw)
|
|
||||||
.use(rehypeStringify)
|
|
||||||
.process(source);
|
|
||||||
return re?.toString() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProjectDetails(slug: string) {
|
export async function getProjectDetails(slug: string) {
|
||||||
const p = PROJECTS.find((v)=>v.slug == slug);
|
const p = PROJECTS.find((v)=>v.slug == slug);
|
||||||
if (!p || !p.details) return null;
|
if (!p || !p.details) return null;
|
||||||
return renderMarkdown(p.details);
|
const re = await remark()
|
||||||
}
|
.use(remarkParse)
|
||||||
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
export async function getBlogDetails(slug: string) {
|
.use(rehypeRaw)
|
||||||
const p = BLOG_POSTS.find((v) => v.slug == slug);
|
.use(rehypeStringify).process(p.details);
|
||||||
if (!p || !p.details) return null;
|
return re?.toString();
|
||||||
return renderMarkdown(p.details);
|
|
||||||
}
|
}
|
||||||
168
styles/blog.css
168
styles/blog.css
@@ -1,168 +0,0 @@
|
|||||||
/* ---- blog tab ---- */
|
|
||||||
|
|
||||||
/* Vertical stack of post cards. Cards are linear reading content, so unlike
|
|
||||||
the project grid there is no column packing -- one column, newest first. */
|
|
||||||
.blog-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card hover/entrance states are driven by inline styles in BlogPostCard,
|
|
||||||
mirroring ProjectCard; this is the base layer those styles sit on top of. */
|
|
||||||
.blog-post-card {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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) {
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user