diff --git a/web/static/index.html b/web/static/index.html index c6ceb42..a2b9c44 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -113,6 +113,28 @@ + + + + New device + + Type + + Loading… + + + + Name + + + + + Create + Cancel + + + + diff --git a/web/static/main.js b/web/static/main.js index 8f3eb91..e7d8d2a 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -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) => ` + + + ${(p.label ?? cableTypeName.get(p.type_id) ?? "Port")} + unconnected + `).join("") + : `No ports yet.`; + body.innerHTML = ` Device @@ -357,18 +409,24 @@ function renderInspectorDevice(body, id) { + type x y w h frame + Ports + ${portsHtml} Delete device `; 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: 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)); diff --git a/web/static/style.css b/web/static/style.css index 4e20ad0..171d035 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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);
No ports yet.
Device
Ports