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

+132
View File
@@ -0,0 +1,132 @@
import { asc, eq } from "drizzle-orm";
import { db, ThemeScope, ThemeScopeValue } from "@/lib/db";
import { THEME_COLOR_KEYS, type ThemeColorKey } from "@/lib/theme-presets";
// ─── Scoped CSS injection ────────────────────────────────────────────────────
// This component works alongside ThemeVars. ThemeVars handles the global theme;
// this component adds scoped CSS blocks for module/route overrides using
// data-attribute selectors.
type ThemeScopeType = "global" | "site" | "module" | "route";
interface ScopeRow {
id: number;
type: ThemeScopeType;
siteDomain: string | null;
routePath: string | null;
moduleId: string | null;
}
interface ScopeValues {
light: Partial<Record<ThemeColorKey, string>>;
dark: Partial<Record<ThemeColorKey, string>>;
}
async function loadScopedThemes(): Promise<
Array<{ scope: ScopeRow; values: ScopeValues }>
> {
const rows = await db
.select()
.from(ThemeScope)
.where(eq(ThemeScope.isActive, true))
.orderBy(asc(ThemeScope.sortOrder), asc(ThemeScope.id));
const scopes: ScopeRow[] = rows
.filter((r) => r.type !== "global")
.map((r) => ({
id: Number(r.id),
type: r.type as ThemeScopeType,
siteDomain: r.siteDomain,
routePath: r.routePath,
moduleId: r.moduleId,
}));
if (scopes.length === 0) return [];
const allValues = await db.select().from(ThemeScopeValue);
const valuesByScope = new Map<number, ScopeValues>();
for (const row of allValues) {
const scopeId = Number(row.scopeId);
const key = row.settingKey as ThemeColorKey;
if (!valuesByScope.has(scopeId)) {
valuesByScope.set(scopeId, { light: {}, dark: {} });
}
const sv = valuesByScope.get(scopeId);
if (!sv) continue;
if (row.mode === "light") sv.light[key] = row.settingVal;
else sv.dark[key] = row.settingVal;
}
return scopes
.filter((s) => valuesByScope.has(s.id))
.map((scope) => ({
scope,
values: valuesByScope.get(scope.id) ?? { light: {}, dark: {} },
}));
}
function escapeAttr(v: string): string {
return v.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function buildScopedCssBlock(
scope: ScopeRow,
values: ScopeValues,
): string | null {
let selector: string;
switch (scope.type) {
case "site":
if (!scope.siteDomain) return null;
selector = `[data-theme-site="${escapeAttr(scope.siteDomain)}"]`;
break;
case "module":
if (!scope.moduleId) return null;
selector = `[data-theme-module="${escapeAttr(scope.moduleId)}"]`;
break;
case "route":
if (!scope.routePath) return null;
selector = `[data-theme-route="${escapeAttr(scope.routePath)}"]`;
break;
default:
return null;
}
const lightVars = THEME_COLOR_KEYS.filter((k) => values.light[k])
.map((k) => `--${k}:${values.light[k]};`)
.join("");
const darkVars = THEME_COLOR_KEYS.filter((k) => values.dark[k])
.map((k) => `--${k}:${values.dark[k]};`)
.join("");
if (!lightVars && !darkVars) return null;
let css = "";
if (lightVars) css += `${selector}{${lightVars}}`;
if (darkVars) css += `html.dark ${selector}{${darkVars}}`;
return css;
}
/**
* ScopedThemeVars injects CSS blocks for non-global theme scopes.
* Place this after <ThemeVars> in your layout.
*/
export async function ScopedThemeVars({ nonce }: { nonce?: string }) {
const scoped = await loadScopedThemes();
if (scoped.length === 0) return null;
const blocks: string[] = [];
for (const { scope, values } of scoped) {
const css = buildScopedCssBlock(scope, values);
if (css) blocks.push(css);
}
if (blocks.length === 0) return null;
return (
<style
nonce={nonce}
dangerouslySetInnerHTML={{ __html: blocks.join("") }}
/>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { detectModule } from "@/lib/theme-modules";
/**
* Sets data-theme-module and data-theme-route attributes on <html>
* based on the current pathname. This enables scoped CSS overrides
* from ScopedThemeVars.
*/
export function ThemeScopeDetector() {
const pathname = usePathname();
useEffect(() => {
const moduleId = detectModule(pathname);
const html = document.documentElement;
if (moduleId) {
html.setAttribute("data-theme-module", moduleId);
} else {
html.removeAttribute("data-theme-module");
}
html.setAttribute("data-theme-route", pathname);
}, [pathname]);
return null;
}