mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 22:04:56 +00:00
Merge pull request #180 from johndoe6345789/codex/organize-components-and-extract-logic
Refactor component hierarchy editor into modular hooks
This commit is contained in:
@@ -1,333 +1,156 @@
|
||||
import { useState, useEffect, useCallback, useId } from 'react'
|
||||
import { useCallback, useId, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import {
|
||||
Tree,
|
||||
Plus,
|
||||
Trash,
|
||||
GearSix,
|
||||
ArrowsOutCardinal,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
Cursor
|
||||
import {
|
||||
ArrowsOutCardinal,
|
||||
Cursor,
|
||||
Plus,
|
||||
Tree,
|
||||
} from '@phosphor-icons/react'
|
||||
import { Database, ComponentNode } from '@/lib/database'
|
||||
import { Database, type ComponentNode } from '@/lib/database'
|
||||
import { componentCatalog } from '@/lib/components/component-catalog'
|
||||
import { toast } from 'sonner'
|
||||
import type { PageConfig } from '@/lib/level-types'
|
||||
import { ComponentConfigDialog } from './ComponentConfigDialog'
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: ComponentNode
|
||||
hierarchy: Record<string, ComponentNode>
|
||||
selectedNodeId: string | null
|
||||
expandedNodes: Set<string>
|
||||
onSelect: (nodeId: string) => void
|
||||
onToggle: (nodeId: string) => void
|
||||
onDelete: (nodeId: string) => void
|
||||
onConfig: (nodeId: string) => void
|
||||
onDragStart: (nodeId: string) => void
|
||||
onDragOver: (e: React.DragEvent, nodeId: string) => void
|
||||
onDrop: (e: React.DragEvent, targetNodeId: string) => void
|
||||
draggingNodeId: string | null
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
hierarchy,
|
||||
selectedNodeId,
|
||||
expandedNodes,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onConfig,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
draggingNodeId,
|
||||
}: TreeNodeProps) {
|
||||
const hasChildren = node.childIds.length > 0
|
||||
const isExpanded = expandedNodes.has(node.id)
|
||||
const isSelected = selectedNodeId === node.id
|
||||
const isDragging = draggingNodeId === node.id
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === node.type)
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={() => onDragStart(node.id)}
|
||||
onDragOver={(e) => onDragOver(e, node.id)}
|
||||
onDrop={(e) => onDrop(e, node.id)}
|
||||
className={`
|
||||
flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer
|
||||
hover:bg-accent transition-colors group
|
||||
${isSelected ? 'bg-accent' : ''}
|
||||
${isDragging ? 'opacity-50' : ''}
|
||||
`}
|
||||
onClick={() => onSelect(node.id)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(node.id)
|
||||
}}
|
||||
className="hover:bg-secondary rounded p-0.5"
|
||||
>
|
||||
{isExpanded ? <CaretDown size={14} /> : <CaretRight size={14} />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-[14px]" />
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Tree size={16} />
|
||||
</div>
|
||||
|
||||
<span className="flex-1 text-sm font-medium">{node.type}</span>
|
||||
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.order}
|
||||
</Badge>
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfig(node.id)
|
||||
}}
|
||||
>
|
||||
<GearSix size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(node.id)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="ml-4 border-l border-border pl-2">
|
||||
{node.childIds
|
||||
.sort((a, b) => hierarchy[a].order - hierarchy[b].order)
|
||||
.map((childId) => (
|
||||
<TreeNode
|
||||
key={childId}
|
||||
node={hierarchy[childId]}
|
||||
hierarchy={hierarchy}
|
||||
selectedNodeId={selectedNodeId}
|
||||
expandedNodes={expandedNodes}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onConfig={onConfig}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
draggingNodeId={draggingNodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { TreeNode } from './modules/TreeNode'
|
||||
import { useHierarchyData } from './modules/useHierarchyData'
|
||||
import { useHierarchyDragDrop } from './modules/useHierarchyDragDrop'
|
||||
|
||||
export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: boolean }) {
|
||||
const [pages, setPages] = useState<PageConfig[]>([])
|
||||
const [selectedPageId, setSelectedPageId] = useState<string>('')
|
||||
const [hierarchy, setHierarchy] = useState<Record<string, ComponentNode>>({})
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
|
||||
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
|
||||
const { pages, selectedPageId, setSelectedPageId, hierarchy, loadHierarchy } = useHierarchyData()
|
||||
const {
|
||||
selectedNodeId,
|
||||
setSelectedNodeId,
|
||||
expandedNodes,
|
||||
draggingNodeId,
|
||||
handleToggleNode,
|
||||
handleExpandAll,
|
||||
handleCollapseAll,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
expandNode,
|
||||
} = useHierarchyDragDrop({ hierarchy, loadHierarchy })
|
||||
const [configNodeId, setConfigNodeId] = useState<string | null>(null)
|
||||
const componentIdPrefix = useId()
|
||||
|
||||
const loadPages = useCallback(async () => {
|
||||
const loadedPages = await Database.getPages()
|
||||
setPages(loadedPages)
|
||||
if (loadedPages.length > 0 && !selectedPageId) {
|
||||
setSelectedPageId(loadedPages[0].id)
|
||||
}
|
||||
}, [selectedPageId])
|
||||
const rootNodes = useMemo(
|
||||
() =>
|
||||
Object.values(hierarchy)
|
||||
.filter(node => node.pageId === selectedPageId && !node.parentId)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[hierarchy, selectedPageId]
|
||||
)
|
||||
|
||||
const loadHierarchy = useCallback(async () => {
|
||||
const allHierarchy = await Database.getComponentHierarchy()
|
||||
setHierarchy(allHierarchy)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPages()
|
||||
loadHierarchy()
|
||||
}, [loadPages, loadHierarchy])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPageId) {
|
||||
loadHierarchy()
|
||||
}
|
||||
}, [selectedPageId, loadHierarchy])
|
||||
|
||||
const getRootNodes = () => {
|
||||
return Object.values(hierarchy)
|
||||
.filter(node => node.pageId === selectedPageId && !node.parentId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
const handleAddComponent = async (componentType: string, parentId?: string) => {
|
||||
if (!selectedPageId) {
|
||||
toast.error('Please select a page first')
|
||||
return
|
||||
}
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === componentType)
|
||||
if (!componentDef) return
|
||||
|
||||
const newNode: ComponentNode = {
|
||||
id: `node_${componentIdPrefix}_${Object.keys(hierarchy).length}`,
|
||||
type: componentType,
|
||||
parentId: parentId,
|
||||
childIds: [],
|
||||
order: parentId
|
||||
? hierarchy[parentId]?.childIds.length || 0
|
||||
: getRootNodes().length,
|
||||
pageId: selectedPageId,
|
||||
}
|
||||
|
||||
if (parentId && hierarchy[parentId]) {
|
||||
await Database.updateComponentNode(parentId, {
|
||||
childIds: [...hierarchy[parentId].childIds, newNode.id],
|
||||
})
|
||||
}
|
||||
|
||||
await Database.addComponentNode(newNode)
|
||||
await loadHierarchy()
|
||||
setExpandedNodes(prev => new Set([...prev, parentId || '']))
|
||||
toast.success(`Added ${componentType}`)
|
||||
}
|
||||
|
||||
const handleDeleteNode = async (nodeId: string) => {
|
||||
if (!confirm('Delete this component and all its children?')) return
|
||||
|
||||
const node = hierarchy[nodeId]
|
||||
if (!node) return
|
||||
|
||||
const deleteRecursive = async (id: string) => {
|
||||
const n = hierarchy[id]
|
||||
if (!n) return
|
||||
|
||||
for (const childId of n.childIds) {
|
||||
await deleteRecursive(childId)
|
||||
const handleAddComponent = useCallback(
|
||||
async (componentType: string, parentId?: string) => {
|
||||
if (!selectedPageId) {
|
||||
toast.error('Please select a page first')
|
||||
return
|
||||
}
|
||||
await Database.deleteComponentNode(id)
|
||||
}
|
||||
|
||||
if (node.parentId && hierarchy[node.parentId]) {
|
||||
const parent = hierarchy[node.parentId]
|
||||
await Database.updateComponentNode(node.parentId, {
|
||||
childIds: parent.childIds.filter(id => id !== nodeId),
|
||||
})
|
||||
}
|
||||
const componentDef = componentCatalog.find(c => c.type === componentType)
|
||||
if (!componentDef) return
|
||||
|
||||
await deleteRecursive(nodeId)
|
||||
await loadHierarchy()
|
||||
toast.success('Component deleted')
|
||||
}
|
||||
|
||||
const handleToggleNode = (nodeId: string) => {
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId)
|
||||
} else {
|
||||
next.add(nodeId)
|
||||
const newNode: ComponentNode = {
|
||||
id: `node_${componentIdPrefix}_${Object.keys(hierarchy).length}`,
|
||||
type: componentType,
|
||||
parentId: parentId,
|
||||
childIds: [],
|
||||
order: parentId ? hierarchy[parentId]?.childIds.length || 0 : rootNodes.length,
|
||||
pageId: selectedPageId,
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragStart = (nodeId: string) => {
|
||||
setDraggingNodeId(nodeId)
|
||||
}
|
||||
if (parentId && hierarchy[parentId]) {
|
||||
await Database.updateComponentNode(parentId, {
|
||||
childIds: [...hierarchy[parentId].childIds, newNode.id],
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, nodeId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
await Database.addComponentNode(newNode)
|
||||
await loadHierarchy()
|
||||
expandNode(parentId)
|
||||
toast.success(`Added ${componentType}`)
|
||||
},
|
||||
[componentIdPrefix, expandNode, hierarchy, loadHierarchy, rootNodes.length, selectedPageId]
|
||||
)
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetNodeId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const handleDeleteNode = useCallback(
|
||||
async (nodeId: string) => {
|
||||
if (!confirm('Delete this component and all its children?')) return
|
||||
|
||||
if (!draggingNodeId || draggingNodeId === targetNodeId) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
const node = hierarchy[nodeId]
|
||||
if (!node) return
|
||||
|
||||
const draggedNode = hierarchy[draggingNodeId]
|
||||
const targetNode = hierarchy[targetNodeId]
|
||||
const deleteRecursive = async (id: string) => {
|
||||
const n = hierarchy[id]
|
||||
if (!n) return
|
||||
|
||||
if (!draggedNode || !targetNode) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
for (const childId of n.childIds) {
|
||||
await deleteRecursive(childId)
|
||||
}
|
||||
await Database.deleteComponentNode(id)
|
||||
}
|
||||
|
||||
if (targetNode.childIds.includes(draggingNodeId)) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
if (node.parentId && hierarchy[node.parentId]) {
|
||||
const parent = hierarchy[node.parentId]
|
||||
await Database.updateComponentNode(node.parentId, {
|
||||
childIds: parent.childIds.filter(id => id !== nodeId),
|
||||
})
|
||||
}
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === targetNode.type)
|
||||
if (!componentDef?.allowsChildren) {
|
||||
toast.error(`${targetNode.type} cannot contain children`)
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
await deleteRecursive(nodeId)
|
||||
await loadHierarchy()
|
||||
toast.success('Component deleted')
|
||||
},
|
||||
[hierarchy, loadHierarchy]
|
||||
)
|
||||
|
||||
if (draggedNode.parentId) {
|
||||
const oldParent = hierarchy[draggedNode.parentId]
|
||||
await Database.updateComponentNode(draggedNode.parentId, {
|
||||
childIds: oldParent.childIds.filter(id => id !== draggingNodeId),
|
||||
})
|
||||
}
|
||||
|
||||
await Database.updateComponentNode(targetNodeId, {
|
||||
childIds: [...targetNode.childIds, draggingNodeId],
|
||||
})
|
||||
|
||||
await Database.updateComponentNode(draggingNodeId, {
|
||||
parentId: targetNodeId,
|
||||
order: targetNode.childIds.length,
|
||||
})
|
||||
|
||||
setDraggingNodeId(null)
|
||||
setExpandedNodes(prev => new Set([...prev, targetNodeId]))
|
||||
await loadHierarchy()
|
||||
toast.success('Component moved')
|
||||
}
|
||||
|
||||
const handleExpandAll = () => {
|
||||
setExpandedNodes(new Set(Object.keys(hierarchy)))
|
||||
}
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpandedNodes(new Set())
|
||||
}
|
||||
const renderTree = useMemo(
|
||||
() =>
|
||||
rootNodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<Cursor size={48} className="mb-4" />
|
||||
<p>No components yet. Add one from the catalog!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{rootNodes.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
hierarchy={hierarchy}
|
||||
selectedNodeId={selectedNodeId}
|
||||
expandedNodes={expandedNodes}
|
||||
onSelect={setSelectedNodeId}
|
||||
onToggle={handleToggleNode}
|
||||
onDelete={handleDeleteNode}
|
||||
onConfig={setConfigNodeId}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
draggingNodeId={draggingNodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
expandedNodes,
|
||||
handleDeleteNode,
|
||||
handleDragOver,
|
||||
handleDragStart,
|
||||
handleDrop,
|
||||
handleToggleNode,
|
||||
hierarchy,
|
||||
rootNodes,
|
||||
selectedNodeId,
|
||||
draggingNodeId,
|
||||
setConfigNodeId,
|
||||
setSelectedNodeId,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-12rem)]">
|
||||
@@ -368,32 +191,7 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
|
||||
<CardContent className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full pr-4">
|
||||
{selectedPageId ? (
|
||||
getRootNodes().length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<Cursor size={48} className="mb-4" />
|
||||
<p>No components yet. Add one from the catalog!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{getRootNodes().map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
hierarchy={hierarchy}
|
||||
selectedNodeId={selectedNodeId}
|
||||
expandedNodes={expandedNodes}
|
||||
onSelect={setSelectedNodeId}
|
||||
onToggle={handleToggleNode}
|
||||
onDelete={handleDeleteNode}
|
||||
onConfig={setConfigNodeId}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
draggingNodeId={draggingNodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
renderTree
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<p>Select a page to edit its component hierarchy</p>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type React from 'react'
|
||||
import { CaretDown, CaretRight, GearSix, Trash, Tree } from '@phosphor-icons/react'
|
||||
import { Badge, Button } from '@/components/ui'
|
||||
import { componentCatalog } from '@/lib/components/component-catalog'
|
||||
import type { ComponentNode } from '@/lib/database'
|
||||
|
||||
export interface TreeNodeProps {
|
||||
node: ComponentNode
|
||||
hierarchy: Record<string, ComponentNode>
|
||||
selectedNodeId: string | null
|
||||
expandedNodes: Set<string>
|
||||
onSelect: (nodeId: string) => void
|
||||
onToggle: (nodeId: string) => void
|
||||
onDelete: (nodeId: string) => void
|
||||
onConfig: (nodeId: string) => void
|
||||
onDragStart: (nodeId: string) => void
|
||||
onDragOver: (e: React.DragEvent, nodeId: string) => void
|
||||
onDrop: (e: React.DragEvent, targetNodeId: string) => void
|
||||
draggingNodeId: string | null
|
||||
}
|
||||
|
||||
export function TreeNode({
|
||||
node,
|
||||
hierarchy,
|
||||
selectedNodeId,
|
||||
expandedNodes,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onConfig,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
draggingNodeId,
|
||||
}: TreeNodeProps) {
|
||||
const hasChildren = node.childIds.length > 0
|
||||
const isExpanded = expandedNodes.has(node.id)
|
||||
const isSelected = selectedNodeId === node.id
|
||||
const isDragging = draggingNodeId === node.id
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === node.type)
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={() => onDragStart(node.id)}
|
||||
onDragOver={(e) => onDragOver(e, node.id)}
|
||||
onDrop={(e) => onDrop(e, node.id)}
|
||||
className={`
|
||||
flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer
|
||||
hover:bg-accent transition-colors group
|
||||
${isSelected ? 'bg-accent' : ''}
|
||||
${isDragging ? 'opacity-50' : ''}
|
||||
`}
|
||||
onClick={() => onSelect(node.id)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(node.id)
|
||||
}}
|
||||
className="hover:bg-secondary rounded p-0.5"
|
||||
>
|
||||
{isExpanded ? <CaretDown size={14} /> : <CaretRight size={14} />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-[14px]" />
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Tree size={16} />
|
||||
</div>
|
||||
|
||||
<span className="flex-1 text-sm font-medium">{node.type}</span>
|
||||
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.order}
|
||||
</Badge>
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfig(node.id)
|
||||
}}
|
||||
>
|
||||
<GearSix size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(node.id)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{componentDef?.allowsChildren && (
|
||||
<div className="text-xs text-muted-foreground">can nest</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="pl-6 space-y-1">
|
||||
{node.childIds
|
||||
.map((childId) => hierarchy[childId])
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
hierarchy={hierarchy}
|
||||
selectedNodeId={selectedNodeId}
|
||||
expandedNodes={expandedNodes}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onConfig={onConfig}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
draggingNodeId={draggingNodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Database, type ComponentNode } from '@/lib/database'
|
||||
import type { PageConfig } from '@/lib/level-types'
|
||||
|
||||
interface UseHierarchyDataResult {
|
||||
pages: PageConfig[]
|
||||
selectedPageId: string
|
||||
setSelectedPageId: (pageId: string) => void
|
||||
hierarchy: Record<string, ComponentNode>
|
||||
loadHierarchy: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useHierarchyData(): UseHierarchyDataResult {
|
||||
const [pages, setPages] = useState<PageConfig[]>([])
|
||||
const [selectedPageId, setSelectedPageId] = useState<string>('')
|
||||
const [hierarchy, setHierarchy] = useState<Record<string, ComponentNode>>({})
|
||||
|
||||
const loadPages = useCallback(async () => {
|
||||
const loadedPages = await Database.getPages()
|
||||
setPages(loadedPages)
|
||||
if (loadedPages.length > 0 && !selectedPageId) {
|
||||
setSelectedPageId(loadedPages[0].id)
|
||||
}
|
||||
}, [selectedPageId])
|
||||
|
||||
const loadHierarchy = useCallback(async () => {
|
||||
const allHierarchy = await Database.getComponentHierarchy()
|
||||
setHierarchy(allHierarchy)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPages()
|
||||
loadHierarchy()
|
||||
}, [loadPages, loadHierarchy])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPageId) {
|
||||
loadHierarchy()
|
||||
}
|
||||
}, [selectedPageId, loadHierarchy])
|
||||
|
||||
return {
|
||||
pages,
|
||||
selectedPageId,
|
||||
setSelectedPageId,
|
||||
hierarchy,
|
||||
loadHierarchy,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { componentCatalog } from '@/lib/components/component-catalog'
|
||||
import { Database, type ComponentNode } from '@/lib/database'
|
||||
|
||||
interface UseHierarchyDragDropProps {
|
||||
hierarchy: Record<string, ComponentNode>
|
||||
loadHierarchy: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useHierarchyDragDrop({ hierarchy, loadHierarchy }: UseHierarchyDragDropProps) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
|
||||
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
|
||||
|
||||
const handleToggleNode = useCallback((nodeId: string) => {
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId)
|
||||
} else {
|
||||
next.add(nodeId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleExpandAll = useCallback(() => {
|
||||
setExpandedNodes(new Set(Object.keys(hierarchy)))
|
||||
}, [hierarchy])
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setExpandedNodes(new Set())
|
||||
}, [])
|
||||
|
||||
const handleDragStart = useCallback((nodeId: string) => {
|
||||
setDraggingNodeId(nodeId)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, nodeId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setExpandedNodes(prev => new Set([...prev, nodeId]))
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent, targetNodeId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!draggingNodeId || draggingNodeId === targetNodeId) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
const draggedNode = hierarchy[draggingNodeId]
|
||||
const targetNode = hierarchy[targetNodeId]
|
||||
|
||||
if (!draggedNode || !targetNode) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (targetNode.childIds.includes(draggingNodeId)) {
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === targetNode.type)
|
||||
if (!componentDef?.allowsChildren) {
|
||||
toast.error(`${targetNode.type} cannot contain children`)
|
||||
setDraggingNodeId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (draggedNode.parentId) {
|
||||
const oldParent = hierarchy[draggedNode.parentId]
|
||||
await Database.updateComponentNode(draggedNode.parentId, {
|
||||
childIds: oldParent.childIds.filter(id => id !== draggingNodeId),
|
||||
})
|
||||
}
|
||||
|
||||
await Database.updateComponentNode(targetNodeId, {
|
||||
childIds: [...targetNode.childIds, draggingNodeId],
|
||||
})
|
||||
|
||||
await Database.updateComponentNode(draggingNodeId, {
|
||||
parentId: targetNodeId,
|
||||
order: targetNode.childIds.length,
|
||||
})
|
||||
|
||||
setDraggingNodeId(null)
|
||||
setExpandedNodes(prev => new Set([...prev, targetNodeId]))
|
||||
await loadHierarchy()
|
||||
toast.success('Component moved')
|
||||
},
|
||||
[draggingNodeId, hierarchy, loadHierarchy]
|
||||
)
|
||||
|
||||
const expandNode = useCallback((nodeId?: string | null) => {
|
||||
if (!nodeId) return
|
||||
setExpandedNodes(prev => new Set([...prev, nodeId]))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectedNodeId,
|
||||
setSelectedNodeId,
|
||||
expandedNodes,
|
||||
draggingNodeId,
|
||||
handleToggleNode,
|
||||
handleExpandAll,
|
||||
handleCollapseAll,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
expandNode,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user