Files
CableGUI/web/static/main.js
mAi b15913124a feat: frontend — frames + devices on SVG, tools, drag, inspector
Renders the slice-2 backend on the empty canvas from slice 1.

Canvas:
- Frames render as dashed-stroke rects with top-left label, slightly
  tinted fill. Devices render as solid-stroke rects with centred label
  in device.color.
- Selection halo via .selected class (stroke-width bump).
- Empty-state hint disappears once any geometry exists.

Tools (left sidebar + keyboard):
- F / + Frame  — rubber-band rect on the canvas. <80×60 cancels. On
  release, inline foreignObject namer → POST /api/projects/:pid/frames.
- D / + Device — single click places a 100×35 device centred at the
  click. Inline namer → POST devices. Drop-point determines initial
  frame_id via point-in-rect against all frames (smallest bbox wins).
- Esc cancels active tool / inline namer / clears selection.

Drag (pointer events + svg getScreenCTM):
- Devices: drag updates x/y live via transform, persists via
  PATCH .../devices/:id on pointerup. Also recomputes frame_id from
  drop point and includes "frame_id": null|<id> if it changed.
- Frames: dragging a frame moves its contained devices visually too;
  on pointerup, single PATCH for the frame + one PATCH per moved device.
  Children-batch is computed at pointerdown and only sent on release —
  no per-pointermove network traffic.

