22 Commits

Author SHA1 Message Date
lfox
35526331fa fixed permissions issues with deploy watch target 2026-11-01 21:47:41 +00:00
cdc37851cd blog: update posts 2026-11-01 21:47:16 +00:00
3e161eb00e blog: update posts 2026-11-01 21:42:29 +00:00
a93868d885 blog: update posts 2026-11-01 21:36:11 +00:00
6dfc113975 blog: update posts 2026-11-01 21:30:37 +00:00
386c17b496 blog: update posts 2026-11-01 21:28:28 +00:00
0a4ea99e1a blog: update posts 2026-11-01 21:22:36 +00:00
9c78ff2d25 blog: update posts 2026-11-01 21:18:43 +00:00
5c366a2137 blog: update posts 2026-11-01 21:08:13 +00:00
7b67dd68f0 blog: update posts 2026-11-01 21:06:03 +00:00
lfox
2c5b403522 added DEPLOY_TRIGGER_DIR to possible .env vars, updated compose 2026-11-01 21:04:48 +00:00
76d0260a52 blog: update posts 2026-11-01 21:02:53 +00:00
debfc217ba blog: update posts 2026-11-01 20:59:59 +00:00
a0b3ac7c0d blog: update posts 2026-11-01 20:03:40 +00:00
lfox
31c1303a0f updated deploy scripts 2026-11-01 20:02:17 +00:00
df2041ba67 blog: update posts 2026-11-01 20:01:21 +00:00
Hunter
51e5ef9aee added .env.example 2026-11-01 13:29:53 -05:00
Hunter
8796682bb7 moved blog posts out of content.ts for external management 2026-11-01 11:14:38 -05:00
Hunter
ef1cc9144d fixed small typo in import 2026-11-01 01:47:41 -04:00
FerrenF
806125f5e2 added pagination controls, test 2026-11-01 05:15:25 +00:00
Hunter
bbb5efeb72 small bug 2026-10-30 23:30:02 -04:00
FerrenF
f3840c4fb6 missing files 2026-10-31 03:13:41 +00:00
20 changed files with 954 additions and 10 deletions

1
.codebase-memory.json Normal file
View File

@@ -0,0 +1 @@
{"extra_extensions": {"package.json": "json", ".json": "json"}}

1
.env.example Normal file
View File

@@ -0,0 +1 @@
DEPLOY_TRIGGER_SECRET=#A random secret string used to trigger the live site's redeploy after a successful push from blog-manager. Must match DEPLOY_TRIGGER_SECRET in blog-manager/.env.

2
.gitignore vendored
View File

@@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env
# vercel # vercel
.vercel .vercel

View File

@@ -9,6 +9,7 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci RUN npm ci
COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build RUN npm run build
@@ -22,7 +23,7 @@ ENV HOSTNAME=0.0.0.0
ENV PORT=3050 ENV PORT=3050
RUN addgroup --system --gid 1001 app && \ RUN addgroup --system --gid 1001 app && \
adduser --system --uid 1001 app adduser --system --uid 1001 -G app app
COPY --from=build /app/public ./public COPY --from=build /app/public ./public
COPY --from=build --chown=app:app /app/.next/standalone ./ COPY --from=build --chown=app:app /app/.next/standalone ./

View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { ProjectProvider } from "@/app/context/ProjectContext";
import { getBlogDetails, BLOG_POSTS } from "@/data/content";
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const post = BLOG_POSTS.find((p) => p.slug === slug);
if (!post) return { title: "Post not found" };
return { title: `${post.title} | Blog`, description: post.description };
}
/**
* Layout for all paths caught under blog/[slug].
* Mirrors app/project/[slug]/layout.tsx: the rendered markdown is resolved
* here on the server and handed to the client page via the shared context.
* (ProjectContext is a generic `any` context despite its name; its value
* contract is `string | null` = rendered HTML.)
*/
export default async function BlogSlugLayout({ children, params }: {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const postData = await getBlogDetails(slug);
return (
<ProjectProvider value={postData}>
<main>
{children}
</main>
</ProjectProvider>
);
}

87
app/blog/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,87 @@
'use client'
import { use } from 'react'
import { notFound } from 'next/navigation'
import { useSharedData } from '@/app/context/ProjectContext';
import { useMountTransition } from '@/hooks/useAnimations';
import "../../../styles/markdown-container.css";
import Link from 'next/link';
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 }> })
{
const mounted = useMountTransition(50)
const { slug } = use(params);
const hasValidPost = BLOG_POSTS.find((v)=>v.slug == slug);
if (!slug || !hasValidPost) notFound();
const hasValidDetails = hasValidPost.details
const postDetails = useSharedData();
return (
<div
id="blog-markdown-container"
className={"markdown-container"}
style={{
opacity: mounted ? 1 : 0,
transition: "all 0.5s ease",
translate: mounted ? "0 2em" : "0 0"
}}
>
{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 ?
(
<main dangerouslySetInnerHTML={{ __html: postDetails }}>
</main>
) : (
<p>No post details available.</p>
)
}
{hasValidPost.images && hasValidPost.images.length > 0 && (
<section className="project-details-gallery">
<h2>Gallery</h2>
<SimpleGallery images={hasValidPost.images} title={hasValidPost.title} gallerySize="large"/>
</section>
)}
<div style={{
display: "flex",
flexDirection: "row",
gap: "1em",
marginBottom: "2em"
}}>
<Link onClick={(e)=>{
window.scrollTo({
top: 0,
left: 0,
behavior: 'smooth'
});
}}
className="expand-on-hover-button"
href={"#"}>Back To Top</Link>
<Link
className="expand-on-hover-button"
href={"/blog"}>All Posts</Link>
</div>
</div>
);
}

