diff --git a/drizzle/migrations/0023_theme_builder_scopes.sql b/drizzle/migrations/0023_theme_builder_scopes.sql new file mode 100644 index 0000000000..608df6b6bc --- /dev/null +++ b/drizzle/migrations/0023_theme_builder_scopes.sql @@ -0,0 +1,37 @@ +-- Theme Builder: scoped theme system for per-route, per-module, and multi-site theming. +-- Idempotent: safe to re-run. + +CREATE TABLE IF NOT EXISTS theme_scopes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + type ENUM('global','site','module','route') NOT NULL, + parent_id BIGINT UNSIGNED NULL, + site_domain VARCHAR(255) NULL, + route_path VARCHAR(255) NULL, + module_id VARCHAR(100) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY theme_scopes_parent_idx (parent_id), + KEY theme_scopes_type_idx (type), + UNIQUE KEY theme_scopes_unique_lookup (type, site_domain, route_path, module_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS theme_scope_values ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + scope_id BIGINT UNSIGNED NOT NULL, + setting_key VARCHAR(100) NOT NULL, + setting_val VARCHAR(255) NOT NULL, + mode ENUM('light','dark') NOT NULL DEFAULT 'light', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY theme_scope_values_unique (scope_id, setting_key, mode), + KEY theme_scope_values_scope_idx (scope_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Seed: create the global scope from existing website_settings theme data. +INSERT IGNORE INTO theme_scopes (id, name, type, parent_id, is_active, sort_order) +VALUES (1, 'Global', 'global', NULL, 1, 0); diff --git a/src/actions/theme-builder.ts b/src/actions/theme-builder.ts new file mode 100644 index 0000000000..fe6367cac8 --- /dev/null +++ b/src/actions/theme-builder.ts @@ -0,0 +1,318 @@ +"use server"; + +import { and, eq, sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +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 { allModuleIds } from "@/lib/theme-modules"; +import { THEME_COLOR_KEYS } from "@/lib/theme-presets"; +import { getAllScopes, getScopeValues } from "@/lib/theme-resolver"; + +const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/; + +// ─── Scope CRUD ────────────────────────────────────────────────────────────── + +export async function createScope(formData: FormData): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + const name = String(formData.get("name") ?? "").trim(); + const type = String(formData.get("type") ?? "") as + | "global" + | "site" + | "module" + | "route"; + const parentId = formData.get("parent_id") + ? Number(formData.get("parent_id")) + : null; + const siteDomain = String(formData.get("site_domain") ?? "").trim() || null; + const routePath = String(formData.get("route_path") ?? "").trim() || null; + const moduleId = String(formData.get("module_id") ?? "").trim() || null; + + if (!name || !type) redirect("/admin/theme-builder"); + + if (type === "module" && !moduleId) redirect("/admin/theme-builder"); + if (type === "route" && !routePath) redirect("/admin/theme-builder"); + if (type === "site" && !siteDomain) redirect("/admin/theme-builder"); + + const [maxRow] = await db + .select({ maxSort: sql`COALESCE(MAX(${ThemeScope.sortOrder}), 0)` }) + .from(ThemeScope); + + try { + await db.insert(ThemeScope).values({ + name, + type, + parentId: parentId ? BigInt(parentId) : null, + siteDomain, + routePath, + moduleId, + isActive: true, + sortOrder: (maxRow?.maxSort ?? 0) + 1, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const staff = await requirePermission(PERMS.SETTINGS_EDIT); + await logStaffActivity({ + staffId: staff.id, + action: "theme_scope_create", + description: `Created theme scope "${name}" (${type})`, + }); + + revalidatePath("/admin/theme-builder"); + } catch { + // ignore + } + redirect("/admin/theme-builder?created=1"); +} + +export async function updateScope(formData: FormData): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + const id = Number(formData.get("id")); + const name = String(formData.get("name") ?? "").trim(); + const isActive = formData.get("is_active") === "on"; + + if (!id) redirect("/admin/theme-builder"); + + try { + await db + .update(ThemeScope) + .set({ name, isActive, updatedAt: new Date() }) + .where(sql`${ThemeScope.id} = ${id}`); + + revalidatePath("/admin/theme-builder"); + } catch { + // ignore + } + redirect("/admin/theme-builder?updated=1"); +} + +export async function deleteScope(formData: FormData): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + const id = Number(formData.get("id")); + if (!id) redirect("/admin/theme-builder"); + + try { + await db + .delete(ThemeScopeValue) + .where(sql`${ThemeScopeValue.scopeId} = ${id}`); + await db.delete(ThemeScope).where(sql`${ThemeScope.id} = ${id}`); + + const staff = await requirePermission(PERMS.SETTINGS_EDIT); + await logStaffActivity({ + staffId: staff.id, + action: "theme_scope_delete", + description: `Deleted theme scope #${id}`, + }); + + revalidatePath("/admin/theme-builder"); + } catch { + // ignore + } + redirect("/admin/theme-builder?deleted=1"); +} + +export async function reorderScopes(orderedIds: number[]): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + try { + for (let i = 0; i < orderedIds.length; i++) { + await db + .update(ThemeScope) + .set({ sortOrder: i, updatedAt: new Date() }) + .where(sql`${ThemeScope.id} = ${orderedIds[i]}`); + } + revalidatePath("/admin/theme-builder"); + } catch { + // ignore + } +} + +// ─── Theme Values CRUD ─────────────────────────────────────────────────────── + +export async function saveScopeValues(formData: FormData): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + const scopeId = Number(formData.get("scope_id")); + if (!scopeId) redirect("/admin/theme-builder"); + + try { + for (const mode of ["light", "dark"] as const) { + for (const key of THEME_COLOR_KEYS) { + const fieldKey = `${key}_${mode}`; + const raw = String(formData.get(fieldKey) ?? "").trim(); + if (!raw || !COLOR_RE.test(raw)) continue; + + await db + .insert(ThemeScopeValue) + .values({ + scopeId: BigInt(scopeId), + settingKey: key, + settingVal: raw, + mode, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onDuplicateKeyUpdate({ + set: { settingVal: raw, updatedAt: new Date() }, + }); + } + } + + const staff = await requirePermission(PERMS.SETTINGS_EDIT); + await logStaffActivity({ + staffId: staff.id, + action: "theme_scope_values", + description: `Updated theme values for scope #${scopeId}`, + }); + + revalidatePath("/admin/theme-builder"); + revalidatePath("/", "layout"); + } catch { + // ignore + } + redirect("/admin/theme-builder?saved=1"); +} + +export async function resetScopeValues(formData: FormData): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + const scopeId = Number(formData.get("scope_id")); + const key = String(formData.get("key") ?? ""); + const mode = String(formData.get("mode") ?? "") as "light" | "dark"; + + if (!scopeId || !key) redirect("/admin/theme-builder"); + + try { + await db + .delete(ThemeScopeValue) + .where( + and( + sql`${ThemeScopeValue.scopeId} = ${scopeId}`, + eq(ThemeScopeValue.settingKey, key), + eq(ThemeScopeValue.mode, mode), + ), + ); + + revalidatePath("/admin/theme-builder"); + } catch { + // ignore + } + redirect("/admin/theme-builder?reset=1"); +} + +// ─── Duplicate scope ───────────────────────────────────────────────────────── + +export async function duplicateScope(formData: FormData): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + const sourceId = Number(formData.get("id")); + if (!sourceId) redirect("/admin/theme-builder"); + + try { + const scopes = await getAllScopes(); + const source = scopes.find((s) => s.id === sourceId); + if (!source) redirect("/admin/theme-builder"); + + const [maxRow] = await db + .select({ + maxSort: sql`COALESCE(MAX(${ThemeScope.sortOrder}), 0)`, + }) + .from(ThemeScope); + + await db.insert(ThemeScope).values({ + name: `${source.name} (copy)`, + type: source.type, + parentId: source.parentId ? BigInt(source.parentId) : null, + siteDomain: source.siteDomain, + routePath: source.routePath, + moduleId: source.moduleId, + isActive: false, + sortOrder: (maxRow?.maxSort ?? 0) + 1, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const sourceValues = await getScopeValues(sourceId); + const allScopes = await getAllScopes(); + const newScope = allScopes.find((s) => s.name === `${source.name} (copy)`); + + if (newScope) { + for (const [key, val] of Object.entries(sourceValues)) { + if (val.light) { + await db.insert(ThemeScopeValue).values({ + scopeId: BigInt(newScope.id), + settingKey: key, + settingVal: val.light, + mode: "light", + createdAt: new Date(), + updatedAt: new Date(), + }); + } + if (val.dark) { + await db.insert(ThemeScopeValue).values({ + scopeId: BigInt(newScope.id), + settingKey: key, + settingVal: val.dark, + mode: "dark", + createdAt: new Date(), + updatedAt: new Date(), + }); + } + } + } + + revalidatePath("/admin/theme-builder"); + } catch { + // ignore + } + redirect("/admin/theme-builder?duplicated=1"); +} + +// ─── Data helpers for the admin page ───────────────────────────────────────── + +export async function getThemeBuilderData() { + const scopes = await getAllScopes(); + const moduleIds = allModuleIds(); + const tree = buildTree(scopes); + + return { scopes, tree, moduleIds }; +} + +type ScopeNode = Awaited>[number] & { + children: ScopeNode[]; +}; + +function buildTree( + scopes: Awaited>, +): ScopeNode[] { + const nodeMap = new Map(); + const roots: ScopeNode[] = []; + + for (const scope of scopes) { + nodeMap.set(scope.id, { ...scope, children: [] }); + } + + for (const scope of scopes) { + const node = nodeMap.get(scope.id); + if (!node) continue; + if (scope.parentId) { + const parent = nodeMap.get(scope.parentId); + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } else { + roots.push(node); + } + } + + return roots; +} diff --git a/src/app/admin/theme-builder/color-field.tsx b/src/app/admin/theme-builder/color-field.tsx new file mode 100644 index 0000000000..426aee7590 --- /dev/null +++ b/src/app/admin/theme-builder/color-field.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState } from "react"; + +export function ColorField({ + name, + label, + description, + defaultValue, +}: { + name: string; + label: string; + description: string; + defaultValue: string; + backgroundColorKey?: string; +}) { + const [val, setVal] = useState(defaultValue); + + return ( +
+
+ setVal(e.target.value)} + className="w-[42px] h-[36px] p-0 border-none bg-none cursor-pointer flex-shrink-0 rounded" + /> +
+
+ + {label} + +
+ + {description} + +
+
+
+ ); +} diff --git a/src/app/admin/theme-builder/page.tsx b/src/app/admin/theme-builder/page.tsx new file mode 100644 index 0000000000..72a67044f6 --- /dev/null +++ b/src/app/admin/theme-builder/page.tsx @@ -0,0 +1,79 @@ +import { redirect } from "next/navigation"; +import { getThemeBuilderData } from "@/actions/theme-builder"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { ScopeForm } from "./scope-form"; +import { ScopeTree } from "./scope-tree"; +import { ThemeEditor } from "./theme-editor"; + +export const metadata = { title: "Theme Builder" }; + +export default async function ThemeBuilderPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const ctx = await getAdminContext(); + if (!ctx || !canAccess(ctx.permissions, PERMS.SETTINGS_VIEW)) { + redirect("/admin"); + } + + const sp = await searchParams; + const { scopes, tree, moduleIds } = await getThemeBuilderData(); + + const selectedId = sp.scope ? Number(sp.scope) : (scopes[0]?.id ?? null); + + return ( +
+

Theme Builder

+

+ Create and manage scoped themes for different parts of your site. Themes + inherit from parent scopes — only override what you need. +

+ + {sp.created ? ( +
+ Scope created. +
+ ) : null} + {sp.updated ? ( +
+ Scope updated. +
+ ) : null} + {sp.deleted ? ( +
+ Scope deleted. +
+ ) : null} + {sp.saved ? ( +
+ Theme values saved. +
+ ) : null} + {sp.duplicated ? ( +
+ Scope duplicated. +
+ ) : null} + +
+
+ +
+ +
+
+ +
+ {selectedId ? ( + + ) : ( +
+ Select or create a scope to start editing. +
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/theme-builder/scope-form.tsx b/src/app/admin/theme-builder/scope-form.tsx new file mode 100644 index 0000000000..f731bb1845 --- /dev/null +++ b/src/app/admin/theme-builder/scope-form.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { createScope } from "@/actions/theme-builder"; +import { Button } from "@/components/ui/button"; + +const SCOPE_TYPES = [ + { + value: "global", + label: "Global", + desc: "Default theme for the entire site", + }, + { value: "site", label: "Site", desc: "Theme for a specific domain" }, + { + value: "module", + label: "Module", + desc: "Theme for a feature section (shop, guilds, etc.)", + }, + { value: "route", label: "Route", desc: "Theme for a specific page path" }, +] as const; + +export function ScopeForm({ + moduleIds, + scopeCount, +}: { + moduleIds: string[]; + scopeCount: number; +}) { + const [type, setType] = useState("module"); + const [expanded, setExpanded] = useState(false); + + if (!expanded) { + return ( + + ); + } + + return ( +
+
+

+ New Scope +

+ +
+ +
+
+ + +
+ +
+ + +
+ + {type === "site" ? ( +
+ + +
+ ) : null} + + {type === "module" ? ( +
+ + +
+ ) : null} + + {type === "route" ? ( +
+ + +
+ ) : null} + + {type !== "global" && scopeCount > 0 ? ( +
+ + +
+ ) : null} + + +
+
+ ); +} diff --git a/src/app/admin/theme-builder/scope-tree.tsx b/src/app/admin/theme-builder/scope-tree.tsx new file mode 100644 index 0000000000..3864f4aa21 --- /dev/null +++ b/src/app/admin/theme-builder/scope-tree.tsx @@ -0,0 +1,105 @@ +"use client"; + +import Link from "next/link"; + +interface ScopeNode { + id: number; + name: string; + type: string; + parentId: number | null; + siteDomain: string | null; + routePath: string | null; + moduleId: string | null; + isActive: boolean; + children: ScopeNode[]; +} + +interface ScopeTreeProps { + tree: ScopeNode[]; + selectedId: number | null; +} + +const TYPE_ICONS: Record = { + global: "🌍", + site: "🌐", + module: "📦", + route: "📄", +}; + +function TreeNode({ + node, + selectedId, + depth = 0, +}: { + node: ScopeNode; + selectedId: number | null; + depth?: number; +}) { + const isSelected = node.id === selectedId; + + return ( +
+ + + {TYPE_ICONS[node.type] ?? "•"} + + {node.name} + {!node.isActive ? ( + + OFF + + ) : null} + + {node.children.length > 0 ? ( +
+ {node.children.map((child) => ( + + ))} +
+ ) : null} +
+ ); +} + +export function ScopeTree({ tree, selectedId }: ScopeTreeProps) { + return ( +
+

+ Theme Scopes +

+
+ {tree.length === 0 ? ( +

+ No scopes created yet. Add one below. +

+ ) : ( + tree.map((node) => ( + + )) + )} +
+
+
+ {Object.entries(TYPE_ICONS).map(([type, icon]) => ( + + {icon} {type} + + ))} +
+
+
+ ); +} diff --git a/src/app/admin/theme-builder/theme-editor.tsx b/src/app/admin/theme-builder/theme-editor.tsx new file mode 100644 index 0000000000..61ac4a3b22 --- /dev/null +++ b/src/app/admin/theme-builder/theme-editor.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useState } from "react"; +import { + deleteScope, + duplicateScope, + saveScopeValues, +} from "@/actions/theme-builder"; +import { Button } from "@/components/ui/button"; +import type { ThemeColorKey } from "@/lib/theme-presets"; +import { ColorField } from "./color-field"; + +interface ScopeInfo { + id: number; + name: string; + type: string; + parentId: number | null; + siteDomain: string | null; + routePath: string | null; + moduleId: string | null; + isActive: boolean; +} + +const COLOR_FIELDS: Array<{ + key: ThemeColorKey; + label: string; + def: string; + desc: string; + bgKey?: string; +}> = [ + { + key: "color_background", + label: "Page background", + def: "#f8fafc", + desc: "Main page background", + }, + { + key: "color_surface", + label: "Card / surface", + def: "#ffffff", + desc: "Cards, panels, elevated surfaces", + }, + { + key: "color_dropdown", + label: "Dropdown", + def: "#ffffff", + desc: "Dropdown menus", + }, + { + key: "color_navbar", + label: "Navbar", + def: "#ffffff", + desc: "Navigation bar background", + }, + { + key: "color_navbar_text", + label: "Navbar text", + def: "#1e293b", + desc: "Nav text color", + bgKey: "color_navbar", + }, + { + key: "color_text", + label: "Body text", + def: "#0f172a", + desc: "Main text color", + bgKey: "color_surface", + }, + { + key: "color_text_muted", + label: "Muted text", + def: "#64748b", + desc: "Secondary text", + bgKey: "color_surface", + }, + { + key: "color_primary", + label: "Primary accent", + def: "#f59e0b", + desc: "Main brand color", + }, + { + key: "color_accent", + label: "Secondary accent", + def: "#10b981", + desc: "Secondary brand color", + }, + { + key: "color_success", + label: "Success", + def: "#16a34a", + desc: "Success states", + }, + { + key: "color_warning", + label: "Warning", + def: "#eab308", + desc: "Warning states", + }, + { key: "color_error", label: "Error", def: "#ef4444", desc: "Error states" }, + { + key: "color_info", + label: "Info", + def: "#0ea5e9", + desc: "Information states", + }, + { + key: "button_primary_color", + label: "Button primary", + def: "#f59e0b", + desc: "Primary button background", + }, + { + key: "button_text_color", + label: "Button primary text", + def: "#1e293b", + desc: "Primary button text", + }, + { + key: "button_secondary_color", + label: "Button secondary", + def: "#22c55e", + desc: "Secondary button background", + }, + { + key: "button_secondary_text_color", + label: "Button secondary text", + def: "#ffffff", + desc: "Secondary button text", + }, + { + key: "button_danger_color", + label: "Button danger", + def: "#ef4444", + desc: "Danger button background", + }, + { + key: "button_danger_text_color", + label: "Button danger text", + def: "#ffffff", + desc: "Danger button text", + }, + { + key: "button_outline_color", + label: "Button outline", + def: "#eeb425", + desc: "Outline button border", + }, + { + key: "button_outline_text_color", + label: "Button outline text", + def: "#1a1a2e", + desc: "Outline button text", + }, + { + key: "link_color", + label: "Link color", + def: "#b77900", + desc: "Link text color", + }, + { + key: "link_hover_color", + label: "Link hover", + def: "#8a5b00", + desc: "Link hover color", + }, + { + key: "border_color", + label: "Border color", + def: "#eeb425", + desc: "General borders", + }, + { + key: "panel_border_color", + label: "Panel border", + def: "#e9b124", + desc: "Panel borders", + }, + { + key: "gradient_from", + label: "Gradient start", + def: "#f59e0b", + desc: "Gradient from color", + }, + { + key: "gradient_to", + label: "Gradient end", + def: "#10b981", + desc: "Gradient to color", + }, +]; + +const TYPE_LABELS: Record = { + global: "🌍 Global", + site: "🌐 Site", + module: "📦 Module", + route: "📄 Route", +}; + +export function ThemeEditor({ + scopeId, + scopes, +}: { + scopeId: number; + scopes: ScopeInfo[]; +}) { + const [mode, setMode] = useState<"light" | "dark">("light"); + + const scope = scopes.find((s) => s.id === scopeId); + if (!scope) return null; + + return ( +
+
+
+
+

+ {TYPE_LABELS[scope.type] ?? scope.type} — {scope.name} +

+

+ {scope.type === "route" && `Path: ${scope.routePath}`} + {scope.type === "module" && `Module: ${scope.moduleId}`} + {scope.type === "site" && `Domain: ${scope.siteDomain}`} + {scope.type === "global" && + "Applies to all pages (default theme)"} +

+
+
+
+ + +
+ {scope.type !== "global" ? ( +
+ + +
+ ) : null} +
+
+
+ +
+ Editing: +
+ + +
+
+ +
+ + +
+

+ Colors ({mode} mode) +

+

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

+ +
+ {COLOR_FIELDS.map((field) => ( + + ))} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/app/api/themes/export/embed/route.ts b/src/app/api/themes/export/embed/route.ts new file mode 100644 index 0000000000..0d262b8c58 --- /dev/null +++ b/src/app/api/themes/export/embed/route.ts @@ -0,0 +1,46 @@ +import { THEME_COLOR_KEYS } from "@/lib/theme-presets"; +import { resolveTheme } from "@/lib/theme-resolver"; + +// GET /api/themes/export/embed.js?module=shop +// Returns a self-contained JavaScript snippet that injects theme CSS variables. + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const moduleParam = url.searchParams.get("module") ?? undefined; + const route = url.searchParams.get("route") ?? undefined; + const site = url.searchParams.get("site") ?? undefined; + + const resolved = await resolveTheme({ + siteDomain: site ?? undefined, + routePath: route ?? undefined, + moduleId: moduleParam ?? undefined, + }); + + const lightVars = THEME_COLOR_KEYS.map( + (key) => ` --${resolved.cssVariables[key]}: ${resolved.palette[key]};`, + ).join("\n"); + const darkVars = THEME_COLOR_KEYS.map( + (key) => + ` --${resolved.cssVariables[key]}: ${resolved.darkPalette[key]};`, + ).join("\n"); + + const js = `(function(){ + var s=document.createElement('style'); + s.textContent=':root{\\n${lightVars.replace(/'/g, "\\'")}\\n}html.dark{\\n${darkVars.replace(/'/g, "\\'")}\\n}'; + document.head.appendChild(s); +})();`; + + return new Response(js, { + headers: { + "Content-Type": "application/javascript", + "Cache-Control": "public, max-age=300", + "Access-Control-Allow-Origin": "*", + }, + }); + } catch { + return new Response("/* theme embed failed */", { + headers: { "Content-Type": "application/javascript" }, + }); + } +} diff --git a/src/app/api/themes/export/route.ts b/src/app/api/themes/export/route.ts new file mode 100644 index 0000000000..1cd59257f6 --- /dev/null +++ b/src/app/api/themes/export/route.ts @@ -0,0 +1,86 @@ +import { apiJson } from "@/lib/api"; +import { THEME_COLOR_KEYS } from "@/lib/theme-presets"; +import { generateScopedCss, resolveTheme } from "@/lib/theme-resolver"; + +// GET /api/themes/export?module=shop&route=/login +// Exports the resolved theme as CSS variables and metadata. +// Useful for external integrations, iframes, and embed widgets. + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const module = url.searchParams.get("module") ?? undefined; + const route = url.searchParams.get("route") ?? undefined; + const site = url.searchParams.get("site") ?? undefined; + const format = url.searchParams.get("format") ?? "json"; + + const resolved = await resolveTheme({ + siteDomain: site ?? undefined, + routePath: route ?? undefined, + moduleId: module ?? undefined, + }); + + if (format === "css") { + const { rootCss, scopedCssBlocks } = generateScopedCss(resolved); + const fullCss = [rootCss, ...scopedCssBlocks].join("\n"); + return new Response(fullCss, { + headers: { + "Content-Type": "text/css", + "Cache-Control": "public, max-age=300", + }, + }); + } + + if (format === "variables") { + const variables: Record = {}; + for (const key of THEME_COLOR_KEYS) { + variables[`--${resolved.cssVariables[key]}`] = resolved.palette[key]; + } + return apiJson({ + light: variables, + dark: Object.fromEntries( + THEME_COLOR_KEYS.map((key) => [ + `--${resolved.cssVariables[key]}`, + resolved.darkPalette[key], + ]), + ), + scopes: resolved.contributingScopes.map((s) => ({ + id: s.id, + name: s.name, + type: s.type, + })), + }); + } + + // Default: full JSON export + const lightVariables: Record = {}; + const darkVariables: Record = {}; + for (const key of THEME_COLOR_KEYS) { + lightVariables[`--${resolved.cssVariables[key]}`] = resolved.palette[key]; + darkVariables[`--${resolved.cssVariables[key]}`] = + resolved.darkPalette[key]; + } + + return apiJson({ + palette: { + light: resolved.palette, + dark: resolved.darkPalette, + }, + cssVariables: { + light: lightVariables, + dark: darkVariables, + }, + scopes: resolved.contributingScopes.map((s) => ({ + id: s.id, + name: s.name, + type: s.type, + })), + meta: { + generatedAt: new Date().toISOString(), + format: "atom-theme-v1", + }, + }); + } catch { + return apiJson({ error: "Failed to resolve theme" }, { status: 500 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 63e8efd62b..cd4feeae7a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,7 +8,9 @@ import { getLocale, getMessages } from "next-intl/server"; import type { ReactNode } from "react"; import { Toaster } from "sonner"; import { PwaRegister } from "@/components/pwa-register"; +import { ScopedThemeVars } from "@/components/scoped-theme-vars"; import { SmoothScroll } from "@/components/smooth-scroll"; +import { ThemeScopeDetector } from "@/components/theme-scope-detector"; import { ThemeVars } from "@/components/theme-vars"; import { enforceSiteAccess } from "@/lib/access-guard"; import { resolveHotelName } from "@/lib/hotel-name"; @@ -96,6 +98,8 @@ export default async function RootLayout({ > + + {children} diff --git a/src/components/scoped-theme-vars.tsx b/src/components/scoped-theme-vars.tsx new file mode 100644 index 0000000000..470e770ee8 --- /dev/null +++ b/src/components/scoped-theme-vars.tsx @@ -0,0 +1,132 @@ +import { asc, eq } from "drizzle-orm"; +import { db, ThemeScope, ThemeScopeValue } from "@/lib/db"; +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. + +type ThemeScopeType = "global" | "site" | "module" | "route"; + +interface ScopeRow { + id: number; + type: ThemeScopeType; + siteDomain: string | null; + routePath: string | null; + moduleId: string | null; +} + +interface ScopeValues { + light: Partial>; + dark: Partial>; +} + +async function loadScopedThemes(): Promise< + Array<{ scope: ScopeRow; values: ScopeValues }> +> { + const rows = await db + .select() + .from(ThemeScope) + .where(eq(ThemeScope.isActive, true)) + .orderBy(asc(ThemeScope.sortOrder), asc(ThemeScope.id)); + + const scopes: ScopeRow[] = rows + .filter((r) => r.type !== "global") + .map((r) => ({ + id: Number(r.id), + type: r.type as ThemeScopeType, + siteDomain: r.siteDomain, + routePath: r.routePath, + moduleId: r.moduleId, + })); + + if (scopes.length === 0) return []; + + const allValues = await db.select().from(ThemeScopeValue); + + const valuesByScope = new Map(); + for (const row of allValues) { + const scopeId = Number(row.scopeId); + const key = row.settingKey as ThemeColorKey; + if (!valuesByScope.has(scopeId)) { + valuesByScope.set(scopeId, { light: {}, dark: {} }); + } + const sv = valuesByScope.get(scopeId); + if (!sv) continue; + if (row.mode === "light") sv.light[key] = row.settingVal; + else sv.dark[key] = row.settingVal; + } + + return scopes + .filter((s) => valuesByScope.has(s.id)) + .map((scope) => ({ + scope, + values: valuesByScope.get(scope.id) ?? { light: {}, dark: {} }, + })); +} + +function escapeAttr(v: string): string { + return v.replace(/"/g, """).replace(//g, ">"); +} + +function buildScopedCssBlock( + scope: ScopeRow, + values: ScopeValues, +): string | null { + let selector: string; + switch (scope.type) { + case "site": + if (!scope.siteDomain) return null; + selector = `[data-theme-site="${escapeAttr(scope.siteDomain)}"]`; + break; + case "module": + if (!scope.moduleId) return null; + selector = `[data-theme-module="${escapeAttr(scope.moduleId)}"]`; + break; + case "route": + if (!scope.routePath) return null; + selector = `[data-theme-route="${escapeAttr(scope.routePath)}"]`; + break; + default: + return null; + } + + const lightVars = THEME_COLOR_KEYS.filter((k) => values.light[k]) + .map((k) => `--${k}:${values.light[k]};`) + .join(""); + const darkVars = THEME_COLOR_KEYS.filter((k) => values.dark[k]) + .map((k) => `--${k}:${values.dark[k]};`) + .join(""); + + if (!lightVars && !darkVars) return null; + + let css = ""; + if (lightVars) css += `${selector}{${lightVars}}`; + if (darkVars) css += `html.dark ${selector}{${darkVars}}`; + return css; +} + +/** + * ScopedThemeVars injects CSS blocks for non-global theme scopes. + * Place this after in your layout. + */ +export async function ScopedThemeVars({ nonce }: { nonce?: string }) { + const scoped = await loadScopedThemes(); + if (scoped.length === 0) return null; + + const blocks: string[] = []; + for (const { scope, values } of scoped) { + const css = buildScopedCssBlock(scope, values); + if (css) blocks.push(css); + } + + if (blocks.length === 0) return null; + + return ( +