240 lines
9.5 KiB
TypeScript
240 lines
9.5 KiB
TypeScript
import { getTranslations } from "next-intl/server";
|
|
import { applyPreset, saveTheme } from "@/actions/admin-theme";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
import { FONTS, PRESETS } from "@/lib/theme-presets";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const metadata = { title: "Theme" };
|
|
|
|
const COLOR_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: "color_success", label: "Success", def: "#16a34a" },
|
|
{ key: "color_warning", label: "Warning", def: "#eab308" },
|
|
{ key: "color_error", label: "Error", def: "#ef4444" },
|
|
{ key: "color_info", label: "Info", def: "#0ea5e9" },
|
|
];
|
|
|
|
const BUTTON_FIELDS: { key: string; label: string; def: string }[] = [
|
|
{ key: "button_primary_color", label: "Primary button", def: "#f59e0b" },
|
|
{ key: "button_text_color", label: "Button text", def: "#1e293b" },
|
|
{ key: "button_secondary_color", label: "Secondary button", def: "#22c55e" },
|
|
{ key: "button_secondary_text_color", label: "Secondary text", def: "#ffffff" },
|
|
{ key: "button_danger_color", label: "Danger button", def: "#ef4444" },
|
|
{ key: "button_danger_text_color", label: "Danger text", def: "#ffffff" },
|
|
{ key: "button_outline_color", label: "Outline button", def: "#eeb425" },
|
|
{ key: "button_outline_text_color", label: "Outline text", def: "#1a1a2e" },
|
|
{ key: "link_color", label: "Link", def: "#eeb425" },
|
|
{ key: "link_hover_color", label: "Link hover", def: "#cf9d15" },
|
|
];
|
|
|
|
const GRADIENT_FIELDS: { key: string; label: string; def: string }[] = [
|
|
{ key: "gradient_from", label: "Gradient from", def: "#f59e0b" },
|
|
{ key: "gradient_to", label: "Gradient to", def: "#10b981" },
|
|
];
|
|
|
|
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, ...GRADIENT_FIELDS];
|
|
|
|
function toHex(value: string | null, def: string): string {
|
|
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({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ saved?: string; preset?: string }>;
|
|
}) {
|
|
const t = await getTranslations("pages.admin.theme");
|
|
|
|
const sp = await searchParams;
|
|
|
|
const current = Object.fromEntries(
|
|
await Promise.all(
|
|
ALL_COLORS.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";
|
|
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 (
|
|
<main>
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
|
<span className="text-lg">🎨</span>
|
|
</div>
|
|
<div>
|
|
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
|
|
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
|
|
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{sp.saved ? (
|
|
<div className="mb-4 p-3 rounded-lg bg-green-600/10 text-green-600 dark:text-green-400 text-xs">
|
|
{t("saved")}
|
|
</div>
|
|
) : null}
|
|
{sp.preset ? (
|
|
<div className="mb-4 p-3 rounded-lg bg-green-600/10 text-green-600 dark:text-green-400 text-xs">
|
|
{t("presetApplied", { preset: sp.preset })}
|
|
</div>
|
|
) : null}
|
|
|
|
<section className="mt-6">
|
|
<h2 className="admin-section-title">{t("presetsTitle")}</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
{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 inline-flex items-center gap-2">
|
|
<span className="flex items-center gap-1">
|
|
{["color_primary", "color_accent", "color_surface", "color_text"].map((k) => (
|
|
<span
|
|
key={k}
|
|
style={{
|
|
width: 14,
|
|
height: 14,
|
|
borderRadius: 3,
|
|
// eslint-disable-next-line security/detect-object-injection -- k from hardcoded array
|
|
background: palette[k],
|
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
|
|
marginLeft: -3,
|
|
}}
|
|
/>
|
|
))}
|
|
</span>
|
|
{name}
|
|
</button>
|
|
</form>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<form action={saveTheme}>
|
|
<section className="mt-6">
|
|
<h2 className="admin-section-title">{t("colours")}</h2>
|
|
<div className="admin-card">{colorGrid(COLOR_FIELDS, current)}</div>
|
|
</section>
|
|
|
|
<section className="mt-6">
|
|
<h2 className="admin-section-title">{t("buttonsLinks")}</h2>
|
|
<div className="admin-card">{colorGrid(BUTTON_FIELDS, current)}</div>
|
|
</section>
|
|
|
|
<section className="mt-6">
|
|
<h2 className="admin-section-title">Gradient mix</h2>
|
|
<p className="text-xs text-[var(--color-text-muted)] mb-2">
|
|
Control the card header and hero background gradients.
|
|
</p>
|
|
<div className="admin-card">{colorGrid(GRADIENT_FIELDS, current)}</div>
|
|
</section>
|
|
|
|
<section className="mt-6">
|
|
<h2 className="admin-section-title">{t("typographyLayout")}</h2>
|
|
<div className="admin-card">
|
|
<div className="flex flex-wrap gap-5 items-center">
|
|
<label className="flex items-center gap-2">
|
|
<span className="text-sm">{t("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} className="flex items-center gap-2">
|
|
<span className="text-sm">{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}
|
|
className="w-20"
|
|
/>
|
|
</label>
|
|
))}
|
|
<label className="flex items-center gap-2">
|
|
<span className="text-sm">{t("borderRadius")}</span>
|
|
<input
|
|
name="border_radius"
|
|
type="number"
|
|
min={0}
|
|
max={40}
|
|
defaultValue={/^\d{1,3}$/.test(radius) ? radius : "12"}
|
|
className="w-20"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-6">
|
|
<h2 className="admin-section-title">{t("cssTitle")}</h2>
|
|
<p className="text-xs text-[var(--color-text-muted)] mb-2">{t("cssSubtitle")}</p>
|
|
<textarea
|
|
name="custom_css"
|
|
defaultValue={customCss}
|
|
rows={8}
|
|
spellCheck={false}
|
|
placeholder={t("cssPlaceholder")}
|
|
className="w-full font-mono text-sm"
|
|
/>
|
|
<div className="mt-4 text-right">
|
|
<button type="submit" className="btn btn-primary">
|
|
{t("saveCss")}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</form>
|
|
</main>
|
|
);
|
|
}
|