- 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
319 lines
9.4 KiB
TypeScript
319 lines
9.4 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 { 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;
|
|
}
|