View 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 async function BlogCategoryPage({ params }: { params: Promise<{ category: string }> }) {
const { category } = await params;
if (!category || !blogCategories().includes(category)) notFound();
return (
<main>
<BlogList category={category} />
</main>
);
}

57
app/blog/layout.tsx Normal file
View File

@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import Nav from "@/components/Nav";
import Footer from "@/components/Footer";
export async function generateMetadata(): Promise<Metadata> {
return {
title: "Blog",
description: "Notes, write-ups and behind-the-scenes from Hunter",
};
}
export default async function BlogLayout({children}: {
children: React.ReactNode;
}) {
return (
<div style={{ minHeight: "100vh", paddingBottom: 36 }}>
<div
style={{
maxWidth: 880,
margin: "0 auto",
padding: "0 24px",
transition: "all 0.5s cubic-bezier(0.16, 1, 0.3, 1)",
}}>
<header
style={{
padding: "24px 0 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<div
style={{
fontFamily: "var(--mono)",
fontSize: 14,
fontWeight: 700,
color: "var(--accent)",
letterSpacing: "-0.02em",
}}>
</div>
</header>
<Nav section={"blog"} />
<main>
{children}
</main>
</div>
<Footer />
</div>
);
}

11
app/blog/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
"use client";
import BlogList from "@/components/BlogList";
export default function Blog() {
return (
<main>
<BlogList />
</main>
);
}

View 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.

183
components/BlogList.tsx Normal file
View File

@@ -0,0 +1,183 @@
"use client";
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 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";
}

149
components/BlogPostCard.tsx Normal file
View File

@@ -0,0 +1,149 @@
"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>
);
}

View File

@@ -7,7 +7,7 @@ type Props = {
section: string; section: string;
}; };
const items = ["profile", "project", "mods"]; const items = ["profile", "project", "blog", "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 24px", padding: "12px 16px",
cursor: "pointer", cursor: "pointer",
fontFamily: "inherit", fontFamily: "inherit",
fontSize: "inherit", fontSize: "inherit",

29
data/blog-posts.json Normal file
View File

@@ -0,0 +1,29 @@
[
{
"id": 1,
"slug": "adding-a-blog-tab",
"title": "Testing testing 123 12134",
"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": "# Testing testing 12345\n\nThis 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.\"\n\n<blockquote>\nA blog is just another collection of cards. The difference is what happens when you click one.\n</blockquote>\n\n## The shape of the problem\n\nEvery tab on this site follows the same recipe:\n\n- A typed data array in a single content file, which acts as the whole CMS\n- A shared layout that provides the nav chrome and the footer\n- A thin client page that renders one list component\n- A detail page that reuses the same layout, minus the nav, plus a context provider\n\nA 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.\n\n## Why the markdown was already there\n\nThe 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.\n\nThat 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.\n\n## What actually changed\n\nA 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.\n\nEditing a post from here on out is editing the array, which is the same as editing a project. That's the part I wanted.\n"
},
{
"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.\n\nThis 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.\n\n<blockquote>\nIf a site is only ever going to have three tabs, it's a portfolio. The fourth tab is where it starts being a site.\n</blockquote>\n\n## Why it exists\n\nThe 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.\n\n## How posts are written\n\nEach 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.\n\n- Headings render in the mono face, like the rest of the site\n- Blockquotes get the raised, bordered treatment you can see above\n- Images are kept out of this post on purpose. The pipeline supports them, and a future post will use one\n\n## What this page is doing while you read it\n\nThis 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.\n"
}
]

View File

@@ -1,3 +1,5 @@
import blogPosts from "./blog-posts.json";
export type MoreSection = { export type MoreSection = {
title?: string; title?: string;
@@ -44,6 +46,18 @@ 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;
@@ -209,6 +223,72 @@ A real-world solution for event staff and volunteer management.
}]; }];
/* Blog posts live in blog-posts.json so tooling (blog-manager/) can edit them
* without rewriting TypeScript. The shape must match the BlogPost type above. */
export const BLOG_POSTS: BlogPost[] = blogPosts as BlogPost[];
/* ---- blog directory helpers ----
* Pagination and category filtering for the /blog list. Everything derives
* from BLOG_POSTS (data/blog-posts.json), 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",
@@ -235,13 +315,29 @@ import rehypeStringify from "rehype-stringify";
import remarkRehype from "remark-rehype"; import remarkRehype from "remark-rehype";
export async function getProjectDetails(slug: string) { /**
const p = PROJECTS.find((v)=>v.slug == slug); * Shared remark -> rehype pipeline. Content is authored in-repo and reviewed
if (!p || !p.details) return null; * 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() const re = await remark()
.use(remarkParse) .use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true }) .use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw) .use(rehypeRaw)
.use(rehypeStringify).process(p.details); .use(rehypeStringify)
return re?.toString(); .process(source);
return re?.toString() ?? "";
}
export async function getProjectDetails(slug: string) {
const p = PROJECTS.find((v)=>v.slug == slug);
if (!p || !p.details) return null;
return renderMarkdown(p.details);
}
export async function getBlogDetails(slug: string) {
const p = BLOG_POSTS.find((v) => v.slug == slug);
if (!p || !p.details) return null;
return renderMarkdown(p.details);
} }

0
scripts/deploy-watch.service Normal file → Executable file
View File

0
scripts/deploy-watch.sh Normal file → Executable file
View File

0
scripts/install-deploy-watch.sh Normal file → Executable file
View File

0
scripts/uninstall-deploy-watch.sh Normal file → Executable file
View File

168
styles/blog.css Normal file
View File

@@ -0,0 +1,168 @@
/* ---- 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;
}
}