Add Theme Settings tool + polish remaining radio detail pages

- New /admin/theme: recolour the whole site from housekeeping. 6 atom-
  faithful presets (Atom/Midnight/Ocean/Forest/Sunset/Candy) + per-colour
  pickers for the 12 settings ThemeVars injects + border radius. Writes to
  website_settings, busts the siteSettings cache, and revalidates the
  layout so the new palette applies live with no rebuild. Constants live
  in src/lib/theme-presets.ts (a "use server" file can't export objects).
  Added to the admin sidebar (System).
- radio/contests/[id] + giveaways/[id] wrapped in ContentCard to match
  the public design system.
- Skipped a separate VPN page: /admin/ip already manages the IP
  white/blacklist, so it would only duplicate it.

Verified live (prod, amx_test): applied the Ocean preset → home renders
--color-primary #0ea5e9 site-wide; reverted the test rows. tsc 0,
vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-28 20:20:38 +02:00
1 parent 6f15e0c8a3
commit 1199176883
6 files changed
+322 -10

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
import { applyPreset, saveTheme } from "@/actions/admin-theme";
import { siteSettings } from "@/lib/services/site-settings";
import { PRESETS } from "@/lib/theme-presets";
export const dynamic = "force-dynamic";
export const metadata = { title: "Theme" };
const 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" },
{ key: "color_background", label: "Background", def: "#f8fafc" },
{ key: "color_surface", label: "Surface / cards", def: "#ffffff" },
{ key: "color_dropdown", label: "Dropdown", def: "#ffffff" },
{ key: "color_navbar", label: "Navbar", def: "#ffffff" },
{ 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" },
{ key: "button_primary_color", label: "Button", def: "#f59e0b" },
{ key: "button_text_color", label: "Button text", def: "#1e293b" },
];
/** 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;
}
export default async function AdminTheme({
searchParams,
}: {
searchParams: Promise<{ saved?: string; preset?: string }>;
}) {
const sp = await searchParams;
const current = Object.fromEntries(
await Promise.all(
FIELDS.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";
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
live (no rebuild).
</p>
{sp.saved ? (
<p className="admin-badge ok" style={{ display: "inline-block" }}>
Theme saved
</p>
) : null}
{sp.preset ? (
<p className="admin-badge ok" style={{ display: "inline-block" }}>
Applied preset “{sp.preset}”
</p>
) : null}
<section className="admin-section">
<h2>Presets</h2>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{Object.entries(PRESETS).map(([name, palette]) => (
<form key={name} action={applyPreset}>
<input type="hidden" name="preset" value={name} />
<button
type="submit"
className="btn btn-outline"
style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem" }}
>
<span style={{ display: "inline-flex" }}>
{["color_primary", "color_accent", "color_surface", "color_text"].map((k) => (
<span
key={k}
style={{
width: 14,
height: 14,
borderRadius: 3,
background: palette[k],
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
marginLeft: -3,
}}
/>
))}
</span>
{name}
</button>
</form>
))}
</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>
<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" }}>
Save theme
</button>
</div>
</form>
</section>
</main>
);
}