Compare commits

..

4 Commits

Author SHA1 Message Date
mAi
6cd5925f4c feat(ui): admin modal — projects + cable types + device types + templates
Header gear ('⚙ Admin') opens a wide modal with four tabs:

- **Projects** — list, rename, edit drawing_name + description, delete
  with typed-name confirm. Wires the existing PATCH /projects/:id and
  DELETE /projects/:id?confirm=<name> endpoints; renaming was previously
  only reachable via the API.
- **Cable types** — full CRUD with the global-scope banner. Mirrors the
  legend's quick edit but in a tabular list, plus an inline "+ Add"
  form at the bottom.
- **Device types** — built-ins listed read-only with a locked badge
  showing kind, description, and port profile (each port row tinted
  with the cable_type's colour). Project-custom types under the active
  project get editable name / kind / icon / description + Delete.
  Port-profile editing on custom types is still deferred (port-profile
  reshape will land in a follow-up).
- **Setup templates** — read-only list of built-ins with member devices
  and connection requirements expanded under each.

The modal re-fetches projects / cable types / setup templates on open
so it reflects current state regardless of what m did via inspector
panes while it was closed.

Files:
- index.html: ⚙ Admin button + #modal-admin dialog scaffold.
- main.js: patchProject + createDeviceType/patchDeviceType/deleteDeviceType
  API helpers; openAdminModal + switchAdminTab + 4 render functions.
- style.css: .admin-shell / .admin-tabs / .admin-row + state classes.
2026-05-16 11:51:05 +02:00
mAi
9773063008 merge: port editor in sidebar — type + edge + name; +Port retired
Port inspector now has a Type dropdown (PATCH /ports/:id with
type_id), keeps edge picker + label input + delete + back-link.

Replaces the canvas-armed +Port tool with a sidebar 'Add port' form
(reached via +Port button in the device inspector). Form fields:
Type, Edge, Label with auto-default '<type> <next-index>' that stops
auto-updating once m hand-edits. Submit → POST → relayout edge for
even spacing → selection switches to the new port's editor.

Port rows in the device inspector's list now click-to-select.

Removed scaffolding: tool === 'port' branch, armPortTool,
placePortAt, snapToDeviceEdge, .tool-port cursor CSS.
2026-05-16 11:45:25 +02:00
mAi
61bc1dcf43 feat(ui): port editor + add-port form in the sidebar inspector
m: 'Add port' should be a sidebar form, not a two-step canvas gesture.

- Port inspector gains a Type dropdown (read /api/cable-types via
  state.cableTypes, PATCH /ports/:id with type_id). Edge picker + label
  + delete from prior shift are unchanged.
- New "Add port" form rendered from selection.kind === "port_new":
  Type / Edge / Label, Create + Cancel buttons. Default label is the
  next free index for the chosen type on this device ("HDMI 3" if two
  HDMIs already live there). Recomputes when m changes the type, but
  stops recomputing as soon as m hand-edits the label.
- +Port in the device inspector now flips selection to port_new,
  rendering the form. Submit → POST → switch to the new port's editor.
  No second canvas click required.
- Clicking a port row in the device inspector's port list selects that
  port and opens its editor (same surface as canvas-click).
- "← <device name>" back-link in both port editor and add-port form
  jumps back to the device inspector.

Removed: state.tool === "port" branch, armPortTool helper, placePortAt
function, .tool-port CSS, state.portToolDevice / portToolTypeID. The
canvas-armed +Port tool was the user-trip-wire perseus flagged; the
sidebar form replaces it entirely.

snapToDeviceEdge also removed — placePortAt was its only caller; the
edgeCentre + portEdge + relayoutEdge trio fully owns port placement
now.

Port rows in the device inspector get a hover background + pointer
cursor to read as clickable.
2026-05-16 11:40:45 +02:00
mAi
056777f1c1 merge: template-apply creates frame + grid-places devices inside
ApplyTemplate now creates a frame named after the template
('Living Room' etc, suffixed on collision), computes a uniform grid
(cols=min(ceil(sqrt(N)),4), rows=ceil(N/cols)), and places each
device inside the frame with frame_id set.

Frontend unchanged — activateProject re-hydrates the snapshot
including the new frame.

Tests cover frame creation + in-frame placement + name-collision
suffix. Verified on mDock: Living Room template → frame (200,200,
294×200) with TV/Soundbar at row 0 and ChromeCast wrapping to row 1.
2026-05-16 11:35:25 +02:00
3 changed files with 633 additions and 81 deletions

View File

@@ -23,6 +23,7 @@
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
<button type="button" id="btn-export" class="btn">Export</button>
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
<span id="toast" class="toast" hidden></span>
</header>
@@ -224,6 +225,23 @@
</form>
</dialog>
<!-- Admin: projects + cable types + device types + setup templates -->
<dialog id="modal-admin" class="modal modal-wide" aria-labelledby="adm-title">
<div class="admin-shell">
<header class="admin-header">
<h2 id="adm-title">Admin</h2>
<button type="button" class="btn btn-link admin-close" data-close></button>
</header>
<nav class="admin-tabs" role="tablist">
<button type="button" class="admin-tab" data-admin-tab="projects" role="tab" aria-selected="true">Projects</button>
<button type="button" class="admin-tab" data-admin-tab="cable-types" role="tab">Cable types</button>
<button type="button" class="admin-tab" data-admin-tab="device-types" role="tab">Device types</button>
<button type="button" class="admin-tab" data-admin-tab="setup-templates" role="tab">Setup templates</button>
</nav>
<section class="admin-body" id="admin-body" role="tabpanel"></section>
</div>
</dialog>
<script type="module" src="/main.js"></script>
</body>
</html>

View File

@@ -56,14 +56,11 @@ const state = {
/** @type {Bundle[]} */ bundles: [],
/** @type {SetupTemplate[]} */ setupTemplates: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | "port" | "cable" | null */
/** "frame" | "device" | "io" | "req" | "cable" | null */
tool: /** @type {string|null} */ (null),
/** Slice-7 transient state for the +Port tool. */
portToolDevice: /** @type {number|null} */ (null),
portToolTypeID: /** @type {number|null} */ (null),
/** Slice-7: when the user clicked a source port, this is its id. */
cableDrawFromPortID: /** @type {number|null} */ (null),
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | null} */ selection: null,
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -88,6 +85,7 @@ async function api(method, path, body) {
const listProjects = () => api("GET", "/projects");
const createProject = (body) => api("POST", "/projects", body);
const patchProject = (id, body) => api("PATCH", `/projects/${id}`, body);
const deleteProject = (id, confirm) =>
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
const getSnapshot = (id) => api("GET", `/projects/${id}`);
@@ -116,6 +114,9 @@ const deletePort = (pid, id) => api("DELETE", `/projects/${pid}/ports/${id}`);
const createCableAPI = (pid, body) => api("POST", `/projects/${pid}/cables`, body);
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
const createDeviceType = (pid, body) => api("POST", `/projects/${pid}/device-types`, body);
const patchDeviceType = (pid, id, body) => api("PATCH", `/projects/${pid}/device-types/${id}`, body);
const deleteDeviceType = (pid, id) => api("DELETE", `/projects/${pid}/device-types/${id}`);
const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connection-requirements`, body);
const patchRequirement = (pid, id, body) => api("PATCH", `/projects/${pid}/connection-requirements/${id}`, body);
@@ -441,6 +442,7 @@ function renderInspector() {
case "requirement": return renderInspectorRequirement(body, state.selection.id);
case "cable": return renderInspectorCable(body, state.selection.id);
case "port": return renderInspectorPort(body, state.selection.id);
case "port_new": return renderInspectorPortNew(body, state.selection.device_id);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
}
}
@@ -727,13 +729,24 @@ function renderInspectorDevice(body, id) {
});
});
// +Port — arms the port-placement gesture. Active cable type comes
// from the legend selection; if none, defaults to the first cable_type.
// +Port — switch the inspector to the new-port form. m fills in
// type + edge + label and clicks Create; no canvas click required.
body.querySelector("#dev-add-port").addEventListener("click", () => {
if (!state.active) return;
const typeID = state.activeTypeId ?? state.cableTypes[0]?.id;
if (!typeID) { alert("Pick a cable type in the legend first"); return; }
armPortTool(d.id, typeID);
state.selection = { kind: "port_new", device_id: d.id };
render();
});
// Clicking a port row in the device's port list selects that port
// and opens its editor in the inspector pane.
body.querySelectorAll(".port-row[data-port-id]").forEach((row) => {
row.addEventListener("click", (e) => {
if (e.target instanceof HTMLElement && e.target.closest(".port-del")) return;
const pid = Number(row.getAttribute("data-port-id"));
if (!pid) return;
state.selection = { kind: "port", id: pid };
render();
});
});
// Per-port delete.
@@ -1003,27 +1016,26 @@ function renderInspectorIO(body, id) {
});
}
// Slice 7 follow-up: m can select a port to edit its edge / label / delete.
// Port editor — type / edge / label / delete. m can also navigate back
// to the device by clicking "back to device" or anywhere on the device.
function renderInspectorPort(body, id) {
const prt = state.ports.find((p) => p.id === id);
if (!prt) { body.innerHTML = ""; return; }
const dev = state.devices.find((d) => d.id === prt.device_id);
if (!dev) { body.innerHTML = ""; return; }
const ct = state.cableTypes.find((t) => t.id === prt.type_id);
const ctColor = ct?.color || "#888";
const ctName = ct?.name || "?";
const currentEdge = portEdge(prt, dev);
const typeOptions = state.cableTypes
.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`)
.join("");
body.innerHTML = `
<p class="section-title">Port</p>
<dl>
<dt>device</dt><dd>${dev.name}</dd>
<dt>type</dt>
<dd><span class="swatch" style="background:${ctColor}"></span>${ctName}</dd>
</dl>
<p style="font-size:12px;margin:0 0 8px 0;">
<a href="#" id="port-back-device" class="btn-link">← ${escapeHtml(dev.name)}</a>
</p>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-label" value="" />
<span>Type</span>
<select id="port-type">${typeOptions}</select>
</label>
<label class="field">
<span>Edge</span>
@@ -1034,12 +1046,36 @@ function renderInspectorPort(body, id) {
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="port-delete">Delete</button>
</div>
`;
body.querySelector("#port-label").value = prt.label ?? "";
body.querySelector("#port-type").value = String(prt.type_id);
body.querySelector("#port-edge").value = currentEdge;
body.querySelector("#port-label").value = prt.label ?? "";
body.querySelector("#port-back-device").addEventListener("click", (e) => {
e.preventDefault();
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-type").addEventListener("change", async (e) => {
if (!state.active) return;
const newTypeID = Number(/** @type {HTMLSelectElement} */ (e.target).value);
if (newTypeID === prt.type_id) return;
try {
const updated = await patchPort(state.active.id, prt.id, { type_id: newTypeID });
Object.assign(prt, updated);
renderCanvas();
} catch (ex) {
alert(`Type change failed: ${ex.message}`);
}
});
bindDebouncedRename(body.querySelector("#port-label"), async (label) => {
if (!state.active) return;
@@ -1110,6 +1146,114 @@ function edgeCentre(dev, edge) {
}
}
// Compute the next available default label for a new port of `typeID`
// on `deviceID`. e.g. if a TV already has "HDMI 1" and "HDMI 2", a new
// HDMI port gets "HDMI 3".
function nextDefaultPortLabel(deviceID, typeID) {
const ct = state.cableTypes.find((t) => t.id === typeID);
const prefix = ct?.name || "Port";
const sibs = state.ports.filter((p) => p.device_id === deviceID && p.type_id === typeID);
let max = 0;
for (const p of sibs) {
const m = (p.label || "").match(new RegExp("^" + escapeRegExp(prefix) + "\\s+(\\d+)$"));
if (m) {
const n = parseInt(m[1], 10);
if (n > max) max = n;
}
}
return `${prefix} ${Math.max(max + 1, sibs.length + 1)}`;
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// "Add port" form. Submit → POST → switch inspector to the new port's
// editor. m can cancel back to the device inspector.
function renderInspectorPortNew(body, deviceID) {
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) { body.innerHTML = ""; return; }
if (state.cableTypes.length === 0) {
body.innerHTML = `
<p class="section-title">Add port</p>
<p class="muted">No cable types defined. Add one from the legend first.</p>
<div class="inspector-actions">
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div>`;
body.querySelector("#port-new-cancel").addEventListener("click", () => {
state.selection = { kind: "device", id: dev.id };
render();
});
return;
}
const defaultTypeID = state.activeTypeId ?? state.cableTypes[0].id;
const typeOptions = state.cableTypes
.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`)
.join("");
body.innerHTML = `
<p class="section-title">Add port</p>
<p style="font-size:12px;margin:0 0 8px 0;">
<a href="#" id="port-new-back" class="btn-link">← ${escapeHtml(dev.name)}</a>
</p>
<label class="field">
<span>Type</span>
<select id="port-new-type">${typeOptions}</select>
</label>
<label class="field">
<span>Edge</span>
<select id="port-new-edge">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom" selected>Bottom</option>
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-new-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-primary btn-tiny" id="port-new-create">Create</button>
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div>
`;
const typeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-type"));
const edgeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-edge"));
const labelInp = /** @type {HTMLInputElement} */ (body.querySelector("#port-new-label"));
typeSel.value = String(defaultTypeID);
labelInp.value = nextDefaultPortLabel(dev.id, defaultTypeID);
labelInp.placeholder = labelInp.value;
// Recompute default label whenever the type changes (only if m hasn't
// edited the field).
let labelUserEdited = false;
labelInp.addEventListener("input", () => { labelUserEdited = true; });
typeSel.addEventListener("change", () => {
if (labelUserEdited) return;
const tid = Number(typeSel.value);
const next = nextDefaultPortLabel(dev.id, tid);
labelInp.value = next;
labelInp.placeholder = next;
});
body.querySelector("#port-new-back").addEventListener("click", (e) => {
e.preventDefault();
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-new-cancel").addEventListener("click", () => {
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-new-create").addEventListener("click", async () => {
const tid = Number(typeSel.value);
const edge = edgeSel.value;
const label = labelInp.value.trim();
await createPortFromForm(dev.id, tid, edge, label);
});
}
function renderInspectorCableType(body, id) {
const t = state.cableTypes.find((x) => x.id === id);
if (!t) { body.innerHTML = ""; return; }
@@ -1313,27 +1457,15 @@ function armTool(tool) {
const wrap = $(".canvas-wrap");
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
wrap.classList.toggle("tool-port", tool === "port");
wrap.classList.toggle("tool-cable", tool === "cable");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
if (tool !== "port") {
state.portToolDevice = null;
state.portToolTypeID = null;
}
if (tool !== "cable") {
state.cableDrawFromPortID = null;
}
}
/** Slice 7: device inspector arms +Port for a specific device + type. */
function armPortTool(deviceID, typeID) {
state.portToolDevice = deviceID;
state.portToolTypeID = typeID;
armTool("port");
}
function bindTools() {
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
@@ -1385,11 +1517,6 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p);
return;
}
if (state.tool === "port") {
e.preventDefault();
placePortAt(p);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
@@ -1568,27 +1695,8 @@ function openNewDeviceModal(geom) {
};
}
/** Snap (x, y) onto the closest edge of `device`. Returns the (x_off,
* y_off) relative to the device's top-left + a debug-friendly edge name. */
function snapToDeviceEdge(device, x, y) {
// Distance from the point to each of the four edges.
const dxLeft = Math.abs(x - device.x);
const dxRight = Math.abs((device.x + device.width) - x);
const dyTop = Math.abs(y - device.y);
const dyBottom = Math.abs((device.y + device.height) - y);
const min = Math.min(dxLeft, dxRight, dyTop, dyBottom);
// Clamp the perpendicular coordinate so the port sits *on* the rect.
const localX = Math.max(0, Math.min(device.width, x - device.x));
const localY = Math.max(0, Math.min(device.height, y - device.y));
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
return { xOff: localX, yOff: device.height, edge: "bottom" };
}
// Which edge does a given port currently sit on? Snaps the port's
// existing (x_offset, y_offset) to the nearest of the four edges using
// the same distance heuristic as snapToDeviceEdge.
// existing (x_offset, y_offset) to the nearest of the four edges.
function portEdge(port, device) {
const dL = port.x_offset;
const dR = device.width - port.x_offset;
@@ -1762,33 +1870,28 @@ async function finishCableDrawAtIO(ioMarker) {
}
}
async function placePortAt(p) {
// Create a port from the sidebar "Add port" form and switch the
// inspector to its editor. Used by renderInspectorPortNew on submit.
async function createPortFromForm(deviceID, typeID, edge, label) {
if (!state.active) return;
const did = state.portToolDevice;
const tid = state.portToolTypeID;
if (did == null || tid == null) { armTool(null); return; }
const dev = state.devices.find((d) => d.id === did);
if (!dev) { armTool(null); return; }
const snap = snapToDeviceEdge(dev, p.x, p.y);
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) return;
const tmp = edgeCentre(dev, edge);
try {
const port = await createPort(state.active.id, did, {
type_id: tid,
x_offset: snap.xOff,
y_offset: snap.yOff,
const port = await createPort(state.active.id, deviceID, {
type_id: typeID,
label: label || undefined,
x_offset: tmp.xOff,
y_offset: tmp.yOff,
});
state.ports.push(port);
// Re-layout all ports on this edge so the new one + existing ones
// are evenly spaced — m's invariant: never let two ports stack.
await relayoutEdge(did, snap.edge);
// Select the freshly-placed port so the inspector switches to the
// port panel (edge dropdown / label / delete) and the .selected halo
// marks it.
// Re-space every port on this edge so the new one slots into the
// even-spacing grid.
await relayoutEdge(deviceID, edge);
state.selection = { kind: "port", id: port.id };
armTool(null);
render();
} catch (e) {
alert(`Add port failed: ${e.message}`);
armTool(null);
}
}
@@ -2364,6 +2467,332 @@ async function exportCurrentProject() {
}
}
// ---------- admin modal ---------- //
const adminState = {
activeTab: /** @type {"projects"|"cable-types"|"device-types"|"setup-templates"} */ ("projects"),
};
async function openAdminModal() {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-admin"));
// Always re-fetch the lists when opening so the modal reflects the
// latest server state (m may have edited things from inspector panes
// while the modal was closed).
try {
state.projects = await listProjects();
state.cableTypes = await listCableTypes();
state.setupTemplates = await listSetupTemplates();
} catch (e) {
alert(`Failed to load admin data: ${e.message}`);
return;
}
for (const btn of dlg.querySelectorAll(".admin-tab")) {
btn.addEventListener("click", () => switchAdminTab(btn.getAttribute("data-admin-tab")));
}
switchAdminTab(adminState.activeTab);
dlg.showModal();
}
function switchAdminTab(name) {
adminState.activeTab = name;
for (const btn of $("#modal-admin").querySelectorAll(".admin-tab")) {
const on = btn.getAttribute("data-admin-tab") === name;
btn.setAttribute("aria-selected", on ? "true" : "false");
}
const body = $("#admin-body");
switch (name) {
case "projects": return renderAdminProjects(body);
case "cable-types": return renderAdminCableTypes(body);
case "device-types": return renderAdminDeviceTypes(body);
case "setup-templates": return renderAdminSetupTemplates(body);
}
}
// ---------- admin: projects ---------- //
function renderAdminProjects(body) {
const rows = state.projects.map((p) => `
<div class="admin-row" data-project-id="${p.id}">
<div class="admin-row-title">
<span>${escapeHtml(p.name)}</span>
<span style="color: var(--text-muted); font-size: 11px;">#${p.id}</span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value="${escapeHtml(p.name)}" />
</label>
<label class="field"><span>Drawing name</span>
<input class="adm-drawing" type="text" value="${escapeHtml(p.drawing_name)}" />
</label>
<label class="field"><span>Description</span>
<textarea class="adm-desc" rows="2">${escapeHtml(p.description ?? "")}</textarea>
</label>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete…</button>
</div>
</div>
`).join("") || `<p class="admin-empty">No projects.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 12px 0;">
Rename, retitle the drawing, or change the description. Delete cascades all frames /
devices / cables / etc. in the project (cable types are global and unaffected).
</p>
${rows}
`;
for (const row of body.querySelectorAll(".admin-row[data-project-id]")) {
const pid = Number(row.getAttribute("data-project-id"));
row.querySelector(".adm-save").addEventListener("click", async () => {
const name = row.querySelector(".adm-name").value.trim();
const drawing = row.querySelector(".adm-drawing").value.trim();
const desc = row.querySelector(".adm-desc").value;
try {
const updated = await patchProject(pid, {
name, drawing_name: drawing, description: desc,
});
const idx = state.projects.findIndex((p) => p.id === pid);
if (idx >= 0) state.projects[idx] = updated;
if (state.active?.id === pid) state.active = updated;
renderProjectPicker();
switchAdminTab("projects");
} catch (e) {
alert(`Save failed: ${e.message}`);
}
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
const p = state.projects.find((x) => x.id === pid);
if (!p) return;
const typed = prompt(`Type "${p.name}" to confirm delete:`);
if (typed !== p.name) return;
try {
await deleteProject(pid, p.name);
state.projects = state.projects.filter((x) => x.id !== pid);
if (state.active?.id === pid) await activateProject(null);
switchAdminTab("projects");
} catch (e) {
alert(`Delete failed: ${e.message}`);
}
});
}
}
// ---------- admin: cable types ---------- //
function renderAdminCableTypes(body) {
const rows = state.cableTypes.map((t) => `
<div class="admin-row" data-cable-type-id="${t.id}">
<div class="admin-row-title">
<span><span class="swatch" style="background:${t.color}"></span>${escapeHtml(t.name)}</span>
<span style="color: var(--text-muted); font-size: 11px;">#${t.id}</span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value="${escapeHtml(t.name)}" />
</label>
<label class="field"><span>Colour</span>
<input class="adm-color" type="color" value="${t.color}" />
</label>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
`).join("") || `<p class="admin-empty">No cable types.</p>`;
body.innerHTML = `
<p class="banner" style="margin:0 0 12px 0;">
Cable types are <strong>global</strong> — renaming or recolouring affects every project.
</p>
${rows}
<div class="admin-add-row">
<div class="admin-row-title"><span>New cable type</span></div>
<label class="field"><span>Name</span>
<input id="adm-ct-new-name" type="text" placeholder="e.g. SATA" />
</label>
<label class="field"><span>Colour</span>
<input id="adm-ct-new-color" type="color" value="#1971c2" />
</label>
<div class="actions">
<button type="button" class="btn btn-primary btn-tiny" id="adm-ct-new-create">+ Add</button>
</div>
</div>
`;
for (const row of body.querySelectorAll(".admin-row[data-cable-type-id]")) {
const id = Number(row.getAttribute("data-cable-type-id"));
row.querySelector(".adm-save").addEventListener("click", async () => {
const name = row.querySelector(".adm-name").value.trim();
const color = row.querySelector(".adm-color").value;
try {
const updated = await patchCableType(id, { name, color });
const idx = state.cableTypes.findIndex((t) => t.id === id);
if (idx >= 0) state.cableTypes[idx] = updated;
renderLegend(); renderCanvas();
switchAdminTab("cable-types");
} catch (e) { alert(`Save failed: ${e.message}`); }
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
if (!confirm("Delete this cable type? Requires no ports / cables to reference it.")) return;
try {
await deleteCableType(id);
state.cableTypes = state.cableTypes.filter((t) => t.id !== id);
renderLegend(); renderCanvas();
switchAdminTab("cable-types");
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
body.querySelector("#adm-ct-new-create").addEventListener("click", async () => {
const name = body.querySelector("#adm-ct-new-name").value.trim();
const color = body.querySelector("#adm-ct-new-color").value;
if (!name) { alert("Name required"); return; }
try {
const created = await createCableType({ name, color });
state.cableTypes.push(created);
renderLegend(); renderCanvas();
switchAdminTab("cable-types");
} catch (e) { alert(`Create failed: ${e.message}`); }
});
}
// ---------- admin: device types ---------- //
function renderAdminDeviceTypes(body) {
if (!state.active) {
body.innerHTML = `
<p class="admin-empty">
Pick a project to manage its custom device types. Built-ins are
listed once a project is active (they're project-agnostic but the
catalog read takes a project context).
</p>`;
return;
}
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 portsLine = (ports) => ports.map((p) =>
`<li><span class="swatch" style="background:${cableTypeColor.get(p.cable_type_id) || "#888"}"></span>` +
`${escapeHtml(cableTypeName.get(p.cable_type_id) || "?")} × ${p.count} <span class="muted">(${escapeHtml(p.edge)})</span></li>`,
).join("");
const builtIns = state.deviceTypes.filter((t) => t.built_in);
const customs = state.deviceTypes.filter((t) => !t.built_in);
const builtRows = builtIns.map((t) => `
<div class="admin-row locked" data-device-type-id="${t.id}">
<div class="admin-row-title">
<span>${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)}
<span class="muted" style="font-weight:normal;font-size:11px;">· ${escapeHtml(t.kind || "")}</span>
</span>
<span class="locked-badge">built-in</span>
</div>
<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(t.description || "")}</p>
<ul class="port-profile-list">${portsLine(t.ports || [])}</ul>
</div>
`).join("");
const customRows = customs.map((t) => `
<div class="admin-row" data-device-type-id="${t.id}">
<div class="admin-row-title">
<span>${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)}</span>
<span style="color: var(--text-muted); font-size: 11px;">#${t.id}</span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value="${escapeHtml(t.name)}" />
</label>
<label class="field"><span>Kind</span>
<input class="adm-kind" type="text" value="${escapeHtml(t.kind || "")}" />
</label>
<label class="field"><span>Icon</span>
<input class="adm-icon" type="text" value="${escapeHtml(t.icon || "")}" />
</label>
<label class="field"><span>Description</span>
<input class="adm-desc" type="text" value="${escapeHtml(t.description || "")}" />
</label>
<ul class="port-profile-list">${portsLine(t.ports || []) || '<li class="muted">no port profile</li>'}</ul>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
`).join("") || `<p class="admin-empty">No project-custom types yet.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
Built-in types are seeded by migrations and read-only.
Project-custom types live under the active project ('${escapeHtml(state.active.name)}') and can be edited or deleted.
Port profiles can't be re-shaped here yet — m can still override per device-instance from the device inspector.
</p>
<h3 style="margin:8px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Built-in (${builtIns.length})</h3>
${builtRows}
<h3 style="margin:16px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Project-custom (${customs.length})</h3>
${customRows}
`;
for (const row of body.querySelectorAll(".admin-row:not(.locked)[data-device-type-id]")) {
const id = Number(row.getAttribute("data-device-type-id"));
row.querySelector(".adm-save").addEventListener("click", async () => {
const name = row.querySelector(".adm-name").value.trim();
const kind = row.querySelector(".adm-kind").value.trim();
const icon = row.querySelector(".adm-icon").value.trim();
const desc = row.querySelector(".adm-desc").value;
try {
const updated = await patchDeviceType(state.active.id, id, {
name, kind, icon, description: desc,
});
const idx = state.deviceTypes.findIndex((t) => t.id === id);
if (idx >= 0) state.deviceTypes[idx] = updated;
switchAdminTab("device-types");
} catch (e) { alert(`Save failed: ${e.message}`); }
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
if (!confirm("Delete this custom device type?")) return;
try {
await deleteDeviceType(state.active.id, id);
state.deviceTypes = state.deviceTypes.filter((t) => t.id !== id);
switchAdminTab("device-types");
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
}
// ---------- admin: setup templates ---------- //
function renderAdminSetupTemplates(body) {
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
const rows = state.setupTemplates.map((t) => {
const dt = (d) => d.device_type?.name ?? `type #${d.device_type_id}`;
const devsById = new Map(t.devices.map((d) => [d.id, d]));
const devsHtml = t.devices.map((d) =>
`<li>${escapeHtml(d.suggested_name ?? dt(d))} <span class="muted">(${escapeHtml(dt(d))})</span></li>`,
).join("") || `<li class="muted">no devices</li>`;
const reqsHtml = t.requirements.map((r) => {
const a = devsById.get(r.from_template_device_id);
const b = devsById.get(r.to_template_device_id);
const an = a ? (a.suggested_name ?? dt(a)) : "?";
const bn = b ? (b.suggested_name ?? dt(b)) : "?";
const ct = r.preferred_cable_type_id != null
? cableTypeName.get(r.preferred_cable_type_id) : null;
const tag = r.must_connect ? "must" : "nice";
return `<li>${escapeHtml(an)}${escapeHtml(bn)} <span class="muted">· ${escapeHtml(ct ?? "solver picks")} · ${tag}</span></li>`;
}).join("") || `<li class="muted">no requirements</li>`;
return `
<div class="admin-row locked" data-template-id="${t.id}">
<div class="admin-row-title">
<span>${escapeHtml(t.name)}</span>
<span class="locked-badge">${t.built_in ? "built-in" : "custom"}</span>
</div>
<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(t.description || "")}</p>
<div class="tmpl-detail">
<strong>Devices (${t.devices.length})</strong>
<ul>${devsHtml}</ul>
</div>
<div class="tmpl-detail">
<strong>Requirements (${t.requirements.length})</strong>
<ul>${reqsHtml}</ul>
</div>
</div>
`;
}).join("") || `<p class="admin-empty">No setup templates.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
Setup templates are stamps for a project — apply one from the header
("Apply template…") to seed a frame + devices + requirements at once.
Built-in templates are read-only.
</p>
${rows}
`;
}
// ---------- boot ---------- //
async function boot() {
@@ -2374,10 +2803,12 @@ async function boot() {
bindCloseButtons($("#modal-requirement"));
bindCloseButtons($("#modal-solve"));
bindCloseButtons($("#modal-template"));
bindCloseButtons($("#modal-admin"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#btn-admin").addEventListener("click", openAdminModal);
$("#btn-add-requirement").addEventListener("click", () => {
if (!state.active) { alert("Pick a project first"); return; }
if (state.devices.length < 2) { alert("Need at least two devices to add a requirement."); return; }

View File

@@ -215,8 +215,6 @@ body {
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-port #canvas,
.canvas-wrap.tool-port #canvas *,
.canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
@@ -296,8 +294,11 @@ body {
align-items: center;
gap: 6px;
font-size: 12px;
padding: 2px 0;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
}
.port-row:hover { background: var(--surface-2); }
.port-row .swatch,
.swatch {
display: inline-block;
@@ -375,6 +376,108 @@ body {
/* Solve preview-diff modal */
.modal-wide { width: 560px; }
/* Admin modal — wider, tabbed */
.modal-wide.admin-shell-host { width: 760px; }
#modal-admin { width: 760px; max-width: 90vw; }
.admin-shell { padding: 16px; min-height: 460px; }
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.admin-header h2 { margin: 0; }
.admin-close { font-size: 16px; padding: 4px 8px; }
.admin-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.admin-tab {
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 8px 12px;
font: inherit;
color: var(--text-muted);
cursor: pointer;
}
.admin-tab:hover { color: var(--text); }
.admin-tab[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent);
}
.admin-body {
font-size: 13px;
max-height: 60vh;
overflow-y: auto;
}
.admin-row {
display: grid;
gap: 6px 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.admin-row:last-child { border-bottom: 0; }
.admin-row .field { display: grid; grid-template-columns: 110px 1fr; align-items: center; }
.admin-row .field span { color: var(--text-muted); font-size: 12px; }
.admin-row .field input,
.admin-row .field textarea,
.admin-row .field select {
width: 100%;
font: inherit;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
}
.admin-row .actions { display: flex; gap: 6px; justify-content: flex-end; }
.admin-row.locked { opacity: 0.85; }
.admin-row .locked-badge {
display: inline-block;
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: var(--surface-2);
color: var(--text-muted);
}
.admin-row-title {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 4px;
}
.admin-row-title .swatch { display: inline-block; }
.admin-empty { color: var(--text-muted); padding: 16px 0; }
.admin-add-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.port-profile-list {
margin: 4px 0 0 0;
padding: 0;
list-style: none;
font-size: 12px;
color: var(--text-muted);
}
.port-profile-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
}
.tmpl-detail {
margin: 4px 0 0 0;
font-size: 12px;
color: var(--text-muted);
}
.tmpl-detail ul { margin: 4px 0 0 16px; padding: 0; }
.sv-body { font-size: 13px; }
.sv-body h3 {
font-size: 11px;