feat: frontend shell — project picker, legend, modals (new project / cable type / delete), URL ?project= state
This commit is contained in:
335
web/static/main.js
Normal file
335
web/static/main.js
Normal 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();
|
||||
Reference in New Issue
Block a user