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

+46
View File
@@ -0,0 +1,46 @@
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { resolveTheme } from "@/lib/theme-resolver";
// GET /api/themes/export/embed.js?module=shop
// Returns a self-contained JavaScript snippet that injects theme CSS variables.
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const moduleParam = url.searchParams.get("module") ?? undefined;
const route = url.searchParams.get("route") ?? undefined;
const site = url.searchParams.get("site") ?? undefined;
const resolved = await resolveTheme({
siteDomain: site ?? undefined,
routePath: route ?? undefined,
moduleId: moduleParam ?? undefined,
});
const lightVars = THEME_COLOR_KEYS.map(
(key) => ` --${resolved.cssVariables[key]}: ${resolved.palette[key]};`,
).join("\n");
const darkVars = THEME_COLOR_KEYS.map(
(key) =>
` --${resolved.cssVariables[key]}: ${resolved.darkPalette[key]};`,
).join("\n");
const js = `(function(){
var s=document.createElement('style');
s.textContent=':root{\\n${lightVars.replace(/'/g, "\\'")}\\n}html.dark{\\n${darkVars.replace(/'/g, "\\'")}\\n}';
document.head.appendChild(s);
})();`;
return new Response(js, {
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=300",
"Access-Control-Allow-Origin": "*",
},
});
} catch {
return new Response("/* theme embed failed */", {
headers: { "Content-Type": "application/javascript" },
});
}
}
+86
View File
@@ -0,0 +1,86 @@
import { apiJson } from "@/lib/api";
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { generateScopedCss, resolveTheme } from "@/lib/theme-resolver";
// GET /api/themes/export?module=shop&route=/login
// Exports the resolved theme as CSS variables and metadata.
// Useful for external integrations, iframes, and embed widgets.
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const module = url.searchParams.get("module") ?? undefined;
const route = url.searchParams.get("route") ?? undefined;
const site = url.searchParams.get("site") ?? undefined;
const format = url.searchParams.get("format") ?? "json";
const resolved = await resolveTheme({
siteDomain: site ?? undefined,
routePath: route ?? undefined,
moduleId: module ?? undefined,
});
if (format === "css") {
const { rootCss, scopedCssBlocks } = generateScopedCss(resolved);
const fullCss = [rootCss, ...scopedCssBlocks].join("\n");
return new Response(fullCss, {
headers: {
"Content-Type": "text/css",
"Cache-Control": "public, max-age=300",
},
});
}
if (format === "variables") {
const variables: Record<string, string> = {};
for (const key of THEME_COLOR_KEYS) {
variables[`--${resolved.cssVariables[key]}`] = resolved.palette[key];
}
return apiJson({
light: variables,
dark: Object.fromEntries(
THEME_COLOR_KEYS.map((key) => [
`--${resolved.cssVariables[key]}`,
resolved.darkPalette[key],
]),
),
scopes: resolved.contributingScopes.map((s) => ({
id: s.id,
name: s.name,
type: s.type,
})),
});
}
// Default: full JSON export
const lightVariables: Record<string, string> = {};
const darkVariables: Record<string, string> = {};
for (const key of THEME_COLOR_KEYS) {
lightVariables[`--${resolved.cssVariables[key]}`] = resolved.palette[key];
darkVariables[`--${resolved.cssVariables[key]}`] =
resolved.darkPalette[key];
}
return apiJson({
palette: {
light: resolved.palette,
dark: resolved.darkPalette,
},
cssVariables: {
light: lightVariables,
dark: darkVariables,
},
scopes: resolved.contributingScopes.map((s) => ({
id: s.id,
name: s.name,
type: s.type,
})),
meta: {
generatedAt: new Date().toISOString(),
format: "atom-theme-v1",
},
});
} catch {
return apiJson({ error: "Failed to resolve theme" }, { status: 500 });
}
}