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