Adds first version of the app. Local storage only.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Normalize line endings: store everything as LF in the repo, let git
|
||||
# check out text files as LF too (so this matches on Windows/Mac/Linux
|
||||
# alike and stops "LF will be replaced by CRLF" warnings/noisy diffs).
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows-only scripts should keep CRLF regardless.
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Don't touch binary files.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Env files (matters once a backend/API is added — see README's repository
|
||||
# pattern for where that would plug in)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Claude Code local settings (machine-specific, not shared)
|
||||
.claude/settings.local.json
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# AV Planner
|
||||
|
||||
Plan out AV/network installs: define devices and their ports, wire them together
|
||||
on a canvas, and get a bill of materials (devices + cables, with lengths) for
|
||||
what you'll need to buy.
|
||||
|
||||
## Stack
|
||||
|
||||
- **React 19 + TypeScript + Vite**
|
||||
- **[@xyflow/react](https://reactflow.dev)** (React Flow) for the device/wiring canvas
|
||||
- **Zustand** for app state
|
||||
- **Tailwind CSS v4** for styling
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
domain/ Framework-agnostic types + logic (compatibility rules, BOM math,
|
||||
the built-in connector/cable/device library). No React, no
|
||||
React Flow — safe to unit test or reuse from a future backend.
|
||||
data/ Storage abstraction. `ProjectRepository` is the interface the
|
||||
rest of the app codes against; `LocalStorageProjectRepository`
|
||||
is the only implementation today. Swapping in a real backend
|
||||
later (REST/GraphQL) means adding one new implementation of
|
||||
that interface, not touching state/UI code.
|
||||
state/ Zustand store (`projectStore`) — the single source of truth for
|
||||
the current project. Auto-saves to the repository (debounced)
|
||||
on every change.
|
||||
components/
|
||||
canvas/ React Flow wiring surface: custom device node, custom cable
|
||||
edge, connection validation.
|
||||
palette/ Device library sidebar + the "new custom device" editor.
|
||||
inspector/ Right-panel editors for the selected device or cable.
|
||||
bom/ Bill-of-materials view.
|
||||
layout/ App shell, top bar, panels.
|
||||
```
|
||||
|
||||
### Data model
|
||||
|
||||
- **PortType** — a connector kind (HDMI, XLR, Cat5e/6, ...). Two ports can be
|
||||
wired together only if their PortTypes share a compatibility `family`.
|
||||
- **DeviceTemplate** — a reusable device shape (its ports), shown in the
|
||||
palette. Built-ins live in `domain/library.ts`; custom ones are saved on the
|
||||
project.
|
||||
- **Device** — an instance placed on the canvas, with its own copy of ports
|
||||
(editing an instance never mutates its template).
|
||||
- **CableType** — a physical cable spec, scoped to a PortType family.
|
||||
- **Connection** — a wire between two ports, referencing a CableType and an
|
||||
optional user-entered distance (there's no floor plan / scale model, so
|
||||
length is a manual estimate per connection).
|
||||
- **Project** — devices + connections + any custom library entries. This is
|
||||
the one object persisted (auto-saved to `localStorage`, or explicitly
|
||||
exported/imported as JSON).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # start the dev server
|
||||
npm run build # typecheck + production build
|
||||
npm run lint # oxlint
|
||||
```
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AV Planner</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2312
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "av-planner",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.11.5",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"uuid": "^14.0.2",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,5 @@
|
||||
import AppShell from './components/layout/AppShell'
|
||||
|
||||
export default function App() {
|
||||
return <AppShell />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
Reference in New Issue
Block a user