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:
1 parent
0cd4d06ff6
commit
5a4b6f27e9
5 files changed
+322
-52
No files matched your search
@@ -6,10 +6,19 @@ 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";
|
||||
import { FONTS, 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%-]+$/;
|
||||
// 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> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
@@ -23,18 +32,32 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
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();
|
||||
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);
|
||||
|
||||
// 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();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_update",
|
||||
description: "Updated theme colours",
|
||||
description: "Updated theme settings",
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
|
||||
+123
-42
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -205,16 +205,16 @@ body {
|
||||
|
||||
/* ── App component classes (used across the converted pages) ── */
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
font-size: var(--size-heading-h1, 30px);
|
||||
font-weight: 800;
|
||||
margin: 0.2em 0 0.5em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-size: var(--size-heading-h2, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-size: var(--size-heading-h3, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
a {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { FONTS } from "@/lib/theme-presets";
|
||||
|
||||
// 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() {
|
||||
const g = (k: string, d: string) => siteSettings.get(k, d);
|
||||
const [
|
||||
@@ -16,8 +18,17 @@ export async function ThemeVars() {
|
||||
accent,
|
||||
buttonColor,
|
||||
buttonText,
|
||||
buttonSecondary,
|
||||
buttonDanger,
|
||||
linkColor,
|
||||
linkHover,
|
||||
borderColor,
|
||||
borderRadius,
|
||||
fontKey,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
customCss,
|
||||
] = await Promise.all([
|
||||
g("color_primary", "#f59e0b"),
|
||||
g("color_background", "#f8fafc"),
|
||||
@@ -30,12 +41,24 @@ export async function ThemeVars() {
|
||||
g("color_accent", "#10b981"),
|
||||
g("button_primary_color", "#f59e0b"),
|
||||
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_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.
|
||||
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{
|
||||
--color-primary:${safe(primary, "#f59e0b")};
|
||||
@@ -49,10 +72,32 @@ export async function ThemeVars() {
|
||||
--color-accent:${safe(accent, "#10b981")};
|
||||
--button-color:${safe(buttonColor, "#f59e0b")};
|
||||
--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-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
|
||||
return <style dangerouslySetInnerHTML={{ __html: css }} />;
|
||||
const googleHref = font.google
|
||||
? `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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user