feat: frontend shell — project picker, legend, modals (new project / cable type / delete), URL ?project= state

This commit is contained in:
mAi
2026-05-15 16:45:29 +02:00
parent 1e3988161b
commit c13000ee7e
4 changed files with 741 additions and 0 deletions

135
web/static/index.html Normal file
View File

@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mCables</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header class="topbar">
<span class="brand">mCables</span>
<div class="project-picker">
<label for="project-select" class="sr-only">Project</label>
<select id="project-select" aria-label="Active project">
<option value="">— no project —</option>
</select>
<button type="button" id="btn-new-project" class="btn">+ Project</button>
<button type="button" id="btn-delete-project" class="btn btn-danger" hidden>
Delete
</button>
</div>
<div class="topbar-spacer"></div>
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
Export
</button>
</header>
<main class="layout">
<aside class="sidebar" aria-label="Tools">
<section class="legend">
<h2 class="sidebar-heading">Cable types</h2>
<ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section>
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>
<ul class="tool-list">
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Frame</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Device</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
</ul>
</section>
</aside>
<section class="canvas-wrap" aria-label="Diagram">
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
<g id="canvas-frames"></g>
<g id="canvas-devices"></g>
<g id="canvas-ports"></g>
<g id="canvas-cables"></g>
<g id="canvas-io"></g>
</svg>
<p id="empty-hint" class="empty-hint">
Pick or create a project to start drawing.
</p>
</section>
<aside class="inspector" aria-label="Inspector">
<h2 class="sidebar-heading">Inspector</h2>
<p class="muted">Nothing selected.</p>
</aside>
</main>
<!-- New Project modal -->
<dialog id="modal-new-project" class="modal" aria-labelledby="np-title">
<form method="dialog" id="form-new-project">
<h2 id="np-title">New project</h2>
<label class="field">
<span>Name</span>
<input type="text" name="name" required autocomplete="off" />
</label>
<label class="field">
<span>Drawing name</span>
<input type="text" name="drawing_name" autocomplete="off"
placeholder="auto: <name>.excalidraw" />
</label>
<label class="field">
<span>Description</span>
<textarea name="description" rows="2"></textarea>
</label>
<p class="form-error" id="np-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Create</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- New/Edit Cable Type modal -->
<dialog id="modal-cable-type" class="modal" aria-labelledby="ct-title">
<form method="dialog" id="form-cable-type">
<h2 id="ct-title">Cable type</h2>
<p class="banner">
Cable types are shared across all projects. Renaming or recolouring
affects every project.
</p>
<label class="field">
<span>Name</span>
<input type="text" name="name" required autocomplete="off" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" name="color" value="#1971c2" />
</label>
<p class="form-error" id="ct-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Delete Project confirm -->
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
<form method="dialog" id="form-delete-project">
<h2 id="dp-title">Delete project</h2>
<p>
This will cascade-delete every frame, device, port, cable, IO marker
and bundle in the project. <strong>Cable types are global and are not affected.</strong>
</p>
<p>Type the project name to confirm:</p>
<input type="text" name="confirm" required autocomplete="off"
id="dp-confirm-input" />
<p class="form-error" id="dp-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-danger">Delete</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<script type="module" src="/main.js"></script>
</body>
</html>

335
web/static/main.js Normal file
View File

@@ -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=<id>.
/**
* @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 = `
<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);
} 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();

248
web/static/style.css Normal file
View File

@@ -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);
}