import { Fragment, createElement, useState } from 'react' import modalStyles from './modal-interactive.module.css' import { RotateCcw, ChevronDown } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons' import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils' const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ { label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] }, { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] }, { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, { label: 'Generic', types: ['computer', 'generic', 'groupRect'] }, ] const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host'] const CHECK_METHOD_LABELS: Record = { none: 'None', ping: 'Ping', http: 'HTTP', https: 'HTTPS', tcp: 'TCP', ssh: 'SSH', prometheus: 'Prometheus', health: 'Health', } const DEFAULT_DATA: Partial = { type: 'server', label: '', hostname: '', ip: '', status: 'unknown', check_method: 'ping', services: [], container_mode: false, custom_colors: undefined, custom_icon: undefined, } interface NodeModalProps { open: boolean onClose: () => void onSubmit: (data: Partial) => void initial?: Partial title?: string parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[] } // NodeModal is always mounted with a key that changes on open/edit, so useState // initial value is enough - no need for a reset effect. export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) { const [form, setForm] = useState>({ ...DEFAULT_DATA, ...initial }) const [iconSearch, setIconSearch] = useState('') const [iconPickerOpen, setIconPickerOpen] = useState(false) const [labelError, setLabelError] = useState(false) const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors }) const showServicesEnabled = form.custom_colors?.show_services === true const hasAppearanceOverrides = Boolean( form.custom_colors?.border || form.custom_colors?.background || form.custom_colors?.icon ) const set = (key: keyof NodeData, value: unknown) => setForm((f) => ({ ...f, [key]: value })) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (!form.label?.trim()) { setLabelError(true) return } setLabelError(false) const selectedType = (form.type ?? 'generic') as NodeType const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) onSubmit({ ...form, container_mode: canUseContainerMode ? !!form.container_mode : false, }) onClose() } const filteredParentNodes = form.type === 'docker_container' ? parentContainerNodes.filter((n) => n.nodeType === 'docker_host') : parentContainerNodes return ( !o && onClose()}> {title}
{/* Type + Icon on the same row */}
{/* Icon */}
{form.custom_icon && ( )}
{/* Trigger button */}
{/* Inline icon picker - full width, shown below the type+icon row */} {iconPickerOpen && (
setIconSearch(e.target.value)} placeholder="Search icons…" className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`} autoFocus />
{ICON_CATEGORIES.map((cat) => { const entries = ICON_REGISTRY.filter( (e) => e.category === cat && (iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase())) ) if (entries.length === 0) return null return (

{cat}

{entries.map((entry) => { const isSelected = form.custom_icon === entry.key return ( ) })}
) })}
)} {/* Label */}
{ set('label', e.target.value); if (labelError) setLabelError(false) }} placeholder="My Server" className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'} ${modalStyles['modal-radius']}`} /> {labelError &&

Label is required

}
{/* Hostname */}
set('hostname', e.target.value)} placeholder="server.lan" className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`} />
{/* IP */}
set('ip', e.target.value)} placeholder="192.168.1.x, 2001:db8::1" className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`} /> comma-separated
{/* Check method */}
{/* Check target */}
set('check_target', e.target.value)} placeholder="http://..." className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`} />
{/* Parent container */} {form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
)} {/* Container mode */} {CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
Allow other nodes to nest inside this node
)} {/* Service visibility */} {form.type !== 'groupRect' && form.type !== 'group' && (
Display discovered services on the node card
)} {/* Appearance */}
{hasAppearanceOverrides && ( )}
{(['border', 'background', 'icon'] as const).map((key) => { const resolved = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors }) const currentValue = resolved[key] const isCustom = !!form.custom_colors?.[key] return (
{!hasAppearanceOverrides && (

Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.

)}
{/* Bottom connection points (not for group containers) */} {form.type !== 'groupRect' && form.type !== 'group' && (
{clampBottomHandles(form.bottom_handles ?? 1)}
set('bottom_handles', clampBottomHandles(Number(e.target.value)))} aria-label="Bottom connection points slider" className="w-full accent-[#00d4ff] cursor-pointer" />
{MIN_BOTTOM_HANDLES} {MAX_BOTTOM_HANDLES}
)} {/* Notes */}
set('notes', e.target.value)} placeholder="Optional notes" className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`} />
{/* Show delete button only for edit mode (not add) */} {title !== 'Add Node' ? ( ) : }
) }