import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { applyCustomTheme, applyPreset, deleteCustomTheme, saveCustomTheme, saveTheme, } from "@/actions/admin-theme"; import { RenameTheme } from "@/components/admin/theme/rename-theme"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { siteSettings } from "@/lib/services/site-settings"; import { listCustomThemes } from "@/lib/theme-custom-store"; import { FONTS, PRESETS } from "@/lib/theme-presets"; import { ColorField } from "./color-field"; export const dynamic = "force-dynamic"; export const metadata = { title: "Theme" }; const COLOR_FIELDS: { key: string; label: string; def: string; desc: string; bgKey?: string; }[] = [ { key: "color_background", label: "Page background", def: "#f8fafc", desc: "Main page background behind all content", }, { key: "color_surface", label: "Card / surface", def: "#ffffff", desc: "Cards, panels, and elevated surfaces", }, { key: "color_dropdown", label: "Dropdown menu", def: "#ffffff", desc: "Dropdown menus and input backgrounds", }, { key: "color_navbar", label: "Navbar bar", def: "#ffffff", desc: "Top navigation bar background", }, { key: "color_navbar_text", label: "Navbar text", def: "#1e293b", desc: "Text color in the navigation bar", bgKey: "color_navbar", }, { key: "color_text", label: "Body text", def: "#0f172a", desc: "Main body text — must contrast with background/surface", bgKey: "color_surface", }, { key: "color_text_muted", label: "Muted text", def: "#64748b", desc: "Secondary text, labels, hints — lighter than body text", bgKey: "color_surface", }, { key: "color_primary", label: "Primary accent", def: "#f59e0b", desc: "Main brand color, highlights, active elements", }, { key: "color_accent", label: "Secondary accent", def: "#10b981", desc: "Secondary brand color, success-oriented highlights", }, { key: "border_color", label: "Border (golden)", def: "#eeb425", desc: "Borders, dividers, outlines", }, { key: "color_success", label: "Success", def: "#16a34a", desc: "Positive status (green)", }, { key: "color_warning", label: "Warning", def: "#eab308", desc: "Warning status (yellow)", }, { key: "color_error", label: "Error", def: "#ef4444", desc: "Error status (red)", }, { key: "color_info", label: "Info", def: "#0ea5e9", desc: "Info status (blue)", }, ]; const BUTTON_FIELDS: { key: string; label: string; def: string; desc: string; bgKey?: string; }[] = [ { key: "button_primary_color", label: "Primary button", def: "#f59e0b", desc: "Main call-to-action button background", }, { key: "button_text_color", label: "Button text", def: "#1e293b", desc: "Text on primary buttons — must contrast with button color", bgKey: "button_primary_color", }, { key: "button_secondary_color", label: "Secondary button", def: "#22c55e", desc: "Secondary action button background", }, { key: "button_secondary_text_color", label: "Secondary text", def: "#ffffff", desc: "Text on secondary buttons", bgKey: "button_secondary_color", }, { key: "button_danger_color", label: "Danger button", def: "#ef4444", desc: "Destructive action button (delete, ban)", }, { key: "button_danger_text_color", label: "Danger text", def: "#ffffff", desc: "Text on danger buttons", bgKey: "button_danger_color", }, { key: "button_outline_color", label: "Outline border", def: "#eeb425", desc: "Outline button border color", }, { key: "button_outline_text_color", label: "Outline text", def: "#1a1a2e", desc: "Text on outline buttons", bgKey: "color_surface", }, { key: "link_color", label: "Link", def: "#eeb425", desc: "Hyperlink text color", bgKey: "color_surface", }, { key: "link_hover_color", label: "Link hover", def: "#cf9d15", desc: "Hyperlink text on hover", bgKey: "color_surface", }, ]; const GRADIENT_FIELDS: { key: string; label: string; def: string; desc: string; }[] = [ { key: "gradient_from", label: "Gradient start", def: "#f59e0b", desc: "Left/top color of card header and hero gradients", }, { key: "gradient_to", label: "Gradient end", def: "#10b981", desc: "Right/bottom color of card header and hero gradients", }, ]; 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 ADMIN_FIELDS: { key: string; label: string; def: string; desc: string; bgKey?: string; }[] = [ { key: "admin_canvas", label: "Page background", def: "#0f172a", desc: "Main admin page background", }, { key: "admin_surface", label: "Card / panel", def: "#1e293b", desc: "Admin cards, tables, and panel backgrounds", }, { key: "admin_text", label: "Body text", def: "#f1f5f9", desc: "Main admin text — must contrast with canvas/surface", bgKey: "admin_canvas", }, { key: "admin_text_muted", label: "Muted text", def: "#94a3b8", desc: "Secondary labels, hints, descriptions", bgKey: "admin_canvas", }, { key: "admin_border", label: "Border", def: "#334155", desc: "Table borders, dividers, input outlines", }, { key: "admin_sidebar_bg", label: "Sidebar", def: "#0f172a", desc: "Left navigation sidebar background", }, ]; const ALL_COLORS = [...COLOR_FIELDS, ...BUTTON_FIELDS, ...GRADIENT_FIELDS]; function toHex(value: string | null, def: string): string { return value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : def; } interface ThemeField { key: string; label: string; def: string; desc: string; bgKey?: string; } function colorFields( fields: ThemeField[], current: Record, suffix = "", ) { return (
{fields.map((f) => { const val = toHex(current[f.key], f.def); const bgKey = f.bgKey ? `${f.bgKey}${suffix}` : undefined; // eslint-disable-next-line security/detect-object-injection -- bgKey derived from hardcoded field list const bgValue = bgKey ? toHex(current[bgKey], "") : undefined; return ( ); })}
); } export default async function AdminTheme({ searchParams, }: { searchParams: Promise<{ saved?: string; preset?: string; savedTheme?: string; theme?: string; renamed?: string; deletedTheme?: string; }>; }) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { redirect("/admin"); } const t = await getTranslations("pages.admin.theme"); const sp = await searchParams; const current = Object.fromEntries( await Promise.all( [...ALL_COLORS, ...ADMIN_FIELDS].map( async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const, ), ), ) as Record; const defaultPreset = PRESETS["Atom (golden)"]; const currentDark = Object.fromEntries( await Promise.all( ALL_COLORS.map( async (f) => [ f.key, (await siteSettings.get( `${f.key}_dark`, defaultPreset.dark[f.key as keyof typeof defaultPreset.dark], )) ?? defaultPreset.dark[f.key as keyof typeof defaultPreset.dark], ] as const, ), ), ) as Record; const customThemes = await listCustomThemes(); 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; const customCss = (await siteSettings.get("custom_css", "")) ?? ""; return (
🎨

{t("title")}

{t.rich("subtitle", { code: (chunks) => {chunks} })}

{sp.saved ? (
{t("saved")}
) : null} {sp.preset ? (
{t("presetApplied", { preset: sp.preset })}
) : null} {sp.savedTheme ? (
Theme saved as preset.
) : null} {sp.theme ? (
Applied theme “{sp.theme}”.
) : null} {sp.renamed ? (
Theme renamed.
) : null} {sp.deletedTheme ? (
Theme deleted.
) : null}

{t("presetsTitle")}

{Object.entries(PRESETS).map(([name, palette]) => (
))}

Your saved themes

Save the current configuration as a named preset, then load it anytime. Perfect for keeping multiple brand themes ready to switch.

{customThemes.length === 0 ? (

No saved themes yet — save one above.

) : (
{customThemes.map((theme) => (
{[ "color_primary", "color_accent", "color_surface", "color_text", ].map((k) => ( ))}
{theme.name}
))}
)}

☀ Light mode

These colors are used when visitors view your site in light mode (default). Make sure body text contrasts well with both the page background and card surface.

Page & text colors

Backgrounds, surfaces, and text colors. Text colors must contrast with their backgrounds for readability.

{colorFields(COLOR_FIELDS, current)}

Buttons & links

Button backgrounds, text on buttons, and link colors. Button text must contrast with its button background.

{colorFields(BUTTON_FIELDS, current)}

Gradients

Card header and hero section gradient. These blend from start → end across headings and banners.

{colorFields(GRADIENT_FIELDS, current)}

🛠 Admin panel (HK)

Override colors for the Housekeeping admin panel. These apply across both light and dark modes. Leave empty to use auto-derived defaults.

{colorFields(ADMIN_FIELDS, current)}

🌙 Dark mode

These colors are used when visitors switch to dark mode. Dark backgrounds need lighter text for good contrast.

Page & text colors

{colorFields(COLOR_FIELDS, currentDark, "_dark")}

Buttons & links

{colorFields(BUTTON_FIELDS, currentDark, "_dark")}

Gradients

{colorFields(GRADIENT_FIELDS, currentDark, "_dark")}

{t("typographyLayout")}

{HEADINGS.map((h) => ( ))}

{t("cssTitle")}

{t("cssSubtitle")}