1 Commits

Author SHA1 Message Date
FerrenF
c521907512 added blog 2026-10-31 03:08:41 +00:00
6 changed files with 8 additions and 340 deletions

View File

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

View File

@@ -1,52 +0,0 @@
"use client";
import { BLOG_POSTS, 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));
}
/** The blog tab: every post, newest first, in a single vertical stack. */
export default function BlogList() {
const posts = sortPostsNewestFirst(BLOG_POSTS);
const visible = useStaggerReveal(posts.length, 100);
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 32px 0",
letterSpacing: "-0.01em",
}}
>
{"Writing"}
</h2>
<div className="blog-list">
{posts.map((post, i) => (
<BlogPostCard key={post.id} post={post} visible={visible.has(i)} />
))}
</div>
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -7,7 +7,7 @@ type Props = {
section: string;
};
const items = ["profile", "project", "blog", "mods"];
const items = ["profile", "project", "mods"];
export default function Nav({ section }: Props) {
@@ -36,7 +36,7 @@ export default function Nav({ section }: Props) {
color: section === item ? "var(--accent)" : "var(--muted)",
border: "none",
borderBottom: section === item ? "2px solid var(--accent)" : "2px solid transparent",
padding: "12px 16px",
padding: "12px 24px",
cursor: "pointer",
fontFamily: "inherit",
fontSize: "inherit",

View File

@@ -44,17 +44,6 @@ export type Project = {
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"
tags: string[];
images?: GalleryImage[]; // optional, rendered via SimpleGallery
details: string; // markdown body
};
export type Mod = {
id: number;
title: string;
@@ -220,81 +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",
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",
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.
`,
}
];
export const MOD_GAMES: ModdedGame[] = [{
id: 1,
slug: "palworld",
@@ -321,29 +235,13 @@ import rehypeStringify from "rehype-stringify";
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) {
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);
const re = await remark()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeStringify).process(p.details);
return re?.toString();
}

View File

@@ -1,28 +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;
}
/* 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;
}
/* Narrow layout: the same card, just tighter. */
@media (max-width: 768px) {
.blog-post-card {
padding: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.blog-post-card {
transition: none;
}
}