import { useEffect, useCallback, useRef, useState } from 'react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react' import { type Node } from '@xyflow/react' import { applyDagreLayout } from '@/utils/layout' import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { generateUUID } from '@/utils/uuid' import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' import { generateMarkdownTable } from '@/utils/exportMarkdown' import { ExportModal } from '@/components/modals/ExportModal' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { parseYamlToCanvas } from '@/utils/importYaml' import { TooltipProvider } from '@/components/ui/tooltip' import { Toaster } from '@/components/ui/sonner' import { toast } from 'sonner' import { CanvasContainer } from '@/components/canvas/CanvasContainer' import { Sidebar } from '@/components/panels/Sidebar' import { Toolbar } from '@/components/panels/Toolbar' import { DetailPanel } from '@/components/panels/DetailPanel' import { LoginPage } from '@/components/LoginPage' import { NodeModal } from '@/components/modals/NodeModal' import { EdgeModal } from '@/components/modals/EdgeModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal' import { SettingsModal } from '@/components/modals/SettingsModal' import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { TextModal, type TextFormData } from '@/components/modals/TextModal' import { ThemeModal } from '@/components/modals/ThemeModal' import { SearchModal } from '@/components/modals/SearchModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal' import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' import { useThemeStore } from '@/stores/themeStore' import { canvasApi, designsApi, liveviewApi } from '@/api/client' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData, CustomStyleDef } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' export default function App() { const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore() useStatusPolling() const [themeModalOpen, setThemeModalOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false) const [scanHistoryOpen, setScanHistoryOpen] = useState(false) const [pendingModalOpen, setPendingModalOpen] = useState(false) const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending') const [pendingHighlightId, setPendingHighlightId] = useState(undefined) const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => { setPendingHighlightId(undefined) setPendingModalStatus(status) setPendingModalOpen(true) if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0) }, []) const [shortcutsOpen, setShortcutsOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false) const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) const [addTextOpen, setAddTextOpen] = useState(false) const [editNodeId, setEditNodeId] = useState(null) const [pendingConnection, setPendingConnection] = useState(null) const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null) const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null) const [editEdgeId, setEditEdgeId] = useState(null) const [scanConfigOpen, setScanConfigOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) const [exportModalOpen, setExportModalOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) // Declare handleSave before the Ctrl+S effect so it is in scope. // Returns true on success, false on failure — the design-switch effect relies // on this to avoid loading (and clobbering) the canvas when a save fails. const handleSave = useCallback(async (designIdOverride?: string): Promise => { try { const saveDesignId = designIdOverride ?? activeDesignId if (STANDALONE) { localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) markSaved() toast.success('Canvas saved') return true } const nodesToSave = nodes.map(serializeNode) const edgesToSave = edges.map(serializeEdge) await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId }) markSaved() toast.success('Canvas saved') return true } catch { toast.error('Save failed') return false } }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId]) // Keep a ref so the keydown handler always calls the latest version const handleSaveRef = useRef(handleSave) useEffect(() => { handleSaveRef.current = handleSave }, [handleSave]) const loadCanvasFromApi = useCallback(async (designId?: string) => { try { const res = await canvasApi.load(designId) const { nodes: apiNodes, edges: apiEdges } = res.data if (apiNodes.length > 0) { const proxmoxContainerMap = new Map( (apiNodes as ApiNode[]) .filter((n) => n.type === 'group' || n.container_mode === true) .map((n) => [n.id, true]) ) const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) const savedTheme = res.data.viewport?.theme_id if (savedTheme) setTheme(savedTheme) if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) loadCanvas(rfNodes, rfEdges) } else { loadCanvas(demoNodes, demoEdges) } } catch { loadCanvas(demoNodes, demoEdges) } }, [loadCanvas, setTheme, setCustomStyle]) const loadDesignsAndCanvas = useCallback(async () => { if (STANDALONE) return try { const res = await designsApi.list() const loadedDesigns = res.data setDesigns(loadedDesigns) const targetId = activeDesignId ?? loadedDesigns[0]?.id if (targetId) { setActiveDesign(targetId) await loadCanvasFromApi(targetId) } } catch { // If API fails (e.g. fresh DB with no designs), fall back to demo data loadCanvas(demoNodes, demoEdges) } }, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, loadCanvas]) // Load canvas on auth (or immediately in standalone mode) useEffect(() => { if (STANDALONE) { try { const saved = localStorage.getItem(STANDALONE_STORAGE_KEY) if (saved) { const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved) if (theme_id) setTheme(theme_id) if (custom_style) setCustomStyle(custom_style) loadCanvas(savedNodes, savedEdges) } else { loadCanvas(demoNodes, demoEdges) } } catch { loadCanvas(demoNodes, demoEdges) } return } if (!isAuthenticated) return loadDesignsAndCanvas() }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change // Reload canvas when active design changes (after initial load) const initialLoadDone = useRef(false) const prevDesignRef = useRef(null) // Set while we programmatically revert activeDesignId after a failed save, so // the re-entrant effect run skips save/load and just re-syncs the refs. const revertingRef = useRef(false) useEffect(() => { if (revertingRef.current) { revertingRef.current = false prevDesignRef.current = activeDesignId return } if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) { const oldId = prevDesignRef.current // If the previous design was deleted (no longer in the list), don't try to // save into it — just load the newly-selected design. const oldStillExists = oldId ? useDesignStore.getState().designs.some((d) => d.id === oldId) : false if (oldId && oldId !== activeDesignId && oldStillExists) { // Save current (old) canvas data under the old design ID before switching. // We call handleSave directly (not via ref) so it runs in this effect's // closure where activeDesignId is already the NEW value — the override // ensures data is stored under the correct design_id. const targetId = activeDesignId handleSave(oldId).then((ok) => { if (ok) { loadCanvasFromApi(targetId) } else { // Save failed: don't load the new design — that would overwrite the // unsaved in-memory canvas. Revert the selection back to the old // design so the UI matches the data still on screen. toast.error('Switch cancelled — unsaved changes kept') revertingRef.current = true setActiveDesign(oldId) } }) } else { loadCanvasFromApi(activeDesignId) } } if (activeDesignId) { prevDesignRef.current = activeDesignId initialLoadDone.current = true } }, [activeDesignId]) // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) const redoRef = useRef(redo) useEffect(() => { undoRef.current = undo }, [undo]) useEffect(() => { redoRef.current = redo }, [redo]) // Global keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { const ctrl = e.ctrlKey || e.metaKey // Ignore shortcuts when typing in an input/textarea const tag = (e.target as HTMLElement).tagName const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable if (ctrl && e.key === 's') { e.preventDefault(); handleSaveRef.current(); return } if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return } if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return } if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return } // Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place // nodes under the cursor / viewport center. if (e.key === '?' && !isInput) { setShortcutsOpen(true); return } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, []) const handleAddNode = useCallback((data: Partial) => { snapshotHistory() const id = generateUUID() const isContainerNode = data.container_mode === true const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null // Only nest when the parent is an actual container. For a non-container // parent the LXC/VM stays a free node (linked by a virtual edge) — setting // extent:'parent' on a non-container would trap it inside the parent's tiny // bounding box with no way to drag it out (issue #205 follow-up). const nestInParent = !!parentNode?.data.container_mode // Seed an ABSOLUTE position near the container's top-left; addNode converts // it to container-relative. addNode is the single authority for parentId / // extent, so we don't set them here. const position = nestInParent && parentNode ? { x: parentNode.position.x + 20, y: parentNode.position.y + 50 } : { x: 300, y: 300 } const newNode: Node = { id, type: data.type ?? 'generic', position, data: { status: 'unknown', services: [], ...data } as NodeData, ...(isContainerNode ? { width: 300, height: 200 } : {}), } addNode(newNode) toast.success(`Added "${data.label}"`) }, [addNode, nodes, snapshotHistory]) const handleAddGroupRect = useCallback((data: GroupRectFormData) => { snapshotHistory() const id = generateUUID() const newNode: Node = { id, type: 'groupRect', position: { x: 200, y: 200 }, data: { label: data.label, type: 'groupRect', status: 'unknown', services: [], custom_colors: { border: data.border_color, border_style: data.border_style, border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_position: data.text_position, text_size: data.text_size, label_position: data.label_position, font: data.font, z_order: data.z_order, }, }, width: 360, height: 240, zIndex: data.z_order - 10, } addNode(newNode) }, [addNode, snapshotHistory]) const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => { if (!editingGroupRectId) return snapshotHistory() const existing = nodes.find((n) => n.id === editingGroupRectId) updateNode(editingGroupRectId, { label: data.label, custom_colors: { ...existing?.data.custom_colors, border: data.border_color, border_style: data.border_style, border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_position: data.text_position, text_size: data.text_size, label_position: data.label_position, font: data.font, z_order: data.z_order, }, }) setNodeZIndex(editingGroupRectId, data.z_order - 10) setEditingGroupRectId(null) }, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory]) const handleAddText = useCallback((data: TextFormData) => { snapshotHistory() const id = generateUUID() const newNode: Node = { id, // Text lives in `label` because the API serializer only persists top-level // node fields; text_content is not in the schema and was lost on reload. // TextNode and the edit modal both already fall back to label. type: 'text', position: { x: 250, y: 250 }, data: { label: data.text, type: 'text', status: 'unknown', services: [], custom_colors: { border: data.border_color, border_style: data.border_style, border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_size: data.text_size, font: data.font, }, }, width: 200, height: 60, } addNode(newNode) }, [addNode, snapshotHistory]) const handleUpdateText = useCallback((data: TextFormData) => { if (!editingTextId) return snapshotHistory() const existing = nodes.find((n) => n.id === editingTextId) updateNode(editingTextId, { label: data.text, // Clear stale text_content if present from older builds — label is the // source of truth now. text_content: undefined, custom_colors: { ...existing?.data.custom_colors, border: data.border_color, border_style: data.border_style, border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_size: data.text_size, font: data.font, }, }) setEditingTextId(null) }, [editingTextId, nodes, updateNode, setEditingTextId, snapshotHistory]) const handleDeleteText = useCallback(() => { if (!editingTextId) return snapshotHistory() deleteNode(editingTextId) setEditingTextId(null) }, [editingTextId, deleteNode, setEditingTextId, snapshotHistory]) const handleDeleteGroupRect = useCallback(() => { if (!editingGroupRectId) return snapshotHistory() deleteNode(editingGroupRectId) setEditingGroupRectId(null) }, [editingGroupRectId, deleteNode, setEditingGroupRectId, snapshotHistory]) const handleEditNode = useCallback((id: string) => { setEditNodeId(id) }, []) const handleUpdateNode = useCallback((data: Partial) => { if (!editNodeId) return snapshotHistory() const existingNode = nodes.find((n) => n.id === editNodeId) updateNode(editNodeId, data) // If container_mode changed, apply structural changes (children parentId, node dimensions) if (typeof data.container_mode === 'boolean') { setProxmoxContainerMode(editNodeId, data.container_mode) } // Sync virtual edge when parent_id changes on an LXC/VM node const nodeType = data.type ?? existingNode?.data.type if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) { const oldParentId = existingNode?.data.parent_id ?? null const newParentId = data.parent_id ?? null if (oldParentId !== newParentId) { // Remove any existing virtual edge between child and old parent if (oldParentId) { const oldEdge = edges.find((e) => e.data?.type === 'virtual' && ((e.source === editNodeId && e.target === oldParentId) || (e.source === oldParentId && e.target === editNodeId)) ) if (oldEdge) deleteEdge(oldEdge.id) } // Create virtual edge only when parent is NOT in container mode // (container mode shows containment visually — no edge needed) if (newParentId) { const parentNode = nodes.find((n) => n.id === newParentId) if (!parentNode?.data.container_mode) { onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection) } } } } setEditNodeId(null) }, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory]) const handleAutoLayout = useCallback(() => { const laid = applyDagreLayout(nodes, edges) loadCanvas(laid, edges) toast.success('Canvas auto-arranged') }, [nodes, edges, loadCanvas]) const handleExportMd = useCallback(async () => { const md = generateMarkdownTable(nodes) if (!md) { toast.error('No nodes to export'); return } await navigator.clipboard.writeText(md) toast.success('Markdown table copied to clipboard') }, [nodes]) const handleExportYaml = useCallback(() => { if (nodes.length === 0) { toast.error('No nodes to export'); return } const content = exportCanvasToYaml(nodes, edges) downloadYaml(content) toast.success('Canvas exported as YAML') }, [nodes, edges]) const handleImportYaml = useCallback((content: string) => { try { const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges) snapshotHistory() loadCanvas(merged, mergedEdges) markUnsaved() toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`) } catch (err) { toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`) } }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) // Open the read-only live view of the currently active design in a new tab. // Standalone has no backend/key — it reads localStorage, so just open /view. // Otherwise fetch the configured live view key and build /view?key=...&design=. const handleViewOnly = useCallback(async () => { if (STANDALONE) { window.open('/view', '_blank', 'noopener,noreferrer') return } try { const res = await liveviewApi.getConfig() if (!res.data.enabled || !res.data.key) { toast.error('Live view is disabled — set LIVEVIEW_KEY in the backend .env') return } const params = new URLSearchParams({ key: res.data.key }) if (activeDesignId) params.set('design', activeDesignId) window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer') } catch { toast.error('Failed to open live view') } }, [activeDesignId]) const handleExport = useCallback(() => { const el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } setExportModalOpen(true) }, []) const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => { snapshotHistory() // Place nodes in a grid starting at x=500, y=100 const COLS = 4 const SPACING_X = 170 const SPACING_Y = 100 zigbeeNodes.forEach((zn, i) => { const id = zn.id const col = i % COLS const row = Math.floor(i / COLS) const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y } const newNode: import('@xyflow/react').Node = { id, type: zn.type, position, data: { label: zn.friendly_name, type: zn.type as NodeData['type'], status: 'unknown' as const, services: [], ...(zn.lqi != null ? { properties: [{ key: 'LQI', value: String(zn.lqi), icon: 'signal', visible: true }] } : {}), ...(zn.model ? { os: zn.model } : {}), ...(zn.parent_id ? { parent_id: zn.parent_id } : {}), }, } addNode(newNode) }) // Add IoT edges between Zigbee devices: parent bottom -> child top zigbeeEdges.forEach((ze) => { onConnect({ source: ze.source, sourceHandle: 'bottom', target: ze.target, targetHandle: 'top-t', type: 'iot', } as unknown as import('@xyflow/react').Connection) }) // Auto-select only the freshly imported nodes so the user can drag the // whole subtree as a group. const importedIds = new Set(zigbeeNodes.map((zn) => zn.id)) useCanvasStore.setState((state) => ({ nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })), selectedNodeIds: Array.from(importedIds), selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null, })) markUnsaved() }, [addNode, onConnect, snapshotHistory, markUnsaved]) const handleEdgeConnect = useCallback((connection: Connection) => { setPendingConnection(connection) }, []) const handleEdgeConfirm = useCallback((edgeData: EdgeData) => { if (!pendingConnection) return snapshotHistory() onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection) // When a virtual edge is drawn between a child node and a container node, sync parent_id if (edgeData.type === 'virtual') { const src = nodes.find((n) => n.id === pendingConnection.source) const tgt = nodes.find((n) => n.id === pendingConnection.target) if (src && tgt) { const assignment = resolveVirtualEdgeParent( { id: src.id, type: src.data.type as NodeData['type'] }, { id: tgt.id, type: tgt.data.type as NodeData['type'] }, ) if (assignment) { updateNode(assignment.childId, { parent_id: assignment.parentId }) } } } setPendingConnection(null) }, [pendingConnection, onConnect, nodes, updateNode, snapshotHistory]) const handleEdgeDoubleClick = useCallback((edge: Edge) => { setEditEdgeId(edge.id) }, []) const handleNodeDoubleClick = useCallback((node: Node) => { // 'group' uses inline rename (pencil button in header). Opening the // generic NodeModal would clobber the group's height (via the // properties-clears-height rule in updateNode) and lose its children. // 'groupRect' has its own onDoubleClick that already routes to GroupRectModal. if (node.data.type === 'group' || node.data.type === 'groupRect') return handleEditNode(node.id) }, [handleEditNode]) const handleEdgeUpdate = useCallback((data: EdgeData) => { if (!editEdgeId) return snapshotHistory() updateEdge(editEdgeId, data) setEditEdgeId(null) }, [editEdgeId, updateEdge, snapshotHistory]) const handleEdgeDelete = useCallback(() => { if (!editEdgeId) return snapshotHistory() deleteEdge(editEdgeId) setEditEdgeId(null) }, [editEdgeId, deleteEdge, snapshotHistory]) const handleClearWaypoints = useCallback(() => { if (!editEdgeId) return snapshotHistory() updateEdge(editEdgeId, { waypoints: [] }) setEditEdgeId(null) }, [editEdgeId, updateEdge, snapshotHistory]) const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null if (!STANDALONE && !isAuthenticated) return return (
setAddNodeOpen(true)} onAddGroupRect={() => setAddGroupRectOpen(true)} onAddText={() => setAddTextOpen(true)} onScan={() => setScanConfigOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)} onSave={handleSave} onOpenSettings={() => setSettingsOpen(true)} onOpenHistory={() => setScanHistoryOpen(true)} onOpenPending={openPendingModal} />
setThemeModalOpen(true)} onUndo={undo} onRedo={redo} onShortcuts={() => setShortcutsOpen(true)} onExportMd={handleExportMd} onExportYaml={handleExportYaml} onImportYaml={handleImportYaml} onViewOnly={handleViewOnly} />
openPendingModal(deviceId)} />
{(selectedNodeId || selectedNodeIds.length > 1) && }
setAddNodeOpen(false)} onSubmit={handleAddNode} title="Add Node" parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))} /> {/* key forces re-mount when editing a different node, resetting form state */} setEditNodeId(null)} onSubmit={handleUpdateNode} initial={editNode?.data} title="Edit Node" parentCandidates={(() => { const descendants = new Set() if (editNodeId) { const queue = [editNodeId] while (queue.length) { const id = queue.shift()! for (const n of nodes) { if (n.data.parent_id === id && !descendants.has(n.id)) { descendants.add(n.id) queue.push(n.id) } } } } return nodes .filter((n) => !descendants.has(n.id)) .map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode })) })()} currentNodeId={editNodeId ?? undefined} /> setPendingConnection(null)} onSubmit={handleEdgeConfirm} initial={ pendingConnection?.sourceHandle?.includes('cluster') || pendingConnection?.targetHandle?.includes('cluster') ? { type: 'cluster' } : undefined } /> setEditEdgeId(null)} onSubmit={handleEdgeUpdate} onDelete={handleEdgeDelete} onClearWaypoints={handleClearWaypoints} initial={editEdge?.data} title="Edit Link" /> {!STANDALONE && ( setScanConfigOpen(false)} onScanNow={() => { toast.success('Network scan started — check Scan History for results') }} /> )} {!STANDALONE && ( setZigbeeImportOpen(false)} onAddToCanvas={handleZigbeeAddToCanvas} onPendingImported={() => { toast.success('Zigbee import started — check Scan History for results') }} /> )} {!STANDALONE && ( setScanHistoryOpen(false)} /> )} setAddGroupRectOpen(false)} onSubmit={handleAddGroupRect} title="Add Zone" /> {/* key forces re-mount when editing a different rect */} setEditingGroupRectId(null)} onSubmit={handleUpdateGroupRect} onDelete={handleDeleteGroupRect} initial={(() => { const n = editingGroupRectId ? nodes.find((nd) => nd.id === editingGroupRectId) : null if (!n) return undefined const rc = n.data.custom_colors ?? {} return { label: n.data.label, font: rc.font ?? 'inter', text_color: rc.text_color ?? '#e6edf3', text_position: rc.text_position ?? 'top-left', border_color: rc.border ?? '#00d4ff', border_style: rc.border_style ?? 'solid', border_width: rc.border_width ?? 2, background_color: rc.background ?? '#00d4ff0d', text_size: rc.text_size ?? 12, label_position: rc.label_position ?? 'inside', z_order: rc.z_order ?? 1, } })()} title="Edit Zone" /> setAddTextOpen(false)} onSubmit={handleAddText} title="Add Text" /> setEditingTextId(null)} onSubmit={handleUpdateText} onDelete={handleDeleteText} initial={(() => { const n = editingTextId ? nodes.find((nd) => nd.id === editingTextId) : null if (!n) return undefined const rc = n.data.custom_colors ?? {} return { text: n.data.text_content ?? n.data.label ?? '', font: rc.font ?? 'inter', text_color: rc.text_color ?? '#e6edf3', text_size: rc.text_size ?? 14, border_color: rc.border ?? '#30363d', border_style: (rc.border_style ?? 'none') as TextFormData['border_style'], border_width: rc.border_width ?? 1, background_color: rc.background ?? '#00000000', } })()} title="Edit Text" /> {/* key forces re-mount on open so useState captures current theme as original */} setThemeModalOpen(false)} /> setSearchOpen(false)} onOpenPending={(deviceId) => openPendingModal(deviceId)} /> setShortcutsOpen(false)} /> n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''} targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''} onConfirm={() => { if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId) setPendingGroupAdd(null) }} onCancel={() => setPendingGroupAdd(null)} /> n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''} targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''} onConfirm={() => { if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId) setPendingContainerAdd(null) }} onCancel={() => setPendingContainerAdd(null)} /> {!STANDALONE && ( setSettingsOpen(false)} /> )} setPendingModalOpen(false)} highlightId={pendingHighlightId} initialStatus={pendingModalStatus} /> setExportModalOpen(false)} getElement={() => canvasRef.current?.querySelector('.react-flow') ?? null} />
) }