feat(ui): type-aware device creation + port rendering

Modal-driven +Dev (replaces the v3 inline namer):
- Tool armed → click on canvas captures the click position + frame_id
  from frameAt(p), then opens a #modal-new-device dialog.
- Dialog has a <select> grouped by `kind` for built-ins, then
  project-custom rows, then "Custom (no type)" at the bottom.
- Default selection is the first built-in (NAS). Name input is
  auto-pre-filled to <type-name>, bumping to <type-name>-N if a name
  collision is detected in the current device list.
- Submit POSTs name + type_id + x/y/w/h + frame_id. Server seeds the
  ports in the same transaction; we re-snapshot to pick them up.

Canvas:
- After each device's <rect> + label, render the device's ports as
  white-filled <circle>s with stroke = the port's cable_type colour.
- Position: (device.x + port.x_offset, device.y + port.y_offset). The
  seeder's "evenly along the edge" layout means ports already sit on
  the device's bottom edge by default and follow the device on drag
  (because they re-render from the same x/y on every renderCanvas).
- Ports themselves are `pointer-events: none` for slice 4 — selection
  remains device-level. Per-port click semantics ship in slice 7
  (manual cable draw).

Inspector device pane:
- New "type" row showing the type name + a "(custom)" badge for
  project-custom types, or "Custom (no type)" for freeform.
- New "Ports" section with one row per seeded port: cable-type-colour
  swatch, label, "unconnected" placeholder. Label falls back to the
  cable type's name when the seeded label_prefix was blank.

State + snapshot:
- state.ports populated from snap.ports; cleared on project switch /
  404.
- state.deviceTypes hydrated from GET /api/projects/:pid/device-types
  after the snapshot loads. Failure of that fetch is non-fatal — the
  +Dev modal just shows "Custom (no type)" only.
- Delete-device cleans up its ports from state.ports too (server-side
  CASCADE already handles persistence).
This commit is contained in:
mAi
2026-05-16 00:31:55 +02:00
parent 0a34dce398
commit 7f0b6e4fab
3 changed files with 226 additions and 13 deletions

View File

@@ -113,6 +113,28 @@
</form>
</dialog>
<!-- New device (slice 4: type-aware) -->
<dialog id="modal-new-device" class="modal" aria-labelledby="nd-title">
<form method="dialog" id="form-new-device">
<h2 id="nd-title">New device</h2>
<label class="field">
<span>Type</span>
<select id="nd-type" name="type_id" required>
<option value="">Loading…</option>
</select>
</label>
<label class="field">
<span>Name</span>
<input type="text" name="name" id="nd-name" required autocomplete="off" />
</label>
<p class="form-error" id="nd-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Create</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Delete Project confirm -->
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
<form method="dialog" id="form-delete-project">

View File

