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:
1 parent
b57697b2e0
commit
a98b194386
19 files changed
+1843
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user