import { useMemo, useState } from 'react' import { deviceTemplateToRow } from '../../data/catalogRowMapping' import { allDeviceTemplates, deviceCategoryName, hiddenDeviceTemplates, manufacturerName } from '../../domain/diagram' import type { DeviceTemplate } from '../../domain/types' import { useAdminReviewStore } from '../../state/adminReviewStore' import { useAuthStore } from '../../state/authStore' import { useCatalogStore } from '../../state/catalogStore' import { useDiagramStore } from '../../state/diagramStore' import { useSubmissionStore } from '../../state/submissionStore' import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas' import Chevron from '../common/Chevron' import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu' import CategoryLibraryModal from './CategoryLibraryModal' import ConnectorLibraryModal from './ConnectorLibraryModal' import DeviceTemplateEditor from './DeviceTemplateEditor' type EditorTarget = | { mode: 'new' } | { mode: 'edit'; template: DeviceTemplate } | { mode: 'suggestEdit'; template: DeviceTemplate } | { mode: 'adminEdit'; template: DeviceTemplate } /** organized-ideas.md §5: browsable both category→manufacturer and * manufacturer→category, not a single fixed hierarchy — the two view * modes swap which dimension is the top-level (collapsible) group and * which is a plain sub-heading within it. */ type ViewMode = 'category' | 'manufacturer' /** Sentinel sub/top-group key for devices with no manufacturer set — * distinct from any real id (manufacturer ids are uuids, this isn't), and * always sorted last within its level since it's a fallback bucket, not a * named group someone would look for alphabetically. */ const NO_MANUFACTURER_KEY = '__no-manufacturer__' interface SubGroup { key: string label: string templates: DeviceTemplate[] } interface TopGroup { key: string label: string subGroups: SubGroup[] count: number } function sortGroupsByLabel(groups: T[]): T[] { return groups.sort((a, b) => { if (a.key === NO_MANUFACTURER_KEY) return 1 if (b.key === NO_MANUFACTURER_KEY) return -1 return a.label.localeCompare(b.label) }) } export default function DevicePalette() { const diagram = useDiagramStore((s) => s.diagram) const access = useDiagramStore((s) => s.access) const canEdit = access?.myPermission !== 'view' const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate) const catalog = useCatalogStore((s) => s.catalog) const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds) const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate) const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate) const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate) const adminUnpublish = useCatalogStore((s) => s.adminUnpublish) const adminRemoveDeviceTemplate = useCatalogStore((s) => s.adminRemoveDeviceTemplate) const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const submitForReview = useSubmissionStore((s) => s.submitForReview) const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact) const role = useAuthStore((s) => s.role) const isAdmin = role === 'admin' || role === 'super_admin' const [editorTarget, setEditorTarget] = useState(null) const [portTypesOpen, setPortTypesOpen] = useState(false) const [categoryLibraryOpen, setCategoryLibraryOpen] = useState(false) const [hiddenListOpen, setHiddenListOpen] = useState(false) const [viewMode, setViewMode] = useState('category') const [search, setSearch] = useState('') // Collapsed by default — expanding on demand is the whole point (keeps a // library with many devices from turning into one long scroll). Shared // across both view modes rather than tracked per-mode: category ids and // manufacturer ids live in different id spaces, so a stale entry left // over from the other mode just never matches anything, harmlessly. const [expandedGroups, setExpandedGroups] = useState>(new Set()) const groups = useMemo(() => { const templates = allDeviceTemplates(catalog, hiddenPublicIds) const categoryLabel = (id: string) => deviceCategoryName(catalog, id) const manufacturerLabel = (id: string) => id === NO_MANUFACTURER_KEY ? 'No manufacturer' : (manufacturerName(catalog, id) ?? id) const manufacturerKey = (t: DeviceTemplate) => t.manufacturerId ?? NO_MANUFACTURER_KEY const byTop = new Map>() for (const t of templates) { const topKey = viewMode === 'category' ? t.category : manufacturerKey(t) const subKey = viewMode === 'category' ? manufacturerKey(t) : t.category const subMap = byTop.get(topKey) ?? new Map() subMap.set(subKey, [...(subMap.get(subKey) ?? []), t]) byTop.set(topKey, subMap) } const topGroups = [...byTop.entries()].map(([topKey, subMap]) => { const subGroups = sortGroupsByLabel( [...subMap.entries()].map(([subKey, list]) => ({ key: subKey, label: viewMode === 'category' ? manufacturerLabel(subKey) : categoryLabel(subKey), templates: list.sort((a, b) => a.name.localeCompare(b.name)), })), ) return { key: topKey, label: viewMode === 'category' ? categoryLabel(topKey) : manufacturerLabel(topKey), subGroups, count: subGroups.reduce((sum, sg) => sum + sg.templates.length, 0), } }) return sortGroupsByLabel(topGroups) }, [catalog, hiddenPublicIds, viewMode]) const searchResults = useMemo(() => { const query = search.trim().toLowerCase() if (!query) return null return allDeviceTemplates(catalog, hiddenPublicIds) .filter((t) => { const haystack = [t.name, t.model, manufacturerName(catalog, t.manufacturerId), deviceCategoryName(catalog, t.category)] .filter(Boolean) .join(' ') .toLowerCase() return haystack.includes(query) }) .sort((a, b) => a.name.localeCompare(b.name)) }, [catalog, hiddenPublicIds, search]) const toggleGroup = (key: string) => { setExpandedGroups((prev) => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) } const expandGroup = (key: string) => { setExpandedGroups((prev) => new Set(prev).add(key)) } const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => { if (!canEdit) return event.dataTransfer.setData(TEMPLATE_DRAG_MIME, template.id) event.dataTransfer.effectAllowed = 'move' } const handleQuickAdd = (template: DeviceTemplate) => { if (!canEdit) return // Cascade placement so repeated quick-adds don't stack exactly on top of each other. const offset = (diagram.devices.length % 8) * 24 addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset }) } const handleDelete = (template: DeviceTemplate) => { if (!template.custom) { const confirmed = window.confirm( `Hide "${template.name}" from your device library? It's a public default, so you can bring it back later from "Show hidden defaults" at the bottom of this panel. Devices already placed on the canvas from it won't be affected.`, ) if (confirmed) hidePublicDeviceTemplate(template.id) return } const confirmed = window.confirm( `Delete "${template.name}" from your device library? Devices already placed on the canvas from it won't be affected.`, ) if (confirmed) removeCustomDeviceTemplate(template.id) } const hiddenDefaults = useMemo(() => hiddenDeviceTemplates(catalog, hiddenPublicIds), [catalog, hiddenPublicIds]) const pendingEntityIds = useMemo( () => new Set( mySubmissions.filter((s) => s.entityType === 'device_template' && s.status === 'pending').map((s) => s.entityId), ), [mySubmissions], ) const handleSubmitForPublic = (template: DeviceTemplate) => { submitForReview('device_template', template.id, deviceTemplateToRow(template)) } const handleUnpublish = async (template: DeviceTemplate) => { const impact = await getUsageImpact('device_template', template.id) const impactNote = impact.diagramCount > 0 ? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.` : '\n\nNot currently used in any diagram.' const confirmed = window.confirm(`Unpublish "${template.name}" from the public catalog?${impactNote}`) if (!confirmed) return adminUnpublish('device_template', template.id) } // Distinct from the ✕ button below, which for a built-in device only ever // hides it from *your own* view (a per-user preference, not a catalog // change) — this permanently removes it from the shared library, gated by // zero usage the same way a private device's delete already is. Device // templates aren't referenced by any foreign key, so the diagram-usage // check is the only thing to verify. const handleAdminDelete = async (template: DeviceTemplate) => { const impact = await getUsageImpact('device_template', template.id) if (impact.diagramCount > 0) { window.alert( `Can't delete "${template.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`, ) return } const confirmed = window.confirm(`Permanently delete "${template.name}" from the shared library? This can't be undone.`) if (!confirmed) return try { await adminRemoveDeviceTemplate(template.id) } catch { window.alert(`Couldn't delete "${template.name}" — it may still be referenced somewhere.`) } } const rowProps = { canEdit, isAdmin, pendingEntityIds, onDragStart: handleDragStart, onQuickAdd: handleQuickAdd, onDelete: handleDelete, onSubmitForPublic: handleSubmitForPublic, onUnpublish: handleUnpublish, onAdminDelete: handleAdminDelete, onSuggestEdit: (template: DeviceTemplate) => setEditorTarget({ mode: 'suggestEdit', template }), onAdminEdit: (template: DeviceTemplate) => setEditorTarget({ mode: 'adminEdit', template }), onEdit: (template: DeviceTemplate) => setEditorTarget({ mode: 'edit', template }), } return ( ) } /** One draggable device row, with whatever action buttons the current user * and this template's public/custom/pending status allow — shared by the * grouped tree and the flat search-results list so the two don't drift. */ function DeviceRow({ template, canEdit, isAdmin, pendingEntityIds, showContext, onDragStart, onQuickAdd, onDelete, onSubmitForPublic, onUnpublish, onAdminDelete, onSuggestEdit, onAdminEdit, onEdit, }: { template: DeviceTemplate canEdit: boolean isAdmin: boolean pendingEntityIds: Set /** Set in search results, where there's no grouping header to already * convey which category/manufacturer this device belongs to. */ showContext?: boolean onDragStart: (event: React.DragEvent, template: DeviceTemplate) => void onQuickAdd: (template: DeviceTemplate) => void onDelete: (template: DeviceTemplate) => void onSubmitForPublic: (template: DeviceTemplate) => void onUnpublish: (template: DeviceTemplate) => void onAdminDelete: (template: DeviceTemplate) => void onSuggestEdit: (template: DeviceTemplate) => void onAdminEdit: (template: DeviceTemplate) => void onEdit: (template: DeviceTemplate) => void }) { const catalog = useCatalogStore((s) => s.catalog) const context = showContext ? [deviceCategoryName(catalog, template.category), manufacturerName(catalog, template.manufacturerId)] .filter(Boolean) .join(' · ') : undefined return (
  • onDragStart(e, template)} className={`flex items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm ${canEdit ? 'cursor-grab active:cursor-grabbing' : ''}`} >
    {template.name}
    {context && <>{context} · } {template.ports.length} port{template.ports.length === 1 ? '' : 's'} {template.custom ? ' · custom' : ''}
    {pendingEntityIds.has(template.id) ? ( Pending ) : template.custom ? ( ) : isAdmin ? ( <> ) : ( )} {template.custom && ( )} {canEdit && ( )}
  • ) }