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:
@@ -18,6 +18,20 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
## Admin panel
|
||||
|
||||
Visit [http://localhost:3000/admin](http://localhost:3000/admin) and sign in with
|
||||
`ADMIN_PASSWORD` (see `.env.example`; defaults to `admin` in dev). From there you can:
|
||||
|
||||
- **Posts** — full create / edit / delete with Markdown bodies, slugs, tags, and dates.
|
||||
- **Settings** — branding (title, subtitle, footer, version), the default theme for
|
||||
new visitors, whether the public theme switcher is shown, and which skins it offers.
|
||||
- **Import / export** — download a JSON backup of settings and/or posts, and import
|
||||
one back (posts can replace or append).
|
||||
|
||||
Auth is a single password kept in `ADMIN_PASSWORD`, with a signed session cookie
|
||||
(`ADMIN_SESSION_SECRET`). All `/admin/*` routes are gated by middleware.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
+27
-14
@@ -1,12 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { type ThemeId } from "@/themes/registry";
|
||||
import { THEMES, type ThemeId } from "@/themes/registry";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { isAdmin } from "@/lib/auth";
|
||||
import ThemeSwitcher from "./ThemeSwitcher";
|
||||
import Clock from "./Clock";
|
||||
|
||||
// Shared, theme-agnostic page chrome. Every theme restyles these `rb-` classes
|
||||
// to look like its own OS/console. The HTML skeleton never changes.
|
||||
export default function Shell({
|
||||
export default async function Shell({
|
||||
theme,
|
||||
title,
|
||||
children,
|
||||
@@ -15,12 +17,24 @@ export default function Shell({
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const settings = getSettings();
|
||||
const admin = await isAdmin();
|
||||
|
||||
// Admins always get the switcher with every skin; the public only sees it when
|
||||
// enabled, and only the allowed skins.
|
||||
const showSwitcher = admin || settings.publicThemeToggle;
|
||||
const switcherThemes = admin
|
||||
? THEMES
|
||||
: THEMES.filter((t) => settings.allowedThemes.includes(t.id));
|
||||
|
||||
return (
|
||||
<div className="rb-desktop">
|
||||
<div className="rb-window" role="application">
|
||||
<div className="rb-titlebar">
|
||||
<span className="rb-titlebar-icon" aria-hidden />
|
||||
<span className="rb-titlebar-text">{title} — RetroBlog</span>
|
||||
<span className="rb-titlebar-text">
|
||||
{title} — {settings.title}
|
||||
</span>
|
||||
<div className="rb-titlebar-buttons" aria-hidden>
|
||||
<button className="rb-tb-btn rb-tb-min" tabIndex={-1}>
|
||||
<span>_</span>
|
||||
@@ -41,16 +55,15 @@ export default function Shell({
|
||||
<Link className="rb-menu-link" href="/about">
|
||||
About
|
||||
</Link>
|
||||
<a
|
||||
className="rb-menu-link"
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Links
|
||||
</a>
|
||||
{admin && (
|
||||
<Link className="rb-menu-link" href="/admin">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<span className="rb-spacer" />
|
||||
<ThemeSwitcher initial={theme} />
|
||||
{showSwitcher && (
|
||||
<ThemeSwitcher initial={theme} themes={switcherThemes} />
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="rb-content">{children}</div>
|
||||
@@ -58,7 +71,7 @@ export default function Shell({
|
||||
<div className="rb-statusbar">
|
||||
<span className="rb-status-cell">Ready</span>
|
||||
<span className="rb-status-cell rb-status-grow">
|
||||
RetroBlog v0.1
|
||||
{settings.footer}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +84,7 @@ export default function Shell({
|
||||
<div className="rb-tasks">
|
||||
<button className="rb-task rb-task-active">
|
||||
<span className="rb-task-icon" aria-hidden />
|
||||
RetroBlog
|
||||
{settings.title}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rb-tray">
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { THEMES, THEME_COOKIE, type ThemeId } from "@/themes/registry";
|
||||
import {
|
||||
THEMES,
|
||||
THEME_COOKIE,
|
||||
type ThemeId,
|
||||
type ThemeMeta,
|
||||
} from "@/themes/registry";
|
||||
|
||||
export default function ThemeSwitcher({ initial }: { initial: ThemeId }) {
|
||||
export default function ThemeSwitcher({
|
||||
initial,
|
||||
themes = THEMES,
|
||||
}: {
|
||||
initial: ThemeId;
|
||||
/** Skins offered in the dropdown. Defaults to all registered themes. */
|
||||
themes?: ThemeMeta[];
|
||||
}) {
|
||||
const [theme, setTheme] = useState<ThemeId>(initial);
|
||||
|
||||
function apply(id: ThemeId) {
|
||||
@@ -22,7 +34,7 @@ export default function ThemeSwitcher({ initial }: { initial: ThemeId }) {
|
||||
onChange={(e) => apply(e.target.value as ThemeId)}
|
||||
aria-label="Theme selector"
|
||||
>
|
||||
{THEMES.map((t) => (
|
||||
{themes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.era})
|
||||
</option>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ADMIN_COOKIE, verifySessionToken } from "./session";
|
||||
|
||||
// The admin password. Set ADMIN_PASSWORD in the environment for production; the
|
||||
// dev fallback exists only so the panel works out of the box locally.
|
||||
export function adminPassword(): string {
|
||||
return process.env.ADMIN_PASSWORD || "admin";
|
||||
}
|
||||
|
||||
export function checkPassword(input: string): boolean {
|
||||
const expected = adminPassword();
|
||||
if (input.length !== expected.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < input.length; i++)
|
||||
diff |= input.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// True if the current request carries a valid admin session cookie.
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
const store = await cookies();
|
||||
return verifySessionToken(store.get(ADMIN_COOKIE)?.value);
|
||||
}
|
||||
|
||||
// Guard for admin server components: bounce to login when unauthenticated.
|
||||
export async function requireAdmin(): Promise<void> {
|
||||
if (!(await isAdmin())) redirect("/admin/login");
|
||||
}
|
||||
@@ -35,6 +35,12 @@ function migrate(db: Database.Database) {
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Single-row key/value blob holding the site settings JSON.
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,122 @@ export function getPostBySlug(slug: string): Post | null {
|
||||
return row ? toPost(row) : null;
|
||||
}
|
||||
|
||||
export function getPostById(id: number): Post | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM posts WHERE id = ?")
|
||||
.get(id) as Row | undefined;
|
||||
return row ? toPost(row) : null;
|
||||
}
|
||||
|
||||
export type PostInput = {
|
||||
slug?: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
body?: string;
|
||||
author?: string;
|
||||
tags?: string; // comma-separated
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
// Turn a title (or supplied slug) into a URL-safe, unique slug. When an id is
|
||||
// given, that row is allowed to keep its own slug (used during edits).
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function uniqueSlug(base: string, ignoreId?: number): string {
|
||||
const db = getDb();
|
||||
let slug = base || "post";
|
||||
let n = 1;
|
||||
for (;;) {
|
||||
const clash = db
|
||||
.prepare("SELECT id FROM posts WHERE slug = ?")
|
||||
.get(slug) as { id: number } | undefined;
|
||||
if (!clash || clash.id === ignoreId) return slug;
|
||||
slug = `${base}-${++n}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPost(input: PostInput): Post {
|
||||
const slug = uniqueSlug(slugify(input.slug?.trim() || input.title));
|
||||
const info = getDb()
|
||||
.prepare(
|
||||
`INSERT INTO posts (slug, title, excerpt, body, author, tags, created_at)
|
||||
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @created_at)`
|
||||
)
|
||||
.run({
|
||||
slug,
|
||||
title: input.title.trim(),
|
||||
excerpt: input.excerpt?.trim() ?? "",
|
||||
body: input.body ?? "",
|
||||
author: input.author?.trim() || "webmaster",
|
||||
tags: normalizeTags(input.tags),
|
||||
created_at: input.createdAt?.trim() || nowStamp(),
|
||||
});
|
||||
return getPostById(Number(info.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
export function updatePost(id: number, input: PostInput): Post | null {
|
||||
const existing = getPostById(id);
|
||||
if (!existing) return null;
|
||||
const slug = uniqueSlug(
|
||||
slugify(input.slug?.trim() || input.title),
|
||||
id
|
||||
);
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE posts
|
||||
SET slug = @slug, title = @title, excerpt = @excerpt,
|
||||
body = @body, author = @author, tags = @tags,
|
||||
created_at = @created_at
|
||||
WHERE id = @id`
|
||||
)
|
||||
.run({
|
||||
id,
|
||||
slug,
|
||||
title: input.title.trim(),
|
||||
excerpt: input.excerpt?.trim() ?? "",
|
||||
body: input.body ?? "",
|
||||
author: input.author?.trim() || "webmaster",
|
||||
tags: normalizeTags(input.tags),
|
||||
created_at: input.createdAt?.trim() || existing.createdAt,
|
||||
});
|
||||
return getPostById(id);
|
||||
}
|
||||
|
||||
export function deletePost(id: number): void {
|
||||
getDb().prepare("DELETE FROM posts WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// Replace every post with the supplied set (used by import "replace" mode).
|
||||
export function replaceAllPosts(posts: PostInput[]): number {
|
||||
const db = getDb();
|
||||
const tx = db.transaction((rows: PostInput[]) => {
|
||||
db.prepare("DELETE FROM posts").run();
|
||||
for (const r of rows) createPost(r);
|
||||
});
|
||||
tx(posts);
|
||||
return posts.length;
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string | undefined): string {
|
||||
if (!tags) return "";
|
||||
return tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function nowStamp(): string {
|
||||
return new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export function renderMarkdown(md: string): string {
|
||||
return marked.parse(md, { async: false }) as string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Stateless signed-session helpers built on the Web Crypto API so the same code
|
||||
// runs in both the Edge middleware and Node server actions. A session token is
|
||||
// `${issuedAt}.${hmac(issuedAt)}`; we recompute the HMAC to verify and reject
|
||||
// anything older than MAX_AGE. No DB round-trip, no per-session storage.
|
||||
|
||||
export const ADMIN_COOKIE = "rb_admin";
|
||||
const MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
function secret(): string {
|
||||
return (
|
||||
process.env.ADMIN_SESSION_SECRET ||
|
||||
process.env.ADMIN_PASSWORD ||
|
||||
"retroblog-insecure-dev-secret"
|
||||
);
|
||||
}
|
||||
|
||||
async function hmacHex(message: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(secret()),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
|
||||
return Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Constant-time-ish comparison to avoid leaking via early exit.
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export async function createSessionToken(): Promise<string> {
|
||||
const issued = Date.now().toString();
|
||||
const sig = await hmacHex(issued);
|
||||
return `${issued}.${sig}`;
|
||||
}
|
||||
|
||||
export async function verifySessionToken(
|
||||
token: string | undefined | null
|
||||
): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
const dot = token.indexOf(".");
|
||||
if (dot <= 0) return false;
|
||||
const issued = token.slice(0, dot);
|
||||
const sig = token.slice(dot + 1);
|
||||
const issuedAt = Number(issued);
|
||||
if (!Number.isFinite(issuedAt)) return false;
|
||||
if (Date.now() - issuedAt > MAX_AGE_SECONDS * 1000) return false;
|
||||
const expected = await hmacHex(issued);
|
||||
return safeEqual(sig, expected);
|
||||
}
|
||||
|
||||
export const SESSION_MAX_AGE = MAX_AGE_SECONDS;
|
||||
@@ -0,0 +1,86 @@
|
||||
import "server-only";
|
||||
import { getDb } from "./db";
|
||||
import {
|
||||
DEFAULT_THEME,
|
||||
THEMES,
|
||||
isThemeId,
|
||||
type ThemeId,
|
||||
} from "@/themes/registry";
|
||||
|
||||
// Site-wide settings, editable from the admin panel and persisted as a single
|
||||
// JSON row in SQLite. Adding a field = extend the type, the defaults, and the
|
||||
// admin form; reads everywhere go through getSettings().
|
||||
export type Settings = {
|
||||
/** Branding */
|
||||
title: string;
|
||||
subtitle: string;
|
||||
footer: string;
|
||||
version: string;
|
||||
/** Theme that new visitors get before they pick one. */
|
||||
defaultTheme: ThemeId;
|
||||
/** Whether the theme switcher is shown to the public (admins always see it). */
|
||||
publicThemeToggle: boolean;
|
||||
/** Which skins appear in the public switcher. */
|
||||
allowedThemes: ThemeId[];
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
title: "RetroBlog",
|
||||
subtitle: "Words from the past, rendered in the chrome of the past.",
|
||||
footer: "RetroBlog v0.1",
|
||||
version: "v0.1",
|
||||
defaultTheme: DEFAULT_THEME,
|
||||
publicThemeToggle: true,
|
||||
allowedThemes: THEMES.map((t) => t.id),
|
||||
};
|
||||
|
||||
// Coerce arbitrary parsed JSON into a valid Settings object, falling back to
|
||||
// defaults field-by-field so a partial or hand-edited import can't corrupt the
|
||||
// site. Returns a clean, fully-populated Settings.
|
||||
export function normalizeSettings(input: unknown): Settings {
|
||||
const o = (input ?? {}) as Record<string, unknown>;
|
||||
const allowed = Array.isArray(o.allowedThemes)
|
||||
? (o.allowedThemes.filter(isThemeId) as ThemeId[])
|
||||
: DEFAULT_SETTINGS.allowedThemes;
|
||||
return {
|
||||
title: str(o.title, DEFAULT_SETTINGS.title),
|
||||
subtitle: str(o.subtitle, DEFAULT_SETTINGS.subtitle),
|
||||
footer: str(o.footer, DEFAULT_SETTINGS.footer),
|
||||
version: str(o.version, DEFAULT_SETTINGS.version),
|
||||
defaultTheme: isThemeId(o.defaultTheme as string)
|
||||
? (o.defaultTheme as ThemeId)
|
||||
: DEFAULT_SETTINGS.defaultTheme,
|
||||
publicThemeToggle:
|
||||
typeof o.publicThemeToggle === "boolean"
|
||||
? o.publicThemeToggle
|
||||
: DEFAULT_SETTINGS.publicThemeToggle,
|
||||
allowedThemes: allowed.length ? allowed : DEFAULT_SETTINGS.allowedThemes,
|
||||
};
|
||||
}
|
||||
|
||||
function str(v: unknown, fallback: string): string {
|
||||
return typeof v === "string" && v.trim().length ? v : fallback;
|
||||
}
|
||||
|
||||
export function getSettings(): Settings {
|
||||
const row = getDb()
|
||||
.prepare("SELECT data FROM settings WHERE id = 1")
|
||||
.get() as { data: string } | undefined;
|
||||
if (!row) return DEFAULT_SETTINGS;
|
||||
try {
|
||||
return normalizeSettings(JSON.parse(row.data));
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(next: Settings): Settings {
|
||||
const clean = normalizeSettings(next);
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO settings (id, data) VALUES (1, @data)
|
||||
ON CONFLICT(id) DO UPDATE SET data = @data`
|
||||
)
|
||||
.run({ data: JSON.stringify(clean) });
|
||||
return clean;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { ADMIN_COOKIE, verifySessionToken } from "@/lib/session";
|
||||
|
||||
// Gate every /admin route behind a valid session, except the login page and its
|
||||
// POST action. Runs on the Edge runtime, so it relies only on Web Crypto
|
||||
// (see lib/session.ts) — no Node APIs.
|
||||
export async function proxy(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
if (pathname === "/admin/login") return NextResponse.next();
|
||||
|
||||
const token = req.cookies.get(ADMIN_COOKIE)?.value;
|
||||
if (await verifySessionToken(token)) return NextResponse.next();
|
||||
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/admin/login";
|
||||
url.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/admin/:path*"],
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
/* ============================================================
|
||||
Theme: Sega Dreamcast — scoped to [data-theme="dreamcast"]
|
||||
Cream BIOS calm, the orange swirl, friendly blue type, bubbly
|
||||
rounded VMU-flavored chrome.
|
||||
============================================================ */
|
||||
|
||||
[data-theme="dreamcast"] {
|
||||
--dc-orange: #f47b20;
|
||||
--dc-orange-dk: #d85f0a;
|
||||
--dc-blue: #143d8c;
|
||||
--dc-blue-lt: #2f6ad0;
|
||||
--dc-cream: #f3efe6;
|
||||
--dc-ink: #2a2f3a;
|
||||
font-family: "Futura", "Century Gothic", "Trebuchet MS", sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--dc-ink);
|
||||
}
|
||||
|
||||
[data-theme="dreamcast"] body {
|
||||
background: linear-gradient(180deg, #fbf9f3 0%, #e9e3d6 100%) fixed;
|
||||
}
|
||||
|
||||
/* reusable swirl */
|
||||
[data-theme="dreamcast"] .rb-titlebar-icon,
|
||||
[data-theme="dreamcast"] .rb-start-logo,
|
||||
[data-theme="dreamcast"] .rb-task-icon {
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
var(--dc-orange),
|
||||
#ffd0a0,
|
||||
var(--dc-orange),
|
||||
var(--dc-orange-dk),
|
||||
var(--dc-orange)
|
||||
);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
[data-theme="dreamcast"] .rb-window {
|
||||
background: #fff;
|
||||
border: 1px solid #ded7c8;
|
||||
border-radius: 20px;
|
||||
padding: 0;
|
||||
box-shadow: 0 10px 30px rgba(212, 95, 10, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="dreamcast"] .rb-titlebar {
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
border-radius: 20px 20px 0 0;
|
||||
background: linear-gradient(180deg, #ffffff, var(--dc-cream));
|
||||
border-bottom: 2px solid var(--dc-orange);
|
||||
color: var(--dc-blue);
|
||||
font-weight: bold;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-titlebar-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-titlebar-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="dreamcast"] .rb-menubar {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #ece6d8;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-menu-link {
|
||||
text-decoration: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 18px;
|
||||
color: var(--dc-blue);
|
||||
font-weight: bold;
|
||||
text-transform: lowercase;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-menu-link:hover {
|
||||
background: var(--dc-orange);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-switcher-label {
|
||||
color: var(--dc-orange-dk);
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-switcher-select {
|
||||
font: 13px "Trebuchet MS", sans-serif;
|
||||
border: 2px solid var(--dc-orange);
|
||||
border-radius: 18px;
|
||||
padding: 4px 12px;
|
||||
background: #fff;
|
||||
color: var(--dc-blue);
|
||||
}
|
||||
|
||||
[data-theme="dreamcast"] .rb-content {
|
||||
padding: 26px 28px;
|
||||
}
|
||||
|
||||
[data-theme="dreamcast"] .rb-statusbar {
|
||||
border-top: 1px solid #ece6d8;
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
color: #8a8270;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
[data-theme="dreamcast"] .rb-taskbar {
|
||||
height: 40px;
|
||||
background: linear-gradient(180deg, #fffdf8, var(--dc-cream));
|
||||
border-top: 2px solid var(--dc-orange);
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
padding: 0 18px;
|
||||
border: 2px solid var(--dc-orange);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
color: var(--dc-blue);
|
||||
font: bold 13px "Trebuchet MS", sans-serif;
|
||||
text-transform: lowercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-start:hover {
|
||||
background: var(--dc-orange);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-start-logo {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e3dccb;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
color: var(--dc-blue);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
text-transform: lowercase;
|
||||
cursor: default;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-task-active {
|
||||
border-color: var(--dc-orange);
|
||||
box-shadow: 0 0 0 2px rgba(244, 123, 32, 0.25);
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-task-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-clock {
|
||||
color: var(--dc-blue);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
[data-theme="dreamcast"] .rb-page-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
text-transform: lowercase;
|
||||
color: var(--dc-orange);
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-page-sub {
|
||||
margin: 0 0 20px;
|
||||
color: var(--dc-blue-lt);
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-post-card {
|
||||
border: 1px solid #ece6d8;
|
||||
border-radius: 16px;
|
||||
padding: 18px 20px;
|
||||
background: linear-gradient(180deg, #ffffff, #fbf8f1);
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-post-card:hover {
|
||||
box-shadow: 0 6px 20px rgba(244, 123, 32, 0.25);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-post-card-title a {
|
||||
color: var(--dc-blue);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-post-card-title a:hover {
|
||||
color: var(--dc-orange-dk);
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-post-date {
|
||||
font-size: 12px;
|
||||
color: #a89c84;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-tag {
|
||||
font-size: 11px;
|
||||
background: #fff0e3;
|
||||
border: 1px solid #ffc99c;
|
||||
border-radius: 12px;
|
||||
padding: 1px 10px;
|
||||
color: var(--dc-orange-dk);
|
||||
font-weight: bold;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-readmore,
|
||||
[data-theme="dreamcast"] .rb-back {
|
||||
color: var(--dc-orange-dk);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-back {
|
||||
display: inline-block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-article-title {
|
||||
color: var(--dc-orange);
|
||||
font-weight: bold;
|
||||
text-transform: lowercase;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-prose {
|
||||
line-height: 1.75;
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-prose a {
|
||||
color: var(--dc-blue-lt);
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-prose code {
|
||||
background: #fff0e3;
|
||||
border: 1px solid #ffc99c;
|
||||
border-radius: 6px;
|
||||
padding: 0 5px;
|
||||
font-family: "Courier New", monospace;
|
||||
color: var(--dc-orange-dk);
|
||||
}
|
||||
[data-theme="dreamcast"] .rb-prose blockquote {
|
||||
border-left: 4px solid var(--dc-orange);
|
||||
margin: 14px 0;
|
||||
padding: 6px 16px;
|
||||
background: #fdf6ef;
|
||||
border-radius: 0 10px 10px 0;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/* ============================================================
|
||||
Theme: Jeuxvideo.com circa 2002 — scoped to [data-theme="jv2002"]
|
||||
Early-2000s French gaming portal: dense boxy tables, Verdana
|
||||
11px, hard gray borders, gradient section bars, blue links,
|
||||
a loud red/orange masthead. Webpage, not an OS window.
|
||||
============================================================ */
|
||||
|
||||
[data-theme="jv2002"] {
|
||||
--jv-red: #c4161c;
|
||||
--jv-orange: #f08000;
|
||||
--jv-blue: #003399;
|
||||
--jv-link: #0033cc;
|
||||
--jv-bar1: #e9e9e9;
|
||||
--jv-bar2: #c4c4c4;
|
||||
--jv-border: #9a9a9a;
|
||||
font-family: Verdana, Geneva, Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
[data-theme="jv2002"] body {
|
||||
background: #6b6b6b url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='4' height='4' fill='%23707070'/%3E%3Crect width='2' height='2' fill='%23686868'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23686868'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* the page = a centered bordered table */
|
||||
[data-theme="jv2002"] .rb-window {
|
||||
width: min(800px, calc(100% - 16px));
|
||||
margin: 10px auto;
|
||||
background: #fff;
|
||||
border: 1px solid #000;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
box-shadow: 0 0 0 1px #fff, 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* masthead */
|
||||
[data-theme="jv2002"] .rb-titlebar {
|
||||
height: 46px;
|
||||
padding: 0 12px;
|
||||
background: linear-gradient(180deg, #e2231a 0%, var(--jv-red) 50%, #8e0f12 100%);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 2px solid var(--jv-orange);
|
||||
}
|
||||
[data-theme="jv2002"] .rb-titlebar-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--jv-orange);
|
||||
transform: rotate(45deg);
|
||||
box-shadow: inset 0 0 0 2px #fff;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-titlebar-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* nav tabs */
|
||||
[data-theme="jv2002"] .rb-menubar {
|
||||
padding: 3px 6px;
|
||||
background: linear-gradient(180deg, var(--jv-blue), #001f66);
|
||||
gap: 2px;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-menu-link {
|
||||
text-decoration: none;
|
||||
padding: 3px 10px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(180deg, #3a5fb8, #14307e);
|
||||
border: 1px solid #6f8fd0;
|
||||
border-bottom: none;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-menu-link:hover {
|
||||
background: var(--jv-orange);
|
||||
color: #000;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-switcher-label {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-switcher-select {
|
||||
font: 11px Verdana, sans-serif;
|
||||
border: 1px solid #000;
|
||||
border-radius: 0;
|
||||
padding: 0 2px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
[data-theme="jv2002"] .rb-content {
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
[data-theme="jv2002"] .rb-statusbar {
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
color: #444;
|
||||
background: var(--jv-bar1);
|
||||
border-top: 1px solid var(--jv-border);
|
||||
}
|
||||
|
||||
/* footer rail */
|
||||
[data-theme="jv2002"] .rb-taskbar {
|
||||
height: 28px;
|
||||
background: linear-gradient(180deg, #4a4a4a, #2a2a2a);
|
||||
border-top: 2px solid var(--jv-orange);
|
||||
gap: 4px;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 20px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #888;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(180deg, #f08000, #c46400);
|
||||
color: #fff;
|
||||
font: bold 11px Verdana, sans-serif;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-start-logo {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: #fff;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
[data-theme="jv2002"] .rb-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #666;
|
||||
background: linear-gradient(180deg, #5a5a5a, #3a3a3a);
|
||||
color: #ddd;
|
||||
font-size: 11px;
|
||||
cursor: default;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-task-active {
|
||||
background: linear-gradient(180deg, #707070, #4a4a4a);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-task-icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--jv-orange);
|
||||
}
|
||||
[data-theme="jv2002"] .rb-clock {
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* ---- content: dense boxy article list ---- */
|
||||
[data-theme="jv2002"] .rb-page-head {
|
||||
background: linear-gradient(180deg, var(--jv-bar1), var(--jv-bar2));
|
||||
border: 1px solid var(--jv-border);
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-page-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--jv-red);
|
||||
}
|
||||
[data-theme="jv2002"] .rb-page-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-post-list {
|
||||
gap: 8px;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-post-card {
|
||||
border: 1px solid var(--jv-border);
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-post-card-head {
|
||||
background: linear-gradient(180deg, var(--jv-bar1), var(--jv-bar2));
|
||||
border-bottom: 1px solid var(--jv-border);
|
||||
padding: 4px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-post-card-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-post-card-title a {
|
||||
color: var(--jv-blue);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-post-card-title a:hover {
|
||||
color: var(--jv-red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-post-date {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-post-excerpt {
|
||||
padding: 6px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-post-meta {
|
||||
padding: 4px 8px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-tag {
|
||||
font-size: 9px;
|
||||
background: #ffe9cc;
|
||||
border: 1px solid var(--jv-orange);
|
||||
padding: 0 5px;
|
||||
color: #a55400;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-readmore,
|
||||
[data-theme="jv2002"] .rb-back,
|
||||
[data-theme="jv2002"] .rb-prose a {
|
||||
color: var(--jv-link);
|
||||
text-decoration: underline;
|
||||
font-size: 11px;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-back {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-article {
|
||||
border: 1px solid var(--jv-border);
|
||||
padding: 0;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-article-title {
|
||||
background: linear-gradient(180deg, var(--jv-bar1), var(--jv-bar2));
|
||||
border-bottom: 1px solid var(--jv-border);
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 15px;
|
||||
color: var(--jv-red);
|
||||
}
|
||||
[data-theme="jv2002"] .rb-article-meta {
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
background: #f6f6f6;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-prose {
|
||||
padding: 10px;
|
||||
line-height: 1.5;
|
||||
font-size: 11px;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-prose h2 {
|
||||
font-size: 13px;
|
||||
color: var(--jv-blue);
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-prose code {
|
||||
background: #eee;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0 3px;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
[data-theme="jv2002"] .rb-prose blockquote {
|
||||
border-left: 3px solid var(--jv-orange);
|
||||
margin: 8px 0;
|
||||
padding: 4px 10px;
|
||||
background: #fff7ec;
|
||||
}
|
||||
|
||||
/* JV2002 didn't do back-link inside article box nicely; keep it outside */
|
||||
[data-theme="jv2002"] .rb-article > .rb-back {
|
||||
display: block;
|
||||
padding: 6px 10px 0;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/* ============================================================
|
||||
Theme: Nintendo DS — scoped to [data-theme="nds"]
|
||||
The DS firmware menu: brushed silver shell, glossy cyan
|
||||
gradients, dual-screen bezel framing, chunky rounded touch
|
||||
targets.
|
||||
============================================================ */
|
||||
|
||||
[data-theme="nds"] {
|
||||
--d-cyan: #1ba1c4;
|
||||
--d-cyan-lt: #7fd6ec;
|
||||
--d-silver: #c9d2d8;
|
||||
--d-silver-dk: #9aa6ae;
|
||||
--d-ink: #2b3a42;
|
||||
font-family: "Trebuchet MS", "Segoe UI", Tahoma, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--d-ink);
|
||||
}
|
||||
|
||||
[data-theme="nds"] body {
|
||||
background: linear-gradient(160deg, #aeb9c0 0%, #7e8b93 100%) fixed;
|
||||
}
|
||||
|
||||
/* the window is the silver shell; content is the touch screen */
|
||||
[data-theme="nds"] .rb-window {
|
||||
background: linear-gradient(180deg, #e7edf0, var(--d-silver));
|
||||
border: 1px solid #fff;
|
||||
border-radius: 14px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.4),
|
||||
inset 0 0 0 1px var(--d-silver-dk);
|
||||
}
|
||||
|
||||
[data-theme="nds"] .rb-titlebar {
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
[data-theme="nds"] .rb-titlebar-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #fff, var(--d-cyan-lt));
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
[data-theme="nds"] .rb-titlebar-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="nds"] .rb-menubar {
|
||||
padding: 8px 10px;
|
||||
background: linear-gradient(180deg, #eef3f5, var(--d-silver));
|
||||
}
|
||||
[data-theme="nds"] .rb-menu-link {
|
||||
text-decoration: none;
|
||||
padding: 5px 14px;
|
||||
border-radius: 16px;
|
||||
color: var(--d-ink);
|
||||
font-weight: bold;
|
||||
background: linear-gradient(180deg, #ffffff, #dde5e9);
|
||||
border: 1px solid var(--d-silver-dk);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
[data-theme="nds"] .rb-menu-link:hover {
|
||||
background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
|
||||
color: #fff;
|
||||
border-color: var(--d-cyan);
|
||||
}
|
||||
[data-theme="nds"] .rb-switcher-label {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: var(--d-cyan);
|
||||
}
|
||||
[data-theme="nds"] .rb-switcher-select {
|
||||
font: 13px "Trebuchet MS", sans-serif;
|
||||
border: 1px solid var(--d-silver-dk);
|
||||
border-radius: 14px;
|
||||
padding: 4px 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* the lower touch screen */
|
||||
[data-theme="nds"] .rb-content {
|
||||
background: #f7fbfc;
|
||||
margin: 6px 2px;
|
||||
padding: 22px 24px;
|
||||
border-radius: 10px;
|
||||
border: 3px solid #2b3a42;
|
||||
box-shadow: inset 0 0 18px rgba(27, 161, 196, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="nds"] .rb-statusbar {
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
color: #5a6a72;
|
||||
}
|
||||
|
||||
[data-theme="nds"] .rb-taskbar {
|
||||
height: 38px;
|
||||
background: linear-gradient(180deg, #b9c4cb, #8e9aa2);
|
||||
border-top: 1px solid #fff;
|
||||
}
|
||||
[data-theme="nds"] .rb-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 28px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--d-silver-dk);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
|
||||
color: #fff;
|
||||
font: bold 13px "Trebuchet MS", sans-serif;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
[data-theme="nds"] .rb-start-logo {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #fff, var(--d-cyan-lt));
|
||||
}
|
||||
[data-theme="nds"] .rb-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--d-silver-dk);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #ffffff, #dde5e9);
|
||||
color: var(--d-ink);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
}
|
||||
[data-theme="nds"] .rb-task-active {
|
||||
background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="nds"] .rb-task-icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 3px;
|
||||
background: var(--d-cyan-lt);
|
||||
}
|
||||
[data-theme="nds"] .rb-clock {
|
||||
color: var(--d-ink);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
[data-theme="nds"] .rb-page-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 26px;
|
||||
font-weight: bold;
|
||||
color: var(--d-cyan);
|
||||
}
|
||||
[data-theme="nds"] .rb-page-sub {
|
||||
margin: 0 0 18px;
|
||||
color: #5a6a72;
|
||||
}
|
||||
[data-theme="nds"] .rb-post-card {
|
||||
border: 1px solid var(--d-silver-dk);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
background: linear-gradient(180deg, #ffffff, #eef4f6);
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
[data-theme="nds"] .rb-post-card:hover {
|
||||
box-shadow: 0 5px 16px rgba(27, 161, 196, 0.25);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
[data-theme="nds"] .rb-post-card-title a {
|
||||
color: var(--d-cyan);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
[data-theme="nds"] .rb-post-date {
|
||||
font-size: 12px;
|
||||
color: #8a98a0;
|
||||
}
|
||||
[data-theme="nds"] .rb-tag {
|
||||
font-size: 11px;
|
||||
background: #e4f4f9;
|
||||
border: 1px solid var(--d-cyan-lt);
|
||||
border-radius: 12px;
|
||||
padding: 1px 9px;
|
||||
color: var(--d-cyan);
|
||||
font-weight: bold;
|
||||
}
|
||||
[data-theme="nds"] .rb-readmore,
|
||||
[data-theme="nds"] .rb-back {
|
||||
color: var(--d-cyan);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
}
|
||||
[data-theme="nds"] .rb-back {
|
||||
display: inline-block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
[data-theme="nds"] .rb-article-title {
|
||||
color: var(--d-cyan);
|
||||
font-weight: bold;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
[data-theme="nds"] .rb-prose {
|
||||
line-height: 1.7;
|
||||
}
|
||||
[data-theme="nds"] .rb-prose a {
|
||||
color: var(--d-cyan);
|
||||
}
|
||||
[data-theme="nds"] .rb-prose code {
|
||||
background: #e4f4f9;
|
||||
border: 1px solid var(--d-cyan-lt);
|
||||
border-radius: 6px;
|
||||
padding: 0 5px;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
[data-theme="nds"] .rb-prose blockquote {
|
||||
border-left: 4px solid var(--d-cyan);
|
||||
margin: 12px 0;
|
||||
padding: 6px 14px;
|
||||
background: #eef7fa;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/* ============================================================
|
||||
Theme: PlayStation 1 — scoped to [data-theme="ps1"]
|
||||
The gray BIOS shell: cool charcoal gradient, soft gray
|
||||
panels, blocky understated type. Memory-card-manager calm.
|
||||
============================================================ */
|
||||
|
||||
[data-theme="ps1"] {
|
||||
--p1-bg1: #3a4351;
|
||||
--p1-bg2: #1c222c;
|
||||
--p1-panel: #c7ccd4;
|
||||
--p1-panel-dk: #9aa1ad;
|
||||
--p1-ink: #2a2f38;
|
||||
--p1-accent: #6b7585;
|
||||
font-family: "Verdana", Geneva, Tahoma, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--p1-ink);
|
||||
}
|
||||
|
||||
[data-theme="ps1"] body {
|
||||
background: linear-gradient(160deg, var(--p1-bg1) 0%, var(--p1-bg2) 100%) fixed;
|
||||
}
|
||||
|
||||
[data-theme="ps1"] .rb-window {
|
||||
background: var(--p1-panel);
|
||||
border: 2px solid #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
[data-theme="ps1"] .rb-titlebar {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
background: linear-gradient(180deg, #5b6473, #424b59);
|
||||
color: #eef1f6;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
}
|
||||
[data-theme="ps1"] .rb-titlebar-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #eef1f6;
|
||||
/* tiny CD glint */
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 4px #5b6473, inset 0 0 0 5px #eef1f6;
|
||||
}
|
||||
[data-theme="ps1"] .rb-titlebar-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="ps1"] .rb-menubar {
|
||||
padding: 6px 10px;
|
||||
background: var(--p1-panel-dk);
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
[data-theme="ps1"] .rb-menu-link {
|
||||
text-decoration: none;
|
||||
padding: 3px 10px;
|
||||
color: var(--p1-ink);
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
[data-theme="ps1"] .rb-menu-link:hover {
|
||||
background: var(--p1-ink);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="ps1"] .rb-switcher-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
[data-theme="ps1"] .rb-switcher-select {
|
||||
font: 12px Verdana, sans-serif;
|
||||
background: #eef1f6;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
[data-theme="ps1"] .rb-content {
|
||||
background: #e6e9ee;
|
||||
margin: 8px;
|
||||
padding: 20px 22px;
|
||||
border-radius: 2px;
|
||||
box-shadow: inset 0 0 0 1px #fff, inset 0 0 0 2px var(--p1-panel-dk);
|
||||
}
|
||||
|
||||
[data-theme="ps1"] .rb-statusbar {
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
color: #4a525f;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
[data-theme="ps1"] .rb-taskbar {
|
||||
height: 34px;
|
||||
background: linear-gradient(180deg, #424b59, var(--p1-bg2));
|
||||
border-top: 1px solid #5b6473;
|
||||
}
|
||||
[data-theme="ps1"] .rb-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 24px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #6b7585;
|
||||
border-radius: 2px;
|
||||
background: #4a525f;
|
||||
color: #eef1f6;
|
||||
font: 11px Verdana, sans-serif;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-theme="ps1"] .rb-start-logo {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #eef1f6;
|
||||
box-shadow: inset 0 0 0 3px #4a525f, inset 0 0 0 4px #eef1f6;
|
||||
}
|
||||
[data-theme="ps1"] .rb-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #aab2c0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
cursor: default;
|
||||
}
|
||||
[data-theme="ps1"] .rb-task-active {
|
||||
border-color: #6b7585;
|
||||
background: #4a525f;
|
||||
color: #eef1f6;
|
||||
}
|
||||
[data-theme="ps1"] .rb-task-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #aab2c0;
|
||||
}
|
||||
[data-theme="ps1"] .rb-clock {
|
||||
color: #cfd5de;
|
||||
font-size: 11px;
|
||||
padding: 0 14px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
[data-theme="ps1"] .rb-page-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 24px;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
color: var(--p1-ink);
|
||||
}
|
||||
[data-theme="ps1"] .rb-page-sub {
|
||||
margin: 0 0 18px;
|
||||
color: #5a626f;
|
||||
}
|
||||
[data-theme="ps1"] .rb-post-card {
|
||||
background: #f1f3f6;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 2px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 1px 0 var(--p1-panel-dk), inset 0 0 0 1px #d2d7df;
|
||||
}
|
||||
[data-theme="ps1"] .rb-post-card-title a {
|
||||
color: var(--p1-ink);
|
||||
text-decoration: none;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
[data-theme="ps1"] .rb-post-card-title a:hover {
|
||||
color: #000;
|
||||
text-decoration: underline;
|
||||
}
|
||||
[data-theme="ps1"] .rb-post-date {
|
||||
font-size: 11px;
|
||||
color: #6b7585;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
[data-theme="ps1"] .rb-tag {
|
||||
font-size: 10px;
|
||||
background: #c7ccd4;
|
||||
border: 1px solid #fff;
|
||||
padding: 1px 7px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
[data-theme="ps1"] .rb-readmore,
|
||||
[data-theme="ps1"] .rb-back {
|
||||
color: #3a4351;
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
[data-theme="ps1"] .rb-back {
|
||||
display: inline-block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
[data-theme="ps1"] .rb-article-title {
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
[data-theme="ps1"] .rb-prose {
|
||||
line-height: 1.7;
|
||||
}
|
||||
[data-theme="ps1"] .rb-prose code {
|
||||
background: #c7ccd4;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 2px;
|
||||
padding: 0 4px;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
[data-theme="ps1"] .rb-prose blockquote {
|
||||
border-left: 3px solid var(--p1-accent);
|
||||
margin: 12px 0;
|
||||
padding: 4px 14px;
|
||||
background: #dde1e8;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/* ============================================================
|
||||
Theme: PlayStation 3 — scoped to [data-theme="ps3"]
|
||||
XMB era: deep gradient that drifts through the day, a flowing
|
||||
light wave, glossy black-glass panels, hair-thin white type.
|
||||
============================================================ */
|
||||
|
||||
[data-theme="ps3"] {
|
||||
--p3-ink: #f4f8ff;
|
||||
--p3-dim: #9fb4cf;
|
||||
--p3-line: rgba(255, 255, 255, 0.16);
|
||||
--p3-glass: rgba(8, 14, 28, 0.55);
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
font-weight: 200;
|
||||
font-size: 14px;
|
||||
color: var(--p3-ink);
|
||||
}
|
||||
|
||||
[data-theme="ps3"] body {
|
||||
background: linear-gradient(120deg, #04122e, #0a2a5e, #04122e, #1a0a3e);
|
||||
background-size: 400% 400%;
|
||||
animation: ps3-sky 40s ease infinite;
|
||||
}
|
||||
@keyframes ps3-sky {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* the flowing XMB wave */
|
||||
[data-theme="ps3"] body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
left: -10%;
|
||||
right: -10%;
|
||||
top: 38%;
|
||||
height: 220px;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
60% 100% at 50% 50%,
|
||||
rgba(150, 200, 255, 0.22),
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(8px);
|
||||
transform: skewY(-4deg);
|
||||
animation: ps3-wave 14s ease-in-out infinite alternate;
|
||||
z-index: 0;
|
||||
}
|
||||
@keyframes ps3-wave {
|
||||
from {
|
||||
transform: skewY(-5deg) translateY(-30px);
|
||||
}
|
||||
to {
|
||||
transform: skewY(3deg) translateY(40px);
|
||||
}
|
||||
}
|
||||
[data-theme="ps3"] .rb-desktop {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
[data-theme="ps3"] .rb-window {
|
||||
background: var(--p3-glass);
|
||||
border: 1px solid var(--p3-line);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
[data-theme="ps3"] .rb-titlebar {
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid var(--p3-line);
|
||||
color: var(--p3-ink);
|
||||
font-weight: 200;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
[data-theme="ps3"] .rb-titlebar-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 30%, #fff, #6fa8ff);
|
||||
box-shadow: 0 0 12px rgba(120, 180, 255, 0.8);
|
||||
}
|
||||
[data-theme="ps3"] .rb-titlebar-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="ps3"] .rb-menubar {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--p3-line);
|
||||
}
|
||||
[data-theme="ps3"] .rb-menu-link {
|
||||
text-decoration: none;
|
||||
padding: 5px 14px;
|
||||
color: var(--p3-dim);
|
||||
font-size: 13px;
|
||||
transition: color 0.25s, text-shadow 0.25s;
|
||||
}
|
||||
[data-theme="ps3"] .rb-menu-link:hover {
|
||||
color: #fff;
|
||||
text-shadow: 0 0 12px rgba(150, 200, 255, 0.9);
|
||||
}
|
||||
[data-theme="ps3"] .rb-switcher-label {
|
||||
color: var(--p3-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
[data-theme="ps3"] .rb-switcher-select {
|
||||
background: rgba(4, 12, 28, 0.85);
|
||||
color: var(--p3-ink);
|
||||
border: 1px solid var(--p3-line);
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
font: 12px "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="ps3"] .rb-content {
|
||||
padding: 26px 30px;
|
||||
}
|
||||
|
||||
[data-theme="ps3"] .rb-statusbar {
|
||||
border-top: 1px solid var(--p3-line);
|
||||
padding: 7px 18px;
|
||||
font-size: 11px;
|
||||
color: var(--p3-dim);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
[data-theme="ps3"] .rb-taskbar {
|
||||
height: 38px;
|
||||
background: linear-gradient(180deg, rgba(8, 18, 40, 0.75), rgba(2, 6, 16, 0.9));
|
||||
border-top: 1px solid var(--p3-line);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
[data-theme="ps3"] .rb-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
height: 26px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid var(--p3-line);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--p3-ink);
|
||||
font: 200 12px "Segoe UI", sans-serif;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-theme="ps3"] .rb-start:hover {
|
||||
background: rgba(120, 180, 255, 0.18);
|
||||
}
|
||||
[data-theme="ps3"] .rb-start-logo {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 30%, #fff, #6fa8ff);
|
||||
box-shadow: 0 0 10px rgba(120, 180, 255, 0.8);
|
||||
}
|
||||
[data-theme="ps3"] .rb-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--p3-dim);
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
[data-theme="ps3"] .rb-task-active {
|
||||
border-color: var(--p3-line);
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
[data-theme="ps3"] .rb-task-icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #6fa8ff;
|
||||
}
|
||||
[data-theme="ps3"] .rb-clock {
|
||||
color: var(--p3-ink);
|
||||
font-size: 12px;
|
||||
padding: 0 16px;
|
||||
font-weight: 200;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
[data-theme="ps3"] .rb-page-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 34px;
|
||||
font-weight: 100;
|
||||
letter-spacing: 1px;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 20px rgba(120, 180, 255, 0.5);
|
||||
}
|
||||
[data-theme="ps3"] .rb-page-sub {
|
||||
margin: 0 0 24px;
|
||||
color: var(--p3-dim);
|
||||
}
|
||||
[data-theme="ps3"] .rb-post-card {
|
||||
border: 1px solid var(--p3-line);
|
||||
border-radius: 4px;
|
||||
padding: 18px 20px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transition: background 0.25s, box-shadow 0.25s, transform 0.25s;
|
||||
}
|
||||
[data-theme="ps3"] .rb-post-card:hover {
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
box-shadow: 0 0 30px rgba(80, 150, 255, 0.3);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
[data-theme="ps3"] .rb-post-card-title a {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 200;
|
||||
}
|
||||
[data-theme="ps3"] .rb-post-card-title a:hover {
|
||||
text-shadow: 0 0 12px rgba(150, 200, 255, 0.9);
|
||||
}
|
||||
[data-theme="ps3"] .rb-post-date {
|
||||
font-size: 11px;
|
||||
color: var(--p3-dim);
|
||||
}
|
||||
[data-theme="ps3"] .rb-post-excerpt {
|
||||
color: #d3e2f7;
|
||||
}
|
||||
[data-theme="ps3"] .rb-tag {
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--p3-line);
|
||||
border-radius: 12px;
|
||||
padding: 1px 10px;
|
||||
color: #bcd4f5;
|
||||
}
|
||||
[data-theme="ps3"] .rb-readmore,
|
||||
[data-theme="ps3"] .rb-back {
|
||||
color: #9fc6ff;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
[data-theme="ps3"] .rb-back {
|
||||
display: inline-block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
[data-theme="ps3"] .rb-article-title {
|
||||
font-weight: 100;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 18px rgba(120, 180, 255, 0.45);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
[data-theme="ps3"] .rb-prose {
|
||||
line-height: 1.85;
|
||||
color: #dce8f9;
|
||||
font-weight: 300;
|
||||
}
|
||||
[data-theme="ps3"] .rb-prose a {
|
||||
color: #9fc6ff;
|
||||
}
|
||||
[data-theme="ps3"] .rb-prose code {
|
||||
background: rgba(4, 12, 28, 0.7);
|
||||
border: 1px solid var(--p3-line);
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
font-family: "Consolas", monospace;
|
||||
}
|
||||
[data-theme="ps3"] .rb-prose blockquote {
|
||||
border-left: 2px solid #6fa8ff;
|
||||
margin: 14px 0;
|
||||
padding: 6px 16px;
|
||||
background: rgba(120, 180, 255, 0.1);
|
||||
}
|
||||
+46
-1
@@ -2,7 +2,16 @@
|
||||
// create a scoped CSS file under src/themes/, and import it in globals.css.
|
||||
// Everything else (switcher, persistence, SSR attribute) is data-driven.
|
||||
|
||||
export type ThemeId = "xp" | "ninex" | "ps2";
|
||||
export type ThemeId =
|
||||
| "xp"
|
||||
| "ninex"
|
||||
| "ps2"
|
||||
| "ps1"
|
||||
| "ps3"
|
||||
| "wii"
|
||||
| "nds"
|
||||
| "dreamcast"
|
||||
| "jv2002";
|
||||
|
||||
export type ThemeMeta = {
|
||||
id: ThemeId;
|
||||
@@ -32,6 +41,42 @@ export const THEMES: ThemeMeta[] = [
|
||||
blurb: "Swaying blue towers boot menu.",
|
||||
era: "2000",
|
||||
},
|
||||
{
|
||||
id: "ps1",
|
||||
name: "PlayStation 1",
|
||||
blurb: "Gray BIOS. Memory card and CD player.",
|
||||
era: "1994",
|
||||
},
|
||||
{
|
||||
id: "ps3",
|
||||
name: "PlayStation 3",
|
||||
blurb: "XMB wave. Glossy black glass.",
|
||||
era: "2006",
|
||||
},
|
||||
{
|
||||
id: "wii",
|
||||
name: "Nintendo Wii",
|
||||
blurb: "White channels and soft blue glow.",
|
||||
era: "2006",
|
||||
},
|
||||
{
|
||||
id: "nds",
|
||||
name: "Nintendo DS",
|
||||
blurb: "Dual-screen firmware, silver and cyan.",
|
||||
era: "2004",
|
||||
},
|
||||
{
|
||||
id: "dreamcast",
|
||||
name: "Dreamcast",
|
||||
blurb: "Orange swirl. Bubbly VMU vibes.",
|
||||
era: "1998",
|
||||
},
|
||||
{
|
||||
id: "jv2002",
|
||||
name: "Jeuxvideo.com 2002",
|
||||
blurb: "Dense tables, Verdana 11px, blue links.",
|
||||
era: "2002",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_THEME: ThemeId = "xp";
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { DEFAULT_THEME, THEME_COOKIE, isThemeId, type ThemeId } from "./registry";
|
||||
import { THEME_COOKIE, isThemeId, type ThemeId } from "./registry";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
|
||||
// Resolve the active theme on the server from the persisted cookie.
|
||||
// Resolve the active theme on the server: the visitor's persisted cookie wins,
|
||||
// otherwise fall back to the admin-configured default theme.
|
||||
export async function getTheme(): Promise<ThemeId> {
|
||||
const store = await cookies();
|
||||
const v = store.get(THEME_COOKIE)?.value;
|
||||
return isThemeId(v) ? v : DEFAULT_THEME;
|
||||
if (isThemeId(v)) return v;
|
||||
return getSettings().defaultTheme;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/* ============================================================
|
||||
Theme: Nintendo Wii — scoped to [data-theme="wii"]
|
||||
The Wii Menu: clean white, soft gray gradients, rounded
|
||||
channel tiles, a gentle blue glow on everything.
|
||||
============================================================ */
|
||||
|
||||
[data-theme="wii"] {
|
||||
--w-blue: #009ac7;
|
||||
--w-blue-lt: #4fc6e8;
|
||||
--w-ink: #4a4a4a;
|
||||
--w-line: #d6dde2;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--w-ink);
|
||||
}
|
||||
|
||||
[data-theme="wii"] body {
|
||||
background: linear-gradient(180deg, #fafdff 0%, #e8eef3 100%) fixed;
|
||||
}
|
||||
|
||||
[data-theme="wii"] .rb-window {
|
||||
background: #fff;
|
||||
border: 1px solid var(--w-line);
|
||||
border-radius: 16px;
|
||||
padding: 0;
|
||||
box-shadow: 0 8px 30px rgba(0, 120, 170, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="wii"] .rb-titlebar {
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--w-line);
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: linear-gradient(180deg, #ffffff, #f1f6f9);
|
||||
color: var(--w-blue);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
[data-theme="wii"] .rb-titlebar-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 30%, #fff, var(--w-blue-lt) 60%, var(--w-blue));
|
||||
box-shadow: 0 0 10px rgba(0, 154, 199, 0.5);
|
||||
}
|
||||
[data-theme="wii"] .rb-titlebar-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="wii"] .rb-menubar {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--w-line);
|
||||
}
|
||||
[data-theme="wii"] .rb-menu-link {
|
||||
text-decoration: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
color: var(--w-ink);
|
||||
font-weight: 600;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
[data-theme="wii"] .rb-menu-link:hover {
|
||||
background: var(--w-blue);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 12px rgba(0, 154, 199, 0.5);
|
||||
}
|
||||
[data-theme="wii"] .rb-switcher-label {
|
||||
color: var(--w-blue);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
[data-theme="wii"] .rb-switcher-select {
|
||||
font: 13px "Helvetica Neue", sans-serif;
|
||||
border: 1px solid var(--w-line);
|
||||
border-radius: 20px;
|
||||
padding: 5px 12px;
|
||||
background: #fff;
|
||||
color: var(--w-ink);
|
||||
}
|
||||
|
||||
[data-theme="wii"] .rb-content {
|
||||
padding: 26px 28px;
|
||||
}
|
||||
|
||||
[data-theme="wii"] .rb-statusbar {
|
||||
border-top: 1px solid var(--w-line);
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
color: #8a99a3;
|
||||
}
|
||||
|
||||
[data-theme="wii"] .rb-taskbar {
|
||||
height: 40px;
|
||||
background: linear-gradient(180deg, #ffffff, #e6edf2);
|
||||
border-top: 1px solid var(--w-line);
|
||||
}
|
||||
[data-theme="wii"] .rb-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid var(--w-line);
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
color: var(--w-blue);
|
||||
font: 700 13px "Helvetica Neue", sans-serif;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 120, 170, 0.15);
|
||||
}
|
||||
[data-theme="wii"] .rb-start:hover {
|
||||
background: var(--w-blue);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="wii"] .rb-start-logo {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 30%, #fff, var(--w-blue-lt) 60%, var(--w-blue));
|
||||
}
|
||||
[data-theme="wii"] .rb-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--w-line);
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
color: var(--w-ink);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
}
|
||||
[data-theme="wii"] .rb-task-active {
|
||||
background: var(--w-blue);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 12px rgba(0, 154, 199, 0.5);
|
||||
}
|
||||
[data-theme="wii"] .rb-task-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--w-blue-lt);
|
||||
}
|
||||
[data-theme="wii"] .rb-clock {
|
||||
color: var(--w-blue);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
[data-theme="wii"] .rb-page-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--w-blue);
|
||||
}
|
||||
[data-theme="wii"] .rb-page-sub {
|
||||
margin: 0 0 20px;
|
||||
color: #7a8a93;
|
||||
}
|
||||
[data-theme="wii"] .rb-post-list {
|
||||
gap: 18px;
|
||||
}
|
||||
[data-theme="wii"] .rb-post-card {
|
||||
border: 1px solid var(--w-line);
|
||||
border-radius: 16px;
|
||||
padding: 18px 20px;
|
||||
background: linear-gradient(180deg, #ffffff, #f6fafc);
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
[data-theme="wii"] .rb-post-card:hover {
|
||||
box-shadow: 0 6px 22px rgba(0, 154, 199, 0.22);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
[data-theme="wii"] .rb-post-card-title a {
|
||||
color: var(--w-blue);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
[data-theme="wii"] .rb-post-date {
|
||||
font-size: 12px;
|
||||
color: #9aa8b0;
|
||||
}
|
||||
[data-theme="wii"] .rb-tag {
|
||||
font-size: 11px;
|
||||
background: #eef6fa;
|
||||
border: 1px solid #c5e6f1;
|
||||
border-radius: 12px;
|
||||
padding: 1px 10px;
|
||||
color: var(--w-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
[data-theme="wii"] .rb-readmore,
|
||||
[data-theme="wii"] .rb-back {
|
||||
color: var(--w-blue);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
[data-theme="wii"] .rb-back {
|
||||
display: inline-block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
[data-theme="wii"] .rb-article-title {
|
||||
color: var(--w-blue);
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
[data-theme="wii"] .rb-prose {
|
||||
line-height: 1.75;
|
||||
}
|
||||
[data-theme="wii"] .rb-prose a {
|
||||
color: var(--w-blue);
|
||||
}
|
||||
[data-theme="wii"] .rb-prose code {
|
||||
background: #eef6fa;
|
||||
border: 1px solid #c5e6f1;
|
||||
border-radius: 6px;
|
||||
padding: 0 6px;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
[data-theme="wii"] .rb-prose blockquote {
|
||||
border-left: 4px solid var(--w-blue-lt);
|
||||
margin: 14px 0;
|
||||
padding: 6px 16px;
|
||||
background: #f3fafd;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
Reference in New Issue
Block a user