Adds first version of the app. Local storage only.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user