mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Generated by Spark: Load more of UI from JSON declarations and break up large components into atomic and create hooks as needed
This commit is contained in:
144
JSON_UI_REFACTOR_PRD.md
Normal file
144
JSON_UI_REFACTOR_PRD.md
Normal file
@@ -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)
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CodeForge - Low-Code App Builder</title>
|
||||
<title>JSON-Driven UI - CodeForge</title>
|
||||
|
||||
<meta name="description" content="Build Next.js, Material UI, and Flask applications with visual designers and AI assistance">
|
||||
<meta name="theme-color" content="#7c3aed">
|
||||
|
||||
11
src/App.demo.tsx
Normal file
11
src/App.demo.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { JSONUIShowcase } from '@/components/organisms/JSONUIShowcase'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="h-screen bg-background text-foreground">
|
||||
<JSONUIShowcase />
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
src/components/organisms/JSONUIShowcase.tsx
Normal file
172
src/components/organisms/JSONUIShowcase.tsx
Normal file
@@ -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 (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Loading schema...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full p-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Failed to load schema: {error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loadedSchema) {
|
||||
return (
|
||||
<div className="h-full p-6">
|
||||
<Alert>
|
||||
<AlertDescription>No schema loaded</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="bg-gradient-to-br from-primary/10 to-accent/10 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Code size={24} weight="duotone" className="text-primary" />
|
||||
JSON-Driven UI System
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Complete UI rendering from declarative JSON schemas with data bindings and event handlers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setShowJSON(!showJSON)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<FileText size={16} weight="duotone" className="mr-2" />
|
||||
{showJSON ? 'Hide' : 'Show'} JSON Schema
|
||||
</Button>
|
||||
</div>
|
||||
{showJSON && (
|
||||
<pre className="bg-secondary/50 p-4 rounded-md overflow-auto text-xs font-mono max-h-96">
|
||||
{JSON.stringify(loadedSchema, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Database size={20} weight="duotone" className="text-blue-500" />
|
||||
Schema-Driven
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
UI structure defined in JSON, making it easy to modify without code changes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Code size={20} weight="duotone" className="text-green-500" />
|
||||
Data Bindings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dynamic expressions in JSON connect UI to application state seamlessly
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileText size={20} weight="duotone" className="text-purple-500" />
|
||||
Atomic Design
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Modular components composed from atoms to organisms following best practices
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-6">
|
||||
<h2 className="text-2xl font-bold mb-4">Rendered from JSON</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
The content below is entirely generated from the JSON schema above, demonstrating data bindings,
|
||||
loops, and component composition.
|
||||
</p>
|
||||
<PageRenderer schema={loadedSchema} data={data} functions={functions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
236
src/config/schemas/json-ui-dashboard.json
Normal file
236
src/config/schemas/json-ui-dashboard.json
Normal file
@@ -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}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
49
src/hooks/ui/use-component-registry.ts
Normal file
49
src/hooks/ui/use-component-registry.ts
Normal file
@@ -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<string, React.ComponentType<any>>
|
||||
}
|
||||
|
||||
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<any> | 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 <IconComponent size={24} weight="duotone" {...props} />
|
||||
}
|
||||
|
||||
return {
|
||||
registry,
|
||||
getComponent,
|
||||
getIcon,
|
||||
}
|
||||
}
|
||||
74
src/hooks/ui/use-data-binding.ts
Normal file
74
src/hooks/ui/use-data-binding.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
interface UseDataBindingOptions {
|
||||
data: Record<string, any>
|
||||
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<string, any>): Record<string, any> => {
|
||||
if (!props) return {}
|
||||
|
||||
const resolved: Record<string, any> = {}
|
||||
|
||||
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
|
||||
}
|
||||
63
src/hooks/ui/use-event-handlers.ts
Normal file
63
src/hooks/ui/use-event-handlers.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
interface UseEventHandlersOptions {
|
||||
functions?: Record<string, (...args: any[]) => 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<string, string>): Record<string, (...args: any[]) => any> => {
|
||||
if (!events) return {}
|
||||
|
||||
const resolved: Record<string, (...args: any[]) => 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
|
||||
}
|
||||
60
src/hooks/ui/use-schema-loader.ts
Normal file
60
src/hooks/ui/use-schema-loader.ts
Normal file
@@ -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<PageSchemaType | null>(initialSchema || null)
|
||||
const [loading, setLoading] = useState(!!schemaUrl && !initialSchema)
|
||||
const [error, setError] = useState<Error | null>(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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
147
src/lib/schema-renderer.tsx
Normal file
147
src/lib/schema-renderer.tsx
Normal file
@@ -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<string, any>
|
||||
functions?: Record<string, (...args: any[]) => 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 <div className={getLayoutClasses()}>{children}</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<SchemaRenderer
|
||||
key={index}
|
||||
schema={{ ...schema, repeat: undefined }}
|
||||
data={itemData}
|
||||
functions={functions}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Component = getComponent(schema.type)
|
||||
|
||||
if (!Component) {
|
||||
console.warn(`Component type "${schema.type}" not found in registry`)
|
||||
return (
|
||||
<div className="border-2 border-dashed border-destructive p-4 rounded-md">
|
||||
<p className="text-destructive font-mono text-sm">
|
||||
Unknown component: {schema.type}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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) => (
|
||||
<SchemaRenderer
|
||||
key={child.id || index}
|
||||
schema={child}
|
||||
data={data}
|
||||
functions={functions}
|
||||
/>
|
||||
))
|
||||
|
||||
return <Component {...combinedProps}>{children}</Component>
|
||||
}
|
||||
|
||||
interface PageRendererProps {
|
||||
schema: {
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
layout: Layout
|
||||
components: ComponentSchema[]
|
||||
}
|
||||
data: Record<string, any>
|
||||
functions?: Record<string, (...args: any[]) => any>
|
||||
}
|
||||
|
||||
export function PageRenderer({ schema, data, functions = {} }: PageRendererProps) {
|
||||
return (
|
||||
<LayoutRenderer layout={schema.layout}>
|
||||
{schema.components.map((component) => (
|
||||
<SchemaRenderer
|
||||
key={component.id}
|
||||
schema={component}
|
||||
data={data}
|
||||
functions={functions}
|
||||
/>
|
||||
))}
|
||||
</LayoutRenderer>
|
||||
)
|
||||
}
|
||||
57
src/schemas/ui-schema.ts
Normal file
57
src/schemas/ui-schema.ts
Normal file
@@ -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<any> = 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<typeof BindingSchema>
|
||||
export type Component = z.infer<typeof ComponentSchema>
|
||||
export type Layout = z.infer<typeof LayoutSchema>
|
||||
export type PageSchemaType = z.infer<typeof PageSchema>
|
||||
Reference in New Issue
Block a user