- Add theme_scopes and theme_scope_values database tables for scoped themes - Implement theme resolver engine with inheritance: global > site > module > route - Add module detection for 20+ routes (shop, guilds, radio, news, etc.) - Create admin UI at /admin/theme-builder with scope tree and color editor - Add ScopedThemeVars component for injecting scoped CSS via data-attributes - Add ThemeScopeDetector client component for runtime module/route detection - Add site-resolver for multi-site domain detection - Add /api/themes/export endpoint (JSON, CSS, variables formats) - Add /api/themes/export/embed.js for external integration widget - Add server actions for full CRUD on scopes and theme values - Add admin nav link and EN/NL translations
324 lines
7.3 KiB
TypeScript
324 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
deleteScope,
|
|
duplicateScope,
|
|
saveScopeValues,
|
|
} from "@/actions/theme-builder";
|
|
import { Button } from "@/components/ui/button";
|
|
import type { ThemeColorKey } from "@/lib/theme-presets";
|
|
import { ColorField } from "./color-field";
|
|
|
|
interface ScopeInfo {
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
parentId: number | null;
|
|
siteDomain: string | null;
|
|
routePath: string | null;
|
|
moduleId: string | null;
|
|
isActive: boolean;
|
|
}
|
|
|
|
const COLOR_FIELDS: Array<{
|
|
key: ThemeColorKey;
|
|
label: string;
|
|
def: string;
|
|
desc: string;
|
|
bgKey?: string;
|
|
}> = [
|
|
{
|
|
key: "color_background",
|
|
label: "Page background",
|
|
def: "#f8fafc",
|
|
desc: "Main page background",
|
|
},
|
|
{
|
|
key: "color_surface",
|
|
label: "Card / surface",
|
|
def: "#ffffff",
|
|
desc: "Cards, panels, elevated surfaces",
|
|
},
|
|
{
|
|
key: "color_dropdown",
|
|
label: "Dropdown",
|
|
def: "#ffffff",
|
|
desc: "Dropdown menus",
|
|
},
|
|
{
|
|
key: "color_navbar",
|
|
label: "Navbar",
|
|
def: "#ffffff",
|
|
desc: "Navigation bar background",
|
|
},
|
|
{
|
|
key: "color_navbar_text",
|
|
label: "Navbar text",
|
|
def: "#1e293b",
|
|
desc: "Nav text color",
|
|
bgKey: "color_navbar",
|
|
},
|
|
{
|
|
key: "color_text",
|
|
label: "Body text",
|
|
def: "#0f172a",
|
|
desc: "Main text color",
|
|
bgKey: "color_surface",
|
|
},
|
|
{
|
|
key: "color_text_muted",
|
|
label: "Muted text",
|
|
def: "#64748b",
|
|
desc: "Secondary text",
|
|
bgKey: "color_surface",
|
|
},
|
|
{
|
|
key: "color_primary",
|
|
label: "Primary accent",
|
|
def: "#f59e0b",
|
|
desc: "Main brand color",
|
|
},
|
|
{
|
|
key: "color_accent",
|
|
label: "Secondary accent",
|
|
def: "#10b981",
|
|
desc: "Secondary brand color",
|
|
},
|
|
{
|
|
key: "color_success",
|
|
label: "Success",
|
|
def: "#16a34a",
|
|
desc: "Success states",
|
|
},
|
|
{
|
|
key: "color_warning",
|
|
label: "Warning",
|
|
def: "#eab308",
|
|
desc: "Warning states",
|
|
},
|
|
{ key: "color_error", label: "Error", def: "#ef4444", desc: "Error states" },
|
|
{
|
|
key: "color_info",
|
|
label: "Info",
|
|
def: "#0ea5e9",
|
|
desc: "Information states",
|
|
},
|
|
{
|
|
key: "button_primary_color",
|
|
label: "Button primary",
|
|
def: "#f59e0b",
|
|
desc: "Primary button background",
|
|
},
|
|
{
|
|
key: "button_text_color",
|
|
label: "Button primary text",
|
|
def: "#1e293b",
|
|
desc: "Primary button text",
|
|
},
|
|
{
|
|
key: "button_secondary_color",
|
|
label: "Button secondary",
|
|
def: "#22c55e",
|
|
desc: "Secondary button background",
|
|
},
|
|
{
|
|
key: "button_secondary_text_color",
|
|
label: "Button secondary text",
|
|
def: "#ffffff",
|
|
desc: "Secondary button text",
|
|
},
|
|
{
|
|
key: "button_danger_color",
|
|
label: "Button danger",
|
|
def: "#ef4444",
|
|
desc: "Danger button background",
|
|
},
|
|
{
|
|
key: "button_danger_text_color",
|
|
label: "Button danger text",
|
|
def: "#ffffff",
|
|
desc: "Danger button text",
|
|
},
|
|
{
|
|
key: "button_outline_color",
|
|
label: "Button outline",
|
|
def: "#eeb425",
|
|
desc: "Outline button border",
|
|
},
|
|
{
|
|
key: "button_outline_text_color",
|
|
label: "Button outline text",
|
|
def: "#1a1a2e",
|
|
desc: "Outline button text",
|
|
},
|
|
{
|
|
key: "link_color",
|
|
label: "Link color",
|
|
def: "#b77900",
|
|
desc: "Link text color",
|
|
},
|
|
{
|
|
key: "link_hover_color",
|
|
label: "Link hover",
|
|
def: "#8a5b00",
|
|
desc: "Link hover color",
|
|
},
|
|
{
|
|
key: "border_color",
|
|
label: "Border color",
|
|
def: "#eeb425",
|
|
desc: "General borders",
|
|
},
|
|
{
|
|
key: "panel_border_color",
|
|
label: "Panel border",
|
|
def: "#e9b124",
|
|
desc: "Panel borders",
|
|
},
|
|
{
|
|
key: "gradient_from",
|
|
label: "Gradient start",
|
|
def: "#f59e0b",
|
|
desc: "Gradient from color",
|
|
},
|
|
{
|
|
key: "gradient_to",
|
|
label: "Gradient end",
|
|
def: "#10b981",
|
|
desc: "Gradient to color",
|
|
},
|
|
];
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
global: "🌍 Global",
|
|
site: "🌐 Site",
|
|
module: "📦 Module",
|
|
route: "📄 Route",
|
|
};
|
|
|
|
export function ThemeEditor({
|
|
scopeId,
|
|
scopes,
|
|
}: {
|
|
scopeId: number;
|
|
scopes: ScopeInfo[];
|
|
}) {
|
|
const [mode, setMode] = useState<"light" | "dark">("light");
|
|
|
|
const scope = scopes.find((s) => s.id === scopeId);
|
|
if (!scope) return null;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="admin-card p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[var(--admin-text)]">
|
|
{TYPE_LABELS[scope.type] ?? scope.type} — {scope.name}
|
|
</h2>
|
|
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
|
|
{scope.type === "route" && `Path: ${scope.routePath}`}
|
|
{scope.type === "module" && `Module: ${scope.moduleId}`}
|
|
{scope.type === "site" && `Domain: ${scope.siteDomain}`}
|
|
{scope.type === "global" &&
|
|
"Applies to all pages (default theme)"}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<form action={duplicateScope} className="inline">
|
|
<input type="hidden" name="id" value={scopeId} />
|
|
<Button type="submit" variant="outline" size="sm">
|
|
Duplicate
|
|
</Button>
|
|
</form>
|
|
{scope.type !== "global" ? (
|
|
<form action={deleteScope} className="inline">
|
|
<input type="hidden" name="id" value={scopeId} />
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-red-500 hover:text-red-600"
|
|
onClick={(e) => {
|
|
if (
|
|
!confirm("Delete this scope and all its theme values?")
|
|
) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</form>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-[var(--admin-text-muted)]">Editing:</span>
|
|
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode("light")}
|
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
mode === "light"
|
|
? "bg-[var(--admin-accent)] text-white"
|
|
: "bg-[var(--admin-surface)] text-[var(--admin-text)] hover:bg-[var(--admin-surface-elevated)]"
|
|
}`}
|
|
>
|
|
☀ Light
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode("dark")}
|
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
mode === "dark"
|
|
? "bg-[var(--admin-accent)] text-white"
|
|
: "bg-[var(--admin-surface)] text-[var(--admin-text)] hover:bg-[var(--admin-surface-elevated)]"
|
|
}`}
|
|
>
|
|
☾ Dark
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form action={saveScopeValues}>
|
|
<input type="hidden" name="scope_id" value={scopeId} />
|
|
|
|
<div className="admin-card p-4">
|
|
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
|
Colors ({mode} mode)
|
|
</h3>
|
|
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
|
Only values you set here override the parent scope. Leave empty to
|
|
inherit.
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{COLOR_FIELDS.map((field) => (
|
|
<ColorField
|
|
key={`${field.key}-${mode}`}
|
|
name={`${field.key}_${mode}`}
|
|
label={field.label}
|
|
description={field.desc}
|
|
defaultValue={field.def}
|
|
backgroundColorKey={
|
|
field.bgKey ? `${field.bgKey}_${mode}` : undefined
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
<Button type="submit" variant="default">
|
|
Save Theme Values
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|