diff --git a/src/actions/theme-builder.ts b/src/actions/theme-builder.ts index fe6367ca..6964cb3a 100644 --- a/src/actions/theme-builder.ts +++ b/src/actions/theme-builder.ts @@ -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 { 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 { } } + // 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, diff --git a/src/app/(site)/layout.tsx b/src/app/(site)/layout.tsx index 3bed6031..b4984653 100644 --- a/src/app/(site)/layout.tsx +++ b/src/app/(site)/layout.tsx @@ -22,20 +22,35 @@ export default async function SiteLayout({ <> {session?.user?.id ? ( <> - - - +
+ +
+
+ +
+
+ +
) : null}
-
+
- {children} +
+ {children} +
- - +
+ +
+
+ +
); } diff --git a/src/app/admin/theme-builder/page.tsx b/src/app/admin/theme-builder/page.tsx index 72a67044..affba2b0 100644 --- a/src/app/admin/theme-builder/page.tsx +++ b/src/app/admin/theme-builder/page.tsx @@ -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 (

Theme Builder

@@ -66,7 +79,7 @@ export default async function ThemeBuilderPage({
{selectedId ? ( - + ) : (
Select or create a scope to start editing. diff --git a/src/app/admin/theme-builder/theme-editor.tsx b/src/app/admin/theme-builder/theme-editor.tsx index 0394091c..463f66bf 100644 --- a/src/app/admin/theme-builder/theme-editor.tsx +++ b/src/app/admin/theme-builder/theme-editor.tsx @@ -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; + dark: Record; + blocks: Record; + layout: Record; + effects: Record; + media: Record; + custom: Record; +} + +// ─── 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 = { global: "🌍 Global", site: "🌐 Site", @@ -197,20 +364,37 @@ const TYPE_LABELS: Record = { 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("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 (
+ {/* Header */}
@@ -256,61 +440,68 @@ export function ThemeEditor({
-
- Editing: -
- - + {/* Mode toggle (only for colors tab) */} + {tab === "colors" && ( +
+ + Editing: + +
+ + +
+ )} + + {/* Tabs */} +
+ {tabs.map((t) => ( + + ))}
+ {/* Tab content */}
-
-

- Colors ({mode} mode) -

-

- Only values you set here override the parent scope. Leave empty to - inherit. -

- -
- {COLOR_FIELDS.map((field) => ( - - ))} -
-
+ {tab === "colors" && } + {tab === "blocks" && } + {tab === "layout" && } + {tab === "effects" && } + {tab === "media" && } + {tab === "custom" && }
); } + +// ─── Colors Tab ────────────────────────────────────────────────────────────── + +function ColorsTab({ + values, + mode, +}: { + values: ScopeValues; + mode: "light" | "dark"; +}) { + return ( +
+

+ Colors ({mode} mode) +

+

+ Only values you set here override the parent scope. Leave empty to + inherit. +

+ +
+ {COLOR_FIELDS.map((field) => ( + + ))} +
+
+ ); +} + +// ─── Blocks Tab ────────────────────────────────────────────────────────────── + +function BlocksTab({ values }: { values: ScopeValues }) { + const grouped = getBlocksByCategory(); + + const categoryLabels: Record = { + chrome: "Site Chrome", + layout: "Layout", + components: "Components", + effects: "Effects", + }; + + return ( +
+ {Object.entries(grouped).map(([category, blocks]) => ( +
+

+ {categoryLabels[category] ?? category} +

+

+ Toggle visibility of blocks in this scope. Hidden blocks use{" "} + + display: none + + . +

+ +
+ {blocks.map((block) => { + const key = `${BLOCK_KEY_PREFIX}${block.id}`; + const currentVal = values.blocks[key]; + const isChecked = + currentVal === "true" || + (currentVal === undefined && block.defaultVisible); + + return ( + + ); + })} +
+
+ ))} +
+ ); +} + +// ─── Layout Tab ────────────────────────────────────────────────────────────── + +function LayoutTab({ values }: { values: ScopeValues }) { + return ( +
+

+ Layout Settings +

+

+ Control spacing, width, and grid settings for this scope. Values are + applied as CSS custom properties. +

+ +
+ {LAYOUT_FIELDS.map((field) => ( +
+ +
+ ))} +
+
+ ); +} + +// ─── Effects Tab ───────────────────────────────────────────────────────────── + +function EffectsTab({ values }: { values: ScopeValues }) { + return ( +
+

+ Effects & Transitions +

+

+ Control shadows, borders, and animation timing for this scope. +

+ +
+ {EFFECT_FIELDS.map((field) => ( +
+ +
+ ))} +
+
+ ); +} + +// ─── Media Tab ─────────────────────────────────────────────────────────────── + +function MediaTab({ values }: { values: ScopeValues }) { + return ( +
+

+ Media & Backgrounds +

+

+ Set custom logos, background images, and background properties for this + scope. +

+ +
+ {MEDIA_FIELDS.map((field) => ( +
+ +
+ ))} +
+
+ ); +} + +// ─── Custom Tab ────────────────────────────────────────────────────────────── + +function CustomTab({ values }: { values: ScopeValues }) { + return ( +
+
+

+ Custom CSS +

+

+ Add custom CSS that applies within this scope. Use{" "} + + &{" "} + {" "} + as a placeholder for the scope selector (e.g.{" "} + + & .content-card {"{"} border: 2px solid red; {"}"} + + ). +

+