feat: add scoped theme builder system with per-route, per-module, and multi-site support
- 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
This commit is contained in:
1 parent
b57697b2e0
commit
a98b194386
19 files changed
+1843
No files matched your search
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function ColorField({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
defaultValue,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultValue: string;
|
||||
backgroundColorKey?: string;
|
||||
}) {
|
||||
const [val, setVal] = useState(defaultValue);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name={name}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
className="w-[42px] h-[36px] p-0 border-none bg-none cursor-pointer flex-shrink-0 rounded"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--admin-text)] leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] leading-snug line-clamp-2">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getThemeBuilderData } from "@/actions/theme-builder";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ScopeForm } from "./scope-form";
|
||||
import { ScopeTree } from "./scope-tree";
|
||||
import { ThemeEditor } from "./theme-editor";
|
||||
|
||||
export const metadata = { title: "Theme Builder" };
|
||||
|
||||
export default async function ThemeBuilderPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const ctx = await getAdminContext();
|
||||
if (!ctx || !canAccess(ctx.permissions, PERMS.SETTINGS_VIEW)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const sp = await searchParams;
|
||||
const { scopes, tree, moduleIds } = await getThemeBuilderData();
|
||||
|
||||
const selectedId = sp.scope ? Number(sp.scope) : (scopes[0]?.id ?? null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="admin-section-title mb-4">Theme Builder</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-6">
|
||||
Create and manage scoped themes for different parts of your site. Themes
|
||||
inherit from parent scopes — only override what you need.
|
||||
</p>
|
||||
|
||||
{sp.created ? (
|
||||
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||
Scope created.
|
||||
</div>
|
||||
) : null}
|
||||
{sp.updated ? (
|
||||
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||
Scope updated.
|
||||
</div>
|
||||
) : null}
|
||||
{sp.deleted ? (
|
||||
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||
Scope deleted.
|
||||
</div>
|
||||
) : null}
|
||||
{sp.saved ? (
|
||||
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||
Theme values saved.
|
||||
</div>
|
||||
) : null}
|
||||
{sp.duplicated ? (
|
||||
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||
Scope duplicated.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div className="lg:col-span-3">
|
||||
<ScopeTree tree={tree} selectedId={selectedId} />
|
||||
<div className="mt-4">
|
||||
<ScopeForm moduleIds={moduleIds} scopeCount={scopes.length} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-9">
|
||||
{selectedId ? (
|
||||
<ThemeEditor scopeId={selectedId} scopes={scopes} />
|
||||
) : (
|
||||
<div className="admin-card p-8 text-center text-[var(--admin-text-muted)]">
|
||||
Select or create a scope to start editing.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createScope } from "@/actions/theme-builder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const SCOPE_TYPES = [
|
||||
{
|
||||
value: "global",
|
||||
label: "Global",
|
||||
desc: "Default theme for the entire site",
|
||||
},
|
||||
{ value: "site", label: "Site", desc: "Theme for a specific domain" },
|
||||
{
|
||||
value: "module",
|
||||
label: "Module",
|
||||
desc: "Theme for a feature section (shop, guilds, etc.)",
|
||||
},
|
||||
{ value: "route", label: "Route", desc: "Theme for a specific page path" },
|
||||
] as const;
|
||||
|
||||
export function ScopeForm({
|
||||
moduleIds,
|
||||
scopeCount,
|
||||
}: {
|
||||
moduleIds: string[];
|
||||
scopeCount: number;
|
||||
}) {
|
||||
const [type, setType] = useState<string>("module");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
+ Add Scope
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)]">
|
||||
New Scope
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text)] text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action={createScope} className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="scope-name"
|
||||
className="block text-xs text-[var(--admin-text-muted)] mb-1"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="scope-name"
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
placeholder="e.g. Login Page"
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="scope-type"
|
||||
className="block text-xs text-[var(--admin-text-muted)] mb-1"
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="scope-type"
|
||||
name="type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
{SCOPE_TYPES.map((st) => (
|
||||
<option key={st.value} value={st.value}>
|
||||
{st.label} — {st.desc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{type === "site" ? (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="scope-domain"
|
||||
className="block text-xs text-[var(--admin-text-muted)] mb-1"
|
||||
>
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
id="scope-domain"
|
||||
type="text"
|
||||
name="site_domain"
|
||||
required
|
||||
placeholder="e.g. hotel.example.com"
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === "module" ? (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="scope-module"
|
||||
className="block text-xs text-[var(--admin-text-muted)] mb-1"
|
||||
>
|
||||
Module
|
||||
</label>
|
||||
<select
|
||||
id="scope-module"
|
||||
name="module_id"
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
<option value="">Select module...</option>
|
||||
{moduleIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === "route" ? (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="scope-route"
|
||||
className="block text-xs text-[var(--admin-text-muted)] mb-1"
|
||||
>
|
||||
Route Path
|
||||
</label>
|
||||
<input
|
||||
id="scope-route"
|
||||
type="text"
|
||||
name="route_path"
|
||||
required
|
||||
placeholder="e.g. /login"
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type !== "global" && scopeCount > 0 ? (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="scope-parent"
|
||||
className="block text-xs text-[var(--admin-text-muted)] mb-1"
|
||||
>
|
||||
Parent Scope
|
||||
</label>
|
||||
<select
|
||||
id="scope-parent"
|
||||
name="parent_id"
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
<option value="">None (inherits from global)</option>
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" variant="default" className="w-full">
|
||||
Create Scope
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
interface ScopeNode {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
parentId: number | null;
|
||||
siteDomain: string | null;
|
||||
routePath: string | null;
|
||||
moduleId: string | null;
|
||||
isActive: boolean;
|
||||
children: ScopeNode[];
|
||||
}
|
||||
|
||||
interface ScopeTreeProps {
|
||||
tree: ScopeNode[];
|
||||
selectedId: number | null;
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
global: "🌍",
|
||||
site: "🌐",
|
||||
module: "📦",
|
||||
route: "📄",
|
||||
};
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
selectedId,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: ScopeNode;
|
||||
selectedId: number | null;
|
||||
depth?: number;
|
||||
}) {
|
||||
const isSelected = node.id === selectedId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
href={`/admin/theme-builder?scope=${node.id}`}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
isSelected
|
||||
? "bg-[var(--admin-accent)]/15 text-[var(--admin-accent)] font-medium"
|
||||
: "text-[var(--admin-text)] hover:bg-[var(--admin-surface-elevated)]"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
>
|
||||
<span className="text-base leading-none">
|
||||
{TYPE_ICONS[node.type] ?? "•"}
|
||||
</span>
|
||||
<span className="truncate flex-1">{node.name}</span>
|
||||
{!node.isActive ? (
|
||||
<span className="text-[10px] text-[var(--admin-text-muted)] opacity-60">
|
||||
OFF
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
{node.children.length > 0 ? (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
selectedId={selectedId}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScopeTree({ tree, selectedId }: ScopeTreeProps) {
|
||||
return (
|
||||
<div className="admin-card p-3">
|
||||
<h3 className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wider px-3 mb-2">
|
||||
Theme Scopes
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{tree.length === 0 ? (
|
||||
<p className="text-xs text-[var(--admin-text-muted)] px-3 py-4">
|
||||
No scopes created yet. Add one below.
|
||||
</p>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<TreeNode key={node.id} node={node} selectedId={selectedId} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 px-3 pt-3 border-t border-[var(--admin-border)]">
|
||||
<div className="flex flex-wrap gap-2 text-[10px] text-[var(--admin-text-muted)]">
|
||||
{Object.entries(TYPE_ICONS).map(([type, icon]) => (
|
||||
<span key={type} className="flex items-center gap-1">
|
||||
{icon} {type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user