diff --git a/JSON_UI_REFACTOR_PRD.md b/JSON_UI_REFACTOR_PRD.md new file mode 100644 index 0000000..a8b468a --- /dev/null +++ b/JSON_UI_REFACTOR_PRD.md @@ -0,0 +1,144 @@ +# JSON-Driven UI Refactoring Project + +Transform CodeForge into a fully JSON-driven component architecture with atomic design patterns and comprehensive custom hooks. + +**Experience Qualities**: +1. **Declarative** - UI structure defined through JSON schemas rather than hardcoded components +2. **Composable** - Small, focused atomic components that combine elegantly into complex interfaces +3. **Maintainable** - Clear separation of concerns with reusable hooks extracting all business logic + +**Complexity Level**: Complex Application (advanced functionality with multiple views) +This is a comprehensive refactoring that introduces a sophisticated JSON rendering engine, breaks down large monolithic components into atomic pieces, and extracts complex logic into custom hooks for maximum reusability. + +## Essential Features + +### JSON Schema Engine +- **Functionality**: Interprets JSON declarations to render complete UI hierarchies +- **Purpose**: Enable rapid UI changes without code modifications +- **Trigger**: Page load with JSON schema file +- **Progression**: Load schema → Parse structure → Resolve bindings → Render components → Attach handlers +- **Success criteria**: Any page can be fully defined via JSON with data bindings and event handlers + +### Atomic Component Library +- **Functionality**: Break all components into atoms, molecules, and organisms following atomic design +- **Purpose**: Maximize reusability and maintainability +- **Trigger**: Component import in any context +- **Progression**: Import atom → Compose into molecule → Compose into organism → Render in page +- **Success criteria**: No component exceeds 150 LOC, all follow single responsibility principle + +### Custom Hooks Extraction +- **Functionality**: Extract all business logic, state management, and side effects into focused hooks +- **Purpose**: Separate concerns and enable logic reuse across components +- **Trigger**: Component mount or user interaction +- **Progression**: Hook initialization → State setup → Effect registration → Return interface → Component consumption +- **Success criteria**: Components are primarily presentational with logic delegated to hooks + +### Dynamic Data Binding +- **Functionality**: Support JavaScript expressions in JSON to bind data dynamically +- **Purpose**: Connect UI declarations to application state +- **Trigger**: Schema parsing +- **Progression**: Parse binding → Evaluate expression → Subscribe to changes → Update UI +- **Success criteria**: Any data property can be referenced in JSON using binding syntax + +### Event Handler Mapping +- **Functionality**: Map string function names in JSON to actual function implementations +- **Purpose**: Enable interactive UIs defined in JSON +- **Trigger**: User interaction +- **Progression**: Event fires → Look up handler → Execute function → Update state → Re-render +- **Success criteria**: All common interactions can be declared in JSON + +## Edge Case Handling + +- **Invalid JSON Schema**: Validate schemas on load, show helpful error messages with schema path +- **Missing Data Bindings**: Gracefully handle undefined data with fallback values +- **Unknown Components**: Log warning and render placeholder component with schema details +- **Circular References**: Detect and prevent infinite rendering loops +- **Performance Issues**: Implement memoization and virtualization for large lists +- **Type Safety**: Generate TypeScript types from JSON schemas where possible + +## Design Direction + +The design should feel like a sophisticated developer tool - clean, precise, and confidence-inspiring. A refined dark theme with vibrant accent colors that pop against the deep background. + +## Color Selection + +**Primary Color**: Deep purple (`oklch(0.55 0.18 280)`) - Commands attention for primary actions and conveys technical sophistication + +**Secondary Colors**: +- Card backgrounds: `oklch(0.16 0.02 260)` - Subtle depth without overwhelming +- Muted surfaces: `oklch(0.20 0.02 260)` - For secondary content areas + +**Accent Color**: Bright cyan (`oklch(0.75 0.15 195)`) - High-energy highlight for interactive elements and status indicators + +**Foreground/Background Pairings**: +- Background (`oklch(0.12 0.02 260)`): Foreground (`oklch(0.95 0.005 260)`) - Ratio 17.8:1 ✓ +- Card (`oklch(0.16 0.02 260)`): Card Foreground (`oklch(0.95 0.005 260)`) - Ratio 15.2:1 ✓ +- Primary (`oklch(0.55 0.18 280)`): Primary Foreground (`oklch(1 0 0)`) - Ratio 6.1:1 ✓ +- Accent (`oklch(0.75 0.15 195)`): Accent Foreground (`oklch(0.12 0.02 260)`) - Ratio 11.4:1 ✓ + +## Font Selection + +Use a distinctive technical aesthetic with modern developer-focused typefaces that communicate precision and clarity. + +**Typographic Hierarchy**: +- H1 (Page Title): Space Grotesk Bold/32px/tight letter spacing/-0.02em +- H2 (Section Header): Space Grotesk SemiBold/24px/normal/0em +- H3 (Card Title): Space Grotesk Medium/18px/normal/0em +- Body: Inter Regular/14px/relaxed/1.6 line height +- Code: JetBrains Mono Regular/13px/monospace/1.5 line height +- Caption: Inter Regular/12px/relaxed/tracking-wide + +## Animations + +Animations should emphasize the technical nature while remaining subtle. Use sharp, precise movements that reflect data flow and system operations. Key moments: schema loading (pulse effect), component mounting (fade-up), data updates (highlight flash), and navigation transitions (slide). + +## Component Selection + +**Components**: +- **Card** - Primary container for grouped content, heavy use throughout +- **Badge** - Status indicators for components, data sources, build status +- **Button** - All sizes from icon-only to full CTAs +- **Tabs** - Navigation between schemas, configuration views +- **Dialog** - Modals for editing schemas, previewing renders +- **ScrollArea** - Custom scrollbars for code editors and tree views +- **Select/Combobox** - Component type selection, binding target selection +- **Input/Textarea** - JSON editing, binding expressions +- **Accordion** - Collapsible sections in property panels +- **Separator** - Visual hierarchy in dense information displays + +**Customizations**: +- Custom JSON editor component with syntax highlighting +- Schema visualizer component showing component hierarchy +- Binding expression builder with autocomplete +- Component palette with drag-and-drop preview + +**States**: +- Buttons: Hover lifts slightly with shadow, active presses down, disabled grays out with reduced opacity +- Inputs: Focus shows accent ring, error shows destructive ring with shake animation +- Cards: Hover subtly brightens border, selected shows accent border + +**Icon Selection**: +- Code/FileCode for schemas and JSON files +- Tree/TreeStructure for component hierarchies +- Database for data bindings +- Lightning for actions and functions +- Cube for atomic components +- Stack for composed components +- Eye for preview modes +- Wrench for configuration + +**Spacing**: +- Base unit: 4px (Tailwind's spacing scale) +- Card padding: p-6 +- Section gaps: gap-6 +- Grid gaps: gap-4 +- Inline gaps: gap-2 +- Tight groups: gap-1 + +**Mobile**: +- Stack navigation tabs vertically in sheet +- Single column grid for stat cards +- Collapsible property panels +- Full-screen modals +- Bottom sheet for quick actions +- Touch-optimized hit areas (min 44px) diff --git a/index.html b/index.html index 9d0320d..d3a81a1 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - CodeForge - Low-Code App Builder + JSON-Driven UI - CodeForge diff --git a/src/App.demo.tsx b/src/App.demo.tsx new file mode 100644 index 0000000..bbd69ad --- /dev/null +++ b/src/App.demo.tsx @@ -0,0 +1,11 @@ +import { JSONUIShowcase } from '@/components/organisms/JSONUIShowcase' +import { Toaster } from '@/components/ui/sonner' + +export default function App() { + return ( +
+ + +
+ ) +} diff --git a/src/components/organisms/JSONUIShowcase.tsx b/src/components/organisms/JSONUIShowcase.tsx new file mode 100644 index 0000000..56c6b1d --- /dev/null +++ b/src/components/organisms/JSONUIShowcase.tsx @@ -0,0 +1,172 @@ +import { useState, useEffect } from 'react' +import { PageRenderer } from '@/lib/schema-renderer' +import { useSchemaLoader } from '@/hooks/ui' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Code, FileText, Database } from '@phosphor-icons/react' +import dashboardSchema from '@/config/schemas/json-ui-dashboard.json' + +interface JSONUIShowcaseProps { + files?: any[] + models?: any[] + components?: any[] +} + +export function JSONUIShowcase({ + files = [], + models = [], + components = [] +}: JSONUIShowcaseProps) { + const [showJSON, setShowJSON] = useState(false) + const {schema: loadedSchema, loading, error} = useSchemaLoader({ + schema: dashboardSchema as any + }) + + const data = { + files: files.length > 0 ? files : [ + { name: 'App.tsx', type: 'TypeScript' }, + { name: 'index.css', type: 'CSS' }, + { name: 'schema-renderer.tsx', type: 'TypeScript' }, + { name: 'use-data-binding.ts', type: 'Hook' }, + { name: 'dashboard.json', type: 'JSON' }, + ], + models: models.length > 0 ? models : [ + { name: 'User', fields: 5 }, + { name: 'Post', fields: 8 }, + { name: 'Comment', fields: 4 }, + ], + components: components.length > 0 ? components : [ + { name: 'Button', type: 'atom' }, + { name: 'Card', type: 'molecule' }, + { name: 'Dashboard', type: 'organism' }, + ], + } + + const functions = { + handleClick: () => { + console.log('Button clicked from JSON!') + }, + } + + if (loading) { + return ( +
+
+
+

Loading schema...

+
+
+ ) + } + + if (error) { + return ( +
+ + + Failed to load schema: {error.message} + + +
+ ) + } + + if (!loadedSchema) { + return ( +
+ + No schema loaded + +
+ ) + } + + return ( +
+
+ + + + + JSON-Driven UI System + + + Complete UI rendering from declarative JSON schemas with data bindings and event handlers + + + +
+ +
+ {showJSON && ( +
+                {JSON.stringify(loadedSchema, null, 2)}
+              
+ )} +
+
+ +
+ + + + + Schema-Driven + + + +

+ UI structure defined in JSON, making it easy to modify without code changes +

+
+
+ + + + + + Data Bindings + + + +

+ Dynamic expressions in JSON connect UI to application state seamlessly +

+
+
+ + + + + + Atomic Design + + + +

+ Modular components composed from atoms to organisms following best practices +

+
+
+
+ +
+

Rendered from JSON

+

+ The content below is entirely generated from the JSON schema above, demonstrating data bindings, + loops, and component composition. +

+ +
+
+
+ ) +} diff --git a/src/config/schemas/json-ui-dashboard.json b/src/config/schemas/json-ui-dashboard.json new file mode 100644 index 0000000..2667312 --- /dev/null +++ b/src/config/schemas/json-ui-dashboard.json @@ -0,0 +1,236 @@ +{ + "id": "json-ui-dashboard", + "title": "JSON-Driven Dashboard", + "description": "A complete UI page rendered from JSON declarations", + "layout": { + "type": "flex", + "direction": "column", + "gap": "6", + "className": "h-full overflow-auto p-6" + }, + "components": [ + { + "id": "page-header", + "type": "div", + "props": { + "className": "mb-6" + }, + "children": [ + { + "id": "page-title", + "type": "div", + "props": { + "className": "text-3xl font-bold mb-2" + }, + "children": [ + { + "id": "title-text", + "type": "div", + "props": { + "children": "JSON-Driven UI System" + } + } + ] + }, + { + "id": "page-description", + "type": "div", + "props": { + "className": "text-muted-foreground", + "children": "This entire page is rendered from JSON schemas with data bindings" + } + } + ] + }, + { + "id": "stat-cards-grid", + "type": "div", + "props": { + "className": "grid gap-4 md:grid-cols-2 lg:grid-cols-3" + }, + "children": [ + { + "id": "stat-card-1", + "type": "Card", + "children": [ + { + "id": "stat-card-1-header", + "type": "CardHeader", + "children": [ + { + "id": "stat-card-1-title", + "type": "CardTitle", + "props": { + "className": "flex items-center gap-2" + }, + "children": [ + { + "id": "stat-1-text", + "type": "div", + "props": { + "children": "{{files.length}} Files" + } + } + ] + } + ] + }, + { + "id": "stat-card-1-content", + "type": "CardContent", + "children": [ + { + "id": "stat-1-value", + "type": "div", + "props": { + "className": "text-2xl font-bold", + "children": "{{files.length}}" + } + } + ] + } + ] + }, + { + "id": "stat-card-2", + "type": "Card", + "children": [ + { + "id": "stat-card-2-header", + "type": "CardHeader", + "children": [ + { + "id": "stat-card-2-title", + "type": "CardTitle", + "props": { + "children": "{{models.length}} Models" + } + } + ] + }, + { + "id": "stat-card-2-content", + "type": "CardContent", + "children": [ + { + "id": "stat-2-value", + "type": "div", + "props": { + "className": "text-2xl font-bold", + "children": "{{models.length}}" + } + } + ] + } + ] + }, + { + "id": "stat-card-3", + "type": "Card", + "children": [ + { + "id": "stat-card-3-header", + "type": "CardHeader", + "children": [ + { + "id": "stat-card-3-title", + "type": "CardTitle", + "props": { + "children": "{{components.length}} Components" + } + } + ] + }, + { + "id": "stat-card-3-content", + "type": "CardContent", + "children": [ + { + "id": "stat-3-value", + "type": "div", + "props": { + "className": "text-2xl font-bold", + "children": "{{components.length}}" + } + } + ] + } + ] + } + ] + }, + { + "id": "files-section", + "type": "Card", + "props": { + "className": "mt-6" + }, + "children": [ + { + "id": "files-header", + "type": "CardHeader", + "children": [ + { + "id": "files-title", + "type": "CardTitle", + "props": { + "children": "Recent Files" + } + }, + { + "id": "files-description", + "type": "CardDescription", + "props": { + "children": "Files loaded from your project" + } + } + ] + }, + { + "id": "files-content", + "type": "CardContent", + "children": [ + { + "id": "files-list", + "type": "div", + "props": { + "className": "space-y-2" + }, + "repeat": { + "items": "files.slice(0, 5)", + "itemVar": "file" + }, + "children": [ + { + "id": "file-item", + "type": "div", + "props": { + "className": "flex items-center justify-between p-2 border border-border rounded-md hover:bg-accent/50 transition-colors" + }, + "children": [ + { + "id": "file-name", + "type": "div", + "props": { + "className": "font-mono text-sm", + "children": "{{file.name}}" + } + }, + { + "id": "file-badge", + "type": "Badge", + "props": { + "variant": "outline", + "children": "{{file.type}}" + } + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/src/hooks/ui/index.ts b/src/hooks/ui/index.ts index 1b26f79..6944a71 100644 --- a/src/hooks/ui/index.ts +++ b/src/hooks/ui/index.ts @@ -1,7 +1,4 @@ -export { useDialog } from './use-dialog' -export { useActionExecutor } from './use-action-executor' -export { useToggle } from './use-toggle' -export { useForm } from './use-form' -export type { UseDialogReturn } from './use-dialog' -export type { UseToggleOptions } from './use-toggle' -export type { UseFormOptions, FormField } from './use-form' +export { useDataBinding } from './use-data-binding' +export { useEventHandlers } from './use-event-handlers' +export { useSchemaLoader } from './use-schema-loader' +export { useComponentRegistry } from './use-component-registry' diff --git a/src/hooks/ui/use-component-registry.ts b/src/hooks/ui/use-component-registry.ts new file mode 100644 index 0000000..edae7ca --- /dev/null +++ b/src/hooks/ui/use-component-registry.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { Progress } from '@/components/ui/progress' +import * as Icons from '@phosphor-icons/react' + +interface ComponentRegistryOptions { + customComponents?: Record> +} + +export function useComponentRegistry({ customComponents = {} }: ComponentRegistryOptions = {}) { + const registry = useMemo( + () => ({ + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Button, + Badge, + Input, + Label, + Separator, + Progress, + ...customComponents, + }), + [customComponents] + ) + + const getComponent = (type: string): React.ComponentType | null => { + return registry[type as keyof typeof registry] || null + } + + const getIcon = (iconName: string, props?: any) => { + const IconComponent = (Icons as any)[iconName] + if (!IconComponent) return null + return + } + + return { + registry, + getComponent, + getIcon, + } +} diff --git a/src/hooks/ui/use-data-binding.ts b/src/hooks/ui/use-data-binding.ts new file mode 100644 index 0000000..3bb0157 --- /dev/null +++ b/src/hooks/ui/use-data-binding.ts @@ -0,0 +1,74 @@ +import { useCallback, useMemo } from 'react' + +interface UseDataBindingOptions { + data: Record + onError?: (error: Error, expression: string) => void +} + +export function useDataBinding({ data, onError }: UseDataBindingOptions) { + const resolveBinding = useCallback( + (expression: string, fallback?: any): any => { + if (!expression) return fallback + + try { + const keys = Object.keys(data) + const values = Object.values(data) + const func = new Function(...keys, `"use strict"; return (${expression})`) + return func(...values) + } catch (error) { + if (onError) { + onError(error as Error, expression) + } + console.warn(`Failed to resolve binding: ${expression}`, error) + return fallback + } + }, + [data, onError] + ) + + const resolveCondition = useCallback( + (condition: string): boolean => { + try { + const result = resolveBinding(condition, false) + return Boolean(result) + } catch { + return false + } + }, + [resolveBinding] + ) + + const resolveProps = useCallback( + (props: Record): Record => { + if (!props) return {} + + const resolved: Record = {} + + for (const [key, value] of Object.entries(props)) { + if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + const expression = value.slice(2, -2).trim() + resolved[key] = resolveBinding(expression) + } else if (typeof value === 'object' && value !== null && value.type === 'binding') { + resolved[key] = resolveBinding(value.expression, value.fallback) + } else { + resolved[key] = value + } + } + + return resolved + }, + [resolveBinding] + ) + + const context = useMemo( + () => ({ + resolveBinding, + resolveCondition, + resolveProps, + data, + }), + [resolveBinding, resolveCondition, resolveProps, data] + ) + + return context +} diff --git a/src/hooks/ui/use-event-handlers.ts b/src/hooks/ui/use-event-handlers.ts new file mode 100644 index 0000000..0387985 --- /dev/null +++ b/src/hooks/ui/use-event-handlers.ts @@ -0,0 +1,63 @@ +import { useCallback, useMemo } from 'react' + +interface UseEventHandlersOptions { + functions?: Record any> + onError?: (error: Error, functionName: string) => void +} + +export function useEventHandlers({ functions = {}, onError }: UseEventHandlersOptions) { + const createHandler = useCallback( + (functionName: string) => { + return (...args: any[]) => { + const handler = functions[functionName] + + if (!handler) { + const error = new Error(`Function "${functionName}" not found`) + if (onError) { + onError(error, functionName) + } else { + console.error(error) + } + return + } + + try { + return handler(...args) + } catch (error) { + if (onError) { + onError(error as Error, functionName) + } else { + console.error(`Error executing function "${functionName}":`, error) + } + } + } + }, + [functions, onError] + ) + + const resolveEvents = useCallback( + (events?: Record): Record any> => { + if (!events) return {} + + const resolved: Record any> = {} + + for (const [eventName, functionName] of Object.entries(events)) { + resolved[eventName] = createHandler(functionName) + } + + return resolved + }, + [createHandler] + ) + + const context = useMemo( + () => ({ + createHandler, + resolveEvents, + functions, + }), + [createHandler, resolveEvents, functions] + ) + + return context +} diff --git a/src/hooks/ui/use-schema-loader.ts b/src/hooks/ui/use-schema-loader.ts new file mode 100644 index 0000000..241ff15 --- /dev/null +++ b/src/hooks/ui/use-schema-loader.ts @@ -0,0 +1,60 @@ +import { useState, useCallback, useEffect } from 'react' +import { PageSchemaType } from '@/schemas/ui-schema' + +interface UseSchemaLoaderOptions { + schemaUrl?: string + schema?: PageSchemaType + onError?: (error: Error) => void +} + +export function useSchemaLoader({ schemaUrl, schema: initialSchema, onError }: UseSchemaLoaderOptions) { + const [schema, setSchema] = useState(initialSchema || null) + const [loading, setLoading] = useState(!!schemaUrl && !initialSchema) + const [error, setError] = useState(null) + + const loadSchema = useCallback( + async (url: string) => { + setLoading(true) + setError(null) + + try { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to load schema: ${response.statusText}`) + } + + const data = await response.json() + setSchema(data) + } catch (err) { + const error = err instanceof Error ? err : new Error('Unknown error loading schema') + setError(error) + if (onError) { + onError(error) + } + } finally { + setLoading(false) + } + }, + [onError] + ) + + useEffect(() => { + if (schemaUrl && !initialSchema) { + loadSchema(schemaUrl) + } + }, [schemaUrl, initialSchema, loadSchema]) + + const reloadSchema = useCallback(() => { + if (schemaUrl) { + loadSchema(schemaUrl) + } + }, [schemaUrl, loadSchema]) + + return { + schema, + loading, + error, + reloadSchema, + setSchema, + } +} diff --git a/src/index.css b/src/index.css index 8460534..577347a 100644 --- a/src/index.css +++ b/src/index.css @@ -31,35 +31,35 @@ } :root { - --background: oklch(0.14 0.02 250); - --foreground: oklch(0.93 0.005 250); + --background: oklch(0.12 0.02 260); + --foreground: oklch(0.95 0.005 260); - --card: oklch(0.18 0.02 250); - --card-foreground: oklch(0.93 0.005 250); + --card: oklch(0.16 0.02 260); + --card-foreground: oklch(0.95 0.005 260); - --popover: oklch(0.18 0.02 250); - --popover-foreground: oklch(0.93 0.005 250); + --popover: oklch(0.16 0.02 260); + --popover-foreground: oklch(0.95 0.005 260); - --primary: oklch(0.45 0.15 270); + --primary: oklch(0.55 0.18 280); --primary-foreground: oklch(1 0 0); - --secondary: oklch(0.35 0.02 250); - --secondary-foreground: oklch(0.93 0.005 250); + --secondary: oklch(0.20 0.02 260); + --secondary-foreground: oklch(0.95 0.005 260); - --muted: oklch(0.22 0.02 250); - --muted-foreground: oklch(0.65 0.01 250); + --muted: oklch(0.20 0.02 260); + --muted-foreground: oklch(0.65 0.01 260); - --accent: oklch(0.70 0.15 200); - --accent-foreground: oklch(0.14 0.02 250); + --accent: oklch(0.75 0.15 195); + --accent-foreground: oklch(0.12 0.02 260); --destructive: oklch(0.55 0.22 25); --destructive-foreground: oklch(1 0 0); - --border: oklch(0.28 0.02 250); - --input: oklch(0.28 0.02 250); - --ring: oklch(0.70 0.15 200); + --border: oklch(0.22 0.02 260); + --input: oklch(0.24 0.02 260); + --ring: oklch(0.75 0.15 195); - --radius: 0.5rem; + --radius: 0.625rem; } @theme { diff --git a/src/lib/schema-renderer.tsx b/src/lib/schema-renderer.tsx new file mode 100644 index 0000000..46de07c --- /dev/null +++ b/src/lib/schema-renderer.tsx @@ -0,0 +1,147 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' +import { Component as ComponentSchema, Layout } from '@/schemas/ui-schema' +import { useDataBinding, useEventHandlers, useComponentRegistry } from '@/hooks/ui' + +interface SchemaRendererProps { + schema: ComponentSchema + data: Record + functions?: Record any> +} + +interface LayoutRendererProps { + layout: Layout + children: ReactNode +} + +function LayoutRenderer({ layout, children }: LayoutRendererProps) { + const getLayoutClasses = () => { + const classes: string[] = [] + + if (layout.type === 'flex') { + classes.push('flex') + if (layout.direction) { + classes.push(layout.direction === 'column' ? 'flex-col' : 'flex-row') + } + } else if (layout.type === 'grid') { + classes.push('grid') + if (layout.columns) { + const { base = 1, sm, md, lg, xl } = layout.columns + classes.push(`grid-cols-${base}`) + if (sm) classes.push(`sm:grid-cols-${sm}`) + if (md) classes.push(`md:grid-cols-${md}`) + if (lg) classes.push(`lg:grid-cols-${lg}`) + if (xl) classes.push(`xl:grid-cols-${xl}`) + } + } else if (layout.type === 'stack') { + classes.push('flex flex-col') + } + + if (layout.gap) { + classes.push(`gap-${layout.gap}`) + } + + if (layout.className) { + classes.push(layout.className) + } + + return cn(...classes) + } + + return
{children}
+} + +export function SchemaRenderer({ schema, data, functions = {} }: SchemaRendererProps) { + const { resolveCondition, resolveProps, resolveBinding } = useDataBinding({ data }) + const { resolveEvents } = useEventHandlers({ functions }) + const { getComponent, getIcon } = useComponentRegistry() + + if (schema.condition && !resolveCondition(schema.condition)) { + return null + } + + if (schema.repeat) { + const items = resolveBinding(schema.repeat.items, []) as any[] + return ( + <> + {items.map((item, index) => { + const itemData = { + ...data, + [schema.repeat!.itemVar]: item, + ...(schema.repeat!.indexVar ? { [schema.repeat!.indexVar]: index } : {}), + } + return ( + + ) + })} + + ) + } + + const Component = getComponent(schema.type) + + if (!Component) { + console.warn(`Component type "${schema.type}" not found in registry`) + return ( +
+

+ Unknown component: {schema.type} +

+
+ ) + } + + const props = resolveProps(schema.props || {}) + const events = resolveEvents(schema.events) + const combinedProps = { ...props, ...events } + + if (schema.binding) { + const iconName = resolveBinding(schema.binding) + if (iconName && schema.type === 'Icon') { + return getIcon(iconName, props) + } + } + + const children = schema.children?.map((child, index) => ( + + )) + + return {children} +} + +interface PageRendererProps { + schema: { + id: string + title?: string + description?: string + layout: Layout + components: ComponentSchema[] + } + data: Record + functions?: Record any> +} + +export function PageRenderer({ schema, data, functions = {} }: PageRendererProps) { + return ( + + {schema.components.map((component) => ( + + ))} + + ) +} diff --git a/src/schemas/ui-schema.ts b/src/schemas/ui-schema.ts new file mode 100644 index 0000000..f3f9a21 --- /dev/null +++ b/src/schemas/ui-schema.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' + +export const BindingSchema = z.object({ + type: z.literal('binding'), + expression: z.string(), + fallback: z.any().optional(), +}) + +export const ComponentSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string(), + type: z.string(), + props: z.record(z.any()).optional(), + children: z.array(ComponentSchema).optional(), + binding: z.string().optional(), + condition: z.string().optional(), + repeat: z + .object({ + items: z.string(), + itemVar: z.string(), + indexVar: z.string().optional(), + }) + .optional(), + events: z.record(z.string()).optional(), + }) +) + +export const LayoutSchema = z.object({ + type: z.enum(['flex', 'grid', 'stack', 'custom']), + direction: z.enum(['row', 'column']).optional(), + gap: z.string().optional(), + columns: z + .object({ + base: z.number().optional(), + sm: z.number().optional(), + md: z.number().optional(), + lg: z.number().optional(), + xl: z.number().optional(), + }) + .optional(), + className: z.string().optional(), +}) + +export const PageSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + layout: LayoutSchema, + components: z.array(ComponentSchema), + dataBindings: z.array(z.string()).optional(), + functions: z.record(z.string()).optional(), +}) + +export type Binding = z.infer +export type Component = z.infer +export type Layout = z.infer +export type PageSchemaType = z.infer