Files
RetroBlog/src/app/admin/actions.ts
T
kawa 91244a5a2b 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>
2026-06-07 01:15:25 +02:00

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");
}