Generated by Spark: Build a visual schema editor to create JSON UI configs through drag-and-drop

This commit is contained in:
2026-01-17 11:03:10 +00:00
committed by GitHub
parent ac6afd9961
commit 9b9f0da541
19 changed files with 1438 additions and 2 deletions

11
PRD.md
View File

@@ -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

View File

@@ -24,6 +24,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = {
sassStyles: true,
faviconDesigner: true,
ideaCloud: true,
schemaEditor: true,
}
function App() {

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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": {}
}
]
}

View 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,
}
}

View 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,
}
}

View 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,
}
}

View File

@@ -74,6 +74,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = {
sassStyles: true,
faviconDesigner: true,
ideaCloud: true,
schemaEditor: true,
}
const DEFAULT_THEME: ThemeConfig = {

View 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)
}

View File

@@ -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 = {

View File

@@ -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',
},
],
},
{

View File

@@ -282,6 +282,7 @@ export interface FeatureToggles {
sassStyles: boolean
faviconDesigner: boolean
ideaCloud: boolean
schemaEditor: boolean
}
export interface Project {