feat: retro blog engine with swappable OS/console themes

Next.js 16 (App Router, TS) + SQLite (better-sqlite3) + marked.

Core: a shared semantic HTML skeleton (rb- classes in Shell.tsx) that
each theme reskins via a scoped [data-theme="..."] CSS file. Theme is
persisted in a cookie, resolved server-side in the root layout, and
swapped live by the client switcher (no reload, no FOUC).

- DB auto-migrates and seeds posts on first run (data/blog.db, gitignored)
- Pages: post list, post detail (markdown), about
- Themes shipped: Windows XP (Luna), Windows 9x, PlayStation 2
- Adding a skin = registry entry + one scoped CSS file

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 00:42:20 +02:00
commit 37ba9b3e19
29 changed files with 5752 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import Shell from "@/components/Shell";
import { getTheme } from "@/themes/server";
import { THEMES } from "@/themes/registry";
export default async function AboutPage() {
const theme = await getTheme();
return (
<Shell theme={theme} title="About">
<article className="rb-article">
<h1 className="rb-article-title">About RetroBlog</h1>
<div className="rb-prose">
<p>
RetroBlog is a blog engine that wears the chrome of bygone operating
systems and game consoles. The posts live in a SQLite database; the
look lives entirely in CSS. Switch skins from the menu bar and the
same content reskins instantly.
</p>
<h2>Available skins</h2>
<ul>
{THEMES.map((t) => (
<li key={t.id}>
<strong>{t.name}</strong> ({t.era}) {t.blurb}
</li>
))}
</ul>
<h2>How it works</h2>
<p>
A shared, semantic HTML skeleton (the <code>rb-</code> classes) is
restyled per theme. Adding a new skin means writing one scoped CSS
file and registering it no markup changes required.
</p>
</div>
</article>
</Shell>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+184
View File
@@ -0,0 +1,184 @@
/* ============================================================
RetroBlog — globals
Shared, theme-agnostic skeleton. Visual identity lives in the
per-theme files imported below, each scoped to
[data-theme="..."]. This file only handles reset + layout
structure that every skin reuses.
============================================================ */
@import "../themes/xp.css";
@import "../themes/ninex.css";
@import "../themes/ps2.css";
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
min-height: 100vh;
}
a {
color: inherit;
}
img {
max-width: 100%;
}
/* ---- structural layout (positioning only; themes paint) ---- */
.rb-desktop {
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 0 0 40px; /* room for the fixed taskbar */
}
.rb-window {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
margin: 24px auto;
width: min(920px, calc(100% - 32px));
}
.rb-titlebar {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
user-select: none;
}
.rb-titlebar-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rb-titlebar-buttons {
display: flex;
gap: 2px;
}
.rb-menubar {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.rb-spacer {
flex: 1;
}
.rb-content {
flex: 1;
min-height: 0;
overflow: auto;
}
.rb-statusbar {
display: flex;
gap: 4px;
flex: 0 0 auto;
}
.rb-status-grow {
flex: 1;
text-align: right;
}
/* taskbar pinned to viewport bottom */
.rb-taskbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 40px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 6px;
z-index: 50;
}
.rb-tasks {
flex: 1;
display: flex;
gap: 4px;
overflow: hidden;
}
.rb-tray {
display: flex;
align-items: center;
}
/* ---- post list ---- */
.rb-post-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.rb-post-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.rb-post-card-title {
margin: 0;
}
.rb-post-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.rb-post-tags {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
}
.rb-readmore {
margin-left: auto;
}
.rb-article-meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.rb-switcher {
display: flex;
align-items: center;
gap: 6px;
}
:root {
color-scheme: light;
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import "./globals.css";
import { getTheme } from "@/themes/server";
export const metadata: Metadata = {
title: "RetroBlog",
description: "A blog engine wearing the skins of retro OSes and consoles.",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const theme = await getTheme();
return (
<html lang="en" data-theme={theme}>
<body>{children}</body>
</html>
);
}
+47
View File
@@ -0,0 +1,47 @@
import Link from "next/link";
import Shell from "@/components/Shell";
import { getTheme } from "@/themes/server";
import { getAllPosts, formatDate } from "@/lib/posts";
export default async function HomePage() {
const theme = await getTheme();
const posts = getAllPosts();
return (
<Shell theme={theme} title="Home">
<header className="rb-page-head">
<h1 className="rb-page-title">RetroBlog</h1>
<p className="rb-page-sub">
Words from the past, rendered in the chrome of the past.
</p>
</header>
<ul className="rb-post-list">
{posts.map((p) => (
<li key={p.id} className="rb-post-card">
<div className="rb-post-card-head">
<h2 className="rb-post-card-title">
<Link href={`/posts/${p.slug}`}>{p.title}</Link>
</h2>
<span className="rb-post-date">{formatDate(p.createdAt)}</span>
</div>
<p className="rb-post-excerpt">{p.excerpt}</p>
<div className="rb-post-meta">
<span className="rb-post-author">by {p.author}</span>
<span className="rb-post-tags">
{p.tags.map((t) => (
<span key={t} className="rb-tag">
{t}
</span>
))}
</span>
<Link className="rb-readmore" href={`/posts/${p.slug}`}>
Read more &raquo;
</Link>
</div>
</li>
))}
</ul>
</Shell>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import Shell from "@/components/Shell";
import { getTheme } from "@/themes/server";
import {
getAllPosts,
getPostBySlug,
renderMarkdown,
formatDate,
} from "@/lib/posts";
export function generateStaticParams() {
return getAllPosts().map((p) => ({ slug: p.slug }));
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const theme = await getTheme();
const post = getPostBySlug(slug);
if (!post) notFound();
const html = renderMarkdown(post.body);
return (
<Shell theme={theme} title={post.title}>
<article className="rb-article">
<Link className="rb-back" href="/">
&laquo; Back to all posts
</Link>
<h1 className="rb-article-title">{post.title}</h1>
<div className="rb-article-meta">
<span>by {post.author}</span>
<span>{formatDate(post.createdAt)}</span>
<span className="rb-post-tags">
{post.tags.map((t) => (
<span key={t} className="rb-tag">
{t}
</span>
))}
</span>
</div>
<div
className="rb-prose"
dangerouslySetInnerHTML={{ __html: html }}
/>
</article>
</Shell>
);
}