Files
av-planner/src/components/inspector/DeviceInspector.tsx
T
aarbitandClaude Sonnet 5 7e37dc5676 Add mobile view-only support
Below md width, the app now forces view-only (same canEdit mechanism as a
shared view-only diagram) rather than trying to support touch editing:
palette and inspector panels hide, editing controls disable, and the top
bar collapses to just Diagrams + Account. A bottom tab bar swaps between a
full-screen canvas and a full-screen Bill of Materials, since there's no
room for both side by side. Canvas gets a few mobile-specific trims too:
MiniMap and the interactivity lock button hide whenever canEdit is false
(the lock button doesn't actually grant editing either way — nodesDraggable/
nodesConnectable already override it — so leaving it visible would just be
misleading), and h-screen is replaced with h-dvh throughout so the layout
doesn't get clipped by a mobile browser's collapsing address bar.

Also fixes quick-add's anti-stack offset (24px — far smaller than a device
node) to actually tile devices apart instead of leaving them nearly
overlapping, and logs a deferred "Auto-arrange" layout feature in
organized-ideas.md, surfaced by mobile visitors having no way to fix an
overlapping diagram themselves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 13:32:27 -05:00

195 lines
8.0 KiB
TypeScript

import { useMemo, useState } from 'react'
import { PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes } from '../../domain/diagram'
import type { PortDirection } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useCanEdit } from '../../hooks/useCanEdit'
import CategorySelect from '../common/CategorySelect'
import ManufacturerSelect from '../common/ManufacturerSelect'
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog)
const canEdit = useCanEdit()
const updateDevice = useDiagramStore((s) => s.updateDevice)
const removeDevice = useDiagramStore((s) => s.removeDevice)
const addPort = useDiagramStore((s) => s.addPort)
const clonePort = useDiagramStore((s) => s.clonePort)
const updatePort = useDiagramStore((s) => s.updatePort)
const removePort = useDiagramStore((s) => s.removePort)
const [focusPortId, setFocusPortId] = useState<string | null>(null)
const device = diagram.devices.find((d) => d.id === deviceId)
const portTypes = useMemo(
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
if (!device) return null
const connectionCountForPort = (portId: string) =>
diagram.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length
return (
<fieldset disabled={!canEdit} className="m-0 space-y-3 border-0 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
<ManufacturerSelect
value={device.manufacturerId}
onChange={(manufacturerId) => updateDevice(device.id, { manufacturerId })}
/>
</label>
<label className="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="text-xs font-medium text-slate-600">
Cost
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-slate-500">$</span>
<input
type="number"
min={0}
step={0.01}
value={device.cost ?? ''}
onChange={(e) =>
updateDevice(device.id, { cost: 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. 249.00"
/>
</div>
</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 flex-wrap 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>
{/* Grouped so these three wrap to their own line as a unit
(aligned to the row's end) rather than each one reflowing
separately if the row ever gets tighter than it can fit. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
onClick={() => updatePort(device.id, port.id, { builtInCable: !port.builtInCable })}
tabIndex={-1}
title="Built-in cable"
className={`shrink-0 rounded px-1 ${
port.builtInCable ? 'bg-amber-100 opacity-100 ring-1 ring-amber-300' : 'opacity-30 hover:opacity-60'
}`}
>
🔌
</button>
<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>
)
})}
</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>
</fieldset>
)
}