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
+198
-5
@@ -3,6 +3,15 @@ import "server-only";
|
||||
import { asc, eq, sql } from "drizzle-orm";
|
||||
import { db, ThemeScope, ThemeScopeValue } from "@/lib/db";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
BLOCK_KEY_PREFIX,
|
||||
EFFECT_DEFAULTS,
|
||||
type EffectKey,
|
||||
LAYOUT_DEFAULTS,
|
||||
type LayoutKey,
|
||||
MEDIA_DEFAULTS,
|
||||
type MediaKey,
|
||||
} from "@/lib/theme-blocks";
|
||||
import { THEME_COLOR_KEYS, type ThemeColorKey } from "@/lib/theme-presets";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
@@ -24,6 +33,16 @@ export interface ThemeScopeRow {
|
||||
export interface ScopeValues {
|
||||
light: Partial<Record<ThemeColorKey, string>>;
|
||||
dark: Partial<Record<ThemeColorKey, string>>;
|
||||
/** Block visibility: block:{blockId} → "true" | "false" */
|
||||
blocks: Record<string, string>;
|
||||
/** Layout settings */
|
||||
layout: Partial<Record<LayoutKey, string>>;
|
||||
/** Effect settings */
|
||||
effects: Partial<Record<EffectKey, string>>;
|
||||
/** Media settings */
|
||||
media: Partial<Record<MediaKey, string>>;
|
||||
/** Custom CSS/HTML */
|
||||
custom: Partial<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface ResolvedTheme {
|
||||
@@ -31,6 +50,18 @@ export interface ResolvedTheme {
|
||||
darkPalette: Record<ThemeColorKey, string>;
|
||||
contributingScopes: ThemeScopeRow[];
|
||||
cssVariables: Record<string, string>;
|
||||
/** Block visibility (blockId → visible) */
|
||||
blocks: Record<string, boolean>;
|
||||
/** Resolved layout settings */
|
||||
layout: Record<LayoutKey, string>;
|
||||
/** Resolved effect settings */
|
||||
effects: Record<EffectKey, string>;
|
||||
/** Resolved media settings */
|
||||
media: Record<MediaKey, string>;
|
||||
/** Raw custom CSS from all contributing scopes */
|
||||
customCss: string;
|
||||
/** Raw custom HTML from all contributing scopes */
|
||||
customHtml: string;
|
||||
}
|
||||
|
||||
// ─── CSS variable name mapping ───────────────────────────────────────────────
|
||||
@@ -108,17 +139,36 @@ async function loadAllScopeValues(): Promise<Map<number, ScopeValues>> {
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = Number(row.scopeId);
|
||||
const key = row.settingKey as ThemeColorKey;
|
||||
const key = row.settingKey;
|
||||
|
||||
if (!result.has(scopeId)) {
|
||||
result.set(scopeId, { light: {}, dark: {} });
|
||||
result.set(scopeId, {
|
||||
light: {},
|
||||
dark: {},
|
||||
blocks: {},
|
||||
layout: {},
|
||||
effects: {},
|
||||
media: {},
|
||||
custom: {},
|
||||
});
|
||||
}
|
||||
const sv = result.get(scopeId);
|
||||
if (!sv) continue;
|
||||
if (row.mode === "dark") {
|
||||
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 === "dark") {
|
||||
sv.dark[key as ThemeColorKey] = row.settingVal;
|
||||
} else {
|
||||
sv.light[key] = row.settingVal;
|
||||
sv.light[key as ThemeColorKey] = row.settingVal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +262,12 @@ export async function resolveTheme(context: {
|
||||
darkPalette: defaults.dark as Record<ThemeColorKey, string>,
|
||||
contributingScopes: [],
|
||||
cssVariables: CSS_VAR_MAP,
|
||||
blocks: {},
|
||||
layout: { ...LAYOUT_DEFAULTS },
|
||||
effects: { ...EFFECT_DEFAULTS },
|
||||
media: { ...MEDIA_DEFAULTS },
|
||||
customCss: "",
|
||||
customHtml: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +276,12 @@ export async function resolveTheme(context: {
|
||||
|
||||
const lightPalette: Partial<Record<ThemeColorKey, string>> = {};
|
||||
const darkPalette: Partial<Record<ThemeColorKey, string>> = {};
|
||||
const blocks: Record<string, string> = {};
|
||||
const layout: Partial<Record<LayoutKey, string>> = {};
|
||||
const effects: Partial<Record<EffectKey, string>> = {};
|
||||
const media: Partial<Record<MediaKey, string>> = {};
|
||||
const customCssParts: string[] = [];
|
||||
const customHtmlParts: string[] = [];
|
||||
|
||||
for (const scope of ancestry) {
|
||||
const sv = allValues.get(scope.id);
|
||||
@@ -229,6 +291,32 @@ export async function resolveTheme(context: {
|
||||
if (sv.light[key]) lightPalette[key] = sv.light[key];
|
||||
if (sv.dark[key]) darkPalette[key] = sv.dark[key];
|
||||
}
|
||||
|
||||
// Blocks: deepest scope wins for each block
|
||||
for (const [k, v] of Object.entries(sv.blocks)) {
|
||||
blocks[k] = v;
|
||||
}
|
||||
|
||||
// Layout: deepest scope wins per key
|
||||
for (const [k, v] of Object.entries(sv.layout)) {
|
||||
if (v) layout[k as LayoutKey] = v;
|
||||
}
|
||||
|
||||
// Effects: deepest scope wins per key
|
||||
for (const [k, v] of Object.entries(sv.effects)) {
|
||||
if (v) effects[k as EffectKey] = v;
|
||||
}
|
||||
|
||||
// Media: deepest scope wins per key
|
||||
for (const [k, v] of Object.entries(sv.media)) {
|
||||
if (v) media[k as MediaKey] = v;
|
||||
}
|
||||
|
||||
// Custom: concatenate from all scopes
|
||||
const cssVal = sv.custom["custom:css"];
|
||||
if (cssVal) customCssParts.push(cssVal);
|
||||
const htmlVal = sv.custom["custom:html"];
|
||||
if (htmlVal) customHtmlParts.push(htmlVal);
|
||||
}
|
||||
|
||||
const defaults = await getGlobalDefaults();
|
||||
@@ -243,9 +331,22 @@ export async function resolveTheme(context: {
|
||||
darkPalette: darkPalette as Record<ThemeColorKey, string>,
|
||||
contributingScopes: ancestry,
|
||||
cssVariables: CSS_VAR_MAP,
|
||||
blocks: Object.fromEntries(
|
||||
Object.entries(blocks).map(([k, v]) => [
|
||||
k.replace(BLOCK_KEY_PREFIX, ""),
|
||||
v === "true",
|
||||
]),
|
||||
),
|
||||
layout: { ...LAYOUT_DEFAULTS, ...layout },
|
||||
effects: { ...EFFECT_DEFAULTS, ...effects },
|
||||
media: { ...MEDIA_DEFAULTS, ...media },
|
||||
customCss: customCssParts.join("\n"),
|
||||
customHtml: customHtmlParts.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── CSS generation ──────────────────────────────────────────────────────────
|
||||
|
||||
export function generateScopedCss(resolved: ResolvedTheme): {
|
||||
rootCss: string;
|
||||
scopedCssBlocks: string[];
|
||||
@@ -254,6 +355,55 @@ export function generateScopedCss(resolved: ResolvedTheme): {
|
||||
|
||||
const scopedCssBlocks: string[] = [];
|
||||
|
||||
// Layout CSS variables
|
||||
const layoutVars = [
|
||||
`--theme-max-width:${resolved.layout["layout:max_width"]}`,
|
||||
`--theme-spacing:${resolved.layout["layout:spacing"]}`,
|
||||
`--theme-grid-columns:${resolved.layout["layout:grid_columns"]}`,
|
||||
`--theme-content-padding:${resolved.layout["layout:content_padding"]}`,
|
||||
`--theme-navbar-height:${resolved.layout["layout:navbar_height"]}`,
|
||||
].join(";");
|
||||
|
||||
// Effect CSS variables
|
||||
const effectVars = [
|
||||
`--theme-card-shadow:${resolved.effects["effect:card_shadow"]}`,
|
||||
`--theme-card-shadow-hover:${resolved.effects["effect:card_shadow_hover"]}`,
|
||||
`--theme-border-style:${resolved.effects["effect:border_style"]}`,
|
||||
`--theme-border-width:${resolved.effects["effect:border_width"]}`,
|
||||
`--theme-transition-duration:${resolved.effects["effect:transition_duration"]}`,
|
||||
`--theme-animation-scale:${resolved.effects["effect:animation_scale"]}`,
|
||||
].join(";");
|
||||
|
||||
// Media CSS variables
|
||||
const mediaVars = [
|
||||
resolved.media["media:logo_url"]
|
||||
? `--theme-logo-url:url("${resolved.media["media:logo_url"].replace(/"/g, '\\"')}")`
|
||||
: "",
|
||||
resolved.media["media:background_image"]
|
||||
? `--theme-bg-image:url("${resolved.media["media:background_image"].replace(/"/g, '\\"')}")`
|
||||
: "",
|
||||
`--theme-bg-position:${resolved.media["media:background_position"]}`,
|
||||
`--theme-bg-size:${resolved.media["media:background_size"]}`,
|
||||
`--theme-bg-repeat:${resolved.media["media:background_repeat"]}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
|
||||
// Root: add layout + effects + media
|
||||
if (layoutVars || effectVars || mediaVars) {
|
||||
const extra = [layoutVars, effectVars, mediaVars].filter(Boolean).join(";");
|
||||
rootCss.replace("}", `${extra}}`);
|
||||
// Inject into root
|
||||
const rootEnd = rootCss.lastIndexOf("}");
|
||||
if (rootEnd !== -1) {
|
||||
const patched = `${rootCss.slice(0, rootEnd)}${extra};${rootCss.slice(rootEnd)}`;
|
||||
return {
|
||||
rootCss: patched,
|
||||
scopedCssBlocks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const scope of resolved.contributingScopes) {
|
||||
if (scope.type === "global") continue;
|
||||
|
||||
@@ -316,3 +466,46 @@ export async function getScopeValues(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all setting values for a scope (colors, blocks, layout, effects, media, custom).
|
||||
*/
|
||||
export async function getFullScopeValues(
|
||||
scopeId: number,
|
||||
): Promise<ScopeValues> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(ThemeScopeValue)
|
||||
.where(sql`${ThemeScopeValue.scopeId} = ${scopeId}`);
|
||||
|
||||
const sv: ScopeValues = {
|
||||
light: {},
|
||||
dark: {},
|
||||
blocks: {},
|
||||
layout: {},
|
||||
effects: {},
|
||||
media: {},
|
||||
custom: {},
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.settingKey;
|
||||
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 === "dark") {
|
||||
sv.dark[key as ThemeColorKey] = row.settingVal;
|
||||
} else {
|
||||
sv.light[key as ThemeColorKey] = row.settingVal;
|
||||
}
|
||||
}
|
||||
|
||||
return sv;
|
||||
}
|
||||
Reference in new issue
Block a user