Compare commits
2 Commits
experiment
...
bbb5efeb72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb5efeb72 | ||
|
|
f3840c4fb6 |
1
.codebase-memory.json
Normal file
1
.codebase-memory.json
Normal file
@@ -0,0 +1 @@
|
||||
{"extra_extensions": {"package.json": "json", ".json": "json"}}
|
||||
33
app/blog/[slug]/layout.tsx
Normal file
33
app/blog/[slug]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
app/blog/[slug]/page.tsx
Normal file
76
app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'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 } from '@/data/content';
|
||||
import SimpleGallery from '@/components/SimpleGallery';
|
||||
|
||||
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"
|
||||
}}
|
||||
>
|
||||
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
57
app/blog/layout.tsx
Normal file
57
app/blog/layout.tsx
Normal 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
11
app/blog/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import BlogList from "@/components/BlogList";
|
||||
|
||||
export default function Blog() {
|
||||
return (
|
||||
<main>
|
||||
<BlogList />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
52
components/BlogList.tsx
Normal file
52
components/BlogList.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
149
components/BlogPostCard.tsx
Normal file
149
components/BlogPostCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
section: string;
|
||||
};
|
||||
|
||||
const items = ["profile", "project", "mods"];
|
||||
const items = ["profile", "project", "blog", "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 24px",
|
||||
padding: "12px 16px",
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
|
||||
112
data/content.ts
112
data/content.ts
@@ -44,6 +44,17 @@ 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;
|
||||
@@ -209,6 +220,81 @@ 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",
|
||||
@@ -235,13 +321,29 @@ import rehypeStringify from "rehype-stringify";
|
||||
import remarkRehype from "remark-rehype";
|
||||
|
||||
|
||||
export async function getProjectDetails(slug: string) {
|
||||
const p = PROJECTS.find((v)=>v.slug == slug);
|
||||
if (!p || !p.details) return null;
|
||||
/**
|
||||
* 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(p.details);
|
||||
return re?.toString();
|
||||
.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);
|
||||
}
|
||||
28
styles/blog.css
Normal file
28
styles/blog.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/* ---- 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user