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:
@@ -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('')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user