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>; dark: Partial>; /** Block visibility: block:{blockId} → "true" | "false" */ blocks: Record; /** Layout settings */ layout: Partial>; /** Effect settings */ effects: Partial>; /** Media settings */ media: Partial>; /** Custom CSS/HTML */ custom: Partial>; } export interface ResolvedTheme { palette: Record; darkPalette: Record; contributingScopes: ThemeScopeRow[]; cssVariables: Record; /** Block visibility (blockId → visible) */ blocks: Record; /** Resolved layout settings */ layout: Record; /** Resolved effect settings */ effects: Record; /** Resolved media settings */ media: Record; /** 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 = { 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 { 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> { const rows = await db.select().from(ThemeScopeValue); const result = new Map(); 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>; dark: Partial>; }> { const keys = THEME_COLOR_KEYS.flatMap((k) => [k, `${k}_dark`]); const values = await siteSettings.getMany(keys); const light: Partial> = {}; const dark: Partial> = {}; 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 { const allScopes = await loadScopes(); const bestScope = findBestScope(allScopes, context); if (!bestScope) { const defaults = await getGlobalDefaults(); return { palette: defaults.light as Record, darkPalette: defaults.dark as Record, 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> = {}; const darkPalette: Partial> = {}; const blocks: Record = {}; const layout: Partial> = {}; const effects: Partial> = {}; const media: Partial> = {}; 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, darkPalette: darkPalette as Record, 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 { 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> { const rows = await db .select() .from(ThemeScopeValue) .where(sql`${ThemeScopeValue.scopeId} = ${scopeId}`); const result: Record = {}; 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 { 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; }