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

+311
View File
@@ -0,0 +1,311 @@
// Theme blocks registry — defines all visual blocks that can be themed or hidden per scope.
// Each block has a CSS selector, display name, and optional description.
export interface ThemeBlock {
id: string;
label: string;
description: string;
/** CSS selector to target this block */
selector: string;
/** Whether this block is visible by default */
defaultVisible: boolean;
/** Category for grouping in admin UI */
category: "chrome" | "layout" | "components" | "effects";
}
export const THEME_BLOCKS: ThemeBlock[] = [
// ─── Chrome (site-wide navigation/branding) ──────────────────────────────
{
id: "top_header",
label: "Top Header",
description: "Currency bar, user menu, notifications (logged-in users)",
selector: '[data-theme-block="top_header"]',
defaultVisible: true,
category: "chrome",
},
{
id: "site_header",
label: "Hero Banner",
description: "Full-width hero banner with background image",
selector: '[data-theme-block="site_header"]',
defaultVisible: true,
category: "chrome",
},
{
id: "navigation",
label: "Navigation Bar",
description: "Sticky nav bar with menu items and search",
selector: '[data-theme-block="navigation"]',
defaultVisible: true,
category: "chrome",
},
{
id: "footer",
label: "Footer",
description: "Site footer with copyright and CMS info",
selector: '[data-theme-block="footer"]',
defaultVisible: true,
category: "chrome",
},
{
id: "radio_player",
label: "Radio Player",
description: "Fixed bottom-right radio player widget",
selector: '[data-theme-block="radio_player"]',
defaultVisible: true,
category: "chrome",
},
// ─── Layout blocks ──────────────────────────────────────────────────────
{
id: "content_grid",
label: "Content Grid",
description: "Main content area with 12-column grid",
selector: '[data-theme-block="content_grid"]',
defaultVisible: true,
category: "layout",
},
{
id: "page_transition",
label: "Page Transitions",
description: "AnimatePresence page transition animations",
selector: '[data-theme-block="page_transition"]',
defaultVisible: true,
category: "layout",
},
// ─── Component blocks ───────────────────────────────────────────────────
{
id: "content_card",
label: "Content Cards",
description: "The signature atom card with header and body",
selector: ".content-card",
defaultVisible: true,
category: "components",
},
{
id: "surface_card",
label: "Surface Cards",
description: "Alternative card used on login, register, dashboard",
selector: ".surface-card",
defaultVisible: true,
category: "components",
},
{
id: "stat_block",
label: "Stat Blocks",
description: "Value + label tiles on profiles and dashboards",
selector: ".stat-block",
defaultVisible: true,
category: "components",
},
{
id: "online_badge",
label: "Online Badges",
description: "Online/offline status pills",
selector: ".online-badge",
defaultVisible: true,
category: "components",
},
{
id: "rank_badge",
label: "Rank Badges",
description: "Leaderboard rank chips with medal emojis",
selector: ".rank-badge",
defaultVisible: true,
category: "components",
},
{
id: "btn_primary",
label: "Primary Buttons",
description: "Main call-to-action buttons",
selector: ".btn-primary",
defaultVisible: true,
category: "components",
},
{
id: "btn_secondary",
label: "Secondary Buttons",
description: "Secondary action buttons",
selector: ".btn-secondary",
defaultVisible: true,
category: "components",
},
{
id: "btn_danger",
label: "Danger Buttons",
description: "Destructive action buttons",
selector: ".btn-danger",
defaultVisible: true,
category: "components",
},
{
id: "btn_outline",
label: "Outline Buttons",
description: "Border-style buttons",
selector: ".btn-outline",
defaultVisible: true,
category: "components",
},
{
id: "dropdown_menu",
label: "Dropdown Menus",
description: "Nav dropdowns and user menus",
selector: ".dropdown-menu",
defaultVisible: true,
category: "components",
},
{
id: "empty_state",
label: "Empty States",
description: "Friendly empty state placeholders",
selector: ".empty-state",
defaultVisible: true,
category: "components",
},
// ─── Effects ────────────────────────────────────────────────────────────
{
id: "glass_effect",
label: "Glassmorphism",
description: "Frosted glass panels (auth top bar, premium cards)",
selector: ".glass, .glass-strong",
defaultVisible: true,
category: "effects",
},
{
id: "card_premium",
label: "Premium Card Effect",
description: "Enhanced card with depth and glow",
selector: ".card-premium",
defaultVisible: true,
category: "effects",
},
{
id: "gradient_border",
label: "Gradient Borders",
description: "Animated gradient border wrappers",
selector: ".gradient-border",
defaultVisible: true,
category: "effects",
},
{
id: "animate_float",
label: "Float Animation",
description: "Gentle floating animation (Frank character on login)",
selector: ".animate-float",
defaultVisible: true,
category: "effects",
},
{
id: "animate_shimmer",
label: "Shimmer Animation",
description: "Loading shimmer effect",
selector: ".animate-shimmer",
defaultVisible: true,
category: "effects",
},
{
id: "animate_reveal",
label: "Scroll Reveal",
description: "Fade-up reveal on scroll (login, register, dashboard)",
selector: ".motion-reveal",
defaultVisible: true,
category: "effects",
},
{
id: "btn_shine",
label: "Button Shine",
description: "Premium shine effect on enter button",
selector: ".btn-shine",
defaultVisible: true,
category: "effects",
},
];
// Quick lookup by ID
export const THEME_BLOCK_MAP = new Map(THEME_BLOCKS.map((b) => [b.id, b]));
// Get blocks grouped by category
export function getBlocksByCategory(): Record<string, ThemeBlock[]> {
const grouped: Record<string, ThemeBlock[]> = {};
for (const block of THEME_BLOCKS) {
if (!grouped[block.category]) grouped[block.category] = [];
grouped[block.category].push(block);
}
return grouped;
}
// Block setting key prefix in ThemeScopeValue
export const BLOCK_KEY_PREFIX = "block:";
// All block setting keys (for a given block id, the key is `block:{id}`)
export function blockSettingKey(blockId: string): string {
return `${BLOCK_KEY_PREFIX}${blockId}`;
}
// Layout setting keys
export const LAYOUT_KEYS = [
"layout:max_width",
"layout:spacing",
"layout:grid_columns",
"layout:content_padding",
"layout:navbar_height",
] as const;
export type LayoutKey = (typeof LAYOUT_KEYS)[number];
export const LAYOUT_DEFAULTS: Record<LayoutKey, string> = {
"layout:max_width": "1280px",
"layout:spacing": "1.5rem",
"layout:grid_columns": "12",
"layout:content_padding": "0.75rem",
"layout:navbar_height": "56px",
};
// Effect setting keys
export const EFFECT_KEYS = [
"effect:card_shadow",
"effect:card_shadow_hover",
"effect:border_style",
"effect:border_width",
"effect:transition_duration",
"effect:animation_scale",
] as const;
export type EffectKey = (typeof EFFECT_KEYS)[number];
export const EFFECT_DEFAULTS: Record<EffectKey, string> = {
"effect:card_shadow":
"0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)",
"effect:card_shadow_hover":
"0 8px 24px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06)",
"effect:border_style": "solid",
"effect:border_width": "1px",
"effect:transition_duration": "200ms",
"effect:animation_scale": "1",
};
// Media setting keys
export const MEDIA_KEYS = [
"media:logo_url",
"media:background_image",
"media:background_position",
"media:background_size",
"media:background_repeat",
] as const;
export type MediaKey = (typeof MEDIA_KEYS)[number];
export const MEDIA_DEFAULTS: Record<MediaKey, string> = {
"media:logo_url": "",
"media:background_image": "",
"media:background_position": "center",
"media:background_size": "cover",
"media:background_repeat": "no-repeat",
};
// Custom setting keys
export const CUSTOM_KEYS = ["custom:css", "custom:html"] as const;
export type CustomKey = (typeof CUSTOM_KEYS)[number];
+198 -5
View File
@@ -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;
}