diff --git a/web/static/index.html b/web/static/index.html index db62865..e14ba7a 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -23,6 +23,7 @@ + @@ -224,6 +225,23 @@ + + +
+
+

Admin

+ +
+ +
+
+
+ diff --git a/web/static/main.js b/web/static/main.js index 70080e7..6c876b4 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -85,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}`); @@ -113,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); @@ -2463,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) => ` +
+
+ ${escapeHtml(p.name)} + #${p.id} +
+ + + +
+ + +
+
+ `).join("") || `

No projects.

`; + body.innerHTML = ` +

+ Rename, retitle the drawing, or change the description. Delete cascades all frames / + devices / cables / etc. in the project (cable types are global and unaffected). +

+ ${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) => ` +
+
+ ${escapeHtml(t.name)} + #${t.id} +
+ + +
+ + +
+
+ `).join("") || `

No cable types.

`; + body.innerHTML = ` + + ${rows} +
+
New cable type
+ + +
+ +
+
+ `; + 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 = ` +

+ 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). +

`; + 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) => + `
  • ` + + `${escapeHtml(cableTypeName.get(p.cable_type_id) || "?")} × ${p.count} (${escapeHtml(p.edge)})
  • `, + ).join(""); + const builtIns = state.deviceTypes.filter((t) => t.built_in); + const customs = state.deviceTypes.filter((t) => !t.built_in); + const builtRows = builtIns.map((t) => ` +
    +
    + ${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)} + · ${escapeHtml(t.kind || "")} + + built-in +
    +

    ${escapeHtml(t.description || "")}

    + +
    + `).join(""); + const customRows = customs.map((t) => ` +
    +
    + ${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)} + #${t.id} +
    + + + + + +
    + + +
    +
    + `).join("") || `

    No project-custom types yet.

    `; + body.innerHTML = ` +

    + 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. +

    +

    Built-in (${builtIns.length})

    + ${builtRows} +

    Project-custom (${customs.length})

    + ${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) => + `
  • ${escapeHtml(d.suggested_name ?? dt(d))} (${escapeHtml(dt(d))})
  • `, + ).join("") || `
  • no devices
  • `; + 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 `
  • ${escapeHtml(an)} ↔ ${escapeHtml(bn)} · ${escapeHtml(ct ?? "solver picks")} · ${tag}
  • `; + }).join("") || `
  • no requirements
  • `; + return ` +
    +
    + ${escapeHtml(t.name)} + ${t.built_in ? "built-in" : "custom"} +
    +

    ${escapeHtml(t.description || "")}

    +
    + Devices (${t.devices.length}) + +
    +
    + Requirements (${t.requirements.length}) + +
    +
    + `; + }).join("") || `

    No setup templates.

    `; + body.innerHTML = ` +

    + 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. +

    + ${rows} + `; +} + // ---------- boot ---------- // async function boot() { @@ -2473,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; } diff --git a/web/static/style.css b/web/static/style.css index d093a3f..4009863 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -376,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;