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 { 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>; } export interface ResolvedTheme { palette: Record; darkPalette: Record; contributingScopes: ThemeScopeRow[]; cssVariables: Record; } // ─── 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 as ThemeColorKey; if (!result.has(scopeId)) { result.set(scopeId, { light: {}, dark: {} }); } const sv = result.get(scopeId); if (!sv) continue; if (row.mode === "dark") { sv.dark[key] = row.settingVal; } else { sv.light[key] = 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, }; } const ancestry = buildAncestry(allScopes, bestScope.id); const allValues = await loadAllScopeValues(); const lightPalette: Partial> = {}; const darkPalette: Partial> = {}; 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]; } } 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, }; } 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[] = []; 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; }