mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Refactor schema editor views
This commit is contained in:
@@ -1,158 +1,5 @@
|
||||
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 { SchemaEditorLayout } from '@/components/organisms'
|
||||
import { ComponentDefinition } from '@/lib/component-definitions'
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { toast } from 'sonner'
|
||||
import { PageSchema } from '@/types/json-ui'
|
||||
import { SchemaEditorWorkspace } from '@/components/schema-editor/SchemaEditorWorkspace'
|
||||
|
||||
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 (
|
||||
<SchemaEditorLayout
|
||||
components={components}
|
||||
selectedId={selectedId}
|
||||
hoveredId={hoveredId}
|
||||
draggedOverId={dropTarget}
|
||||
dropPosition={dropPosition}
|
||||
selectedComponent={selectedComponent}
|
||||
onSelect={setSelectedId}
|
||||
onHover={setHoveredId}
|
||||
onHoverEnd={() => setHoveredId(null)}
|
||||
onComponentDragStart={handleComponentDragStart}
|
||||
onTreeDragStart={handleComponentTreeDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
onUpdate={(updates) => {
|
||||
if (selectedId) {
|
||||
updateComponent(selectedId, updates)
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (selectedId) {
|
||||
deleteComponent(selectedId)
|
||||
}
|
||||
}}
|
||||
onImport={handleImport}
|
||||
onExport={handleExportJson}
|
||||
onCopy={handleCopyJson}
|
||||
onPreview={handlePreview}
|
||||
onClear={clearAll}
|
||||
/>
|
||||
)
|
||||
return <SchemaEditorWorkspace />
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { ComponentTreeNode } from '@/components/atoms/ComponentTreeNode'
|
||||
import { PanelHeader } from '@/components/atoms'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Tree, CaretDown, CaretRight } from '@phosphor-icons/react'
|
||||
import { ComponentTreeHeader } from '@/components/molecules/component-tree/ComponentTreeHeader'
|
||||
import { ComponentTreeEmptyState } from '@/components/molecules/component-tree/ComponentTreeEmptyState'
|
||||
import { ComponentTreeNodes } from '@/components/molecules/component-tree/ComponentTreeNodes'
|
||||
|
||||
interface ComponentTreeProps {
|
||||
components: UIComponent[]
|
||||
@@ -40,8 +38,8 @@ export function ComponentTree({
|
||||
|
||||
const getAllComponentIds = useCallback((comps: UIComponent[]): string[] => {
|
||||
const ids: string[] = []
|
||||
const traverse = (components: UIComponent[]) => {
|
||||
components.forEach((comp) => {
|
||||
const traverse = (nodes: UIComponent[]) => {
|
||||
nodes.forEach((comp) => {
|
||||
if (Array.isArray(comp.children) && comp.children.length > 0) {
|
||||
ids.push(comp.id)
|
||||
traverse(comp.children)
|
||||
@@ -73,90 +71,36 @@ export function ComponentTree({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const renderTree = (comps: UIComponent[], depth = 0) => {
|
||||
return comps.map((comp) => {
|
||||
const hasChildren = Array.isArray(comp.children) && comp.children.length > 0
|
||||
const isExpanded = expandedIds.has(comp.id)
|
||||
|
||||
return (
|
||||
<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}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={() => toggleExpand(comp.id)}
|
||||
/>
|
||||
{hasChildren && isExpanded && comp.children && (
|
||||
<div>{renderTree(comp.children, depth + 1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<PanelHeader
|
||||
title="Component Tree"
|
||||
subtitle={`${components.length} component${components.length !== 1 ? 's' : ''}`}
|
||||
icon={<Tree size={20} weight="duotone" />}
|
||||
/>
|
||||
{components.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExpandAll}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<CaretDown size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Expand All</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCollapseAll}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<CaretRight size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Collapse All</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ComponentTreeHeader
|
||||
componentsCount={components.length}
|
||||
onExpandAll={handleExpandAll}
|
||||
onCollapseAll={handleCollapseAll}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<ComponentTreeEmptyState />
|
||||
) : (
|
||||
<div className="py-2">{renderTree(components)}</div>
|
||||
<div className="py-2">
|
||||
<ComponentTreeNodes
|
||||
components={components}
|
||||
expandedIds={expandedIds}
|
||||
selectedId={selectedId}
|
||||
hoveredId={hoveredId}
|
||||
draggedOverId={draggedOverId}
|
||||
dropPosition={dropPosition}
|
||||
onSelect={onSelect}
|
||||
onHover={onHover}
|
||||
onHoverEnd={onHoverEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onToggleExpand={toggleExpand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { PropertyEditorField } from '@/components/atoms/PropertyEditorField'
|
||||
import { PanelHeader, Badge, IconButton, Stack, Text, EmptyStateIcon } from '@/components/atoms'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Sliders, Trash } from '@phosphor-icons/react'
|
||||
import { getComponentDef } from '@/lib/component-definitions'
|
||||
import { PropertyEditorEmptyState } from '@/components/molecules/property-editor/PropertyEditorEmptyState'
|
||||
import { propertyEditorConfig } from '@/components/molecules/property-editor/propertyEditorConfig'
|
||||
import { PropertyEditorHeader } from '@/components/molecules/property-editor/PropertyEditorHeader'
|
||||
import { PropertyEditorSection } from '@/components/molecules/property-editor/PropertyEditorSection'
|
||||
import { Stack } from '@/components/atoms'
|
||||
|
||||
interface PropertyEditorProps {
|
||||
component: UIComponent | null
|
||||
@@ -14,22 +16,12 @@ interface PropertyEditorProps {
|
||||
|
||||
export function PropertyEditor({ component, onUpdate, onDelete }: PropertyEditorProps) {
|
||||
if (!component) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8">
|
||||
<Stack direction="vertical" align="center" spacing="md">
|
||||
<EmptyStateIcon icon={<Sliders className="w-12 h-12" />} />
|
||||
<Stack direction="vertical" align="center" spacing="xs">
|
||||
<Text variant="small">No component selected</Text>
|
||||
<Text variant="caption">Select a component to edit its properties</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
)
|
||||
return <PropertyEditorEmptyState />
|
||||
}
|
||||
|
||||
const def = getComponentDef(component.type)
|
||||
|
||||
const handlePropChange = (key: string, value: any) => {
|
||||
|
||||
const handlePropChange = (key: string, value: unknown) => {
|
||||
onUpdate({
|
||||
props: {
|
||||
...component.props,
|
||||
@@ -38,132 +30,33 @@ export function PropertyEditor({ component, onUpdate, onDelete }: PropertyEditor
|
||||
})
|
||||
}
|
||||
|
||||
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] || []
|
||||
const props = propertyEditorConfig.typeSpecificProps[component.type] || []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4">
|
||||
<PanelHeader
|
||||
title="Properties"
|
||||
subtitle={
|
||||
<Stack direction="horizontal" align="center" spacing="sm" className="mt-1">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{def?.label || component.type}
|
||||
</Badge>
|
||||
<Text variant="caption">#{component.id}</Text>
|
||||
</Stack>
|
||||
}
|
||||
icon={<Sliders size={20} weight="duotone" />}
|
||||
actions={
|
||||
<IconButton
|
||||
icon={<Trash className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PropertyEditorHeader
|
||||
componentId={component.id}
|
||||
componentLabel={def?.label || component.type}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<Stack spacing="lg">
|
||||
<Stack spacing="md">
|
||||
<Text variant="caption" className="font-semibold uppercase tracking-wide">
|
||||
Component Properties
|
||||
</Text>
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<PropertyEditorSection
|
||||
title={propertyEditorConfig.sections.componentProps}
|
||||
fields={props}
|
||||
component={component}
|
||||
onChange={handlePropChange}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Stack spacing="md">
|
||||
<Text variant="caption" className="font-semibold uppercase tracking-wide">
|
||||
Common Properties
|
||||
</Text>
|
||||
{commonProps.map((prop) => (
|
||||
<PropertyEditorField
|
||||
key={prop.name}
|
||||
label={prop.label}
|
||||
name={prop.name}
|
||||
value={component.props?.[prop.name]}
|
||||
type={prop.type}
|
||||
onChange={handlePropChange}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<PropertyEditorSection
|
||||
title={propertyEditorConfig.sections.commonProps}
|
||||
fields={propertyEditorConfig.commonProps}
|
||||
component={component}
|
||||
onChange={handlePropChange}
|
||||
/>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { componentTreeConfig } from '@/components/molecules/component-tree/componentTreeConfig'
|
||||
import { componentTreeIcons } from '@/components/molecules/component-tree/componentTreeIcons'
|
||||
|
||||
export function ComponentTreeEmptyState() {
|
||||
const Icon = componentTreeIcons[componentTreeConfig.icon as keyof typeof componentTreeIcons]
|
||||
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Icon className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{componentTreeConfig.emptyState.title}</p>
|
||||
<p className="text-xs mt-1">{componentTreeConfig.emptyState.description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { PanelHeader } from '@/components/atoms'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { componentTreeConfig } from '@/components/molecules/component-tree/componentTreeConfig'
|
||||
import { componentTreeIcons } from '@/components/molecules/component-tree/componentTreeIcons'
|
||||
import { CaretDown, CaretRight } from '@phosphor-icons/react'
|
||||
|
||||
interface ComponentTreeHeaderProps {
|
||||
componentsCount: number
|
||||
onExpandAll: () => void
|
||||
onCollapseAll: () => void
|
||||
}
|
||||
|
||||
export function ComponentTreeHeader({
|
||||
componentsCount,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
}: ComponentTreeHeaderProps) {
|
||||
const Icon = componentTreeIcons[componentTreeConfig.icon as keyof typeof componentTreeIcons]
|
||||
const subtitleLabel = componentsCount === 1
|
||||
? componentTreeConfig.subtitle.singular
|
||||
: componentTreeConfig.subtitle.plural
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<PanelHeader
|
||||
title={componentTreeConfig.title}
|
||||
subtitle={`${componentsCount} ${subtitleLabel}`}
|
||||
icon={<Icon size={20} weight="duotone" />}
|
||||
/>
|
||||
{componentsCount > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onExpandAll}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<CaretDown size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{componentTreeConfig.tooltips.expandAll}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCollapseAll}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<CaretRight size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{componentTreeConfig.tooltips.collapseAll}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { ComponentTreeNode } from '@/components/atoms/ComponentTreeNode'
|
||||
|
||||
interface ComponentTreeNodesProps {
|
||||
components: UIComponent[]
|
||||
depth?: number
|
||||
expandedIds: Set<string>
|
||||
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
|
||||
onToggleExpand: (id: string) => void
|
||||
}
|
||||
|
||||
export function ComponentTreeNodes({
|
||||
components,
|
||||
depth = 0,
|
||||
expandedIds,
|
||||
selectedId,
|
||||
hoveredId,
|
||||
draggedOverId,
|
||||
dropPosition,
|
||||
onSelect,
|
||||
onHover,
|
||||
onHoverEnd,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onToggleExpand,
|
||||
}: ComponentTreeNodesProps) {
|
||||
return (
|
||||
<>
|
||||
{components.map((comp) => {
|
||||
const hasChildren = Array.isArray(comp.children) && comp.children.length > 0
|
||||
const isExpanded = expandedIds.has(comp.id)
|
||||
|
||||
return (
|
||||
<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}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={() => onToggleExpand(comp.id)}
|
||||
/>
|
||||
{hasChildren && isExpanded && comp.children && (
|
||||
<div>
|
||||
<ComponentTreeNodes
|
||||
components={comp.children}
|
||||
depth={depth + 1}
|
||||
expandedIds={expandedIds}
|
||||
selectedId={selectedId}
|
||||
hoveredId={hoveredId}
|
||||
draggedOverId={draggedOverId}
|
||||
dropPosition={dropPosition}
|
||||
onSelect={onSelect}
|
||||
onHover={onHover}
|
||||
onHoverEnd={onHoverEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import config from '@/data/schema-editor/component-tree.json'
|
||||
|
||||
export interface ComponentTreeConfig {
|
||||
icon: string
|
||||
title: string
|
||||
subtitle: {
|
||||
singular: string
|
||||
plural: string
|
||||
}
|
||||
tooltips: {
|
||||
expandAll: string
|
||||
collapseAll: string
|
||||
}
|
||||
emptyState: {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
}
|
||||
|
||||
export const componentTreeConfig = config as ComponentTreeConfig
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Tree } from '@phosphor-icons/react'
|
||||
|
||||
export const componentTreeIcons = {
|
||||
tree: Tree,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EmptyStateIcon, Stack, Text } from '@/components/atoms'
|
||||
import { propertyEditorConfig } from '@/components/molecules/property-editor/propertyEditorConfig'
|
||||
import { propertyEditorIcons } from '@/components/molecules/property-editor/propertyEditorIcons'
|
||||
|
||||
export function PropertyEditorEmptyState() {
|
||||
const Icon = propertyEditorIcons[propertyEditorConfig.icon as keyof typeof propertyEditorIcons]
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8">
|
||||
<Stack direction="vertical" align="center" spacing="md">
|
||||
<EmptyStateIcon icon={<Icon className="w-12 h-12" />} />
|
||||
<Stack direction="vertical" align="center" spacing="xs">
|
||||
<Text variant="small">{propertyEditorConfig.emptyState.title}</Text>
|
||||
<Text variant="caption">{propertyEditorConfig.emptyState.description}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Badge, IconButton, PanelHeader, Stack, Text } from '@/components/atoms'
|
||||
import { propertyEditorConfig } from '@/components/molecules/property-editor/propertyEditorConfig'
|
||||
import { propertyEditorIcons } from '@/components/molecules/property-editor/propertyEditorIcons'
|
||||
import { Trash } from '@phosphor-icons/react'
|
||||
|
||||
interface PropertyEditorHeaderProps {
|
||||
componentId: string
|
||||
componentLabel: string
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function PropertyEditorHeader({ componentId, componentLabel, onDelete }: PropertyEditorHeaderProps) {
|
||||
const Icon = propertyEditorIcons[propertyEditorConfig.icon as keyof typeof propertyEditorIcons]
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<PanelHeader
|
||||
title={propertyEditorConfig.title}
|
||||
subtitle={
|
||||
<Stack direction="horizontal" align="center" spacing="sm" className="mt-1">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{componentLabel}
|
||||
</Badge>
|
||||
<Text variant="caption">#{componentId}</Text>
|
||||
</Stack>
|
||||
}
|
||||
icon={<Icon size={20} weight="duotone" />}
|
||||
actions={
|
||||
<IconButton
|
||||
icon={<Trash className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PropertyEditorField } from '@/components/atoms/PropertyEditorField'
|
||||
import { Stack, Text } from '@/components/atoms'
|
||||
import { PropertyEditorFieldDefinition } from '@/components/molecules/property-editor/propertyEditorConfig'
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
|
||||
interface PropertyEditorSectionProps {
|
||||
title: string
|
||||
fields: PropertyEditorFieldDefinition[]
|
||||
component: UIComponent
|
||||
onChange: (key: string, value: unknown) => void
|
||||
}
|
||||
|
||||
export function PropertyEditorSection({ title, fields, component, onChange }: PropertyEditorSectionProps) {
|
||||
return (
|
||||
<Stack spacing="md">
|
||||
<Text variant="caption" className="font-semibold uppercase tracking-wide">
|
||||
{title}
|
||||
</Text>
|
||||
{fields.map((field) => (
|
||||
<PropertyEditorField
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
value={component.props?.[field.name]}
|
||||
type={field.type}
|
||||
options={field.options}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import config from '@/data/schema-editor/property-editor.json'
|
||||
|
||||
export type PropertyEditorFieldType = 'text' | 'select' | 'boolean' | 'textarea' | 'number'
|
||||
|
||||
export interface PropertyEditorOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface PropertyEditorFieldDefinition {
|
||||
name: string
|
||||
label: string
|
||||
type: PropertyEditorFieldType
|
||||
options?: PropertyEditorOption[]
|
||||
}
|
||||
|
||||
export interface PropertyEditorConfig {
|
||||
icon: string
|
||||
title: string
|
||||
emptyState: {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
sections: {
|
||||
componentProps: string
|
||||
commonProps: string
|
||||
}
|
||||
commonProps: PropertyEditorFieldDefinition[]
|
||||
typeSpecificProps: Record<string, PropertyEditorFieldDefinition[]>
|
||||
}
|
||||
|
||||
export const propertyEditorConfig = config as PropertyEditorConfig
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Sliders } from '@phosphor-icons/react'
|
||||
|
||||
export const propertyEditorIcons = {
|
||||
sliders: Sliders,
|
||||
}
|
||||
158
src/components/schema-editor/SchemaEditorWorkspace.tsx
Normal file
158
src/components/schema-editor/SchemaEditorWorkspace.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
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 { SchemaEditorLayout } from '@/components/organisms'
|
||||
import { ComponentDefinition } from '@/lib/component-definitions'
|
||||
import { UIComponent, PageSchema } from '@/types/json-ui'
|
||||
import { toast } from 'sonner'
|
||||
import { schemaEditorConfig } from '@/components/schema-editor/schemaEditorConfig'
|
||||
|
||||
export function SchemaEditorWorkspace() {
|
||||
const {
|
||||
components,
|
||||
selectedId,
|
||||
hoveredId,
|
||||
setSelectedId,
|
||||
setHoveredId,
|
||||
findComponentById,
|
||||
addComponent,
|
||||
updateComponent,
|
||||
deleteComponent,
|
||||
moveComponent,
|
||||
clearAll,
|
||||
} = useSchemaEditor()
|
||||
|
||||
const {
|
||||
draggedItem,
|
||||
dropTarget,
|
||||
dropPosition,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
} = useDragDrop()
|
||||
|
||||
const { exportToJson, copyToClipboard, importFromJson } = useJsonExport()
|
||||
|
||||
const createSchema = (): PageSchema => ({
|
||||
id: schemaEditorConfig.schema.id,
|
||||
name: schemaEditorConfig.schema.name,
|
||||
layout: schemaEditorConfig.schema.layout,
|
||||
dataSources: [],
|
||||
components,
|
||||
})
|
||||
|
||||
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 = () => {
|
||||
exportToJson(createSchema(), schemaEditorConfig.export.fileName)
|
||||
}
|
||||
|
||||
const handleCopyJson = () => {
|
||||
copyToClipboard(createSchema())
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = schemaEditorConfig.import.accept
|
||||
input.onchange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = 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(schemaEditorConfig.preview.message)
|
||||
}
|
||||
|
||||
const selectedComponent = selectedId ? findComponentById(selectedId) : null
|
||||
|
||||
return (
|
||||
<SchemaEditorLayout
|
||||
components={components}
|
||||
selectedId={selectedId}
|
||||
hoveredId={hoveredId}
|
||||
draggedOverId={dropTarget}
|
||||
dropPosition={dropPosition}
|
||||
selectedComponent={selectedComponent}
|
||||
onSelect={setSelectedId}
|
||||
onHover={setHoveredId}
|
||||
onHoverEnd={() => setHoveredId(null)}
|
||||
onComponentDragStart={handleComponentDragStart}
|
||||
onTreeDragStart={handleComponentTreeDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
onUpdate={(updates) => {
|
||||
if (selectedId) {
|
||||
updateComponent(selectedId, updates)
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (selectedId) {
|
||||
deleteComponent(selectedId)
|
||||
}
|
||||
}}
|
||||
onImport={handleImport}
|
||||
onExport={handleExportJson}
|
||||
onCopy={handleCopyJson}
|
||||
onPreview={handlePreview}
|
||||
onClear={clearAll}
|
||||
/>
|
||||
)
|
||||
}
|
||||
22
src/components/schema-editor/schemaEditorConfig.ts
Normal file
22
src/components/schema-editor/schemaEditorConfig.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import config from '@/data/schema-editor/schema-editor-page.json'
|
||||
|
||||
export interface SchemaEditorConfig {
|
||||
schema: {
|
||||
id: string
|
||||
name: string
|
||||
layout: {
|
||||
type: string
|
||||
}
|
||||
}
|
||||
export: {
|
||||
fileName: string
|
||||
}
|
||||
import: {
|
||||
accept: string
|
||||
}
|
||||
preview: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export const schemaEditorConfig = config as SchemaEditorConfig
|
||||
16
src/data/schema-editor/component-tree.json
Normal file
16
src/data/schema-editor/component-tree.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"icon": "tree",
|
||||
"title": "Component Tree",
|
||||
"subtitle": {
|
||||
"singular": "component",
|
||||
"plural": "components"
|
||||
},
|
||||
"tooltips": {
|
||||
"expandAll": "Expand All",
|
||||
"collapseAll": "Collapse All"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "No components yet",
|
||||
"description": "Drag components from the palette"
|
||||
}
|
||||
}
|
||||
91
src/data/schema-editor/property-editor.json
Normal file
91
src/data/schema-editor/property-editor.json
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"icon": "sliders",
|
||||
"title": "Properties",
|
||||
"emptyState": {
|
||||
"title": "No component selected",
|
||||
"description": "Select a component to edit its properties"
|
||||
},
|
||||
"sections": {
|
||||
"componentProps": "Component Properties",
|
||||
"commonProps": "Common Properties"
|
||||
},
|
||||
"commonProps": [
|
||||
{
|
||||
"name": "className",
|
||||
"label": "CSS Classes",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"typeSpecificProps": {
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
18
src/data/schema-editor/schema-editor-page.json
Normal file
18
src/data/schema-editor/schema-editor-page.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema": {
|
||||
"id": "custom-page",
|
||||
"name": "Custom Page",
|
||||
"layout": {
|
||||
"type": "single"
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"fileName": "schema.json"
|
||||
},
|
||||
"import": {
|
||||
"accept": ".json"
|
||||
},
|
||||
"preview": {
|
||||
"message": "Preview mode coming soon"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user