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:
openhands committed 2026-08-31 17:15:42 +02:00
1 parent b57697b2e0
commit a98b194386
19 files changed
+1843

No files matched your search

+318
View File
@@ -0,0 +1,318 @@
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<Record<ThemeColorKey, string>>;
dark: Partial<Record<ThemeColorKey, string>>;
}
export interface ResolvedTheme {
palette: Record<ThemeColorKey, string>;
darkPalette: Record<ThemeColorKey, string>;
contributingScopes: ThemeScopeRow[];
cssVariables: Record<string, 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 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<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,
};
}
const ancestry = buildAncestry(allScopes, bestScope.id);
const allValues = await loadAllScopeValues();
const lightPalette: Partial<Record<ThemeColorKey, string>> = {};
const darkPalette: Partial<Record<ThemeColorKey, 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];
}
}
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,
};
}
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<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;
}