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