feat: drag-to-resize device nodes + BrickWallFire for firewall
- NodeResizer on DeviceNode (same pattern as group nodes); icon scales proportionally with node width, clamped 16–60px - Removes S/M/L static picker — resize is now direct manipulation - firewall: ShieldAlert → BrickWallFire Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { Position, type NodeProps } from '@xyflow/react'
|
||||
import { Position, NodeResizer, type NodeProps } from '@xyflow/react'
|
||||
import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
|
||||
import { BaseHandle } from '../ui/base-handle'
|
||||
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
|
||||
@@ -7,14 +7,10 @@ import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-
|
||||
import { getDeviceRenderConfig } from './deviceRegistry'
|
||||
import type { DeviceProperties } from '@/types'
|
||||
|
||||
export type IconSize = 'sm' | 'md' | 'lg'
|
||||
export const ICON_SIZE_PX: Record<IconSize, number> = { sm: 18, md: 28, lg: 42 }
|
||||
|
||||
export interface DeviceNodeData {
|
||||
label: string
|
||||
deviceType: string
|
||||
category?: string
|
||||
iconSize?: IconSize
|
||||
properties: DeviceProperties
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -29,55 +25,66 @@ function TooltipRow({ label, value }: { label: string; value: string | null | un
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceNodeComponent({ data }: NodeProps) {
|
||||
function DeviceNodeComponent({ data, selected, width }: NodeProps) {
|
||||
const nodeData = data as unknown as DeviceNodeData
|
||||
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
|
||||
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
|
||||
const ip = nodeData.properties?.ip
|
||||
const props = nodeData.properties || {}
|
||||
const iconPx = ICON_SIZE_PX[nodeData.iconSize ?? 'md']
|
||||
|
||||
// Scale icon proportionally: 28px at default 120px wide, clamped 16–60px
|
||||
const iconPx = Math.round(Math.max(16, Math.min(60, ((width ?? 120) / 120) * 28)))
|
||||
|
||||
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
|
||||
|
||||
return (
|
||||
<NodeStatusIndicator status={status}>
|
||||
<NodeTooltip>
|
||||
<NodeTooltipTrigger>
|
||||
<BaseNode className="min-w-[120px] group">
|
||||
<BaseNodeHeader className="flex-col gap-1 items-center py-3 px-4">
|
||||
<Icon size={iconPx} style={{ color }} />
|
||||
<BaseNodeHeaderTitle className="text-center text-xs">
|
||||
{nodeData.label}
|
||||
</BaseNodeHeaderTitle>
|
||||
</BaseNodeHeader>
|
||||
{ip && (
|
||||
<BaseNodeContent className="items-center pt-0">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{ip}</span>
|
||||
</BaseNodeContent>
|
||||
)}
|
||||
<BaseHandle type="target" position={Position.Top} />
|
||||
<BaseHandle type="source" position={Position.Bottom} />
|
||||
<BaseHandle type="target" position={Position.Left} id="left" />
|
||||
<BaseHandle type="source" position={Position.Right} id="right" />
|
||||
</BaseNode>
|
||||
</NodeTooltipTrigger>
|
||||
{hasTooltipContent && (
|
||||
<NodeTooltipContent position={Position.Top}>
|
||||
<div className="flex flex-col gap-1 min-w-[140px]">
|
||||
<TooltipRow label="Host" value={props.hostname} />
|
||||
<TooltipRow label="IP" value={props.ip} />
|
||||
{(props.vendor || props.model) && (
|
||||
<TooltipRow label="HW" value={[props.vendor, props.model].filter(Boolean).join(' ')} />
|
||||
<>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={80}
|
||||
minHeight={70}
|
||||
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
|
||||
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
|
||||
/>
|
||||
<NodeStatusIndicator status={status}>
|
||||
<NodeTooltip>
|
||||
<NodeTooltipTrigger>
|
||||
<BaseNode className="w-full min-w-[80px] group">
|
||||
<BaseNodeHeader className="flex-col gap-1 items-center py-3 px-4">
|
||||
<Icon size={iconPx} style={{ color }} />
|
||||
<BaseNodeHeaderTitle className="text-center text-xs">
|
||||
{nodeData.label}
|
||||
</BaseNodeHeaderTitle>
|
||||
</BaseNodeHeader>
|
||||
{ip && (
|
||||
<BaseNodeContent className="items-center pt-0">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{ip}</span>
|
||||
</BaseNodeContent>
|
||||
)}
|
||||
<TooltipRow label="Role" value={props.role} />
|
||||
{props.notes && (
|
||||
<TooltipRow label="Notes" value={props.notes.length > 100 ? props.notes.slice(0, 100) + '...' : props.notes} />
|
||||
)}
|
||||
</div>
|
||||
</NodeTooltipContent>
|
||||
)}
|
||||
</NodeTooltip>
|
||||
</NodeStatusIndicator>
|
||||
<BaseHandle type="target" position={Position.Top} />
|
||||
<BaseHandle type="source" position={Position.Bottom} />
|
||||
<BaseHandle type="target" position={Position.Left} id="left" />
|
||||
<BaseHandle type="source" position={Position.Right} id="right" />
|
||||
</BaseNode>
|
||||
</NodeTooltipTrigger>
|
||||
{hasTooltipContent && (
|
||||
<NodeTooltipContent position={Position.Top}>
|
||||
<div className="flex flex-col gap-1 min-w-[140px]">
|
||||
<TooltipRow label="Host" value={props.hostname} />
|
||||
<TooltipRow label="IP" value={props.ip} />
|
||||
{(props.vendor || props.model) && (
|
||||
<TooltipRow label="HW" value={[props.vendor, props.model].filter(Boolean).join(' ')} />
|
||||
)}
|
||||
<TooltipRow label="Role" value={props.role} />
|
||||
{props.notes && (
|
||||
<TooltipRow label="Notes" value={props.notes.length > 100 ? props.notes.slice(0, 100) + '...' : props.notes} />
|
||||
)}
|
||||
</div>
|
||||
</NodeTooltipContent>
|
||||
)}
|
||||
</NodeTooltip>
|
||||
</NodeStatusIndicator>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Router, Network, ShieldAlert, Wifi, Server, Monitor, Boxes, Package, Cloud,
|
||||
Router, Network, BrickWallFire, Wifi, Server, Monitor, Boxes, Package, Cloud,
|
||||
Printer, Smartphone, HardDrive, Gauge, Database, CloudCog,
|
||||
Cpu, Tablet, Laptop, BatteryCharging, RectangleVertical,
|
||||
Cable, Camera, KeyRound, Globe, Video, PlugZap, Radio,
|
||||
@@ -35,7 +35,7 @@ const SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
|
||||
'load-balancer': { icon: Gauge, color: NETWORK_COLOR },
|
||||
|
||||
// Security
|
||||
'firewall': { icon: ShieldAlert, color: SECURITY_COLOR },
|
||||
'firewall': { icon: BrickWallFire, color: SECURITY_COLOR },
|
||||
'badge-reader': { icon: KeyRound, color: SECURITY_COLOR },
|
||||
|
||||
// Compute
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack } from 'luci
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DeviceProperties, DiagramEdge } from '@/types'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { DeviceNodeData, IconSize } from '../nodes/DeviceNode'
|
||||
import type { DeviceNodeData } from '../nodes/DeviceNode'
|
||||
|
||||
interface PropertiesPanelProps {
|
||||
selectedNode: Node | null
|
||||
@@ -272,30 +272,6 @@ export function PropertiesPanel({
|
||||
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
|
||||
</div>
|
||||
|
||||
{/* Icon size */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Icon Size</FieldLabel>
|
||||
<div className="flex gap-1">
|
||||
{(['sm', 'md', 'lg'] as IconSize[]).map(size => {
|
||||
const active = (nodeData.iconSize ?? 'md') === size
|
||||
return (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => onNodeUpdate(selectedNode!.id, { iconSize: size } as Partial<DeviceNodeData>)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center rounded border py-1.5 text-[10px] font-medium uppercase transition-colors',
|
||||
active
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
|
||||
)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layering */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Layer</FieldLabel>
|
||||
|
||||
Reference in New Issue
Block a user