import { memo, useState, useRef, useEffect } from 'react' import { Position, NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react' import { BaseNode, BaseNodeHeader, BaseNodeContent } from '../ui/base-node' import { BaseHandle } from '../ui/base-handle' import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator' import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip' import { getDeviceRenderConfig, CATEGORY_LABELS } from './deviceRegistry' import { cn } from '@/lib/utils' import type { DeviceProperties } from '@/types' export interface DeviceNodeData { label: string deviceType: string category?: string properties: DeviceProperties [key: string]: unknown } function TooltipRow({ label, value }: { label: string; value: string | null | undefined }) { if (!value) return null return (
{label} {value}
) } const NODE_DEFAULT = 120 // default square side in px const NODE_MIN = 80 // minimum square side in px const NODE_MAX = 280 // maximum square side in px function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) { const nodeData = data as unknown as DeviceNodeData const { icon: Icon, color, accentClass, surfaceClass, category } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category) const status = (nodeData.properties?.status || 'unknown') as NodeStatus const ip = nodeData.properties?.ip const props = nodeData.properties || {} // Use the shorter dimension so content never overflows a non-square node const size = Math.min(width ?? NODE_DEFAULT, height ?? NODE_DEFAULT) const scale = size / NODE_DEFAULT // Icon: 28px at default, clamped to [14, 72] const iconPx = Math.round(Math.max(14, Math.min(72, scale * 28))) // Label font: 11px at default, clamped to [9, 20] const labelPx = Math.max(9, Math.min(20, Math.round(scale * 11))) // IP font: 9px at default, clamped to [8, 16] const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9))) const metaPx = Math.max(8, Math.min(11, Math.round(scale * 8))) const iconPlateSize = Math.round(Math.max(34, Math.min(82, scale * 50))) const [editing, setEditing] = useState(false) const [labelValue, setLabelValue] = useState(nodeData.label ?? '') const inputRef = useRef(null) const { updateNodeData } = useReactFlow() useEffect(() => { if (editing) { inputRef.current?.focus() inputRef.current?.select() } }, [editing]) // While not editing, the displayed label is derived directly from // nodeData.label — no effect-driven sync needed. labelValue holds the // edit buffer only and is reset when an edit session starts. const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes return ( <>
{editing ? ( setLabelValue(e.target.value)} onBlur={() => { setEditing(false) if (labelValue !== nodeData.label) { updateNodeData(id, { ...nodeData, label: labelValue }) } }} onKeyDown={e => { if (e.key === 'Enter') inputRef.current?.blur() if (e.key === 'Escape') { setLabelValue(nodeData.label ?? '') setEditing(false) } e.stopPropagation() }} style={{ fontSize: labelPx }} className="bg-transparent border-none outline-none text-center text-primary font-medium w-4/5" /> ) : ( { e.stopPropagation() setLabelValue(nodeData.label ?? '') setEditing(true) }} > {nodeData.label ?? ''} )} {CATEGORY_LABELS[category] ?? nodeData.deviceType.replace(/-/g, ' ')} {ip && ( {ip} )} {hasTooltipContent && (
{(props.vendor || props.model) && ( )} {props.notes && ( 100 ? props.notes.slice(0, 100) + '...' : props.notes} /> )}
)} ) } export const DeviceNode = memo(DeviceNodeComponent)