feat: add 6 more skins (PS1, PS3, Wii, NDS, Dreamcast, JV2002)

Brings the catalog to nine. Each is a single scoped [data-theme="..."]
CSS file plus a registry entry — no markup changes.

- ps1: charcoal BIOS, gray panels, uppercase letterspacing
- ps3: animated XMB sky gradient + drifting wave, black glass
- wii: white channels, rounded pills, soft blue glow
- nds: silver shell with content framed as the touch screen
- dreamcast: cream BIOS, conic-gradient orange swirl, lowercase blue
- jv2002: dense boxy portal, Verdana 11px, red masthead, blue nav tabs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 00:49:52 +02:00
parent 37ba9b3e19
commit 91244a5a2b
32 changed files with 3093 additions and 29 deletions
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { requireAdmin } from "@/lib/auth";
import { logoutAction } from "../actions";
// Guarded chrome for every authenticated admin page. Login lives outside this
// group so it never inherits the nav or the auth gate.
export default async function PanelLayout({
children,
}: {
children: ReactNode;
}) {
await requireAdmin();
return (
<div className="rb-admin-shell">
<header className="rb-admin-bar">
<Link href="/admin" className="rb-admin-brand">
RetroBlog Admin
</Link>
<nav className="rb-admin-nav">
<Link href="/admin">Dashboard</Link>
<Link href="/admin/posts">Posts</Link>
<Link href="/admin/settings">Settings</Link>
<Link href="/" target="_blank">
View site
</Link>
</nav>
<form action={logoutAction} className="rb-admin-logout">
<button className="rb-btn rb-btn-ghost" type="submit">
Sign out
</button>
</form>
</header>
<main className="rb-admin-main">{children}</main>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";
import { getSettings } from "@/lib/settings";
import { themeMeta } from "@/themes/registry";
export default async function DashboardPage() {
const posts = getAllPosts();
const settings = getSettings();
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">Dashboard</h1>
<div className="rb-admin-grid">
<div className="rb-admin-card">
<h2 className="rb-admin-h2">Posts</h2>
<p className="rb-admin-stat">{posts.length}</p>
<Link className="rb-btn" href="/admin/posts">
Manage posts
</Link>
<Link className="rb-btn rb-btn-primary" href="/admin/posts/new">
New post
</Link>
</div>
<div className="rb-admin-card">
<h2 className="rb-admin-h2">Theme</h2>
<p className="rb-admin-stat-sm">
Default: {themeMeta(settings.defaultTheme).name}
</p>
<p className="rb-admin-muted">
Public switcher:{" "}
{settings.publicThemeToggle ? "visible" : "hidden"} ·{" "}
{settings.allowedThemes.length} skins allowed
</p>
<Link className="rb-btn" href="/admin/settings">
Edit settings
</Link>
</div>
<div className="rb-admin-card">
<h2 className="rb-admin-h2">Backup</h2>
<p className="rb-admin-muted">Export or import settings and posts.</p>
<a className="rb-btn" href="/admin/export">
Export everything
</a>
<Link className="rb-btn" href="/admin/settings#data">
Import / export
</Link>
</div>
</div>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import Link from "next/link";
import type { Post } from "@/lib/posts";
import { savePostAction } from "../../actions";
// Server-rendered create/edit form. Both modes post to the same action; the
// hidden `id` (present only when editing) decides insert vs update.
export default function PostForm({
post,
titleError,
}: {
post?: Post;
titleError?: boolean;
}) {
const editing = !!post;
return (
<form className="rb-admin-card rb-post-form" action={savePostAction}>
{editing && <input type="hidden" name="id" value={post.id} />}
{titleError && <p className="rb-admin-error">Title is required.</p>}
<label className="rb-field">
<span>Title</span>
<input name="title" defaultValue={post?.title ?? ""} required />
</label>
<label className="rb-field">
<span>
Slug <em className="rb-admin-muted">(blank = auto from title)</em>
</span>
<input
name="slug"
defaultValue={post?.slug ?? ""}
placeholder="my-post-title"
/>
</label>
<div className="rb-field-row">
<label className="rb-field">
<span>Author</span>
<input name="author" defaultValue={post?.author ?? "webmaster"} />
</label>
<label className="rb-field">
<span>
Date <em className="rb-admin-muted">(YYYY-MM-DD HH:MM:SS)</em>
</span>
<input
name="createdAt"
defaultValue={post?.createdAt ?? ""}
placeholder="2002-03-14 09:21:00"
/>
</label>
</div>
<label className="rb-field">
<span>
Tags <em className="rb-admin-muted">(comma-separated)</em>
</span>
<input
name="tags"
defaultValue={post?.tags.join(", ") ?? ""}
placeholder="design, opinion"
/>
</label>
<label className="rb-field">
<span>Excerpt</span>
<textarea name="excerpt" rows={2} defaultValue={post?.excerpt ?? ""} />
</label>
<label className="rb-field">
<span>
Body <em className="rb-admin-muted">(Markdown)</em>
</span>
<textarea
name="body"
rows={16}
className="rb-mono"
defaultValue={post?.body ?? ""}
/>
</label>
<div className="rb-form-actions">
<button className="rb-btn rb-btn-primary" type="submit">
{editing ? "Save changes" : "Create post"}
</button>
<Link className="rb-btn rb-btn-ghost" href="/admin/posts">
Cancel
</Link>
</div>
</form>
);
}
@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import { getPostById } from "@/lib/posts";
import PostForm from "../../PostForm";
export default async function EditPostPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { id } = await params;
const { error } = await searchParams;
const post = getPostById(Number(id));
if (!post) notFound();
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">Edit post</h1>
<PostForm post={post} titleError={error === "title"} />
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import PostForm from "../PostForm";
export default async function NewPostPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">New post</h1>
<PostForm titleError={error === "title"} />
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import Link from "next/link";
import { getAllPosts, formatDate } from "@/lib/posts";
import { deletePostAction } from "../../actions";
export default async function AdminPostsPage() {
const posts = getAllPosts();
return (
<div className="rb-admin-page">
<div className="rb-admin-page-head">
<h1 className="rb-admin-h1">Posts</h1>
<Link className="rb-btn rb-btn-primary" href="/admin/posts/new">
New post
</Link>
</div>
{posts.length === 0 ? (
<p className="rb-admin-muted">No posts yet. Create your first one.</p>
) : (
<table className="rb-admin-table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Date</th>
<th className="rb-col-actions">Actions</th>
</tr>
</thead>
<tbody>
{posts.map((p) => (
<tr key={p.id}>
<td>{p.title}</td>
<td className="rb-admin-mono">{p.slug}</td>
<td>{formatDate(p.createdAt)}</td>
<td className="rb-col-actions">
<Link className="rb-btn rb-btn-sm" href={`/admin/posts/${p.id}/edit`}>
Edit
</Link>
<a
className="rb-btn rb-btn-sm"
href={`/posts/${p.slug}`}
target="_blank"
>
View
</a>
<form action={deletePostAction} className="rb-inline-form">
<input type="hidden" name="id" value={p.id} />
<button
className="rb-btn rb-btn-sm rb-btn-danger"
type="submit"
>
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { getSettings } from "@/lib/settings";
import { THEMES } from "@/themes/registry";
import { saveSettingsAction, importAction } from "../../actions";
function StatusBanner({
saved,
imp,
}: {
saved?: string;
imp?: string;
}) {
if (saved) return <p className="rb-admin-ok">Settings saved.</p>;
if (imp === "ok") return <p className="rb-admin-ok">Import complete.</p>;
if (imp === "empty")
return <p className="rb-admin-error">Nothing to import no file or text.</p>;
if (imp === "invalid")
return <p className="rb-admin-error">Import failed invalid JSON.</p>;
return null;
}
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ saved?: string; import?: string }>;
}) {
const { saved, import: imp } = await searchParams;
const settings = getSettings();
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">Settings</h1>
<StatusBanner saved={saved} imp={imp} />
{/* ---- branding + theme ---- */}
<form className="rb-admin-card" action={saveSettingsAction}>
<h2 className="rb-admin-h2">Branding</h2>
<label className="rb-field">
<span>Site title</span>
<input name="title" defaultValue={settings.title} />
</label>
<label className="rb-field">
<span>Subtitle / tagline</span>
<input name="subtitle" defaultValue={settings.subtitle} />
</label>
<div className="rb-field-row">
<label className="rb-field">
<span>Status bar / footer text</span>
<input name="footer" defaultValue={settings.footer} />
</label>
<label className="rb-field">
<span>Version label</span>
<input name="version" defaultValue={settings.version} />
</label>
</div>
<h2 className="rb-admin-h2">Theme</h2>
<label className="rb-field">
<span>Default theme for new visitors</span>
<select name="defaultTheme" defaultValue={settings.defaultTheme}>
{THEMES.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.era})
</option>
))}
</select>
</label>
<label className="rb-check">
<input
type="checkbox"
name="publicThemeToggle"
defaultChecked={settings.publicThemeToggle}
/>
<span>
Show the theme switcher to public visitors
<em className="rb-admin-muted"> (you always see it as admin)</em>
</span>
</label>
<fieldset className="rb-fieldset">
<legend>Skins available in the public switcher</legend>
<div className="rb-check-grid">
{THEMES.map((t) => (
<label key={t.id} className="rb-check">
<input
type="checkbox"
name="allowedThemes"
value={t.id}
defaultChecked={settings.allowedThemes.includes(t.id)}
/>
<span>
{t.name} <em className="rb-admin-muted">({t.era})</em>
</span>
</label>
))}
</div>
</fieldset>
<div className="rb-form-actions">
<button className="rb-btn rb-btn-primary" type="submit">
Save settings
</button>
</div>
</form>
{/* ---- export / import ---- */}
<div id="data" className="rb-admin-card">
<h2 className="rb-admin-h2">Export</h2>
<p className="rb-admin-muted">
Download a JSON backup. Pick what to include.
</p>
<div className="rb-btn-group">
<a className="rb-btn" href="/admin/export?settings=1">
Settings only
</a>
<a className="rb-btn" href="/admin/export?posts=1">
Posts only
</a>
<a className="rb-btn rb-btn-primary" href="/admin/export?settings=1&posts=1">
Settings + posts
</a>
</div>
</div>
<form
className="rb-admin-card"
action={importAction}
encType="multipart/form-data"
>
<h2 className="rb-admin-h2">Import</h2>
<p className="rb-admin-muted">
Upload a previously exported JSON file (or paste it below), then choose
what to apply.
</p>
<label className="rb-field">
<span>JSON file</span>
<input type="file" name="file" accept="application/json,.json" />
</label>
<label className="rb-field">
<span>or paste JSON</span>
<textarea name="pasted" rows={5} className="rb-mono" />
</label>
<fieldset className="rb-fieldset">
<legend>What to import</legend>
<label className="rb-check">
<input type="checkbox" name="what" value="settings" defaultChecked />
<span>Settings</span>
</label>
<label className="rb-check">
<input type="checkbox" name="what" value="posts" />
<span>Posts</span>
</label>
</fieldset>
<fieldset className="rb-fieldset">
<legend>Posts import mode</legend>
<label className="rb-check">
<input type="radio" name="postMode" value="replace" defaultChecked />
<span>
Replace all <em className="rb-admin-muted">(wipes existing posts)</em>
</span>
</label>
<label className="rb-check">
<input type="radio" name="postMode" value="append" />
<span>Append to existing</span>
</label>
</fieldset>
<div className="rb-form-actions">
<button className="rb-btn rb-btn-primary" type="submit">
Import
</button>
</div>
</form>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { checkPassword } from "@/lib/auth";
import {
ADMIN_COOKIE,
createSessionToken,
SESSION_MAX_AGE,
} from "@/lib/session";
import {
createPost,
updatePost,
deletePost,
replaceAllPosts,
type PostInput,
} from "@/lib/posts";
import {
saveSettings,
normalizeSettings,
type Settings,
} from "@/lib/settings";
import { THEMES, isThemeId, type ThemeId } from "@/themes/registry";
function s(formData: FormData, key: string): string {
const v = formData.get(key);
return typeof v === "string" ? v : "";
}
// Refresh the whole site after a content/settings change. The layout-level
// revalidation covers branding + theme that live in the shared shell.
function revalidateSite() {
revalidatePath("/", "layout");
}
/* ------------------------------- auth -------------------------------- */
export async function loginAction(formData: FormData) {
const password = s(formData, "password");
const next = s(formData, "next");
if (!checkPassword(password)) {
redirect(
`/admin/login?error=1${next ? `&next=${encodeURIComponent(next)}` : ""}`
);
}
const token = await createSessionToken();
(await cookies()).set(ADMIN_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: SESSION_MAX_AGE,
});
redirect(next && next.startsWith("/admin") ? next : "/admin");
}
export async function logoutAction() {
(await cookies()).delete(ADMIN_COOKIE);
redirect("/admin/login");
}
/* ------------------------------- posts ------------------------------- */
function postInputFromForm(formData: FormData): PostInput {
return {
slug: s(formData, "slug"),
title: s(formData, "title"),
excerpt: s(formData, "excerpt"),
body: s(formData, "body"),
author: s(formData, "author"),
tags: s(formData, "tags"),
createdAt: s(formData, "createdAt"),
};
}
export async function savePostAction(formData: FormData) {
const input = postInputFromForm(formData);
const idRaw = s(formData, "id");
if (!input.title.trim()) {
redirect(idRaw ? `/admin/posts/${idRaw}/edit?error=title` : "/admin/posts/new?error=title");
}
if (idRaw) {
updatePost(Number(idRaw), input);
} else {
createPost(input);
}
revalidateSite();
redirect("/admin/posts");
}
export async function deletePostAction(formData: FormData) {
const id = Number(s(formData, "id"));
if (Number.isFinite(id)) deletePost(id);
revalidateSite();
redirect("/admin/posts");
}
/* ------------------------------ settings ----------------------------- */
export async function saveSettingsAction(formData: FormData) {
const allowed = formData
.getAll("allowedThemes")
.map(String)
.filter(isThemeId) as ThemeId[];
const defaultTheme = s(formData, "defaultTheme");
const next: Settings = normalizeSettings({
title: s(formData, "title"),
subtitle: s(formData, "subtitle"),
footer: s(formData, "footer"),
version: s(formData, "version"),
defaultTheme: isThemeId(defaultTheme) ? defaultTheme : undefined,
publicThemeToggle: formData.get("publicThemeToggle") === "on",
allowedThemes: allowed.length ? allowed : THEMES.map((t) => t.id),
});
saveSettings(next);
revalidateSite();
redirect("/admin/settings?saved=1");
}
/* ------------------------------- import ------------------------------ */
export async function importAction(formData: FormData) {
const file = formData.get("file");
const pasted = s(formData, "pasted");
const wants = formData.getAll("what").map(String);
const wantSettings = wants.includes("settings");
const wantPosts = wants.includes("posts");
const postMode = s(formData, "postMode") || "replace";
let raw = pasted;
if (file && file instanceof File && file.size > 0) {
raw = await file.text();
}
if (!raw.trim()) redirect("/admin/settings?import=empty");
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
redirect("/admin/settings?import=invalid");
}
const obj = parsed as Record<string, unknown>;
if (wantSettings && obj.settings) {
saveSettings(normalizeSettings(obj.settings));
}
if (wantPosts && Array.isArray(obj.posts)) {
const posts = (obj.posts as Record<string, unknown>[]).map(
(p): PostInput => ({
slug: typeof p.slug === "string" ? p.slug : undefined,
title: typeof p.title === "string" ? p.title : "Untitled",
excerpt: typeof p.excerpt === "string" ? p.excerpt : "",
body: typeof p.body === "string" ? p.body : "",
author: typeof p.author === "string" ? p.author : "webmaster",
tags: Array.isArray(p.tags)
? (p.tags as string[]).join(",")
: typeof p.tags === "string"
? p.tags
: "",
createdAt:
typeof p.createdAt === "string"
? p.createdAt
: typeof p.created_at === "string"
? (p.created_at as string)
: undefined,
})
);
if (postMode === "append") {
for (const p of posts) createPost(p);
} else {
replaceAllPosts(posts);
}
}
revalidateSite();
redirect("/admin/settings?import=ok");
}
+392
View File
@@ -0,0 +1,392 @@
/* ============================================================
RetroBlog — admin panel styles
Deliberately theme-agnostic: a clean, neutral control surface
that stays readable no matter which public skin is active.
Everything is scoped under .rb-admin.
============================================================ */
.rb-admin {
--a-bg: #f4f5f7;
--a-surface: #ffffff;
--a-border: #d8dce3;
--a-text: #1c2330;
--a-muted: #69707d;
--a-primary: #2563eb;
--a-primary-text: #ffffff;
--a-danger: #dc2626;
--a-ok-bg: #e7f6ec;
--a-ok-text: #16693a;
--a-err-bg: #fdecec;
--a-err-text: #a11212;
--a-radius: 8px;
position: fixed;
inset: 0;
overflow: auto;
background: var(--a-bg);
color: var(--a-text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
z-index: 100;
}
.rb-admin * {
box-sizing: border-box;
}
/* ---- shell / top bar ---- */
.rb-admin-shell {
min-height: 100%;
display: flex;
flex-direction: column;
}
.rb-admin-bar {
display: flex;
align-items: center;
gap: 20px;
padding: 0 20px;
height: 52px;
background: var(--a-surface);
border-bottom: 1px solid var(--a-border);
position: sticky;
top: 0;
z-index: 10;
}
.rb-admin-brand {
font-weight: 700;
text-decoration: none;
color: var(--a-text);
}
.rb-admin-nav {
display: flex;
gap: 16px;
flex: 1;
}
.rb-admin-nav a {
color: var(--a-muted);
text-decoration: none;
padding: 4px 2px;
}
.rb-admin-nav a:hover {
color: var(--a-text);
}
.rb-admin-logout {
margin: 0;
}
.rb-admin-main {
flex: 1;
width: min(880px, 100%);
margin: 0 auto;
padding: 28px 20px 60px;
}
/* ---- headings / text ---- */
.rb-admin-h1 {
font-size: 22px;
margin: 0 0 18px;
}
.rb-admin-h2 {
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--a-muted);
margin: 22px 0 12px;
}
.rb-admin-h2:first-child {
margin-top: 0;
}
.rb-admin-muted {
color: var(--a-muted);
}
.rb-admin-mono,
.rb-mono {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas,
monospace;
}
.rb-admin-page-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.rb-admin-page-head .rb-admin-h1 {
margin: 0;
}
/* ---- cards / grid ---- */
.rb-admin-card {
background: var(--a-surface);
border: 1px solid var(--a-border);
border-radius: var(--a-radius);
padding: 20px;
margin-bottom: 20px;
}
.rb-admin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.rb-admin-grid .rb-admin-card {
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.rb-admin-stat {
font-size: 34px;
font-weight: 700;
margin: 0;
}
.rb-admin-stat-sm {
font-size: 16px;
font-weight: 600;
margin: 0;
}
/* ---- banners ---- */
.rb-admin-ok,
.rb-admin-error {
border-radius: var(--a-radius);
padding: 10px 14px;
margin: 0 0 16px;
font-weight: 500;
}
.rb-admin-ok {
background: var(--a-ok-bg);
color: var(--a-ok-text);
}
.rb-admin-error {
background: var(--a-err-bg);
color: var(--a-err-text);
}
/* ---- forms ---- */
.rb-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.rb-field > span {
font-weight: 600;
font-size: 13px;
}
.rb-field em {
font-style: normal;
font-weight: 400;
}
.rb-field input,
.rb-field select,
.rb-field textarea {
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 6px;
padding: 9px 11px;
width: 100%;
}
.rb-field textarea {
resize: vertical;
}
.rb-field input:focus,
.rb-field select:focus,
.rb-field textarea:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
border-color: var(--a-primary);
}
.rb-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.rb-fieldset {
border: 1px solid var(--a-border);
border-radius: var(--a-radius);
padding: 14px 16px;
margin: 0 0 16px;
}
.rb-fieldset legend {
font-weight: 600;
font-size: 13px;
padding: 0 6px;
}
.rb-check {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 6px 0;
cursor: pointer;
}
.rb-check input {
margin-top: 3px;
}
.rb-check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 4px 16px;
}
.rb-form-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
/* ---- buttons ---- */
.rb-btn {
display: inline-block;
font: inherit;
font-weight: 500;
line-height: 1;
text-decoration: none;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 6px;
padding: 9px 14px;
cursor: pointer;
}
.rb-btn:hover {
background: #f0f2f5;
}
.rb-btn-primary {
background: var(--a-primary);
border-color: var(--a-primary);
color: var(--a-primary-text);
}
.rb-btn-primary:hover {
filter: brightness(1.07);
background: var(--a-primary);
}
.rb-btn-ghost {
background: transparent;
border-color: transparent;
}
.rb-btn-ghost:hover {
background: #e9ebef;
}
.rb-btn-danger {
color: var(--a-danger);
border-color: #f0c4c4;
}
.rb-btn-danger:hover {
background: var(--a-err-bg);
}
.rb-btn-sm {
padding: 5px 9px;
font-size: 13px;
}
.rb-btn-group,
.rb-form-actions {
flex-wrap: wrap;
}
.rb-btn-group {
display: flex;
gap: 10px;
}
/* ---- table ---- */
.rb-admin-table {
width: 100%;
border-collapse: collapse;
}
.rb-admin-table th,
.rb-admin-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--a-border);
vertical-align: middle;
}
.rb-admin-table th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--a-muted);
}
.rb-col-actions {
text-align: right;
white-space: nowrap;
}
.rb-col-actions .rb-btn {
margin-left: 6px;
}
.rb-inline-form {
display: inline;
margin: 0;
}
/* ---- login ---- */
.rb-admin-login {
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.rb-login-card {
width: min(360px, 100%);
margin: 0;
}
.rb-login-card .rb-admin-h1 {
margin-bottom: 6px;
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse, type NextRequest } from "next/server";
import { isAdmin } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { getAllPosts } from "@/lib/posts";
// GET /admin/export?settings=1&posts=1
// Returns a JSON file download with whichever sections were requested. Defaults
// to exporting everything if no flags are given. Guarded by middleware, with a
// belt-and-braces check here too.
export async function GET(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const sp = req.nextUrl.searchParams;
const noFlags = !sp.has("settings") && !sp.has("posts");
const includeSettings = noFlags || sp.get("settings") === "1";
const includePosts = noFlags || sp.get("posts") === "1";
const payload: Record<string, unknown> = {
schema: "retroblog-export",
version: 1,
exportedAt: new Date().toISOString(),
};
if (includeSettings) payload.settings = getSettings();
if (includePosts) {
payload.posts = getAllPosts().map((p) => ({
slug: p.slug,
title: p.title,
excerpt: p.excerpt,
body: p.body,
author: p.author,
tags: p.tags,
createdAt: p.createdAt,
}));
}
const stamp = new Date().toISOString().slice(0, 10);
const parts = [
includeSettings ? "settings" : null,
includePosts ? "posts" : null,
].filter(Boolean);
const filename = `retroblog-${parts.join("-")}-${stamp}.json`;
return new NextResponse(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+9
View File
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import "./admin.css";
// Outer admin wrapper. Theme-agnostic on purpose: the panel keeps one clean,
// readable look regardless of which public skin is active. Auth is enforced by
// middleware (and re-checked in the (panel) layout).
export default function AdminLayout({ children }: { children: ReactNode }) {
return <div className="rb-admin">{children}</div>;
}
+38
View File
@@ -0,0 +1,38 @@
import { loginAction } from "../actions";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; next?: string }>;
}) {
const { error, next } = await searchParams;
return (
<div className="rb-admin-login">
<form className="rb-admin-card rb-login-card" action={loginAction}>
<h1 className="rb-admin-h1">RetroBlog Admin</h1>
<p className="rb-admin-muted">Sign in to manage posts and settings.</p>
{error && (
<p className="rb-admin-error">Incorrect password. Try again.</p>
)}
<label className="rb-field">
<span>Password</span>
<input
type="password"
name="password"
autoFocus
autoComplete="current-password"
/>
</label>
{next && <input type="hidden" name="next" value={next} />}
<button className="rb-btn rb-btn-primary" type="submit">
Sign in
</button>
</form>
</div>
);
}
+6
View File
@@ -9,6 +9,12 @@
@import "../themes/xp.css";
@import "../themes/ninex.css";
@import "../themes/ps2.css";
@import "../themes/ps1.css";
@import "../themes/ps3.css";
@import "../themes/wii.css";
@import "../themes/nds.css";
@import "../themes/dreamcast.css";
@import "../themes/jv2002.css";
*,
*::before,
+8 -4
View File
@@ -1,11 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
import { getTheme } from "@/themes/server";
import { getSettings } from "@/lib/settings";
export const metadata: Metadata = {
title: "RetroBlog",
description: "A blog engine wearing the skins of retro OSes and consoles.",
};
export async function generateMetadata(): Promise<Metadata> {
const settings = getSettings();
return {
title: settings.title,
description: settings.subtitle,
};
}
export default async function RootLayout({
children,
+4 -4
View File
@@ -2,18 +2,18 @@ import Link from "next/link";
import Shell from "@/components/Shell";
import { getTheme } from "@/themes/server";
import { getAllPosts, formatDate } from "@/lib/posts";
import { getSettings } from "@/lib/settings";
export default async function HomePage() {
const theme = await getTheme();
const posts = getAllPosts();
const settings = getSettings();
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>
<h1 className="rb-page-title">{settings.title}</h1>
<p className="rb-page-sub">{settings.subtitle}</p>
</header>
<ul className="rb-post-list">