293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react'
|
|
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
|
|
import { NodeFormDecision } from './NodeFormDecision'
|
|
import { NodeFormAction } from './NodeFormAction'
|
|
import { NodeFormResolution } from './NodeFormResolution'
|
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
|
import { cn } from '@/lib/utils'
|
|
import type { TreeStructure, NodeType } from '@/types'
|
|
|
|
interface NodeEditorPanelProps {
|
|
nodeId: string
|
|
onClose: () => void
|
|
onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
|
}
|
|
|
|
const TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, { icon: typeof HelpCircle; label: string; badgeClass: string }> = {
|
|
decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' },
|
|
action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' },
|
|
solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' },
|
|
}
|
|
|
|
function cloneWithoutChildren(node: TreeStructure): TreeStructure {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { children: _children, ...rest } = node
|
|
return structuredClone(rest) as TreeStructure
|
|
}
|
|
|
|
export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) {
|
|
const treeStructure = useTreeEditorStore(s => s.treeStructure)
|
|
const updateNode = useTreeEditorStore(s => s.updateNode)
|
|
const deleteNode = useTreeEditorStore(s => s.deleteNode)
|
|
const duplicateNode = useTreeEditorStore(s => s.duplicateNode)
|
|
const addNode = useTreeEditorStore(s => s.addNode)
|
|
const selectNode = useTreeEditorStore(s => s.selectNode)
|
|
|
|
const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null
|
|
const [draft, setDraft] = useState<TreeStructure | null>(null)
|
|
const [isDirty, setIsDirty] = useState(false)
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Initialize/reset draft when nodeId changes or when node type changes
|
|
// (e.g., answer stub → decision/action/solution via type picker)
|
|
useEffect(() => {
|
|
if (node) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setDraft(cloneWithoutChildren(node))
|
|
|
|
setIsDirty(false)
|
|
|
|
setShowDeleteConfirm(false)
|
|
}
|
|
}, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
|
|
setDraft(prev => prev ? { ...prev, ...updates } : prev)
|
|
setIsDirty(true)
|
|
}, [])
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (!draft || !node) return
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { children: _children, ...draftWithoutChildren } = draft
|
|
updateNode(nodeId, draftWithoutChildren)
|
|
|
|
// Auto-create answer stubs for new decision options without next_node_id
|
|
if (draft.type === 'decision' && draft.options) {
|
|
const options = draft.options.filter(o => o.label.trim())
|
|
const stubsCreated: Array<{ optId: string; stubId: string }> = []
|
|
|
|
options.forEach(opt => {
|
|
if (!opt.next_node_id) {
|
|
const stubId = addNode(nodeId, 'answer')
|
|
updateNode(stubId, { title: opt.label })
|
|
stubsCreated.push({ optId: opt.id, stubId })
|
|
}
|
|
})
|
|
|
|
if (stubsCreated.length > 0) {
|
|
const updatedOptions = options.map(o => {
|
|
const stub = stubsCreated.find(s => s.optId === o.id)
|
|
return stub ? { ...o, next_node_id: stub.stubId } : o
|
|
})
|
|
updateNode(nodeId, { options: updatedOptions })
|
|
}
|
|
}
|
|
|
|
// Auto-create answer stub for action node without next_node_id
|
|
if (draft.type === 'action' && !draft.next_node_id) {
|
|
const stubId = addNode(nodeId, 'answer')
|
|
updateNode(stubId, { title: 'Next Step' })
|
|
updateNode(nodeId, { next_node_id: stubId })
|
|
}
|
|
|
|
setIsDirty(false)
|
|
}, [draft, node, nodeId, updateNode, addNode])
|
|
|
|
const handleClose = useCallback(() => {
|
|
if (isDirty) {
|
|
setShowDiscardConfirm(true)
|
|
return
|
|
}
|
|
onClose()
|
|
}, [isDirty, onClose])
|
|
|
|
// Escape to close
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
handleClose()
|
|
}
|
|
}
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
}, [handleClose])
|
|
|
|
const handleDelete = useCallback(() => {
|
|
if (!treeStructure) return
|
|
clearInboundReferences(nodeId, treeStructure, updateNode)
|
|
deleteNode(nodeId)
|
|
onClose()
|
|
}, [nodeId, treeStructure, updateNode, deleteNode, onClose])
|
|
|
|
const handleDuplicate = useCallback(() => {
|
|
const newId = duplicateNode(nodeId)
|
|
if (newId) {
|
|
selectNode(newId)
|
|
}
|
|
}, [nodeId, duplicateNode, selectNode])
|
|
|
|
if (!node || !draft) return null
|
|
|
|
// Answer stub: show type picker instead of form
|
|
if (node.type === 'answer') {
|
|
return (
|
|
<div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
|
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
<span className="text-sm font-heading font-medium text-foreground">
|
|
{node.title || 'Answer Placeholder'}
|
|
</span>
|
|
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-4">
|
|
<p className="text-sm text-muted-foreground text-center">Choose a type for this node:</p>
|
|
<div className="flex gap-2">
|
|
{(['decision', 'action', 'solution'] as const).map(type => {
|
|
const cfg = TYPE_CONFIG[type]
|
|
const TypeIcon = cfg.icon
|
|
return (
|
|
<button
|
|
key={type}
|
|
type="button"
|
|
onClick={() => onSelectType?.(nodeId, type)}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-sans text-xs border transition-colors',
|
|
type === 'decision' && 'border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20',
|
|
type === 'action' && 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20',
|
|
type === 'solution' && 'border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20',
|
|
)}
|
|
>
|
|
<TypeIcon className="h-4 w-4" /> {cfg.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const config = TYPE_CONFIG[node.type as Exclude<NodeType, 'answer'>] ?? TYPE_CONFIG.decision
|
|
const TypeIcon = config.icon
|
|
const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`)
|
|
const isRoot = treeStructure?.id === nodeId
|
|
|
|
return (
|
|
<div ref={panelRef} className="flex h-full min-h-0 w-[400px] shrink-0 flex-col border-l border-border bg-card">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3 shrink-0">
|
|
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
|
|
<TypeIcon className="h-3 w-3" />
|
|
</span>
|
|
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">{title}</span>
|
|
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body — scrollable form area */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 scroll-pb-24">
|
|
{draft.type === 'decision' && <NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />}
|
|
{draft.type === 'action' && <NodeFormAction node={draft} onUpdate={handleDraftUpdate} />}
|
|
{draft.type === 'solution' && <NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="sticky bottom-0 flex items-center gap-2 border-t border-border bg-card px-4 py-3 shrink-0">
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={!isDirty}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white transition-opacity',
|
|
isDirty ? 'bg-primary hover:brightness-110' : 'bg-primary opacity-50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
<Save className="h-3.5 w-3.5" /> Save
|
|
</button>
|
|
<button
|
|
onClick={handleClose}
|
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<div className="flex-1" />
|
|
{!isRoot && (
|
|
<>
|
|
<button
|
|
onClick={handleDuplicate}
|
|
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
title="Duplicate"
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
</button>
|
|
{showDeleteConfirm ? (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={handleDelete}
|
|
className="rounded-md bg-red-500/20 px-2 py-1 text-xs text-red-400 hover:bg-red-500/30"
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-red-400"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
isOpen={showDiscardConfirm}
|
|
onClose={() => setShowDiscardConfirm(false)}
|
|
onConfirm={() => {
|
|
setShowDiscardConfirm(false)
|
|
onClose()
|
|
}}
|
|
title="Discard Changes"
|
|
message="You have unsaved changes. Discard them?"
|
|
confirmLabel="Discard"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Clear all next_node_id references to a node before deleting
|
|
function clearInboundReferences(
|
|
nodeId: string,
|
|
treeStructure: TreeStructure,
|
|
updateNode: (id: string, updates: Partial<TreeStructure>) => void
|
|
) {
|
|
function walk(node: TreeStructure) {
|
|
if (node.type === 'decision' && node.options) {
|
|
const needsUpdate = node.options.some(o => o.next_node_id === nodeId)
|
|
if (needsUpdate) {
|
|
updateNode(node.id, {
|
|
options: node.options.map(o => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o),
|
|
})
|
|
}
|
|
}
|
|
if (node.type === 'action' && node.next_node_id === nodeId) {
|
|
updateNode(node.id, { next_node_id: '' })
|
|
}
|
|
node.children?.forEach(walk)
|
|
}
|
|
walk(treeStructure)
|
|
}
|