Add manufacturer catalog, category/manufacturer library management, and built-in cable flag
Normalizes manufacturer as a shared catalog entity (like device categories) instead of free text on each device, giving the admin duplicate-detection nudge a reliable signal. Adds full CRUD (including Admin direct-publish, bypassing the submission queue) for categories, manufacturers, port types, and cable types, plus a Categories & Manufacturers library modal and a browse-by-manufacturer/search view in the device palette. Adds Port.builtInCable so a captive/permanently-attached cable (a keyboard's USB lead, a budget AVR's power cord) can be flagged and excluded from the BOM. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { deviceTemplateToRow } from '../../data/catalogRowMapping'
|
||||
import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/diagram'
|
||||
import { allDeviceTemplates, deviceCategoryName, hiddenDeviceTemplates, manufacturerName } from '../../domain/diagram'
|
||||
import type { DeviceTemplate } from '../../domain/types'
|
||||
import { useAdminReviewStore } from '../../state/adminReviewStore'
|
||||
import { useAuthStore } from '../../state/authStore'
|
||||
@@ -9,6 +9,8 @@ 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'
|
||||
|
||||
@@ -18,6 +20,39 @@ type EditorTarget =
|
||||
| { 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<T extends { key: string; label: string }>(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)
|
||||
@@ -29,6 +64,7 @@ export default function DevicePalette() {
|
||||
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)
|
||||
@@ -36,40 +72,77 @@ export default function DevicePalette() {
|
||||
const isAdmin = role === 'admin' || role === 'super_admin'
|
||||
const [editorTarget, setEditorTarget] = useState<EditorTarget | null>(null)
|
||||
const [portTypesOpen, setPortTypesOpen] = useState(false)
|
||||
const [categoryLibraryOpen, setCategoryLibraryOpen] = useState(false)
|
||||
const [hiddenListOpen, setHiddenListOpen] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('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).
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
// 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<Set<string>>(new Set())
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const categories = allDeviceCategories(catalog)
|
||||
const categoryName = (id: string) => categories.find((c) => c.id === id)?.name ?? id
|
||||
const byCategory = new Map<string, DeviceTemplate[]>()
|
||||
for (const template of allDeviceTemplates(catalog, hiddenPublicIds)) {
|
||||
const list = byCategory.get(template.category) ?? []
|
||||
list.push(template)
|
||||
byCategory.set(template.category, list)
|
||||
const groups = useMemo<TopGroup[]>(() => {
|
||||
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<string, Map<string, DeviceTemplate[]>>()
|
||||
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<string, DeviceTemplate[]>()
|
||||
subMap.set(subKey, [...(subMap.get(subKey) ?? []), t])
|
||||
byTop.set(topKey, subMap)
|
||||
}
|
||||
return [...byCategory.entries()]
|
||||
.map(([categoryId, templates]) => ({
|
||||
categoryId,
|
||||
categoryName: categoryName(categoryId),
|
||||
templates: templates.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}))
|
||||
.sort((a, b) => a.categoryName.localeCompare(b.categoryName))
|
||||
}, [catalog, hiddenPublicIds])
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
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(categoryId)) next.delete(categoryId)
|
||||
else next.add(categoryId)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const expandCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) => new Set(prev).add(categoryId))
|
||||
const expandGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => new Set(prev).add(key))
|
||||
}
|
||||
|
||||
const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => {
|
||||
@@ -124,18 +197,53 @@ export default function DevicePalette() {
|
||||
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 (
|
||||
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setPortTypesOpen(true)}
|
||||
title="Manage connector types"
|
||||
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-200"
|
||||
>
|
||||
Connectors
|
||||
</button>
|
||||
<DropdownMenu label="Library">
|
||||
<DropdownMenuItem onClick={() => setPortTypesOpen(true)}>Connectors</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setCategoryLibraryOpen(true)}>Categories & Manufacturers</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
onClick={() => setEditorTarget({ mode: 'new' })}
|
||||
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
|
||||
@@ -144,116 +252,86 @@ export default function DevicePalette() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="border-b border-slate-200 px-3 py-2 text-[11px] text-slate-500">
|
||||
Drag a device onto the canvas, or click + to drop it in.
|
||||
</p>
|
||||
<div className="space-y-1.5 border-b border-slate-200 px-2 py-1.5">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search devices…"
|
||||
className="w-full rounded border border-slate-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
{!search && (
|
||||
<div className="flex rounded bg-slate-100 p-0.5 text-[11px] font-medium">
|
||||
<button
|
||||
onClick={() => setViewMode('category')}
|
||||
className={`flex-1 rounded px-1.5 py-0.5 ${viewMode === 'category' ? 'bg-white text-slate-700 shadow-sm' : 'text-slate-500'}`}
|
||||
>
|
||||
By Category
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('manufacturer')}
|
||||
className={`flex-1 rounded px-1.5 py-0.5 ${viewMode === 'manufacturer' ? 'bg-white text-slate-700 shadow-sm' : 'text-slate-500'}`}
|
||||
>
|
||||
By Manufacturer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!search && (
|
||||
<p className="border-b border-slate-200 px-3 py-2 text-[11px] text-slate-500">
|
||||
Drag a device onto the canvas, or click + to drop it in.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{groups.map((group) => {
|
||||
const expanded = expandedCategories.has(group.categoryId)
|
||||
return (
|
||||
<div key={group.categoryId} className="mb-1">
|
||||
<button
|
||||
onClick={() => toggleCategory(group.categoryId)}
|
||||
className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-xs font-medium text-slate-600 hover:bg-slate-100"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Chevron expanded={expanded} className="text-slate-400" />
|
||||
{group.categoryName}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">{group.templates.length}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<ul className="mt-1 space-y-1 pl-2">
|
||||
{group.templates.map((template) => (
|
||||
<li key={template.id} className="group">
|
||||
<div
|
||||
draggable={canEdit}
|
||||
onDragStart={(e) => handleDragStart(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' : ''}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-slate-700">{template.name}</div>
|
||||
<div className="truncate text-[10px] text-slate-400">
|
||||
{template.ports.length} port{template.ports.length === 1 ? '' : 's'}
|
||||
{template.custom ? ' · custom' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
|
||||
{pendingEntityIds.has(template.id) ? (
|
||||
<span
|
||||
title="A submission for this device is pending Admin review"
|
||||
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"
|
||||
>
|
||||
Pending
|
||||
</span>
|
||||
) : template.custom ? (
|
||||
<button
|
||||
onClick={() => handleSubmitForPublic(template)}
|
||||
title="Submit this to the public catalog for Admin review"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-emerald-100 hover:text-emerald-700"
|
||||
>
|
||||
⇪
|
||||
</button>
|
||||
) : isAdmin ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setEditorTarget({ mode: 'adminEdit', template })}
|
||||
title="Edit this public device directly"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUnpublish(template)}
|
||||
title="Unpublish from the public catalog"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
⛔
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditorTarget({ mode: 'suggestEdit', template })}
|
||||
title="Suggest an edit to this device for Admin review"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{template.custom && (
|
||||
<button
|
||||
onClick={() => setEditorTarget({ mode: 'edit', template })}
|
||||
title="Edit this device"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(template)}
|
||||
title={template.custom ? 'Delete from library' : 'Hide this default device'}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => handleQuickAdd(template)}
|
||||
title="Add to canvas"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{searchResults ? (
|
||||
searchResults.length === 0 ? (
|
||||
<p className="px-1.5 py-2 text-[11px] italic text-slate-400">No devices match "{search.trim()}".</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{searchResults.map((template) => (
|
||||
<DeviceRow key={template.id} template={template} showContext {...rowProps} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
})}
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
const expanded = expandedGroups.has(group.key)
|
||||
return (
|
||||
<div key={group.key} className="mb-1">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-xs font-medium text-slate-600 hover:bg-slate-100"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Chevron expanded={expanded} className="text-slate-400" />
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">{group.count}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1 space-y-2 pl-2">
|
||||
{group.subGroups.map((subGroup) => (
|
||||
<div key={subGroup.key}>
|
||||
{/* Not independently collapsible, unlike the top-level
|
||||
group — expanding the category/manufacturer above is
|
||||
the one click this menu asks for; a second click per
|
||||
sub-group would be one too many for groups that
|
||||
usually hold just a couple of devices. */}
|
||||
<div className="px-1.5 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
{subGroup.label}
|
||||
</div>
|
||||
<ul className="mt-0.5 space-y-1">
|
||||
{subGroup.templates.map((template) => (
|
||||
<DeviceRow key={template.id} template={template} {...rowProps} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{hiddenDefaults.length > 0 && (
|
||||
<div className="border-t border-slate-200 px-2 py-1.5">
|
||||
@@ -289,10 +367,151 @@ export default function DevicePalette() {
|
||||
submissionMode={editorTarget.mode === 'suggestEdit'}
|
||||
adminMode={editorTarget.mode === 'adminEdit'}
|
||||
onClose={() => setEditorTarget(null)}
|
||||
onSaved={(categoryId) => expandCategory(categoryId)}
|
||||
onSaved={(categoryId, manufacturerId) =>
|
||||
expandGroup(viewMode === 'category' ? categoryId : (manufacturerId ?? NO_MANUFACTURER_KEY))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{portTypesOpen && <ConnectorLibraryModal onClose={() => setPortTypesOpen(false)} />}
|
||||
{categoryLibraryOpen && <CategoryLibraryModal onClose={() => setCategoryLibraryOpen(false)} />}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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<string>
|
||||
/** 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 (
|
||||
<li className="group">
|
||||
<div
|
||||
draggable={canEdit}
|
||||
onDragStart={(e) => 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' : ''}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-slate-700">{template.name}</div>
|
||||
<div className="truncate text-[10px] text-slate-400">
|
||||
{context && <>{context} · </>}
|
||||
{template.ports.length} port{template.ports.length === 1 ? '' : 's'}
|
||||
{template.custom ? ' · custom' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
|
||||
{pendingEntityIds.has(template.id) ? (
|
||||
<span
|
||||
title="A submission for this device is pending Admin review"
|
||||
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"
|
||||
>
|
||||
Pending
|
||||
</span>
|
||||
) : template.custom ? (
|
||||
<button
|
||||
onClick={() => onSubmitForPublic(template)}
|
||||
title="Submit this to the public catalog for Admin review"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-emerald-100 hover:text-emerald-700"
|
||||
>
|
||||
⇪
|
||||
</button>
|
||||
) : isAdmin ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onAdminEdit(template)}
|
||||
title="Edit this public device directly"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onUnpublish(template)}
|
||||
title="Unpublish from the public catalog"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
⛔
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdminDelete(template)}
|
||||
title="Permanently delete from the shared library — only possible while nothing uses it"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onSuggestEdit(template)}
|
||||
title="Suggest an edit to this device for Admin review"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{template.custom && (
|
||||
<button
|
||||
onClick={() => onEdit(template)}
|
||||
title="Edit this device"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(template)}
|
||||
title={template.custom ? 'Delete from library' : 'Hide this default device'}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => onQuickAdd(template)}
|
||||
title="Add to canvas"
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user