refactor: extract dialog fields and hierarchy tree

This commit is contained in:
2025-12-27 18:48:15 +00:00
parent cadaa8c5fe
commit f57b41f86d
6 changed files with 364 additions and 267 deletions

View File

@@ -1,24 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Textarea } from '@/components/ui'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Switch } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
import { Database, ComponentNode, ComponentConfig } from '@/lib/database'
import { componentCatalog } from '@/lib/components/component-catalog'
import { toast } from 'sonner'
import type { PropDefinition } from '@/lib/builder-types'
/** Select option type for property schema options */
interface SelectOption {
value: string
label: string
}
import { ComponentConfigActions } from './ComponentConfigDialog/Actions'
import { ComponentConfigFields } from './ComponentConfigDialog/Fields'
interface ComponentConfigDialogProps {
node: ComponentNode
@@ -74,65 +60,6 @@ export function ComponentConfigDialog({ node, isOpen, onClose, onSave, nerdMode
const componentDef = componentCatalog.find(c => c.type === node.type)
const renderPropEditor = (propSchema: PropDefinition) => {
const value = props[propSchema.name] ?? propSchema.defaultValue
switch (propSchema.type) {
case 'string':
return (
<Input
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: e.target.value })}
placeholder={String(propSchema.defaultValue || '')}
/>
)
case 'number':
return (
<Input
type="number"
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: Number(e.target.value) })}
/>
)
case 'boolean':
return (
<Switch
checked={Boolean(value)}
onCheckedChange={(checked) => setProps({ ...props, [propSchema.name]: checked })}
/>
)
case 'select':
return (
<Select
value={String(value || propSchema.defaultValue || '')}
onValueChange={(val) => setProps({ ...props, [propSchema.name]: val })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{propSchema.options?.map((opt: SelectOption) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
default:
return (
<Input
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: e.target.value })}
/>
)
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh]">
@@ -143,147 +70,18 @@ export function ComponentConfigDialog({ node, isOpen, onClose, onSave, nerdMode
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="props" className="flex-1">
<TabsList className={nerdMode ? "grid w-full grid-cols-3" : "grid w-full grid-cols-2"}>
<TabsTrigger value="props">Properties</TabsTrigger>
<TabsTrigger value="styles">Styles</TabsTrigger>
{nerdMode && <TabsTrigger value="events">Events</TabsTrigger>}
</TabsList>
<ComponentConfigFields
componentDef={componentDef}
props={props}
setProps={setProps}
styles={styles}
setStyles={setStyles}
events={events}
setEvents={setEvents}
nerdMode={nerdMode}
/>
<ScrollArea className="h-[500px] mt-4">
<TabsContent value="props" className="space-y-4 px-1">
{componentDef?.propSchema && componentDef.propSchema.length > 0 ? (
componentDef.propSchema.map((propSchema) => (
<div key={propSchema.name} className="space-y-2">
<Label htmlFor={propSchema.name}>{propSchema.label}</Label>
{renderPropEditor(propSchema)}
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No configurable properties for this component</p>
</div>
)}
{nerdMode && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-sm">Custom Properties (JSON)</CardTitle>
<CardDescription className="text-xs">
Add additional props as JSON
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(props, null, 2)}
onChange={(e) => {
try {
setProps(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={6}
/>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="styles" className="space-y-4 px-1">
<div className="space-y-2">
<Label htmlFor="className">Tailwind Classes</Label>
<Input
id="className"
value={String(props.className || '')}
onChange={(e) => setProps({ ...props, className: e.target.value })}
placeholder="p-4 bg-white rounded-lg"
/>
</div>
{nerdMode && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-sm">Custom Styles (CSS-in-JS)</CardTitle>
<CardDescription className="text-xs">
Define inline styles as JSON object
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(styles, null, 2)}
onChange={(e) => {
try {
setStyles(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={12}
placeholder='{\n "backgroundColor": "#fff",\n "padding": "16px"\n}'
/>
</CardContent>
</Card>
)}
</TabsContent>
{nerdMode && (
<TabsContent value="events" className="space-y-4 px-1">
<Card>
<CardHeader>
<CardTitle className="text-sm">Event Handlers</CardTitle>
<CardDescription className="text-xs">
Map events to Lua script IDs
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{['onClick', 'onChange', 'onSubmit', 'onFocus', 'onBlur'].map((eventName) => (
<div key={eventName} className="space-y-2">
<Label htmlFor={eventName}>{eventName}</Label>
<Input
id={eventName}
value={events[eventName] || ''}
onChange={(e) => setEvents({ ...events, [eventName]: e.target.value })}
placeholder="lua_script_id"
/>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Custom Events (JSON)</CardTitle>
<CardDescription className="text-xs">
Define additional event handlers
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(events, null, 2)}
onChange={(e) => {
try {
setEvents(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={6}
/>
</CardContent>
</Card>
</TabsContent>
)}
</ScrollArea>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={() => void handleSave()}>Save Configuration</Button>
</DialogFooter>
<ComponentConfigActions onClose={onClose} onSave={handleSave} />
</DialogContent>
</Dialog>
)

View File

@@ -0,0 +1,20 @@
import { Button } from '@/components/ui'
import { DialogFooter } from '@/components/ui'
interface ComponentConfigActionsProps {
onClose: () => void
onSave: () => Promise<void> | void
}
export function ComponentConfigActions({ onClose, onSave }: ComponentConfigActionsProps) {
return (
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={() => void onSave()}>
Save Configuration
</Button>
</DialogFooter>
)
}

View File

@@ -0,0 +1,238 @@
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Textarea } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Switch } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import type { ComponentDefinition, PropDefinition } from '@/lib/components/types'
interface SelectOption {
value: string
label: string
}
interface ComponentConfigFieldsProps {
componentDef?: ComponentDefinition
props: Record<string, unknown>
setProps: (value: Record<string, unknown>) => void
styles: Record<string, unknown>
setStyles: (value: Record<string, unknown>) => void
events: Record<string, string>
setEvents: (value: Record<string, string>) => void
nerdMode: boolean
}
function renderPropEditor(
propSchema: PropDefinition,
props: Record<string, unknown>,
setProps: (value: Record<string, unknown>) => void
) {
const value = props[propSchema.name] ?? propSchema.defaultValue
switch (propSchema.type) {
case 'string':
return (
<Input
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: e.target.value })}
placeholder={String(propSchema.defaultValue || '')}
/>
)
case 'number':
return (
<Input
type="number"
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: Number(e.target.value) })}
/>
)
case 'boolean':
return (
<Switch
checked={Boolean(value)}
onCheckedChange={(checked) => setProps({ ...props, [propSchema.name]: checked })}
/>
)
case 'select':
return (
<Select
value={String(value || propSchema.defaultValue || '')}
onValueChange={(val) => setProps({ ...props, [propSchema.name]: val })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{propSchema.options?.map((opt: SelectOption) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
default:
return (
<Input
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: e.target.value })}
/>
)
}
}
export function ComponentConfigFields({
componentDef,
props,
setProps,
styles,
setStyles,
events,
setEvents,
nerdMode,
}: ComponentConfigFieldsProps) {
return (
<Tabs defaultValue="props" className="flex-1">
<TabsList className={nerdMode ? "grid w-full grid-cols-3" : "grid w-full grid-cols-2"}>
<TabsTrigger value="props">Properties</TabsTrigger>
<TabsTrigger value="styles">Styles</TabsTrigger>
{nerdMode && <TabsTrigger value="events">Events</TabsTrigger>}
</TabsList>
<ScrollArea className="h-[500px] mt-4">
<TabsContent value="props" className="space-y-4 px-1">
{componentDef?.propSchema && componentDef.propSchema.length > 0 ? (
componentDef.propSchema.map((propSchema) => (
<div key={propSchema.name} className="space-y-2">
<Label htmlFor={propSchema.name}>{propSchema.label}</Label>
{renderPropEditor(propSchema, props, setProps)}
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No configurable properties for this component</p>
</div>
)}
{nerdMode && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-sm">Custom Properties (JSON)</CardTitle>
<CardDescription className="text-xs">
Add additional props as JSON
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(props, null, 2)}
onChange={(e) => {
try {
setProps(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={6}
/>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="styles" className="space-y-4 px-1">
<div className="space-y-2">
<Label htmlFor="className">Tailwind Classes</Label>
<Input
id="className"
value={String(props.className || '')}
onChange={(e) => setProps({ ...props, className: e.target.value })}
placeholder="p-4 bg-white rounded-lg"
/>
</div>
{nerdMode && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-sm">Custom Styles (CSS-in-JS)</CardTitle>
<CardDescription className="text-xs">
Define inline styles as JSON object
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(styles, null, 2)}
onChange={(e) => {
try {
setStyles(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={12}
placeholder={'{\n "backgroundColor": "#fff",\n "padding": "16px"\n}'}
/>
</CardContent>
</Card>
)}
</TabsContent>
{nerdMode && (
<TabsContent value="events" className="space-y-4 px-1">
<Card>
<CardHeader>
<CardTitle className="text-sm">Event Handlers</CardTitle>
<CardDescription className="text-xs">
Map events to Lua script IDs
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{['onClick', 'onChange', 'onSubmit', 'onFocus', 'onBlur'].map((eventName) => (
<div key={eventName} className="space-y-2">
<Label htmlFor={eventName}>{eventName}</Label>
<Input
id={eventName}
value={events[eventName] || ''}
onChange={(e) => setEvents({ ...events, [eventName]: e.target.value })}
placeholder="lua_script_id"
/>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Custom Events (JSON)</CardTitle>
<CardDescription className="text-xs">
Define additional event handlers
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(events, null, 2)}
onChange={(e) => {
try {
setEvents(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={6}
/>
</CardContent>
</Card>
</TabsContent>
)}
</ScrollArea>
</Tabs>
)
}

View File

@@ -6,7 +6,6 @@ import { ScrollArea } from '@/components/ui'
import { Separator } from '@/components/ui'
import {
ArrowsOutCardinal,
Cursor,
Plus,
Tree,
} from '@phosphor-icons/react'
@@ -14,9 +13,10 @@ import { Database, type ComponentNode } from '@/lib/database'
import { componentCatalog } from '@/lib/components/component-catalog'
import { toast } from 'sonner'
import { ComponentConfigDialog } from './ComponentConfigDialog'
import { TreeNode } from './modules/TreeNode'
import { useHierarchyData } from './modules/useHierarchyData'
import { useHierarchyDragDrop } from './modules/useHierarchyDragDrop'
import { HierarchyTree } from './ComponentHierarchyEditor/Tree'
import { selectRootNodes } from './ComponentHierarchyEditor/selectors'
export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: boolean }) {
const { pages, selectedPageId, setSelectedPageId, hierarchy, loadHierarchy } = useHierarchyData()
@@ -37,10 +37,7 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
const componentIdPrefix = useId()
const rootNodes = useMemo(
() =>
Object.values(hierarchy)
.filter(node => node.pageId === selectedPageId && !node.parentId)
.sort((a, b) => a.order - b.order),
() => selectRootNodes(hierarchy, selectedPageId),
[hierarchy, selectedPageId]
)
@@ -108,50 +105,6 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
[hierarchy, loadHierarchy]
)
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)]">
<div className="col-span-8 space-y-4">
@@ -191,7 +144,20 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
<CardContent className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
{selectedPageId ? (
renderTree
<HierarchyTree
rootNodes={rootNodes}
hierarchy={hierarchy}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
draggingNodeId={draggingNodeId}
onSelect={setSelectedNodeId}
onToggle={handleToggleNode}
onDelete={handleDeleteNode}
onConfig={setConfigNodeId}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
/>
) : (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<p>Select a page to edit its component hierarchy</p>

View File

@@ -0,0 +1,65 @@
import { Cursor } from '@phosphor-icons/react'
import type React from 'react'
import type { ComponentNode } from '@/lib/database'
import { TreeNode } from '../modules/TreeNode'
interface HierarchyTreeProps {
rootNodes: ComponentNode[]
hierarchy: Record<string, ComponentNode>
selectedNodeId: string | null
expandedNodes: Record<string, boolean>
draggingNodeId: string | null
onSelect: (nodeId: string) => void
onToggle: (nodeId: string) => void
onDelete: (nodeId: string) => Promise<void>
onConfig: (nodeId: string) => void
onDragStart: (event: React.DragEvent, nodeId: string) => void
onDragOver: (event: React.DragEvent, nodeId: string) => void
onDrop: (event: React.DragEvent, nodeId: string) => void
}
export function HierarchyTree({
rootNodes,
hierarchy,
selectedNodeId,
expandedNodes,
draggingNodeId,
onSelect,
onToggle,
onDelete,
onConfig,
onDragStart,
onDragOver,
onDrop,
}: HierarchyTreeProps) {
if (rootNodes.length === 0) {
return (
<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>
)
}
return (
<div className="space-y-1">
{rootNodes.map((node) => (
<TreeNode
key={node.id}
node={node}
hierarchy={hierarchy}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
onSelect={onSelect}
onToggle={onToggle}
onDelete={onDelete}
onConfig={onConfig}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
draggingNodeId={draggingNodeId}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,10 @@
import type { ComponentNode } from '@/lib/database'
export function selectRootNodes(
hierarchy: Record<string, ComponentNode>,
selectedPageId: string | null
): ComponentNode[] {
return Object.values(hierarchy)
.filter(node => node.pageId === selectedPageId && !node.parentId)
.sort((a, b) => a.order - b.order)
}