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