Files
EpicNext-Cms/src/components/scoped-theme-vars.tsx
T
openhands adf0658916
CI / check (push) Failing after 29s
CI / release (push) Skipped
CI / deploy (push) Skipped
feat: extend theme builder with block visibility, layout, effects, media, and custom CSS tabs
- Add theme-blocks.ts registry with 24 themeable blocks across 4 categories
- Extend resolver to resolve blocks, layout, effects, media, and custom CSS per scope
- Add data-theme-block attributes to all site layout blocks
- Extend ScopedThemeVars to generate CSS for block visibility, layout vars, effect vars, media vars, and custom CSS
- Rewrite admin UI with 6 tabs: Colors, Blocks, Layout, Effects, Media, Custom
- Extend server actions to save/load all new setting types
- No database migration needed - uses existing theme_scope_values table with prefixed keys
2026-08-31 17:44:29 +02:00

250 lines
6.6 KiB
TypeScript

import { asc, eq } from "drizzle-orm";
import { db, ThemeScope, ThemeScopeValue } from "@/lib/db";
import {
BLOCK_KEY_PREFIX,
type EffectKey,
type LayoutKey,
type MediaKey,
THEME_BLOCKS,
} from "@/lib/theme-blocks";
import { THEME_COLOR_KEYS, type ThemeColorKey } from "@/lib/theme-presets";
// ─── Scoped CSS injection ────────────────────────────────────────────────────
// Injects CSS blocks for module/route overrides using data-attribute selectors.
// Also handles block visibility, layout, effects, media, and custom CSS.
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>>;
blocks: Record<string, string>;
layout: Partial<Record<LayoutKey, string>>;
effects: Partial<Record<EffectKey, string>>;
media: Partial<Record<MediaKey, string>>;
custom: Partial<Record<string, 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;
if (!valuesByScope.has(scopeId)) {
valuesByScope.set(scopeId, {
light: {},
dark: {},
blocks: {},
layout: {},
effects: {},
media: {},
custom: {},
});
}
const sv = valuesByScope.get(scopeId);
if (!sv) continue;
if (key.startsWith(BLOCK_KEY_PREFIX)) {
sv.blocks[key] = row.settingVal;
} else if (key.startsWith("layout:")) {
sv.layout[key as LayoutKey] = row.settingVal;
} else if (key.startsWith("effect:")) {
sv.effects[key as EffectKey] = row.settingVal;
} else if (key.startsWith("media:")) {
sv.media[key as MediaKey] = row.settingVal;
} else if (key.startsWith("custom:")) {
sv.custom[key] = row.settingVal;
} else if (row.mode === "light") {
sv.light[key as ThemeColorKey] = row.settingVal;
} else {
sv.dark[key as ThemeColorKey] = row.settingVal;
}
}
return scopes
.filter((s) => valuesByScope.has(s.id))
.map((scope) => ({
scope,
values: valuesByScope.get(scope.id) ?? {
light: {},
dark: {},
blocks: {},
layout: {},
effects: {},
media: {},
custom: {},
},
}));
}
function escapeAttr(v: string): string {
return v.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeCss(v: string): string {
return v.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 parts: string[] = [];
// Color variables
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) parts.push(`${selector}{${lightVars}}`);
if (darkVars) parts.push(`html.dark ${selector}{${darkVars}}`);
// Block visibility — hide blocks with display:none
const hiddenBlocks = Object.entries(values.blocks)
.filter(([, v]) => v === "false")
.map(([k]) => k.replace(BLOCK_KEY_PREFIX, ""));
if (hiddenBlocks.length > 0) {
const blockSelectors = hiddenBlocks
.map((blockId) => {
const block = THEME_BLOCKS.find((b) => b.id === blockId);
return block ? block.selector : `[data-theme-block="${blockId}"]`;
})
.join(",");
// Apply within scope selector context
if (blockSelectors) {
parts.push(`${selector} ${blockSelectors}{display:none !important;}`);
}
}
// Layout CSS variables
const layoutVars = Object.entries(values.layout)
.filter(([, v]) => v)
.map(([k, v]) => {
const cssKey = k.replace("layout:", "theme-");
return `--${cssKey}:${v};`;
})
.join("");
if (layoutVars) {
parts.push(`${selector}{${layoutVars}}`);
}
// Effect CSS variables
const effectVars = Object.entries(values.effects)
.filter(([, v]) => v)
.map(([k, v]) => {
const cssKey = k.replace("effect:", "theme-");
const val = v ?? "";
return `--${cssKey}:${escapeCss(val)};`;
})
.join("");
if (effectVars) {
parts.push(`${selector}{${effectVars}}`);
}
// Media CSS variables
const mediaVars = Object.entries(values.media)
.filter(([, v]) => v)
.map(([k, v]) => {
const cssKey = k.replace("media:", "theme-");
const val = v ?? "";
if (k === "media:logo_url") {
return `--theme-logo-url:url("${escapeCss(val)}");`;
}
if (k === "media:background_image") {
return `--theme-bg-image:url("${escapeCss(val)}");`;
}
return `--${cssKey}:${val};`;
})
.join("");
if (mediaVars) {
parts.push(`${selector}{${mediaVars}}`);
}
// Custom CSS
const customCss = values.custom["custom:css"];
if (customCss) {
// Replace & with the scope selector for user convenience
const processed = customCss.replace(/&/g, selector);
parts.push(processed);
}
if (parts.length === 0) return null;
return parts.join("");
}
/**
* 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("") }}
/>
);
}