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:
2026-09-28 10:04:05 -05:00
co-authored by Claude Sonnet 5
parent 00777077c2
commit 29c3ed46a3
28 changed files with 2201 additions and 320 deletions
+8 -1
View File
@@ -11,13 +11,20 @@ export default function CategorySelect({
value,
onChange,
className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm',
publishPublic = false,
}: {
value: string
onChange: (categoryId: string) => void
className?: string
/** When true, a category created here goes straight into the public
* catalog (Admin-only) instead of your own private one — used when the
* containing form is itself publishing directly, so a brand-new category
* it references doesn't end up private and invisible to everyone else. */
publishPublic?: boolean
}) {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory)
const adminAddDeviceCategory = useCatalogStore((s) => s.adminAddDeviceCategory)
const [creating, setCreating] = useState(false)
const [draftName, setDraftName] = useState('')
@@ -29,7 +36,7 @@ export default function CategorySelect({
const commitNewCategory = async () => {
const trimmed = draftName.trim()
if (trimmed) {
onChange(await addCustomDeviceCategory(trimmed))
onChange(await (publishPublic ? adminAddDeviceCategory(trimmed) : addCustomDeviceCategory(trimmed)))
}
setCreating(false)
setDraftName('')
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import Chevron from './Chevron'
/**
* A click-toggled dropdown menu for grouping several TopBar actions behind
* one trigger — closes on an outside click, or automatically when any menu
* item is clicked (via bubbling, so individual items don't each need their
* own "and now close the menu" call).
*/
export default function DropdownMenu({
label,
align = 'left',
children,
}: {
label: ReactNode
align?: 'left' | 'right'
children: ReactNode
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onOutsideClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onOutsideClick)
return () => document.removeEventListener('mousedown', onOutsideClick)
}, [open])
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
{label}
<Chevron expanded={open} className="text-slate-400" />
</button>
{open && (
<div
onClick={() => setOpen(false)}
className={`absolute top-full z-30 mt-1 min-w-[190px] rounded border border-slate-200 bg-white py-1 shadow-lg ${
align === 'right' ? 'right-0' : 'left-0'
}`}
>
{children}
</div>
)}
</div>
)
}
/** A row inside a DropdownMenu — same look everywhere so items from
* different menus (Diagram, Admin, Account) don't feel inconsistent. */
export function DropdownMenuItem({
onClick,
children,
badge,
}: {
onClick: () => void
children: ReactNode
badge?: ReactNode
}) {
return (
<button
onClick={onClick}
className="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-xs text-slate-600 hover:bg-slate-100"
>
<span>{children}</span>
{badge}
</button>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { useMemo, useState } from 'react'
import { familyLabel } from '../../domain/diagram'
import type { PortType } from '../../domain/types'
import InfoTooltip from './InfoTooltip'
const NEW_FAMILY_VALUE = '__new__'
const FAMILY_HELP_TEXT =
'A connector family groups together connectors that are physically and electrically identical, so the same cables fit them all with no adapter needed. Pick an existing family to match one exactly; use "+ New family…" only for a genuinely different connector. For one that mates via an adapter cable instead, use "Can accept connections from" below.'
/**
* A compatibility-family picker for defining/editing a PortType. `family` is
* an opaque matching key (two ports connect only if their families are the
* same string), which used to be a plain free-text field — impossible to
* know what to type to deliberately match an *existing* family (is it
* "HDMI", "hdmi", "Hdmi 2.1"?) without reading source. This instead lists
* every family already in use, labeled by the port type that owns it (see
* familyLabel), so matching an existing one is a pick, not a guess — with a
* "+ New family…" escape hatch (free text) for a genuinely new one, same
* inline-create shape as CategorySelect/ManufacturerSelect.
*
* Unlike those two, there's no backend entity to insert here — family is
* just a string on the PortType being saved — so "new" mode only needs a
* local draft and a plain callback, no async create step.
*/
export default function FamilySelect({
value,
onChange,
portTypes,
className = 'w-full rounded border border-slate-300 px-1.5 py-1 text-xs',
}: {
value: string
onChange: (family: string) => void
portTypes: PortType[]
className?: string
}) {
const families = useMemo(() => {
const seen = new Set<string>()
const entries: { family: string; label: string }[] = []
for (const pt of portTypes) {
if (seen.has(pt.family)) continue
seen.add(pt.family)
entries.push({ family: pt.family, label: familyLabel(portTypes, pt.family) })
}
return entries.sort((a, b) => a.label.localeCompare(b.label))
}, [portTypes])
// Defaults to the free-text box when the current value isn't one of the
// known families — covers both "brand new port type, nothing typed yet"
// (value is '') and "editing a type whose family predates this picker".
// An existing type's own family is always itself in `families` (it's one
// of `portTypes`), so this never spuriously hides a valid current value.
const [creating, setCreating] = useState(() => value === '' || !families.some((f) => f.family === value))
const [draft, setDraft] = useState(value)
const commitDraft = () => {
const trimmed = draft.trim()
if (trimmed) onChange(trimmed)
setCreating(false)
}
const label = (
<div className="mb-1 flex items-center gap-1">
<span className="text-[10px] font-medium uppercase tracking-wide text-slate-500">Family</span>
<InfoTooltip text={FAMILY_HELP_TEXT} />
</div>
)
if (creating) {
return (
<div>
{label}
<div className="flex gap-1.5">
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitDraft()
} else if (e.key === 'Escape' && families.length > 0) {
setCreating(false)
}
}}
placeholder="New family (e.g. hdmi)"
className={`min-w-0 flex-1 ${className}`}
/>
<button
onClick={commitDraft}
className="shrink-0 rounded bg-indigo-600 px-2 text-xs font-medium text-white hover:bg-indigo-500"
>
Use
</button>
{families.length > 0 && (
<button
onClick={() => setCreating(false)}
className="shrink-0 rounded px-2 text-xs text-slate-500 hover:bg-slate-100"
>
Choose existing
</button>
)}
</div>
</div>
)
}
return (
<div>
{label}
<select
value={value}
onChange={(e) => {
if (e.target.value === NEW_FAMILY_VALUE) {
setDraft('')
setCreating(true)
} else {
onChange(e.target.value)
}
}}
className={className}
>
{families.map((f) => (
<option key={f.family} value={f.family}>
{f.label} ({f.family})
</option>
))}
<option value={NEW_FAMILY_VALUE}>+ New family…</option>
</select>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const POPOVER_WIDTH = 256
const VIEWPORT_MARGIN = 8
/**
* A small "i" affordance that reveals a block of help text on click — for a
* concept that needs more than a one-line inline hint can carry, without
* permanently taking up screen space. Click-toggled rather than hover-only
* so it works the same on touch as with a mouse, and stays open long enough
* to actually read instead of vanishing the moment the pointer drifts.
*
* Renders the popover through a portal to `document.body`, positioned via
* the trigger's own bounding rect rather than as a normal absolutely-
* positioned child — this component is mostly used inside a Modal, whose
* scrollable body clips anything (including absolutely-positioned children)
* that pokes past its edge, which cut the popover off before this existed.
*/
export default function InfoTooltip({ text }: { text: string }) {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const toggle = () => {
if (!open && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
const left = Math.min(Math.max(rect.left, VIEWPORT_MARGIN), window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN)
setPosition({ top: rect.bottom + 4, left })
}
setOpen((v) => !v)
}
useEffect(() => {
if (!open) return
const onOutsideClick = (e: MouseEvent) => {
const target = e.target as Node
if (buttonRef.current?.contains(target) || popoverRef.current?.contains(target)) return
setOpen(false)
}
// Also close on scroll (e.g. the modal body it's usually opened inside)
// rather than tracking the trigger's position live — simplest way to
// avoid a stale popover that's drifted away from its "i" icon.
const close = () => setOpen(false)
document.addEventListener('mousedown', onOutsideClick)
document.addEventListener('scroll', close, true)
return () => {
document.removeEventListener('mousedown', onOutsideClick)
document.removeEventListener('scroll', close, true)
}
}, [open])
return (
<>
<button
ref={buttonRef}
type="button"
onClick={toggle}
title="More info"
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-0 bg-transparent p-0 text-slate-400 hover:text-slate-600"
>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
<circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M10 9.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="10" cy="6.5" r="1" fill="currentColor" />
</svg>
</button>
{open &&
position &&
createPortal(
<div
ref={popoverRef}
style={{ position: 'fixed', top: position.top, left: position.left, width: POPOVER_WIDTH }}
className="z-[60] rounded border border-slate-200 bg-white p-2 text-[11px] font-normal normal-case leading-snug text-slate-600 shadow-lg"
>
{text}
</div>,
document.body,
)}
</>
)
}
@@ -0,0 +1,100 @@
import { useMemo, useState } from 'react'
import { allManufacturers } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
const NEW_MANUFACTURER_VALUE = '__new__'
const NONE_VALUE = ''
/** A manufacturer <select> mirroring CategorySelect's "+ New…" inline-create
* pattern, but optional — unlike a device category, not every device has a
* known manufacturer (DIY/generic gear), so this always offers a blank
* "(none)" option alongside the catalog and the create option. */
export default function ManufacturerSelect({
value,
onChange,
className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm',
publishPublic = false,
}: {
value: string | undefined
onChange: (manufacturerId: string | undefined) => void
className?: string
/** See CategorySelect's own doc — same cascade for the same reason. */
publishPublic?: boolean
}) {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomManufacturer = useCatalogStore((s) => s.addCustomManufacturer)
const adminAddManufacturer = useCatalogStore((s) => s.adminAddManufacturer)
const [creating, setCreating] = useState(false)
const [draftName, setDraftName] = useState('')
const manufacturers = useMemo(
() => [...allManufacturers(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const commitNewManufacturer = async () => {
const trimmed = draftName.trim()
if (trimmed) {
onChange(await (publishPublic ? adminAddManufacturer(trimmed) : addCustomManufacturer(trimmed)))
}
setCreating(false)
setDraftName('')
}
if (creating) {
return (
<div className="mt-1 flex gap-1.5">
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitNewManufacturer()
} else if (e.key === 'Escape') {
setCreating(false)
setDraftName('')
}
}}
placeholder="New manufacturer name"
className="w-full min-w-0 flex-1 rounded border border-slate-300 px-2 py-1 text-sm"
/>
<button
onClick={commitNewManufacturer}
className="shrink-0 rounded bg-indigo-600 px-2 text-xs font-medium text-white hover:bg-indigo-500"
>
Add
</button>
<button
onClick={() => {
setCreating(false)
setDraftName('')
}}
className="shrink-0 rounded px-2 text-xs text-slate-500 hover:bg-slate-100"
>
Cancel
</button>
</div>
)
}
return (
<select
value={value ?? NONE_VALUE}
onChange={(e) => {
if (e.target.value === NEW_MANUFACTURER_VALUE) setCreating(true)
else onChange(e.target.value === NONE_VALUE ? undefined : e.target.value)
}}
className={className}
>
<option value={NONE_VALUE}>(none)</option>
{manufacturers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
<option value={NEW_MANUFACTURER_VALUE}>+ New manufacturer…</option>
</select>
)
}
+20 -5
View File
@@ -5,6 +5,7 @@ import type { PortDirection } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import CategorySelect from '../common/CategorySelect'
import ManufacturerSelect from '../common/ManufacturerSelect'
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
const diagram = useDiagramStore((s) => s.diagram)
@@ -47,10 +48,9 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</label>
<label className="text-xs font-medium text-slate-600">
Manufacturer
<input
value={device.manufacturer ?? ''}
onChange={(e) => updateDevice(device.id, { manufacturer: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
<ManufacturerSelect
value={device.manufacturerId}
onChange={(manufacturerId) => updateDevice(device.id, { manufacturerId })}
/>
</label>
<label className="text-xs font-medium text-slate-600">
@@ -95,7 +95,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
{device.ports.map((port) => {
const inUse = connectionCountForPort(port.id) > 0
return (
<div key={port.id} className="flex items-center gap-1.5">
<div key={port.id} className="flex flex-wrap items-center gap-1.5">
<input
ref={(el) => {
if (el && focusPortId === port.id) {
@@ -129,6 +129,20 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</option>
))}
</select>
{/* Grouped so these three wrap to their own line as a unit
(aligned to the row's end) rather than each one reflowing
separately if the row ever gets tighter than it can fit. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
onClick={() => updatePort(device.id, port.id, { builtInCable: !port.builtInCable })}
tabIndex={-1}
title="Built-in cable"
className={`shrink-0 rounded px-1 ${
port.builtInCable ? 'bg-amber-100 opacity-100 ring-1 ring-amber-300' : 'opacity-30 hover:opacity-60'
}`}
>
🔌
</button>
<button
onClick={() => setFocusPortId(clonePort(device.id, port.id))}
tabIndex={-1}
@@ -147,6 +161,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
✕
</button>
</div>
</div>
)
})}
</div>
+67 -16
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { allCableTypes, allPortTypes } from '../../domain/diagram'
import { allCableTypes, allPortTypes, familyLabel } from '../../domain/diagram'
import type { CableType, PortType } from '../../domain/types'
import { cableTypeToRow } from '../../data/catalogRowMapping'
import { useAdminReviewStore } from '../../state/adminReviewStore'
@@ -10,23 +10,19 @@ import { resolveCableTypeFields, type CableTypeFormFields } from './cableTypeFor
const UNITS: CableType['unit'][] = ['ft', 'm']
/** Best-effort friendly label for a raw family string: the name of whichever
* port type owns that family, or the raw string if none matches (e.g. a
* family only ever typed into a text field, not backed by a port type). */
function familyLabel(family: string, portTypes: PortType[]): string {
return portTypes.find((pt) => pt.family === family)?.name ?? family
}
export function CableTypeForm({
initial,
portTypes,
showPublishToggle = false,
onCancel,
onSave,
}: {
initial?: CableType
portTypes: PortType[]
/** See PortTypeForm's own doc — same "new + Admin" -only publish toggle. */
showPublishToggle?: boolean
onCancel: () => void
onSave: (fields: CableTypeFormFields) => void
onSave: (fields: CableTypeFormFields, publishPublic: boolean) => void
}) {
const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined
const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined
@@ -36,18 +32,22 @@ export function CableTypeForm({
const [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '')
const [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft')
const [costPerUnit, setCostPerUnit] = useState(initial?.costPerUnit?.toString() ?? '')
const [publishPublic, setPublishPublic] = useState(false)
const isAdapter = endAPortTypeId !== endBPortTypeId
const handleSave = () => {
if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return
onSave({
onSave(
{
name: name.trim(),
endAPortTypeId,
endBPortTypeId,
unit,
costPerUnit: costPerUnit.trim() === '' ? undefined : Number(costPerUnit),
})
},
publishPublic,
)
}
return (
@@ -112,6 +112,12 @@ export function CableTypeForm({
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
</div>
{showPublishToggle && (
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
<input type="checkbox" checked={publishPublic} onChange={(e) => setPublishPublic(e.target.checked)} />
Publish directly to the shared library (skips the review queue)
</label>
)}
<div className="flex justify-end gap-2">
<button onClick={onCancel} className="text-[11px] text-slate-500">
Cancel
@@ -131,7 +137,10 @@ export default function CableTypesPanel() {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomCableType = useCatalogStore((s) => s.addCustomCableType)
const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType)
const removeCustomCableType = useCatalogStore((s) => s.removeCustomCableType)
const adminUpdateCableType = useCatalogStore((s) => s.adminUpdateCableType)
const adminAddCableType = useCatalogStore((s) => s.adminAddCableType)
const adminRemoveCableType = useCatalogStore((s) => s.adminRemoveCableType)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const submitForReview = useSubmissionStore((s) => s.submitForReview)
@@ -163,15 +172,16 @@ export default function CableTypesPanel() {
)
const describeEnds = (ct: CableType) => {
const a = familyLabel(ct.family, portTypes)
const b = familyLabel(ct.family2 ?? ct.family, portTypes)
const a = familyLabel(portTypes, ct.family)
const b = familyLabel(portTypes, ct.family2 ?? ct.family)
return a === b ? a : `${a} ↔ ${b}`
}
const handleCreate = (fields: CableTypeFormFields) => {
const handleCreate = (fields: CableTypeFormFields, publishPublic: boolean) => {
const resolved = resolveCableTypeFields(fields, portTypes)
if (!resolved) return
addCustomCableType(resolved)
if (isAdmin && publishPublic) adminAddCableType(resolved)
else addCustomCableType(resolved)
setCreating(false)
}
@@ -211,6 +221,29 @@ export default function CableTypesPanel() {
adminUnpublish('cable_type', ct.id)
}
// Shared by Custom (your own) and Built-in (Admin permanent-delete) —
// cable types aren't referenced by any foreign key (matched by family at
// diagram-render time, not by id), so the only usage check needed is the
// same diagram-usage-impact one Unpublish already uses. Blocks outright
// rather than offering "confirm anyway" — unlike unpublish, this can't be
// undone.
const deleteIfUnused = async (ct: CableType, remove: (id: string) => Promise<void>) => {
const impact = await getUsageImpact('cable_type', ct.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${ct.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(`Delete "${ct.name}"? This can't be undone.`)
if (!confirmed) return
try {
await remove(ct.id)
} catch {
window.alert(`Couldn't delete "${ct.name}" — it may still be in use somewhere.`)
}
}
return (
<div className="space-y-4">
<div>
@@ -226,7 +259,12 @@ export default function CableTypesPanel() {
</div>
{creating && (
<div className="mb-1.5">
<CableTypeForm portTypes={portTypes} onCancel={() => setCreating(false)} onSave={handleCreate} />
<CableTypeForm
portTypes={portTypes}
showPublishToggle={isAdmin}
onCancel={() => setCreating(false)}
onSave={handleCreate}
/>
</div>
)}
{customTypes.length === 0 && !creating ? (
@@ -276,6 +314,12 @@ export default function CableTypesPanel() {
>
Edit
</button>
<button
onClick={() => deleteIfUnused(ct, removeCustomCableType)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
</div>
),
@@ -333,6 +377,13 @@ export default function CableTypesPanel() {
>
Unpublish
</button>
<button
onClick={() => deleteIfUnused(ct, adminRemoveCableType)}
title="Permanently delete — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
) : pendingEntityIds.has(ct.id) ? (
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
@@ -0,0 +1,44 @@
import { useState } from 'react'
import CategoryManufacturerPanel from './CategoryManufacturerPanel'
import Modal from '../common/Modal'
type Tab = 'categories' | 'manufacturers'
export default function CategoryLibraryModal({ onClose }: { onClose: () => void }) {
const [tab, setTab] = useState<Tab>('categories')
return (
<Modal title="Categories & Manufacturers" onClose={onClose} width="max-w-xl">
<div className="mb-3 flex border-b border-slate-200">
<button
onClick={() => setTab('categories')}
className={`px-3 py-1.5 text-xs font-semibold uppercase tracking-wide ${
tab === 'categories' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Device Categories
</button>
<button
onClick={() => setTab('manufacturers')}
className={`px-3 py-1.5 text-xs font-semibold uppercase tracking-wide ${
tab === 'manufacturers' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Manufacturers
</button>
</div>
{tab === 'categories' ? (
<CategoryManufacturerPanel entityType="device_category" />
) : (
<CategoryManufacturerPanel entityType="manufacturer" />
)}
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,374 @@
import { useMemo, useState } from 'react'
import { deviceCategoryToRow, manufacturerToRow } from '../../data/catalogRowMapping'
import type { CatalogEntityType } from '../../data/SubmissionRepository'
import { allDeviceCategories, allManufacturers } from '../../domain/diagram'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
interface NamedEntity {
id: string
name: string
custom?: boolean
}
/** Inline rename/create row — every action here (add, rename, suggest an
* edit) is just "type a name, confirm", so one small form covers all of
* them rather than a bespoke component per action the way port/cable
* types need (PortTypeForm/CableTypeForm) for their much richer shapes. */
function NameForm({
initialName = '',
confirmLabel,
onCancel,
onSave,
}: {
initialName?: string
confirmLabel: string
onCancel: () => void
onSave: (name: string) => void
}) {
const [name, setName] = useState(initialName)
const commit = () => {
if (name.trim()) onSave(name.trim())
}
return (
<div className="flex items-center gap-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commit()
} else if (e.key === 'Escape') {
onCancel()
}
}}
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<button onClick={onCancel} className="shrink-0 text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={commit}
disabled={!name.trim()}
className="shrink-0 rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{confirmLabel}
</button>
</div>
)
}
type EditorTarget =
| { mode: 'new' }
| { mode: 'rename' | 'suggestEdit' | 'adminEdit'; entity: NamedEntity }
/**
* Device categories and manufacturers share an identical shape (just a
* name, plus the usual is_public/owner_id split) and an identical set of
* actions — one generic panel, parameterized by entity type, rather than
* duplicating PortTypesPanel/CableTypesPanel's much larger structure twice
* for something this simple.
*/
export default function CategoryManufacturerPanel({
entityType,
}: {
entityType: Extract<CatalogEntityType, 'device_category' | 'manufacturer'>
}) {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory)
const updateCustomDeviceCategory = useCatalogStore((s) => s.updateCustomDeviceCategory)
const removeCustomDeviceCategory = useCatalogStore((s) => s.removeCustomDeviceCategory)
const adminUpdateDeviceCategory = useCatalogStore((s) => s.adminUpdateDeviceCategory)
const adminAddDeviceCategory = useCatalogStore((s) => s.adminAddDeviceCategory)
const adminRemoveDeviceCategory = useCatalogStore((s) => s.adminRemoveDeviceCategory)
const addCustomManufacturer = useCatalogStore((s) => s.addCustomManufacturer)
const updateCustomManufacturer = useCatalogStore((s) => s.updateCustomManufacturer)
const removeCustomManufacturer = useCatalogStore((s) => s.removeCustomManufacturer)
const adminUpdateManufacturer = useCatalogStore((s) => s.adminUpdateManufacturer)
const adminAddManufacturer = useCatalogStore((s) => s.adminAddManufacturer)
const adminRemoveManufacturer = useCatalogStore((s) => s.adminRemoveManufacturer)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
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<EditorTarget | null>(null)
const [publishNewPublic, setPublishNewPublic] = useState(false)
const isCategory = entityType === 'device_category'
const label = isCategory ? 'category' : 'manufacturer'
const items: NamedEntity[] = isCategory ? allDeviceCategories(catalog) : allManufacturers(catalog)
const addCustom = isCategory ? addCustomDeviceCategory : addCustomManufacturer
const updateCustom = isCategory ? updateCustomDeviceCategory : updateCustomManufacturer
const removeCustom = isCategory ? removeCustomDeviceCategory : removeCustomManufacturer
const adminUpdate = isCategory ? adminUpdateDeviceCategory : adminUpdateManufacturer
const adminAdd = isCategory ? adminAddDeviceCategory : adminAddManufacturer
const adminRemove = isCategory ? adminRemoveDeviceCategory : adminRemoveManufacturer
const toRow = isCategory ? deviceCategoryToRow : manufacturerToRow
const sortedItems = useMemo(() => [...items].sort((a, b) => a.name.localeCompare(b.name)), [items])
const customItems = useMemo(() => sortedItems.filter((i) => i.custom), [sortedItems])
const builtInItems = useMemo(() => sortedItems.filter((i) => !i.custom), [sortedItems])
const pendingEntityIds = useMemo(
() => new Set(mySubmissions.filter((s) => s.entityType === entityType && s.status === 'pending').map((s) => s.entityId)),
[mySubmissions, entityType],
)
const handleUnpublish = async (item: NamedEntity) => {
const impact = await getUsageImpact(entityType, item.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 "${item.name}" from the public catalog?${impactNote}`)
if (!confirmed) return
adminUnpublish(entityType, item.id)
}
// Deleting your own private category/manufacturer needs two checks
// first: a device_templates.category_id/manufacturer_id foreign key
// would otherwise reject the delete outright if any device template
// still references it (checked client-side here for a friendly message
// instead of a raw DB error) — and unlike that FK, nothing stops deleting
// one still referenced by a *placed device* inside a diagram (diagrams
// are a JSONB blob, not relationally linked), so that's checked via the
// same usage-impact function Admin unpublish uses, now extended to let
// you check your own still-private entries too. Either kind of usage
// blocks the delete outright — unlike unpublish, a hard delete can't be
// undone, so "confirm anyway" isn't offered here.
const handleDelete = async (item: NamedEntity) => {
const usedByTemplate = catalog.deviceTemplates.some((t) => (isCategory ? t.category : t.manufacturerId) === item.id)
if (usedByTemplate) {
window.alert(`Can't delete "${item.name}" — it's still used by one of your devices in the palette. Change that device's ${label} first.`)
return
}
const impact = await getUsageImpact(entityType, item.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${item.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(`Delete "${item.name}"? This can't be undone.`)
if (!confirmed) return
try {
await removeCustom(item.id)
} catch {
window.alert(`Couldn't delete "${item.name}" — it may still be in use somewhere.`)
}
}
// Admin permanent-delete of a built-in entry — same zero-usage gate as
// handleDelete above (nothing stops here that unpublish wouldn't also
// have blocked with a confirm; this just doesn't offer "confirm anyway"
// since a hard delete can't be undone the way unpublish can).
const handleAdminDelete = async (item: NamedEntity) => {
const usedByTemplate = catalog.deviceTemplates.some((t) => (isCategory ? t.category : t.manufacturerId) === item.id)
if (usedByTemplate) {
window.alert(`Can't delete "${item.name}" — it's still used by a device template. Change that device's ${label} first.`)
return
}
const impact = await getUsageImpact(entityType, item.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${item.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 "${item.name}" from the shared library? This can't be undone.`)
if (!confirmed) return
try {
await adminRemove(item.id)
} catch {
window.alert(`Couldn't delete "${item.name}" — it may still be referenced somewhere.`)
}
}
return (
<div className="space-y-4">
<div>
<div className="mb-1 flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Custom ({customItems.length})
</h4>
{editorTarget?.mode !== 'new' && (
<button
onClick={() => setEditorTarget({ mode: 'new' })}
className="text-[11px] font-medium text-indigo-600 hover:underline"
>
+ New {label}
</button>
)}
</div>
{editorTarget?.mode === 'new' && (
<div className="mb-1.5 space-y-1.5">
{isAdmin && (
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
<input
type="checkbox"
checked={publishNewPublic}
onChange={(e) => setPublishNewPublic(e.target.checked)}
/>
Publish directly to the shared library (skips the review queue)
</label>
)}
<NameForm
confirmLabel="Add"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
if (isAdmin && publishNewPublic) adminAdd(name)
else addCustom(name)
setEditorTarget(null)
setPublishNewPublic(false)
}}
/>
</div>
)}
{customItems.length === 0 && editorTarget?.mode !== 'new' ? (
<p className="text-[11px] italic text-slate-400">None yet.</p>
) : (
<div className="space-y-1.5">
{customItems.map((item) =>
editorTarget?.mode === 'rename' && editorTarget.entity.id === item.id ? (
<NameForm
key={item.id}
initialName={item.name}
confirmLabel="Save"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
updateCustom(item.id, name)
setEditorTarget(null)
}}
/>
) : (
<div
key={item.id}
className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs"
>
<span className="min-w-0 truncate font-medium text-slate-700">{item.name}</span>
<div className="flex shrink-0 items-center gap-1">
{pendingEntityIds.has(item.id) ? (
<span className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-600">
Pending review
</span>
) : (
<button
onClick={() => submitForReview(entityType, item.id, toRow(item.name))}
title={`Submit this ${label} to the public catalog for Admin review`}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-emerald-100 hover:text-emerald-700"
>
Submit
</button>
)}
<button
onClick={() => setEditorTarget({ mode: 'rename', entity: item })}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
>
Rename
</button>
<button
onClick={() => handleDelete(item)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
</div>
),
)}
</div>
)}
</div>
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Built-in ({builtInItems.length})
</h4>
<div className="max-h-64 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInItems.map((item) => {
if (isAdmin && editorTarget?.mode === 'adminEdit' && editorTarget.entity.id === item.id) {
return (
<div key={item.id} className="mb-1.5">
<NameForm
initialName={item.name}
confirmLabel="Save"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
adminUpdate(item.id, name)
setEditorTarget(null)
}}
/>
</div>
)
}
if (!isAdmin && editorTarget?.mode === 'suggestEdit' && editorTarget.entity.id === item.id) {
return (
<div key={item.id} className="mb-1.5">
<NameForm
initialName={item.name}
confirmLabel="Submit"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
submitForReview(entityType, item.id, toRow(name))
setEditorTarget(null)
}}
/>
</div>
)
}
return (
<div key={item.id} className="flex items-center justify-between gap-2 px-1 py-0.5 text-[11px] text-slate-500">
<span className="min-w-0 truncate">{item.name}</span>
{isAdmin ? (
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => setEditorTarget({ mode: 'adminEdit', entity: item })}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Edit
</button>
<button
onClick={() => handleUnpublish(item)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Unpublish
</button>
<button
onClick={() => handleAdminDelete(item)}
title="Permanently delete — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
) : pendingEntityIds.has(item.id) ? (
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
Edit pending review
</span>
) : (
<button
onClick={() => setEditorTarget({ mode: 'suggestEdit', entity: item })}
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Suggest edit
</button>
)}
</div>
)
})}
</div>
<p className="mt-1 text-[10px] text-slate-400">
{isAdmin
? `As an Admin, you can edit or unpublish public ${label}s directly.`
: `Built-in ${label}s can't be edited directly — "Suggest edit" sends a proposed change to Admins for review.`}
</p>
</div>
</div>
)
}
+343 -124
View File
@@ -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 &amp; 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>
<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)
{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.categoryId} className="mb-1">
<div key={group.key} className="mb-1">
<button
onClick={() => toggleCategory(group.categoryId)}
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.categoryName}
{group.label}
</span>
<span className="text-[10px] text-slate-400">{group.templates.length}</span>
<span className="text-[10px] text-slate-400">{group.count}</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 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>
</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 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>
)
}
+87 -26
View File
@@ -3,9 +3,12 @@ import { deviceTemplateToRow } from '../../data/catalogRowMapping'
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/diagram'
import type { DeviceTemplate, PortDirection } from '../../domain/types'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
import CategorySelect from '../common/CategorySelect'
import FamilySelect from '../common/FamilySelect'
import ManufacturerSelect from '../common/ManufacturerSelect'
import Modal from '../common/Modal'
interface DraftPort {
@@ -13,6 +16,7 @@ interface DraftPort {
name: string
direction: PortDirection
portTypeId: string
builtInCable?: boolean
}
let draftKeySeq = 0
@@ -45,17 +49,27 @@ export default function DeviceTemplateEditor({
* `submissionMode`/`adminMode` if somehow more than one is set. */
resubmitId?: string
onClose: () => void
/** Called with the template's category id right before closing, so the
* palette can expand that category and reveal what was just created/edited. */
onSaved?: (categoryId: string) => void
/** Called with the template's category and manufacturer ids right before
* closing, so the palette can expand whichever group (category or
* manufacturer, depending on its current view mode) contains what was
* just created/edited. */
onSaved?: (categoryId: string, manufacturerId: string | undefined) => void
}) {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
const adminUpdateDeviceTemplate = useCatalogStore((s) => s.adminUpdateDeviceTemplate)
const adminAddDeviceTemplate = useCatalogStore((s) => s.adminAddDeviceTemplate)
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
const adminAddPortType = useCatalogStore((s) => s.adminAddPortType)
const submitForReview = useSubmissionStore((s) => s.submitForReview)
const resubmit = useSubmissionStore((s) => s.resubmit)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
// Only meaningful when creating a genuinely new device (not editing,
// suggesting, resubmitting, or admin-editing an existing public one) —
// those all already have their own place in the ownership/review model.
const canPublishDirectly = isAdmin && !template && !submissionMode && !adminMode && !resubmitId
const portTypes = useMemo(
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
@@ -64,12 +78,18 @@ export default function DeviceTemplateEditor({
const [name, setName] = useState(template?.name ?? '')
const [category, setCategory] = useState<string>(template?.category ?? 'other')
const [manufacturer, setManufacturer] = useState(template?.manufacturer ?? '')
const [manufacturerId, setManufacturerId] = useState<string | undefined>(template?.manufacturerId)
const [model, setModel] = useState(template?.model ?? '')
const [cost, setCost] = useState(template?.cost?.toString() ?? '')
const [ports, setPorts] = useState<DraftPort[]>(
() =>
template?.ports.map((p) => ({ key: p.id, name: p.name, direction: p.direction, portTypeId: p.portTypeId })) ?? [
template?.ports.map((p) => ({
key: p.id,
name: p.name,
direction: p.direction,
portTypeId: p.portTypeId,
builtInCable: p.builtInCable,
})) ?? [
{ key: nextDraftKey(), name: 'Port 1', direction: 'output', portTypeId: portTypes[0]?.id ?? '' },
],
)
@@ -79,6 +99,7 @@ export default function DeviceTemplateEditor({
const [newPortTypeCategory, setNewPortTypeCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>('other')
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(new Set())
const [focusKey, setFocusKey] = useState<string | null>(null)
const [publishDirectly, setPublishDirectly] = useState(false)
const toggleAlsoCompatible = (portTypeId: string) => {
setAlsoCompatibleWith((prev) => {
@@ -121,12 +142,17 @@ export default function DeviceTemplateEditor({
const handleCreatePortType = () => {
if (!newPortTypeName.trim()) return
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes)
addCustomPortType({
const fields = {
name: newPortTypeName.trim(),
category: newPortTypeCategory,
family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'),
...(compatibleFamilyIds.length > 0 ? { compatibleFamilyIds } : {}),
})
}
// If this device is going straight into the public catalog, a connector
// type invented for it needs to as well — otherwise the public device
// ends up referencing a private port type nobody else can see.
if (canPublishDirectly && publishDirectly) adminAddPortType(fields)
else addCustomPortType(fields)
setNewPortTypeName('')
setNewPortTypeFamily('')
setAlsoCompatibleWith(new Set())
@@ -140,10 +166,16 @@ export default function DeviceTemplateEditor({
const fields = {
name: name.trim(),
category,
manufacturer: manufacturer.trim() || undefined,
manufacturerId,
model: model.trim() || undefined,
cost: cost.trim() === '' ? undefined : Number(cost),
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })),
ports: ports.map((p) => ({
id: p.key,
name: p.name.trim() || 'Port',
direction: p.direction,
portTypeId: p.portTypeId,
builtInCable: p.builtInCable,
})),
}
if (resubmitId) {
resubmit(resubmitId, deviceTemplateToRow(fields))
@@ -153,10 +185,12 @@ export default function DeviceTemplateEditor({
submitForReview('device_template', template.id, deviceTemplateToRow(fields))
} else if (template) {
updateCustomDeviceTemplate(template.id, fields)
} else if (canPublishDirectly && publishDirectly) {
adminAddDeviceTemplate(fields)
} else {
addCustomDeviceTemplate(fields)
}
onSaved?.(category)
onSaved?.(category, manufacturerId)
onClose()
}
@@ -189,14 +223,14 @@ export default function DeviceTemplateEditor({
</label>
<label className="text-xs font-medium text-slate-600">
Category
<CategorySelect value={category} onChange={setCategory} />
<CategorySelect value={category} onChange={setCategory} publishPublic={canPublishDirectly && publishDirectly} />
</label>
<label className="text-xs font-medium text-slate-600">
Manufacturer
<input
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
<ManufacturerSelect
value={manufacturerId}
onChange={setManufacturerId}
publishPublic={canPublishDirectly && publishDirectly}
/>
</label>
<label className="text-xs font-medium text-slate-600">
@@ -224,11 +258,28 @@ export default function DeviceTemplateEditor({
</label>
</div>
{canPublishDirectly && (
<label className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50/60 px-2 py-1.5 text-xs text-amber-800">
<input
type="checkbox"
checked={publishDirectly}
onChange={(e) => setPublishDirectly(e.target.checked)}
className="mt-0.5"
/>
<span>
<span className="font-medium">Publish directly to the shared library.</span> Skips the review queue —
use this when you're authoring the official catalog yourself. Any new category, manufacturer, or
connector type you create below while this is checked is published too, so the device doesn't end up
referencing something private.
</span>
</label>
)}
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4>
<div className="space-y-1.5">
{ports.map((port) => (
<div key={port.key} className="flex items-center gap-1.5">
<div key={port.key} className="flex flex-wrap items-center gap-1.5">
<input
ref={(el) => {
if (el && focusKey === port.key) {
@@ -263,6 +314,20 @@ export default function DeviceTemplateEditor({
</option>
))}
</select>
{/* Grouped so these three wrap to their own line as a unit
rather than each one reflowing separately if the row
ever gets tighter than it can fit. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
onClick={() => updatePort(port.key, { builtInCable: !port.builtInCable })}
tabIndex={-1}
title="Built-in cable"
className={`shrink-0 rounded px-1 ${
port.builtInCable ? 'bg-amber-100 opacity-100 ring-1 ring-amber-300' : 'opacity-30 hover:opacity-60'
}`}
>
🔌
</button>
<button
onClick={() => clonePortRow(port.key)}
tabIndex={-1}
@@ -280,6 +345,7 @@ export default function DeviceTemplateEditor({
✕
</button>
</div>
</div>
))}
{ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>}
</div>
@@ -316,18 +382,11 @@ export default function DeviceTemplateEditor({
))}
</select>
</div>
<input
value={newPortTypeFamily}
onChange={(e) => setNewPortTypeFamily(e.target.value)}
placeholder="Compatibility family (optional, defaults to name)"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<p className="text-[10px] text-slate-500">
Two ports can be wired together only if their connector type shares the same family.
</p>
<FamilySelect value={newPortTypeFamily} onChange={setNewPortTypeFamily} portTypes={portTypes} />
<p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>
<div>
<p className="text-[10px] font-medium text-slate-600">
Also compatible with (for jacks that accept more than one kind — e.g. a patch bay that's
Can accept connections from (for jacks that accept more than one kind — e.g. a patch bay that's
balanced or unbalanced):
</p>
<div className="mt-1 max-h-24 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
@@ -376,6 +435,8 @@ export default function DeviceTemplateEditor({
? 'Submit for review'
: template
? 'Save changes'
: canPublishDirectly && publishDirectly
? 'Publish device'
: 'Save device'}
</button>
</div>
+119 -25
View File
@@ -3,6 +3,7 @@ import { PORT_CATEGORIES } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/diagram'
import type { PortType } from '../../domain/types'
import { portTypeToRow } from '../../data/catalogRowMapping'
import FamilySelect from '../common/FamilySelect'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
@@ -11,21 +12,30 @@ import { useSubmissionStore } from '../../state/submissionStore'
export function PortTypeForm({
portType,
allTypes,
showPublishToggle = false,
onCancel,
onSave,
}: {
portType: PortType
/** Omitted when defining a brand-new port type — mirrors CableTypeForm's
* optional `initial`. */
portType?: PortType
allTypes: PortType[]
/** Shows a "Publish directly" checkbox and passes its value as onSave's
* second argument — only meaningful for an Admin defining a brand-new
* type (never set for edit/suggest-edit/admin-edit). */
showPublishToggle?: boolean
onCancel: () => void
onSave: (fields: Omit<PortType, 'id' | 'custom'>) => void
onSave: (fields: Omit<PortType, 'id' | 'custom'>, publishPublic: boolean) => void
}) {
const [name, setName] = useState(portType.name)
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category)
const [family, setFamily] = useState(portType.family)
const [name, setName] = useState(portType?.name ?? '')
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType?.category ?? 'other')
const [family, setFamily] = useState(portType?.family ?? '')
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(() => {
if (!portType) return new Set()
const currentFamilies = new Set([portType.family, ...(portType.compatibleFamilyIds ?? [])])
return new Set(allTypes.filter((pt) => pt.id !== portType.id && currentFamilies.has(pt.family)).map((pt) => pt.id))
})
const [publishPublic, setPublishPublic] = useState(false)
const toggle = (id: string) => {
setAlsoCompatibleWith((prev) => {
@@ -36,18 +46,28 @@ export function PortTypeForm({
})
}
const canSave = name.trim().length > 0
const handleSave = () => {
if (!name.trim() || !family.trim()) return
if (!canSave) return
// Mirrors DeviceTemplateEditor's inline connector-type panel: an empty
// family (never confirmed in the FamilySelect sub-form below) defaults
// to the connector's own name rather than silently blocking Save — a
// brand-new connector is its own family unless told otherwise.
const resolvedFamily = (family.trim() || name.trim()).toLowerCase().replace(/\s+/g, '-')
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], allTypes).filter(
(f) => f !== family.trim(),
(f) => f !== resolvedFamily,
)
onSave({
onSave(
{
name: name.trim(),
category,
family: family.trim(),
family: resolvedFamily,
compatibleFamilyIds,
maxConnections: portType.maxConnections,
})
maxConnections: portType?.maxConnections,
},
publishPublic,
)
}
return (
@@ -70,17 +90,13 @@ export function PortTypeForm({
))}
</select>
</div>
<input
value={family}
onChange={(e) => setFamily(e.target.value)}
placeholder="Compatibility family"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<FamilySelect value={family} onChange={setFamily} portTypes={allTypes} />
{!portType && <p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>}
<div>
<p className="text-[10px] font-medium text-slate-600">Also compatible with:</p>
<p className="text-[10px] font-medium text-slate-600">Can accept connections from:</p>
<div className="mt-1 max-h-32 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
{allTypes
.filter((pt) => pt.id !== portType.id)
.filter((pt) => pt.id !== portType?.id)
.map((pt) => (
<label key={pt.id} className="flex items-center gap-1.5 py-0.5 text-[11px] text-slate-600">
<input type="checkbox" checked={alsoCompatibleWith.has(pt.id)} onChange={() => toggle(pt.id)} />
@@ -89,13 +105,20 @@ export function PortTypeForm({
))}
</div>
</div>
{showPublishToggle && (
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
<input type="checkbox" checked={publishPublic} onChange={(e) => setPublishPublic(e.target.checked)} />
Publish directly to the shared library (skips the review queue)
</label>
)}
<div className="flex justify-end gap-2">
<button onClick={onCancel} className="text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={handleSave}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
disabled={!canSave}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Save
</button>
@@ -106,14 +129,19 @@ export function PortTypeForm({
export default function PortTypesPanel() {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType)
const removeCustomPortType = useCatalogStore((s) => s.removeCustomPortType)
const adminUpdatePortType = useCatalogStore((s) => s.adminUpdatePortType)
const adminAddPortType = useCatalogStore((s) => s.adminAddPortType)
const adminRemovePortType = useCatalogStore((s) => s.adminRemovePortType)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
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 [creating, setCreating] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [suggestingId, setSuggestingId] = useState<string | null>(null)
const [adminEditingId, setAdminEditingId] = useState<string | null>(null)
@@ -143,6 +171,12 @@ export default function PortTypesPanel() {
return names.length > 0 ? names.join(', ') : null
}
const handleCreate = (fields: Omit<PortType, 'id' | 'custom'>, publishPublic: boolean) => {
if (isAdmin && publishPublic) adminAddPortType(fields)
else addCustomPortType(fields)
setCreating(false)
}
const handleSubmitForPublic = (pt: PortType) => {
submitForReview('port_type', pt.id, portTypeToRow(pt))
}
@@ -168,15 +202,62 @@ export default function PortTypesPanel() {
adminUnpublish('port_type', pt.id)
}
// Shared by both the Custom (your own private type) and Built-in (Admin
// permanent-delete) delete flows — same two-stage check as
// CategoryManufacturerPanel's handleDelete: a device template's port
// still pointing at it would otherwise fail as a raw foreign-key
// violation, and nothing stops deleting one still used by a *placed*
// device in a diagram (diagrams are a JSONB blob, not relationally
// linked). Either kind of usage blocks the delete outright — unlike
// unpublish, a hard delete can't be undone.
const deleteIfUnused = async (pt: PortType, remove: (id: string) => Promise<void>) => {
const usedByTemplate = catalog.deviceTemplates.some((t) => t.ports.some((p) => p.portTypeId === pt.id))
if (usedByTemplate) {
window.alert(`Can't delete "${pt.name}" — it's still used by a device template's port.`)
return
}
const impact = await getUsageImpact('port_type', pt.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${pt.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(`Delete "${pt.name}"? This can't be undone.`)
if (!confirmed) return
try {
await remove(pt.id)
} catch {
window.alert(`Couldn't delete "${pt.name}" — it may still be in use somewhere.`)
}
}
return (
<div className="space-y-4">
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div className="mb-1 flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Custom ({customTypes.length})
</h4>
{customTypes.length === 0 ? (
{!creating && (
<button onClick={() => setCreating(true)} className="text-[11px] font-medium text-indigo-600 hover:underline">
+ Define a new port type
</button>
)}
</div>
{creating && (
<div className="mb-1.5">
<PortTypeForm
allTypes={allTypes}
showPublishToggle={isAdmin}
onCancel={() => setCreating(false)}
onSave={handleCreate}
/>
</div>
)}
{customTypes.length === 0 && !creating ? (
<p className="text-[11px] italic text-slate-400">
None yet — define one from the "+ Define a new connector type" link when adding a custom device.
None yet — useful for a connector this app doesn't already have, like a proprietary jack.
</p>
) : (
<div className="space-y-1.5">
@@ -201,7 +282,7 @@ export default function PortTypesPanel() {
<div className="font-medium text-slate-700">{pt.name}</div>
<div className="truncate text-[10px] text-slate-400">
family: {pt.family}
{describeCompatibility(pt) ? ` · also fits: ${describeCompatibility(pt)}` : ''}
{describeCompatibility(pt) ? ` · can accept: ${describeCompatibility(pt)}` : ''}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -224,6 +305,12 @@ export default function PortTypesPanel() {
>
Edit
</button>
<button
onClick={() => deleteIfUnused(pt, removeCustomPortType)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
</div>
),
@@ -265,7 +352,7 @@ export default function PortTypesPanel() {
<span className="min-w-0 truncate">
{pt.name}
{describeCompatibility(pt) && (
<span className="text-slate-400"> · also fits: {describeCompatibility(pt)}</span>
<span className="text-slate-400"> · can accept: {describeCompatibility(pt)}</span>
)}
</span>
{isAdmin ? (
@@ -282,6 +369,13 @@ export default function PortTypesPanel() {
>
Unpublish
</button>
<button
onClick={() => deleteIfUnused(pt, adminRemovePortType)}
title="Permanently delete — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
) : pendingEntityIds.has(pt.id) ? (
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
@@ -14,6 +14,7 @@ const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
port_type: 'Port type',
cable_type: 'Cable type',
device_category: 'Category',
manufacturer: 'Manufacturer',
}
function formatDate(iso: string): string {
@@ -62,15 +63,36 @@ function SubmissionReviewCard({
return diffRows(currentRow, submission.proposedData)
}, [currentEntity, submission.entityType, submission.proposedData])
// Device names, categories, and manufacturers all benefit from the same
// nudge — a normalized manufacturer catalog only actually stays clean if
// near-duplicate manufacturer/category submissions get flagged the same
// way near-duplicate devices do (organized-ideas.md §3). Port/cable types
// aren't included — nothing so far has suggested they drift the same way.
const duplicates = useMemo(() => {
if (isEditToPublicEntry || submission.entityType !== 'device_template') return []
if (isEditToPublicEntry) return []
const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : ''
if (!proposedName) return []
const publicTemplates = catalog.deviceTemplates
.filter((dt) => !dt.custom && dt.id !== submission.entityId)
.map((dt) => ({ id: dt.id, name: dt.name }))
return findLikelyDuplicates(proposedName, publicTemplates)
}, [isEditToPublicEntry, submission.entityType, submission.entityId, submission.proposedData, catalog.deviceTemplates])
const candidates =
submission.entityType === 'device_template'
? catalog.deviceTemplates.filter((dt) => !dt.custom && dt.id !== submission.entityId)
: submission.entityType === 'device_category'
? catalog.deviceCategories.filter((c) => !c.custom && c.id !== submission.entityId)
: submission.entityType === 'manufacturer'
? catalog.manufacturers.filter((m) => !m.custom && m.id !== submission.entityId)
: []
return findLikelyDuplicates(
proposedName,
candidates.map((c) => ({ id: c.id, name: c.name })),
)
}, [
isEditToPublicEntry,
submission.entityType,
submission.entityId,
submission.proposedData,
catalog.deviceTemplates,
catalog.deviceCategories,
catalog.manufacturers,
])
const handleApprove = async () => {
setBusy(true)
@@ -17,6 +17,7 @@ const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
port_type: 'Port type',
cable_type: 'Cable type',
device_category: 'Category',
manufacturer: 'Manufacturer',
}
/** Best-effort display name: the entity's current live name if it still
@@ -69,7 +70,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
const withdraw = useSubmissionStore((s) => s.withdraw)
const resubmit = useSubmissionStore((s) => s.resubmit)
const [editingId, setEditingId] = useState<string | null>(null)
const [categoryDraft, setCategoryDraft] = useState('')
const [nameDraft, setNameDraft] = useState('')
const allPortTypesList = allPortTypes(catalog)
@@ -80,7 +81,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
}
const startEditing = (submission: CatalogSubmission) => {
setCategoryDraft(typeof submission.proposedData.name === 'string' ? submission.proposedData.name : '')
setNameDraft(typeof submission.proposedData.name === 'string' ? submission.proposedData.name : '')
setEditingId(submission.id)
}
@@ -188,13 +189,13 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
/>
)
}
if (isEditing && submission.entityType === 'device_category') {
if (isEditing && (submission.entityType === 'device_category' || submission.entityType === 'manufacturer')) {
return (
<div key={submission.id} className="flex items-center gap-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
<input
autoFocus
value={categoryDraft}
onChange={(e) => setCategoryDraft(e.target.value)}
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<button onClick={() => setEditingId(null)} className="shrink-0 text-[11px] text-slate-500">
@@ -202,8 +203,8 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
</button>
<button
onClick={() => {
if (!categoryDraft.trim()) return
resubmit(submission.id, { name: categoryDraft.trim() })
if (!nameDraft.trim()) return
resubmit(submission.id, { name: nameDraft.trim() })
setEditingId(null)
}}
className="shrink-0 rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
@@ -1,8 +1,8 @@
import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, portTypeToRow } from '../../data/catalogRowMapping'
import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, manufacturerToRow, portTypeToRow } from '../../data/catalogRowMapping'
import type { CatalogEntityType } from '../../data/SubmissionRepository'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, PortType } from '../../domain/types'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, PortType } from '../../domain/types'
export type CatalogEntity = PortType | CableType | DeviceTemplate | DeviceCategoryDef
export type CatalogEntity = PortType | CableType | DeviceTemplate | DeviceCategoryDef | Manufacturer
/** Looks up a submission's entity in the current catalog (public or your
* own private) by (entityType, entityId) — shared by MySubmissionsModal
@@ -18,6 +18,8 @@ export function findCatalogEntity(catalog: Catalog, entityType: CatalogEntityTyp
return catalog.deviceTemplates.find((dt) => dt.id === entityId)
case 'device_category':
return catalog.deviceCategories.find((c) => c.id === entityId)
case 'manufacturer':
return catalog.manufacturers.find((m) => m.id === entityId)
}
}
@@ -34,5 +36,7 @@ export function catalogEntityToRow(entityType: CatalogEntityType, entity: Catalo
return deviceTemplateToRow(entity as DeviceTemplate)
case 'device_category':
return deviceCategoryToRow(entity.name)
case 'manufacturer':
return manufacturerToRow(entity.name)
}
}
+2 -2
View File
@@ -26,11 +26,11 @@ const FIELD_LABELS: Record<string, string> = {
category_id: 'Category',
family: 'Family',
family2: 'Family (end 2)',
compatible_family_ids: 'Also compatible with',
compatible_family_ids: 'Can accept connections from',
max_connections: 'Max connections',
unit: 'Unit',
cost_per_unit: 'Cost per unit',
manufacturer: 'Manufacturer',
manufacturer_id: 'Manufacturer',
model: 'Model',
cost: 'Cost',
ports: 'Ports',
+50 -1
View File
@@ -1,5 +1,5 @@
import type { CatalogEntityType } from './SubmissionRepository'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, PortType } from '../domain/types'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, PortType } from '../domain/types'
/**
* Storage abstraction for the global port/cable/category/device-template
@@ -22,26 +22,75 @@ export interface CatalogRepository {
load(): Promise<Catalog>
addDeviceCategory(name: string): Promise<DeviceCategoryDef>
updateDeviceCategory(id: string, name: string): Promise<void>
/** Throws (rather than swallowing, like most methods here) if the delete
* is rejected — most commonly a foreign-key violation because one of
* your device templates still references this category, which the
* caller needs to know about to tell you why nothing happened. */
removeDeviceCategory(id: string): Promise<void>
addManufacturer(name: string): Promise<Manufacturer>
updateManufacturer(id: string, name: string): Promise<void>
/** Same throw-on-failure contract as removeDeviceCategory. */
removeManufacturer(id: string): Promise<void>
addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType>
updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void>
/** Same throw-on-failure contract as removeDeviceCategory — most commonly
* a foreign-key violation because some device template's port still
* references it. */
removePortType(id: string): Promise<void>
addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType>
updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void>
removeCableType(id: string): Promise<void>
addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate>
updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void>
removeDeviceTemplate(id: string): Promise<void>
/** Admin/Super-Admin direct-to-public creation — skips the submission/
* review workflow entirely, for authoring the shared library yourself
* (e.g. building out the initial device catalog) rather than reviewing
* someone else's proposal. Same RLS branch that already allows an Admin
* to directly edit a public entry (`is_admin() and is_public`) also
* covers inserting one — unlike adminUpdate* below, this is a genuinely
* different write, not just an alias (is_public: true, owner_id: null
* from the start). */
adminAddDeviceCategory(name: string): Promise<DeviceCategoryDef>
adminAddManufacturer(name: string): Promise<Manufacturer>
adminAddPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType>
adminAddCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType>
adminAddDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate>
/** Admin/Super-Admin direct edit of an already-public port/cable/device
* entry — same underlying write as approving a submission, minus the
* submission. */
adminUpdatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void>
adminUpdateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void>
adminUpdateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void>
adminUpdateDeviceCategory(id: string, name: string): Promise<void>
adminUpdateManufacturer(id: string, name: string): Promise<void>
/** Unpublishes a public entry (is_public -> false) rather than deleting
* it — organized-ideas.md §3's "public catalog entries are never hard-
* deleted, only hidden/unpublished". Existing diagrams that reference it
* by id are unaffected; it just stops being offered for new use. */
adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void>
/** Admin/Super-Admin permanent delete of a built-in (public) entry —
* narrower than adminUnpublish: the caller is expected to have already
* verified zero usage (no diagram references it, nothing else in the
* catalog does either) before calling this, same gate already applied to
* a regular user deleting their own private entry. Doesn't relax
* organized-ideas.md §3's "never break an existing diagram" principle —
* it only ever removes something genuinely unused (e.g. a mistake made
* while authoring the library), which is why this exists alongside
* adminUnpublish rather than instead of it. Same RLS branch as the other
* admin* methods (`is_admin()`), so these are aliases of the plain
* remove* methods above, not separate logic. Throws on failure. */
adminRemoveDeviceCategory(id: string): Promise<void>
adminRemoveManufacturer(id: string): Promise<void>
adminRemovePortType(id: string): Promise<void>
adminRemoveCableType(id: string): Promise<void>
adminRemoveDeviceTemplate(id: string): Promise<void>
}
+2 -4
View File
@@ -1,8 +1,6 @@
/** Which catalog table a submission is about. Mirrors `catalog_submissions.entity_type`'s
* check constraint — `manufacturer` is omitted since the app doesn't wire up the
* normalized manufacturer catalog yet (see organized-ideas.md §3, deferred alongside
* device_templates.manufacturer staying free text for now). */
export type CatalogEntityType = 'device_template' | 'port_type' | 'cable_type' | 'device_category'
* check constraint. */
export type CatalogEntityType = 'device_template' | 'port_type' | 'cable_type' | 'device_category' | 'manufacturer'
/**
* A user's request to change the public catalog — either promoting one of
+172 -27
View File
@@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Port, PortType } from '../domain/types'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, Port, PortType } from '../domain/types'
import { CATALOG_TABLE_BY_ENTITY_TYPE, cableTypeToRow, deviceTemplateToRow, portTypeToRow } from './catalogRowMapping'
import type { CatalogRepository } from './CatalogRepository'
import type { CatalogEntityType } from './SubmissionRepository'
@@ -11,6 +11,12 @@ interface DeviceCategoryRow {
is_public: boolean
}
interface ManufacturerRow {
id: string
name: string
is_public: boolean
}
interface PortTypeRow {
id: string
name: string
@@ -35,7 +41,7 @@ interface DeviceTemplateRow {
id: string
name: string
category_id: string
manufacturer: string | null
manufacturer_id: string | null
model: string | null
cost: number | null
is_public: boolean
@@ -48,12 +54,17 @@ interface DeviceTemplatePortRow {
direction: Port['direction']
port_type_id: string
sort_order: number
built_in_cable: boolean
}
function toDeviceCategory(row: DeviceCategoryRow): DeviceCategoryDef {
return { id: row.id, name: row.name, custom: !row.is_public }
}
function toManufacturer(row: ManufacturerRow): Manufacturer {
return { id: row.id, name: row.name, custom: !row.is_public }
}
function toPortType(row: PortTypeRow): PortType {
return {
id: row.id,
@@ -83,13 +94,13 @@ function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRo
id: row.id,
name: row.name,
category: row.category_id,
manufacturer: row.manufacturer ?? undefined,
manufacturerId: row.manufacturer_id ?? undefined,
model: row.model ?? undefined,
cost: row.cost ?? undefined,
ports: portRows
.filter((p) => p.device_template_id === row.id)
.sort((a, b) => a.sort_order - b.sort_order)
.map((p) => ({ id: p.id, name: p.name, direction: p.direction, portTypeId: p.port_type_id })),
.map((p) => ({ id: p.id, name: p.name, direction: p.direction, portTypeId: p.port_type_id, builtInCable: p.built_in_cable })),
custom: !row.is_public,
}
}
@@ -105,8 +116,9 @@ function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRo
*/
export class SupabaseCatalogRepository implements CatalogRepository {
async load(): Promise<Catalog> {
const [categories, portTypes, cableTypes, templates, templatePorts] = await Promise.all([
const [categories, manufacturers, portTypes, cableTypes, templates, templatePorts] = await Promise.all([
supabase.from('device_categories').select('id, name, is_public').order('name'),
supabase.from('manufacturers').select('id, name, is_public').order('name'),
supabase
.from('port_types')
.select('id, name, category, family, compatible_family_ids, max_connections, is_public')
@@ -115,12 +127,15 @@ export class SupabaseCatalogRepository implements CatalogRepository {
.from('cable_types')
.select('id, name, family, family2, unit, cost_per_unit, is_public')
.order('name'),
supabase.from('device_templates').select('id, name, category_id, manufacturer, model, cost, is_public').order('name'),
supabase.from('device_template_ports').select('id, device_template_id, name, direction, port_type_id, sort_order'),
supabase.from('device_templates').select('id, name, category_id, manufacturer_id, model, cost, is_public').order('name'),
supabase
.from('device_template_ports')
.select('id, device_template_id, name, direction, port_type_id, sort_order, built_in_cable'),
])
for (const [label, result] of [
['device categories', categories],
['manufacturers', manufacturers],
['port types', portTypes],
['cable types', cableTypes],
['device templates', templates],
@@ -132,6 +147,7 @@ export class SupabaseCatalogRepository implements CatalogRepository {
const portRows = (templatePorts.data ?? []) as DeviceTemplatePortRow[]
return {
deviceCategories: ((categories.data ?? []) as DeviceCategoryRow[]).map(toDeviceCategory),
manufacturers: ((manufacturers.data ?? []) as ManufacturerRow[]).map(toManufacturer),
portTypes: ((portTypes.data ?? []) as PortTypeRow[]).map(toPortType),
cableTypes: ((cableTypes.data ?? []) as CableTypeRow[]).map(toCableType),
deviceTemplates: ((templates.data ?? []) as DeviceTemplateRow[]).map((row) => toDeviceTemplate(row, portRows)),
@@ -145,24 +161,81 @@ export class SupabaseCatalogRepository implements CatalogRepository {
return user?.id ?? null
}
async addDeviceCategory(name: string): Promise<DeviceCategoryDef> {
const ownerId = await this.currentUserId()
private async insertDeviceCategory(name: string, isPublic: boolean, ownerId: string | null): Promise<DeviceCategoryDef> {
const id = uuid()
const { error } = await supabase
.from('device_categories')
.insert({ id, name, is_public: false, owner_id: ownerId })
const { error } = await supabase.from('device_categories').insert({ id, name, is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add device category to Supabase', error)
return { id, name, custom: true }
return { id, name, custom: !isPublic }
}
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
const ownerId = await this.currentUserId()
async addDeviceCategory(name: string): Promise<DeviceCategoryDef> {
return this.insertDeviceCategory(name, false, await this.currentUserId())
}
adminAddDeviceCategory(name: string): Promise<DeviceCategoryDef> {
return this.insertDeviceCategory(name, true, null)
}
async updateDeviceCategory(id: string, name: string): Promise<void> {
const { error } = await supabase.from('device_categories').update({ name }).eq('id', id)
if (error) console.error('Failed to update device category in Supabase', error)
}
async removeDeviceCategory(id: string): Promise<void> {
const { error } = await supabase.from('device_categories').delete().eq('id', id)
if (error) {
console.error('Failed to remove device category from Supabase', error)
throw error
}
}
private async insertManufacturer(name: string, isPublic: boolean, ownerId: string | null): Promise<Manufacturer> {
const id = uuid()
const { error } = await supabase.from('manufacturers').insert({ id, name, is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add manufacturer to Supabase', error)
return { id, name, custom: !isPublic }
}
async addManufacturer(name: string): Promise<Manufacturer> {
return this.insertManufacturer(name, false, await this.currentUserId())
}
adminAddManufacturer(name: string): Promise<Manufacturer> {
return this.insertManufacturer(name, true, null)
}
async updateManufacturer(id: string, name: string): Promise<void> {
const { error } = await supabase.from('manufacturers').update({ name }).eq('id', id)
if (error) console.error('Failed to update manufacturer in Supabase', error)
}
async removeManufacturer(id: string): Promise<void> {
const { error } = await supabase.from('manufacturers').delete().eq('id', id)
if (error) {
console.error('Failed to remove manufacturer from Supabase', error)
throw error
}
}
private async insertPortType(
portType: Omit<PortType, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<PortType> {
const id = uuid()
const { error } = await supabase
.from('port_types')
.insert({ id, ...portTypeToRow(portType), is_public: false, owner_id: ownerId })
.insert({ id, ...portTypeToRow(portType), is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add port type to Supabase', error)
return { ...portType, id, custom: true }
return { ...portType, id, custom: !isPublic }
}
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
return this.insertPortType(portType, false, await this.currentUserId())
}
adminAddPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
return this.insertPortType(portType, true, null)
}
async updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> {
@@ -176,14 +249,33 @@ export class SupabaseCatalogRepository implements CatalogRepository {
if (error) console.error('Failed to update port type in Supabase', error)
}
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
const ownerId = await this.currentUserId()
async removePortType(id: string): Promise<void> {
const { error } = await supabase.from('port_types').delete().eq('id', id)
if (error) {
console.error('Failed to remove port type from Supabase', error)
throw error
}
}
private async insertCableType(
cableType: Omit<CableType, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<CableType> {
const id = uuid()
const { error } = await supabase
.from('cable_types')
.insert({ id, ...cableTypeToRow(cableType), is_public: false, owner_id: ownerId })
.insert({ id, ...cableTypeToRow(cableType), is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add cable type to Supabase', error)
return { ...cableType, id, custom: true }
return { ...cableType, id, custom: !isPublic }
}
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
return this.insertCableType(cableType, false, await this.currentUserId())
}
adminAddCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
return this.insertCableType(cableType, true, null)
}
async updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> {
@@ -197,14 +289,25 @@ export class SupabaseCatalogRepository implements CatalogRepository {
if (error) console.error('Failed to update cable type in Supabase', error)
}
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
const ownerId = await this.currentUserId()
async removeCableType(id: string): Promise<void> {
const { error } = await supabase.from('cable_types').delete().eq('id', id)
if (error) {
console.error('Failed to remove cable type from Supabase', error)
throw error
}
}
private async insertDeviceTemplate(
template: Omit<DeviceTemplate, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<DeviceTemplate> {
const id = uuid()
const { ports: _ports, ...templateRow } = deviceTemplateToRow(template)
const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: false, owner_id: ownerId })
const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: isPublic, owner_id: ownerId })
if (error) {
console.error('Failed to add device template to Supabase', error)
return { ...template, id, custom: true }
return { ...template, id, custom: !isPublic }
}
const ports = template.ports.map((port, index) => ({ ...port, id: uuid(), sortOrder: index }))
if (ports.length > 0) {
@@ -216,18 +319,27 @@ export class SupabaseCatalogRepository implements CatalogRepository {
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: port.sortOrder,
built_in_cable: port.builtInCable ?? false,
})),
)
if (portsError) console.error('Failed to add device template ports to Supabase', portsError)
}
return { ...template, id, ports, custom: true }
return { ...template, id, ports, custom: !isPublic }
}
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
return this.insertDeviceTemplate(template, false, await this.currentUserId())
}
adminAddDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
return this.insertDeviceTemplate(template, true, null)
}
async updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> {
const payload: Record<string, unknown> = {}
if (patch.name !== undefined) payload.name = patch.name
if (patch.category !== undefined) payload.category_id = patch.category
if (patch.manufacturer !== undefined) payload.manufacturer = patch.manufacturer
if (patch.manufacturerId !== undefined) payload.manufacturer_id = patch.manufacturerId
if (patch.model !== undefined) payload.model = patch.model
if (patch.cost !== undefined) payload.cost = patch.cost
if (Object.keys(payload).length > 0) {
@@ -250,6 +362,7 @@ export class SupabaseCatalogRepository implements CatalogRepository {
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: index,
built_in_cable: port.builtInCable ?? false,
})),
)
if (insertError) console.error('Failed to replace device template ports in Supabase', insertError)
@@ -280,9 +393,41 @@ export class SupabaseCatalogRepository implements CatalogRepository {
return this.updateDeviceTemplate(id, patch)
}
adminUpdateDeviceCategory(id: string, name: string): Promise<void> {
return this.updateDeviceCategory(id, name)
}
adminUpdateManufacturer(id: string, name: string): Promise<void> {
return this.updateManufacturer(id, name)
}
async adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void> {
const table = CATALOG_TABLE_BY_ENTITY_TYPE[entityType]
const { error } = await supabase.from(table).update({ is_public: false }).eq('id', id)
if (error) console.error('Failed to unpublish catalog entry in Supabase', error)
}
adminRemoveDeviceCategory(id: string): Promise<void> {
return this.removeDeviceCategory(id)
}
adminRemoveManufacturer(id: string): Promise<void> {
return this.removeManufacturer(id)
}
adminRemovePortType(id: string): Promise<void> {
return this.removePortType(id)
}
adminRemoveCableType(id: string): Promise<void> {
return this.removeCableType(id)
}
async adminRemoveDeviceTemplate(id: string): Promise<void> {
const { error } = await supabase.from('device_templates').delete().eq('id', id)
if (error) {
console.error('Failed to remove device template from Supabase', error)
throw error
}
}
}
+16 -3
View File
@@ -19,6 +19,7 @@ export const CATALOG_TABLE_BY_ENTITY_TYPE: Record<CatalogEntityType, string> = {
port_type: 'port_types',
cable_type: 'cable_types',
device_category: 'device_categories',
manufacturer: 'manufacturers',
}
export function portTypeToRow(portType: Omit<PortType, 'id' | 'custom'>): Record<string, unknown> {
@@ -45,11 +46,15 @@ export function deviceCategoryToRow(name: string): Record<string, unknown> {
return { name }
}
export function manufacturerToRow(name: string): Record<string, unknown> {
return { name }
}
export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custom'>): Record<string, unknown> {
return {
name: template.name,
category_id: template.category,
manufacturer: template.manufacturer ?? null,
manufacturer_id: template.manufacturerId ?? null,
model: template.model ?? null,
cost: template.cost ?? null,
ports: template.ports.map((port, index) => ({
@@ -57,6 +62,7 @@ export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custo
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: index,
built_in_cable: port.builtInCable ?? false,
})),
}
}
@@ -92,11 +98,17 @@ export function rowToCableTypeFields(row: Record<string, unknown>): Omit<CableTy
}
export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<DeviceTemplate, 'id' | 'custom'> {
const ports = (row.ports as Array<{ name: string; direction: DeviceTemplate['ports'][number]['direction']; port_type_id: string }>) ?? []
const ports =
(row.ports as Array<{
name: string
direction: DeviceTemplate['ports'][number]['direction']
port_type_id: string
built_in_cable?: boolean
}>) ?? []
return {
name: row.name as string,
category: row.category_id as string,
manufacturer: (row.manufacturer as string | null) ?? undefined,
manufacturerId: (row.manufacturer_id as string | null) ?? undefined,
model: (row.model as string | null) ?? undefined,
cost: (row.cost as number | null) ?? undefined,
ports: ports.map((port, index) => ({
@@ -104,6 +116,7 @@ export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<De
name: port.name,
direction: port.direction,
portTypeId: port.port_type_id,
builtInCable: port.built_in_cable ?? false,
})),
}
}
+24 -3
View File
@@ -1,4 +1,4 @@
import { allCableTypes } from './diagram'
import { allCableTypes, manufacturerName } from './diagram'
import type { CableType, Catalog, Connection, Device, Diagram } from './types'
/** One length within a cable type's group — e.g. "3 × 10ft". `distance`
@@ -59,7 +59,7 @@ export interface Bom {
}
function deviceGroupKey(device: Device): string {
return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}`
return device.templateId ?? `${device.category}:${device.manufacturerId ?? ''}:${device.model ?? device.name}`
}
/** A connection's cost for BOM purposes: its own override if set, else
@@ -82,8 +82,25 @@ function connectionCost(connection: Connection, cableType: CableType | undefined
return undefined
}
/** Every port id, across all devices, with a built-in/captive cable — a
* connection touching either end of one doesn't need a cable purchased
* (organized-ideas.md-adjacent decision: the cable's already paid for as
* part of the device, like a keyboard's USB cable or a budget AVR's power
* cord). Built once per computeBom call rather than searched per
* connection. */
function builtInPortIds(devices: Device[]): Set<string> {
const ids = new Set<string>()
for (const device of devices) {
for (const port of device.ports) {
if (port.builtInCable) ids.add(port.id)
}
}
return ids
}
export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
const builtIn = builtInPortIds(diagram.devices)
const cableLines = new Map<string, BomCableLine>()
// Nested by cableTypeId, then by distance (undefined key for "no length
@@ -93,6 +110,10 @@ export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
const lengthLines = new Map<string, Map<number | undefined, BomCableLengthLine>>()
for (const connection of diagram.connections) {
// A built-in cable is already paid for as part of the device — no line
// for it here, not even a "missing cost" note; the canvas is where this
// connection's built-in-ness is visible, not the shopping list.
if (builtIn.has(connection.sourcePortId) || builtIn.has(connection.targetPortId)) continue
const cableType = cableTypes.get(connection.cableTypeId)
const name = cableType?.name ?? 'Unknown Cable'
const unit = cableType?.unit ?? 'ft'
@@ -161,7 +182,7 @@ export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
key,
name: device.name,
category: device.category,
manufacturer: device.manufacturer,
manufacturer: manufacturerName(catalog, device.manufacturerId),
model: device.model,
count: 0,
totalCost: 0,
+26 -2
View File
@@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid'
import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, PortType } from './types'
import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, Manufacturer, PortType } from './types'
export function allPortTypes(catalog: Catalog): PortType[] {
return catalog.portTypes
@@ -35,6 +35,22 @@ export function deviceCategoryName(catalog: Catalog, categoryId: string): string
return getDeviceCategory(catalog, categoryId)?.name ?? categoryId
}
export function allManufacturers(catalog: Catalog): Manufacturer[] {
return catalog.manufacturers
}
export function getManufacturer(catalog: Catalog, manufacturerId: string): Manufacturer | undefined {
return allManufacturers(catalog).find((m) => m.id === manufacturerId)
}
/** Friendly name for a manufacturer id — unlike `deviceCategoryName`,
* `manufacturerId` is optional (not every device has a known manufacturer),
* so this returns undefined rather than a fallback string when it's unset
* or, for older data, references a manufacturer that no longer exists. */
export function manufacturerName(catalog: Catalog, manufacturerId: string | undefined): string | undefined {
return manufacturerId ? getManufacturer(catalog, manufacturerId)?.name : undefined
}
export function getPortType(catalog: Catalog, portTypeId: string): PortType | undefined {
return allPortTypes(catalog).find((pt) => pt.id === portTypeId)
}
@@ -43,6 +59,14 @@ export function getCableType(catalog: Catalog, cableTypeId: string): CableType |
return allCableTypes(catalog).find((ct) => ct.id === cableTypeId)
}
/** Best-effort friendly label for a raw family string: the name of whichever
* port type owns that family, or the raw string itself if none matches
* (e.g. a family that only ever existed as hand-typed text, from before
* FamilySelect started sourcing new families from an existing port type). */
export function familyLabel(portTypes: PortType[], family: string): string {
return portTypes.find((pt) => pt.family === family)?.name ?? family
}
/** The set of connector families a port type will mate with: its own family
* plus anything it explicitly lists as also-compatible. Shared with
* `compatibility.ts` so "does A connect to B" and "which cable fits" agree. */
@@ -145,7 +169,7 @@ export function createDeviceFromTemplate(template: DeviceTemplate, position: { x
name: template.name,
templateId: template.id,
category: template.category,
manufacturer: template.manufacturer,
manufacturerId: template.manufacturerId,
model: template.model,
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
cost: template.cost,
+25 -2
View File
@@ -28,6 +28,16 @@ export interface DeviceCategoryDef {
custom?: boolean
}
/** A device manufacturer/brand, normalized the same way as DeviceCategoryDef
* (organized-ideas.md §3) rather than free text on each device — gives the
* Admin review queue's duplicate-detection nudge a reliable signal
* (matching manufacturer + similar model) that name-matching alone can't. */
export interface Manufacturer {
id: string
name: string
custom?: boolean
}
/**
* A kind of physical connector (HDMI, XLR, Cat6/RJ45, ...). Compatibility
* between two ports is decided by comparing `family` (plus optional
@@ -54,6 +64,14 @@ export interface Port {
name: string
direction: PortDirection
portTypeId: string
/** This port already has a captive/permanently-attached cable (a
* keyboard's USB cable, a budget AV receiver's power cord, ...) — a
* property of the physical port, not of any one wiring choice, so it
* lives here rather than on Connection. Any connection touching a port
* with this set doesn't need a separate purchased cable: the BOM omits
* it entirely, though the canvas still draws the link (styled
* differently) since the wiring topology is still real. */
builtInCable?: boolean
}
/** A reusable, non-instantiated device definition (shown in the palette). */
@@ -61,7 +79,9 @@ export interface DeviceTemplate {
id: string
name: string
category: DeviceCategory
manufacturer?: string
/** References a Manufacturer catalog entry — optional since not every
* device has a known/chosen manufacturer (e.g. DIY or generic gear). */
manufacturerId?: string
model?: string
ports: Port[]
/** Suggested default cost, copied onto each placed Device at creation time
@@ -82,7 +102,9 @@ export interface Device {
name: string
templateId?: string
category: DeviceCategory
manufacturer?: string
/** References a Manufacturer catalog entry, same as the template's —
* independently editable per instance afterward, like category/model. */
manufacturerId?: string
model?: string
notes?: string
ports: Port[]
@@ -156,5 +178,6 @@ export interface Catalog {
portTypes: PortType[]
cableTypes: CableType[]
deviceCategories: DeviceCategoryDef[]
manufacturers: Manufacturer[]
deviceTemplates: DeviceTemplate[]
}
+153 -2
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types'
import type { CatalogRepository } from '../data/CatalogRepository'
import { cableTypeToRow, deviceTemplateToRow, portTypeToRow } from '../data/catalogRowMapping'
import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, manufacturerToRow, portTypeToRow } from '../data/catalogRowMapping'
import { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository'
import type { CatalogEntityType } from '../data/SubmissionRepository'
import { useSubmissionStore } from './submissionStore'
@@ -29,7 +29,7 @@ function saveHiddenPublicIds(ids: Set<string>): void {
}
}
const emptyCatalog: Catalog = { portTypes: [], cableTypes: [], deviceCategories: [], deviceTemplates: [] }
const emptyCatalog: Catalog = { portTypes: [], cableTypes: [], deviceCategories: [], manufacturers: [], deviceTemplates: [] }
interface CatalogStoreState {
catalog: Catalog
@@ -39,12 +39,23 @@ interface CatalogStoreState {
loadCatalog: () => Promise<void>
addCustomDeviceCategory: (name: string) => Promise<string>
updateCustomDeviceCategory: (id: string, name: string) => Promise<void>
removeCustomDeviceCategory: (id: string) => Promise<void>
addCustomManufacturer: (name: string) => Promise<string>
updateCustomManufacturer: (id: string, name: string) => Promise<void>
removeCustomManufacturer: (id: string) => Promise<void>
addCustomPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void>
updateCustomPortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void>
/** Throws (like removeCustomDeviceCategory) if the delete is rejected —
* most commonly a foreign-key violation because a device template's port
* still references it. */
removeCustomPortType: (id: string) => Promise<void>
addCustomCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void>
updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
removeCustomCableType: (id: string) => Promise<void>
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void>
updateCustomDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void>
@@ -53,15 +64,35 @@ interface CatalogStoreState {
hidePublicDeviceTemplate: (id: string) => void
restorePublicDeviceTemplate: (id: string) => void
/** Admin/Super-Admin direct-to-public creation — skips the submission/
* review workflow, for authoring the shared library yourself (see
* CatalogRepository.adminAddDeviceCategory's own comment). */
adminAddDeviceCategory: (name: string) => Promise<string>
adminAddManufacturer: (name: string) => Promise<string>
adminAddPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void>
adminAddCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void>
adminAddDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void>
/** Admin/Super-Admin direct edit of an already-public entry, per
* organized-ideas.md §6 — bypasses the submission/review workflow
* entirely (RLS's is_admin() clause is what actually permits it). */
adminUpdatePortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void>
adminUpdateCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
adminUpdateDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void>
adminUpdateDeviceCategory: (id: string, name: string) => Promise<void>
adminUpdateManufacturer: (id: string, name: string) => Promise<void>
/** Unpublishes (is_public -> false) rather than deletes — see
* CatalogRepository.adminUnpublish. */
adminUnpublish: (entityType: CatalogEntityType, id: string) => Promise<void>
/** Permanent delete of a built-in entry — see
* CatalogRepository.adminRemoveDeviceCategory's own comment for the
* zero-usage contract the caller is expected to have already checked. */
adminRemoveDeviceCategory: (id: string) => Promise<void>
adminRemoveManufacturer: (id: string) => Promise<void>
adminRemovePortType: (id: string) => Promise<void>
adminRemoveCableType: (id: string) => Promise<void>
adminRemoveDeviceTemplate: (id: string) => Promise<void>
}
export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
@@ -80,11 +111,70 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
return category.id
},
updateCustomDeviceCategory: async (id, name) => {
await repository.updateDeviceCategory(id, name)
set((state) => ({
catalog: {
...state.catalog,
deviceCategories: state.catalog.deviceCategories.map((c) => (c.id === id ? { ...c, name } : c)),
},
}))
useSubmissionStore.getState().syncProposedData('device_category', id, deviceCategoryToRow(name))
},
removeCustomDeviceCategory: async (id) => {
await repository.removeDeviceCategory(id)
set((state) => ({
catalog: { ...state.catalog, deviceCategories: state.catalog.deviceCategories.filter((c) => c.id !== id) },
}))
},
adminAddDeviceCategory: async (name) => {
const category = await repository.adminAddDeviceCategory(name)
set((state) => ({ catalog: { ...state.catalog, deviceCategories: [...state.catalog.deviceCategories, category] } }))
return category.id
},
addCustomManufacturer: async (name) => {
const manufacturer = await repository.addManufacturer(name)
set((state) => ({ catalog: { ...state.catalog, manufacturers: [...state.catalog.manufacturers, manufacturer] } }))
return manufacturer.id
},
updateCustomManufacturer: async (id, name) => {
await repository.updateManufacturer(id, name)
set((state) => ({
catalog: {
...state.catalog,
manufacturers: state.catalog.manufacturers.map((m) => (m.id === id ? { ...m, name } : m)),
},
}))
useSubmissionStore.getState().syncProposedData('manufacturer', id, manufacturerToRow(name))
},
removeCustomManufacturer: async (id) => {
await repository.removeManufacturer(id)
set((state) => ({
catalog: { ...state.catalog, manufacturers: state.catalog.manufacturers.filter((m) => m.id !== id) },
}))
},
adminAddManufacturer: async (name) => {
const manufacturer = await repository.adminAddManufacturer(name)
set((state) => ({ catalog: { ...state.catalog, manufacturers: [...state.catalog.manufacturers, manufacturer] } }))
return manufacturer.id
},
addCustomPortType: async (portType) => {
const created = await repository.addPortType(portType)
set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } }))
},
adminAddPortType: async (portType) => {
const created = await repository.adminAddPortType(portType)
set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } }))
},
updateCustomPortType: async (id, patch) => {
await repository.updatePortType(id, patch)
set((state) => ({
@@ -97,11 +187,21 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
if (updated) useSubmissionStore.getState().syncProposedData('port_type', id, portTypeToRow(updated))
},
removeCustomPortType: async (id) => {
await repository.removePortType(id)
set((state) => ({ catalog: { ...state.catalog, portTypes: state.catalog.portTypes.filter((pt) => pt.id !== id) } }))
},
addCustomCableType: async (cableType) => {
const created = await repository.addCableType(cableType)
set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } }))
},
adminAddCableType: async (cableType) => {
const created = await repository.adminAddCableType(cableType)
set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } }))
},
updateCustomCableType: async (id, patch) => {
await repository.updateCableType(id, patch)
set((state) => ({
@@ -114,11 +214,21 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
if (updated) useSubmissionStore.getState().syncProposedData('cable_type', id, cableTypeToRow(updated))
},
removeCustomCableType: async (id) => {
await repository.removeCableType(id)
set((state) => ({ catalog: { ...state.catalog, cableTypes: state.catalog.cableTypes.filter((ct) => ct.id !== id) } }))
},
addCustomDeviceTemplate: async (template) => {
const created = await repository.addDeviceTemplate(template)
set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } }))
},
adminAddDeviceTemplate: async (template) => {
const created = await repository.adminAddDeviceTemplate(template)
set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } }))
},
updateCustomDeviceTemplate: async (id, patch) => {
await repository.updateDeviceTemplate(id, patch)
set((state) => ({
@@ -174,8 +284,49 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
await get().loadCatalog()
},
adminUpdateDeviceCategory: async (id, name) => {
await repository.adminUpdateDeviceCategory(id, name)
await get().loadCatalog()
},
adminUpdateManufacturer: async (id, name) => {
await repository.adminUpdateManufacturer(id, name)
await get().loadCatalog()
},
adminUnpublish: async (entityType, id) => {
await repository.adminUnpublish(entityType, id)
await get().loadCatalog()
},
adminRemoveDeviceCategory: async (id) => {
await repository.adminRemoveDeviceCategory(id)
set((state) => ({
catalog: { ...state.catalog, deviceCategories: state.catalog.deviceCategories.filter((c) => c.id !== id) },
}))
},
adminRemoveManufacturer: async (id) => {
await repository.adminRemoveManufacturer(id)
set((state) => ({
catalog: { ...state.catalog, manufacturers: state.catalog.manufacturers.filter((m) => m.id !== id) },
}))
},
adminRemovePortType: async (id) => {
await repository.adminRemovePortType(id)
set((state) => ({ catalog: { ...state.catalog, portTypes: state.catalog.portTypes.filter((pt) => pt.id !== id) } }))
},
adminRemoveCableType: async (id) => {
await repository.adminRemoveCableType(id)
set((state) => ({ catalog: { ...state.catalog, cableTypes: state.catalog.cableTypes.filter((ct) => ct.id !== id) } }))
},
adminRemoveDeviceTemplate: async (id) => {
await repository.adminRemoveDeviceTemplate(id)
set((state) => ({
catalog: { ...state.catalog, deviceTemplates: state.catalog.deviceTemplates.filter((t) => t.id !== id) },
}))
},
}))
@@ -0,0 +1,30 @@
-- Wires up the normalized `manufacturers` catalog (organized-ideas.md §3:
-- "manufacturer becomes a normalized, submittable catalog — the same
-- public/user-defined pattern already used for device categories").
--
-- The table itself, its RLS policies, and device_templates.manufacturer_id
-- have all existed unused since the initial schema / the manufacturer_text
-- migration — this is the pass that actually populates and reads them.
-- Scope matches what device_category has today: create-your-own-private-one
-- only, no submission/admin-edit UI yet (that's a separate, already-tracked
-- follow-up for both entities together).
--
-- Any existing free-text manufacturer values (there's realistically at most
-- a handful of dev-only rows at this point, nothing shipped) get backfilled
-- into a manufacturers row each, grouped by (name, owner, is_public) so two
-- different users' private "Fake" manufacturers don't collide into one row.
insert into public.manufacturers (name, is_public, owner_id)
select distinct dt.manufacturer, dt.is_public, dt.owner_id
from public.device_templates dt
where dt.manufacturer is not null;
update public.device_templates dt
set manufacturer_id = m.id
from public.manufacturers m
where dt.manufacturer is not null
and m.name = dt.manufacturer
and m.is_public = dt.is_public
and m.owner_id is not distinct from dt.owner_id;
alter table public.device_templates drop column manufacturer;
@@ -0,0 +1,61 @@
-- Rounds device_category and manufacturer out to full parity with the
-- other catalog entity types: submission/review workflow (already halfway
-- there — catalog_submissions.entity_type has allowed both since the
-- initial schema, and the app-side plumbing for device_category already
-- exists in the review queue and My Submissions), plus direct rename of
-- your own private entry / an already-public one by an Admin.
--
-- No new tables or RLS policies — device_categories/manufacturers already
-- have full is_public/owner_id CRUD policies from the initial schema
-- (device_categories is explicitly the "representative case" the RLS test
-- suite already covers for this shape), just never exercised by app code
-- for anything beyond insert. This migration only extends the one function
-- that didn't already know about manufacturers.
create or replace function public.catalog_entity_usage_impact(p_entity_type text, p_entity_id text)
returns table(diagram_count integer, sample jsonb)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.is_admin() then
raise exception 'insufficient_privilege' using errcode = '42501';
end if;
return query
select count(*)::int, coalesce(jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name, 'ownerUsername', s.owner_username) order by s.rn) filter (where s.rn <= 5), '[]'::jsonb)
from (
select d.id, d.name, p.username as owner_username,
row_number() over (order by d.updated_at desc) as rn
from public.diagrams d
join public.profiles p on p.id = d.owner_id
where case p_entity_type
when 'device_template' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'templateId' = p_entity_id
)
when 'device_category' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'category' = p_entity_id
)
when 'manufacturer' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'manufacturerId' = p_entity_id
)
when 'port_type' then exists (
select 1
from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev,
jsonb_array_elements(coalesce(dev -> 'ports', '[]'::jsonb)) port
where port ->> 'portTypeId' = p_entity_id
)
when 'cable_type' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'connections', '[]'::jsonb)) conn
where conn ->> 'cableTypeId' = p_entity_id
)
else false
end
) s;
end;
$$;
@@ -0,0 +1,76 @@
-- Lets a regular user check the usage-impact of their OWN still-private
-- device category or manufacturer, not just Admins — needed so the app can
-- warn "used in N diagrams, can't delete" before a destructive delete of
-- your own catalog entry, the same way it already warns an Admin before an
-- unpublish. Scoped to just these two entity types and only entities you
-- privately own (is_public = false, owner_id = you): a still-private
-- category/manufacturer could only ever have been referenced by things you
-- yourself created (nobody else's picker would ever have offered it), so
-- this can't be used to snoop on another user's diagrams the way opening
-- this up generally would.
create or replace function public.catalog_entity_usage_impact(p_entity_type text, p_entity_id text)
returns table(diagram_count integer, sample jsonb)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not (
public.is_admin()
or (
p_entity_type = 'device_category'
and exists (
select 1 from public.device_categories
where id = p_entity_id and owner_id = auth.uid() and not is_public
)
)
or (
p_entity_type = 'manufacturer'
and exists (
-- manufacturers.id is still uuid (never migrated to text like the
-- other catalog tables — see 20260908033553_catalog_text_ids.sql's
-- own comment on why), hence the cast.
select 1 from public.manufacturers
where id::text = p_entity_id and owner_id = auth.uid() and not is_public
)
)
) then
raise exception 'insufficient_privilege' using errcode = '42501';
end if;
return query
select count(*)::int, coalesce(jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name, 'ownerUsername', s.owner_username) order by s.rn) filter (where s.rn <= 5), '[]'::jsonb)
from (
select d.id, d.name, p.username as owner_username,
row_number() over (order by d.updated_at desc) as rn
from public.diagrams d
join public.profiles p on p.id = d.owner_id
where case p_entity_type
when 'device_template' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'templateId' = p_entity_id
)
when 'device_category' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'category' = p_entity_id
)
when 'manufacturer' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'manufacturerId' = p_entity_id
)
when 'port_type' then exists (
select 1
from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev,
jsonb_array_elements(coalesce(dev -> 'ports', '[]'::jsonb)) port
where port ->> 'portTypeId' = p_entity_id
)
when 'cable_type' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'connections', '[]'::jsonb)) conn
where conn ->> 'cableTypeId' = p_entity_id
)
else false
end
) s;
end;
$$;
@@ -0,0 +1,9 @@
-- Devices with a captive/permanently-attached cable (a keyboard's USB
-- cable, a budget AV receiver's power cord, some passive speakers' fixed
-- speaker wire, ...) — a property of the physical port, not of any one
-- wiring choice, so it lives on device_template_ports rather than on
-- connections (which aren't even a relational table — see diagrams' JSONB
-- `data` for those; nothing to migrate there, the domain type change alone
-- covers newly-placed devices, and this column is what the palette/library
-- side needs).
alter table public.device_template_ports add column built_in_cable boolean not null default false;