Inspector:
- Frame selection: name (debounced rename), x/y/w/h, device count,
  Delete button (confirm prompt — devices keep existing, frame_id → NULL
  via the schema's ON DELETE SET NULL).
- Device selection: name (debounced rename), colour picker
  (change-event PATCH, no debounce), x/y/w/h, current frame, Delete.
- Background click clears selection.

devicePatch wire format uses tri-state frame_id: key absent = leave,
key:null = clear, key:<int> = move. Frontend uses `null` explicitly
when a device drops outside all frames.
2026-05-15 18:22:49 +02:00

844 lines
27 KiB
JavaScript

// mCables frontend entry — vanilla ES module, no build step.
//
// Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position,
// inline naming, inspector for selection. State stays minimal: one
// snapshot from the server, then individual PATCHes on each mutation.
/**
* @typedef {{ id: number, name: string, drawing_name: string,
* description: string, created_at: string, updated_at: string }} Project
* @typedef {{ id: number, name: string, color: string,
* created_at: string, updated_at: string }} CableType
* @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,
* x: number, y: number, width: number, height: number }} Device
*/
const API = "/api";
const SVG_NS = "http://www.w3.org/2000/svg";
const state = {
/** @type {Project[]} */ projects: [],
/** @type {CableType[]} */ cableTypes: [],
/** @type {Project | null} */ active: null,
/** @type {Frame[]} */ frames: [],
/** @type {Device[]} */ devices: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | null */
tool: /** @type {string|null} */ (null),
/** @type {{kind: "frame"|"device", id: number} | null} */ selection: null,
};
// ---------- API client ---------- //
async function api(method, path, body) {
const res = await fetch(API + path, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const text = await res.text();
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = new Error(json?.error || res.statusText);
err.status = res.status;
err.details = json?.details;
throw err;
}
return json;
}
const listProjects = () => api("GET", "/projects");
const createProject = (body) => api("POST", "/projects", body);
const deleteProject = (id, confirm) =>
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
const getSnapshot = (id) => api("GET", `/projects/${id}`);
const listCableTypes = () => api("GET", "/cable-types");
const createCableType = (body) => api("POST", "/cable-types", body);
const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body);
const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`);
const createFrame = (pid, body) => api("POST", `/projects/${pid}/frames`, body);
const patchFrame = (pid, id, body) => api("PATCH", `/projects/${pid}/frames/${id}`, body);
const deleteFrame = (pid, id) => api("DELETE", `/projects/${pid}/frames/${id}`);
const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, body);
const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body);
const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
function setHidden(el, hidden) {
if (hidden) el.setAttribute("hidden", "");
else el.removeAttribute("hidden");
}
function svgEl(name, attrs = {}) {
const el = document.createElementNS(SVG_NS, name);
for (const [k, v] of Object.entries(attrs)) {
if (v == null) continue;
el.setAttribute(k, String(v));
}
return el;
}
// ---------- URL state ---------- //
function activeProjectIdFromURL() {
const raw = new URLSearchParams(location.search).get("project");
const id = raw && Number.parseInt(raw, 10);
return Number.isFinite(id) && id > 0 ? id : null;
}
function setActiveInURL(id) {
const url = new URL(location.href);
if (id == null) url.searchParams.delete("project");
else url.searchParams.set("project", String(id));
history.replaceState(null, "", url.toString());
}
// ---------- geometry ---------- //
/** Returns the smallest frame whose bbox contains (x, y), or null. */
function frameAt(x, y) {
/** @type {Frame|null} */ let best = null;
let bestArea = Infinity;
for (const f of state.frames) {
if (x < f.x || x > f.x + f.width || y < f.y || y > f.y + f.height) continue;
const a = f.width * f.height;
if (a < bestArea) { best = f; bestArea = a; }
}
return best;
}
/** Convert a pointer event to SVG-canvas coordinates. */
function svgPoint(evt) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
// ---------- render ---------- //
function renderProjectPicker() {
const sel = /** @type {HTMLSelectElement} */ ($("#project-select"));
const current = state.active?.id ?? "";
sel.innerHTML = "";
sel.append(new Option("— pick a project —", ""));
for (const p of state.projects) {
const opt = new Option(p.name, String(p.id));
if (p.id === current) opt.selected = true;
sel.append(opt);
}
setHidden($("#btn-delete-project"), !state.active);
}
function renderLegend() {
const ul = $("#legend-list");
ul.innerHTML = "";
for (const t of state.cableTypes) {
const li = document.createElement("li");
li.className = "legend-row";
li.dataset.id = String(t.id);
if (state.activeTypeId === t.id) li.setAttribute("aria-current", "true");
li.innerHTML = `
<span class="legend-swatch" style="background:${t.color}"></span>
<span class="legend-name"></span>
<button type="button" class="legend-edit" aria-label="Edit cable type">edit</button>
`;
li.querySelector(".legend-name").textContent = t.name;
li.addEventListener("click", (e) => {
if (e.target instanceof HTMLElement && e.target.classList.contains("legend-edit")) {
openCableTypeModal(t);
e.stopPropagation();
return;
}
state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
renderLegend();
});
ul.append(li);
}
}
function renderEmptyHint() {
const hint = $("#empty-hint");
if (!state.active) {
hint.textContent = state.projects.length
? "Pick a project from the dropdown to start drawing."
: "Create your first project to get started.";
setHidden(hint, false);
return;
}
if (state.frames.length === 0 && state.devices.length === 0) {
hint.textContent = `${state.active.name} — empty. Use + Frame / + Device to start (press F or D).`;
setHidden(hint, false);
} else {
setHidden(hint, true);
}
}
function renderCanvas() {
const gFrames = $("#canvas-frames");
const gDevices = $("#canvas-devices");
gFrames.innerHTML = "";
gDevices.innerHTML = "";
for (const f of state.frames) {
const g = svgEl("g", { "data-frame-id": f.id });
const rect = svgEl("rect", {
x: f.x, y: f.y, width: f.width, height: f.height,
class: "frame-rect svg-draggable",
rx: 6, ry: 6,
});
if (state.selection?.kind === "frame" && state.selection.id === f.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: f.x + 8, y: f.y + 18,
class: "frame-label",
});
label.textContent = f.name;
g.append(rect, label);
gFrames.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
}
for (const d of state.devices) {
const g = svgEl("g", { "data-device-id": d.id });
const rect = svgEl("rect", {
x: d.x, y: d.y, width: d.width, height: d.height,
class: "device-rect svg-draggable",
stroke: d.color,
rx: 3, ry: 3,
});
if (state.selection?.kind === "device" && state.selection.id === d.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: d.x + d.width / 2, y: d.y + d.height / 2,
class: "device-label",
});
label.textContent = d.name;
g.append(rect, label);
gDevices.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
}
}
function renderInspector() {
const body = $("#inspector-body");
if (!state.selection) {
body.innerHTML = `<p class="muted">Nothing selected.</p>`;
return;
}
if (state.selection.kind === "frame") {
renderInspectorFrame(body, state.selection.id);
} else {
renderInspectorDevice(body, state.selection.id);
}
}
function renderInspectorFrame(body, id) {
const f = state.frames.find((x) => x.id === id);
if (!f) { body.innerHTML = ""; return; }
const deviceCount = state.devices.filter((d) => d.frame_id === f.id).length;
body.innerHTML = `
<p class="section-title">Frame</p>
<label class="field">
<span>Name</span>
<input class="inline-input" id="frm-name" value="" />
</label>
<dl>
<dt>x</dt><dd id="frm-x"></dd>
<dt>y</dt><dd id="frm-y"></dd>
<dt>w</dt><dd id="frm-w"></dd>
<dt>h</dt><dd id="frm-h"></dd>
<dt>devices</dt><dd id="frm-count"></dd>
</dl>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="frm-delete">Delete frame</button>
</div>
`;
body.querySelector("#frm-name").value = f.name;
body.querySelector("#frm-x").textContent = f.x.toFixed(0);
body.querySelector("#frm-y").textContent = f.y.toFixed(0);
body.querySelector("#frm-w").textContent = f.width.toFixed(0);
body.querySelector("#frm-h").textContent = f.height.toFixed(0);
body.querySelector("#frm-count").textContent = String(deviceCount);
bindDebouncedRename(body.querySelector("#frm-name"), async (name) => {
if (!state.active) return;
const updated = await patchFrame(state.active.id, f.id, { name });
Object.assign(f, updated);
renderCanvas();
});
body.querySelector("#frm-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete frame "${f.name}"? Its devices stay but lose their frame.`)) return;
deleteFrame(state.active.id, f.id).then(() => {
state.frames = state.frames.filter((x) => x.id !== f.id);
for (const d of state.devices) if (d.frame_id === f.id) d.frame_id = null;
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
}
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;
body.innerHTML = `
<p class="section-title">Device</p>
<label class="field">
<span>Name</span>
<input class="inline-input" id="dev-name" value="" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" class="inline-input" id="dev-color" />
</label>
<dl>
<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>
<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-x").textContent = d.x.toFixed(0);
body.querySelector("#dev-y").textContent = d.y.toFixed(0);
body.querySelector("#dev-w").textContent = d.width.toFixed(0);
body.querySelector("#dev-h").textContent = d.height.toFixed(0);
body.querySelector("#dev-frame").textContent = frame ? frame.name : "—";
bindDebouncedRename(body.querySelector("#dev-name"), async (name) => {
if (!state.active) return;
const updated = await patchDevice(state.active.id, d.id, { name });
Object.assign(d, updated);
renderCanvas();
});
// Colour changes need no debounce — the native colour picker only fires
// `change` on commit.
body.querySelector("#dev-color").addEventListener("change", async (e) => {
if (!state.active) return;
const color = /** @type {HTMLInputElement} */ (e.target).value;
try {
const updated = await patchDevice(state.active.id, d.id, { color });
Object.assign(d, updated);
renderCanvas();
} catch (err) {
alert(`Colour update failed: ${err.message}`);
}
});
body.querySelector("#dev-delete").addEventListener("click", () => {
if (!state.active) return;
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.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
}
function bindDebouncedRename(input, persist) {
let timer = null;
input.addEventListener("input", () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const v = input.value.trim();
if (v) persist(v).catch((e) => alert(`Save failed: ${e.message}`));
}, 400);
});
input.addEventListener("blur", () => {
if (timer) { clearTimeout(timer); timer = null; }
const v = input.value.trim();
if (v && v !== input.dataset.last) {
persist(v).catch((e) => alert(`Save failed: ${e.message}`));
input.dataset.last = v;
}
});
}
function render() {
renderProjectPicker();
renderLegend();
renderCanvas();
renderEmptyHint();
renderInspector();
}
// ---------- active project ---------- //
async function activateProject(id) {
if (id == null) {
state.active = null;
state.frames = [];
state.devices = [];
state.selection = null;
setActiveInURL(null);
render();
return;
}
try {
const snap = await getSnapshot(id);
state.active = snap.project;
state.frames = snap.frames || [];
state.devices = snap.devices || [];
state.cableTypes = snap.cable_types || [];
state.selection = null;
setActiveInURL(id);
render();
} catch (err) {
if (err.status === 404) {
state.active = null;
state.frames = [];
state.devices = [];
setActiveInURL(null);
render();
} else {
alert(`Failed to load project: ${err.message}`);
}
}
}
// ---------- tools ---------- //
function armTool(tool) {
if (state.tool === tool) tool = null; // toggle off
state.tool = tool;
const wrap = $(".canvas-wrap");
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
}
function bindTools() {
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
}
document.addEventListener("keydown", (e) => {
// Avoid stealing keys while user is typing into an input.
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.selection = null; render(); }
else if (e.key === "f" || e.key === "F") armTool("frame");
else if (e.key === "d" || e.key === "D") armTool("device");
});
// Canvas-level pointerdown handles tool activation + selection clearing.
$("#canvas").addEventListener("pointerdown", onCanvasPointerDown);
}
let rubberBand = /** @type {SVGRectElement|null} */ (null);
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
function onCanvasPointerDown(e) {
if (!state.active) return;
// Ignore clicks that started on a device/frame — their own handlers
// captured the pointer already.
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id]")) return;
const p = svgPoint(e);
if (state.tool === "frame") {
startFrameRubberBand(e, p);
return;
}
if (state.tool === "device") {
placeDeviceAt(p);
return;
}
// Plain canvas click = clear selection.
if (state.selection) { state.selection = null; render(); }
}
function startFrameRubberBand(e, p0) {
if (!state.active) return;
rubberStart = p0;
rubberBand = svgEl("rect", {
x: p0.x, y: p0.y, width: 0, height: 0,
class: "rubber-band", rx: 6, ry: 6,
});
$("#canvas").append(rubberBand);
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
svg.setPointerCapture(e.pointerId);
const onMove = (ev) => {
if (!rubberBand || !rubberStart) return;
const p = svgPoint(ev);
const x = Math.min(rubberStart.x, p.x);
const y = Math.min(rubberStart.y, p.y);
rubberBand.setAttribute("x", String(x));
rubberBand.setAttribute("y", String(y));
rubberBand.setAttribute("width", String(Math.abs(p.x - rubberStart.x)));
rubberBand.setAttribute("height", String(Math.abs(p.y - rubberStart.y)));
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
const rect = rubberBand;
const start = rubberStart;
rubberBand = null;
rubberStart = null;
if (!rect || !start) return;
const w = Number(rect.getAttribute("width"));
const h = Number(rect.getAttribute("height"));
const x = Number(rect.getAttribute("x"));
const y = Number(rect.getAttribute("y"));
rect.remove();
if (w < 80 || h < 60) { armTool(null); return; }
armTool(null);
const name = await promptInline("Frame name", x + w / 2, y + 16);
if (!name || !state.active) return;
try {
const f = await createFrame(state.active.id, { name, x, y, width: w, height: h });
state.frames.push(f);
state.selection = { kind: "frame", id: f.id };
render();
} catch (err) {
alert(`Create frame failed: ${err.message}`);
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
}
async function placeDeviceAt(p) {
if (!state.active) return;
armTool(null);
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}`);
}
}
// ---------- inline namer (foreignObject overlay) ---------- //
let activeNamer = /** @type {SVGForeignObjectElement|null} */ (null);
function cancelInlineNamer() {
if (activeNamer) { activeNamer.remove(); activeNamer = null; }
}
function promptInline(placeholder, cx, cy) {
cancelInlineNamer();
return new Promise((resolve) => {
const fo = document.createElementNS(SVG_NS, "foreignObject");
fo.setAttribute("x", String(cx - 110));
fo.setAttribute("y", String(cy - 14));
fo.setAttribute("width", "220");
fo.setAttribute("height", "28");
fo.innerHTML = `
<div class="inline-namer" xmlns="http://www.w3.org/1999/xhtml">
<input type="text" placeholder="${placeholder}" />
</div>
`;
$("#canvas").append(fo);
activeNamer = fo;
const input = fo.querySelector("input");
input.focus();
const done = (val) => {
if (activeNamer === fo) { fo.remove(); activeNamer = null; }
resolve(val);
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") done(input.value.trim());
else if (e.key === "Escape") done(null);
});
input.addEventListener("blur", () => done(input.value.trim() || null));
});
}
// ---------- drag ---------- //
function startDrag(e, kind, id) {
if (!state.active) return;
if (state.tool) return; // a tool is armed; don't hijack
e.stopPropagation();
state.selection = { kind, id };
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e);
/** @type {Frame|Device|undefined} */
const obj = kind === "frame"
? state.frames.find((f) => f.id === id)
: state.devices.find((d) => d.id === id);
if (!obj) return;
const startX = obj.x;
const startY = obj.y;
// For frame drags, remember the contained devices + their offsets so
// they follow the frame visually + persist on release.
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
if (kind === "frame") {
for (const d of state.devices) {
if (d.frame_id === obj.id) {
trackedDevices.push({ d, sx: d.x, sy: d.y });
}
}
}
e.currentTarget.classList.add("dragging");
svg.setPointerCapture(e.pointerId);
let dragged = false;
const onMove = (ev) => {
const p = svgPoint(ev);
const dx = p.x - start.x;
const dy = p.y - start.y;
if (!dragged && (Math.abs(dx) + Math.abs(dy) > 1)) dragged = true;
obj.x = startX + dx;
obj.y = startY + dy;
if (kind === "frame") {
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
}
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
e.currentTarget.classList.remove("dragging");
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
if (!state.active) return;
try {
if (kind === "frame") {
const f = /** @type {Frame} */ (obj);
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
// Persist contained devices too.
await Promise.all(
trackedDevices.map((t) =>
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
);
} else {
const d = /** @type {Device} */ (obj);
// Recompute frame_id from drop point (centre of device).
const cx = d.x + d.width / 2;
const cy = d.y + d.height / 2;
const targetFrame = frameAt(cx, cy);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: d.x, y: d.y };
if ((d.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID; // explicit null = clear
d.frame_id = newFrameID;
}
await patchDevice(state.active.id, d.id, patchBody);
}
} catch (err) {
alert(`Save failed: ${err.message}`);
}
render();
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
}
// ---------- modals (project / cable type) ---------- //
function bindCloseButtons(dialog) {
dialog.querySelectorAll("[data-close]").forEach((btn) =>
btn.addEventListener("click", () => dialog.close()),
);
}
function showError(el, msg) {
if (!msg) { setHidden(el, true); el.textContent = ""; return; }
el.textContent = msg;
setHidden(el, false);
}
function openNewProjectModal() {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-new-project"));
const err = $("#np-error");
form.reset();
showError(err, "");
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
drawing_name: String(fd.get("drawing_name") || "").trim(),
description: String(fd.get("description") || ""),
};
if (!body.drawing_name) delete body.drawing_name;
try {
const p = await createProject(body);
state.projects = await listProjects();
dlg.close();
await activateProject(p.id);
} catch (e) {
showError(err, e.message || "Failed to create project");
}
};
}
function openCableTypeModal(existing) {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type"));
const form = /** @type {HTMLFormElement} */ ($("#form-cable-type"));
const err = $("#ct-error");
const title = $("#ct-title");
form.reset();
showError(err, "");
title.textContent = existing ? `Edit "${existing.name}"` : "New cable type";
if (existing) {
form.elements.namedItem("name").value = existing.name;
form.elements.namedItem("color").value = existing.color;
} else {
form.elements.namedItem("color").value = "#1971c2";
}
const actions = form.querySelector(".actions");
actions.querySelector(".btn-delete-type")?.remove();
if (existing) {
const del = document.createElement("button");
del.type = "button";
del.className = "btn btn-danger btn-delete-type";
del.style.marginRight = "auto";
del.textContent = "Delete";
del.addEventListener("click", async () => {
try {
await deleteCableType(existing.id);
state.cableTypes = await listCableTypes();
if (state.activeTypeId === existing.id) state.activeTypeId = null;
dlg.close();
render();
} catch (e) {
const n = e.details?.in_use_by_cables;
showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed"));
}
});
actions.prepend(del);
}
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
color: String(fd.get("color") || "").trim(),
};
try {
if (existing) await patchCableType(existing.id, body);
else await createCableType(body);
state.cableTypes = await listCableTypes();
dlg.close();
render();
} catch (e) {
showError(err, e.message || "Save failed");
}
};
}
function openDeleteProjectModal() {
if (!state.active) return;
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-delete-project"));
const err = $("#dp-error");
const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input"));
form.reset();
showError(err, "");
input.placeholder = state.active.name;
dlg.showModal();
input.focus();
form.onsubmit = async (e) => {
e.preventDefault();
const confirm = String(new FormData(form).get("confirm") || "");
try {
await deleteProject(state.active.id, confirm);
state.projects = await listProjects();
dlg.close();
await activateProject(null);
} catch (e) {
showError(err, e.message || "Delete failed");
}
};
}
// ---------- boot ---------- //
async function boot() {
bindCloseButtons($("#modal-new-project"));
bindCloseButtons($("#modal-cable-type"));
bindCloseButtons($("#modal-delete-project"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#project-select").addEventListener("change", (e) => {
const v = /** @type {HTMLSelectElement} */ (e.target).value;
activateProject(v ? Number(v) : null);
});
bindTools();
try {
[state.projects, state.cableTypes] = await Promise.all([
listProjects(),
listCableTypes(),
]);
} catch (e) {
alert(`Failed to load: ${e.message}`);
return;
}
const wanted = activeProjectIdFromURL();
if (wanted && state.projects.some((p) => p.id === wanted)) {
await activateProject(wanted);
} else {
render();
}
}
boot();