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:
openhands committed 2026-08-31 17:15:42 +02:00
1 parent b57697b2e0
commit a98b194386
19 files changed
+1843

No files matched your search

+318
View File
@@ -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;
}