Files
EpicNext-Cms/src/lib/theme-resolver.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

512 lines
15 KiB
TypeScript

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 {
BLOCK_KEY_PREFIX,
EFFECT_DEFAULTS,
type EffectKey,
LAYOUT_DEFAULTS,
type LayoutKey,
MEDIA_DEFAULTS,
type MediaKey,
} from "@/lib/theme-blocks";
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>>;
/** Block visibility: block:{blockId} → "true" | "false" */
blocks: Record<string, string>;
/** Layout settings */
layout: Partial<Record<LayoutKey, string>>;
/** Effect settings */
effects: Partial<Record<EffectKey, string>>;
/** Media settings */
media: Partial<Record<MediaKey, string>>;
/** Custom CSS/HTML */
custom: Partial<Record<string, string>>;
}
export interface ResolvedTheme {
palette: Record<ThemeColorKey, string>;
darkPalette: Record<ThemeColorKey, string>;
contributingScopes: ThemeScopeRow[];
cssVariables: Record<string, string>;
/** Block visibility (blockId → visible) */
blocks: Record<string, boolean>;
/** Resolved layout settings */
layout: Record<LayoutKey, string>;
/** Resolved effect settings */
effects: Record<EffectKey, string>;
/** Resolved media settings */
media: Record<MediaKey, string>;
/** Raw custom CSS from all contributing scopes */
customCss: string;
/** Raw custom HTML from all contributing scopes */
customHtml: 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;
if (!result.has(scopeId)) {
result.set(scopeId, {
light: {},
dark: {},
blocks: {},
layout: {},
effects: {},
media: {},
custom: {},
});
}
const sv = result.get(scopeId);
if (!sv) continue;
if (key.startsWith(BLOCK_KEY_PREFIX)) {
sv.blocks[key] = row.settingVal;
} else if (key.startsWith("layout:")) {
sv.layout[key as LayoutKey] = row.settingVal;
} else if (key.startsWith("effect:")) {
sv.effects[key as EffectKey] = row.settingVal;
} else if (key.startsWith("media:")) {
sv.media[key as MediaKey] = row.settingVal;
} else if (key.startsWith("custom:")) {
sv.custom[key] = row.settingVal;
} else if (row.mode === "dark") {
sv.dark[key as ThemeColorKey] = row.settingVal;
} else {
sv.light[key as ThemeColorKey] = 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,
blocks: {},
layout: { ...LAYOUT_DEFAULTS },
effects: { ...EFFECT_DEFAULTS },
media: { ...MEDIA_DEFAULTS },
customCss: "",
customHtml: "",
};
}
const ancestry = buildAncestry(allScopes, bestScope.id);
const allValues = await loadAllScopeValues();
const lightPalette: Partial<Record<ThemeColorKey, string>> = {};
const darkPalette: Partial<Record<ThemeColorKey, string>> = {};
const blocks: Record<string, string> = {};
const layout: Partial<Record<LayoutKey, string>> = {};
const effects: Partial<Record<EffectKey, string>> = {};
const media: Partial<Record<MediaKey, string>> = {};
const customCssParts: string[] = [];
const customHtmlParts: 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];
}
// Blocks: deepest scope wins for each block
for (const [k, v] of Object.entries(sv.blocks)) {
blocks[k] = v;
}
// Layout: deepest scope wins per key
for (const [k, v] of Object.entries(sv.layout)) {
if (v) layout[k as LayoutKey] = v;
}
// Effects: deepest scope wins per key
for (const [k, v] of Object.entries(sv.effects)) {
if (v) effects[k as EffectKey] = v;
}
// Media: deepest scope wins per key
for (const [k, v] of Object.entries(sv.media)) {
if (v) media[k as MediaKey] = v;
}
// Custom: concatenate from all scopes
const cssVal = sv.custom["custom:css"];
if (cssVal) customCssParts.push(cssVal);
const htmlVal = sv.custom["custom:html"];
if (htmlVal) customHtmlParts.push(htmlVal);
}
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,
blocks: Object.fromEntries(
Object.entries(blocks).map(([k, v]) => [
k.replace(BLOCK_KEY_PREFIX, ""),
v === "true",
]),
),
layout: { ...LAYOUT_DEFAULTS, ...layout },
effects: { ...EFFECT_DEFAULTS, ...effects },
media: { ...MEDIA_DEFAULTS, ...media },
customCss: customCssParts.join("\n"),
customHtml: customHtmlParts.join("\n"),
};
}
// ─── CSS generation ──────────────────────────────────────────────────────────
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[] = [];
// Layout CSS variables
const layoutVars = [
`--theme-max-width:${resolved.layout["layout:max_width"]}`,
`--theme-spacing:${resolved.layout["layout:spacing"]}`,
`--theme-grid-columns:${resolved.layout["layout:grid_columns"]}`,
`--theme-content-padding:${resolved.layout["layout:content_padding"]}`,
`--theme-navbar-height:${resolved.layout["layout:navbar_height"]}`,
].join(";");
// Effect CSS variables
const effectVars = [
`--theme-card-shadow:${resolved.effects["effect:card_shadow"]}`,
`--theme-card-shadow-hover:${resolved.effects["effect:card_shadow_hover"]}`,
`--theme-border-style:${resolved.effects["effect:border_style"]}`,
`--theme-border-width:${resolved.effects["effect:border_width"]}`,
`--theme-transition-duration:${resolved.effects["effect:transition_duration"]}`,
`--theme-animation-scale:${resolved.effects["effect:animation_scale"]}`,
].join(";");
// Media CSS variables
const mediaVars = [
resolved.media["media:logo_url"]
? `--theme-logo-url:url("${resolved.media["media:logo_url"].replace(/"/g, '\\"')}")`
: "",
resolved.media["media:background_image"]
? `--theme-bg-image:url("${resolved.media["media:background_image"].replace(/"/g, '\\"')}")`
: "",
`--theme-bg-position:${resolved.media["media:background_position"]}`,
`--theme-bg-size:${resolved.media["media:background_size"]}`,
`--theme-bg-repeat:${resolved.media["media:background_repeat"]}`,
]
.filter(Boolean)
.join(";");
// Root: add layout + effects + media
if (layoutVars || effectVars || mediaVars) {
const extra = [layoutVars, effectVars, mediaVars].filter(Boolean).join(";");
rootCss.replace("}", `${extra}}`);
// Inject into root
const rootEnd = rootCss.lastIndexOf("}");
if (rootEnd !== -1) {
const patched = `${rootCss.slice(0, rootEnd)}${extra};${rootCss.slice(rootEnd)}`;
return {
rootCss: patched,
scopedCssBlocks,
};
}
}
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;
}
/**
* Get all setting values for a scope (colors, blocks, layout, effects, media, custom).
*/
export async function getFullScopeValues(
scopeId: number,
): Promise<ScopeValues> {
const rows = await db
.select()
.from(ThemeScopeValue)
.where(sql`${ThemeScopeValue.scopeId} = ${scopeId}`);
const sv: ScopeValues = {
light: {},
dark: {},
blocks: {},
layout: {},
effects: {},
media: {},
custom: {},
};
for (const row of rows) {
const key = row.settingKey;
if (key.startsWith(BLOCK_KEY_PREFIX)) {
sv.blocks[key] = row.settingVal;
} else if (key.startsWith("layout:")) {
sv.layout[key as LayoutKey] = row.settingVal;
} else if (key.startsWith("effect:")) {
sv.effects[key as EffectKey] = row.settingVal;
} else if (key.startsWith("media:")) {
sv.media[key as MediaKey] = row.settingVal;
} else if (key.startsWith("custom:")) {
sv.custom[key] = row.settingVal;
} else if (row.mode === "dark") {
sv.dark[key as ThemeColorKey] = row.settingVal;
} else {
sv.light[key as ThemeColorKey] = row.settingVal;
}
}
return sv;
}