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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user