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,37 @@
|
|||||||
|
-- Theme Builder: scoped theme system for per-route, per-module, and multi-site theming.
|
||||||
|
-- Idempotent: safe to re-run.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS theme_scopes (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
type ENUM('global','site','module','route') NOT NULL,
|
||||||
|
parent_id BIGINT UNSIGNED NULL,
|
||||||
|
site_domain VARCHAR(255) NULL,
|
||||||
|
route_path VARCHAR(255) NULL,
|
||||||
|
module_id VARCHAR(100) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY theme_scopes_parent_idx (parent_id),
|
||||||
|
KEY theme_scopes_type_idx (type),
|
||||||
|
UNIQUE KEY theme_scopes_unique_lookup (type, site_domain, route_path, module_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS theme_scope_values (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
scope_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
setting_key VARCHAR(100) NOT NULL,
|
||||||
|
setting_val VARCHAR(255) NOT NULL,
|
||||||
|
mode ENUM('light','dark') NOT NULL DEFAULT 'light',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY theme_scope_values_unique (scope_id, setting_key, mode),
|
||||||
|
KEY theme_scope_values_scope_idx (scope_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Seed: create the global scope from existing website_settings theme data.
|
||||||
|
INSERT IGNORE INTO theme_scopes (id, name, type, parent_id, is_active, sort_order)
|
||||||
|
VALUES (1, 'Global', 'global', NULL, 1, 0);
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, ThemeScope, ThemeScopeValue } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { allModuleIds } from "@/lib/theme-modules";
|
||||||
|
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
|
import { getAllScopes, getScopeValues } from "@/lib/theme-resolver";
|
||||||
|
|
||||||
|
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||||
|
|
||||||
|
// ─── Scope CRUD ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createScope(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
const type = String(formData.get("type") ?? "") as
|
||||||
|
| "global"
|
||||||
|
| "site"
|
||||||
|
| "module"
|
||||||
|
| "route";
|
||||||
|
const parentId = formData.get("parent_id")
|
||||||
|
? Number(formData.get("parent_id"))
|
||||||
|
: null;
|
||||||
|
const siteDomain = String(formData.get("site_domain") ?? "").trim() || null;
|
||||||
|
const routePath = String(formData.get("route_path") ?? "").trim() || null;
|
||||||
|
const moduleId = String(formData.get("module_id") ?? "").trim() || null;
|
||||||
|
|
||||||
|
if (!name || !type) redirect("/admin/theme-builder");
|
||||||
|
|
||||||
|
if (type === "module" && !moduleId) redirect("/admin/theme-builder");
|
||||||
|
if (type === "route" && !routePath) redirect("/admin/theme-builder");
|
||||||
|
if (type === "site" && !siteDomain) redirect("/admin/theme-builder");
|
||||||
|
|
||||||
|
const [maxRow] = await db
|
||||||
|
.select({ maxSort: sql<number>`COALESCE(MAX(${ThemeScope.sortOrder}), 0)` })
|
||||||
|
.from(ThemeScope);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(ThemeScope).values({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
parentId: parentId ? BigInt(parentId) : null,
|
||||||
|
siteDomain,
|
||||||
|
routePath,
|
||||||
|
moduleId,
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: (maxRow?.maxSort ?? 0) + 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "theme_scope_create",
|
||||||
|
description: `Created theme scope "${name}" (${type})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/theme-builder");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect("/admin/theme-builder?created=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateScope(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
const isActive = formData.get("is_active") === "on";
|
||||||
|
|
||||||
|
if (!id) redirect("/admin/theme-builder");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(ThemeScope)
|
||||||
|
.set({ name, isActive, updatedAt: new Date() })
|
||||||
|
.where(sql`${ThemeScope.id} = ${id}`);
|
||||||
|
|
||||||
|
revalidatePath("/admin/theme-builder");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect("/admin/theme-builder?updated=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteScope(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
if (!id) redirect("/admin/theme-builder");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(ThemeScopeValue)
|
||||||
|
.where(sql`${ThemeScopeValue.scopeId} = ${id}`);
|
||||||
|
await db.delete(ThemeScope).where(sql`${ThemeScope.id} = ${id}`);
|
||||||
|
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "theme_scope_delete",
|
||||||
|
description: `Deleted theme scope #${id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/theme-builder");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect("/admin/theme-builder?deleted=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderScopes(orderedIds: number[]): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < orderedIds.length; i++) {
|
||||||
|
await db
|
||||||
|
.update(ThemeScope)
|
||||||
|
.set({ sortOrder: i, updatedAt: new Date() })
|
||||||
|
.where(sql`${ThemeScope.id} = ${orderedIds[i]}`);
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/theme-builder");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Theme Values CRUD ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveScopeValues(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
|
const scopeId = Number(formData.get("scope_id"));
|
||||||
|
if (!scopeId) redirect("/admin/theme-builder");
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const mode of ["light", "dark"] as const) {
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
const fieldKey = `${key}_${mode}`;
|
||||||
|
const raw = String(formData.get(fieldKey) ?? "").trim();
|
||||||
|
if (!raw || !COLOR_RE.test(raw)) continue;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(ThemeScopeValue)
|
||||||
|
.values({
|
||||||
|
scopeId: BigInt(scopeId),
|
||||||
|
settingKey: key,
|
||||||
|
settingVal: raw,
|
||||||
|
mode,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: { settingVal: raw, updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "theme_scope_values",
|
||||||
|
description: `Updated theme values for scope #${scopeId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/theme-builder");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect("/admin/theme-builder?saved=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetScopeValues(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
|
const scopeId = Number(formData.get("scope_id"));
|
||||||
|
const key = String(formData.get("key") ?? "");
|
||||||
|
const mode = String(formData.get("mode") ?? "") as "light" | "dark";
|
||||||
|
|
||||||
|
if (!scopeId || !key) redirect("/admin/theme-builder");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(ThemeScopeValue)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
|
||||||
|
eq(ThemeScopeValue.settingKey, key),
|
||||||
|
eq(ThemeScopeValue.mode, mode),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath("/admin/theme-builder");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect("/admin/theme-builder?reset=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Duplicate scope ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function duplicateScope(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
|
const sourceId = Number(formData.get("id"));
|
||||||
|
if (!sourceId) redirect("/admin/theme-builder");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scopes = await getAllScopes();
|
||||||
|
const source = scopes.find((s) => s.id === sourceId);
|
||||||
|
if (!source) redirect("/admin/theme-builder");
|
||||||
|
|
||||||
|
const [maxRow] = await db
|
||||||
|
.select({
|
||||||
|
maxSort: sql<number>`COALESCE(MAX(${ThemeScope.sortOrder}), 0)`,
|
||||||
|
})
|
||||||
|
.from(ThemeScope);
|
||||||
|
|
||||||
|
await db.insert(ThemeScope).values({
|
||||||
|
name: `${source.name} (copy)`,
|
||||||
|
type: source.type,
|
||||||
|
parentId: source.parentId ? BigInt(source.parentId) : null,
|
||||||
|
siteDomain: source.siteDomain,
|
||||||
|
routePath: source.routePath,
|
||||||
|
moduleId: source.moduleId,
|
||||||
|
isActive: false,
|
||||||
|
sortOrder: (maxRow?.maxSort ?? 0) + 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceValues = await getScopeValues(sourceId);
|
||||||
|
const allScopes = await getAllScopes();
|
||||||
|
const newScope = allScopes.find((s) => s.name === `${source.name} (copy)`);
|
||||||
|
|
||||||
|
if (newScope) {
|
||||||
|
for (const [key, val] of Object.entries(sourceValues)) {
|
||||||
|
if (val.light) {
|
||||||
|
await db.insert(ThemeScopeValue).values({
|
||||||
|
scopeId: BigInt(newScope.id),
|
||||||
|
settingKey: key,
|
||||||
|
settingVal: val.light,
|
||||||
|
mode: "light",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (val.dark) {
|
||||||
|
await db.insert(ThemeScopeValue).values({
|
||||||
|
scopeId: BigInt(newScope.id),
|
||||||
|
settingKey: key,
|
||||||
|
settingVal: val.dark,
|
||||||
|
mode: "dark",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/theme-builder");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect("/admin/theme-builder?duplicated=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data helpers for the admin page ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getThemeBuilderData() {
|
||||||
|
const scopes = await getAllScopes();
|
||||||
|
const moduleIds = allModuleIds();
|
||||||
|
const tree = buildTree(scopes);
|
||||||
|
|
||||||
|
return { scopes, tree, moduleIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScopeNode = Awaited<ReturnType<typeof getAllScopes>>[number] & {
|
||||||
|
children: ScopeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildTree(
|
||||||
|
scopes: Awaited<ReturnType<typeof getAllScopes>>,
|
||||||
|
): ScopeNode[] {
|
||||||
|
const nodeMap = new Map<number, ScopeNode>();
|
||||||
|
const roots: ScopeNode[] = [];
|
||||||
|
|
||||||
|
for (const scope of scopes) {
|
||||||
|
nodeMap.set(scope.id, { ...scope, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const node = nodeMap.get(scope.id);
|
||||||
|
if (!node) continue;
|
||||||
|
if (scope.parentId) {
|
||||||
|
const parent = nodeMap.get(scope.parentId);
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
|
import { resolveTheme } from "@/lib/theme-resolver";
|
||||||
|
|
||||||
|
// GET /api/themes/export/embed.js?module=shop
|
||||||
|
// Returns a self-contained JavaScript snippet that injects theme CSS variables.
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const moduleParam = url.searchParams.get("module") ?? undefined;
|
||||||
|
const route = url.searchParams.get("route") ?? undefined;
|
||||||
|
const site = url.searchParams.get("site") ?? undefined;
|
||||||
|
|
||||||
|
const resolved = await resolveTheme({
|
||||||
|
siteDomain: site ?? undefined,
|
||||||
|
routePath: route ?? undefined,
|
||||||
|
moduleId: moduleParam ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lightVars = THEME_COLOR_KEYS.map(
|
||||||
|
(key) => ` --${resolved.cssVariables[key]}: ${resolved.palette[key]};`,
|
||||||
|
).join("\n");
|
||||||
|
const darkVars = THEME_COLOR_KEYS.map(
|
||||||
|
(key) =>
|
||||||
|
` --${resolved.cssVariables[key]}: ${resolved.darkPalette[key]};`,
|
||||||
|
).join("\n");
|
||||||
|
|
||||||
|
const js = `(function(){
|
||||||
|
var s=document.createElement('style');
|
||||||
|
s.textContent=':root{\\n${lightVars.replace(/'/g, "\\'")}\\n}html.dark{\\n${darkVars.replace(/'/g, "\\'")}\\n}';
|
||||||
|
document.head.appendChild(s);
|
||||||
|
})();`;
|
||||||
|
|
||||||
|
return new Response(js, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/javascript",
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response("/* theme embed failed */", {
|
||||||
|
headers: { "Content-Type": "application/javascript" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { apiJson } from "@/lib/api";
|
||||||
|
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
|
import { generateScopedCss, resolveTheme } from "@/lib/theme-resolver";
|
||||||
|
|
||||||
|
// GET /api/themes/export?module=shop&route=/login
|
||||||
|
// Exports the resolved theme as CSS variables and metadata.
|
||||||
|
// Useful for external integrations, iframes, and embed widgets.
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const module = url.searchParams.get("module") ?? undefined;
|
||||||
|
const route = url.searchParams.get("route") ?? undefined;
|
||||||
|
const site = url.searchParams.get("site") ?? undefined;
|
||||||
|
const format = url.searchParams.get("format") ?? "json";
|
||||||
|
|
||||||
|
const resolved = await resolveTheme({
|
||||||
|
siteDomain: site ?? undefined,
|
||||||
|
routePath: route ?? undefined,
|
||||||
|
moduleId: module ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (format === "css") {
|
||||||
|
const { rootCss, scopedCssBlocks } = generateScopedCss(resolved);
|
||||||
|
const fullCss = [rootCss, ...scopedCssBlocks].join("\n");
|
||||||
|
return new Response(fullCss, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/css",
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "variables") {
|
||||||
|
const variables: Record<string, string> = {};
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
variables[`--${resolved.cssVariables[key]}`] = resolved.palette[key];
|
||||||
|
}
|
||||||
|
return apiJson({
|
||||||
|
light: variables,
|
||||||
|
dark: Object.fromEntries(
|
||||||
|
THEME_COLOR_KEYS.map((key) => [
|
||||||
|
`--${resolved.cssVariables[key]}`,
|
||||||
|
resolved.darkPalette[key],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
scopes: resolved.contributingScopes.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
type: s.type,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: full JSON export
|
||||||
|
const lightVariables: Record<string, string> = {};
|
||||||
|
const darkVariables: Record<string, string> = {};
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
lightVariables[`--${resolved.cssVariables[key]}`] = resolved.palette[key];
|
||||||
|
darkVariables[`--${resolved.cssVariables[key]}`] =
|
||||||
|
resolved.darkPalette[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson({
|
||||||
|
palette: {
|
||||||
|
light: resolved.palette,
|
||||||
|
dark: resolved.darkPalette,
|
||||||
|
},
|
||||||
|
cssVariables: {
|
||||||
|
light: lightVariables,
|
||||||
|
dark: darkVariables,
|
||||||
|
},
|
||||||
|
scopes: resolved.contributingScopes.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
type: s.type,
|
||||||
|
})),
|
||||||
|
meta: {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
format: "atom-theme-v1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return apiJson({ error: "Failed to resolve theme" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import { getLocale, getMessages } from "next-intl/server";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { PwaRegister } from "@/components/pwa-register";
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
|
import { ScopedThemeVars } from "@/components/scoped-theme-vars";
|
||||||
import { SmoothScroll } from "@/components/smooth-scroll";
|
import { SmoothScroll } from "@/components/smooth-scroll";
|
||||||
|
import { ThemeScopeDetector } from "@/components/theme-scope-detector";
|
||||||
import { ThemeVars } from "@/components/theme-vars";
|
import { ThemeVars } from "@/components/theme-vars";
|
||||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
@@ -96,6 +98,8 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
<ThemeVars nonce={nonce} />
|
<ThemeVars nonce={nonce} />
|
||||||
|
<ScopedThemeVars nonce={nonce} />
|
||||||
|
<ThemeScopeDetector />
|
||||||
{children}
|
{children}
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
<SmoothScroll />
|
<SmoothScroll />
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import { db, ThemeScope, ThemeScopeValue } from "@/lib/db";
|
||||||
|
import { THEME_COLOR_KEYS, type ThemeColorKey } from "@/lib/theme-presets";
|
||||||
|
|
||||||
|
// ─── Scoped CSS injection ────────────────────────────────────────────────────
|
||||||
|
// This component works alongside ThemeVars. ThemeVars handles the global theme;
|
||||||
|
// this component adds scoped CSS blocks for module/route overrides using
|
||||||
|
// data-attribute selectors.
|
||||||
|
|
||||||
|
type ThemeScopeType = "global" | "site" | "module" | "route";
|
||||||
|
|
||||||
|
interface ScopeRow {
|
||||||
|
id: number;
|
||||||
|
type: ThemeScopeType;
|
||||||
|
siteDomain: string | null;
|
||||||
|
routePath: string | null;
|
||||||
|
moduleId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScopeValues {
|
||||||
|
light: Partial<Record<ThemeColorKey, string>>;
|
||||||
|
dark: Partial<Record<ThemeColorKey, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScopedThemes(): Promise<
|
||||||
|
Array<{ scope: ScopeRow; values: ScopeValues }>
|
||||||
|
> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(ThemeScope)
|
||||||
|
.where(eq(ThemeScope.isActive, true))
|
||||||
|
.orderBy(asc(ThemeScope.sortOrder), asc(ThemeScope.id));
|
||||||
|
|
||||||
|
const scopes: ScopeRow[] = rows
|
||||||
|
.filter((r) => r.type !== "global")
|
||||||
|
.map((r) => ({
|
||||||
|
id: Number(r.id),
|
||||||
|
type: r.type as ThemeScopeType,
|
||||||
|
siteDomain: r.siteDomain,
|
||||||
|
routePath: r.routePath,
|
||||||
|
moduleId: r.moduleId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (scopes.length === 0) return [];
|
||||||
|
|
||||||
|
const allValues = await db.select().from(ThemeScopeValue);
|
||||||
|
|
||||||
|
const valuesByScope = new Map<number, ScopeValues>();
|
||||||
|
for (const row of allValues) {
|
||||||
|
const scopeId = Number(row.scopeId);
|
||||||
|
const key = row.settingKey as ThemeColorKey;
|
||||||
|
if (!valuesByScope.has(scopeId)) {
|
||||||
|
valuesByScope.set(scopeId, { light: {}, dark: {} });
|
||||||
|
}
|
||||||
|
const sv = valuesByScope.get(scopeId);
|
||||||
|
if (!sv) continue;
|
||||||
|
if (row.mode === "light") sv.light[key] = row.settingVal;
|
||||||
|
else sv.dark[key] = row.settingVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scopes
|
||||||
|
.filter((s) => valuesByScope.has(s.id))
|
||||||
|
.map((scope) => ({
|
||||||
|
scope,
|
||||||
|
values: valuesByScope.get(scope.id) ?? { light: {}, dark: {} },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(v: string): string {
|
||||||
|
return v.replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScopedCssBlock(
|
||||||
|
scope: ScopeRow,
|
||||||
|
values: ScopeValues,
|
||||||
|
): string | null {
|
||||||
|
let selector: string;
|
||||||
|
switch (scope.type) {
|
||||||
|
case "site":
|
||||||
|
if (!scope.siteDomain) return null;
|
||||||
|
selector = `[data-theme-site="${escapeAttr(scope.siteDomain)}"]`;
|
||||||
|
break;
|
||||||
|
case "module":
|
||||||
|
if (!scope.moduleId) return null;
|
||||||
|
selector = `[data-theme-module="${escapeAttr(scope.moduleId)}"]`;
|
||||||
|
break;
|
||||||
|
case "route":
|
||||||
|
if (!scope.routePath) return null;
|
||||||
|
selector = `[data-theme-route="${escapeAttr(scope.routePath)}"]`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightVars = THEME_COLOR_KEYS.filter((k) => values.light[k])
|
||||||
|
.map((k) => `--${k}:${values.light[k]};`)
|
||||||
|
.join("");
|
||||||
|
const darkVars = THEME_COLOR_KEYS.filter((k) => values.dark[k])
|
||||||
|
.map((k) => `--${k}:${values.dark[k]};`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
if (!lightVars && !darkVars) return null;
|
||||||
|
|
||||||
|
let css = "";
|
||||||
|
if (lightVars) css += `${selector}{${lightVars}}`;
|
||||||
|
if (darkVars) css += `html.dark ${selector}{${darkVars}}`;
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScopedThemeVars injects CSS blocks for non-global theme scopes.
|
||||||
|
* Place this after <ThemeVars> in your layout.
|
||||||
|
*/
|
||||||
|
export async function ScopedThemeVars({ nonce }: { nonce?: string }) {
|
||||||
|
const scoped = await loadScopedThemes();
|
||||||
|
if (scoped.length === 0) return null;
|
||||||
|
|
||||||
|
const blocks: string[] = [];
|
||||||
|
for (const { scope, values } of scoped) {
|
||||||
|
const css = buildScopedCssBlock(scope, values);
|
||||||
|
if (css) blocks.push(css);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocks.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
nonce={nonce}
|
||||||
|
dangerouslySetInnerHTML={{ __html: blocks.join("") }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { detectModule } from "@/lib/theme-modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets data-theme-module and data-theme-route attributes on <html>
|
||||||
|
* based on the current pathname. This enables scoped CSS overrides
|
||||||
|
* from ScopedThemeVars.
|
||||||
|
*/
|
||||||
|
export function ThemeScopeDetector() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const moduleId = detectModule(pathname);
|
||||||
|
const html = document.documentElement;
|
||||||
|
|
||||||
|
if (moduleId) {
|
||||||
|
html.setAttribute("data-theme-module", moduleId);
|
||||||
|
} else {
|
||||||
|
html.removeAttribute("data-theme-module");
|
||||||
|
}
|
||||||
|
|
||||||
|
html.setAttribute("data-theme-route", pathname);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2583,3 +2583,45 @@ export const YoutubePlaylists = mysqlTable(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Theme Builder: scoped theme system ──────────────────────────────────────
|
||||||
|
|
||||||
|
export const ThemeScope = mysqlTable("theme_scopes", {
|
||||||
|
id: bigint("id", { mode: "bigint", unsigned: true })
|
||||||
|
.autoincrement()
|
||||||
|
.primaryKey()
|
||||||
|
.notNull(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
type: mysqlEnum("type", ["global", "site", "module", "route"]).notNull(),
|
||||||
|
parentId: bigint("parent_id", { mode: "bigint", unsigned: true }),
|
||||||
|
siteDomain: varchar("site_domain", { length: 255 }),
|
||||||
|
routePath: varchar("route_path", { length: 255 }),
|
||||||
|
moduleId: varchar("module_id", { length: 100 }),
|
||||||
|
isActive: boolean("is_active").notNull().default(true),
|
||||||
|
sortOrder: int("sort_order").notNull().default(0),
|
||||||
|
createdAt: datetime("created_at").notNull(),
|
||||||
|
updatedAt: datetime("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThemeScopeValue = mysqlTable(
|
||||||
|
"theme_scope_values",
|
||||||
|
{
|
||||||
|
id: bigint("id", { mode: "bigint", unsigned: true })
|
||||||
|
.autoincrement()
|
||||||
|
.primaryKey()
|
||||||
|
.notNull(),
|
||||||
|
scopeId: bigint("scope_id", { mode: "bigint", unsigned: true }).notNull(),
|
||||||
|
settingKey: varchar("setting_key", { length: 100 }).notNull(),
|
||||||
|
settingVal: varchar("setting_val", { length: 255 }).notNull(),
|
||||||
|
mode: mysqlEnum("mode", ["light", "dark"]).notNull().default("light"),
|
||||||
|
createdAt: datetime("created_at").notNull(),
|
||||||
|
updatedAt: datetime("updated_at").notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("theme_scope_values_unique").on(
|
||||||
|
t.scopeId,
|
||||||
|
t.settingKey,
|
||||||
|
t.mode,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -553,6 +553,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
permission: PERMS.SETTINGS_VIEW,
|
permission: PERMS.SETTINGS_VIEW,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/theme-builder",
|
||||||
|
labelKey: "themeBuilder",
|
||||||
|
icon: Palette,
|
||||||
|
permission: PERMS.SETTINGS_VIEW,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/maintenance",
|
href: "/admin/maintenance",
|
||||||
labelKey: "maintenance",
|
labelKey: "maintenance",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current site domain from request headers.
|
||||||
|
* Used by the theme resolver to apply site-scoped themes.
|
||||||
|
*/
|
||||||
|
export async function resolveSiteDomain(): Promise<string | null> {
|
||||||
|
const h = await headers();
|
||||||
|
// Next.js sets host in the Host header
|
||||||
|
const host = h.get("host") ?? h.get("x-forwarded-host");
|
||||||
|
if (!host) return null;
|
||||||
|
// Strip port if present
|
||||||
|
return host.split(":")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full theme context from the current request.
|
||||||
|
* Combines site domain + route path + module detection.
|
||||||
|
*/
|
||||||
|
export async function buildThemeContext(pathname: string): Promise<{
|
||||||
|
siteDomain: string | null;
|
||||||
|
routePath: string;
|
||||||
|
moduleId: string | null;
|
||||||
|
}> {
|
||||||
|
const siteDomain = await resolveSiteDomain();
|
||||||
|
const { detectModule } = await import("@/lib/theme-modules");
|
||||||
|
const moduleId = detectModule(pathname);
|
||||||
|
|
||||||
|
return {
|
||||||
|
siteDomain,
|
||||||
|
routePath: pathname,
|
||||||
|
moduleId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Module detection: maps route path prefixes to module IDs.
|
||||||
|
// Used by the theme resolver to apply module-scoped themes.
|
||||||
|
|
||||||
|
export const MODULE_MAP: Record<string, string> = {
|
||||||
|
"/shop": "shop",
|
||||||
|
"/marketplace": "marketplace",
|
||||||
|
"/rares": "rares",
|
||||||
|
"/guilds": "guilds",
|
||||||
|
"/radio": "radio",
|
||||||
|
"/news": "news",
|
||||||
|
"/help": "help",
|
||||||
|
"/events": "events",
|
||||||
|
"/photos": "photos",
|
||||||
|
"/badges": "badges",
|
||||||
|
"/rankings": "rankings",
|
||||||
|
"/leaderboard": "leaderboard",
|
||||||
|
"/community": "community",
|
||||||
|
"/staff": "staff",
|
||||||
|
"/settings": "settings",
|
||||||
|
"/messages": "messages",
|
||||||
|
"/friends": "friends",
|
||||||
|
"/apply": "apply",
|
||||||
|
"/developers": "developers",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the module ID for a given pathname.
|
||||||
|
* Returns the longest matching prefix to avoid false positives.
|
||||||
|
*/
|
||||||
|
export function detectModule(pathname: string): string | null {
|
||||||
|
// Sort by key length descending so longest prefix wins
|
||||||
|
const sortedKeys = Object.keys(MODULE_MAP).sort(
|
||||||
|
(a, b) => b.length - a.length,
|
||||||
|
);
|
||||||
|
for (const prefix of sortedKeys) {
|
||||||
|
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
|
||||||
|
return MODULE_MAP[prefix];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All registered module IDs (for admin UI dropdown).
|
||||||
|
*/
|
||||||
|
export function allModuleIds(): string[] {
|
||||||
|
return [...new Set(Object.values(MODULE_MAP))].sort();
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { asc, eq, sql } from "drizzle-orm";
|
||||||
|
import { db, ThemeScope, ThemeScopeValue } from "@/lib/db";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { THEME_COLOR_KEYS, type ThemeColorKey } from "@/lib/theme-presets";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ThemeScopeType = "global" | "site" | "module" | "route";
|
||||||
|
|
||||||
|
export interface ThemeScopeRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: ThemeScopeType;
|
||||||
|
parentId: number | null;
|
||||||
|
siteDomain: string | null;
|
||||||
|
routePath: string | null;
|
||||||
|
moduleId: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScopeValues {
|
||||||
|
light: Partial<Record<ThemeColorKey, string>>;
|
||||||
|
dark: Partial<Record<ThemeColorKey, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedTheme {
|
||||||
|
palette: Record<ThemeColorKey, string>;
|
||||||
|
darkPalette: Record<ThemeColorKey, string>;
|
||||||
|
contributingScopes: ThemeScopeRow[];
|
||||||
|
cssVariables: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CSS variable name mapping ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CSS_VAR_MAP: Record<ThemeColorKey, string> = {
|
||||||
|
color_primary: "color-primary",
|
||||||
|
color_background: "color-background",
|
||||||
|
color_surface: "color-surface",
|
||||||
|
color_dropdown: "color-dropdown",
|
||||||
|
color_navbar: "color-navbar",
|
||||||
|
color_navbar_text: "color-navbar-text",
|
||||||
|
color_text: "color-text",
|
||||||
|
color_text_muted: "color-text-muted",
|
||||||
|
color_accent: "color-accent",
|
||||||
|
color_success: "color-success",
|
||||||
|
color_warning: "color-warning",
|
||||||
|
color_error: "color-error",
|
||||||
|
color_info: "color-info",
|
||||||
|
button_primary_color: "button-color",
|
||||||
|
button_text_color: "button-text-color",
|
||||||
|
button_secondary_color: "button-secondary-color",
|
||||||
|
button_secondary_text_color: "button-secondary-text-color",
|
||||||
|
button_danger_color: "button-danger-color",
|
||||||
|
button_danger_text_color: "button-danger-text-color",
|
||||||
|
button_outline_color: "button-outline-color",
|
||||||
|
button_outline_text_color: "button-outline-text-color",
|
||||||
|
link_color: "link-color",
|
||||||
|
link_hover_color: "link-hover-color",
|
||||||
|
border_color: "border-color",
|
||||||
|
panel_border_color: "panel-border-color",
|
||||||
|
gradient_from: "gradient-from",
|
||||||
|
gradient_to: "gradient-to",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Scope loading ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function rowToScopeRow(r: {
|
||||||
|
id: bigint | number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
parentId: bigint | number | null;
|
||||||
|
siteDomain: string | null;
|
||||||
|
routePath: string | null;
|
||||||
|
moduleId: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}): ThemeScopeRow {
|
||||||
|
return {
|
||||||
|
id: Number(r.id),
|
||||||
|
name: r.name,
|
||||||
|
type: r.type as ThemeScopeType,
|
||||||
|
parentId: r.parentId ? Number(r.parentId) : null,
|
||||||
|
siteDomain: r.siteDomain,
|
||||||
|
routePath: r.routePath,
|
||||||
|
moduleId: r.moduleId,
|
||||||
|
isActive: r.isActive,
|
||||||
|
sortOrder: r.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScopes(): Promise<ThemeScopeRow[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(ThemeScope)
|
||||||
|
.where(eq(ThemeScope.isActive, true))
|
||||||
|
.orderBy(asc(ThemeScope.sortOrder), asc(ThemeScope.id));
|
||||||
|
|
||||||
|
return rows.map(rowToScopeRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllScopeValues(): Promise<Map<number, ScopeValues>> {
|
||||||
|
const rows = await db.select().from(ThemeScopeValue);
|
||||||
|
|
||||||
|
const result = new Map<number, ScopeValues>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const scopeId = Number(row.scopeId);
|
||||||
|
const key = row.settingKey as ThemeColorKey;
|
||||||
|
|
||||||
|
if (!result.has(scopeId)) {
|
||||||
|
result.set(scopeId, { light: {}, dark: {} });
|
||||||
|
}
|
||||||
|
const sv = result.get(scopeId);
|
||||||
|
if (!sv) continue;
|
||||||
|
if (row.mode === "dark") {
|
||||||
|
sv.dark[key] = row.settingVal;
|
||||||
|
} else {
|
||||||
|
sv.light[key] = row.settingVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ancestry & scope matching ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildAncestry(
|
||||||
|
scopes: ThemeScopeRow[],
|
||||||
|
targetId: number,
|
||||||
|
): ThemeScopeRow[] {
|
||||||
|
const byId = new Map(scopes.map((s) => [s.id, s]));
|
||||||
|
const chain: ThemeScopeRow[] = [];
|
||||||
|
let current = byId.get(targetId);
|
||||||
|
while (current) {
|
||||||
|
chain.unshift(current);
|
||||||
|
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestScope(
|
||||||
|
scopes: ThemeScopeRow[],
|
||||||
|
context: { siteDomain?: string; routePath?: string; moduleId?: string },
|
||||||
|
): ThemeScopeRow | null {
|
||||||
|
let best: ThemeScopeRow | null = null;
|
||||||
|
|
||||||
|
for (const scope of scopes) {
|
||||||
|
switch (scope.type) {
|
||||||
|
case "global":
|
||||||
|
if (!best) best = scope;
|
||||||
|
break;
|
||||||
|
case "site":
|
||||||
|
if (context.siteDomain && scope.siteDomain === context.siteDomain) {
|
||||||
|
best = scope;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "module":
|
||||||
|
if (context.moduleId && scope.moduleId === context.moduleId) {
|
||||||
|
best = scope;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "route":
|
||||||
|
if (context.routePath && scope.routePath === context.routePath) {
|
||||||
|
best = scope;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Global defaults from website_settings ───────────────────────────────────
|
||||||
|
|
||||||
|
async function getGlobalDefaults(): Promise<{
|
||||||
|
light: Partial<Record<ThemeColorKey, string>>;
|
||||||
|
dark: Partial<Record<ThemeColorKey, string>>;
|
||||||
|
}> {
|
||||||
|
const keys = THEME_COLOR_KEYS.flatMap((k) => [k, `${k}_dark`]);
|
||||||
|
const values = await siteSettings.getMany(keys);
|
||||||
|
|
||||||
|
const light: Partial<Record<ThemeColorKey, string>> = {};
|
||||||
|
const dark: Partial<Record<ThemeColorKey, string>> = {};
|
||||||
|
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
const lightVal = values[key];
|
||||||
|
const darkVal = values[`${key}_dark`];
|
||||||
|
if (lightVal) light[key] = lightVal;
|
||||||
|
if (darkVal) dark[key] = darkVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { light, dark };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main resolver ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function resolveTheme(context: {
|
||||||
|
siteDomain?: string;
|
||||||
|
routePath?: string;
|
||||||
|
moduleId?: string;
|
||||||
|
}): Promise<ResolvedTheme> {
|
||||||
|
const allScopes = await loadScopes();
|
||||||
|
const bestScope = findBestScope(allScopes, context);
|
||||||
|
|
||||||
|
if (!bestScope) {
|
||||||
|
const defaults = await getGlobalDefaults();
|
||||||
|
return {
|
||||||
|
palette: defaults.light as Record<ThemeColorKey, string>,
|
||||||
|
darkPalette: defaults.dark as Record<ThemeColorKey, string>,
|
||||||
|
contributingScopes: [],
|
||||||
|
cssVariables: CSS_VAR_MAP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ancestry = buildAncestry(allScopes, bestScope.id);
|
||||||
|
const allValues = await loadAllScopeValues();
|
||||||
|
|
||||||
|
const lightPalette: Partial<Record<ThemeColorKey, string>> = {};
|
||||||
|
const darkPalette: Partial<Record<ThemeColorKey, string>> = {};
|
||||||
|
|
||||||
|
for (const scope of ancestry) {
|
||||||
|
const sv = allValues.get(scope.id);
|
||||||
|
if (!sv) continue;
|
||||||
|
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
if (sv.light[key]) lightPalette[key] = sv.light[key];
|
||||||
|
if (sv.dark[key]) darkPalette[key] = sv.dark[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = await getGlobalDefaults();
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
if (!lightPalette[key])
|
||||||
|
lightPalette[key] = defaults.light[key] ?? "#f59e0b";
|
||||||
|
if (!darkPalette[key]) darkPalette[key] = defaults.dark[key] ?? "#0b0d14";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
palette: lightPalette as Record<ThemeColorKey, string>,
|
||||||
|
darkPalette: darkPalette as Record<ThemeColorKey, string>,
|
||||||
|
contributingScopes: ancestry,
|
||||||
|
cssVariables: CSS_VAR_MAP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateScopedCss(resolved: ResolvedTheme): {
|
||||||
|
rootCss: string;
|
||||||
|
scopedCssBlocks: string[];
|
||||||
|
} {
|
||||||
|
const rootCss = `:root{${THEME_COLOR_KEYS.map((k) => `--${CSS_VAR_MAP[k]}:${resolved.palette[k]};`).join("")}}html.dark{${THEME_COLOR_KEYS.map((k) => `--${CSS_VAR_MAP[k]}:${resolved.darkPalette[k]};`).join("")}}`;
|
||||||
|
|
||||||
|
const scopedCssBlocks: string[] = [];
|
||||||
|
|
||||||
|
for (const scope of resolved.contributingScopes) {
|
||||||
|
if (scope.type === "global") continue;
|
||||||
|
|
||||||
|
let selector: string;
|
||||||
|
switch (scope.type) {
|
||||||
|
case "site":
|
||||||
|
selector = `[data-theme-site="${scope.siteDomain}"]`;
|
||||||
|
break;
|
||||||
|
case "module":
|
||||||
|
selector = `[data-theme-module="${scope.moduleId}"]`;
|
||||||
|
break;
|
||||||
|
case "route":
|
||||||
|
selector = `[data-theme-route="${scope.routePath}"]`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vars = THEME_COLOR_KEYS.map(
|
||||||
|
(k) => `--${CSS_VAR_MAP[k]}:${resolved.palette[k]};`,
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
if (vars) {
|
||||||
|
scopedCssBlocks.push(`${selector}{${vars}}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rootCss, scopedCssBlocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getAllScopes(): Promise<ThemeScopeRow[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(ThemeScope)
|
||||||
|
.orderBy(asc(ThemeScope.sortOrder), asc(ThemeScope.id));
|
||||||
|
|
||||||
|
return rows.map(rowToScopeRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScopeValues(
|
||||||
|
scopeId: number,
|
||||||
|
): Promise<Record<string, { light: string; dark: string }>> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(ThemeScopeValue)
|
||||||
|
.where(sql`${ThemeScopeValue.scopeId} = ${scopeId}`);
|
||||||
|
|
||||||
|
const result: Record<string, { light: string; dark: string }> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!result[row.settingKey]) {
|
||||||
|
result[row.settingKey] = { light: "", dark: "" };
|
||||||
|
}
|
||||||
|
if (row.mode === "light") {
|
||||||
|
result[row.settingKey].light = row.settingVal;
|
||||||
|
} else {
|
||||||
|
result[row.settingKey].dark = row.settingVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -2598,6 +2598,7 @@
|
|||||||
"autoDj": "Auto DJ",
|
"autoDj": "Auto DJ",
|
||||||
"cms": "CMS",
|
"cms": "CMS",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
|
"themeBuilder": "Theme Builder",
|
||||||
"maintenance": "Maintenance",
|
"maintenance": "Maintenance",
|
||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"emulator": "Emulator",
|
"emulator": "Emulator",
|
||||||
|
|||||||
@@ -2850,6 +2850,7 @@
|
|||||||
"autoDj": "Auto DJ",
|
"autoDj": "Auto DJ",
|
||||||
"cms": "CMS",
|
"cms": "CMS",
|
||||||
"theme": "Thema",
|
"theme": "Thema",
|
||||||
|
"themeBuilder": "Thema Bouwer",
|
||||||
"maintenance": "Onderhoud",
|
"maintenance": "Onderhoud",
|
||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"emulator": "Emulator",
|
"emulator": "Emulator",
|
||||||
|
|||||||
Reference in new issue
Block a user