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,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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user