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:
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 »
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -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="/">
|
||||
« 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user