diff --git a/frontend/src/components/network/panels/DeviceToolbar.tsx b/frontend/src/components/network/panels/DeviceToolbar.tsx new file mode 100644 index 00000000..1af345a2 --- /dev/null +++ b/frontend/src/components/network/panels/DeviceToolbar.tsx @@ -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>(new Set()) + const [showAddForm, setShowAddForm] = useState(false) + const [newType, setNewType] = useState({ slug: '', label: '', category: 'network' }) + const [addError, setAddError] = useState(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 = {} + 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 ( +
+
+ + 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" + /> +
+ +
+ {CATEGORY_ORDER.map(cat => { + const items = filteredByCategory[cat] + if (!items?.length) return null + const collapsed = collapsedCategories.has(cat) + + return ( +
+ + {!collapsed && ( +
+ {items.map(dt => { + const { icon: Icon, color } = getDeviceRenderConfig(dt.slug, dt.category) + return ( +
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" + > + + {dt.label} +
+ ) + })} +
+ )} +
+ ) + })} +
+ +
+ {!showAddForm ? ( + + ) : ( +
+
+ New Type + +
+ 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" + /> + 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" + /> + + {addError &&

{addError}

} + +
+ )} +
+
+ ) +}