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>
)
}