From 9b9f0da541b7bff90eb4d54af9bfe057890df00f Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 17 Jan 2026 11:03:10 +0000 Subject: [PATCH] Generated by Spark: Build a visual schema editor to create JSON UI configs through drag-and-drop --- PRD.md | 11 +- src/App.simple.tsx | 1 + src/components/SchemaEditorPage.tsx | 258 ++++++++++++++++++ src/components/atoms/ComponentPaletteItem.tsx | 31 +++ src/components/atoms/ComponentTreeNode.tsx | 84 ++++++ src/components/atoms/PropertyEditorField.tsx | 87 ++++++ src/components/molecules/CanvasRenderer.tsx | 133 +++++++++ src/components/molecules/ComponentPalette.tsx | 54 ++++ src/components/molecules/ComponentTree.tsx | 83 ++++++ src/components/molecules/PropertyEditor.tsx | 165 +++++++++++ src/config/pages.json | 11 + src/hooks/ui/use-drag-drop.ts | 86 ++++++ src/hooks/ui/use-json-export.ts | 59 ++++ src/hooks/ui/use-schema-editor.ts | 197 +++++++++++++ src/hooks/use-project-state.ts | 1 + src/lib/component-definitions.ts | 144 ++++++++++ src/lib/component-registry.ts | 10 + src/lib/navigation-config.tsx | 24 ++ src/types/project.ts | 1 + 19 files changed, 1438 insertions(+), 2 deletions(-) create mode 100644 src/components/SchemaEditorPage.tsx create mode 100644 src/components/atoms/ComponentPaletteItem.tsx create mode 100644 src/components/atoms/ComponentTreeNode.tsx create mode 100644 src/components/atoms/PropertyEditorField.tsx create mode 100644 src/components/molecules/CanvasRenderer.tsx create mode 100644 src/components/molecules/ComponentPalette.tsx create mode 100644 src/components/molecules/ComponentTree.tsx create mode 100644 src/components/molecules/PropertyEditor.tsx create mode 100644 src/hooks/ui/use-drag-drop.ts create mode 100644 src/hooks/ui/use-json-export.ts create mode 100644 src/hooks/ui/use-schema-editor.ts create mode 100644 src/lib/component-definitions.ts diff --git a/PRD.md b/PRD.md index d30c137..219c0bc 100644 --- a/PRD.md +++ b/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 diff --git a/src/App.simple.tsx b/src/App.simple.tsx index 01c51bc..2d60e67 100644 --- a/src/App.simple.tsx +++ b/src/App.simple.tsx @@ -24,6 +24,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = { sassStyles: true, faviconDesigner: true, ideaCloud: true, + schemaEditor: true, } function App() { diff --git a/src/components/SchemaEditorPage.tsx b/src/components/SchemaEditorPage.tsx new file mode 100644 index 0000000..9d16e73 --- /dev/null +++ b/src/components/SchemaEditorPage.tsx @@ -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 ( +
+
+
+
+

+ Schema Editor +

+

+ Build JSON UI schemas with drag-and-drop +

+
+ +
+ + + + + + +
+
+
+ +
+
+ +
+ +
+ setHoveredId(null)} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleCanvasDrop} + /> +
+ +
+
+ setHoveredId(null)} + onDragStart={handleComponentTreeDragStart} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleCanvasDrop} + /> +
+ + + +
+ { + if (selectedId) { + updateComponent(selectedId, updates) + } + }} + onDelete={() => { + if (selectedId) { + deleteComponent(selectedId) + } + }} + /> +
+
+
+
+ ) +} diff --git a/src/components/atoms/ComponentPaletteItem.tsx b/src/components/atoms/ComponentPaletteItem.tsx new file mode 100644 index 0000000..99c2b48 --- /dev/null +++ b/src/components/atoms/ComponentPaletteItem.tsx @@ -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 ( + 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 + )} + > + + {component.label} + {component.type} + + ) +} diff --git a/src/components/atoms/ComponentTreeNode.tsx b/src/components/atoms/ComponentTreeNode.tsx new file mode 100644 index 0000000..186bede --- /dev/null +++ b/src/components/atoms/ComponentTreeNode.tsx @@ -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 ( +
+ {isDraggedOver && dropPosition === 'before' && ( +
+ )} + +
{ + 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 ? ( + + ) : ( +
+ )} + + {def?.label || component.type} + {component.id} +
+ + {isDraggedOver && dropPosition === 'after' && ( +
+ )} +
+ ) +} diff --git a/src/components/atoms/PropertyEditorField.tsx b/src/components/atoms/PropertyEditorField.tsx new file mode 100644 index 0000000..7948fce --- /dev/null +++ b/src/components/atoms/PropertyEditorField.tsx @@ -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 ( + onChange(name, checked)} + /> + ) + + case 'select': + return ( + + ) + + case 'number': + return ( + onChange(name, Number(e.target.value))} + /> + ) + + case 'textarea': + return ( +