diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..dabaaed --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,135 @@ + + + + + + mCables + + + +
+ mCables +
+ + + + +
+
+ +
+ +
+ + +
+ + + + + + + +

+ Pick or create a project to start drawing. +

+
+ + +
+ + + +
+

New project

+ + + + +
+ + +
+
+
+ + + +
+

Cable type

+ + + + +
+ + +
+
+
+ + + +
+

Delete project

+

+ This will cascade-delete every frame, device, port, cable, IO marker + and bundle in the project. Cable types are global and are not affected. +

+

Type the project name to confirm:

+ + +
+ + +
+
+
+ + + + diff --git a/web/static/main.js b/web/static/main.js new file mode 100644 index 0000000..c967036 --- /dev/null +++ b/web/static/main.js @@ -0,0 +1,335 @@ +// mCables frontend entry — vanilla ES module, no build step. +// +// Slice 1 covers: list/create/delete projects, list/create/edit/delete +// global cable types, and reflect the active project in ?project=. + +/** + * @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 + */ + +const API = "/api"; + +const state = { + /** @type {Project[]} */ projects: [], + /** @type {CableType[]} */ cableTypes: [], + /** @type {Project | null} */ active: null, + /** active cable-type id (used for drawing in later slices) */ + activeTypeId: 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 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}`); + +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}`); + +// ---------- DOM helpers ---------- // + +const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel)); + +function setHidden(el, hidden) { + if (hidden) el.setAttribute("hidden", ""); + else el.removeAttribute("hidden"); +} + +// ---------- 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()); +} + +// ---------- render ---------- // + +function renderProjectPicker() { + const sel = /** @type {HTMLSelectElement} */ ($("#project-select")); + const current = state.active?.id ?? ""; + sel.innerHTML = ""; + const blank = new Option("— pick a project —", ""); + sel.append(blank); + 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 = ` + + + + `; + 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); + } else { + hint.textContent = `${state.active.name} — slice 1: empty canvas. Frames + devices arrive in slice 2.`; + setHidden(hint, false); + } +} + +function render() { + renderProjectPicker(); + renderLegend(); + renderEmptyHint(); +} + +// ---------- active project ---------- // + +async function activateProject(id) { + if (id == null) { + state.active = null; + setActiveInURL(null); + render(); + return; + } + try { + const snap = await getSnapshot(id); + state.active = snap.project; + // The snapshot also returns the global cable types — refresh from + // the source of truth so a stale state.cableTypes can never linger. + state.cableTypes = snap.cable_types || []; + setActiveInURL(id); + render(); + } catch (err) { + if (err.status === 404) { + // The id in the URL points to a deleted project — clear it. + state.active = null; + setActiveInURL(null); + render(); + } else { + alert(`Failed to load project: ${err.message}`); + } + } +} + +// ---------- modals ---------- // + +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"; + } + + // Slot in a Delete button when editing an existing type. + 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); + }); + + 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(); diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..90586a4 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,248 @@ +:root { + --bg: #fafafa; + --surface: #ffffff; + --surface-2: #f4f4f5; + --border: #d4d4d8; + --text: #18181b; + --text-muted: #71717a; + --accent: #1971c2; + --danger: #e03131; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04); + --radius: 4px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +/* ---------- topbar ---------- */ + +.topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.brand { + font-weight: 600; + font-size: 15px; +} + +.project-picker { + display: flex; + align-items: center; + gap: 6px; +} + +.topbar-spacer { flex: 1; } + +/* ---------- layout ---------- */ + +.layout { + display: grid; + grid-template-columns: 220px 1fr 280px; + flex: 1; + min-height: 0; +} + +.sidebar, +.inspector { + background: var(--surface); + padding: 12px; + overflow-y: auto; +} + +.sidebar { border-right: 1px solid var(--border); } +.inspector { border-left: 1px solid var(--border); } + +.sidebar-heading { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin: 0 0 8px 0; +} + +.tool-list, +.legend-list { + list-style: none; + padding: 0; + margin: 0 0 8px 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.legend-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + border-radius: var(--radius); + cursor: pointer; +} +.legend-row:hover { background: var(--surface-2); } +.legend-row[aria-current="true"] { + background: var(--surface-2); + outline: 1px solid var(--accent); +} +.legend-swatch { + width: 14px; + height: 14px; + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, 0.15); + flex-shrink: 0; +} +.legend-name { flex: 1; } +.legend-edit { + background: transparent; + border: 0; + cursor: pointer; + color: var(--text-muted); + padding: 2px 4px; + border-radius: 2px; + font-size: 12px; +} +.legend-edit:hover { color: var(--text); background: var(--surface-2); } + +/* ---------- canvas ---------- */ + +.canvas-wrap { + position: relative; + overflow: hidden; + background: #f7f7f7; + background-image: + linear-gradient(to right, rgba(0,0,0,0.04) 1px, transparent 1px), + linear-gradient(to bottom, rgba(0,0,0,0.04) 1px, transparent 1px); + background-size: 50px 50px; +} + +#canvas { + width: 100%; + height: 100%; + display: block; +} + +.empty-hint { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-muted); + font-size: 14px; + pointer-events: none; + background: rgba(255, 255, 255, 0.85); + padding: 8px 14px; + border-radius: var(--radius); +} + +.muted { color: var(--text-muted); } + +/* ---------- buttons ---------- */ + +.btn { + font: inherit; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + padding: 4px 10px; + border-radius: var(--radius); + cursor: pointer; + box-shadow: var(--shadow); +} +.btn:hover { background: var(--surface-2); } +.btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; } + +.btn-tiny { padding: 2px 8px; font-size: 12px; } +.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); } +.btn-primary:hover { background: #155da3; } +.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); } +.btn-danger:hover { background: #b02828; } + +/* ---------- dialog ---------- */ + +.modal { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0; + width: 380px; + max-width: calc(100vw - 32px); + background: var(--surface); + box-shadow: 0 10px 30px rgba(0,0,0,0.18); +} +.modal::backdrop { background: rgba(0,0,0,0.3); } +.modal form { padding: 16px; } +.modal h2 { margin: 0 0 12px 0; font-size: 16px; } +.modal .banner { + background: #fff8e1; + border: 1px solid #f5d76e; + color: #5b4500; + padding: 8px 10px; + border-radius: var(--radius); + font-size: 13px; + margin: 0 0 12px 0; +} +.modal .actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} +.modal .form-error { + color: var(--danger); + font-size: 13px; + margin: 6px 0 0 0; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0 0 10px 0; +} +.field span { + font-size: 12px; + color: var(--text-muted); +} +.field input, +.field textarea { + font: inherit; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; + width: 100%; +} +.field input:focus, +.field textarea:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; + border-color: var(--accent); +} diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..053c5b1 --- /dev/null +++ b/web/web.go @@ -0,0 +1,23 @@ +// Package web bundles the frontend (HTML/JS/CSS) into the Go binary +// via embed.FS so deploying mCables means shipping one file. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:static +var assets embed.FS + +// Static returns the frontend filesystem rooted at the package's static/ +// dir so callers see index.html at "/". +func Static() fs.FS { + sub, err := fs.Sub(assets, "static") + if err != nil { + // embed sub-rooting can only fail if "static" doesn't exist, + // which is a build-time error. Panic is the right shape. + panic(err) + } + return sub +}