- DeviceNode: flat bg-card (no surface gradient), darker icon plate inset, correct text-muted token for category label - GroupNode: label pill gets bg-card/90 background so it reads against canvas - ConnectionEdge: label now has border + bg-card so it doesn't float invisible - BaseHandle: tightened to 12px with accent-toned border - NodeStatusIndicator: glow reduced to 0.15 opacity (design system compliant) - ContextMenu: Ungroup now uses Ungroup icon instead of BoxSelect - DeviceToolbar: group type icons coloured with semantic palette - PropertiesPanel: empty state gets icon tile + cleaner copy hierarchy - DiagramEditor: shortcut ? button repositioned above MiniMap, accent hover - NetworkDiagrams list: card thumbnail placeholder uses dot-grid pattern, card menu gets icons and divider before destructive action Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
228 lines
10 KiB
TypeScript
228 lines
10 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react'
|
|
import { Search, Plus, ChevronDown, ChevronRight, X, LayoutGrid, GripVertical, Globe } from 'lucide-react'
|
|
import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry'
|
|
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
|
|
import { deviceTypesApi } from '@/api'
|
|
|
|
interface DeviceToolbarProps {
|
|
deviceTypes: DeviceTypeResponse[]
|
|
onDeviceTypesChange: () => void
|
|
}
|
|
|
|
export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolbarProps) {
|
|
const [search, setSearch] = useState('')
|
|
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
|
|
const [showAddForm, setShowAddForm] = useState(false)
|
|
const [newType, setNewType] = useState<DeviceTypeCreate>({ slug: '', label: '', category: 'network' })
|
|
const [addError, setAddError] = useState<string | null>(null)
|
|
const [addLoading, setAddLoading] = useState(false)
|
|
|
|
const filteredByCategory = useMemo(() => {
|
|
const lower = search.toLowerCase()
|
|
const filtered = search
|
|
? deviceTypes.filter(dt => dt.label.toLowerCase().includes(lower) || dt.slug.toLowerCase().includes(lower))
|
|
: deviceTypes
|
|
|
|
const grouped: Record<string, DeviceTypeResponse[]> = {}
|
|
for (const dt of filtered) {
|
|
if (!grouped[dt.category]) grouped[dt.category] = []
|
|
grouped[dt.category].push(dt)
|
|
}
|
|
return grouped
|
|
}, [deviceTypes, search])
|
|
|
|
const toggleCategory = useCallback((cat: string) => {
|
|
setCollapsedCategories(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(cat)) next.delete(cat)
|
|
else next.add(cat)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const handleDragStart = useCallback((e: React.DragEvent, deviceType: DeviceTypeResponse) => {
|
|
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
|
|
slug: deviceType.slug,
|
|
label: deviceType.label,
|
|
category: deviceType.category,
|
|
}))
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
}, [])
|
|
|
|
const handleAddType = useCallback(async () => {
|
|
if (!newType.slug || !newType.label) {
|
|
setAddError('Slug and label are required')
|
|
return
|
|
}
|
|
setAddLoading(true)
|
|
setAddError(null)
|
|
try {
|
|
await deviceTypesApi.create(newType)
|
|
setNewType({ slug: '', label: '', category: 'network' })
|
|
setShowAddForm(false)
|
|
onDeviceTypesChange()
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : 'Failed to create device type'
|
|
setAddError(msg)
|
|
} finally {
|
|
setAddLoading(false)
|
|
}
|
|
}, [newType, onDeviceTypesChange])
|
|
|
|
return (
|
|
<div className="flex h-full w-[200px] flex-col border-r border-default bg-sidebar">
|
|
<div className="relative p-2">
|
|
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search devices..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
className="w-full rounded-md border border-default bg-input pl-8 pr-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
|
{CATEGORY_ORDER.map(cat => {
|
|
const items = filteredByCategory[cat] || []
|
|
const isCloud = cat === 'cloud'
|
|
const ispMatchesSearch = !search || 'isp'.includes(search.toLowerCase()) || 'internet service provider'.includes(search.toLowerCase())
|
|
const showIsp = isCloud && ispMatchesSearch
|
|
if (!items.length && !showIsp) return null
|
|
const collapsed = collapsedCategories.has(cat)
|
|
const totalCount = items.length + (showIsp ? 1 : 0)
|
|
|
|
return (
|
|
<div key={cat} className="mb-1">
|
|
<button
|
|
onClick={() => toggleCategory(cat)}
|
|
className="flex w-full items-center gap-1 rounded px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-primary"
|
|
>
|
|
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
{CATEGORY_LABELS[cat] || cat}
|
|
<span className="ml-auto text-[10px] font-normal">{totalCount}</span>
|
|
</button>
|
|
{!collapsed && (
|
|
<div className="flex flex-col gap-0.5">
|
|
{items.map(dt => {
|
|
const { icon: Icon, color } = getDeviceRenderConfig(dt.slug, dt.category)
|
|
return (
|
|
<div
|
|
key={dt.id}
|
|
draggable
|
|
onDragStart={e => handleDragStart(e, dt)}
|
|
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
|
>
|
|
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
|
<Icon size={14} style={{ color }} />
|
|
<span>{dt.label}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
{showIsp && (
|
|
<div
|
|
draggable
|
|
onDragStart={e => {
|
|
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
|
|
slug: 'isp',
|
|
label: 'ISP',
|
|
category: 'cloud',
|
|
}))
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
}}
|
|
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
|
>
|
|
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
|
<Globe size={14} style={{ color: 'var(--color-accent)' }} />
|
|
<span>ISP</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Grouping section */}
|
|
<div className="mb-1 mt-2 border-t border-default pt-2">
|
|
<div className="flex items-center gap-1 px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Grouping
|
|
</div>
|
|
<div className="flex flex-col gap-0.5">
|
|
{[
|
|
{ slug: 'subnet', label: 'Subnet', color: '#60a5fa' },
|
|
{ slug: 'vlan', label: 'VLAN', color: '#a78bfa' },
|
|
{ slug: 'site', label: 'Site', color: '#34d399' },
|
|
{ slug: 'dmz', label: 'DMZ', color: '#f87171' },
|
|
].map(item => (
|
|
<div
|
|
key={item.slug}
|
|
draggable
|
|
onDragStart={e => {
|
|
e.dataTransfer.setData('application/reactflow-group', JSON.stringify({ slug: item.slug, label: item.label }))
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
}}
|
|
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
|
>
|
|
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
|
<LayoutGrid size={14} style={{ color: item.color }} />
|
|
<span>{item.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-default p-2">
|
|
{!showAddForm ? (
|
|
<button
|
|
onClick={() => setShowAddForm(true)}
|
|
className="flex w-full items-center justify-center gap-1 rounded border border-default px-2 py-1.5 text-xs text-muted-foreground hover:border-hover hover:text-primary"
|
|
>
|
|
<Plus size={12} />
|
|
Custom Type
|
|
</button>
|
|
) : (
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">New Type</span>
|
|
<button onClick={() => { setShowAddForm(false); setAddError(null) }} className="text-muted-foreground hover:text-primary">
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
<input
|
|
placeholder="slug (e.g. pacs-server)"
|
|
value={newType.slug}
|
|
onChange={e => setNewType(prev => ({ ...prev, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
|
|
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
|
/>
|
|
<input
|
|
placeholder="Label (e.g. PACS Server)"
|
|
value={newType.label}
|
|
onChange={e => setNewType(prev => ({ ...prev, label: e.target.value }))}
|
|
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
|
/>
|
|
<select
|
|
value={newType.category}
|
|
onChange={e => setNewType(prev => ({ ...prev, category: e.target.value }))}
|
|
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
|
|
>
|
|
{CATEGORY_ORDER.map(c => (
|
|
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
|
|
))}
|
|
</select>
|
|
{addError && <p className="text-[10px] text-red-400">{addError}</p>}
|
|
<button
|
|
onClick={handleAddType}
|
|
disabled={addLoading}
|
|
className="rounded bg-accent px-2 py-1 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
|
|
>
|
|
{addLoading ? 'Adding...' : 'Add Type'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|