diff --git a/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx index fb8da16ef..00715843f 100644 --- a/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx +++ b/frontends/nextjs/src/components/managers/component/ComponentHierarchyEditor.tsx @@ -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 - selectedNodeId: string | null - expandedNodes: Set - 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 ( -
-
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 ? ( - - ) : ( -
- )} - -
- -
- - {node.type} - - - {node.order} - - -
- - -
-
- - {hasChildren && isExpanded && ( -
- {node.childIds - .sort((a, b) => hierarchy[a].order - hierarchy[b].order) - .map((childId) => ( - - ))} -
- )} -
- ) -} +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([]) - const [selectedPageId, setSelectedPageId] = useState('') - const [hierarchy, setHierarchy] = useState>({}) - const [selectedNodeId, setSelectedNodeId] = useState(null) - const [expandedNodes, setExpandedNodes] = useState>(new Set()) - const [draggingNodeId, setDraggingNodeId] = useState(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(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 ? ( +
+ +

No components yet. Add one from the catalog!

+
+ ) : ( +
+ {rootNodes.map((node) => ( + + ))} +
+ ), + [ + expandedNodes, + handleDeleteNode, + handleDragOver, + handleDragStart, + handleDrop, + handleToggleNode, + hierarchy, + rootNodes, + selectedNodeId, + draggingNodeId, + setConfigNodeId, + setSelectedNodeId, + ] + ) return (
@@ -368,32 +191,7 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool {selectedPageId ? ( - getRootNodes().length === 0 ? ( -
- -

No components yet. Add one from the catalog!

-
- ) : ( -
- {getRootNodes().map((node) => ( - - ))} -
- ) + renderTree ) : (

Select a page to edit its component hierarchy

diff --git a/frontends/nextjs/src/components/managers/component/modules/TreeNode.tsx b/frontends/nextjs/src/components/managers/component/modules/TreeNode.tsx new file mode 100644 index 000000000..2a4b2f132 --- /dev/null +++ b/frontends/nextjs/src/components/managers/component/modules/TreeNode.tsx @@ -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 + selectedNodeId: string | null + expandedNodes: Set + 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 ( +
+
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 ? ( + + ) : ( +
+ )} + +
+ +
+ + {node.type} + + + {node.order} + + +
+ + +
+ + {componentDef?.allowsChildren && ( +
can nest
+ )} +
+ + {hasChildren && isExpanded && ( +
+ {node.childIds + .map((childId) => hierarchy[childId]) + .filter(Boolean) + .sort((a, b) => a.order - b.order) + .map((child) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontends/nextjs/src/components/managers/component/modules/useHierarchyData.ts b/frontends/nextjs/src/components/managers/component/modules/useHierarchyData.ts new file mode 100644 index 000000000..5e566b492 --- /dev/null +++ b/frontends/nextjs/src/components/managers/component/modules/useHierarchyData.ts @@ -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 + loadHierarchy: () => Promise +} + +export function useHierarchyData(): UseHierarchyDataResult { + const [pages, setPages] = useState([]) + const [selectedPageId, setSelectedPageId] = useState('') + const [hierarchy, setHierarchy] = useState>({}) + + 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, + } +} diff --git a/frontends/nextjs/src/components/managers/component/modules/useHierarchyDragDrop.ts b/frontends/nextjs/src/components/managers/component/modules/useHierarchyDragDrop.ts new file mode 100644 index 000000000..122bbccdc --- /dev/null +++ b/frontends/nextjs/src/components/managers/component/modules/useHierarchyDragDrop.ts @@ -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 + loadHierarchy: () => Promise +} + +export function useHierarchyDragDrop({ hierarchy, loadHierarchy }: UseHierarchyDragDropProps) { + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [expandedNodes, setExpandedNodes] = useState>(new Set()) + const [draggingNodeId, setDraggingNodeId] = useState(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, + } +}