Add Theme Settings tool + polish remaining radio detail pages
- New /admin/theme: recolour the whole site from housekeeping. 6 atom- faithful presets (Atom/Midnight/Ocean/Forest/Sunset/Candy) + per-colour pickers for the 12 settings ThemeVars injects + border radius. Writes to website_settings, busts the siteSettings cache, and revalidates the layout so the new palette applies live with no rebuild. Constants live in src/lib/theme-presets.ts (a "use server" file can't export objects). Added to the admin sidebar (System). - radio/contests/[id] + giveaways/[id] wrapped in ContentCard to match the public design system. - Skipped a separate VPN page: /admin/ip already manages the IP white/blacklist, so it would only duplicate it. Verified live (prod, amx_test): applied the Ocean preset → home renders --color-primary #0ea5e9 site-wide; reverted the test rows. tsc 0, vitest 49/49, next build 0.
This commit is contained in:
1 parent
6f15e0c8a3
commit
1199176883
6 files changed
+322
-10
No files matched your search
@@ -0,0 +1,65 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
|
|
||||||
|
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
||||||
|
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||||
|
|
||||||
|
async function writeSetting(key: string, value: string): Promise<void> {
|
||||||
|
await prisma.websiteSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value },
|
||||||
|
create: { key, value, comment: "Theme (housekeeping)" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTheme(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requireStaff();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
const raw = String(formData.get(key) ?? "").trim();
|
||||||
|
if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw);
|
||||||
|
}
|
||||||
|
const radius = String(formData.get("border_radius") ?? "").trim();
|
||||||
|
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
|
||||||
|
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "theme_update",
|
||||||
|
description: "Updated theme colours",
|
||||||
|
});
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
} catch {
|
||||||
|
// ignore — page re-renders current state
|
||||||
|
}
|
||||||
|
redirect("/admin/theme?saved=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPreset(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requireStaff();
|
||||||
|
const name = String(formData.get("preset") ?? "");
|
||||||
|
const preset = PRESETS[name];
|
||||||
|
if (!preset) redirect("/admin/theme");
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [key, value] of Object.entries(preset)) await writeSetting(key, value);
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "theme_preset",
|
||||||
|
description: `Applied theme preset "${name}"`,
|
||||||
|
});
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ const NAV_GROUPS: { label: string; items: { href: string; label: string }[] }[]
|
|||||||
{ href: "/admin/housekeeping", label: "Housekeeping" },
|
{ href: "/admin/housekeeping", label: "Housekeeping" },
|
||||||
{ href: "/admin/rooms", label: "Rooms" },
|
{ href: "/admin/rooms", label: "Rooms" },
|
||||||
{ href: "/admin/email-templates", label: "Email" },
|
{ href: "/admin/email-templates", label: "Email" },
|
||||||
|
{ href: "/admin/theme", label: "Theme" },
|
||||||
{ href: "/admin/settings", label: "Settings" },
|
{ href: "/admin/settings", label: "Settings" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { applyPreset, saveTheme } from "@/actions/admin-theme";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { PRESETS } from "@/lib/theme-presets";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Theme" };
|
||||||
|
|
||||||
|
const FIELDS: { key: string; label: string; def: string }[] = [
|
||||||
|
{ key: "color_primary", label: "Primary", def: "#f59e0b" },
|
||||||
|
{ key: "color_accent", label: "Accent", def: "#10b981" },
|
||||||
|
{ key: "border_color", label: "Border (golden)", def: "#eeb425" },
|
||||||
|
{ key: "color_background", label: "Background", def: "#f8fafc" },
|
||||||
|
{ key: "color_surface", label: "Surface / cards", def: "#ffffff" },
|
||||||
|
{ key: "color_dropdown", label: "Dropdown", def: "#ffffff" },
|
||||||
|
{ key: "color_navbar", label: "Navbar", def: "#ffffff" },
|
||||||
|
{ key: "color_navbar_text", label: "Navbar text", def: "#1e293b" },
|
||||||
|
{ key: "color_text", label: "Text", def: "#0f172a" },
|
||||||
|
{ key: "color_text_muted", label: "Muted text", def: "#64748b" },
|
||||||
|
{ key: "button_primary_color", label: "Button", def: "#f59e0b" },
|
||||||
|
{ key: "button_text_color", label: "Button text", def: "#1e293b" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A <input type=color> needs #rrggbb — fall back to the default otherwise. */
|
||||||
|
function toHex(value: string | null, def: string): string {
|
||||||
|
return value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminTheme({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ saved?: string; preset?: string }>;
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
|
||||||
|
const current = Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
FIELDS.map(async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const),
|
||||||
|
),
|
||||||
|
) as Record<string, string>;
|
||||||
|
const radius = (await siteSettings.get("border_radius", "12")) ?? "12";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Theme</h1>
|
||||||
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
|
Recolour the whole site. Values are written to <code>website_settings</code> and applied
|
||||||
|
live (no rebuild).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sp.saved ? (
|
||||||
|
<p className="admin-badge ok" style={{ display: "inline-block" }}>
|
||||||
|
Theme saved
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{sp.preset ? (
|
||||||
|
<p className="admin-badge ok" style={{ display: "inline-block" }}>
|
||||||
|
Applied preset “{sp.preset}”
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="admin-section">
|
||||||
|
<h2>Presets</h2>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||||
|
{Object.entries(PRESETS).map(([name, palette]) => (
|
||||||
|
<form key={name} action={applyPreset}>
|
||||||
|
<input type="hidden" name="preset" value={name} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-outline"
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<span style={{ display: "inline-flex" }}>
|
||||||
|
{["color_primary", "color_accent", "color_surface", "color_text"].map((k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: palette[k],
|
||||||
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
|
||||||
|
marginLeft: -3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-section">
|
||||||
|
<h2>Custom colours</h2>
|
||||||
|
<form action={saveTheme} className="card">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
|
||||||
|
gap: "0.75rem 1.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FIELDS.map((f) => (
|
||||||
|
<label
|
||||||
|
key={f.key}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
name={f.key}
|
||||||
|
defaultValue={toHex(current[f.key], f.def)}
|
||||||
|
style={{ width: 40, height: 30, padding: 0, border: "none", background: "none" }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: "0.88rem" }}>{f.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1rem", display: "flex", alignItems: "center", gap: "0.6rem" }}>
|
||||||
|
<label style={{ fontSize: "0.88rem" }}>Border radius (px)</label>
|
||||||
|
<input
|
||||||
|
name="border_radius"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={40}
|
||||||
|
defaultValue={/^\d{1,3}$/.test(radius) ? radius : "12"}
|
||||||
|
style={{ width: 90 }}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ marginLeft: "auto" }}>
|
||||||
|
Save theme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { ContentCard } from "@/components/public/ui";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -33,13 +34,12 @@ export default async function RadioContestDetailPage({
|
|||||||
const contestIdStr = contest.id.toString();
|
const contestIdStr = contest.id.toString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
<p className="muted" style={{ margin: "0 0 0.5rem" }}>
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
<Link href="/radio/contests">← Back to contests</Link>
|
<Link href="/radio/contests">← Back to contests</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="card">
|
<ContentCard icon="🎉" title={`Contest #${contestIdStr}`} subtitle="Radio contest details">
|
||||||
<h1 style={{ margin: "0 0 0.5rem" }}>Contest #{contestIdStr}</h1>
|
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -52,7 +52,7 @@ export default async function RadioContestDetailPage({
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</ContentCard>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { ContentCard } from "@/components/public/ui";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -33,13 +34,12 @@ export default async function RadioGiveawayDetailPage({
|
|||||||
const giveawayIdStr = giveaway.id.toString();
|
const giveawayIdStr = giveaway.id.toString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
<p className="muted" style={{ margin: "0 0 0.5rem" }}>
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
<Link href="/radio/giveaways">← Back to giveaways</Link>
|
<Link href="/radio/giveaways">← Back to giveaways</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="card">
|
<ContentCard icon="🎁" title={`Giveaway #${giveawayIdStr}`} subtitle="Radio giveaway details">
|
||||||
<h1 style={{ margin: "0 0 0.5rem" }}>Giveaway #{giveawayIdStr}</h1>
|
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -52,7 +52,7 @@ export default async function RadioGiveawayDetailPage({
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</ContentCard>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Theme constants shared by the admin Theme page and its server action. Kept in
|
||||||
|
// a plain module because a "use server" file may only export async functions.
|
||||||
|
|
||||||
|
// The colour settings ThemeVars injects into :root. Editing these recolours the
|
||||||
|
// whole site live (ThemeVars reads them on every request).
|
||||||
|
export const THEME_COLOR_KEYS = [
|
||||||
|
"color_primary",
|
||||||
|
"color_background",
|
||||||
|
"color_surface",
|
||||||
|
"color_dropdown",
|
||||||
|
"color_navbar",
|
||||||
|
"color_navbar_text",
|
||||||
|
"color_text",
|
||||||
|
"color_text_muted",
|
||||||
|
"color_accent",
|
||||||
|
"button_primary_color",
|
||||||
|
"button_text_color",
|
||||||
|
"border_color",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Curated palettes (faithful to the atom theme presets). Each is a full set of
|
||||||
|
// the colour keys above so applying one fully reskins the site.
|
||||||
|
export const PRESETS: Record<string, Record<string, string>> = {
|
||||||
|
"Atom (golden)": {
|
||||||
|
color_primary: "#f59e0b",
|
||||||
|
color_background: "#f8fafc",
|
||||||
|
color_surface: "#ffffff",
|
||||||
|
color_dropdown: "#ffffff",
|
||||||
|
color_navbar: "#ffffff",
|
||||||
|
color_navbar_text: "#1e293b",
|
||||||
|
color_text: "#0f172a",
|
||||||
|
color_text_muted: "#64748b",
|
||||||
|
color_accent: "#10b981",
|
||||||
|
button_primary_color: "#f59e0b",
|
||||||
|
button_text_color: "#1e293b",
|
||||||
|
border_color: "#eeb425",
|
||||||
|
},
|
||||||
|
Midnight: {
|
||||||
|
color_primary: "#eeb425",
|
||||||
|
color_background: "#0f1117",
|
||||||
|
color_surface: "#171a21",
|
||||||
|
color_dropdown: "#1f242d",
|
||||||
|
color_navbar: "#171a21",
|
||||||
|
color_navbar_text: "#e5e7eb",
|
||||||
|
color_text: "#e5e7eb",
|
||||||
|
color_text_muted: "#9ca3af",
|
||||||
|
color_accent: "#22c55e",
|
||||||
|
button_primary_color: "#eeb425",
|
||||||
|
button_text_color: "#1a1a2e",
|
||||||
|
border_color: "#eeb425",
|
||||||
|
},
|
||||||
|
Ocean: {
|
||||||
|
color_primary: "#0ea5e9",
|
||||||
|
color_background: "#f0f9ff",
|
||||||
|
color_surface: "#ffffff",
|
||||||
|
color_dropdown: "#ffffff",
|
||||||
|
color_navbar: "#ffffff",
|
||||||
|
color_navbar_text: "#0c4a6e",
|
||||||
|
color_text: "#082f49",
|
||||||
|
color_text_muted: "#64748b",
|
||||||
|
color_accent: "#14b8a6",
|
||||||
|
button_primary_color: "#0ea5e9",
|
||||||
|
button_text_color: "#ffffff",
|
||||||
|
border_color: "#38bdf8",
|
||||||
|
},
|
||||||
|
Forest: {
|
||||||
|
color_primary: "#16a34a",
|
||||||
|
color_background: "#f3faf4",
|
||||||
|
color_surface: "#ffffff",
|
||||||
|
color_dropdown: "#ffffff",
|
||||||
|
color_navbar: "#ffffff",
|
||||||
|
color_navbar_text: "#14532d",
|
||||||
|
color_text: "#052e16",
|
||||||
|
color_text_muted: "#5f6b63",
|
||||||
|
color_accent: "#84cc16",
|
||||||
|
button_primary_color: "#16a34a",
|
||||||
|
button_text_color: "#ffffff",
|
||||||
|
border_color: "#22c55e",
|
||||||
|
},
|
||||||
|
Sunset: {
|
||||||
|
color_primary: "#f97316",
|
||||||
|
color_background: "#fff7ed",
|
||||||
|
color_surface: "#ffffff",
|
||||||
|
color_dropdown: "#ffffff",
|
||||||
|
color_navbar: "#ffffff",
|
||||||
|
color_navbar_text: "#7c2d12",
|
||||||
|
color_text: "#431407",
|
||||||
|
color_text_muted: "#78716c",
|
||||||
|
color_accent: "#ec4899",
|
||||||
|
button_primary_color: "#f97316",
|
||||||
|
button_text_color: "#ffffff",
|
||||||
|
border_color: "#fb923c",
|
||||||
|
},
|
||||||
|
Candy: {
|
||||||
|
color_primary: "#ec4899",
|
||||||
|
color_background: "#fdf2f8",
|
||||||
|
color_surface: "#ffffff",
|
||||||
|
color_dropdown: "#ffffff",
|
||||||
|
color_navbar: "#ffffff",
|
||||||
|
color_navbar_text: "#831843",
|
||||||
|
color_text: "#500724",
|
||||||
|
color_text_muted: "#9d6b80",
|
||||||
|
color_accent: "#a855f7",
|
||||||
|
button_primary_color: "#ec4899",
|
||||||
|
button_text_color: "#ffffff",
|
||||||
|
border_color: "#f472b6",
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in new issue
Block a user