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

+123 -42
View File
@@ -1,11 +1,11 @@
import { applyPreset, saveTheme } from "@/actions/admin-theme";
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 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_accent", label: "Accent", def: "#10b981" },
{ 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_text", label: "Text", def: "#0f172a" },
{ 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_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. */
function toHex(value: string | null, def: string): string {
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({
searchParams,
}: {
@@ -34,16 +76,23 @@ export default async function AdminTheme({
const current = Object.fromEntries(
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>;
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 (
<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
Restyle the whole site. Values are written to <code>website_settings</code> and applied
live (no rebuild).
</p>
@@ -91,48 +140,80 @@ export default async function AdminTheme({
</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>
<form action={saveTheme}>
<section className="admin-section">
<h2>Colours</h2>
<div className="card">{colorGrid(COLOR_FIELDS, current)}</div>
</section>
<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" }}>
<section className="admin-section">
<h2>Buttons & links</h2>
<div className="card">{colorGrid(BUTTON_FIELDS, current)}</div>
</section>
<section className="admin-section">
<h2>Typography & layout</h2>
<div className="card">
<div style={{ display: "flex", flexWrap: "wrap", gap: "1.25rem", alignItems: "center" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<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
</button>
</div>
</form>
</section>
</section>
</form>
</main>
);
}