- 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
133 lines
3.7 KiB
TypeScript
133 lines
3.7 KiB
TypeScript
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("") }}
|
|
/>
|
|
);
|
|
}
|