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 { 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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user