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,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;
|
||||
}
|
||||
Reference in new issue
Block a user