merge: admin modal — projects + cable types + device types + templates
⚙ button in header opens a tabbed modal: - Projects: list, rename name/drawing_name/description, delete with typed-name confirm. patchProject API helper added. - Cable types: global-scope banner, name + colour edit + delete (blocked on use) + add. - Device types: built-ins read-only with locked badge; project-custom name/kind/icon/description CRUD. Port-profile reshape deferred — flagged in the UI. - Setup templates: read-only with expanded member devices + requirements. Modal over full page — fits the no-build vanilla-JS shape. Verified on mDock (PATCH project rename + description round-trips).
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
|
||||
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
|
||||
<button type="button" id="btn-export" class="btn">Export</button>
|
||||
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
|
||||
<span id="toast" class="toast" hidden></span>
|
||||
</header>
|
||||
|
||||
@@ -224,6 +225,23 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Admin: projects + cable types + device types + setup templates -->
|
||||
<dialog id="modal-admin" class="modal modal-wide" aria-labelledby="adm-title">
|
||||
<div class="admin-shell">
|
||||
<header class="admin-header">
|
||||
<h2 id="adm-title">Admin</h2>
|
||||
<button type="button" class="btn btn-link admin-close" data-close>✕</button>
|
||||
</header>
|
||||
<nav class="admin-tabs" role="tablist">
|
||||
<button type="button" class="admin-tab" data-admin-tab="projects" role="tab" aria-selected="true">Projects</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="cable-types" role="tab">Cable types</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="device-types" role="tab">Device types</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="setup-templates" role="tab">Setup templates</button>
|
||||
</nav>
|
||||
<section class="admin-body" id="admin-body" role="tabpanel"></section>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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) => `
|
||||
<div class="admin-row" data-project-id="${p.id}">
|
||||
<div class="admin-row-title">
|
||||
<span>${escapeHtml(p.name)}</span>
|
||||
<span style="color: var(--text-muted); font-size: 11px;">#${p.id}</span>
|
||||
</div>
|
||||
<label class="field"><span>Name</span>
|
||||
<input class="adm-name" type="text" value="${escapeHtml(p.name)}" />
|
||||
</label>
|
||||
<label class="field"><span>Drawing name</span>
|
||||
<input class="adm-drawing" type="text" value="${escapeHtml(p.drawing_name)}" />
|
||||
</label>
|
||||
<label class="field"><span>Description</span>
|
||||
<textarea class="adm-desc" rows="2">${escapeHtml(p.description ?? "")}</textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-tiny adm-save">Save</button>
|
||||
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete…</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("") || `<p class="admin-empty">No projects.</p>`;
|
||||
body.innerHTML = `
|
||||
<p class="muted" style="font-size:12px;margin:0 0 12px 0;">
|
||||
Rename, retitle the drawing, or change the description. Delete cascades all frames /
|
||||
devices / cables / etc. in the project (cable types are global and unaffected).
|
||||
</p>
|
||||
${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) => `
|
||||
<div class="admin-row" data-cable-type-id="${t.id}">
|
||||
<div class="admin-row-title">
|
||||
<span><span class="swatch" style="background:${t.color}"></span>${escapeHtml(t.name)}</span>
|
||||
<span style="color: var(--text-muted); font-size: 11px;">#${t.id}</span>
|
||||
</div>
|
||||
<label class="field"><span>Name</span>
|
||||
<input class="adm-name" type="text" value="${escapeHtml(t.name)}" />
|
||||
</label>
|
||||
<label class="field"><span>Colour</span>
|
||||
<input class="adm-color" type="color" value="${t.color}" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-tiny adm-save">Save</button>
|
||||
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("") || `<p class="admin-empty">No cable types.</p>`;
|
||||
body.innerHTML = `
|
||||
<p class="banner" style="margin:0 0 12px 0;">
|
||||
Cable types are <strong>global</strong> — renaming or recolouring affects every project.
|
||||
</p>
|
||||
${rows}
|
||||
<div class="admin-add-row">
|
||||
<div class="admin-row-title"><span>New cable type</span></div>
|
||||
<label class="field"><span>Name</span>
|
||||
<input id="adm-ct-new-name" type="text" placeholder="e.g. SATA" />
|
||||
</label>
|
||||
<label class="field"><span>Colour</span>
|
||||
<input id="adm-ct-new-color" type="color" value="#1971c2" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-primary btn-tiny" id="adm-ct-new-create">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<p class="admin-empty">
|
||||
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).
|
||||
</p>`;
|
||||
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) =>
|
||||
`<li><span class="swatch" style="background:${cableTypeColor.get(p.cable_type_id) || "#888"}"></span>` +
|
||||
`${escapeHtml(cableTypeName.get(p.cable_type_id) || "?")} × ${p.count} <span class="muted">(${escapeHtml(p.edge)})</span></li>`,
|
||||
).join("");
|
||||
const builtIns = state.deviceTypes.filter((t) => t.built_in);
|
||||
const customs = state.deviceTypes.filter((t) => !t.built_in);
|
||||
const builtRows = builtIns.map((t) => `
|
||||
<div class="admin-row locked" data-device-type-id="${t.id}">
|
||||
<div class="admin-row-title">
|
||||
<span>${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)}
|
||||
<span class="muted" style="font-weight:normal;font-size:11px;">· ${escapeHtml(t.kind || "")}</span>
|
||||
</span>
|
||||
<span class="locked-badge">built-in</span>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(t.description || "")}</p>
|
||||
<ul class="port-profile-list">${portsLine(t.ports || [])}</ul>
|
||||
</div>
|
||||
`).join("");
|
||||
const customRows = customs.map((t) => `
|
||||
<div class="admin-row" data-device-type-id="${t.id}">
|
||||
<div class="admin-row-title">
|
||||
<span>${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)}</span>
|
||||
<span style="color: var(--text-muted); font-size: 11px;">#${t.id}</span>
|
||||
</div>
|
||||
<label class="field"><span>Name</span>
|
||||
<input class="adm-name" type="text" value="${escapeHtml(t.name)}" />
|
||||
</label>
|
||||
<label class="field"><span>Kind</span>
|
||||
<input class="adm-kind" type="text" value="${escapeHtml(t.kind || "")}" />
|
||||
</label>
|
||||
<label class="field"><span>Icon</span>
|
||||
<input class="adm-icon" type="text" value="${escapeHtml(t.icon || "")}" />
|
||||
</label>
|
||||
<label class="field"><span>Description</span>
|
||||
<input class="adm-desc" type="text" value="${escapeHtml(t.description || "")}" />
|
||||
</label>
|
||||
<ul class="port-profile-list">${portsLine(t.ports || []) || '<li class="muted">no port profile</li>'}</ul>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-tiny adm-save">Save</button>
|
||||
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("") || `<p class="admin-empty">No project-custom types yet.</p>`;
|
||||
body.innerHTML = `
|
||||
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
|
||||
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.
|
||||
</p>
|
||||
<h3 style="margin:8px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Built-in (${builtIns.length})</h3>
|
||||
${builtRows}
|
||||
<h3 style="margin:16px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Project-custom (${customs.length})</h3>
|
||||
${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) =>
|
||||
`<li>${escapeHtml(d.suggested_name ?? dt(d))} <span class="muted">(${escapeHtml(dt(d))})</span></li>`,
|
||||
).join("") || `<li class="muted">no devices</li>`;
|
||||
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 `<li>${escapeHtml(an)} ↔ ${escapeHtml(bn)} <span class="muted">· ${escapeHtml(ct ?? "solver picks")} · ${tag}</span></li>`;
|
||||
}).join("") || `<li class="muted">no requirements</li>`;
|
||||
return `
|
||||
<div class="admin-row locked" data-template-id="${t.id}">
|
||||
<div class="admin-row-title">
|
||||
<span>${escapeHtml(t.name)}</span>
|
||||
<span class="locked-badge">${t.built_in ? "built-in" : "custom"}</span>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(t.description || "")}</p>
|
||||
<div class="tmpl-detail">
|
||||
<strong>Devices (${t.devices.length})</strong>
|
||||
<ul>${devsHtml}</ul>
|
||||
</div>
|
||||
<div class="tmpl-detail">
|
||||
<strong>Requirements (${t.requirements.length})</strong>
|
||||
<ul>${reqsHtml}</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("") || `<p class="admin-empty">No setup templates.</p>`;
|
||||
body.innerHTML = `
|
||||
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
|
||||
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.
|
||||
</p>
|
||||
${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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user