Adds first version of the app. Local storage only.

This commit is contained in:
2026-09-01 14:44:49 -05:00
commit 9710f1ee5a
44 changed files with 5865 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
import AppShell from './components/layout/AppShell'
export default function App() {
return <AppShell />
}
+70
View File
@@ -0,0 +1,70 @@
import { useMemo } from 'react'
import { computeBom } from '../../domain/bom'
import { useProjectStore } from '../../state/projectStore'
export default function BomPanel() {
const project = useProjectStore((s) => s.project)
const bom = useMemo(() => computeBom(project), [project])
return (
<div className="space-y-4 p-3">
<section>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h4>
{bom.devices.length === 0 ? (
<p className="text-xs italic text-slate-400">No devices on the canvas yet.</p>
) : (
<table className="w-full text-xs">
<tbody>
{bom.devices.map((line) => (
<tr key={line.key} className="border-b border-slate-100 last:border-0">
<td className="py-1 pr-2">
<div className="font-medium text-slate-700">{line.name}</div>
{(line.manufacturer || line.model) && (
<div className="text-[10px] text-slate-400">
{[line.manufacturer, line.model].filter(Boolean).join(' ')}
</div>
)}
</td>
<td className="py-1 text-right font-medium text-slate-600">×{line.count}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Cables</h4>
{bom.cables.length === 0 ? (
<p className="text-xs italic text-slate-400">No cables wired up yet.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wide text-slate-400">
<th className="pb-1 font-medium">Cable</th>
<th className="pb-1 text-right font-medium">Count</th>
<th className="pb-1 text-right font-medium">Total length</th>
</tr>
</thead>
<tbody>
{bom.cables.map((line) => (
<tr key={line.cableTypeId} className="border-b border-slate-100 last:border-0">
<td className="py-1 pr-2 font-medium text-slate-700">{line.cableTypeName}</td>
<td className="py-1 text-right text-slate-600">×{line.count}</td>
<td className="py-1 text-right text-slate-600">
{line.totalLength > 0 ? `${line.totalLength}${line.unit}` : '—'}
{line.connectionsMissingLength > 0 && (
<span className="ml-1 text-[10px] text-amber-500" title="Some cables are missing a distance">
({line.connectionsMissingLength} unset)
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { BaseEdge, EdgeLabelRenderer, getBezierPath, type Edge, type EdgeProps } from '@xyflow/react'
export type CableEdgeData = {
cableTypeName: string
distance?: number
unit: string
}
export type CableFlowEdge = Edge<CableEdgeData, 'cable'>
export default function CableEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
markerEnd,
}: EdgeProps<CableFlowEdge>) {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
const label = data
? `${data.cableTypeName}${typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : ''}`
: ''
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{ stroke: selected ? '#6366f1' : '#94a3b8', strokeWidth: selected ? 2.5 : 1.5 }}
/>
{label && (
<EdgeLabelRenderer>
<div
className="nodrag nopan absolute rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] text-slate-600 shadow-sm"
style={{ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)` }}
>
{label}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
import type { Device, Port, PortType } from '../../domain/types'
import { buildHandleId } from './handleIds'
export interface PortInfo {
port: Port
portType: PortType | undefined
connectionCount: number
isFull: boolean
}
export type DeviceNodeData = {
device: Device
/** Resolved from the device's category id — see FlowCanvas — since a
* custom category's id is an opaque uuid, not something to show raw. */
categoryName: string
portInfos: PortInfo[]
}
export type DeviceFlowNode = Node<DeviceNodeData, 'device'>
function PortHandle({ portInfo, position }: { portInfo: PortInfo; position: Position }) {
const { port, isFull } = portInfo
const edgeStyle =
position === Position.Left
? { left: -13 }
: position === Position.Right
? { right: -13 }
: { bottom: -13 }
return (
<Handle
type="source"
position={position}
id={buildHandleId(port.id)}
isConnectableStart={!isFull}
isConnectableEnd={!isFull}
style={{
position: 'relative',
transform: 'none',
...edgeStyle,
width: 10,
height: 10,
background: isFull ? '#cbd5e1' : '#6366f1',
}}
/>
)
}
function CountBadge({ count }: { count: number }) {
if (count === 0) return null
return <span className="shrink-0 rounded bg-slate-100 px-1 text-[10px] text-slate-500">{count}</span>
}
export default function DeviceNode({ data, selected }: NodeProps<DeviceFlowNode>) {
const { device, categoryName, portInfos } = data
const inputs = portInfos.filter((p) => p.port.direction === 'input')
const outputs = portInfos.filter((p) => p.port.direction === 'output')
const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional')
return (
<div
className={`w-72 rounded-lg border bg-white shadow-sm transition-shadow ${
selected ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-slate-300'
}`}
>
<div className="rounded-t-lg border-b border-slate-200 bg-slate-50 px-3 py-2">
<div className="truncate text-sm font-semibold text-slate-800">{device.name}</div>
<div className="truncate text-[11px] text-slate-500">
{[device.manufacturer, device.model].filter(Boolean).join(' ') || categoryName}
</div>
</div>
<div className="grid grid-cols-2 divide-x divide-slate-100">
<div className="divide-y divide-slate-100">
{inputs.length === 0 ? (
<div className="px-2 py-2 text-[10px] italic text-slate-300">No inputs</div>
) : (
inputs.map((portInfo) => (
<div key={portInfo.port.id} className="flex items-center gap-1.5 px-2 py-1.5 text-xs">
<PortHandle portInfo={portInfo} position={Position.Left} />
<div className="min-w-0 flex-1">
<div className="truncate text-slate-700">{portInfo.port.name}</div>
<div className="truncate text-[10px] text-slate-400">{portInfo.portType?.name ?? 'Unknown type'}</div>
</div>
<CountBadge count={portInfo.connectionCount} />
</div>
))
)}
</div>
<div className="divide-y divide-slate-100">
{outputs.length === 0 ? (
<div className="px-2 py-2 text-right text-[10px] italic text-slate-300">No outputs</div>
) : (
outputs.map((portInfo) => (
<div key={portInfo.port.id} className="flex items-center gap-1.5 px-2 py-1.5 text-xs">
<CountBadge count={portInfo.connectionCount} />
<div className="min-w-0 flex-1 text-right">
<div className="truncate text-slate-700">{portInfo.port.name}</div>
<div className="truncate text-[10px] text-slate-400">{portInfo.portType?.name ?? 'Unknown type'}</div>
</div>
<PortHandle portInfo={portInfo} position={Position.Right} />
</div>
))
)}
</div>
</div>
{bidirectional.length > 0 && (
<div className="flex flex-wrap justify-center gap-3 border-t border-slate-100 px-2 pb-3 pt-2">
{bidirectional.map((portInfo) => (
<div key={portInfo.port.id} className="flex flex-col items-center gap-1 text-center">
<div className="max-w-[80px] truncate text-[10px] text-slate-700" title={portInfo.port.name}>
{portInfo.port.name}
</div>
<CountBadge count={portInfo.connectionCount} />
<PortHandle portInfo={portInfo} position={Position.Bottom} />
</div>
))}
</div>
)}
</div>
)
}
+203
View File
@@ -0,0 +1,203 @@
import {
Background,
ConnectionMode,
Controls,
MiniMap,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Connection,
type EdgeChange,
type EdgeTypes,
type NodeChange,
type NodeTypes,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useCallback, useMemo } from 'react'
import { validateConnection } from '../../domain/compatibility'
import { allCableTypes, allPortTypes, cableTypesForConnection, deviceCategoryName, getPortType } from '../../domain/project'
import { useProjectStore } from '../../state/projectStore'
import CableEdge, { type CableFlowEdge } from './CableEdge'
import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode'
import { buildHandleId, parsePortId } from './handleIds'
export const TEMPLATE_DRAG_MIME = 'application/x-av-planner-template'
const nodeTypes: NodeTypes = { device: DeviceNode }
const edgeTypes: EdgeTypes = { cable: CableEdge }
function FlowCanvasInner() {
const project = useProjectStore((s) => s.project)
const selectedDeviceId = useProjectStore((s) => s.selectedDeviceId)
const selectedConnectionId = useProjectStore((s) => s.selectedConnectionId)
const updateDevicePosition = useProjectStore((s) => s.updateDevicePosition)
const removeDevice = useProjectStore((s) => s.removeDevice)
const removeConnection = useProjectStore((s) => s.removeConnection)
const addConnection = useProjectStore((s) => s.addConnection)
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
const selectDevice = useProjectStore((s) => s.selectDevice)
const selectConnection = useProjectStore((s) => s.selectConnection)
const { screenToFlowPosition } = useReactFlow()
const portTypes = useMemo(() => allPortTypes(project), [project])
const cableTypes = useMemo(() => allCableTypes(project), [project])
const nodes: DeviceFlowNode[] = useMemo(
() =>
project.devices.map((device) => {
const portInfos: PortInfo[] = device.ports.map((port) => {
const portType = portTypes.find((pt) => pt.id === port.portTypeId)
const connectionCount = project.connections.filter(
(c) => c.sourcePortId === port.id || c.targetPortId === port.id,
).length
const max = portType?.maxConnections ?? 1
return { port, portType, connectionCount, isFull: connectionCount >= max }
})
return {
id: device.id,
type: 'device',
position: device.position,
selected: device.id === selectedDeviceId,
data: { device, categoryName: deviceCategoryName(project, device.category), portInfos },
}
}),
[project, portTypes, selectedDeviceId],
)
const edges: CableFlowEdge[] = useMemo(
() =>
project.connections.map((connection) => {
const cableType = cableTypes.find((ct) => ct.id === connection.cableTypeId)
return {
id: connection.id,
type: 'cable',
source: connection.sourceDeviceId,
sourceHandle: buildHandleId(connection.sourcePortId),
target: connection.targetDeviceId,
targetHandle: buildHandleId(connection.targetPortId),
selected: connection.id === selectedConnectionId,
data: {
cableTypeName: cableType?.name ?? 'Unknown Cable',
distance: connection.distance,
unit: cableType?.unit ?? 'ft',
},
}
}),
[project.connections, cableTypes, selectedConnectionId],
)
const isValidConnection = useCallback(
(edgeOrConnection: Connection | { source: string | null; sourceHandle?: string | null; target: string | null; targetHandle?: string | null }) => {
const c = edgeOrConnection as Connection
const sourcePortId = parsePortId(c.sourceHandle)
const targetPortId = parsePortId(c.targetHandle)
if (!c.source || !c.target || !sourcePortId || !targetPortId) return false
return validateConnection(project, {
sourceDeviceId: c.source,
sourcePortId,
targetDeviceId: c.target,
targetPortId,
}).valid
},
[project],
)
const onConnect = useCallback(
(connection: Connection) => {
const sourcePortId = parsePortId(connection.sourceHandle)
const targetPortId = parsePortId(connection.targetHandle)
if (!connection.source || !connection.target || !sourcePortId || !targetPortId) return
const sourceDevice = project.devices.find((d) => d.id === connection.source)
const targetDevice = project.devices.find((d) => d.id === connection.target)
const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId)
const sourcePortType = sourcePort && getPortType(project, sourcePort.portTypeId)
const targetPortType = targetPort && getPortType(project, targetPort.portTypeId)
const defaultCableType =
sourcePortType && targetPortType && cableTypesForConnection(project, sourcePortType, targetPortType)[0]
if (!defaultCableType) return
addConnection({
sourceDeviceId: connection.source,
sourcePortId,
targetDeviceId: connection.target,
targetPortId,
cableTypeId: defaultCableType.id,
})
},
[project, addConnection],
)
const onNodesChange = useCallback(
(changes: NodeChange<DeviceFlowNode>[]) => {
for (const change of changes) {
if (change.type === 'position' && change.position) {
updateDevicePosition(change.id, change.position)
} else if (change.type === 'remove') {
removeDevice(change.id)
}
}
},
[updateDevicePosition, removeDevice],
)
const onEdgesChange = useCallback(
(changes: EdgeChange<CableFlowEdge>[]) => {
for (const change of changes) {
if (change.type === 'remove') removeConnection(change.id)
}
},
[removeConnection],
)
const onDragOver = useCallback((event: React.DragEvent) => {
if (!event.dataTransfer.types.includes(TEMPLATE_DRAG_MIME)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}, [])
const onDrop = useCallback(
(event: React.DragEvent) => {
const templateId = event.dataTransfer.getData(TEMPLATE_DRAG_MIME)
if (!templateId) return
event.preventDefault()
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
addDeviceFromTemplate(templateId, position)
},
[screenToFlowPosition, addDeviceFromTemplate],
)
return (
<div className="h-full w-full" onDrop={onDrop} onDragOver={onDragOver}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
connectionMode={ConnectionMode.Loose}
onNodeClick={(_, node) => selectDevice(node.id)}
onEdgeClick={(_, edge) => selectConnection(edge.id)}
onPaneClick={() => {
selectDevice(null)
}}
fitView
colorMode="light"
>
<Background />
<Controls />
<MiniMap pannable zoomable className="!bg-white" />
</ReactFlow>
</div>
)
}
export default function FlowCanvas() {
return (
<ReactFlowProvider>
<FlowCanvasInner />
</ReactFlowProvider>
)
}
+17
View File
@@ -0,0 +1,17 @@
/**
* React Flow handle ids encode which port they belong to. We use a single
* handle per port (not two stacked source/target handles) combined with
* `connectionMode="loose"` on <ReactFlow>, which makes every handle
* connectable in both directions — legality is enforced separately by
* `domain/compatibility.ts`, not by React Flow's source/target typing.
*/
const PREFIX = 'port:'
export function buildHandleId(portId: string): string {
return `${PREFIX}${portId}`
}
export function parsePortId(handleId: string | null | undefined): string | null {
if (!handleId) return null
return handleId.startsWith(PREFIX) ? handleId.slice(PREFIX.length) : handleId
}
+93
View File
@@ -0,0 +1,93 @@
import { useMemo, useState } from 'react'
import { allDeviceCategories } from '../../domain/project'
import { useProjectStore } from '../../state/projectStore'
const NEW_CATEGORY_VALUE = '__new__'
/** A device-category <select> with a built-in "+ New category…" option that
* switches to a small inline text field, creates the category, and selects
* it — so adding a category never requires leaving the form you're in. */
export default function CategorySelect({
value,
onChange,
className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm',
}: {
value: string
onChange: (categoryId: string) => void
className?: string
}) {
const project = useProjectStore((s) => s.project)
const addCustomDeviceCategory = useProjectStore((s) => s.addCustomDeviceCategory)
const [creating, setCreating] = useState(false)
const [draftName, setDraftName] = useState('')
const categories = useMemo(
() => [...allDeviceCategories(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
)
const commitNewCategory = () => {
const trimmed = draftName.trim()
if (trimmed) {
onChange(addCustomDeviceCategory(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()
commitNewCategory()
} else if (e.key === 'Escape') {
setCreating(false)
setDraftName('')
}
}}
placeholder="New category name"
className="w-full min-w-0 flex-1 rounded border border-slate-300 px-2 py-1 text-sm"
/>
<button
onClick={commitNewCategory}
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}
onChange={(e) => {
if (e.target.value === NEW_CATEGORY_VALUE) setCreating(true)
else onChange(e.target.value)
}}
className={className}
>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
<option value={NEW_CATEGORY_VALUE}>+ New category…</option>
</select>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
export default function Modal({
title,
onClose,
children,
width = 'max-w-lg',
}: {
title: string
onClose: () => void
children: ReactNode
width?: string
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4">
<div className={`flex max-h-[85vh] w-full ${width} flex-col rounded-lg bg-white shadow-xl`}>
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<h3 className="text-sm font-semibold text-slate-800">{title}</h3>
<button onClick={onClose} className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
✕
</button>
</div>
<div className="overflow-y-auto px-4 py-3">{children}</div>
</div>
</div>
)
}
@@ -0,0 +1,109 @@
import { useMemo } from 'react'
import { cableTypesForConnection, getPortType } from '../../domain/project'
import { useProjectStore } from '../../state/projectStore'
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
const project = useProjectStore((s) => s.project)
const updateConnection = useProjectStore((s) => s.updateConnection)
const removeConnection = useProjectStore((s) => s.removeConnection)
const connection = project.connections.find((c) => c.id === connectionId)
const sourceDevice = project.devices.find((d) => d.id === connection?.sourceDeviceId)
const targetDevice = project.devices.find((d) => d.id === connection?.targetDeviceId)
const sourcePort = sourceDevice?.ports.find((p) => p.id === connection?.sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === connection?.targetPortId)
const availableCableTypes = useMemo(() => {
if (!sourcePort || !targetPort) return []
const sourcePortType = getPortType(project, sourcePort.portTypeId)
const targetPortType = getPortType(project, targetPort.portTypeId)
return sourcePortType && targetPortType ? cableTypesForConnection(project, sourcePortType, targetPortType) : []
}, [project, sourcePort, targetPort])
if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null
return (
<div className="space-y-3 p-3">
<div className="rounded border border-slate-200 bg-slate-50 p-2 text-xs">
<div className="flex items-center justify-between py-0.5">
<span className="text-slate-500">From</span>
<span className="font-medium text-slate-700">
{sourceDevice.name} · {sourcePort.name}
</span>
</div>
<div className="flex items-center justify-between py-0.5">
<span className="text-slate-500">To</span>
<span className="font-medium text-slate-700">
{targetDevice.name} · {targetPort.name}
</span>
</div>
</div>
<label className="block text-xs font-medium text-slate-600">
Cable type
<select
value={connection.cableTypeId}
onChange={(e) => updateConnection(connection.id, { cableTypeId: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
>
{availableCableTypes.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.name}
</option>
))}
</select>
</label>
<label className="block text-xs font-medium text-slate-600">
Distance (manual estimate)
<div className="mt-1 flex items-center gap-2">
<input
type="number"
min={0}
step={0.5}
value={connection.distance ?? ''}
onChange={(e) =>
updateConnection(connection.id, {
distance: e.target.value === '' ? undefined : Number(e.target.value),
})
}
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
placeholder="e.g. 12"
/>
<span className="text-xs text-slate-500">
{availableCableTypes.find((ct) => ct.id === connection.cableTypeId)?.unit ?? 'ft'}
</span>
</div>
</label>
<label className="block text-xs font-medium text-slate-600">
Label
<input
value={connection.label ?? ''}
onChange={(e) => updateConnection(connection.id, { label: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
placeholder="e.g. Runs through wall conduit"
/>
</label>
<label className="block text-xs font-medium text-slate-600">
Notes
<textarea
value={connection.notes ?? ''}
onChange={(e) => updateConnection(connection.id, { notes: e.target.value })}
rows={2}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<div className="border-t border-slate-100 pt-3">
<button
onClick={() => removeConnection(connection.id)}
className="w-full rounded border border-red-200 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50"
>
Remove cable
</button>
</div>
</div>
)
}
@@ -0,0 +1,158 @@
import { useMemo, useState } from 'react'
import { PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes } from '../../domain/project'
import type { PortDirection } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore'
import CategorySelect from '../common/CategorySelect'
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
const project = useProjectStore((s) => s.project)
const updateDevice = useProjectStore((s) => s.updateDevice)
const removeDevice = useProjectStore((s) => s.removeDevice)
const addPort = useProjectStore((s) => s.addPort)
const clonePort = useProjectStore((s) => s.clonePort)
const updatePort = useProjectStore((s) => s.updatePort)
const removePort = useProjectStore((s) => s.removePort)
const [focusPortId, setFocusPortId] = useState<string | null>(null)
const device = project.devices.find((d) => d.id === deviceId)
const portTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
)
if (!device) return null
const connectionCountForPort = (portId: string) =>
project.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length
return (
<div className="space-y-3 p-3">
<div className="grid grid-cols-2 gap-2">
<label className="col-span-2 text-xs font-medium text-slate-600">
Name
<input
value={device.name}
onChange={(e) => updateDevice(device.id, { name: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<label className="text-xs font-medium text-slate-600">
Category
<CategorySelect value={device.category} onChange={(category) => updateDevice(device.id, { category })} />
</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"
/>
</label>
<label className="col-span-2 text-xs font-medium text-slate-600">
Model
<input
value={device.model ?? ''}
onChange={(e) => updateDevice(device.id, { model: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<label className="col-span-2 text-xs font-medium text-slate-600">
Notes
<textarea
value={device.notes ?? ''}
onChange={(e) => updateDevice(device.id, { notes: e.target.value })}
rows={2}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
</div>
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4>
<div className="space-y-1.5">
{device.ports.map((port) => {
const inUse = connectionCountForPort(port.id) > 0
return (
<div key={port.id} className="flex items-center gap-1.5">
<input
ref={(el) => {
if (el && focusPortId === port.id) {
el.focus()
setFocusPortId(null)
}
}}
value={port.name}
onChange={(e) => updatePort(device.id, port.id, { name: e.target.value })}
className="w-24 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<select
value={port.direction}
onChange={(e) => updatePort(device.id, port.id, { direction: e.target.value as PortDirection })}
className="rounded border border-slate-300 px-1 py-1 text-xs"
>
{PORT_DIRECTIONS.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
<select
value={port.portTypeId}
onChange={(e) => updatePort(device.id, port.id, { portTypeId: e.target.value })}
className="w-28 min-w-0 flex-1 rounded border border-slate-300 px-1 py-1 text-xs"
>
{portTypes.map((pt) => (
<option key={pt.id} value={pt.id}>
{pt.name}
</option>
))}
</select>
<button
onClick={() => setFocusPortId(clonePort(device.id, port.id))}
tabIndex={-1}
title="Duplicate this port"
className="shrink-0 rounded px-1 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600"
>
⧉
</button>
<button
onClick={() => removePort(device.id, port.id)}
disabled={inUse}
tabIndex={-1}
title={inUse ? 'Disconnect its cable first' : 'Remove port'}
className="shrink-0 rounded px-1 text-slate-400 hover:bg-red-50 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-30"
>
✕
</button>
</div>
)
})}
</div>
<button
onClick={() =>
setFocusPortId(
addPort(device.id, {
name: `Port ${device.ports.length + 1}`,
direction: 'output',
portTypeId: portTypes[0]?.id ?? '',
}),
)
}
className="mt-1.5 text-[11px] font-medium text-indigo-600 hover:underline"
>
+ Add port
</button>
</div>
<div className="border-t border-slate-100 pt-3">
<button
onClick={() => removeDevice(device.id)}
className="w-full rounded border border-red-200 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50"
>
Delete device
</button>
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { useProjectStore } from '../../state/projectStore'
import FlowCanvas from '../canvas/FlowCanvas'
import DevicePalette from '../palette/DevicePalette'
import ConnectionErrorBanner from './ConnectionErrorBanner'
import RightPanel from './RightPanel'
import TopBar from './TopBar'
export default function AppShell() {
const isLoaded = useProjectStore((s) => s.isLoaded)
const loadFromStorage = useProjectStore((s) => s.loadFromStorage)
useEffect(() => {
loadFromStorage()
}, [loadFromStorage])
if (!isLoaded) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
}
return (
<div className="flex h-screen flex-col">
<TopBar />
<div className="flex min-h-0 flex-1">
<DevicePalette />
<main className="relative min-w-0 flex-1">
<ConnectionErrorBanner />
<FlowCanvas />
</main>
<RightPanel />
</div>
</div>
)
}
@@ -0,0 +1,26 @@
import { useEffect } from 'react'
import { useProjectStore } from '../../state/projectStore'
export default function ConnectionErrorBanner() {
const error = useProjectStore((s) => s.lastConnectionError)
const clear = useProjectStore((s) => s.clearConnectionError)
useEffect(() => {
if (!error) return
const timer = setTimeout(clear, 4000)
return () => clearTimeout(timer)
}, [error, clear])
if (!error) return null
return (
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center">
<div className="pointer-events-auto flex items-center gap-2 rounded border border-red-200 bg-red-50 px-3 py-1.5 text-xs text-red-700 shadow-sm">
{error}
<button onClick={clear} className="text-red-400 hover:text-red-600">
✕
</button>
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useState } from 'react'
import BomPanel from '../bom/BomPanel'
import ConnectionInspector from '../inspector/ConnectionInspector'
import DeviceInspector from '../inspector/DeviceInspector'
import { useProjectStore } from '../../state/projectStore'
type Tab = 'inspector' | 'bom'
export default function RightPanel() {
const selectedDeviceId = useProjectStore((s) => s.selectedDeviceId)
const selectedConnectionId = useProjectStore((s) => s.selectedConnectionId)
const [tab, setTab] = useState<Tab>('inspector')
return (
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-slate-200 bg-white">
<div className="flex border-b border-slate-200">
<button
onClick={() => setTab('inspector')}
className={`flex-1 px-3 py-2 text-xs font-semibold uppercase tracking-wide ${
tab === 'inspector' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Inspector
</button>
<button
onClick={() => setTab('bom')}
className={`flex-1 px-3 py-2 text-xs font-semibold uppercase tracking-wide ${
tab === 'bom' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Bill of Materials
</button>
</div>
<div className="flex-1 overflow-y-auto">
{tab === 'bom' ? (
<BomPanel />
) : selectedDeviceId ? (
<DeviceInspector deviceId={selectedDeviceId} />
) : selectedConnectionId ? (
<ConnectionInspector connectionId={selectedConnectionId} />
) : (
<div className="p-4 text-xs italic text-slate-400">
Select a device or cable on the canvas to edit its details.
</div>
)}
</div>
</aside>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useRef } from 'react'
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
import { useProjectStore } from '../../state/projectStore'
export default function TopBar() {
const project = useProjectStore((s) => s.project)
const renameProject = useProjectStore((s) => s.renameProject)
const newProject = useProjectStore((s) => s.newProject)
const importProject = useProjectStore((s) => s.importProject)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleNewProject = () => {
if (project.devices.length > 0 || project.connections.length > 0) {
const confirmed = window.confirm('Start a new project? Unsaved changes to the current one will be lost (unless exported).')
if (!confirmed) return
}
newProject()
}
const handleExport = () => downloadProjectFile(project)
const handleImportClick = () => fileInputRef.current?.click()
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
try {
const imported = await readProjectFile(file)
importProject(imported)
} catch (err) {
const message = err instanceof ProjectImportError ? err.message : 'Could not read that file.'
window.alert(`Import failed: ${message}`)
}
}
return (
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-indigo-700">AV Planner</span>
<input
value={project.name}
onChange={(e) => renameProject(e.target.value)}
className="rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none"
/>
</div>
<div className="flex items-center gap-1.5">
<button onClick={handleNewProject} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
New
</button>
<button onClick={handleImportClick} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Import
</button>
<input ref={fileInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleFileChange} />
<button
onClick={handleExport}
className="rounded bg-indigo-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
>
Export
</button>
</div>
</header>
)
}
+230
View File
@@ -0,0 +1,230 @@
import { useMemo, useState } from 'react'
import { allCableTypes, allPortTypes } from '../../domain/project'
import type { CableType, PortType } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore'
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
}
function CableTypeForm({
initial,
portTypes,
onCancel,
onSave,
}: {
initial?: CableType
portTypes: PortType[]
onCancel: () => void
onSave: (fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }) => 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
const [name, setName] = useState(initial?.name ?? '')
const [endAPortTypeId, setEndAPortTypeId] = useState(initialEndA?.id ?? portTypes[0]?.id ?? '')
const [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '')
const [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft')
const isAdapter = endAPortTypeId !== endBPortTypeId
const handleSave = () => {
if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return
onSave({ name: name.trim(), endAPortTypeId, endBPortTypeId, unit })
}
return (
<div className="space-y-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Cable name (e.g. XLR to 1/4&quot; TRS Adapter Cable)"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<div className="flex items-center gap-1.5">
<select
value={endAPortTypeId}
onChange={(e) => setEndAPortTypeId(e.target.value)}
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1 py-1 text-xs"
>
{portTypes.map((pt) => (
<option key={pt.id} value={pt.id}>
{pt.name}
</option>
))}
</select>
<span className="shrink-0 text-slate-400">↔</span>
<select
value={endBPortTypeId}
onChange={(e) => setEndBPortTypeId(e.target.value)}
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1 py-1 text-xs"
>
{portTypes.map((pt) => (
<option key={pt.id} value={pt.id}>
{pt.name}
</option>
))}
</select>
<select
value={unit}
onChange={(e) => setUnit(e.target.value as CableType['unit'])}
className="shrink-0 rounded border border-slate-300 px-1 py-1 text-xs"
>
{UNITS.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</div>
<p className="text-[10px] text-slate-500">
{isAdapter
? 'Different connector on each end — this becomes an adapter cable, letting those two port types wire together even if nothing else declares them compatible.'
: 'Same connector on each end — a regular cable for that connector type.'}
</p>
<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"
>
Save
</button>
</div>
</div>
)
}
export default function CableTypesPanel() {
const project = useProjectStore((s) => s.project)
const addCustomCableType = useProjectStore((s) => s.addCustomCableType)
const updateCustomCableType = useProjectStore((s) => s.updateCustomCableType)
const [creating, setCreating] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const portTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
)
const allTypes = useMemo(
() => [...allCableTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
)
const customTypes = useMemo(() => allTypes.filter((ct) => ct.custom), [allTypes])
const builtInTypes = useMemo(() => allTypes.filter((ct) => !ct.custom), [allTypes])
const describeEnds = (ct: CableType) => {
const a = familyLabel(ct.family, portTypes)
const b = familyLabel(ct.family2 ?? ct.family, portTypes)
return a === b ? a : `${a} ↔ ${b}`
}
const handleCreate = (fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }) => {
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
if (!endA || !endB) return
addCustomCableType({
name: fields.name,
family: endA.family,
family2: endA.family === endB.family ? undefined : endB.family,
unit: fields.unit,
})
setCreating(false)
}
const handleUpdate = (
id: string,
fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] },
) => {
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
if (!endA || !endB) return
updateCustomCableType(id, {
name: fields.name,
family: endA.family,
family2: endA.family === endB.family ? undefined : endB.family,
unit: fields.unit,
})
setEditingId(null)
}
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 ({customTypes.length})
</h4>
{!creating && (
<button onClick={() => setCreating(true)} className="text-[11px] font-medium text-indigo-600 hover:underline">
+ Define a new cable type
</button>
)}
</div>
{creating && (
<div className="mb-1.5">
<CableTypeForm portTypes={portTypes} onCancel={() => setCreating(false)} onSave={handleCreate} />
</div>
)}
{customTypes.length === 0 && !creating ? (
<p className="text-[11px] italic text-slate-400">
None yet — useful for adapter cables (e.g. XLR to 1/4" TRS) that bridge two connector types.
</p>
) : (
<div className="space-y-1.5">
{customTypes.map((ct) =>
editingId === ct.id ? (
<CableTypeForm
key={ct.id}
initial={ct}
portTypes={portTypes}
onCancel={() => setEditingId(null)}
onSave={(fields) => handleUpdate(ct.id, fields)}
/>
) : (
<div
key={ct.id}
className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs"
>
<div className="min-w-0">
<div className="font-medium text-slate-700">{ct.name}</div>
<div className="truncate text-[10px] text-slate-400">
{describeEnds(ct)} · {ct.unit}
</div>
</div>
<button
onClick={() => setEditingId(ct.id)}
className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
>
Edit
</button>
</div>
),
)}
</div>
)}
</div>
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Built-in ({builtInTypes.length})
</h4>
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInTypes.map((ct) => (
<div key={ct.id} className="px-1 py-0.5 text-[11px] text-slate-500">
{ct.name} <span className="text-slate-400">· {describeEnds(ct)}</span>
</div>
))}
</div>
<p className="mt-1 text-[10px] text-slate-400">Built-in cable types can't be edited or removed.</p>
</div>
</div>
)
}
@@ -0,0 +1,41 @@
import { useState } from 'react'
import Modal from '../common/Modal'
import CableTypesPanel from './CableTypeManager'
import PortTypesPanel from './PortTypeManager'
type Tab = 'ports' | 'cables'
export default function ConnectorLibraryModal({ onClose }: { onClose: () => void }) {
const [tab, setTab] = useState<Tab>('ports')
return (
<Modal title="Connector Library" onClose={onClose} width="max-w-xl">
<div className="mb-3 flex border-b border-slate-200">
<button
onClick={() => setTab('ports')}
className={`px-3 py-1.5 text-xs font-semibold uppercase tracking-wide ${
tab === 'ports' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Port Types
</button>
<button
onClick={() => setTab('cables')}
className={`px-3 py-1.5 text-xs font-semibold uppercase tracking-wide ${
tab === 'cables' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Cable Types
</button>
</div>
{tab === 'ports' ? <PortTypesPanel /> : <CableTypesPanel />}
<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>
)
}
+208
View File
@@ -0,0 +1,208 @@
import { useMemo, useState } from 'react'
import { allDeviceCategories, allDeviceTemplates, hiddenBuiltInDeviceTemplates } from '../../domain/project'
import type { DeviceTemplate } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore'
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
import ConnectorLibraryModal from './ConnectorLibraryModal'
import DeviceTemplateEditor from './DeviceTemplateEditor'
export default function DevicePalette() {
const project = useProjectStore((s) => s.project)
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
const removeCustomDeviceTemplate = useProjectStore((s) => s.removeCustomDeviceTemplate)
const hideBuiltInDeviceTemplate = useProjectStore((s) => s.hideBuiltInDeviceTemplate)
const restoreBuiltInDeviceTemplate = useProjectStore((s) => s.restoreBuiltInDeviceTemplate)
const [editorTarget, setEditorTarget] = useState<'new' | DeviceTemplate | null>(null)
const [portTypesOpen, setPortTypesOpen] = useState(false)
const [hiddenListOpen, setHiddenListOpen] = useState(false)
// 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())
const groups = useMemo(() => {
const categories = allDeviceCategories(project)
const categoryName = (id: string) => categories.find((c) => c.id === id)?.name ?? id
const byCategory = new Map<string, DeviceTemplate[]>()
for (const template of allDeviceTemplates(project)) {
const list = byCategory.get(template.category) ?? []
list.push(template)
byCategory.set(template.category, list)
}
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))
}, [project])
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(categoryId)) next.delete(categoryId)
else next.add(categoryId)
return next
})
}
const expandCategory = (categoryId: string) => {
setExpandedCategories((prev) => new Set(prev).add(categoryId))
}
const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => {
event.dataTransfer.setData(TEMPLATE_DRAG_MIME, template.id)
event.dataTransfer.effectAllowed = 'move'
}
const handleQuickAdd = (template: DeviceTemplate) => {
// Cascade placement so repeated quick-adds don't stack exactly on top of each other.
const offset = (project.devices.length % 8) * 24
addDeviceFromTemplate(template.id, { x: 80 + offset, y: 80 + offset })
}
const handleDelete = (template: DeviceTemplate) => {
if (template.builtIn) {
const confirmed = window.confirm(
`Hide "${template.name}" from your device library? It's a built-in default, so you can bring it back later from "Show hidden defaults" at the bottom of this panel. Devices already placed on the canvas from it won't be affected.`,
)
if (confirmed) hideBuiltInDeviceTemplate(template.id)
return
}
const confirmed = window.confirm(
`Delete "${template.name}" from your device library? Devices already placed on the canvas from it won't be affected.`,
)
if (confirmed) removeCustomDeviceTemplate(template.id)
}
const hiddenDefaults = useMemo(() => hiddenBuiltInDeviceTemplates(project), [project])
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>
<button
onClick={() => setEditorTarget('new')}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
>
+ Custom
</button>
</div>
</div>
<p className="border-b border-slate-200 px-3 py-2 text-[11px] text-slate-500">
Drag a device onto the canvas, or click + to drop it in.
</p>
<div className="flex-1 overflow-y-auto p-2">
{groups.map((group) => {
const expanded = expandedCategories.has(group.categoryId)
return (
<div key={group.categoryId} className="mb-1">
<button
onClick={() => toggleCategory(group.categoryId)}
className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-xs font-medium text-slate-600 hover:bg-slate-100"
>
<span className="flex items-center gap-1.5">
<span className={`inline-block text-[9px] text-slate-400 transition-transform ${expanded ? 'rotate-90' : ''}`}>
▶
</span>
{group.categoryName}
</span>
<span className="text-[10px] text-slate-400">{group.templates.length}</span>
</button>
{expanded && (
<ul className="mt-1 space-y-1 pl-2">
{group.templates.map((template) => (
<li key={template.id} className="group">
<div
draggable
onDragStart={(e) => handleDragStart(e, template)}
className="flex cursor-grab items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm 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.builtIn ? ' · custom' : ''}
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
{!template.builtIn && (
<button
onClick={() => setEditorTarget(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.builtIn ? 'Hide this default device' : 'Delete from library'}
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={() => handleQuickAdd(template)}
title="Add to canvas"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
+
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
)
})}
</div>
{hiddenDefaults.length > 0 && (
<div className="border-t border-slate-200 px-2 py-1.5">
<button
onClick={() => setHiddenListOpen((v) => !v)}
className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-[11px] text-slate-500 hover:bg-slate-100"
>
<span>
{hiddenListOpen ? 'Hide' : 'Show'} hidden defaults ({hiddenDefaults.length})
</span>
<span className={`inline-block text-[9px] transition-transform ${hiddenListOpen ? 'rotate-90' : ''}`}>▶</span>
</button>
{hiddenListOpen && (
<ul className="mt-1 space-y-1">
{hiddenDefaults.map((template) => (
<li key={template.id} className="flex items-center justify-between rounded bg-white px-2 py-1 text-xs">
<span className="truncate text-slate-600">{template.name}</span>
<button
onClick={() => restoreBuiltInDeviceTemplate(template.id)}
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
>
Restore
</button>
</li>
))}
</ul>
)}
</div>
)}
{editorTarget && (
<DeviceTemplateEditor
template={editorTarget === 'new' ? undefined : editorTarget}
onClose={() => setEditorTarget(null)}
onSaved={(categoryId) => expandCategory(categoryId)}
/>
)}
{portTypesOpen && <ConnectorLibraryModal onClose={() => setPortTypesOpen(false)} />}
</aside>
)
}
@@ -0,0 +1,319 @@
import { useMemo, useState } from 'react'
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/project'
import type { DeviceTemplate, PortDirection } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore'
import CategorySelect from '../common/CategorySelect'
import Modal from '../common/Modal'
interface DraftPort {
key: string
name: string
direction: PortDirection
portTypeId: string
}
let draftKeySeq = 0
function nextDraftKey() {
draftKeySeq += 1
return `draft-${draftKeySeq}`
}
export default function DeviceTemplateEditor({
template,
onClose,
onSaved,
}: {
/** When set, edits this existing custom template instead of creating a new one. */
template?: DeviceTemplate
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
}) {
const project = useProjectStore((s) => s.project)
const addCustomDeviceTemplate = useProjectStore((s) => s.addCustomDeviceTemplate)
const updateCustomDeviceTemplate = useProjectStore((s) => s.updateCustomDeviceTemplate)
const addCustomPortType = useProjectStore((s) => s.addCustomPortType)
const portTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
)
const [name, setName] = useState(template?.name ?? '')
const [category, setCategory] = useState<string>(template?.category ?? 'other')
const [manufacturer, setManufacturer] = useState(template?.manufacturer ?? '')
const [model, setModel] = useState(template?.model ?? '')
const [ports, setPorts] = useState<DraftPort[]>(
() =>
template?.ports.map((p) => ({ key: p.id, name: p.name, direction: p.direction, portTypeId: p.portTypeId })) ?? [
{ key: nextDraftKey(), name: 'Port 1', direction: 'output', portTypeId: portTypes[0]?.id ?? '' },
],
)
const [newPortTypeOpen, setNewPortTypeOpen] = useState(false)
const [newPortTypeName, setNewPortTypeName] = useState('')
const [newPortTypeFamily, setNewPortTypeFamily] = useState('')
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 toggleAlsoCompatible = (portTypeId: string) => {
setAlsoCompatibleWith((prev) => {
const next = new Set(prev)
if (next.has(portTypeId)) next.delete(portTypeId)
else next.add(portTypeId)
return next
})
}
const updatePort = (key: string, patch: Partial<DraftPort>) => {
setPorts((prev) => prev.map((p) => (p.key === key ? { ...p, ...patch } : p)))
}
const addPortRow = () => {
const key = nextDraftKey()
setPorts((prev) => [
...prev,
{ key, name: `Port ${prev.length + 1}`, direction: 'output', portTypeId: portTypes[0]?.id ?? '' },
])
setFocusKey(key)
}
const clonePortRow = (sourceKey: string) => {
const key = nextDraftKey()
setPorts((prev) => {
const index = prev.findIndex((p) => p.key === sourceKey)
if (index === -1) return prev
const source = prev[index]
const clone: DraftPort = { ...source, key, name: incrementPortName(source.name) }
return [...prev.slice(0, index + 1), clone, ...prev.slice(index + 1)]
})
setFocusKey(key)
}
const removePortRow = (key: string) => {
setPorts((prev) => prev.filter((p) => p.key !== key))
}
const handleCreatePortType = () => {
if (!newPortTypeName.trim()) return
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes)
addCustomPortType({
name: newPortTypeName.trim(),
category: newPortTypeCategory,
family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'),
...(compatibleFamilyIds.length > 0 ? { compatibleFamilyIds } : {}),
})
setNewPortTypeName('')
setNewPortTypeFamily('')
setAlsoCompatibleWith(new Set())
setNewPortTypeOpen(false)
}
const canSave = name.trim().length > 0 && ports.length > 0 && ports.every((p) => p.portTypeId)
const handleSave = () => {
if (!canSave) return
const fields = {
name: name.trim(),
category,
manufacturer: manufacturer.trim() || undefined,
model: model.trim() || undefined,
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })),
}
if (template) {
updateCustomDeviceTemplate(template.id, fields)
} else {
addCustomDeviceTemplate(fields)
}
onSaved?.(category)
onClose()
}
return (
<Modal title={template ? 'Edit Custom Device' : 'New Custom Device'} onClose={onClose} width="max-w-xl">
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<label className="col-span-2 text-xs font-medium text-slate-600">
Name
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
placeholder="e.g. Living Room Display"
/>
</label>
<label className="text-xs font-medium text-slate-600">
Category
<CategorySelect value={category} onChange={setCategory} />
</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"
/>
</label>
<label className="col-span-2 text-xs font-medium text-slate-600">
Model
<input
value={model}
onChange={(e) => setModel(e.target.value)}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
</div>
<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">
<input
ref={(el) => {
if (el && focusKey === port.key) {
el.focus()
setFocusKey(null)
}
}}
value={port.name}
onChange={(e) => updatePort(port.key, { name: e.target.value })}
className="w-28 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
placeholder="Port name"
/>
<select
value={port.direction}
onChange={(e) => updatePort(port.key, { direction: e.target.value as PortDirection })}
className="rounded border border-slate-300 px-1 py-1 text-xs"
>
{PORT_DIRECTIONS.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
<select
value={port.portTypeId}
onChange={(e) => updatePort(port.key, { portTypeId: e.target.value })}
className="w-32 min-w-0 flex-1 rounded border border-slate-300 px-1 py-1 text-xs"
>
{portTypes.map((pt) => (
<option key={pt.id} value={pt.id}>
{pt.name}
</option>
))}
</select>
<button
onClick={() => clonePortRow(port.key)}
tabIndex={-1}
className="shrink-0 rounded px-1 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600"
title="Duplicate this port"
>
⧉
</button>
<button
onClick={() => removePortRow(port.key)}
tabIndex={-1}
className="shrink-0 rounded px-1 text-slate-400 hover:bg-red-50 hover:text-red-500"
title="Remove port"
>
✕
</button>
</div>
))}
{ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>}
</div>
<button onClick={addPortRow} className="mt-1.5 text-[11px] font-medium text-indigo-600 hover:underline">
+ Add port
</button>
<div className="mt-2 border-t border-slate-100 pt-2">
{!newPortTypeOpen ? (
<button
onClick={() => setNewPortTypeOpen(true)}
className="text-[11px] font-medium text-indigo-600 hover:underline"
>
+ Define a new connector type
</button>
) : (
<div className="space-y-1.5 rounded border border-slate-200 bg-slate-50 p-2">
<div className="flex gap-1.5">
<input
value={newPortTypeName}
onChange={(e) => setNewPortTypeName(e.target.value)}
placeholder="Connector name (e.g. Zigbee)"
className="flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<select
value={newPortTypeCategory}
onChange={(e) => setNewPortTypeCategory(e.target.value as (typeof PORT_CATEGORIES)[number]['value'])}
className="rounded border border-slate-300 px-1 py-1 text-xs"
>
{PORT_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</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>
<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
balanced or unbalanced):
</p>
<div className="mt-1 max-h-24 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
{portTypes.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={() => toggleAlsoCompatible(pt.id)}
/>
{pt.name}
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setNewPortTypeOpen(false)} className="text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={handleCreatePortType}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
>
Add connector type
</button>
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 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">
Cancel
</button>
<button
onClick={handleSave}
disabled={!canSave}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{template ? 'Save changes' : 'Save device'}
</button>
</div>
</div>
</Modal>
)
}
+183
View File
@@ -0,0 +1,183 @@
import { useMemo, useState } from 'react'
import { PORT_CATEGORIES } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/project'
import type { PortType } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore'
function EditCustomPortTypeForm({
portType,
allTypes,
onCancel,
}: {
portType: PortType
allTypes: PortType[]
onCancel: () => void
}) {
const updateCustomPortType = useProjectStore((s) => s.updateCustomPortType)
const [name, setName] = useState(portType.name)
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category)
const [family, setFamily] = useState(portType.family)
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(() => {
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 toggle = (id: string) => {
setAlsoCompatibleWith((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleSave = () => {
if (!name.trim() || !family.trim()) return
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], allTypes).filter(
(f) => f !== family.trim(),
)
updateCustomPortType(portType.id, {
name: name.trim(),
category,
family: family.trim(),
compatibleFamilyIds,
})
onCancel()
}
return (
<div className="space-y-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
<div className="flex gap-1.5">
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value as (typeof PORT_CATEGORIES)[number]['value'])}
className="rounded border border-slate-300 px-1 py-1 text-xs"
>
{PORT_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</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"
/>
<div>
<p className="text-[10px] font-medium text-slate-600">Also compatible with:</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)
.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)} />
{pt.name}
</label>
))}
</div>
</div>
<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"
>
Save
</button>
</div>
</div>
)
}
export default function PortTypesPanel() {
const project = useProjectStore((s) => s.project)
const [editingId, setEditingId] = useState<string | null>(null)
const allTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
)
const customTypes = useMemo(() => allTypes.filter((pt) => pt.custom), [allTypes])
const builtInTypes = useMemo(() => allTypes.filter((pt) => !pt.custom), [allTypes])
const describeCompatibility = (pt: PortType) => {
const families = new Set(pt.compatibleFamilyIds ?? [])
if (families.size === 0) return null
const names = allTypes.filter((other) => other.id !== pt.id && families.has(other.family)).map((o) => o.name)
return names.length > 0 ? names.join(', ') : null
}
return (
<div className="space-y-4">
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Custom ({customTypes.length})
</h4>
{customTypes.length === 0 ? (
<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.
</p>
) : (
<div className="space-y-1.5">
{customTypes.map((pt) =>
editingId === pt.id ? (
<EditCustomPortTypeForm
key={pt.id}
portType={pt}
allTypes={allTypes}
onCancel={() => setEditingId(null)}
/>
) : (
<div
key={pt.id}
className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs"
>
<div className="min-w-0">
<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)}` : ''}
</div>
</div>
<button
onClick={() => setEditingId(pt.id)}
className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
>
Edit
</button>
</div>
),
)}
</div>
)}
</div>
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Built-in ({builtInTypes.length})
</h4>
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInTypes.map((pt) => (
<div key={pt.id} className="px-1 py-0.5 text-[11px] text-slate-500">
{pt.name}
{describeCompatibility(pt) && (
<span className="text-slate-400"> · also fits: {describeCompatibility(pt)}</span>
)}
</div>
))}
</div>
<p className="mt-1 text-[10px] text-slate-400">Built-in connector types can't be edited or removed.</p>
</div>
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { Project } from '../domain/types'
import { normalizeProject } from './exportImport'
import type { ProjectRepository } from './ProjectRepository'
const STORAGE_KEY = 'av-planner:project:v1'
export class LocalStorageProjectRepository implements ProjectRepository {
async load(): Promise<Project | null> {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return null
return normalizeProject(JSON.parse(raw) as Project)
} catch (err) {
console.error('Failed to load project from localStorage', err)
return null
}
}
async save(project: Project): Promise<void> {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(project))
}
async clear(): Promise<void> {
window.localStorage.removeItem(STORAGE_KEY)
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { Project } from '../domain/types'
/**
* Storage abstraction the rest of the app codes against. Today only
* `LocalStorageProjectRepository` implements it; a future backend would add
* e.g. `ApiProjectRepository` (REST/GraphQL) behind this same interface so
* the store/UI layers wouldn't need to change.
*/
export interface ProjectRepository {
load(): Promise<Project | null>
save(project: Project): Promise<void>
clear(): Promise<void>
}
+64
View File
@@ -0,0 +1,64 @@
import type { Project } from '../domain/types'
export function projectToJson(project: Project): string {
return JSON.stringify(project, null, 2)
}
export function downloadProjectFile(project: Project): void {
const json = projectToJson(project)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const safeName = project.name.trim().replace(/[^a-z0-9-_]+/gi, '_') || 'project'
a.href = url
a.download = `${safeName}.avplan.json`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
class ProjectImportError extends Error {}
/** Minimal structural check — enough to catch "wrong file" without pulling
* in a schema validation library for a single call site. */
function assertLooksLikeProject(value: unknown): asserts value is Project {
if (typeof value !== 'object' || value === null) {
throw new ProjectImportError('File does not contain a JSON object.')
}
const p = value as Partial<Project>
if (typeof p.id !== 'string' || typeof p.name !== 'string') {
throw new ProjectImportError('File is missing required project fields.')
}
if (!Array.isArray(p.devices) || !Array.isArray(p.connections)) {
throw new ProjectImportError('File is missing devices/connections arrays.')
}
}
export async function readProjectFile(file: File): Promise<Project> {
const text = await file.text()
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new ProjectImportError('File is not valid JSON.')
}
assertLooksLikeProject(parsed)
return normalizeProject(parsed)
}
/** Fills in collections added after a project may have been saved/exported,
* so older data (from localStorage or an older export file) doesn't crash
* code that expects these arrays to always exist. */
export function normalizeProject(project: Project): Project {
return {
...project,
customDeviceTemplates: project.customDeviceTemplates ?? [],
customPortTypes: project.customPortTypes ?? [],
customCableTypes: project.customCableTypes ?? [],
customDeviceCategories: project.customDeviceCategories ?? [],
hiddenBuiltInDeviceTemplateIds: project.hiddenBuiltInDeviceTemplateIds ?? [],
}
}
export { ProjectImportError }
+76
View File
@@ -0,0 +1,76 @@
import { allCableTypes } from './project'
import type { Device, Project } from './types'
export interface BomCableLine {
cableTypeId: string
cableTypeName: string
unit: string
count: number
/** Sum of user-entered distances; connections with no distance are excluded. */
totalLength: number
connectionsMissingLength: number
}
export interface BomDeviceLine {
key: string
name: string
category: string
manufacturer?: string
model?: string
count: number
}
export interface Bom {
cables: BomCableLine[]
devices: BomDeviceLine[]
}
function deviceGroupKey(device: Device): string {
return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}`
}
export function computeBom(project: Project): Bom {
const cableTypes = new Map(allCableTypes(project).map((ct) => [ct.id, ct]))
const cableLines = new Map<string, BomCableLine>()
for (const connection of project.connections) {
const cableType = cableTypes.get(connection.cableTypeId)
const name = cableType?.name ?? 'Unknown Cable'
const unit = cableType?.unit ?? 'ft'
const existing = cableLines.get(connection.cableTypeId) ?? {
cableTypeId: connection.cableTypeId,
cableTypeName: name,
unit,
count: 0,
totalLength: 0,
connectionsMissingLength: 0,
}
existing.count += 1
if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) {
existing.totalLength += connection.distance
} else {
existing.connectionsMissingLength += 1
}
cableLines.set(connection.cableTypeId, existing)
}
const deviceLines = new Map<string, BomDeviceLine>()
for (const device of project.devices) {
const key = deviceGroupKey(device)
const existing = deviceLines.get(key) ?? {
key,
name: device.name,
category: device.category,
manufacturer: device.manufacturer,
model: device.model,
count: 0,
}
existing.count += 1
deviceLines.set(key, existing)
}
return {
cables: [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName)),
devices: [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name)),
}
}
+103
View File
@@ -0,0 +1,103 @@
import { acceptedFamilies, getPortType, hasBridgingCableType } from './project'
import type { Connection, Device, PortType, Project } from './types'
/**
* Two ports connect if the sets of families they each accept (their own
* family plus anything declared in `compatibleFamilyIds`) overlap at all.
* This has to be a full set intersection, not just "does A's family appear
* in B's list" — a pairing that only exists via a shared third "bridge"
* family (e.g. USB-A Host and USB-B Device both listing 'usb-a-to-b', while
* neither's own family is 'usb-a-to-b') would otherwise never match.
*/
export function portTypesCompatible(a: PortType, b: PortType): boolean {
if (a.id === b.id) return true
const aFamilies = acceptedFamilies(a)
for (const family of acceptedFamilies(b)) {
if (aFamilies.has(family)) return true
}
return false
}
export interface ConnectionCandidate {
sourceDeviceId: string
sourcePortId: string
targetDeviceId: string
targetPortId: string
}
export interface ConnectionValidation {
valid: boolean
reason?: string
}
/**
* Central rule set for "can these two ports be wired together". Used both by
* the canvas (to accept/reject a drag-to-connect gesture) and could be reused
* server-side later since it only depends on plain domain data.
*/
export function validateConnection(
project: Project,
candidate: ConnectionCandidate,
existingConnections: Connection[] = project.connections,
): ConnectionValidation {
const { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId } = candidate
if (sourceDeviceId === targetDeviceId && sourcePortId === targetPortId) {
return { valid: false, reason: 'A port cannot connect to itself.' }
}
const sourceDevice = project.devices.find((d) => d.id === sourceDeviceId)
const targetDevice = project.devices.find((d) => d.id === targetDeviceId)
const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId)
if (!sourceDevice || !targetDevice || !sourcePort || !targetPort) {
return { valid: false, reason: 'Unknown device or port.' }
}
if (sourcePort.direction === 'input' && targetPort.direction === 'input') {
return { valid: false, reason: 'Cannot connect two input ports.' }
}
if (sourcePort.direction === 'output' && targetPort.direction === 'output') {
return { valid: false, reason: 'Cannot connect two output ports.' }
}
const sourcePortType = getPortType(project, sourcePort.portTypeId)
const targetPortType = getPortType(project, targetPort.portTypeId)
if (!sourcePortType || !targetPortType) {
return { valid: false, reason: 'Unknown port type.' }
}
// Directly compatible port types, or bridgeable by a specific adapter
// cable (different connectors on each end, e.g. XLR-to-TRS) even when the
// port types themselves declare no general compatibility.
if (!portTypesCompatible(sourcePortType, targetPortType) && !hasBridgingCableType(project, sourcePortType, targetPortType)) {
return {
valid: false,
reason: `${sourcePortType.name} is not compatible with ${targetPortType.name}.`,
}
}
const maxSource = sourcePortType.maxConnections ?? 1
const usedSource = existingConnections.filter(
(c) => c.sourcePortId === sourcePortId || c.targetPortId === sourcePortId,
).length
if (usedSource >= maxSource) {
return { valid: false, reason: `${sourcePort.name} already has the maximum number of cables.` }
}
const maxTarget = targetPortType.maxConnections ?? 1
const usedTarget = existingConnections.filter(
(c) => c.sourcePortId === targetPortId || c.targetPortId === targetPortId,
).length
if (usedTarget >= maxTarget) {
return { valid: false, reason: `${targetPort.name} already has the maximum number of cables.` }
}
return { valid: true }
}
export function findDevicePort(devices: Device[], deviceId: string, portId: string) {
const device = devices.find((d) => d.id === deviceId)
const port = device?.ports.find((p) => p.id === portId)
return device && port ? { device, port } : undefined
}
+20
View File
@@ -0,0 +1,20 @@
import type { PortCategory, PortDirection } from './types'
// Device categories are no longer a fixed list — see `domain/project.ts`'s
// `allDeviceCategories` (built-ins + project-custom, merged).
export const PORT_DIRECTIONS: { value: PortDirection; label: string }[] = [
{ value: 'input', label: 'Input' },
{ value: 'output', label: 'Output' },
{ value: 'bidirectional', label: 'Bidirectional' },
]
export const PORT_CATEGORIES: { value: PortCategory; label: string }[] = [
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' },
{ value: 'network', label: 'Network' },
{ value: 'usb', label: 'USB / Data' },
{ value: 'power', label: 'Power' },
{ value: 'control', label: 'Control' },
{ value: 'other', label: 'Other' },
]
+264
View File
@@ -0,0 +1,264 @@
import type { CableType, DeviceCategoryDef, DeviceTemplate, PortType } from './types'
/**
* Built-in connector/cable/device libraries. These ship read-only
* (`builtIn`/`custom: false`-ish via omission) and are merged with each
* project's custom entries at lookup time — see `domain/project.ts`.
*/
export const BUILT_IN_DEVICE_CATEGORIES: DeviceCategoryDef[] = [
{ id: 'display', name: 'Display' },
{ id: 'video-source', name: 'Video Source' },
{ id: 'audio-source', name: 'Audio Source' },
{ id: 'synth', name: 'Synthesizer / Sound Module' },
{ id: 'mixer', name: 'Mixer' },
{ id: 'amplifier', name: 'Amplifier / Receiver' },
{ id: 'speaker', name: 'Speaker' },
{ id: 'network', name: 'Network' },
{ id: 'computer', name: 'Computer' },
{ id: 'camera', name: 'Camera' },
{ id: 'control', name: 'Control' },
{ id: 'power', name: 'Power' },
{ id: 'adapter', name: 'Adapter / Converter' },
{ id: 'other', name: 'Other' },
]
export const BUILT_IN_PORT_TYPES: PortType[] = [
{ id: 'pt-hdmi', name: 'HDMI', category: 'video', family: 'hdmi' },
{ id: 'pt-displayport', name: 'DisplayPort', category: 'video', family: 'displayport' },
{ id: 'pt-sdi', name: 'SDI (BNC)', category: 'video', family: 'sdi' },
{ id: 'pt-vga', name: 'VGA', category: 'video', family: 'vga' },
{ id: 'pt-composite', name: 'Composite Video (RCA)', category: 'video', family: 'rca-composite' },
{ id: 'pt-xlr', name: 'XLR (Balanced Audio)', category: 'audio', family: 'xlr' },
{ id: 'pt-trs-quarter', name: '1/4" TRS (Balanced Audio)', category: 'audio', family: 'trs-quarter' },
{ id: 'pt-ts-quarter', name: '1/4" TS (Unbalanced Audio)', category: 'audio', family: 'ts-quarter' },
{ id: 'pt-trs-mini', name: '3.5mm TRS (Balanced Audio)', category: 'audio', family: 'trs-mini' },
{ id: 'pt-ts-mini', name: '3.5mm (Unbalanced Audio)', category: 'audio', family: 'ts-mini' },
{
id: 'pt-trs-quarter-combo',
name: '1/4" TRS/TS (Balanced or Unbalanced)',
category: 'audio',
family: 'trs-quarter-combo',
compatibleFamilyIds: ['trs-quarter', 'ts-quarter'],
},
{
id: 'pt-trs-mini-combo',
name: '3.5mm TRS/TS (Balanced or Unbalanced)',
category: 'audio',
family: 'trs-mini-combo',
compatibleFamilyIds: ['trs-mini', 'ts-mini'],
},
{ id: 'pt-rca-audio', name: 'RCA (Stereo Audio)', category: 'audio', family: 'rca-audio' },
{ id: 'pt-speakon', name: 'Speakon', category: 'audio', family: 'speakon' },
{ id: 'pt-speaker-wire', name: 'Speaker Wire (Bare/Terminal)', category: 'audio', family: 'speaker-wire', maxConnections: 1 },
{ id: 'pt-toslink', name: 'TOSLINK (Optical)', category: 'audio', family: 'toslink' },
{
id: 'pt-cat-rj45',
name: 'Cat5e/6 (RJ45)',
category: 'network',
family: 'cat-rj45',
compatibleFamilyIds: ['cat-rj45'],
},
{ id: 'pt-fiber-lc', name: 'Fiber (LC)', category: 'network', family: 'fiber-lc' },
{ id: 'pt-usb-a', name: 'USB-A (Unspecified)', category: 'usb', family: 'usb-a' },
{ id: 'pt-usb-c', name: 'USB-C (Unspecified)', category: 'usb', family: 'usb-c' },
{
id: 'pt-usb-a-host',
name: 'USB-A (Host)',
category: 'usb',
family: 'usb-a-host',
// 'usb-a-to-b' lets this pair with a USB-B device port via an A-to-B
// cable, distinct from the plain A-to-A cable used with usb-a-device.
compatibleFamilyIds: ['usb-a-device', 'usb-a-to-b'],
},
{
id: 'pt-usb-a-device',
name: 'USB-A (Device)',
category: 'usb',
family: 'usb-a-device',
compatibleFamilyIds: ['usb-a-host'],
},
{
id: 'pt-usb-c-host',
name: 'USB-C (Host)',
category: 'usb',
family: 'usb-c-host',
compatibleFamilyIds: ['usb-c-device', 'usb-c-to-b'],
},
{
id: 'pt-usb-c-device',
name: 'USB-C (Device)',
category: 'usb',
family: 'usb-c-device',
compatibleFamilyIds: ['usb-c-host'],
},
{
id: 'pt-usb-b-device',
name: 'USB-B (Device)',
category: 'usb',
family: 'usb-b-device',
// The classic "printer" connector — pairs with a host's USB-A or USB-C
// port via the corresponding shaped cable, not with another USB-B port.
compatibleFamilyIds: ['usb-a-to-b', 'usb-c-to-b'],
},
{ id: 'pt-iec-power', name: 'IEC C13/C14 Power', category: 'power', family: 'iec-power' },
{ id: 'pt-nema-power', name: 'NEMA 5-15 Power', category: 'power', family: 'nema-power' },
{ id: 'pt-rs232', name: 'RS-232 (Serial)', category: 'control', family: 'rs232' },
{ id: 'pt-ir', name: 'IR (3.5mm Emitter/Receiver)', category: 'control', family: 'ir' },
{ id: 'pt-midi-din', name: 'MIDI (5-Pin DIN)', category: 'control', family: 'midi-din' },
{
id: 'pt-midi-trs-a',
name: '3.5mm MIDI (Type A)',
category: 'control',
family: 'midi-trs-a',
// Deliberately NOT cross-compatible with Type B — the two standards
// swap tip/ring polarity, so a straight cable between them doesn't work.
},
{ id: 'pt-midi-trs-b', name: '3.5mm MIDI (Type B)', category: 'control', family: 'midi-trs-b' },
]
export const BUILT_IN_CABLE_TYPES: CableType[] = [
{ id: 'ct-hdmi', name: 'HDMI Cable', family: 'hdmi', unit: 'ft' },
{ id: 'ct-displayport', name: 'DisplayPort Cable', family: 'displayport', unit: 'ft' },
{ id: 'ct-sdi', name: 'SDI Coax (BNC)', family: 'sdi', unit: 'ft' },
{ id: 'ct-vga', name: 'VGA Cable', family: 'vga', unit: 'ft' },
{ id: 'ct-composite', name: 'Composite Video Cable (RCA)', family: 'rca-composite', unit: 'ft' },
{ id: 'ct-xlr', name: 'XLR Cable', family: 'xlr', unit: 'ft' },
{
id: 'ct-xlr-to-trs-quarter',
name: 'XLR to 1/4" TRS Adapter Cable',
family: 'xlr',
family2: 'trs-quarter',
unit: 'ft',
},
{ id: 'ct-trs-quarter', name: '1/4" TRS Cable (Balanced)', family: 'trs-quarter', unit: 'ft' },
{ id: 'ct-ts-quarter', name: '1/4" TS Cable (Instrument/Unbalanced)', family: 'ts-quarter', unit: 'ft' },
{ id: 'ct-trs-mini', name: '3.5mm TRS Cable (Balanced)', family: 'trs-mini', unit: 'ft' },
{ id: 'ct-ts-mini', name: '3.5mm Cable (Unbalanced)', family: 'ts-mini', unit: 'ft' },
{ id: 'ct-rca-audio', name: 'RCA Stereo Audio Cable', family: 'rca-audio', unit: 'ft' },
{ id: 'ct-speakon', name: 'Speakon Cable', family: 'speakon', unit: 'ft' },
{ id: 'ct-speaker-wire', name: 'Speaker Wire (14/2)', family: 'speaker-wire', unit: 'ft' },
{ id: 'ct-toslink', name: 'TOSLINK Optical Cable', family: 'toslink', unit: 'ft' },
{ id: 'ct-cat6', name: 'Cat6 Cable', family: 'cat-rj45', unit: 'ft' },
{ id: 'ct-fiber-lc', name: 'Fiber LC-LC Cable', family: 'fiber-lc', unit: 'ft' },
{ id: 'ct-usb-a', name: 'USB-A to USB-A Cable', family: 'usb-a', unit: 'ft' },
{ id: 'ct-usb-c', name: 'USB-C Cable', family: 'usb-c', unit: 'ft' },
{ id: 'ct-usb-a-host-device', name: 'USB-A Cable (Host ↔ Device)', family: 'usb-a-host', unit: 'ft' },
{ id: 'ct-usb-c-host-device', name: 'USB-C Cable (Host ↔ Device)', family: 'usb-c-host', unit: 'ft' },
{ id: 'ct-usb-a-to-b', name: 'USB-A to USB-B Cable', family: 'usb-a-to-b', unit: 'ft' },
{ id: 'ct-usb-c-to-b', name: 'USB-C to USB-B Cable', family: 'usb-c-to-b', unit: 'ft' },
{ id: 'ct-iec-power', name: 'IEC C13 Power Cable', family: 'iec-power', unit: 'ft' },
{ id: 'ct-nema-power', name: 'NEMA 5-15 Extension Cord', family: 'nema-power', unit: 'ft' },
{ id: 'ct-rs232', name: 'RS-232 Serial Cable', family: 'rs232', unit: 'ft' },
{ id: 'ct-ir', name: 'IR Emitter Cable', family: 'ir', unit: 'ft' },
{ id: 'ct-midi-din', name: 'MIDI Cable (5-Pin DIN)', family: 'midi-din', unit: 'ft' },
{ id: 'ct-midi-trs-a', name: '3.5mm MIDI Cable (Type A)', family: 'midi-trs-a', unit: 'ft' },
{ id: 'ct-midi-trs-b', name: '3.5mm MIDI Cable (Type B)', family: 'midi-trs-b', unit: 'ft' },
]
/** Small starter set of common AV device shapes; users add their own via the editor. */
export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
{
id: 'dt-display',
name: 'Display (TV / Projector)',
category: 'display',
builtIn: true,
ports: [
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p3', name: 'Power In', direction: 'input', portTypeId: 'pt-iec-power' },
],
},
{
id: 'dt-media-player',
name: 'Media Player / Streamer',
category: 'video-source',
builtIn: true,
ports: [
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
{ id: 'p2', name: 'Power In', direction: 'input', portTypeId: 'pt-nema-power' },
],
},
{
id: 'dt-avr',
name: 'AV Receiver',
category: 'amplifier',
builtIn: true,
ports: [
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p3', name: 'HDMI In 3', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p4', name: 'HDMI Out (Display)', direction: 'output', portTypeId: 'pt-hdmi' },
{ id: 'p5', name: 'Speaker Out L', direction: 'output', portTypeId: 'pt-speaker-wire' },
{ id: 'p6', name: 'Speaker Out R', direction: 'output', portTypeId: 'pt-speaker-wire' },
{ id: 'p7', name: 'Power In', direction: 'input', portTypeId: 'pt-iec-power' },
],
},
{
id: 'dt-speaker',
name: 'Speaker (Passive)',
category: 'speaker',
builtIn: true,
ports: [{ id: 'p1', name: 'Speaker In', direction: 'input', portTypeId: 'pt-speaker-wire' }],
},
{
id: 'dt-switch',
name: 'Network Switch',
category: 'network',
builtIn: true,
ports: [
{ id: 'p1', name: 'Port 1', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p2', name: 'Port 2', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p3', name: 'Port 3', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p4', name: 'Port 4', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p5', name: 'Power In', direction: 'input', portTypeId: 'pt-iec-power' },
],
},
{
id: 'dt-camera',
name: 'PTZ Camera',
category: 'camera',
builtIn: true,
ports: [
{ id: 'p1', name: 'SDI Out', direction: 'output', portTypeId: 'pt-sdi' },
{ id: 'p2', name: 'Control (RJ45)', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p3', name: 'Power In', direction: 'input', portTypeId: 'pt-nema-power' },
],
},
{
id: 'dt-computer',
name: 'Computer / Laptop',
category: 'computer',
builtIn: true,
ports: [
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
{ id: 'p2', name: 'USB-C', direction: 'bidirectional', portTypeId: 'pt-usb-c' },
{ id: 'p3', name: 'Ethernet', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
],
},
// Adapters model a real standalone part (a barrel/dongle) as a tiny
// 2-port device, so a connection that genuinely needs two physical
// cables — e.g. 3.5mm MIDI into a DIN adapter, then a DIN cable onward —
// shows up in the BOM as what it actually is: one adapter + two cables,
// each with its own length, instead of being squeezed into one cable type.
{
id: 'dt-adapter-midi-trs-a-to-din',
name: '3.5mm-to-DIN MIDI Adapter (Type A)',
category: 'adapter',
builtIn: true,
ports: [
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-a' },
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
],
},
{
id: 'dt-adapter-midi-trs-b-to-din',
name: '3.5mm-to-DIN MIDI Adapter (Type B)',
category: 'adapter',
builtIn: true,
ports: [
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-b' },
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
],
},
]
+200
View File
@@ -0,0 +1,200 @@
import { v4 as uuid } from 'uuid'
import { BUILT_IN_CABLE_TYPES, BUILT_IN_DEVICE_CATEGORIES, BUILT_IN_DEVICE_TEMPLATES, BUILT_IN_PORT_TYPES } from './library'
import type { CableType, Device, DeviceCategoryDef, DeviceTemplate, Port, PortType, Project } from './types'
/** Merges built-in + project-custom entries. Custom entries with a matching
* id win, so a project could in principle override a built-in (not exposed
* in the UI today, but keeps the merge rule simple and predictable). */
function mergeById<T extends { id: string }>(builtIns: T[], custom: T[]): T[] {
const byId = new Map(builtIns.map((item) => [item.id, item]))
for (const item of custom) byId.set(item.id, item)
return [...byId.values()]
}
export function allPortTypes(project: Project): PortType[] {
return mergeById(BUILT_IN_PORT_TYPES, project.customPortTypes)
}
export function allCableTypes(project: Project): CableType[] {
return mergeById(BUILT_IN_CABLE_TYPES, project.customCableTypes)
}
export function allDeviceTemplates(project: Project): DeviceTemplate[] {
const merged = mergeById(BUILT_IN_DEVICE_TEMPLATES, project.customDeviceTemplates)
const hidden = new Set(project.hiddenBuiltInDeviceTemplateIds)
return merged.filter((t) => !(t.builtIn && hidden.has(t.id)))
}
/** Built-in templates the user has hidden, for a "restore" list — deleting a
* custom template is permanent, but hiding a built-in one isn't, since the
* built-in still exists in code and can always be shown again. */
export function hiddenBuiltInDeviceTemplates(project: Project): DeviceTemplate[] {
const hidden = new Set(project.hiddenBuiltInDeviceTemplateIds)
return BUILT_IN_DEVICE_TEMPLATES.filter((t) => hidden.has(t.id))
}
export function allDeviceCategories(project: Project): DeviceCategoryDef[] {
return mergeById(BUILT_IN_DEVICE_CATEGORIES, project.customDeviceCategories)
}
export function getDeviceCategory(project: Project, categoryId: string): DeviceCategoryDef | undefined {
return allDeviceCategories(project).find((c) => c.id === categoryId)
}
/** Friendly label for a category id, falling back to the raw id if it's
* somehow unknown (e.g. data from an older export) rather than crashing. */
export function deviceCategoryName(project: Project, categoryId: string): string {
return getDeviceCategory(project, categoryId)?.name ?? categoryId
}
export function getPortType(project: Project, portTypeId: string): PortType | undefined {
return allPortTypes(project).find((pt) => pt.id === portTypeId)
}
export function getCableType(project: Project, cableTypeId: string): CableType | undefined {
return allCableTypes(project).find((ct) => ct.id === cableTypeId)
}
/** 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. */
export function acceptedFamilies(portType: PortType): Set<string> {
return new Set([portType.family, ...(portType.compatibleFamilyIds ?? [])])
}
/** A cable's two connector ends as families — `family2` falls back to
* `family` for a plain (non-adapter) cable, so it always has exactly two. */
function cableEnds(cableType: CableType): [string, string] {
return [cableType.family, cableType.family2 ?? cableType.family]
}
/** Does this cable type fit between a port that accepts `sourceFamilies` and
* one that accepts `targetFamilies`? Checked in both orientations since a
* passive cable (including an adapter with different connectors on each
* end) can be plugged in either way around. */
function cableFits(cableType: CableType, sourceFamilies: Set<string>, targetFamilies: Set<string>): boolean {
const [endA, endB] = cableEnds(cableType)
if (sourceFamilies.has(endA) && targetFamilies.has(endB)) return true
if (sourceFamilies.has(endB) && targetFamilies.has(endA)) return true
return false
}
/** Cable types usable with the given port type at (at least) one end — for
* a plain cable that means its family matches; for an adapter cable it's
* enough that either end matches. */
export function cableTypesForPortType(project: Project, portType: PortType): CableType[] {
const families = acceptedFamilies(portType)
return allCableTypes(project).filter((ct) => {
const [endA, endB] = cableEnds(ct)
return families.has(endA) || families.has(endB)
})
}
/**
* Cable types valid for a connection between two specific ports. For a plain
* cable this is the *intersection* of what each end accepts, not the union:
* a "combo" jack (e.g. a patch bay accepting balanced or unbalanced) paired
* with a plain balanced port should only offer balanced cable — the plain
* end still dictates what's electrically correct, even though the combo end
* alone would accept more. An adapter cable (different connector families on
* each end — e.g. XLR-to-TRS) is included whenever its two ends line up with
* the two ports, in either orientation; that's what lets two port types that
* aren't declared generally compatible still be wired with the right cable,
* without changing either port type's own compatibility rules.
*/
export function cableTypesForConnection(
project: Project,
sourcePortType: PortType,
targetPortType: PortType,
): CableType[] {
const sourceFamilies = acceptedFamilies(sourcePortType)
const targetFamilies = acceptedFamilies(targetPortType)
return allCableTypes(project).filter((ct) => cableFits(ct, sourceFamilies, targetFamilies))
}
/** Whether any cable type (built-in or custom) could bridge these two port
* types — used to allow a connection even when the port types aren't
* declared directly compatible, as long as a specific adapter cable exists. */
export function hasBridgingCableType(project: Project, sourcePortType: PortType, targetPortType: PortType): boolean {
return cableTypesForConnection(project, sourcePortType, targetPortType).length > 0
}
/**
* Resolves a set of "also compatible with" picks (port type ids, as checked
* in the connector-type editor) into the family list to store on a new/edited
* port type. Crucially this pulls in each picked type's *full* accepted-family
* set, not just its own bare family — so picking an existing combo/hybrid type
* (like "1/4" TRS/TS (Balanced or Unbalanced)") inherits everything *that*
* type accepts, rather than only matching other ports that are that exact
* combo type. Without this, "compatible with a combo type" wouldn't imply
* "compatible with what the combo type itself accepts", which is the
* confusing gap that makes newly-authored combo connectors fail to mate with
* the plain connectors they were meant to cover.
*/
export function expandCompatibleFamilyIds(selectedPortTypeIds: string[], allTypes: PortType[]): string[] {
const families = new Set<string>()
for (const id of selectedPortTypeIds) {
const portType = allTypes.find((pt) => pt.id === id)
if (!portType) continue
for (const family of acceptedFamilies(portType)) families.add(family)
}
return [...families]
}
export function createEmptyProject(name = 'Untitled Project'): Project {
const now = new Date().toISOString()
return {
id: uuid(),
name,
createdAt: now,
updatedAt: now,
devices: [],
connections: [],
customDeviceTemplates: [],
customPortTypes: [],
customCableTypes: [],
customDeviceCategories: [],
hiddenBuiltInDeviceTemplateIds: [],
}
}
/** Instantiates a device on the canvas from a template, deep-copying ports
* (with fresh ids) so editing the instance never mutates the template. */
export function createDeviceFromTemplate(
template: DeviceTemplate,
position: { x: number; y: number },
): Device {
return {
id: uuid(),
name: template.name,
templateId: template.id,
category: template.category,
manufacturer: template.manufacturer,
model: template.model,
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
position,
}
}
export function createBlankPort(overrides: Partial<Port> = {}): Port {
return {
id: uuid(),
name: overrides.name ?? 'New Port',
direction: overrides.direction ?? 'output',
portTypeId: overrides.portTypeId ?? BUILT_IN_PORT_TYPES[0].id,
}
}
/** "CV Out 1" -> "CV Out 2", "Port 3" -> "Port 4", "Gate In" -> "Gate In 2".
* Only increments a number that's the very last thing in the name (trailing
* whitespace aside), so labels like `1/4" TS In` aren't misread as ending
* in a counter — they just get " 2" appended instead. Used by "clone port". */
export function incrementPortName(name: string): string {
const match = name.match(/^(.*?)(\d+)\s*$/)
if (match) {
const [, prefix, digits] = match
const next = String(Number(digits) + 1).padStart(digits.length, '0')
return `${prefix}${next}`
}
return `${name.trim()} 2`
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Core domain model. Deliberately framework-agnostic (no React, no React Flow
* types) so it can be unit tested and reused if a backend/API layer is added
* later — only the `position` field leaks a canvas concept, kept generic.
*/
export type PortDirection = 'input' | 'output' | 'bidirectional'
export type PortCategory =
| 'video'
| 'audio'
| 'network'
| 'usb'
| 'power'
| 'control'
| 'other'
/** A device category id (references a DeviceCategoryDef). Kept as a plain
* string rather than a fixed union so projects can define their own. */
export type DeviceCategory = string
/** A device grouping shown in the palette and category pickers. Built-ins
* ship in `library.ts`; users can add their own, scoped to the project. */
export interface DeviceCategoryDef {
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
* cross-compatible families), not by PortType identity, so a custom
* "HDMI 2.1" type can still mate with the built-in "HDMI" type.
*/
export interface PortType {
id: string
name: string
category: PortCategory
/** Compatibility key. Two ports can connect if their families match
* (directly or via `compatibleFamilyIds`). */
family: string
/** Extra families this type may also mate with (e.g. Cat5e <-> Cat6). */
compatibleFamilyIds?: string[]
/** How many cables this port can simultaneously accept. Defaults to 1. */
maxConnections?: number
custom?: boolean
}
/** A named connection point on a device or device template. */
export interface Port {
id: string
name: string
direction: PortDirection
portTypeId: string
}
/** A reusable, non-instantiated device definition (shown in the palette). */
export interface DeviceTemplate {
id: string
name: string
category: DeviceCategory
manufacturer?: string
model?: string
ports: Port[]
builtIn?: boolean
}
/** A device placed on the canvas. Ports are copied from the template at
* creation time so editing one instance never mutates the shared template. */
export interface Device {
id: string
name: string
templateId?: string
category: DeviceCategory
manufacturer?: string
model?: string
notes?: string
ports: Port[]
position: { x: number; y: number }
}
/** A physical cable spec (what you'd actually buy). */
export interface CableType {
id: string
name: string
/** Connector family at one end. For a plain cable both ends are this
* family. */
family: string
/** Connector family at the other end, only set for an *adapter* cable with
* a different connector on each end (e.g. XLR-to-1/4"-TRS) — this lets a
* specific cable bridge two port families that aren't declared generally
* compatible, without changing either port type's own compatibility
* rules (which would make that bridge apply to every port of that type,
* not just where this particular cable is actually used). */
family2?: string
unit: 'ft' | 'm'
costPerUnit?: number
custom?: boolean
}
/** A wired connection ("cable") between two ports on two devices. */
export interface Connection {
id: string
sourceDeviceId: string
sourcePortId: string
targetDeviceId: string
targetPortId: string
cableTypeId: string
/** User-entered run length, in the CableType's unit. Optional because
* real-world layout isn't modeled (no floor plan / scale). */
distance?: number
label?: string
notes?: string
}
export interface Project {
id: string
name: string
createdAt: string
updatedAt: string
devices: Device[]
connections: Connection[]
/** User-defined additions to the built-in libraries, scoped to this project. */
customDeviceTemplates: DeviceTemplate[]
customPortTypes: PortType[]
customCableTypes: CableType[]
customDeviceCategories: DeviceCategoryDef[]
/** Built-in device template ids the user has removed from their palette.
* Hidden rather than deleted since built-ins are shared code, not
* per-project data — this lets them be restored later. */
hiddenBuiltInDeviceTemplateIds: string[]
}
+11
View File
@@ -0,0 +1,11 @@
@import 'tailwindcss';
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+361
View File
@@ -0,0 +1,361 @@
import { v4 as uuid } from 'uuid'
import { create } from 'zustand'
import { validateConnection } from '../domain/compatibility'
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project'
import type { CableType, Connection, Device, DeviceCategory, DeviceTemplate, Port, PortType, Project } from '../domain/types'
import { LocalStorageProjectRepository } from '../data/LocalStorageProjectRepository'
import type { ProjectRepository } from '../data/ProjectRepository'
// Swapping to a backend later means constructing a different repository here
// (or injecting one) — nothing else in the store changes.
const repository: ProjectRepository = new LocalStorageProjectRepository()
interface ProjectStoreState {
project: Project
isLoaded: boolean
selectedDeviceId: string | null
selectedConnectionId: string | null
lastConnectionError: string | null
clearConnectionError: () => void
loadFromStorage: () => Promise<void>
newProject: (name?: string) => void
renameProject: (name: string) => void
importProject: (project: Project) => void
selectDevice: (id: string | null) => void
selectConnection: (id: string | null) => void
addDeviceFromTemplate: (templateId: string, position: { x: number; y: number }) => void
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
updateDevice: (id: string, patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes'>>) => void
removeDevice: (id: string) => void
/** Returns the new port's id, so the caller can move keyboard focus to it. */
addPort: (deviceId: string, port: Omit<Port, 'id'>) => string
/** Inserts a copy of the port right after the original (name auto-incremented) and
* returns the new port's id, so the caller can move keyboard focus to it. */
clonePort: (deviceId: string, portId: string) => string
updatePort: (deviceId: string, portId: string, patch: Partial<Omit<Port, 'id'>>) => void
removePort: (deviceId: string, portId: string) => void
addConnection: (params: {
sourceDeviceId: string
sourcePortId: string
targetDeviceId: string
targetPortId: string
cableTypeId: string
}) => boolean
updateConnection: (id: string, patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'label' | 'notes'>>) => void
removeConnection: (id: string) => void
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'builtIn'>) => void
updateCustomDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'builtIn'>>) => void
removeCustomDeviceTemplate: (id: string) => void
hideBuiltInDeviceTemplate: (id: string) => void
restoreBuiltInDeviceTemplate: (id: string) => void
addCustomPortType: (portType: Omit<PortType, 'id' | 'custom'>) => void
updateCustomPortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => void
addCustomCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => void
updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => void
/** Returns the new category's id, so the caller can select it immediately. */
addCustomDeviceCategory: (name: string) => string
}
function touch(project: Project): Project {
return { ...project, updatedAt: new Date().toISOString() }
}
export const useProjectStore = create<ProjectStoreState>((set, get) => ({
project: createEmptyProject(),
isLoaded: false,
selectedDeviceId: null,
selectedConnectionId: null,
lastConnectionError: null,
clearConnectionError: () => set({ lastConnectionError: null }),
loadFromStorage: async () => {
const loaded = await repository.load()
set({ project: loaded ?? createEmptyProject(), isLoaded: true })
},
newProject: (name = 'Untitled Project') => {
set({ project: createEmptyProject(name), selectedDeviceId: null, selectedConnectionId: null })
},
renameProject: (name) => {
set((state) => ({ project: touch({ ...state.project, name }) }))
},
importProject: (project) => {
set({ project: touch(project), selectedDeviceId: null, selectedConnectionId: null })
},
selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }),
selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }),
addDeviceFromTemplate: (templateId, position) => {
const { project } = get()
const template = allDeviceTemplates(project).find((t) => t.id === templateId)
if (!template) return
const device = createDeviceFromTemplate(template, position)
set({ project: touch({ ...project, devices: [...project.devices, device] }), selectedDeviceId: device.id })
},
updateDevicePosition: (id, position) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) => (d.id === id ? { ...d, position } : d)),
}),
}))
},
updateDevice: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
}),
}))
},
removeDevice: (id) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.filter((d) => d.id !== id),
connections: state.project.connections.filter(
(c) => c.sourceDeviceId !== id && c.targetDeviceId !== id,
),
}),
selectedDeviceId: state.selectedDeviceId === id ? null : state.selectedDeviceId,
}))
},
addPort: (deviceId, port) => {
const newId = uuid()
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) =>
d.id === deviceId ? { ...d, ports: [...d.ports, { ...port, id: newId }] } : d,
),
}),
}))
return newId
},
updatePort: (deviceId, portId, patch) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) =>
d.id === deviceId
? { ...d, ports: d.ports.map((p) => (p.id === portId ? { ...p, ...patch } : p)) }
: d,
),
}),
}))
},
clonePort: (deviceId, portId) => {
const newId = uuid()
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) => {
if (d.id !== deviceId) return d
const index = d.ports.findIndex((p) => p.id === portId)
if (index === -1) return d
const source = d.ports[index]
const clone: Port = { ...source, id: newId, name: incrementPortName(source.name) }
return { ...d, ports: [...d.ports.slice(0, index + 1), clone, ...d.ports.slice(index + 1)] }
}),
}),
}))
return newId
},
removePort: (deviceId, portId) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) =>
d.id === deviceId ? { ...d, ports: d.ports.filter((p) => p.id !== portId) } : d,
),
connections: state.project.connections.filter(
(c) => c.sourcePortId !== portId && c.targetPortId !== portId,
),
}),
}))
},
addConnection: ({ sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => {
const { project } = get()
const validation = validateConnection(project, {
sourceDeviceId,
sourcePortId,
targetDeviceId,
targetPortId,
})
if (!validation.valid) {
set({ lastConnectionError: validation.reason ?? 'Invalid connection.' })
return false
}
const connection: Connection = {
id: uuid(),
sourceDeviceId,
sourcePortId,
targetDeviceId,
targetPortId,
cableTypeId,
}
set({
project: touch({ ...project, connections: [...project.connections, connection] }),
lastConnectionError: null,
selectedConnectionId: connection.id,
selectedDeviceId: null,
})
return true
},
updateConnection: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
connections: state.project.connections.map((c) => (c.id === id ? { ...c, ...patch } : c)),
}),
}))
},
removeConnection: (id) => {
set((state) => ({
project: touch({
...state.project,
connections: state.project.connections.filter((c) => c.id !== id),
}),
selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId,
}))
},
addCustomDeviceTemplate: (template) => {
set((state) => ({
project: touch({
...state.project,
customDeviceTemplates: [
...state.project.customDeviceTemplates,
{ ...template, id: uuid() },
],
}),
}))
},
updateCustomDeviceTemplate: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
customDeviceTemplates: state.project.customDeviceTemplates.map((t) =>
t.id === id ? { ...t, ...patch } : t,
),
}),
}))
},
removeCustomDeviceTemplate: (id) => {
set((state) => ({
project: touch({
...state.project,
customDeviceTemplates: state.project.customDeviceTemplates.filter((t) => t.id !== id),
}),
}))
},
hideBuiltInDeviceTemplate: (id) => {
set((state) => ({
project: touch({
...state.project,
hiddenBuiltInDeviceTemplateIds: state.project.hiddenBuiltInDeviceTemplateIds.includes(id)
? state.project.hiddenBuiltInDeviceTemplateIds
: [...state.project.hiddenBuiltInDeviceTemplateIds, id],
}),
}))
},
restoreBuiltInDeviceTemplate: (id) => {
set((state) => ({
project: touch({
...state.project,
hiddenBuiltInDeviceTemplateIds: state.project.hiddenBuiltInDeviceTemplateIds.filter((h) => h !== id),
}),
}))
},
addCustomPortType: (portType) => {
set((state) => ({
project: touch({
...state.project,
customPortTypes: [...state.project.customPortTypes, { ...portType, id: uuid(), custom: true }],
}),
}))
},
updateCustomPortType: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
customPortTypes: state.project.customPortTypes.map((pt) => (pt.id === id ? { ...pt, ...patch } : pt)),
}),
}))
},
addCustomCableType: (cableType) => {
set((state) => ({
project: touch({
...state.project,
customCableTypes: [...state.project.customCableTypes, { ...cableType, id: uuid(), custom: true }],
}),
}))
},
updateCustomCableType: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
customCableTypes: state.project.customCableTypes.map((ct) => (ct.id === id ? { ...ct, ...patch } : ct)),
}),
}))
},
addCustomDeviceCategory: (name) => {
const newId = uuid()
set((state) => ({
project: touch({
...state.project,
customDeviceCategories: [
...state.project.customDeviceCategories,
{ id: newId, name, custom: true },
],
}),
}))
return newId
},
}))
// Debounced autosave: fires whenever `project` changes (skipped until the
// initial load completes, so we don't immediately overwrite storage with a
// blank project on first mount).
let saveTimeout: ReturnType<typeof setTimeout> | undefined
useProjectStore.subscribe((state, prevState) => {
// Skip the isLoaded:false -> true transition itself (that's the initial
// load, not a user edit) and any tick where `project` didn't change.
if (!prevState.isLoaded) return
if (state.project === prevState.project) return
if (saveTimeout) clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
repository.save(state.project).catch((err) => console.error('Autosave failed', err))
}, 400)
})
export type { DeviceCategory }