@@ -12,10 +12,19 @@
* @typedef {{ id: number, project_id: number, name: string,
* x: number, y: number, width: number, height: number }} Frame
* @typedef {{ id: number, project_id: number, frame_id: number|null,
* name: string, color: string,
* type_id: number|null, name: string, color: string,
* x: number, y: number, width: number, height: number }} Device
* @typedef {{ id: number, project_id: number, frame_id: number|null,
* label: string, x: number, y: number }} IOMarker
* @typedef {{ id: number, project_id: number, device_id: number,
* type_id: number, label: string|null,
* x_offset: number, y_offset: number }} Port
* @typedef {{ id: number, device_type_id: number, cable_type_id: number,
* label_prefix: string, count: number, edge: string,
* sort_order: number }} DeviceTypePort
* @typedef {{ id: number, project_id: number|null, name: string,
* kind: string, icon: string|null, description: string,
* built_in: boolean, ports: DeviceTypePort[] }} DeviceType
*/
const API = "/api";
@@ -25,9 +34,11 @@ const IO_SIZE = 30; // diamond bounding-box side (the rotated rect's width/heigh
const state = {
/** @type {Project[]} */ projects: [],
/** @type {CableType[]} */ cableTypes: [],
/** @type {DeviceType[]} */ deviceTypes: [],
/** @type {Project | null} */ active: null,
/** @type {Frame[]} */ frames: [],
/** @type {Device[]} */ devices: [],
/** @type {Port[]} */ ports: [],
/** @type {IOMarker[]} */ ioMarkers: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | null */
@@ -78,6 +89,8 @@ const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers
const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body);
const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`);
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
@@ -229,6 +242,14 @@ function renderCanvas() {
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
}
const portsByDevice = new Map();
for (const prt of state.ports) {
const arr = portsByDevice.get(prt.device_id) || [];
arr.push(prt);
portsByDevice.set(prt.device_id, arr);
}
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
for (const d of state.devices) {
const g = svgEl("g", { "data-device-id": d.id });
const rect = svgEl("rect", {
@@ -246,6 +267,23 @@ function renderCanvas() {
});
label.textContent = d.name;
g.append(rect, label);
// Render ports as small circles at (device.x + x_offset, device.y + y_offset).
// Stroke colour = the cable_type colour the port carries; fill stays white
// so the port reads against any device colour.
const ports = portsByDevice.get(d.id) || [];
for (const prt of ports) {
const cx = d.x + prt.x_offset;
const cy = d.y + prt.y_offset;
const color = cableTypeColor.get(prt.type_id) || "#888";
const c = svgEl("circle", {
cx, cy, r: 5,
class: "port-circle",
stroke: color,
});
g.append(c);
}
gDevices.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
}
@@ -346,6 +384,20 @@ function renderInspectorDevice(body, id) {
const d = state.devices.find((x) => x.id === id);
if (!d) { body.innerHTML = ""; return; }
const frame = d.frame_id ? state.frames.find((f) => f.id === d.frame_id) : null;
const type = d.type_id ? state.deviceTypes.find((t) => t.id === d.type_id) : null;
const ports = state.ports.filter((p) => p.device_id === d.id);
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
const portsHtml = ports.length
? ports.map((p) => `
<div class="port-row">
<span class="swatch" style="background:${cableTypeColor.get(p.type_id) || "#888"}"></span>
<span class="label">${(p.label ?? cableTypeName.get(p.type_id) ?? "Port")}</span>
<span class="conn">unconnected</span>
</div>`).join("")
: `<p class="muted" style="font-size:12px">No ports yet.</p>`;
body.innerHTML = `
<p class="section-title">Device</p>
<label class="field">
@@ -357,18 +409,24 @@ function renderInspectorDevice(body, id) {
<input type="color" class="inline-input" id="dev-color" />
</label>
<dl>
<dt>type</dt><dd id="dev-type"></dd>
<dt>x</dt><dd id="dev-x"></dd>
<dt>y</dt><dd id="dev-y"></dd>
<dt>w</dt><dd id="dev-w"></dd>
<dt>h</dt><dd id="dev-h"></dd>
<dt>frame</dt><dd id="dev-frame"></dd>
</dl>
<p class="section-title">Ports</p>
<div id="dev-ports">${portsHtml}</div>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
</div>
`;
body.querySelector("#dev-name").value = d.name;
body.querySelector("#dev-color").value = d.color;
body.querySelector("#dev-type").textContent = type
? `${type.name}${type.built_in ? "" : " (custom)"}`
: "Custom (no type)";
body.querySelector("#dev-x").textContent = d.x.toFixed(0);
body.querySelector("#dev-y").textContent = d.y.toFixed(0);
body.querySelector("#dev-w").textContent = d.width.toFixed(0);
@@ -401,6 +459,7 @@ function renderInspectorDevice(body, id) {
if (!confirm(`Delete device "${d.name}"?`)) return;
deleteDevice(state.active.id, d.id).then(() => {
state.devices = state.devices.filter((x) => x.id !== d.id);
state.ports = state.ports.filter((p) => p.device_id !== d.id);
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
@@ -555,6 +614,7 @@ async function activateProject(id) {
state.active = null;
state.frames = [];
state.devices = [];
state.ports = [];
state.ioMarkers = [];
state.selection = null;
setActiveInURL(null);
@@ -567,15 +627,27 @@ async function activateProject(id) {
state.frames = snap.frames || [];
state.devices = snap.devices || [];
state.ioMarkers = snap.io_markers || [];
state.ports = snap.ports || [];
state.cableTypes = snap.cable_types || [];
state.selection = null;
setActiveInURL(id);
// Hydrate the device-type catalog for this project — used by the
// +Dev modal's dropdown + the device inspector's "Type" row. Done in
// parallel after snapshot loads (small response, doesn't gate render).
try {
state.deviceTypes = await listDeviceTypesForProject(id) || [];
} catch (_) {
// Don't fail the whole load if catalog fetch fails — the +Dev
// modal can show a degraded "Custom only" mode.
state.deviceTypes = [];
}
render();
} catch (err) {
if (err.status === 404) {
state.active = null;
state.frames = [];
state.devices = [];
state.ports = [];
state.ioMarkers = [];
setActiveInURL(null);
render();
@@ -719,20 +791,110 @@ async function placeDeviceAt(p) {
const W = 100, H = 35;
const x = p.x - W / 2;
const y = p.y - H / 2;
const name = await promptInline("Device name", p.x, p.y);
if (!name || !state.active) return;
const frame = frameAt(p.x, p.y);
try {
const d = await createDevice(state.active.id, {
name, x, y, width: W, height: H,
frame_id: frame ? frame.id : undefined,
});
state.devices.push(d);
state.selection = { kind: "device", id: d.id };
render();
} catch (err) {
alert(`Create device failed: ${err.message}`);
// Modal-driven flow (v4 slice 4): pick type + name in one form. Click
// position is captured here and POSTed on submit.
openNewDeviceModal({ x, y, width: W, height: H, frame_id: frame?.id ?? null });
}
function nextNameFor(typeName) {
// Auto-pick a name like "PC" / "PC-2" / "PC-3" against current devices.
const taken = new Set(state.devices.map((d) => d.name));
if (!taken.has(typeName)) return typeName;
for (let i = 2; i < 1000; i++) {
const candidate = `${typeName}-${i}`;
if (!taken.has(candidate)) return candidate;
}
return typeName;
}
function openNewDeviceModal(geom) {
if (!state.active) return;
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-device"));
const form = /** @type {HTMLFormElement} */ ($("#form-new-device"));
const sel = /** @type {HTMLSelectElement} */ ($("#nd-type"));
const nameInput = /** @type {HTMLInputElement} */ ($("#nd-name"));
const err = $("#nd-error");
showError(err, "");
form.reset();
// Build the dropdown: <optgroup label="kind"> for built-ins grouped by
// their `kind`, then project-custom, then a "Custom (no type)" option.
sel.innerHTML = "";
const builtIns = state.deviceTypes.filter((t) => t.built_in);
const customs = state.deviceTypes.filter((t) => !t.built_in);
const byKind = new Map();
for (const t of builtIns) {
const k = t.kind || "generic";
const arr = byKind.get(k) || [];
arr.push(t);
byKind.set(k, arr);
}
for (const [kind, arr] of byKind) {
const og = document.createElement("optgroup");
og.label = kind;
for (const t of arr) {
const opt = new Option(t.name, String(t.id));
og.append(opt);
}
sel.append(og);
}
if (customs.length) {
const og = document.createElement("optgroup");
og.label = "custom";
for (const t of customs) {
og.append(new Option(t.name, String(t.id)));
}
sel.append(og);
}
const customOpt = new Option("Custom (no type)", "");
sel.append(customOpt);
// Default to the first built-in (NAS in m's catalog) so m sees a
// sensible first option. Auto-fill the name to match.
sel.value = builtIns[0] ? String(builtIns[0].id) : "";
syncNameToType();
sel.onchange = syncNameToType;
function syncNameToType() {
const idStr = sel.value;
if (!idStr) { nameInput.value = ""; return; }
const t = state.deviceTypes.find((x) => String(x.id) === idStr);
if (!t) return;
nameInput.value = nextNameFor(t.name);
}
dlg.showModal();
nameInput.focus();
nameInput.select();
form.onsubmit = async (e) => {
e.preventDefault();
const name = nameInput.value.trim();
if (!name) { showError(err, "Name is required"); return; }
const idStr = sel.value;
const body = {
name,
x: geom.x, y: geom.y, width: geom.width, height: geom.height,
};
if (geom.frame_id != null) body.frame_id = geom.frame_id;
if (idStr) body.type_id = Number(idStr);
try {
const d = await createDevice(state.active.id, body);
state.devices.push(d);
// Re-fetch ports for the project — the server seeded them in the
// same transaction, so they're already in the DB.
const snap = await getSnapshot(state.active.id);
state.ports = snap.ports || [];
state.selection = { kind: "device", id: d.id };
dlg.close();
render();
} catch (e) {
showError(err, e.message || "Create failed");
}
};
}
async function placeIOMarkerAt(p) {
@@ -1053,6 +1215,7 @@ async function boot() {
bindCloseButtons($("#modal-new-project"));
bindCloseButtons($("#modal-cable-type"));
bindCloseButtons($("#modal-delete-project"));
bindCloseButtons($("#modal-new-device"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));

View File

@@ -238,6 +238,34 @@ body {
user-select: none;
}
/* Ports — small circles laid out along the device edge. The fill is
white so the port is visible regardless of the underlying device's
stroke; the stroke colour comes from the cable_type the port carries
(set inline in JS). */
.port-circle {
fill: #fff;
stroke: var(--text);
stroke-width: 2;
pointer-events: none; /* slice 4 — selection happens at device-level */
}
.port-row {
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 2px 0;
}
.port-row .swatch {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
}
.port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; }
.rubber-band {
fill: rgba(25, 113, 194, 0.08);
stroke: var(--accent);