Add admin HK color customization fields to theme editor
Local Build and Deploy / deploy (push) Successful in 58s
Local Build and Deploy / deploy (push) Successful in 58s
- Add 6 new admin color DB keys (admin_canvas, admin_surface, admin_text, admin_text_muted, admin_border, admin_sidebar_bg) that override the derived admin palette - Extract adminPaletteCss() from themePaletteCss() for reuse - Generate admin CSS variables in both :root and html.dark with overrides - Persist admin color settings via saveTheme action - Add Admin panel (HK) section to /admin/theme with color pickers
This commit is contained in:
1 parent
4dcf6fdce8
commit
a16d9859e8
5 files changed
+134
-57
No files matched your search
@@ -34,6 +34,12 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
|||||||
if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw);
|
if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const ADMIN_KEYS = ["admin_canvas", "admin_surface", "admin_text", "admin_text_muted", "admin_border", "admin_sidebar_bg"];
|
||||||
|
for (const key of ADMIN_KEYS) {
|
||||||
|
const raw = String(formData.get(key) ?? "").normalize("NFC").trim();
|
||||||
|
if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw);
|
||||||
|
}
|
||||||
|
|
||||||
const radius = String(formData.get("border_radius") ?? "").normalize("NFC").trim();
|
const radius = String(formData.get("border_radius") ?? "").normalize("NFC").trim();
|
||||||
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
|
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ const HEADINGS: { key: string; label: string; def: string }[] = [
|
|||||||
{ key: "size_heading_h3", label: "H3", def: "18" },
|
{ key: "size_heading_h3", label: "H3", def: "18" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ADMIN_FIELDS: { key: string; label: string; def: string }[] = [
|
||||||
|
{ key: "admin_canvas", label: "Admin canvas", def: "#0f172a" },
|
||||||
|
{ key: "admin_surface", label: "Admin surface", def: "#1e293b" },
|
||||||
|
{ key: "admin_text", label: "Admin text", def: "#f1f5f9" },
|
||||||
|
{ key: "admin_text_muted", label: "Admin muted text", def: "#94a3b8" },
|
||||||
|
{ key: "admin_border", label: "Admin border", def: "#334155" },
|
||||||
|
{ key: "admin_sidebar_bg", label: "Admin sidebar", def: "#0f172a" },
|
||||||
|
];
|
||||||
|
|
||||||
const ALL_COLORS = [...COLOR_FIELDS, ...BUTTON_FIELDS, ...GRADIENT_FIELDS];
|
const ALL_COLORS = [...COLOR_FIELDS, ...BUTTON_FIELDS, ...GRADIENT_FIELDS];
|
||||||
|
|
||||||
function toHex(value: string | null, def: string): string {
|
function toHex(value: string | null, def: string): string {
|
||||||
@@ -92,7 +101,9 @@ export default async function AdminTheme({
|
|||||||
|
|
||||||
const current = Object.fromEntries(
|
const current = Object.fromEntries(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ALL_COLORS.map(async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const),
|
[...ALL_COLORS, ...ADMIN_FIELDS].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 defaultPreset = PRESETS["Atom (golden)"];
|
const defaultPreset = PRESETS["Atom (golden)"];
|
||||||
@@ -190,6 +201,14 @@ export default async function AdminTheme({
|
|||||||
<div className="admin-card">{colorGrid(GRADIENT_FIELDS, current)}</div>
|
<div className="admin-card">{colorGrid(GRADIENT_FIELDS, current)}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<h2 className="mt-8 text-lg font-extrabold">Admin panel (HK)</h2>
|
||||||
|
<section className="mt-4">
|
||||||
|
<p className="text-xs text-[var(--admin-text-muted)] mb-2">
|
||||||
|
Colors for the Housekeeping admin panel. These override the defaults derived from light/dark mode palettes.
|
||||||
|
</p>
|
||||||
|
<div className="admin-card">{colorGrid(ADMIN_FIELDS, current)}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h2 className="mt-8 text-lg font-extrabold">Dark mode</h2>
|
<h2 className="mt-8 text-lg font-extrabold">Dark mode</h2>
|
||||||
<section className="mt-4">
|
<section className="mt-4">
|
||||||
<h3 className="admin-section-title">{t("colours")}</h3>
|
<h3 className="admin-section-title">{t("colours")}</h3>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { themePaletteCss } from "@/lib/theme-css";
|
import { adminPaletteCss, themePaletteCss } from "@/lib/theme-css";
|
||||||
import { FONTS, PRESETS, THEME_COLOR_KEYS, type ThemePalette } from "@/lib/theme-presets";
|
import { FONTS, PRESETS, THEME_COLOR_KEYS, type ThemePalette } from "@/lib/theme-presets";
|
||||||
import { derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
import { deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
||||||
|
|
||||||
// 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. Covers
|
// AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers
|
||||||
@@ -41,6 +41,12 @@ export async function ThemeVars() {
|
|||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
customCss,
|
customCss,
|
||||||
|
adminCanvas,
|
||||||
|
adminSurface,
|
||||||
|
adminText,
|
||||||
|
adminTextMuted,
|
||||||
|
adminBorder,
|
||||||
|
adminSidebarBg,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
g("color_primary", "#f59e0b"),
|
g("color_primary", "#f59e0b"),
|
||||||
g("color_background", "#f8fafc"),
|
g("color_background", "#f8fafc"),
|
||||||
@@ -74,6 +80,12 @@ export async function ThemeVars() {
|
|||||||
g("size_heading_h2", "22"),
|
g("size_heading_h2", "22"),
|
||||||
g("size_heading_h3", "18"),
|
g("size_heading_h3", "18"),
|
||||||
g("custom_css", ""),
|
g("custom_css", ""),
|
||||||
|
g("admin_canvas", ""),
|
||||||
|
g("admin_surface", ""),
|
||||||
|
g("admin_text", ""),
|
||||||
|
g("admin_text_muted", ""),
|
||||||
|
g("admin_border", ""),
|
||||||
|
g("admin_sidebar_bg", ""),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only hex/keyword color values are admin-controlled — sanitize defensively.
|
// Only hex/keyword color values are admin-controlled — sanitize defensively.
|
||||||
@@ -99,6 +111,15 @@ export async function ThemeVars() {
|
|||||||
const readableNavbarText = readableColor(safe(navbarText, "#1e293b"), [safe(navbar, "#ffffff")]);
|
const readableNavbarText = readableColor(safe(navbarText, "#1e293b"), [safe(navbar, "#ffffff")]);
|
||||||
const readablePrimaryForeground = readableColor(safe(buttonText, "#1e293b"), [safe(primary, "#f59e0b")]);
|
const readablePrimaryForeground = readableColor(safe(buttonText, "#1e293b"), [safe(primary, "#f59e0b")]);
|
||||||
const readableAccentForeground = readableColor("#ffffff", [safe(accent, "#10b981")]);
|
const readableAccentForeground = readableColor("#ffffff", [safe(accent, "#10b981")]);
|
||||||
|
const adminOverrides = {
|
||||||
|
...(adminCanvas ? { canvas: safe(adminCanvas, "") } : {}),
|
||||||
|
...(adminSurface ? { surface: safe(adminSurface, "") } : {}),
|
||||||
|
...(adminText ? { text: safe(adminText, "") } : {}),
|
||||||
|
...(adminTextMuted ? { muted: safe(adminTextMuted, "") } : {}),
|
||||||
|
...(adminBorder ? { border: safe(adminBorder, "") } : {}),
|
||||||
|
...(adminSidebarBg ? { sidebar: safe(adminSidebarBg, "") } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
const semantic = derivePublicForegrounds({
|
const semantic = derivePublicForegrounds({
|
||||||
color_background: safeBackground,
|
color_background: safeBackground,
|
||||||
color_surface: safeSurface,
|
color_surface: safeSurface,
|
||||||
@@ -132,9 +153,22 @@ export async function ThemeVars() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
) as ThemePalette;
|
) as ThemePalette;
|
||||||
const darkCss = themePaletteCss("html.dark", darkPalette);
|
const darkCss = themePaletteCss("html.dark", darkPalette, adminOverrides);
|
||||||
|
|
||||||
const css = `:root{
|
const lightAdmin = deriveAdminPalette({
|
||||||
|
color_background: safeBackground,
|
||||||
|
color_surface: safeSurface,
|
||||||
|
color_text: safe(text, "#0f172a"),
|
||||||
|
color_text_muted: safe(textMuted, "#64748b"),
|
||||||
|
color_primary: safe(primary, "#f59e0b"),
|
||||||
|
color_accent: safe(accent, "#10b981"),
|
||||||
|
color_success: safe(success, "#16a34a"),
|
||||||
|
color_warning: safe(warning, "#eab308"),
|
||||||
|
color_error: safe(error, "#ef4444"),
|
||||||
|
color_info: safe(info, "#0ea5e9"),
|
||||||
|
} as ThemePalette, adminOverrides);
|
||||||
|
|
||||||
|
const css = `:root{${adminPaletteCss(lightAdmin)}
|
||||||
--color-primary:${safe(primary, "#f59e0b")};
|
--color-primary:${safe(primary, "#f59e0b")};
|
||||||
--color-background:${safeBackground};
|
--color-background:${safeBackground};
|
||||||
--color-surface:${safeSurface};
|
--color-surface:${safeSurface};
|
||||||
|
|||||||
+32
-23
@@ -27,36 +27,45 @@ export interface AdminPalette {
|
|||||||
focusRing: string;
|
focusRing: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveAdminPalette(colors: PublicThemeColors): AdminPalette {
|
export function deriveAdminPalette(
|
||||||
const publicBackground = colors.color_background ?? "#f8fafc";
|
colors: PublicThemeColors,
|
||||||
const blackRatio = contrastRatio("#000000", publicBackground) ?? 21;
|
overrides?: Partial<AdminPalette>,
|
||||||
const whiteRatio = contrastRatio("#ffffff", publicBackground) ?? 1;
|
): AdminPalette {
|
||||||
const dark = whiteRatio > blackRatio;
|
|
||||||
const canvas = dark ? "#0b0f19" : "#f1f5f9";
|
|
||||||
const surface = dark ? "#151b27" : "#ffffff";
|
|
||||||
const surfaceElevated = dark ? "#1e2635" : "#f8fafc";
|
|
||||||
const text = dark ? "#f8fafc" : "#0f172a";
|
|
||||||
const muted = dark ? "#a8b3c7" : "#475569";
|
|
||||||
const accent = colors.color_primary ?? "#f59e0b";
|
const accent = colors.color_primary ?? "#f59e0b";
|
||||||
return {
|
const defaults: AdminPalette = {
|
||||||
canvas,
|
canvas: "#0b0f19",
|
||||||
surface,
|
surface: "#151b27",
|
||||||
surfaceElevated,
|
surfaceElevated: "#1e2635",
|
||||||
text: readableColor(text, [canvas, surface, surfaceElevated]),
|
text: "#f8fafc",
|
||||||
muted: readableColor(muted, [canvas, surface, surfaceElevated]),
|
muted: "#94a3b8",
|
||||||
border: dark ? "#334155" : "#cbd5e1",
|
border: "#2a3448",
|
||||||
accent,
|
accent,
|
||||||
accentText: readableColor(accent, [canvas, surface, surfaceElevated]),
|
accentText: readableColor(accent, ["#0b0f19", "#151b27", "#1e2635"]),
|
||||||
accentForeground: readableColor("#ffffff", [accent]),
|
accentForeground: readableColor("#ffffff", [accent]),
|
||||||
success: dark ? "#4ade80" : "#15803d",
|
success: "#22c55e",
|
||||||
warning: dark ? "#facc15" : "#a16207",
|
warning: "#eab308",
|
||||||
error: dark ? "#f87171" : "#b91c1c",
|
error: "#ef4444",
|
||||||
info: dark ? "#38bdf8" : "#0369a1",
|
info: "#3b82f6",
|
||||||
sidebar: dark ? "#101622" : "#172033",
|
sidebar: "#080c18",
|
||||||
sidebarText: "#f8fafc",
|
sidebarText: "#f8fafc",
|
||||||
overlay: "rgba(2, 6, 23, 0.72)",
|
overlay: "rgba(2, 6, 23, 0.72)",
|
||||||
focusRing: accent,
|
focusRing: accent,
|
||||||
};
|
};
|
||||||
|
if (!overrides) return defaults;
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...overrides,
|
||||||
|
accent,
|
||||||
|
accentText: readableColor(accent, [overrides.canvas ?? defaults.canvas, overrides.surface ?? defaults.surface, overrides.surfaceElevated ?? defaults.surfaceElevated]),
|
||||||
|
accentForeground: readableColor("#ffffff", [accent]),
|
||||||
|
success: overrides.success ?? defaults.success,
|
||||||
|
warning: overrides.warning ?? defaults.warning,
|
||||||
|
error: overrides.error ?? defaults.error,
|
||||||
|
info: overrides.info ?? defaults.info,
|
||||||
|
sidebarText: readableColor(overrides.sidebar ?? defaults.sidebar, [overrides.canvas ?? defaults.canvas, overrides.surface ?? defaults.surface]),
|
||||||
|
overlay: defaults.overlay,
|
||||||
|
focusRing: accent,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function derivePublicForegrounds(c: PublicThemeColors) {
|
export function derivePublicForegrounds(c: PublicThemeColors) {
|
||||||
|
|||||||
+38
-29
@@ -1,4 +1,4 @@
|
|||||||
import { deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
import { deriveAdminPalette, derivePublicForegrounds, readableColor, type AdminPalette } from "@/lib/theme-contrast";
|
||||||
import type { ThemePalette } from "@/lib/theme-presets";
|
import type { ThemePalette } from "@/lib/theme-presets";
|
||||||
|
|
||||||
const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
|
const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
|
||||||
@@ -30,9 +30,41 @@ const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
|
|||||||
gradient_to: "gradient-to",
|
gradient_to: "gradient-to",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function themePaletteCss(selector: string, palette: ThemePalette): string {
|
export function adminPaletteCss(admin: AdminPalette): string {
|
||||||
|
const parts: string[] = [
|
||||||
|
`--admin-sidebar-background:${admin.sidebar};`,
|
||||||
|
`--admin-sidebar-text-readable:${admin.sidebarText};`,
|
||||||
|
`--admin-sidebar-muted-readable:color-mix(in srgb, ${admin.sidebarText}, ${admin.sidebar} 38%);`,
|
||||||
|
`--admin-input-text-readable:${readableColor(admin.text, [admin.canvas, admin.surface])};`,
|
||||||
|
`--admin-canvas:${admin.canvas};`,
|
||||||
|
`--admin-surface:${admin.surface};`,
|
||||||
|
`--admin-surface-elevated:${admin.surfaceElevated};`,
|
||||||
|
`--admin-text:${admin.text};`,
|
||||||
|
`--admin-text-muted:${admin.muted};`,
|
||||||
|
`--admin-border:${admin.border};`,
|
||||||
|
`--admin-accent:${admin.accent};`,
|
||||||
|
`--admin-accent-text:${admin.accentText};`,
|
||||||
|
`--admin-accent-foreground:${admin.accentForeground};`,
|
||||||
|
`--admin-success:${admin.success};`,
|
||||||
|
`--admin-warning:${admin.warning};`,
|
||||||
|
`--admin-error:${admin.error};`,
|
||||||
|
`--admin-info:${admin.info};`,
|
||||||
|
`--admin-sidebar:${admin.sidebar};`,
|
||||||
|
`--admin-sidebar-text:${admin.sidebarText};`,
|
||||||
|
`--admin-overlay:${admin.overlay};`,
|
||||||
|
`--admin-focus-ring:${admin.focusRing};`,
|
||||||
|
...(["success", "warning", "error", "info"] as const).flatMap((status) => [
|
||||||
|
`--admin-${status}-foreground:${readableColor("#ffffff", [admin[status]])};`,
|
||||||
|
`--admin-${status}-subtle:color-mix(in srgb, ${admin[status]} 14%, ${admin.surface});`,
|
||||||
|
`--admin-${status}-border:color-mix(in srgb, ${admin[status]} 38%, transparent);`,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function themePaletteCss(selector: string, palette: ThemePalette, adminOverrides?: Partial<AdminPalette>): string {
|
||||||
const semantic = derivePublicForegrounds(palette);
|
const semantic = derivePublicForegrounds(palette);
|
||||||
const admin = deriveAdminPalette(palette);
|
const admin = deriveAdminPalette(palette, adminOverrides);
|
||||||
const declarations = Object.entries(CSS_VARIABLES).map(
|
const declarations = Object.entries(CSS_VARIABLES).map(
|
||||||
([key, cssName]) => `--${cssName}:${palette[key as keyof ThemePalette]};`,
|
([key, cssName]) => `--${cssName}:${palette[key as keyof ThemePalette]};`,
|
||||||
);
|
);
|
||||||
@@ -57,32 +89,6 @@ export function themePaletteCss(selector: string, palette: ThemePalette): string
|
|||||||
`--color-overlay-text-readable:${semantic.byName.overlay};`,
|
`--color-overlay-text-readable:${semantic.byName.overlay};`,
|
||||||
`--color-text-subtle-readable:${semantic.byName.subtle};`,
|
`--color-text-subtle-readable:${semantic.byName.subtle};`,
|
||||||
`--color-text-disabled-readable:${semantic.byName.disabled};`,
|
`--color-text-disabled-readable:${semantic.byName.disabled};`,
|
||||||
`--admin-sidebar-background:${admin.sidebar};`,
|
|
||||||
`--admin-sidebar-text-readable:${admin.sidebarText};`,
|
|
||||||
`--admin-sidebar-muted-readable:color-mix(in srgb, ${admin.sidebarText}, ${admin.sidebar} 38%);`,
|
|
||||||
`--admin-input-text-readable:${semantic.byName.input};`,
|
|
||||||
`--admin-canvas:${admin.canvas};`,
|
|
||||||
`--admin-surface:${admin.surface};`,
|
|
||||||
`--admin-surface-elevated:${admin.surfaceElevated};`,
|
|
||||||
`--admin-text:${admin.text};`,
|
|
||||||
`--admin-text-muted:${admin.muted};`,
|
|
||||||
`--admin-border:${admin.border};`,
|
|
||||||
`--admin-accent:${admin.accent};`,
|
|
||||||
`--admin-accent-text:${admin.accentText};`,
|
|
||||||
`--admin-accent-foreground:${admin.accentForeground};`,
|
|
||||||
`--admin-success:${admin.success};`,
|
|
||||||
`--admin-warning:${admin.warning};`,
|
|
||||||
`--admin-error:${admin.error};`,
|
|
||||||
`--admin-info:${admin.info};`,
|
|
||||||
`--admin-sidebar:${admin.sidebar};`,
|
|
||||||
`--admin-sidebar-text:${admin.sidebarText};`,
|
|
||||||
`--admin-overlay:${admin.overlay};`,
|
|
||||||
`--admin-focus-ring:${admin.focusRing};`,
|
|
||||||
...(["success", "warning", "error", "info"] as const).flatMap((status) => [
|
|
||||||
`--admin-${status}-foreground:${readableColor("#ffffff", [admin[status]])};`,
|
|
||||||
`--admin-${status}-subtle:color-mix(in srgb, ${admin[status]} 14%, ${admin.surface});`,
|
|
||||||
`--admin-${status}-border:color-mix(in srgb, ${admin[status]} 38%, transparent);`,
|
|
||||||
]),
|
|
||||||
...(["success", "warning", "error", "info"] as const).flatMap((status) => [
|
...(["success", "warning", "error", "info"] as const).flatMap((status) => [
|
||||||
`--color-${status}-text-readable:${semantic.byName[`${status}-text`]};`,
|
`--color-${status}-text-readable:${semantic.byName[`${status}-text`]};`,
|
||||||
`--color-${status}-foreground-readable:${semantic.byName[`${status}-solid`]};`,
|
`--color-${status}-foreground-readable:${semantic.byName[`${status}-solid`]};`,
|
||||||
@@ -90,5 +96,8 @@ export function themePaletteCss(selector: string, palette: ThemePalette): string
|
|||||||
`--color-${status}-border:color-mix(in srgb, ${palette[`color_${status}`]} 38%, transparent);`,
|
`--color-${status}-border:color-mix(in srgb, ${palette[`color_${status}`]} 38%, transparent);`,
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
if (adminOverrides || selector !== ":root") {
|
||||||
|
declarations.push(adminPaletteCss(admin));
|
||||||
|
}
|
||||||
return `${selector}{${declarations.join("")}}`;
|
return `${selector}{${declarations.join("")}}`;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user