91244a5a2b
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>
186 lines
5.2 KiB
TypeScript
186 lines
5.2 KiB
TypeScript
"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");
|
|
}
|