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
@@ -8,6 +8,13 @@ import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, ThemeScope, ThemeScopeValue } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import {
|
||||
BLOCK_KEY_PREFIX,
|
||||
EFFECT_KEYS,
|
||||
LAYOUT_KEYS,
|
||||
MEDIA_KEYS,
|
||||
THEME_BLOCKS,
|
||||
} from "@/lib/theme-blocks";
|
||||
import { allModuleIds } from "@/lib/theme-modules";
|
||||
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||
import { getAllScopes, getScopeValues } from "@/lib/theme-resolver";
|
||||
@@ -143,6 +150,7 @@ export async function saveScopeValues(formData: FormData): Promise<void> {
|
||||
if (!scopeId) redirect("/admin/theme-builder");
|
||||
|
||||
try {
|
||||
// Save color values (light + dark)
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
for (const key of THEME_COLOR_KEYS) {
|
||||
const fieldKey = `${key}_${mode}`;
|
||||
@@ -165,6 +173,154 @@ export async function saveScopeValues(formData: FormData): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Save block visibility
|
||||
for (const block of THEME_BLOCKS) {
|
||||
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
|
||||
const val = formData.get(key);
|
||||
const visible = val === "on" || val === "true" ? "true" : "false";
|
||||
|
||||
// Only save if different from default
|
||||
if (visible !== String(block.defaultVisible)) {
|
||||
await db
|
||||
.insert(ThemeScopeValue)
|
||||
.values({
|
||||
scopeId: BigInt(scopeId),
|
||||
settingKey: key,
|
||||
settingVal: visible,
|
||||
mode: "light",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { settingVal: visible, updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
// Remove if set back to default
|
||||
await db
|
||||
.delete(ThemeScopeValue)
|
||||
.where(
|
||||
and(
|
||||
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
|
||||
eq(ThemeScopeValue.settingKey, key),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Save layout settings
|
||||
for (const key of LAYOUT_KEYS) {
|
||||
const raw = String(formData.get(key) ?? "").trim();
|
||||
if (!raw) continue;
|
||||
|
||||
await db
|
||||
.insert(ThemeScopeValue)
|
||||
.values({
|
||||
scopeId: BigInt(scopeId),
|
||||
settingKey: key,
|
||||
settingVal: raw,
|
||||
mode: "light",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { settingVal: raw, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// Save effect settings
|
||||
for (const key of EFFECT_KEYS) {
|
||||
const raw = String(formData.get(key) ?? "").trim();
|
||||
if (!raw) continue;
|
||||
|
||||
await db
|
||||
.insert(ThemeScopeValue)
|
||||
.values({
|
||||
scopeId: BigInt(scopeId),
|
||||
settingKey: key,
|
||||
settingVal: raw,
|
||||
mode: "light",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { settingVal: raw, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// Save media settings
|
||||
for (const key of MEDIA_KEYS) {
|
||||
const raw = String(formData.get(key) ?? "").trim();
|
||||
if (!raw) continue;
|
||||
|
||||
await db
|
||||
.insert(ThemeScopeValue)
|
||||
.values({
|
||||
scopeId: BigInt(scopeId),
|
||||
settingKey: key,
|
||||
settingVal: raw,
|
||||
mode: "light",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { settingVal: raw, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// Save custom CSS
|
||||
const customCss = String(formData.get("custom:css") ?? "").trim();
|
||||
if (customCss) {
|
||||
await db
|
||||
.insert(ThemeScopeValue)
|
||||
.values({
|
||||
scopeId: BigInt(scopeId),
|
||||
settingKey: "custom:css",
|
||||
settingVal: customCss,
|
||||
mode: "light",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { settingVal: customCss, updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await db
|
||||
.delete(ThemeScopeValue)
|
||||
.where(
|
||||
and(
|
||||
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
|
||||
eq(ThemeScopeValue.settingKey, "custom:css"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Save custom HTML
|
||||
const customHtml = String(formData.get("custom:html") ?? "").trim();
|
||||
if (customHtml) {
|
||||
await db
|
||||
.insert(ThemeScopeValue)
|
||||
.values({
|
||||
scopeId: BigInt(scopeId),
|
||||
settingKey: "custom:html",
|
||||
settingVal: customHtml,
|
||||
mode: "light",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { settingVal: customHtml, updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await db
|
||||
.delete(ThemeScopeValue)
|
||||
.where(
|
||||
and(
|
||||
sql`${ThemeScopeValue.scopeId} = ${scopeId}`,
|
||||
eq(ThemeScopeValue.settingKey, "custom:html"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
|
||||
@@ -22,20 +22,35 @@ export default async function SiteLayout({
|
||||
<>
|
||||
{session?.user?.id ? (
|
||||
<>
|
||||
<TopHeader session={session} />
|
||||
<SiteHeader />
|
||||
<Navigation session={session} />
|
||||
<div data-theme-block="top_header">
|
||||
<TopHeader session={session} />
|
||||
</div>
|
||||
<div data-theme-block="site_header">
|
||||
<SiteHeader />
|
||||
</div>
|
||||
<div data-theme-block="navigation">
|
||||
<Navigation session={session} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<main>
|
||||
<div className="mx-auto mt-4 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-5 px-3 py-3 sm:px-4 sm:py-4 md:mt-0 md:px-6 md:py-6 md:gap-y-6">
|
||||
<div
|
||||
data-theme-block="content_grid"
|
||||
className="mx-auto mt-4 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-5 px-3 py-3 sm:px-4 sm:py-4 md:mt-0 md:px-6 md:py-6 md:gap-y-6"
|
||||
>
|
||||
<div className="col-span-12 min-w-0">
|
||||
<MotionPageWrapper>{children}</MotionPageWrapper>
|
||||
<div data-theme-block="page_transition">
|
||||
<MotionPageWrapper>{children}</MotionPageWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<RadioPlayerGate />
|
||||
<div data-theme-block="footer">
|
||||
<SiteFooter />
|
||||
</div>
|
||||
<div data-theme-block="radio_player">
|
||||
<RadioPlayerGate />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getThemeBuilderData } from "@/actions/theme-builder";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { getFullScopeValues } from "@/lib/theme-resolver";
|
||||
import { ScopeForm } from "./scope-form";
|
||||
import { ScopeTree } from "./scope-tree";
|
||||
import { ThemeEditor } from "./theme-editor";
|
||||
@@ -22,6 +23,18 @@ export default async function ThemeBuilderPage({
|
||||
|
||||
const selectedId = sp.scope ? Number(sp.scope) : (scopes[0]?.id ?? null);
|
||||
|
||||
const values = selectedId
|
||||
? await getFullScopeValues(selectedId)
|
||||
: {
|
||||
light: {},
|
||||
dark: {},
|
||||
blocks: {},
|
||||
layout: {},
|
||||
effects: {},
|
||||
media: {},
|
||||
custom: {},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="admin-section-title mb-4">Theme Builder</h1>
|
||||
@@ -66,7 +79,7 @@ export default async function ThemeBuilderPage({
|
||||
|
||||
<div className="lg:col-span-9">
|
||||
{selectedId ? (
|
||||
<ThemeEditor scopeId={selectedId} scopes={scopes} />
|
||||
<ThemeEditor scopeId={selectedId} scopes={scopes} values={values} />
|
||||
) : (
|
||||
<div className="admin-card p-8 text-center text-[var(--admin-text-muted)]">
|
||||
Select or create a scope to start editing.
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
saveScopeValues,
|
||||
} from "@/actions/theme-builder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
BLOCK_KEY_PREFIX,
|
||||
type EffectKey,
|
||||
getBlocksByCategory,
|
||||
type LayoutKey,
|
||||
type MediaKey,
|
||||
} from "@/lib/theme-blocks";
|
||||
import type { ThemeColorKey } from "@/lib/theme-presets";
|
||||
import { ColorField } from "./color-field";
|
||||
|
||||
@@ -21,6 +28,18 @@ interface ScopeInfo {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface ScopeValues {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
blocks: Record<string, string>;
|
||||
layout: Record<string, string | undefined>;
|
||||
effects: Record<string, string | undefined>;
|
||||
media: Record<string, string | undefined>;
|
||||
custom: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
// ─── Color fields ────────────────────────────────────────────────────────────
|
||||
|
||||
const COLOR_FIELDS: Array<{
|
||||
key: ThemeColorKey;
|
||||
label: string;
|
||||
@@ -190,6 +209,154 @@ const COLOR_FIELDS: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Layout fields ───────────────────────────────────────────────────────────
|
||||
|
||||
const LAYOUT_FIELDS: Array<{
|
||||
key: LayoutKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "layout:max_width",
|
||||
label: "Max Width",
|
||||
def: "1280px",
|
||||
desc: "Maximum content width",
|
||||
},
|
||||
{
|
||||
key: "layout:spacing",
|
||||
label: "Grid Spacing",
|
||||
def: "1.5rem",
|
||||
desc: "Gap between grid items",
|
||||
},
|
||||
{
|
||||
key: "layout:grid_columns",
|
||||
label: "Grid Columns",
|
||||
def: "12",
|
||||
desc: "Number of grid columns",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "layout:content_padding",
|
||||
label: "Content Padding",
|
||||
def: "0.75rem",
|
||||
desc: "Horizontal padding on content",
|
||||
},
|
||||
{
|
||||
key: "layout:navbar_height",
|
||||
label: "Navbar Height",
|
||||
def: "56px",
|
||||
desc: "Height of the navigation bar",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Effect fields ───────────────────────────────────────────────────────────
|
||||
|
||||
const EFFECT_FIELDS: Array<{
|
||||
key: EffectKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "effect:card_shadow",
|
||||
label: "Card Shadow",
|
||||
def: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)",
|
||||
desc: "Default card box shadow",
|
||||
},
|
||||
{
|
||||
key: "effect:card_shadow_hover",
|
||||
label: "Card Shadow (hover)",
|
||||
def: "0 8px 24px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06)",
|
||||
desc: "Card box shadow on hover",
|
||||
},
|
||||
{
|
||||
key: "effect:border_style",
|
||||
label: "Border Style",
|
||||
def: "solid",
|
||||
desc: "CSS border-style (solid, dashed, dotted, etc.)",
|
||||
},
|
||||
{
|
||||
key: "effect:border_width",
|
||||
label: "Border Width",
|
||||
def: "1px",
|
||||
desc: "Default border width",
|
||||
},
|
||||
{
|
||||
key: "effect:transition_duration",
|
||||
label: "Transition Duration",
|
||||
def: "200ms",
|
||||
desc: "Duration for hover/transition effects",
|
||||
},
|
||||
{
|
||||
key: "effect:animation_scale",
|
||||
label: "Animation Scale",
|
||||
def: "1",
|
||||
desc: "Scale factor for animations (1 = normal)",
|
||||
type: "number",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Media fields ────────────────────────────────────────────────────────────
|
||||
|
||||
const MEDIA_FIELDS: Array<{
|
||||
key: MediaKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "media:logo_url",
|
||||
label: "Logo URL",
|
||||
def: "",
|
||||
desc: "Custom logo image URL (overrides default)",
|
||||
},
|
||||
{
|
||||
key: "media:background_image",
|
||||
label: "Background Image",
|
||||
def: "",
|
||||
desc: "Page background image URL",
|
||||
},
|
||||
{
|
||||
key: "media:background_position",
|
||||
label: "Background Position",
|
||||
def: "center",
|
||||
desc: "CSS background-position",
|
||||
},
|
||||
{
|
||||
key: "media:background_size",
|
||||
label: "Background Size",
|
||||
def: "cover",
|
||||
desc: "CSS background-size (cover, contain, auto)",
|
||||
},
|
||||
{
|
||||
key: "media:background_repeat",
|
||||
label: "Background Repeat",
|
||||
def: "no-repeat",
|
||||
desc: "CSS background-repeat",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getVal(
|
||||
values: ScopeValues | null,
|
||||
key: string,
|
||||
mode: "light" | "dark",
|
||||
): string {
|
||||
if (!values) return "";
|
||||
if (key.startsWith("block:")) return values.blocks[key] ?? "";
|
||||
if (key.startsWith("layout:")) return values.layout[key] ?? "";
|
||||
if (key.startsWith("effect:")) return values.effects[key] ?? "";
|
||||
if (key.startsWith("media:")) return values.media[key] ?? "";
|
||||
if (key.startsWith("custom:")) return values.custom[key] ?? "";
|
||||
if (mode === "dark") return values.dark[key] ?? "";
|
||||
return values.light[key] ?? "";
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
global: "🌍 Global",
|
||||
site: "🌐 Site",
|
||||
@@ -197,20 +364,37 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
route: "📄 Route",
|
||||
};
|
||||
|
||||
type Tab = "colors" | "blocks" | "layout" | "effects" | "media" | "custom";
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function ThemeEditor({
|
||||
scopeId,
|
||||
scopes,
|
||||
values,
|
||||
}: {
|
||||
scopeId: number;
|
||||
scopes: ScopeInfo[];
|
||||
values: ScopeValues;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"light" | "dark">("light");
|
||||
const [tab, setTab] = useState<Tab>("colors");
|
||||
|
||||
const scope = scopes.find((s) => s.id === scopeId);
|
||||
if (!scope) return null;
|
||||
|
||||
const tabs: Array<{ id: Tab; label: string; icon: string }> = [
|
||||
{ id: "colors", label: "Colors", icon: "🎨" },
|
||||
{ id: "blocks", label: "Blocks", icon: "🧩" },
|
||||
{ id: "layout", label: "Layout", icon: "📐" },
|
||||
{ id: "effects", label: "Effects", icon: "✨" },
|
||||
{ id: "media", label: "Media", icon: "🖼" },
|
||||
{ id: "custom", label: "Custom", icon: "💻" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="admin-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -256,61 +440,68 @@ export function ThemeEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">Editing:</span>
|
||||
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("light")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === "light"
|
||||
? "bg-[var(--admin-accent)] text-[var(--admin-accent-text)]"
|
||||
: "bg-[var(--admin-surface)] text-[var(--admin-text)] hover:bg-[var(--admin-surface-elevated)]"
|
||||
}`}
|
||||
>
|
||||
☀ Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("dark")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === "dark"
|
||||
? "bg-[var(--admin-accent)] text-[var(--admin-accent-text)]"
|
||||
: "bg-[var(--admin-surface)] text-[var(--admin-text)] hover:bg-[var(--admin-surface-elevated)]"
|
||||
}`}
|
||||
>
|
||||
☾ Dark
|
||||
</button>
|
||||
{/* Mode toggle (only for colors tab) */}
|
||||
{tab === "colors" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
Editing:
|
||||
</span>
|
||||
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("light")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === "light"
|
||||
? "bg-[var(--admin-accent)] text-[var(--admin-accent-text)]"
|
||||
: "bg-[var(--admin-surface)] text-[var(--admin-text)] hover:bg-[var(--admin-surface-elevated)]"
|
||||
}`}
|
||||
>
|
||||
☀ Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("dark")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === "dark"
|
||||
? "bg-[var(--admin-accent)] text-[var(--admin-accent-text)]"
|
||||
: "bg-[var(--admin-surface)] text-[var(--admin-text)] hover:bg-[var(--admin-surface-elevated)]"
|
||||
}`}
|
||||
>
|
||||
☾ Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
tab === t.id
|
||||
? "bg-[var(--admin-accent)]/15 text-[var(--admin-accent)]"
|
||||
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface-elevated)] hover:text-[var(--admin-text)]"
|
||||
}`}
|
||||
>
|
||||
<span>{t.icon}</span>
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<form action={saveScopeValues}>
|
||||
<input type="hidden" name="scope_id" value={scopeId} />
|
||||
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Colors ({mode} mode)
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Only values you set here override the parent scope. Leave empty to
|
||||
inherit.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{COLOR_FIELDS.map((field) => (
|
||||
<ColorField
|
||||
key={`${field.key}-${mode}`}
|
||||
name={`${field.key}_${mode}`}
|
||||
label={field.label}
|
||||
description={field.desc}
|
||||
defaultValue={field.def}
|
||||
backgroundColorKey={
|
||||
field.bgKey ? `${field.bgKey}_${mode}` : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{tab === "colors" && <ColorsTab values={values} mode={mode} />}
|
||||
{tab === "blocks" && <BlocksTab values={values} />}
|
||||
{tab === "layout" && <LayoutTab values={values} />}
|
||||
{tab === "effects" && <EffectsTab values={values} />}
|
||||
{tab === "media" && <MediaTab values={values} />}
|
||||
{tab === "custom" && <CustomTab values={values} />}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button type="submit" variant="default">
|
||||
@@ -321,3 +512,266 @@ export function ThemeEditor({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Colors Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ColorsTab({
|
||||
values,
|
||||
mode,
|
||||
}: {
|
||||
values: ScopeValues;
|
||||
mode: "light" | "dark";
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Colors ({mode} mode)
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Only values you set here override the parent scope. Leave empty to
|
||||
inherit.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{COLOR_FIELDS.map((field) => (
|
||||
<ColorField
|
||||
key={`${field.key}-${mode}`}
|
||||
name={`${field.key}_${mode}`}
|
||||
label={field.label}
|
||||
description={field.desc}
|
||||
defaultValue={getVal(values, field.key, mode) || field.def}
|
||||
backgroundColorKey={
|
||||
field.bgKey ? `${field.bgKey}_${mode}` : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blocks Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function BlocksTab({ values }: { values: ScopeValues }) {
|
||||
const grouped = getBlocksByCategory();
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
chrome: "Site Chrome",
|
||||
layout: "Layout",
|
||||
components: "Components",
|
||||
effects: "Effects",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([category, blocks]) => (
|
||||
<div key={category} className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{categoryLabels[category] ?? category}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Toggle visibility of blocks in this scope. Hidden blocks use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
display: none
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block) => {
|
||||
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
|
||||
const currentVal = values.blocks[key];
|
||||
const isChecked =
|
||||
currentVal === "true" ||
|
||||
(currentVal === undefined && block.defaultVisible);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={block.id}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--admin-surface-elevated)] cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={key}
|
||||
defaultChecked={isChecked}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-[var(--admin-text)]">
|
||||
{block.label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-2">
|
||||
{block.description}
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] text-[var(--admin-text-muted)] font-mono">
|
||||
{block.selector}
|
||||
</code>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Layout Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function LayoutTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Layout Settings
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control spacing, width, and grid settings for this scope. Values are
|
||||
applied as CSS custom properties.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{LAYOUT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Effects Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EffectsTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Effects & Transitions
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control shadows, borders, and animation timing for this scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{EFFECT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Media Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function MediaTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Media & Backgrounds
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Set custom logos, background images, and background properties for this
|
||||
scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{MEDIA_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
placeholder={field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function CustomTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom CSS
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom CSS that applies within this scope. Use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
&{" "}
|
||||
</code>{" "}
|
||||
as a placeholder for the scope selector (e.g.{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
& .content-card {"{"} border: 2px solid red; {"}"}
|
||||
</code>
|
||||
).
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:css"
|
||||
defaultValue={getVal(values, "custom:css", "light")}
|
||||
rows={8}
|
||||
placeholder={".content-card { border-radius: 20px; }"}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom HTML
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom HTML that injects into the page for this scope. Use for
|
||||
custom banners, scripts, or content blocks.
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:html"
|
||||
defaultValue={getVal(values, "custom:html", "light")}
|
||||
rows={6}
|
||||
placeholder='<div class="my-banner">Welcome!</div>'
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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