Merge pull request #35 from johndoe6345789/codex/refactor-componenttreebuilder-into-subcomponents

Refactor component tree builder into subcomponents
This commit is contained in:
2026-01-18 00:28:42 +00:00
committed by GitHub
6 changed files with 339 additions and 227 deletions

View File

@@ -1,43 +1,25 @@
import { useState } from 'react'
import { ComponentNode } from '@/types/project'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Plus, Trash, Tree, CaretRight, CaretDown, Sparkle } from '@phosphor-icons/react'
import { Textarea } from '@/components/ui/textarea'
import { AIService } from '@/lib/ai-service'
import { toast } from 'sonner'
import componentTreeBuilderData from '@/data/component-tree-builder.json'
import { ComponentInspector } from '@/components/component-tree-builder/ComponentInspector'
import { ComponentTreeToolbar } from '@/components/component-tree-builder/ComponentTreeToolbar'
import { ComponentTreeView } from '@/components/component-tree-builder/ComponentTreeView'
import {
addChildNode,
createComponentNode,
deleteNodeFromTree,
findNodeById,
updateNodeInTree,
} from '@/components/component-tree-builder/tree-utils'
interface ComponentTreeBuilderProps {
components: ComponentNode[]
onComponentsChange: (components: ComponentNode[]) => void
}
const MUI_COMPONENTS = [
'Box',
'Container',
'Grid',
'Stack',
'Paper',
'Card',
'CardContent',
'CardActions',
'Button',
'TextField',
'Typography',
'AppBar',
'Toolbar',
'List',
'ListItem',
'ListItemText',
'Divider',
'Avatar',
'Chip',
'IconButton',
]
const { muiComponents, prompts } = componentTreeBuilderData
export function ComponentTreeBuilder({
components,
@@ -46,79 +28,34 @@ export function ComponentTreeBuilder({
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
const findNodeById = (
nodes: ComponentNode[],
id: string
): ComponentNode | null => {
for (const node of nodes) {
if (node.id === id) return node
const found = findNodeById(node.children, id)
if (found) return found
}
return null
}
const selectedNode = selectedNodeId ? findNodeById(components, selectedNodeId) : null
const selectedNode = selectedNodeId
? findNodeById(components, selectedNodeId)
: null
const addRootComponent = () => {
const newNode: ComponentNode = {
id: `node-${Date.now()}`,
type: 'Box',
const newNode = createComponentNode({
name: `Component${components.length + 1}`,
props: {},
children: [],
}
})
onComponentsChange([...components, newNode])
setSelectedNodeId(newNode.id)
}
const addChildComponent = (parentId: string) => {
const newNode: ComponentNode = {
id: `node-${Date.now()}`,
type: 'Box',
name: 'NewComponent',
props: {},
children: [],
}
const addChild = (nodes: ComponentNode[]): ComponentNode[] => {
return nodes.map((node) => {
if (node.id === parentId) {
return { ...node, children: [...node.children, newNode] }
}
return { ...node, children: addChild(node.children) }
})
}
onComponentsChange(addChild(components))
const newNode = createComponentNode()
onComponentsChange(addChildNode(components, parentId, newNode))
setExpandedNodes(new Set([...expandedNodes, parentId]))
setSelectedNodeId(newNode.id)
}
const deleteNode = (nodeId: string) => {
const deleteFromTree = (nodes: ComponentNode[]): ComponentNode[] => {
return nodes
.filter((node) => node.id !== nodeId)
.map((node) => ({ ...node, children: deleteFromTree(node.children) }))
}
onComponentsChange(deleteFromTree(components))
onComponentsChange(deleteNodeFromTree(components, nodeId))
if (selectedNodeId === nodeId) {
setSelectedNodeId(null)
}
}
const updateNode = (nodeId: string, updates: Partial<ComponentNode>) => {
const updateInTree = (nodes: ComponentNode[]): ComponentNode[] => {
return nodes.map((node) => {
if (node.id === nodeId) {
return { ...node, ...updates }
}
return { ...node, children: updateInTree(node.children) }
})
}
onComponentsChange(updateInTree(components))
onComponentsChange(updateNodeInTree(components, nodeId, updates))
}
const toggleExpand = (nodeId: string) => {
@@ -132,13 +69,13 @@ export function ComponentTreeBuilder({
}
const generateComponentWithAI = async () => {
const description = prompt('Describe the component you want to create:')
const description = prompt(prompts.generateComponentDescription)
if (!description) return
try {
toast.info('Generating component with AI...')
const component = await AIService.generateComponent(description)
if (component) {
onComponentsChange([...components, component])
setSelectedNodeId(component.id)
@@ -153,151 +90,28 @@ export function ComponentTreeBuilder({
}
}
const renderTreeNode = (node: ComponentNode, level: number = 0) => {
const isExpanded = expandedNodes.has(node.id)
const isSelected = selectedNodeId === node.id
const hasChildren = node.children.length > 0
return (
<div key={node.id}>
<button
onClick={() => setSelectedNodeId(node.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded text-sm transition-colors ${
isSelected
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted text-foreground'
}`}
style={{ paddingLeft: `${level * 20 + 12}px` }}
>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpand(node.id)
}}
className="hover:text-accent"
>
{isExpanded ? <CaretDown size={16} /> : <CaretRight size={16} />}
</button>
)}
{!hasChildren && <div className="w-4" />}
<Tree size={16} />
<span className="font-medium">{node.name}</span>
<span className="text-muted-foreground text-xs ml-auto">{node.type}</span>
</button>
{isExpanded &&
node.children.map((child) => renderTreeNode(child, level + 1))}
</div>
)
}
return (
<div className="h-full flex gap-4 p-6">
<div className="w-80 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm uppercase tracking-wide">
Component Tree
</h3>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={generateComponentWithAI}
className="h-8 w-8 p-0"
title="Generate component with AI"
>
<Sparkle size={16} weight="duotone" />
</Button>
<Button size="sm" onClick={addRootComponent} className="h-8 w-8 p-0">
<Plus size={16} />
</Button>
</div>
</div>
<ScrollArea className="flex-1 border rounded-lg">
<div className="p-2 space-y-1">
{components.map((node) => renderTreeNode(node))}
</div>
</ScrollArea>
<ComponentTreeToolbar
onGenerate={generateComponentWithAI}
onAddRoot={addRootComponent}
/>
<ComponentTreeView
nodes={components}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
onSelectNode={setSelectedNodeId}
onToggleExpand={toggleExpand}
/>
</div>
<Card className="flex-1 p-6">
{selectedNode ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold">Component Properties</h4>
<Button
variant="destructive"
size="sm"
onClick={() => deleteNode(selectedNode.id)}
>
<Trash size={16} />
</Button>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label>Component Name</Label>
<Input
value={selectedNode.name}
onChange={(e) =>
updateNode(selectedNode.id, { name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Component Type</Label>
<Select
value={selectedNode.type}
onValueChange={(value) =>
updateNode(selectedNode.id, { type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MUI_COMPONENTS.map((comp) => (
<SelectItem key={comp} value={comp}>
{comp}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Props (JSON)</Label>
<Textarea
value={JSON.stringify(selectedNode.props, null, 2)}
onChange={(e) => {
try {
const props = JSON.parse(e.target.value)
updateNode(selectedNode.id, { props })
} catch (err) {
// Invalid JSON while typing - ignore
}
}}
className="font-mono text-sm h-64"
placeholder='{"variant": "contained", "color": "primary"}'
/>
</div>
<Button onClick={() => addChildComponent(selectedNode.id)}>
<Plus size={16} className="mr-2" />
Add Child Component
</Button>
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Tree size={48} className="mx-auto mb-4 opacity-50" />
<p>Select a component to edit properties</p>
</div>
</div>
)}
</Card>
<ComponentInspector
selectedNode={selectedNode}
muiComponents={muiComponents}
onDelete={deleteNode}
onUpdate={updateNode}
onAddChild={addChildComponent}
/>
</div>
)
}

View File

@@ -0,0 +1,105 @@
import { ComponentNode } from '@/types/project'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Plus, Trash, Tree } from '@phosphor-icons/react'
interface ComponentInspectorProps {
selectedNode: ComponentNode | null
muiComponents: string[]
onDelete: (nodeId: string) => void
onUpdate: (nodeId: string, updates: Partial<ComponentNode>) => void
onAddChild: (parentId: string) => void
}
export function ComponentInspector({
selectedNode,
muiComponents,
onDelete,
onUpdate,
onAddChild,
}: ComponentInspectorProps) {
return (
<Card className="flex-1 p-6">
{selectedNode ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold">Component Properties</h4>
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(selectedNode.id)}
>
<Trash size={16} />
</Button>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label>Component Name</Label>
<Input
value={selectedNode.name}
onChange={(event) =>
onUpdate(selectedNode.id, { name: event.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Component Type</Label>
<Select
value={selectedNode.type}
onValueChange={(value) =>
onUpdate(selectedNode.id, { type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{muiComponents.map((comp) => (
<SelectItem key={comp} value={comp}>
{comp}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Props (JSON)</Label>
<Textarea
value={JSON.stringify(selectedNode.props, null, 2)}
onChange={(event) => {
try {
const props = JSON.parse(event.target.value)
onUpdate(selectedNode.id, { props })
} catch (err) {
// Invalid JSON while typing - ignore
}
}}
className="font-mono text-sm h-64"
placeholder='{"variant": "contained", "color": "primary"}'
/>
</div>
<Button onClick={() => onAddChild(selectedNode.id)}>
<Plus size={16} className="mr-2" />
Add Child Component
</Button>
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Tree size={48} className="mx-auto mb-4 opacity-50" />
<p>Select a component to edit properties</p>
</div>
</div>
)}
</Card>
)
}

View File

@@ -0,0 +1,34 @@
import { Button } from '@/components/ui/button'
import { Plus, Sparkle } from '@phosphor-icons/react'
interface ComponentTreeToolbarProps {
onGenerate: () => void
onAddRoot: () => void
}
export function ComponentTreeToolbar({
onGenerate,
onAddRoot,
}: ComponentTreeToolbarProps) {
return (
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm uppercase tracking-wide">
Component Tree
</h3>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={onGenerate}
className="h-8 w-8 p-0"
title="Generate component with AI"
>
<Sparkle size={16} weight="duotone" />
</Button>
<Button size="sm" onClick={onAddRoot} className="h-8 w-8 p-0">
<Plus size={16} />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { ComponentNode } from '@/types/project'
import { ScrollArea } from '@/components/ui/scroll-area'
import { CaretDown, CaretRight, Tree } from '@phosphor-icons/react'
interface ComponentTreeViewProps {
nodes: ComponentNode[]
selectedNodeId: string | null
expandedNodes: Set<string>
onSelectNode: (nodeId: string) => void
onToggleExpand: (nodeId: string) => void
}
export function ComponentTreeView({
nodes,
selectedNodeId,
expandedNodes,
onSelectNode,
onToggleExpand,
}: ComponentTreeViewProps) {
const renderTreeNode = (node: ComponentNode, level: number = 0) => {
const isExpanded = expandedNodes.has(node.id)
const isSelected = selectedNodeId === node.id
const hasChildren = node.children.length > 0
return (
<div key={node.id}>
<div
role="button"
tabIndex={0}
onClick={() => onSelectNode(node.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelectNode(node.id)
}
}}
className={`w-full flex items-center gap-2 px-3 py-2 rounded text-sm transition-colors cursor-pointer ${
isSelected
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted text-foreground'
}`}
style={{ paddingLeft: `${level * 20 + 12}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onToggleExpand(node.id)
}}
className="hover:text-accent"
aria-label={isExpanded ? 'Collapse node' : 'Expand node'}
>
{isExpanded ? <CaretDown size={16} /> : <CaretRight size={16} />}
</button>
) : (
<div className="w-4" />
)}
<Tree size={16} />
<span className="font-medium">{node.name}</span>
<span className="text-muted-foreground text-xs ml-auto">
{node.type}
</span>
</div>
{isExpanded &&
node.children.map((child) => renderTreeNode(child, level + 1))}
</div>
)
}
return (
<ScrollArea className="flex-1 border rounded-lg">
<div className="p-2 space-y-1">{nodes.map((node) => renderTreeNode(node))}</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,56 @@
import { ComponentNode } from '@/types/project'
export const createComponentNode = (
overrides: Partial<ComponentNode> = {}
): ComponentNode => ({
id: `node-${Date.now()}`,
type: 'Box',
name: 'NewComponent',
props: {},
children: [],
...overrides,
})
export const findNodeById = (
nodes: ComponentNode[],
id: string
): ComponentNode | null => {
for (const node of nodes) {
if (node.id === id) return node
const found = findNodeById(node.children, id)
if (found) return found
}
return null
}
export const addChildNode = (
nodes: ComponentNode[],
parentId: string,
childNode: ComponentNode
): ComponentNode[] =>
nodes.map((node) => {
if (node.id === parentId) {
return { ...node, children: [...node.children, childNode] }
}
return { ...node, children: addChildNode(node.children, parentId, childNode) }
})
export const deleteNodeFromTree = (
nodes: ComponentNode[],
nodeId: string
): ComponentNode[] =>
nodes
.filter((node) => node.id !== nodeId)
.map((node) => ({ ...node, children: deleteNodeFromTree(node.children, nodeId) }))
export const updateNodeInTree = (
nodes: ComponentNode[],
nodeId: string,
updates: Partial<ComponentNode>
): ComponentNode[] =>
nodes.map((node) => {
if (node.id === nodeId) {
return { ...node, ...updates }
}
return { ...node, children: updateNodeInTree(node.children, nodeId, updates) }
})

View File

@@ -0,0 +1,27 @@
{
"muiComponents": [
"Box",
"Container",
"Grid",
"Stack",
"Paper",
"Card",
"CardContent",
"CardActions",
"Button",
"TextField",
"Typography",
"AppBar",
"Toolbar",
"List",
"ListItem",
"ListItemText",
"Divider",
"Avatar",
"Chip",
"IconButton"
],
"prompts": {
"generateComponentDescription": "Describe the component you want to create:"
}
}