Files
EpicNext-Cms/src/actions/theme-builder.ts
T
openhands adf0658916
CI / check (push) Failing after 29s
CI / release (push) Skipped
CI / deploy (push) Skipped
feat: extend theme builder with block visibility, layout, effects, media, and custom CSS tabs
- Add theme-blocks.ts registry with 24 themeable blocks across 4 categories
- Extend resolver to resolve blocks, layout, effects, media, and custom CSS per scope
- Add data-theme-block attributes to all site layout blocks
- Extend ScopedThemeVars to generate CSS for block visibility, layout vars, effect vars, media vars, and custom CSS
- Rewrite admin UI with 6 tabs: Colors, Blocks, Layout, Effects, Media, Custom
- Extend server actions to save/load all new setting types
- No database migration needed - uses existing theme_scope_values table with prefixed keys
2026-08-31 17:44:29 +02:00

475 lines
12 KiB
TypeScript

"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 {
BLOCK_KEY_PREFIX,
EFFECT_KEYS,
LAYOUT_KEYS,
MEDIA_KEYS,
THEME_BLOCKS,
} from "@/lib/theme-blocks";
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 {
// Save color values (light + dark)
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() },
});
}
}
// Save block visibility
for (const block of THEME_BLOCKS) {
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
const val = formData.get(key);
const visible = val === "on" || val === "true" ? "true" : "false";
// Only save if different from default
if (visible !== String(block.defaultVisible)) {
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: key,
settingVal: visible,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: visible, updatedAt: new Date() },
});
} else {
// Remove if set back to default
await db
.delete(ThemeScopeValue)
.where(
and(
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
eq(ThemeScopeValue.settingKey, key),
),
);
}
}
// Save layout settings
for (const key of LAYOUT_KEYS) {
const raw = String(formData.get(key) ?? "").trim();
if (!raw) continue;
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: key,
settingVal: raw,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: raw, updatedAt: new Date() },
});
}
// Save effect settings
for (const key of EFFECT_KEYS) {
const raw = String(formData.get(key) ?? "").trim();
if (!raw) continue;
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: key,
settingVal: raw,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: raw, updatedAt: new Date() },
});
}
// Save media settings
for (const key of MEDIA_KEYS) {
const raw = String(formData.get(key) ?? "").trim();
if (!raw) continue;
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: key,
settingVal: raw,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: raw, updatedAt: new Date() },
});
}
// Save custom CSS
const customCss = String(formData.get("custom:css") ?? "").trim();
if (customCss) {
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: "custom:css",
settingVal: customCss,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: customCss, updatedAt: new Date() },
});
} else {
await db
.delete(ThemeScopeValue)
.where(
and(
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
eq(ThemeScopeValue.settingKey, "custom:css"),
),
);
}
// Save custom HTML
const customHtml = String(formData.get("custom:html") ?? "").trim();
if (customHtml) {
await db
.insert(ThemeScopeValue)
.values({
scopeId: BigInt(scopeId),
settingKey: "custom:html",
settingVal: customHtml,
mode: "light",
createdAt: new Date(),
updatedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: { settingVal: customHtml, updatedAt: new Date() },
});
} else {
await db
.delete(ThemeScopeValue)
.where(
and(
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
eq(ThemeScopeValue.settingKey, "custom:html"),
),
);
}
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;
}