Extend the theme editor: typography, button/link colours, custom CSS, more presets

Grew /admin/theme from colours-only to a full theme editor, all applied
live via website_settings + ThemeVars:
- Typography: body font (10 web-safe + Google options; Google fonts load
  via an injected <link>) and H1/H2/H3 sizes (globals.css now reads
  --size-heading-* vars).
- Buttons & links: secondary/danger button colours + link/link-hover.
- Custom CSS: a raw textarea injected after the theme variables (staff-
  trusted), for anything the controls don't cover.
- Presets: 6 → 13 (added Galaxy, Royal, Cyberpunk, Neon, Coffee, Arctic,
  Christmas). ThemeVars now injects all the new vars + the font link.

Verified live (prod, amx_test): saved font=mono / H1=44px / custom CSS →
the public home reflected --font-family "Courier New", --size-heading-h1
44px and the injected rule; reverted the test settings. tsc 0,
vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-28 21:14:10 +02:00
1 parent 0cd4d06ff6
commit 5a4b6f27e9
5 files changed
+322 -52

No files matched your search

+26 -3
View File
@@ -6,10 +6,19 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets"; import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser). // Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/; const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
// Extra colour settings beyond the preset palette (buttons + links).
const EXTRA_COLOR_KEYS = [
"button_secondary_color",
"button_danger_color",
"link_color",
"link_hover_color",
];
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"];
const CUSTOM_CSS_MAX = 20000;
async function writeSetting(key: string, value: string): Promise<void> { async function writeSetting(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({ await prisma.websiteSetting.upsert({
@@ -23,18 +32,32 @@ export async function saveTheme(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
try { try {
for (const key of THEME_COLOR_KEYS) { for (const key of [...THEME_COLOR_KEYS, ...EXTRA_COLOR_KEYS]) {
const raw = String(formData.get(key) ?? "").trim(); const raw = String(formData.get(key) ?? "").trim();
if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw); if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw);
} }
const radius = String(formData.get("border_radius") ?? "").trim(); const radius = String(formData.get("border_radius") ?? "").trim();
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius); if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
// Typography
const font = String(formData.get("font_family") ?? "").trim();
if (font in FONTS) await writeSetting("font_family", font);
for (const key of HEADING_KEYS) {
const v = String(formData.get(key) ?? "").trim();
if (/^\d{1,3}$/.test(v)) await writeSetting(key, v);
}
// Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is).
if (formData.has("custom_css")) {
const cssRaw = String(formData.get("custom_css") ?? "").slice(0, CUSTOM_CSS_MAX);
await writeSetting("custom_css", cssRaw);
}
siteSettings.reload(); siteSettings.reload();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "theme_update", action: "theme_update",
description: "Updated theme colours", description: "Updated theme settings",
}); });
revalidatePath("/", "layout"); revalidatePath("/", "layout");
} catch { } catch {
+123 -42
View File
@@ -1,11 +1,11 @@
import { applyPreset, saveTheme } from "@/actions/admin-theme"; import { applyPreset, saveTheme } from "@/actions/admin-theme";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { PRESETS } from "@/lib/theme-presets"; import { FONTS, PRESETS } from "@/lib/theme-presets";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata = { title: "Theme" }; export const metadata = { title: "Theme" };
const FIELDS: { key: string; label: string; def: string }[] = [ const COLOR_FIELDS: { key: string; label: string; def: string }[] = [
{ key: "color_primary", label: "Primary", def: "#f59e0b" }, { key: "color_primary", label: "Primary", def: "#f59e0b" },
{ key: "color_accent", label: "Accent", def: "#10b981" }, { key: "color_accent", label: "Accent", def: "#10b981" },
{ key: "border_color", label: "Border (golden)", def: "#eeb425" }, { key: "border_color", label: "Border (golden)", def: "#eeb425" },
@@ -16,15 +16,57 @@ const FIELDS: { key: string; label: string; def: string }[] = [
{ key: "color_navbar_text", label: "Navbar text", def: "#1e293b" }, { key: "color_navbar_text", label: "Navbar text", def: "#1e293b" },
{ key: "color_text", label: "Text", def: "#0f172a" }, { key: "color_text", label: "Text", def: "#0f172a" },
{ key: "color_text_muted", label: "Muted text", def: "#64748b" }, { key: "color_text_muted", label: "Muted text", def: "#64748b" },
];
const BUTTON_FIELDS: { key: string; label: string; def: string }[] = [
{ key: "button_primary_color", label: "Button", def: "#f59e0b" }, { key: "button_primary_color", label: "Button", def: "#f59e0b" },
{ key: "button_text_color", label: "Button text", def: "#1e293b" }, { key: "button_text_color", label: "Button text", def: "#1e293b" },
{ key: "button_secondary_color", label: "Secondary", def: "#22c55e" },
{ key: "button_danger_color", label: "Danger", def: "#ef4444" },
{ key: "link_color", label: "Link", def: "#eeb425" },
{ key: "link_hover_color", label: "Link hover", def: "#cf9d15" },
]; ];
const HEADINGS: { key: string; label: string; def: string }[] = [
{ key: "size_heading_h1", label: "H1", def: "30" },
{ key: "size_heading_h2", label: "H2", def: "22" },
{ key: "size_heading_h3", label: "H3", def: "18" },
];
const ALL_COLORS = [...COLOR_FIELDS, ...BUTTON_FIELDS];
/** A <input type=color> needs #rrggbb — fall back to the default otherwise. */ /** A <input type=color> needs #rrggbb — fall back to the default otherwise. */
function toHex(value: string | null, def: string): string { function toHex(value: string | null, def: string): string {
return value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : def; return value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : def;
} }
function colorGrid(
fields: { key: string; label: string; def: string }[],
current: Record<string, string>,
) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 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>
);
}
export default async function AdminTheme({ export default async function AdminTheme({
searchParams, searchParams,
}: { }: {
@@ -34,16 +76,23 @@ export default async function AdminTheme({
const current = Object.fromEntries( const current = Object.fromEntries(
await Promise.all( await Promise.all(
FIELDS.map(async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const), ALL_COLORS.map(async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const),
), ),
) as Record<string, string>; ) as Record<string, string>;
const radius = (await siteSettings.get("border_radius", "12")) ?? "12"; const radius = (await siteSettings.get("border_radius", "12")) ?? "12";
const fontKey = (await siteSettings.get("font_family", "nunito")) ?? "nunito";
const headings = Object.fromEntries(
await Promise.all(
HEADINGS.map(async (h) => [h.key, (await siteSettings.get(h.key, h.def)) ?? h.def] as const),
),
) as Record<string, string>;
const customCss = (await siteSettings.get("custom_css", "")) ?? "";
return ( return (
<main> <main>
<h1>Theme</h1> <h1>Theme</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Recolour the whole site. Values are written to <code>website_settings</code> and applied Restyle the whole site. Values are written to <code>website_settings</code> and applied
live (no rebuild). live (no rebuild).
</p> </p>
@@ -91,48 +140,80 @@ export default async function AdminTheme({
</div> </div>
</section> </section>
<section className="admin-section"> <form action={saveTheme}>
<h2>Custom colours</h2> <section className="admin-section">
<form action={saveTheme} className="card"> <h2>Colours</h2>
<div <div className="card">{colorGrid(COLOR_FIELDS, current)}</div>
style={{ </section>
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" }}> <section className="admin-section">
<label style={{ fontSize: "0.88rem" }}>Border radius (px)</label> <h2>Buttons & links</h2>
<input <div className="card">{colorGrid(BUTTON_FIELDS, current)}</div>
name="border_radius" </section>
type="number"
min={0} <section className="admin-section">
max={40} <h2>Typography & layout</h2>
defaultValue={/^\d{1,3}$/.test(radius) ? radius : "12"} <div className="card">
style={{ width: 90 }} <div style={{ display: "flex", flexWrap: "wrap", gap: "1.25rem", alignItems: "center" }}>
/> <label style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<button type="submit" className="btn btn-primary" style={{ marginLeft: "auto" }}> <span style={{ fontSize: "0.88rem" }}>Font</span>
<select name="font_family" defaultValue={fontKey}>
{Object.entries(FONTS).map(([key, f]) => (
<option key={key} value={key}>
{f.label}
</option>
))}
</select>
</label>
{HEADINGS.map((h) => (
<label key={h.key} style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ fontSize: "0.88rem" }}>{h.label} (px)</span>
<input
name={h.key}
type="number"
min={10}
max={80}
defaultValue={/^\d{1,3}$/.test(headings[h.key]) ? headings[h.key] : h.def}
style={{ width: 80 }}
/>
</label>
))}
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ fontSize: "0.88rem" }}>Border radius (px)</span>
<input
name="border_radius"
type="number"
min={0}
max={40}
defaultValue={/^\d{1,3}$/.test(radius) ? radius : "12"}
style={{ width: 80 }}
/>
</label>
</div>
</div>
</section>
<section className="admin-section">
<h2>Custom CSS</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Injected raw into every page (after the theme variables). Use it for tweaks the controls
above don’t cover.
</p>
<textarea
name="custom_css"
defaultValue={customCss}
rows={8}
spellCheck={false}
placeholder={"/* e.g. */\n.nav-item { letter-spacing: 0.5px; }"}
style={{ width: "100%", fontFamily: "monospace", fontSize: "0.85rem" }}
/>
<div style={{ marginTop: "1rem", textAlign: "right" }}>
<button type="submit" className="btn btn-primary">
Save theme Save theme
</button> </button>
</div> </div>
</form> </section>
</section> </form>
</main> </main>
); );
} }
+3 -3
View File
@@ -205,16 +205,16 @@ body {
/* ── App component classes (used across the converted pages) ── */ /* ── App component classes (used across the converted pages) ── */
h1 { h1 {
font-size: 30px; font-size: var(--size-heading-h1, 30px);
font-weight: 800; font-weight: 800;
margin: 0.2em 0 0.5em; margin: 0.2em 0 0.5em;
} }
h2 { h2 {
font-size: 22px; font-size: var(--size-heading-h2, 22px);
font-weight: 700; font-weight: 700;
} }
h3 { h3 {
font-size: 18px; font-size: var(--size-heading-h3, 18px);
font-weight: 700; font-weight: 700;
} }
a { a {
+49 -4
View File
@@ -1,7 +1,9 @@
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { FONTS } from "@/lib/theme-presets";
// Injects the DB-driven CSS custom properties into :root, exactly like // Injects the DB-driven CSS custom properties into :root, exactly like
// AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. // AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers
// colours, typography, buttons, links + an optional raw custom-CSS block.
export async function ThemeVars() { export async function ThemeVars() {
const g = (k: string, d: string) => siteSettings.get(k, d); const g = (k: string, d: string) => siteSettings.get(k, d);
const [ const [
@@ -16,8 +18,17 @@ export async function ThemeVars() {
accent, accent,
buttonColor, buttonColor,
buttonText, buttonText,
buttonSecondary,
buttonDanger,
linkColor,
linkHover,
borderColor, borderColor,
borderRadius, borderRadius,
fontKey,
h1,
h2,
h3,
customCss,
] = await Promise.all([ ] = await Promise.all([
g("color_primary", "#f59e0b"), g("color_primary", "#f59e0b"),
g("color_background", "#f8fafc"), g("color_background", "#f8fafc"),
@@ -30,12 +41,24 @@ export async function ThemeVars() {
g("color_accent", "#10b981"), g("color_accent", "#10b981"),
g("button_primary_color", "#f59e0b"), g("button_primary_color", "#f59e0b"),
g("button_text_color", "#1e293b"), g("button_text_color", "#1e293b"),
g("button_secondary_color", "#22c55e"),
g("button_danger_color", "#ef4444"),
g("link_color", "#eeb425"),
g("link_hover_color", "#cf9d15"),
g("border_color", "#eeb425"), g("border_color", "#eeb425"),
g("border_radius", "12"), g("border_radius", "12"),
g("font_family", "nunito"),
g("size_heading_h1", "30"),
g("size_heading_h2", "22"),
g("size_heading_h3", "18"),
g("custom_css", ""),
]); ]);
// Only hex/keyword color values are admin-controlled — sanitize defensively. // Only hex/keyword color values are admin-controlled — sanitize defensively.
const safe = (v: string | null, d: string) => (v && /^[#a-zA-Z0-9(),.\s%-]+$/.test(v) ? v : d); const safe = (v: string | null, d: string) => (v && /^[#a-zA-Z0-9(),.\s%-]+$/.test(v) ? v : d);
const px = (v: string | null, d: string) => (/^\d{1,3}$/.test(v ?? "") ? (v as string) : d);
const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito;
const css = `:root{ const css = `:root{
--color-primary:${safe(primary, "#f59e0b")}; --color-primary:${safe(primary, "#f59e0b")};
@@ -49,10 +72,32 @@ export async function ThemeVars() {
--color-accent:${safe(accent, "#10b981")}; --color-accent:${safe(accent, "#10b981")};
--button-color:${safe(buttonColor, "#f59e0b")}; --button-color:${safe(buttonColor, "#f59e0b")};
--button-text-color:${safe(buttonText, "#1e293b")}; --button-text-color:${safe(buttonText, "#1e293b")};
--button-secondary-color:${safe(buttonSecondary, "#22c55e")};
--button-danger-color:${safe(buttonDanger, "#ef4444")};
--link-color:${safe(linkColor, "#eeb425")};
--link-hover-color:${safe(linkHover, "#cf9d15")};
--border-color:${safe(borderColor, "#eeb425")}; --border-color:${safe(borderColor, "#eeb425")};
--border-radius:${/^\d+$/.test(borderRadius ?? "") ? borderRadius : "12"}px; --border-radius:${px(borderRadius, "12")}px;
--font-family:${font.stack};
--size-heading-h1:${px(h1, "30")}px;
--size-heading-h2:${px(h2, "22")}px;
--size-heading-h3:${px(h3, "18")}px;
}`; }`;
// biome-ignore lint/security/noDangerouslySetInnerHtml: server-built CSS from sanitized admin settings const googleHref = font.google
return <style dangerouslySetInnerHTML={{ __html: css }} />; ? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
: null;
return (
<>
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: server-built CSS from sanitized admin settings */}
<style dangerouslySetInnerHTML={{ __html: css }} />
{customCss && customCss.trim() ? (
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
// biome-ignore lint/security/noDangerouslySetInnerHtml: staff-authored theme CSS
<style dangerouslySetInnerHTML={{ __html: customCss }} />
) : null}
</>
);
} }
+121
View File
@@ -1,6 +1,29 @@
// Theme constants shared by the admin Theme page and its server action. Kept in // 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. // a plain module because a "use server" file may only export async functions.
// Selectable body fonts. Web-safe stacks render with no extra request; the few
// Google fonts load via a <link> ThemeVars injects when chosen.
export const FONTS: Record<string, { label: string; stack: string; google?: string }> = {
nunito: { label: "Nunito (default)", stack: 'var(--font-nunito), "Nunito", sans-serif' },
system: { label: "System", stack: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif' },
inter: {
label: "Inter",
stack: '"Inter", sans-serif',
google: "Inter:wght@400;600;700;800",
},
poppins: {
label: "Poppins",
stack: '"Poppins", sans-serif',
google: "Poppins:wght@400;600;700;800",
},
roboto: { label: "Roboto", stack: '"Roboto", sans-serif', google: "Roboto:wght@400;500;700" },
georgia: { label: "Georgia (serif)", stack: 'Georgia, "Times New Roman", serif' },
trebuchet: { label: "Trebuchet", stack: '"Trebuchet MS", Verdana, sans-serif' },
verdana: { label: "Verdana", stack: "Verdana, Geneva, sans-serif" },
comic: { label: "Comic (retro)", stack: '"Comic Sans MS", "Comic Sans", cursive' },
mono: { label: "Monospace", stack: '"Courier New", ui-monospace, monospace' },
};
// The colour settings ThemeVars injects into :root. Editing these recolours the // The colour settings ThemeVars injects into :root. Editing these recolours the
// whole site live (ThemeVars reads them on every request). // whole site live (ThemeVars reads them on every request).
export const THEME_COLOR_KEYS = [ export const THEME_COLOR_KEYS = [
@@ -105,4 +128,102 @@ export const PRESETS: Record<string, Record<string, string>> = {
button_text_color: "#ffffff", button_text_color: "#ffffff",
border_color: "#f472b6", border_color: "#f472b6",
}, },
Galaxy: {
color_primary: "#a855f7",
color_background: "#0b0a1f",
color_surface: "#171430",
color_dropdown: "#211d44",
color_navbar: "#171430",
color_navbar_text: "#e9e5ff",
color_text: "#e9e5ff",
color_text_muted: "#a39dcf",
color_accent: "#22d3ee",
button_primary_color: "#a855f7",
button_text_color: "#ffffff",
border_color: "#7c3aed",
},
Royal: {
color_primary: "#4f46e5",
color_background: "#f5f6ff",
color_surface: "#ffffff",
color_dropdown: "#ffffff",
color_navbar: "#ffffff",
color_navbar_text: "#1e1b4b",
color_text: "#1e1b4b",
color_text_muted: "#636681",
color_accent: "#d97706",
button_primary_color: "#4f46e5",
button_text_color: "#ffffff",
border_color: "#818cf8",
},
Cyberpunk: {
color_primary: "#f0db4f",
color_background: "#0d0221",
color_surface: "#190933",
color_dropdown: "#241046",
color_navbar: "#190933",
color_navbar_text: "#f0db4f",
color_text: "#f5e9ff",
color_text_muted: "#b08fd6",
color_accent: "#ff2a6d",
button_primary_color: "#ff2a6d",
button_text_color: "#ffffff",
border_color: "#05d9e8",
},
Neon: {
color_primary: "#39ff14",
color_background: "#0a0a0a",
color_surface: "#141414",
color_dropdown: "#1d1d1d",
color_navbar: "#141414",
color_navbar_text: "#39ff14",
color_text: "#e8ffe8",
color_text_muted: "#7fae7f",
color_accent: "#ff00ff",
button_primary_color: "#39ff14",
button_text_color: "#0a0a0a",
border_color: "#39ff14",
},
Coffee: {
color_primary: "#a16207",
color_background: "#f5efe6",
color_surface: "#fffaf2",
color_dropdown: "#fffaf2",
color_navbar: "#fffaf2",
color_navbar_text: "#3f2d1c",
color_text: "#3f2d1c",
color_text_muted: "#8a7560",
color_accent: "#15803d",
button_primary_color: "#a16207",
button_text_color: "#ffffff",
border_color: "#ca8a04",
},
Arctic: {
color_primary: "#0891b2",
color_background: "#eef6fb",
color_surface: "#ffffff",
color_dropdown: "#ffffff",
color_navbar: "#ffffff",
color_navbar_text: "#0e3a4f",
color_text: "#0e3a4f",
color_text_muted: "#5b7585",
color_accent: "#6366f1",
button_primary_color: "#0891b2",
button_text_color: "#ffffff",
border_color: "#67e8f9",
},
Christmas: {
color_primary: "#dc2626",
color_background: "#f4faf4",
color_surface: "#ffffff",
color_dropdown: "#ffffff",
color_navbar: "#0f3d24",
color_navbar_text: "#ffffff",
color_text: "#14321f",
color_text_muted: "#5f7a68",
color_accent: "#15803d",
button_primary_color: "#dc2626",
button_text_color: "#ffffff",
border_color: "#16a34a",
},
}; };