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
This commit is contained in:
1 parent
3efd6c90f0
commit
adf0658916
7 files changed
+1334
-75
No files matched your search
@@ -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, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user