Adds first version of the app. Local storage only.

This commit is contained in:
2026-09-01 14:44:49 -05:00
commit 9710f1ee5a
44 changed files with 5865 additions and 0 deletions
@@ -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>
)
}