feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
176
frontend/src/components/network/panels/DeviceToolbar.tsx
Normal file
176
frontend/src/components/network/panels/DeviceToolbar.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Search, Plus, ChevronDown, ChevronRight, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
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]
|
||||
if (!items?.length) return null
|
||||
const collapsed = collapsedCategories.has(cat)
|
||||
|
||||
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">{items.length}</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"
|
||||
>
|
||||
<Icon size={14} style={{ color }} />
|
||||
<span>{dt.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user