mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Generated by Spark: Build a visual schema editor to create JSON UI configs through drag-and-drop
This commit is contained in:
11
PRD.md
11
PRD.md
@@ -1,6 +1,6 @@
|
||||
# JSON-Driven UI Architecture Enhancement
|
||||
|
||||
Build a comprehensive JSON-driven UI system that allows building entire user interfaces from declarative JSON schemas, breaking down complex components into atomic pieces, and extracting reusable logic into custom hooks for maximum maintainability and rapid development.
|
||||
Build a comprehensive JSON-driven UI system that allows building entire user interfaces from declarative JSON schemas, including a visual drag-and-drop schema editor for creating JSON UI configs, breaking down complex components into atomic pieces, and extracting reusable logic into custom hooks for maximum maintainability and rapid development.
|
||||
|
||||
**Experience Qualities**:
|
||||
1. **Modular** - Every component under 150 LOC, highly composable and reusable
|
||||
@@ -9,10 +9,17 @@ Build a comprehensive JSON-driven UI system that allows building entire user int
|
||||
|
||||
**Complexity Level**: Complex Application (advanced functionality with multiple views)
|
||||
|
||||
This is an advanced system that interprets JSON schemas, manages state across multiple data sources, executes actions dynamically, and renders complex component hierarchies - requiring sophisticated architecture with component registries, action executors, and data source managers.
|
||||
This is an advanced system that interprets JSON schemas, manages state across multiple data sources, executes actions dynamically, renders complex component hierarchies, and provides a visual editor for creating schemas through drag-and-drop - requiring sophisticated architecture with component registries, action executors, data source managers, and interactive canvas rendering.
|
||||
|
||||
## Essential Features
|
||||
|
||||
### Visual Schema Editor
|
||||
- **Functionality**: Drag-and-drop interface for building JSON UI schemas with real-time preview
|
||||
- **Purpose**: Enable non-technical users to create complex UIs without writing JSON
|
||||
- **Trigger**: User opens the schema editor page
|
||||
- **Progression**: Select component from palette → Drag to canvas → Drop at position → Configure properties → Preview result → Export JSON
|
||||
- **Success criteria**: Users can create complete page schemas visually, with property editing, component tree view, and JSON export
|
||||
|
||||
### JSON Schema Parser
|
||||
- **Functionality**: Parse and validate JSON UI schemas with full TypeScript type safety
|
||||
- **Purpose**: Enable building UIs from configuration rather than code
|
||||
|
||||
@@ -24,6 +24,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = {
|
||||
sassStyles: true,
|
||||
faviconDesigner: true,
|
||||
ideaCloud: true,
|
||||
schemaEditor: true,
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
258
src/components/SchemaEditorPage.tsx
Normal file
258
src/components/SchemaEditorPage.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useSchemaEditor } from '@/hooks/ui/use-schema-editor'
|
||||
import { useDragDrop } from '@/hooks/ui/use-drag-drop'
|
||||
import { useJsonExport } from '@/hooks/ui/use-json-export'
|
||||
import { ComponentPalette } from '@/components/molecules/ComponentPalette'
|
||||
import { ComponentTree } from '@/components/molecules/ComponentTree'
|
||||
import { PropertyEditor } from '@/components/molecules/PropertyEditor'
|
||||
import { CanvasRenderer } from '@/components/molecules/CanvasRenderer'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ComponentDefinition } from '@/lib/component-definitions'
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import {
|
||||
Download,
|
||||
Upload,
|
||||
Play,
|
||||
Trash,
|
||||
Copy,
|
||||
Code,
|
||||
} from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageSchema } from '@/types/json-ui'
|
||||
|
||||
export function SchemaEditorPage() {
|
||||
const {
|
||||
components,
|
||||
selectedId,
|
||||
hoveredId,
|
||||
setSelectedId,
|
||||
setHoveredId,
|
||||
findComponentById,
|
||||
addComponent,
|
||||
updateComponent,
|
||||
deleteComponent,
|
||||
moveComponent,
|
||||
clearAll,
|
||||
} = useSchemaEditor()
|
||||
|
||||
const {
|
||||
draggedItem,
|
||||
dropTarget,
|
||||
dropPosition,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
} = useDragDrop()
|
||||
|
||||
const { exportToJson, copyToClipboard, importFromJson } = useJsonExport()
|
||||
|
||||
const handleComponentDragStart = (component: ComponentDefinition, e: React.DragEvent) => {
|
||||
const newComponent: UIComponent = {
|
||||
id: `${component.type.toLowerCase()}-${Date.now()}`,
|
||||
type: component.type,
|
||||
props: component.defaultProps || {},
|
||||
children: component.canHaveChildren ? [] : undefined,
|
||||
}
|
||||
|
||||
handleDragStart({
|
||||
id: 'new',
|
||||
type: 'component',
|
||||
data: newComponent,
|
||||
}, e)
|
||||
}
|
||||
|
||||
const handleComponentTreeDragStart = (id: string, e: React.DragEvent) => {
|
||||
handleDragStart({
|
||||
id,
|
||||
type: 'existing',
|
||||
data: id,
|
||||
}, e)
|
||||
}
|
||||
|
||||
const handleCanvasDrop = (targetId: string, e: React.DragEvent) => {
|
||||
if (!draggedItem) return
|
||||
|
||||
const position = dropPosition || 'inside'
|
||||
|
||||
if (draggedItem.type === 'component') {
|
||||
addComponent(draggedItem.data, targetId === 'root' ? undefined : targetId, position)
|
||||
} else if (draggedItem.type === 'existing') {
|
||||
if (draggedItem.data !== targetId) {
|
||||
moveComponent(draggedItem.data, targetId, position)
|
||||
}
|
||||
}
|
||||
|
||||
handleDrop(targetId, e)
|
||||
}
|
||||
|
||||
const handleExportJson = () => {
|
||||
const schema: PageSchema = {
|
||||
id: 'custom-page',
|
||||
name: 'Custom Page',
|
||||
layout: { type: 'single' },
|
||||
dataSources: [],
|
||||
components,
|
||||
}
|
||||
exportToJson(schema, 'schema.json')
|
||||
}
|
||||
|
||||
const handleCopyJson = () => {
|
||||
const schema: PageSchema = {
|
||||
id: 'custom-page',
|
||||
name: 'Custom Page',
|
||||
layout: { type: 'single' },
|
||||
dataSources: [],
|
||||
components,
|
||||
}
|
||||
copyToClipboard(schema)
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json'
|
||||
input.onchange = (e: any) => {
|
||||
const file = e.target?.files?.[0]
|
||||
if (file) {
|
||||
importFromJson(file, (data) => {
|
||||
if (data.components) {
|
||||
clearAll()
|
||||
data.components.forEach((comp: UIComponent) => {
|
||||
addComponent(comp)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
const handlePreview = () => {
|
||||
toast.info('Preview mode coming soon')
|
||||
}
|
||||
|
||||
const selectedComponent = selectedId ? findComponentById(selectedId) : null
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div className="border-b border-border px-6 py-3 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
Schema Editor
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Build JSON UI schemas with drag-and-drop
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyJson}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportJson}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreview}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash className="w-4 h-4 mr-2" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="w-64 border-r border-border bg-card">
|
||||
<ComponentPalette onDragStart={handleComponentDragStart} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<CanvasRenderer
|
||||
components={components}
|
||||
selectedId={selectedId}
|
||||
hoveredId={hoveredId}
|
||||
draggedOverId={dropTarget}
|
||||
dropPosition={dropPosition}
|
||||
onSelect={setSelectedId}
|
||||
onHover={setHoveredId}
|
||||
onHoverEnd={() => setHoveredId(null)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-80 border-l border-border bg-card flex flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ComponentTree
|
||||
components={components}
|
||||
selectedId={selectedId}
|
||||
hoveredId={hoveredId}
|
||||
draggedOverId={dropTarget}
|
||||
dropPosition={dropPosition}
|
||||
onSelect={setSelectedId}
|
||||
onHover={setHoveredId}
|
||||
onHoverEnd={() => setHoveredId(null)}
|
||||
onDragStart={handleComponentTreeDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PropertyEditor
|
||||
component={selectedComponent}
|
||||
onUpdate={(updates) => {
|
||||
if (selectedId) {
|
||||
updateComponent(selectedId, updates)
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (selectedId) {
|
||||
deleteComponent(selectedId)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/components/atoms/ComponentPaletteItem.tsx
Normal file
31
src/components/atoms/ComponentPaletteItem.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ComponentDefinition } from '@/lib/component-definitions'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import * as Icons from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ComponentPaletteItemProps {
|
||||
component: ComponentDefinition
|
||||
onDragStart: (component: ComponentDefinition, e: React.DragEvent) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ComponentPaletteItem({ component, onDragStart, className }: ComponentPaletteItemProps) {
|
||||
const IconComponent = (Icons as any)[component.icon] || Icons.Cube
|
||||
|
||||
return (
|
||||
<Card
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(component, e)}
|
||||
className={cn(
|
||||
'p-3 cursor-move hover:bg-accent/50 hover:border-accent transition-all',
|
||||
'flex flex-col items-center gap-2 text-center',
|
||||
'hover:scale-105 active:scale-95',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<IconComponent className="w-6 h-6 text-primary" weight="duotone" />
|
||||
<span className="text-xs font-medium text-foreground">{component.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{component.type}</span>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
84
src/components/atoms/ComponentTreeNode.tsx
Normal file
84
src/components/atoms/ComponentTreeNode.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { getComponentDef } from '@/lib/component-definitions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import * as Icons from '@phosphor-icons/react'
|
||||
|
||||
interface ComponentTreeNodeProps {
|
||||
component: UIComponent
|
||||
isSelected: boolean
|
||||
isHovered: boolean
|
||||
isDraggedOver: boolean
|
||||
dropPosition: 'before' | 'after' | 'inside' | null
|
||||
onSelect: () => void
|
||||
onHover: () => void
|
||||
onHoverEnd: () => void
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
depth?: number
|
||||
}
|
||||
|
||||
export function ComponentTreeNode({
|
||||
component,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isDraggedOver,
|
||||
dropPosition,
|
||||
onSelect,
|
||||
onHover,
|
||||
onHoverEnd,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
depth = 0,
|
||||
}: ComponentTreeNodeProps) {
|
||||
const def = getComponentDef(component.type)
|
||||
const IconComponent = def ? (Icons as any)[def.icon] || Icons.Cube : Icons.Cube
|
||||
const hasChildren = Array.isArray(component.children) && component.children.length > 0
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{isDraggedOver && dropPosition === 'before' && (
|
||||
<div className="absolute -top-0.5 left-0 right-0 h-0.5 bg-accent" />
|
||||
)}
|
||||
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect()
|
||||
}}
|
||||
onMouseEnter={onHover}
|
||||
onMouseLeave={onHoverEnd}
|
||||
style={{ paddingLeft: `${depth * 16}px` }}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer',
|
||||
'hover:bg-muted/50 transition-colors',
|
||||
'border-l-2 border-transparent',
|
||||
isSelected && 'bg-accent/20 border-l-accent',
|
||||
isHovered && !isSelected && 'bg-muted/30',
|
||||
isDraggedOver && dropPosition === 'inside' && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<Icons.CaretDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="w-3" />
|
||||
)}
|
||||
<IconComponent className="w-4 h-4 text-primary" weight="duotone" />
|
||||
<span className="flex-1 text-foreground truncate">{def?.label || component.type}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{component.id}</span>
|
||||
</div>
|
||||
|
||||
{isDraggedOver && dropPosition === 'after' && (
|
||||
<div className="absolute -bottom-0.5 left-0 right-0 h-0.5 bg-accent" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
src/components/atoms/PropertyEditorField.tsx
Normal file
87
src/components/atoms/PropertyEditorField.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
interface PropertyEditorFieldProps {
|
||||
label: string
|
||||
name: string
|
||||
value: any
|
||||
type?: 'text' | 'number' | 'boolean' | 'select' | 'textarea'
|
||||
options?: Array<{ label: string; value: string }>
|
||||
onChange: (name: string, value: any) => void
|
||||
}
|
||||
|
||||
export function PropertyEditorField({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
type = 'text',
|
||||
options,
|
||||
onChange,
|
||||
}: PropertyEditorFieldProps) {
|
||||
const renderField = () => {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
checked={value || false}
|
||||
onCheckedChange={(checked) => onChange(name, checked)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select value={value || ''} onValueChange={(val) => onChange(name, val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value || 0}
|
||||
onChange={(e) => onChange(name, Number(e.target.value))}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(name, e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(name, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
{renderField()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
src/components/molecules/CanvasRenderer.tsx
Normal file
133
src/components/molecules/CanvasRenderer.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { getUIComponent } from '@/lib/json-ui/component-registry'
|
||||
import { getComponentDef } from '@/lib/component-definitions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createElement, ReactNode } from 'react'
|
||||
|
||||
interface CanvasRendererProps {
|
||||
components: UIComponent[]
|
||||
selectedId: string | null
|
||||
hoveredId: string | null
|
||||
draggedOverId: string | null
|
||||
dropPosition: 'before' | 'after' | 'inside' | null
|
||||
onSelect: (id: string) => void
|
||||
onHover: (id: string) => void
|
||||
onHoverEnd: () => void
|
||||
onDragOver: (id: string, e: React.DragEvent) => void
|
||||
onDragLeave: (e: React.DragEvent) => void
|
||||
onDrop: (id: string, e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
export function CanvasRenderer({
|
||||
components,
|
||||
selectedId,
|
||||
hoveredId,
|
||||
draggedOverId,
|
||||
dropPosition,
|
||||
onSelect,
|
||||
onHover,
|
||||
onHoverEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
}: CanvasRendererProps) {
|
||||
const renderComponent = (comp: UIComponent): ReactNode => {
|
||||
const Component = getUIComponent(comp.type)
|
||||
const def = getComponentDef(comp.type)
|
||||
|
||||
if (!Component) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isSelected = selectedId === comp.id
|
||||
const isHovered = hoveredId === comp.id
|
||||
const isDraggedOver = draggedOverId === comp.id
|
||||
|
||||
const wrapperClasses = cn(
|
||||
'relative transition-all',
|
||||
isSelected && 'ring-2 ring-accent ring-offset-2 ring-offset-background',
|
||||
isHovered && !isSelected && 'ring-1 ring-primary/50',
|
||||
isDraggedOver && dropPosition === 'inside' && 'ring-2 ring-primary ring-offset-2'
|
||||
)
|
||||
|
||||
const props = {
|
||||
...comp.props,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onSelect(comp.id)
|
||||
},
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onHover(comp.id)
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onHoverEnd()
|
||||
},
|
||||
onDragOver: (e: React.DragEvent) => {
|
||||
e.stopPropagation()
|
||||
onDragOver(comp.id, e)
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent) => {
|
||||
onDragLeave(e)
|
||||
},
|
||||
onDrop: (e: React.DragEvent) => {
|
||||
e.stopPropagation()
|
||||
onDrop(comp.id, e)
|
||||
},
|
||||
}
|
||||
|
||||
let children: ReactNode = null
|
||||
if (Array.isArray(comp.children)) {
|
||||
children = comp.children.map(renderComponent)
|
||||
} else if (typeof comp.children === 'string') {
|
||||
children = comp.children
|
||||
} else if (comp.props?.children) {
|
||||
children = comp.props.children
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={comp.id} className={wrapperClasses}>
|
||||
{isDraggedOver && dropPosition === 'before' && (
|
||||
<div className="absolute -top-1 left-0 right-0 h-1 bg-accent rounded" />
|
||||
)}
|
||||
{createElement(Component, props, children)}
|
||||
{isDraggedOver && dropPosition === 'after' && (
|
||||
<div className="absolute -bottom-1 left-0 right-0 h-1 bg-accent rounded" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto p-8 bg-gradient-to-br from-background via-muted/10 to-muted/20">
|
||||
<div
|
||||
className="min-h-full bg-card border border-border rounded-lg shadow-lg p-8"
|
||||
onDragOver={(e) => {
|
||||
if (components.length === 0) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (components.length === 0) {
|
||||
e.preventDefault()
|
||||
onDrop('root', e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{components.length === 0 ? (
|
||||
<div className="h-96 flex items-center justify-center text-muted-foreground border-2 border-dashed border-border rounded-lg">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium mb-2">Drop components here</p>
|
||||
<p className="text-sm">Drag components from the palette to start building</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{components.map(renderComponent)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
src/components/molecules/ComponentPalette.tsx
Normal file
54
src/components/molecules/ComponentPalette.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ComponentDefinition, getCategoryComponents } from '@/lib/component-definitions'
|
||||
import { ComponentPaletteItem } from '@/components/atoms/ComponentPaletteItem'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
interface ComponentPaletteProps {
|
||||
onDragStart: (component: ComponentDefinition, e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
export function ComponentPalette({ onDragStart }: ComponentPaletteProps) {
|
||||
const categories = [
|
||||
{ id: 'layout', label: 'Layout' },
|
||||
{ id: 'input', label: 'Input' },
|
||||
{ id: 'display', label: 'Display' },
|
||||
{ id: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Components</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Drag components to the canvas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="layout" className="flex-1 flex flex-col">
|
||||
<TabsList className="w-full justify-start px-4 pt-2">
|
||||
{categories.map((cat) => (
|
||||
<TabsTrigger key={cat.id} value={cat.id} className="text-xs">
|
||||
{cat.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{categories.map((cat) => (
|
||||
<TabsContent key={cat.id} value={cat.id} className="flex-1 m-0 mt-2">
|
||||
<ScrollArea className="h-full px-4 pb-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{getCategoryComponents(cat.id).map((comp) => (
|
||||
<ComponentPaletteItem
|
||||
key={comp.type}
|
||||
component={comp}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
src/components/molecules/ComponentTree.tsx
Normal file
83
src/components/molecules/ComponentTree.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { ComponentTreeNode } from '@/components/atoms/ComponentTreeNode'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tree, Plus } from '@phosphor-icons/react'
|
||||
|
||||
interface ComponentTreeProps {
|
||||
components: UIComponent[]
|
||||
selectedId: string | null
|
||||
hoveredId: string | null
|
||||
draggedOverId: string | null
|
||||
dropPosition: 'before' | 'after' | 'inside' | null
|
||||
onSelect: (id: string) => void
|
||||
onHover: (id: string) => void
|
||||
onHoverEnd: () => void
|
||||
onDragStart: (id: string, e: React.DragEvent) => void
|
||||
onDragOver: (id: string, e: React.DragEvent) => void
|
||||
onDragLeave: (e: React.DragEvent) => void
|
||||
onDrop: (id: string, e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
export function ComponentTree({
|
||||
components,
|
||||
selectedId,
|
||||
hoveredId,
|
||||
draggedOverId,
|
||||
dropPosition,
|
||||
onSelect,
|
||||
onHover,
|
||||
onHoverEnd,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
}: ComponentTreeProps) {
|
||||
const renderTree = (comps: UIComponent[], depth = 0) => {
|
||||
return comps.map((comp) => (
|
||||
<div key={comp.id}>
|
||||
<ComponentTreeNode
|
||||
component={comp}
|
||||
isSelected={selectedId === comp.id}
|
||||
isHovered={hoveredId === comp.id}
|
||||
isDraggedOver={draggedOverId === comp.id}
|
||||
dropPosition={draggedOverId === comp.id ? dropPosition : null}
|
||||
onSelect={() => onSelect(comp.id)}
|
||||
onHover={() => onHover(comp.id)}
|
||||
onHoverEnd={onHoverEnd}
|
||||
onDragStart={(e) => onDragStart(comp.id, e)}
|
||||
onDragOver={(e) => onDragOver(comp.id, e)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(comp.id, e)}
|
||||
depth={depth}
|
||||
/>
|
||||
{Array.isArray(comp.children) && comp.children.length > 0 && (
|
||||
<div>{renderTree(comp.children, depth + 1)}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tree className="w-5 h-5 text-primary" weight="duotone" />
|
||||
<h2 className="text-lg font-semibold">Component Tree</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
{components.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Tree className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">No components yet</p>
|
||||
<p className="text-xs mt-1">Drag components from the palette</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">{renderTree(components)}</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
src/components/molecules/PropertyEditor.tsx
Normal file
165
src/components/molecules/PropertyEditor.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { PropertyEditorField } from '@/components/atoms/PropertyEditorField'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Sliders, Trash, Code } from '@phosphor-icons/react'
|
||||
import { getComponentDef } from '@/lib/component-definitions'
|
||||
|
||||
interface PropertyEditorProps {
|
||||
component: UIComponent | null
|
||||
onUpdate: (updates: Partial<UIComponent>) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function PropertyEditor({ component, onUpdate, onDelete }: PropertyEditorProps) {
|
||||
if (!component) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground p-8 text-center">
|
||||
<Sliders className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-sm">No component selected</p>
|
||||
<p className="text-xs mt-1">Select a component to edit its properties</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const def = getComponentDef(component.type)
|
||||
|
||||
const handlePropChange = (key: string, value: any) => {
|
||||
onUpdate({
|
||||
props: {
|
||||
...component.props,
|
||||
[key]: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const commonProps = [
|
||||
{ name: 'className', label: 'CSS Classes', type: 'text' as const },
|
||||
]
|
||||
|
||||
const typeSpecificProps: Record<string, Array<{ name: string; label: string; type: any; options?: any[] }>> = {
|
||||
Button: [
|
||||
{
|
||||
name: 'variant',
|
||||
label: 'Variant',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Default', value: 'default' },
|
||||
{ label: 'Destructive', value: 'destructive' },
|
||||
{ label: 'Outline', value: 'outline' },
|
||||
{ label: 'Secondary', value: 'secondary' },
|
||||
{ label: 'Ghost', value: 'ghost' },
|
||||
{ label: 'Link', value: 'link' },
|
||||
]
|
||||
},
|
||||
{ name: 'children', label: 'Text', type: 'text' },
|
||||
{ name: 'disabled', label: 'Disabled', type: 'boolean' },
|
||||
],
|
||||
Input: [
|
||||
{ name: 'placeholder', label: 'Placeholder', type: 'text' },
|
||||
{ name: 'type', label: 'Type', type: 'select', options: [
|
||||
{ label: 'Text', value: 'text' },
|
||||
{ label: 'Password', value: 'password' },
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: 'Number', value: 'number' },
|
||||
]},
|
||||
{ name: 'disabled', label: 'Disabled', type: 'boolean' },
|
||||
],
|
||||
Heading: [
|
||||
{ name: 'level', label: 'Level', type: 'select', options: [
|
||||
{ label: 'H1', value: '1' },
|
||||
{ label: 'H2', value: '2' },
|
||||
{ label: 'H3', value: '3' },
|
||||
{ label: 'H4', value: '4' },
|
||||
]},
|
||||
{ name: 'children', label: 'Text', type: 'text' },
|
||||
],
|
||||
Text: [
|
||||
{ name: 'children', label: 'Content', type: 'textarea' },
|
||||
],
|
||||
Badge: [
|
||||
{ name: 'variant', label: 'Variant', type: 'select', options: [
|
||||
{ label: 'Default', value: 'default' },
|
||||
{ label: 'Secondary', value: 'secondary' },
|
||||
{ label: 'Destructive', value: 'destructive' },
|
||||
{ label: 'Outline', value: 'outline' },
|
||||
]},
|
||||
{ name: 'children', label: 'Text', type: 'text' },
|
||||
],
|
||||
Progress: [
|
||||
{ name: 'value', label: 'Value', type: 'number' },
|
||||
],
|
||||
Grid: [
|
||||
{ name: 'columns', label: 'Columns', type: 'number' },
|
||||
{ name: 'gap', label: 'Gap', type: 'number' },
|
||||
],
|
||||
}
|
||||
|
||||
const props = typeSpecificProps[component.type] || []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders className="w-5 h-5 text-primary" weight="duotone" />
|
||||
<h2 className="text-lg font-semibold">Properties</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{def?.label || component.type}</span>
|
||||
<span className="text-xs text-muted-foreground">#{component.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Component Properties
|
||||
</h3>
|
||||
{props.map((prop) => (
|
||||
<PropertyEditorField
|
||||
key={prop.name}
|
||||
label={prop.label}
|
||||
name={prop.name}
|
||||
value={component.props?.[prop.name]}
|
||||
type={prop.type}
|
||||
options={prop.options}
|
||||
onChange={handlePropChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Common Properties
|
||||
</h3>
|
||||
{commonProps.map((prop) => (
|
||||
<PropertyEditorField
|
||||
key={prop.name}
|
||||
label={prop.label}
|
||||
name={prop.name}
|
||||
value={component.props?.[prop.name]}
|
||||
type={prop.type}
|
||||
onChange={handlePropChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -282,6 +282,17 @@
|
||||
"enabled": true,
|
||||
"order": 22,
|
||||
"props": {}
|
||||
},
|
||||
{
|
||||
"id": "schema-editor",
|
||||
"title": "Schema Editor",
|
||||
"icon": "PencilRuler",
|
||||
"component": "SchemaEditor",
|
||||
"enabled": true,
|
||||
"toggleKey": "schemaEditor",
|
||||
"shortcut": "ctrl+shift+s",
|
||||
"order": 23,
|
||||
"props": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
86
src/hooks/ui/use-drag-drop.ts
Normal file
86
src/hooks/ui/use-drag-drop.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
export interface DragItem {
|
||||
id: string
|
||||
type: string
|
||||
data: any
|
||||
}
|
||||
|
||||
export interface DropPosition {
|
||||
targetId: string
|
||||
position: 'before' | 'after' | 'inside'
|
||||
}
|
||||
|
||||
export function useDragDrop() {
|
||||
const [draggedItem, setDraggedItem] = useState<DragItem | null>(null)
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
const [dropPosition, setDropPosition] = useState<'before' | 'after' | 'inside' | null>(null)
|
||||
const dragStartPos = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
const handleDragStart = useCallback((item: DragItem, e: React.DragEvent) => {
|
||||
setDraggedItem(item)
|
||||
dragStartPos.current = { x: e.clientX, y: e.clientY }
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify(item))
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedItem(null)
|
||||
setDropTarget(null)
|
||||
setDropPosition(null)
|
||||
dragStartPos.current = null
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((targetId: string, e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const y = e.clientY - rect.top
|
||||
const height = rect.height
|
||||
|
||||
let position: 'before' | 'after' | 'inside' = 'inside'
|
||||
|
||||
if (y < height * 0.25) {
|
||||
position = 'before'
|
||||
} else if (y > height * 0.75) {
|
||||
position = 'after'
|
||||
}
|
||||
|
||||
setDropTarget(targetId)
|
||||
setDropPosition(position)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const related = e.relatedTarget as HTMLElement
|
||||
if (!related || !e.currentTarget.contains(related)) {
|
||||
setDropTarget(null)
|
||||
setDropPosition(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((targetId: string, e: React.DragEvent, onDrop?: (item: DragItem, target: DropPosition) => void) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (draggedItem && onDrop) {
|
||||
onDrop(draggedItem, {
|
||||
targetId,
|
||||
position: dropPosition || 'inside',
|
||||
})
|
||||
}
|
||||
|
||||
handleDragEnd()
|
||||
}, [draggedItem, dropPosition, handleDragEnd])
|
||||
|
||||
return {
|
||||
draggedItem,
|
||||
dropTarget,
|
||||
dropPosition,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
}
|
||||
}
|
||||
59
src/hooks/ui/use-json-export.ts
Normal file
59
src/hooks/ui/use-json-export.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function useJsonExport() {
|
||||
const exportToJson = useCallback((data: any, filename: string = 'schema.json') => {
|
||||
try {
|
||||
const jsonString = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([jsonString], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Schema exported successfully')
|
||||
} catch (error) {
|
||||
toast.error('Failed to export schema')
|
||||
console.error('Export error:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = useCallback((data: any) => {
|
||||
try {
|
||||
const jsonString = JSON.stringify(data, null, 2)
|
||||
navigator.clipboard.writeText(jsonString)
|
||||
toast.success('Copied to clipboard')
|
||||
} catch (error) {
|
||||
toast.error('Failed to copy to clipboard')
|
||||
console.error('Copy error:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const importFromJson = useCallback((file: File, onImport: (data: any) => void) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target?.result as string)
|
||||
onImport(data)
|
||||
toast.success('Schema imported successfully')
|
||||
} catch (error) {
|
||||
toast.error('Invalid JSON file')
|
||||
console.error('Import error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
reader.readAsText(file)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
exportToJson,
|
||||
copyToClipboard,
|
||||
importFromJson,
|
||||
}
|
||||
}
|
||||
197
src/hooks/ui/use-schema-editor.ts
Normal file
197
src/hooks/ui/use-schema-editor.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
import { toast } from 'sonner'
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
|
||||
export interface SchemaEditorState {
|
||||
components: UIComponent[]
|
||||
selectedId: string | null
|
||||
hoveredId: string | null
|
||||
}
|
||||
|
||||
export function useSchemaEditor() {
|
||||
const [components, setComponents, deleteComponents] = useKV<UIComponent[]>('schema-editor-components', [])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
|
||||
const findComponentById = useCallback((id: string, comps: UIComponent[] = components): UIComponent | null => {
|
||||
for (const comp of comps) {
|
||||
if (comp.id === id) return comp
|
||||
if (Array.isArray(comp.children)) {
|
||||
const found = findComponentById(id, comp.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [components])
|
||||
|
||||
const findParentComponent = useCallback((id: string, comps: UIComponent[] = components, parent: UIComponent | null = null): UIComponent | null => {
|
||||
for (const comp of comps) {
|
||||
if (comp.id === id) return parent
|
||||
if (Array.isArray(comp.children)) {
|
||||
const found = findParentComponent(id, comp.children, comp)
|
||||
if (found !== null) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [components])
|
||||
|
||||
const addComponent = useCallback((component: UIComponent, targetId?: string, position: 'before' | 'after' | 'inside' = 'inside') => {
|
||||
setComponents((current) => {
|
||||
const newComps = [...current]
|
||||
|
||||
if (!targetId) {
|
||||
newComps.push(component)
|
||||
return newComps
|
||||
}
|
||||
|
||||
const insertComponent = (comps: UIComponent[]): boolean => {
|
||||
for (let i = 0; i < comps.length; i++) {
|
||||
const comp = comps[i]
|
||||
|
||||
if (comp.id === targetId) {
|
||||
if (position === 'inside') {
|
||||
if (!Array.isArray(comp.children)) {
|
||||
comp.children = []
|
||||
}
|
||||
comp.children.push(component)
|
||||
} else if (position === 'before') {
|
||||
comps.splice(i, 0, component)
|
||||
} else if (position === 'after') {
|
||||
comps.splice(i + 1, 0, component)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(comp.children)) {
|
||||
if (insertComponent(comp.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
insertComponent(newComps)
|
||||
return newComps
|
||||
})
|
||||
|
||||
setSelectedId(component.id)
|
||||
toast.success('Component added')
|
||||
}, [setComponents])
|
||||
|
||||
const updateComponent = useCallback((id: string, updates: Partial<UIComponent>) => {
|
||||
setComponents((current) => {
|
||||
const updateInTree = (comps: UIComponent[]): UIComponent[] => {
|
||||
return comps.map(comp => {
|
||||
if (comp.id === id) {
|
||||
return { ...comp, ...updates }
|
||||
}
|
||||
if (Array.isArray(comp.children)) {
|
||||
return {
|
||||
...comp,
|
||||
children: updateInTree(comp.children)
|
||||
}
|
||||
}
|
||||
return comp
|
||||
})
|
||||
}
|
||||
|
||||
return updateInTree(current)
|
||||
})
|
||||
}, [setComponents])
|
||||
|
||||
const deleteComponent = useCallback((id: string) => {
|
||||
setComponents((current) => {
|
||||
const deleteFromTree = (comps: UIComponent[]): UIComponent[] => {
|
||||
return comps.filter(comp => {
|
||||
if (comp.id === id) return false
|
||||
if (Array.isArray(comp.children)) {
|
||||
comp.children = deleteFromTree(comp.children)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return deleteFromTree(current)
|
||||
})
|
||||
|
||||
if (selectedId === id) {
|
||||
setSelectedId(null)
|
||||
}
|
||||
toast.success('Component deleted')
|
||||
}, [selectedId, setComponents])
|
||||
|
||||
const moveComponent = useCallback((sourceId: string, targetId: string, position: 'before' | 'after' | 'inside') => {
|
||||
setComponents((current) => {
|
||||
const component = findComponentById(sourceId, current)
|
||||
if (!component) return current
|
||||
|
||||
const newComps = [...current]
|
||||
|
||||
const removeFromTree = (comps: UIComponent[]): UIComponent[] => {
|
||||
return comps.filter(comp => {
|
||||
if (comp.id === sourceId) return false
|
||||
if (Array.isArray(comp.children)) {
|
||||
comp.children = removeFromTree(comp.children)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const cleanedComps = removeFromTree(newComps)
|
||||
|
||||
const insertComponent = (comps: UIComponent[]): boolean => {
|
||||
for (let i = 0; i < comps.length; i++) {
|
||||
const comp = comps[i]
|
||||
|
||||
if (comp.id === targetId) {
|
||||
if (position === 'inside') {
|
||||
if (!Array.isArray(comp.children)) {
|
||||
comp.children = []
|
||||
}
|
||||
comp.children.push(component)
|
||||
} else if (position === 'before') {
|
||||
comps.splice(i, 0, component)
|
||||
} else if (position === 'after') {
|
||||
comps.splice(i + 1, 0, component)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(comp.children)) {
|
||||
if (insertComponent(comp.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
insertComponent(cleanedComps)
|
||||
return cleanedComps
|
||||
})
|
||||
}, [findComponentById, setComponents])
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
deleteComponents()
|
||||
setSelectedId(null)
|
||||
setHoveredId(null)
|
||||
toast.success('Canvas cleared')
|
||||
}, [deleteComponents])
|
||||
|
||||
return {
|
||||
components,
|
||||
selectedId,
|
||||
hoveredId,
|
||||
setSelectedId,
|
||||
setHoveredId,
|
||||
findComponentById,
|
||||
findParentComponent,
|
||||
addComponent,
|
||||
updateComponent,
|
||||
deleteComponent,
|
||||
moveComponent,
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = {
|
||||
sassStyles: true,
|
||||
faviconDesigner: true,
|
||||
ideaCloud: true,
|
||||
schemaEditor: true,
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: ThemeConfig = {
|
||||
|
||||
144
src/lib/component-definitions.ts
Normal file
144
src/lib/component-definitions.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ComponentType } from '@/types/json-ui'
|
||||
|
||||
export interface ComponentDefinition {
|
||||
type: ComponentType
|
||||
label: string
|
||||
category: 'layout' | 'input' | 'display' | 'custom'
|
||||
icon: string
|
||||
defaultProps?: Record<string, any>
|
||||
canHaveChildren?: boolean
|
||||
}
|
||||
|
||||
export const componentDefinitions: ComponentDefinition[] = [
|
||||
{
|
||||
type: 'div',
|
||||
label: 'Container',
|
||||
category: 'layout',
|
||||
icon: 'Square',
|
||||
canHaveChildren: true,
|
||||
defaultProps: { className: 'p-4 space-y-2' }
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
label: 'Section',
|
||||
category: 'layout',
|
||||
icon: 'SquaresFour',
|
||||
canHaveChildren: true,
|
||||
defaultProps: { className: 'space-y-4' }
|
||||
},
|
||||
{
|
||||
type: 'Grid',
|
||||
label: 'Grid',
|
||||
category: 'layout',
|
||||
icon: 'GridFour',
|
||||
canHaveChildren: true,
|
||||
defaultProps: { columns: 2, gap: 4 }
|
||||
},
|
||||
{
|
||||
type: 'Card',
|
||||
label: 'Card',
|
||||
category: 'layout',
|
||||
icon: 'Rectangle',
|
||||
canHaveChildren: true,
|
||||
defaultProps: { className: 'p-4' }
|
||||
},
|
||||
{
|
||||
type: 'Button',
|
||||
label: 'Button',
|
||||
category: 'input',
|
||||
icon: 'Circle',
|
||||
defaultProps: { children: 'Click me', variant: 'default' }
|
||||
},
|
||||
{
|
||||
type: 'Input',
|
||||
label: 'Input',
|
||||
category: 'input',
|
||||
icon: 'TextT',
|
||||
defaultProps: { placeholder: 'Enter text...' }
|
||||
},
|
||||
{
|
||||
type: 'Select',
|
||||
label: 'Select',
|
||||
category: 'input',
|
||||
icon: 'CaretDown',
|
||||
defaultProps: { placeholder: 'Choose option...' }
|
||||
},
|
||||
{
|
||||
type: 'Checkbox',
|
||||
label: 'Checkbox',
|
||||
category: 'input',
|
||||
icon: 'CheckSquare',
|
||||
defaultProps: {}
|
||||
},
|
||||
{
|
||||
type: 'Switch',
|
||||
label: 'Switch',
|
||||
category: 'input',
|
||||
icon: 'ToggleLeft',
|
||||
defaultProps: {}
|
||||
},
|
||||
{
|
||||
type: 'Heading',
|
||||
label: 'Heading',
|
||||
category: 'display',
|
||||
icon: 'TextHOne',
|
||||
defaultProps: { level: 1, children: 'Heading' }
|
||||
},
|
||||
{
|
||||
type: 'Text',
|
||||
label: 'Text',
|
||||
category: 'display',
|
||||
icon: 'Paragraph',
|
||||
defaultProps: { children: 'Text content' }
|
||||
},
|
||||
{
|
||||
type: 'Badge',
|
||||
label: 'Badge',
|
||||
category: 'display',
|
||||
icon: 'Tag',
|
||||
defaultProps: { children: 'Badge', variant: 'default' }
|
||||
},
|
||||
{
|
||||
type: 'Progress',
|
||||
label: 'Progress',
|
||||
category: 'display',
|
||||
icon: 'CircleNotch',
|
||||
defaultProps: { value: 50 }
|
||||
},
|
||||
{
|
||||
type: 'Separator',
|
||||
label: 'Separator',
|
||||
category: 'display',
|
||||
icon: 'Minus',
|
||||
defaultProps: {}
|
||||
},
|
||||
{
|
||||
type: 'DataCard',
|
||||
label: 'Data Card',
|
||||
category: 'custom',
|
||||
icon: 'ChartBar',
|
||||
defaultProps: { title: 'Metric', value: '100', icon: 'TrendUp' }
|
||||
},
|
||||
{
|
||||
type: 'SearchInput',
|
||||
label: 'Search Input',
|
||||
category: 'custom',
|
||||
icon: 'MagnifyingGlass',
|
||||
defaultProps: { placeholder: 'Search...' }
|
||||
},
|
||||
{
|
||||
type: 'StatusBadge',
|
||||
label: 'Status Badge',
|
||||
category: 'custom',
|
||||
icon: 'Circle',
|
||||
defaultProps: { status: 'active', children: 'Active' }
|
||||
},
|
||||
]
|
||||
|
||||
export function getCategoryComponents(category: string): ComponentDefinition[] {
|
||||
return componentDefinitions.filter(c => c.category === category)
|
||||
}
|
||||
|
||||
export function getComponentDef(type: ComponentType): ComponentDefinition | undefined {
|
||||
return componentDefinitions.find(c => c.type === type)
|
||||
}
|
||||
@@ -121,6 +121,16 @@ export const ComponentRegistry = {
|
||||
() => import('@/components/TemplateSelector').then(m => ({ default: m.TemplateSelector })),
|
||||
'TemplateSelector'
|
||||
),
|
||||
|
||||
JSONUIShowcase: lazyWithPreload(
|
||||
() => import('@/components/JSONUIShowcasePage').then(m => ({ default: m.JSONUIShowcasePage })),
|
||||
'JSONUIShowcase'
|
||||
),
|
||||
|
||||
SchemaEditor: lazyWithPreload(
|
||||
() => import('@/components/SchemaEditorPage').then(m => ({ default: m.SchemaEditorPage })),
|
||||
'SchemaEditor'
|
||||
),
|
||||
} as const
|
||||
|
||||
export const DialogRegistry = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Image,
|
||||
Faders,
|
||||
Lightbulb,
|
||||
PencilRuler,
|
||||
} from '@phosphor-icons/react'
|
||||
import { FeatureToggles } from '@/types/project'
|
||||
|
||||
@@ -141,6 +142,16 @@ export const tabInfo: Record<string, TabInfo> = {
|
||||
icon: <Lightbulb size={24} weight="duotone" />,
|
||||
description: 'Brainstorm and organize feature ideas',
|
||||
},
|
||||
'schema-editor': {
|
||||
title: 'Schema Editor',
|
||||
icon: <PencilRuler size={24} weight="duotone" />,
|
||||
description: 'Visual JSON schema builder',
|
||||
},
|
||||
'json-ui': {
|
||||
title: 'JSON UI Showcase',
|
||||
icon: <Code size={24} weight="duotone" />,
|
||||
description: 'JSON-driven UI examples',
|
||||
},
|
||||
}
|
||||
|
||||
export const navigationGroups: NavigationGroup[] = [
|
||||
@@ -242,6 +253,19 @@ export const navigationGroups: NavigationGroup[] = [
|
||||
value: 'ideas',
|
||||
featureKey: 'ideaCloud',
|
||||
},
|
||||
{
|
||||
id: 'schema-editor',
|
||||
label: 'Schema Editor',
|
||||
icon: <PencilRuler size={18} />,
|
||||
value: 'schema-editor',
|
||||
featureKey: 'schemaEditor',
|
||||
},
|
||||
{
|
||||
id: 'json-ui',
|
||||
label: 'JSON UI',
|
||||
icon: <Code size={18} />,
|
||||
value: 'json-ui',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -282,6 +282,7 @@ export interface FeatureToggles {
|
||||
sassStyles: boolean
|
||||
faviconDesigner: boolean
|
||||
ideaCloud: boolean
|
||||
schemaEditor: boolean
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
|
||||
Reference in New Issue
Block a user