Refactor schema editor views

This commit is contained in:
2026-01-18 00:43:31 +00:00
parent 1d6c968386
commit 1f8f584a13
18 changed files with 687 additions and 375 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
)
})}
</>
)
}

View File

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

View File

@@ -0,0 +1,5 @@
import { Tree } from '@phosphor-icons/react'
export const componentTreeIcons = {
tree: Tree,
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { Sliders } from '@phosphor-icons/react'
export const propertyEditorIcons = {
sliders: Sliders,
}

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

View 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

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

View 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" }
]
}
}

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