feat: extend theme builder with block visibility, layout, effects, media, and custom CSS tabs
CI / check (push) Failing after 29s
CI / release (push) Skipped
CI / deploy (push) Skipped

- 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
This commit is contained in:
openhands committed 2026-08-31 17:44:29 +02:00
1 parent 3efd6c90f0
commit adf0658916
7 files changed
+1334 -75

No files matched your search

+130 -13
View File
@@ -1,11 +1,17 @@
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 ────────────────────────────────────────────────────
// This component works alongside ThemeVars. ThemeVars handles the global theme;
// this component adds scoped CSS blocks for module/route overrides using
// data-attribute selectors.
// 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";
@@ -20,6 +26,11 @@ interface ScopeRow {
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<
@@ -48,21 +59,51 @@ async function loadScopedThemes(): Promise<
const valuesByScope = new Map<number, ScopeValues>();
for (const row of allValues) {
const scopeId = Number(row.scopeId);
const key = row.settingKey as ThemeColorKey;
const key = row.settingKey;
if (!valuesByScope.has(scopeId)) {
valuesByScope.set(scopeId, { light: {}, dark: {} });
valuesByScope.set(scopeId, {
light: {},
dark: {},
blocks: {},
layout: {},
effects: {},
media: {},
custom: {},
});
}
const sv = valuesByScope.get(scopeId);
if (!sv) continue;
if (row.mode === "light") sv.light[key] = row.settingVal;
else sv.dark[key] = row.settingVal;
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: {} },
values: valuesByScope.get(scope.id) ?? {
light: {},
dark: {},
blocks: {},
layout: {},
effects: {},
media: {},
custom: {},
},
}));
}
@@ -70,6 +111,10 @@ 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,
@@ -92,6 +137,9 @@ function buildScopedCssBlock(
return null;
}
const parts: string[] = [];
// Color variables
const lightVars = THEME_COLOR_KEYS.filter((k) => values.light[k])
.map((k) => `--${k}:${values.light[k]};`)
.join("");
@@ -99,12 +147,81 @@ function buildScopedCssBlock(
.map((k) => `--${k}:${values.dark[k]};`)
.join("");
if (!lightVars && !darkVars) return null;
if (lightVars) parts.push(`${selector}{${lightVars}}`);
if (darkVars) parts.push(`html.dark ${selector}{${darkVars}}`);
let css = "";
if (lightVars) css += `${selector}{${lightVars}}`;
if (darkVars) css += `html.dark ${selector}{${darkVars}}`;
return css;
// 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("");
}
/**