0b89244317
- Add lasso/box selection via selectionOnDrag (Space to pan, lasso by default) - Add lasso/pan toggle button in canvas controls (bottom-left) - Multi-select panel in right panel when 2+ nodes selected (including zones) - Create named Group node from selected nodes with bounding box math - Group node: resizable, inline rename, show/hide border toggle, status summary - GroupDetailPanel: lists members, online/offline count, ungroup action - Fix group persistence after save/reload (extend proxmox container map to include group nodes) - Fix groupRect serialization to preserve parent_id - Remove background color from group and proxmox container node wrappers - Add tests for all new store actions, GroupNode, MultiSelectPanel, GroupDetailPanel
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
import { useState } from 'react'
|
|
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
|
|
import { Layers, Pencil, Check, X } from 'lucide-react'
|
|
import { useCanvasStore } from '@/stores/canvasStore'
|
|
import { STATUS_COLORS, type NodeData } from '@/types'
|
|
|
|
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
|
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
|
const showBorder = data.custom_colors?.show_border !== false
|
|
const isVisible = showBorder || selected
|
|
|
|
const [editing, setEditing] = useState(false)
|
|
const [labelDraft, setLabelDraft] = useState(data.label)
|
|
|
|
const children = nodes.filter((n) => n.parentId === id)
|
|
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
|
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
|
const unknownCount = children.length - onlineCount - offlineCount
|
|
|
|
const handleRename = () => {
|
|
if (labelDraft.trim()) {
|
|
snapshotHistory()
|
|
updateNode(id, { label: labelDraft.trim() })
|
|
}
|
|
setEditing(false)
|
|
}
|
|
|
|
const borderColor = selected ? '#00d4ff' : '#30363d'
|
|
const borderStyle = selected ? 'solid' : 'dashed'
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
position: 'relative',
|
|
borderRadius: 8,
|
|
border: isVisible ? `2px ${borderStyle} ${borderColor}` : '2px solid transparent',
|
|
background: 'transparent',
|
|
transition: 'border-color 0.15s, background 0.15s',
|
|
boxSizing: 'border-box',
|
|
}}
|
|
>
|
|
<NodeResizer
|
|
isVisible={selected}
|
|
minWidth={120}
|
|
minHeight={80}
|
|
lineStyle={{ stroke: '#00d4ff', strokeWidth: 1 }}
|
|
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
|
/>
|
|
|
|
{/* Header */}
|
|
{isVisible && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
padding: '5px 10px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
background: selected ? 'rgba(0,212,255,0.08)' : 'rgba(22,27,34,0.8)',
|
|
borderRadius: '6px 6px 0 0',
|
|
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
|
|
pointerEvents: 'auto',
|
|
}}
|
|
className="nodrag"
|
|
>
|
|
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
|
|
|
|
{editing ? (
|
|
<input
|
|
autoFocus
|
|
value={labelDraft}
|
|
onChange={(e) => setLabelDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleRename()
|
|
if (e.key === 'Escape') { setLabelDraft(data.label); setEditing(false) }
|
|
}}
|
|
style={{
|
|
flex: 1,
|
|
background: 'transparent',
|
|
border: 'none',
|
|
outline: 'none',
|
|
color: '#e6edf3',
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
}}
|
|
/>
|
|
) : (
|
|
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e6edf3', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{data.label}
|
|
</span>
|
|
)}
|
|
|
|
{editing ? (
|
|
<>
|
|
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
|
<button onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
|
|
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
|
|
title="Rename group"
|
|
>
|
|
<Pencil size={10} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Status summary */}
|
|
{children.length > 0 && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
|
|
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}>● {onlineCount}</span>}
|
|
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount}</span>}
|
|
{unknownCount > 0 && <span style={{ color: STATUS_COLORS.unknown }}>● {unknownCount}</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|