// ── API client ──────────────────────────────────────────────────────────────── const api = { async req(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 data = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(data.error || `HTTP ${res.status}`); err.status = res.status; err.details = data.details; throw err; } return data; }, hosts: { search: (q) => api.req('GET', `/hosts${q ? `?q=${encodeURIComponent(q)}` : ''}`), get: (id) => api.req('GET', `/hosts/${id}`), getByHardwareId: (hwid) => api.req('GET', `/hosts/by-hardware-id/${encodeURIComponent(hwid)}`), create: (h) => api.req('POST', '/hosts', h), update: (id, h) => api.req('PUT', `/hosts/${id}`, h), delete: (id) => api.req('DELETE', `/hosts/${id}`), }, sites: { list: () => api.req('GET', '/sites'), create: (name) => api.req('POST', '/sites', { name }), update: (id, name) => api.req('PUT', `/sites/${id}`, { name }), delete: (id) => api.req('DELETE', `/sites/${id}`), }, rooms: { list: (siteId) => api.req('GET', `/rooms${siteId ? `?site_id=${siteId}` : ''}`), create: (site_id, name) => api.req('POST', '/rooms', { site_id, name }), update: (id, site_id, name) => api.req('PUT', `/rooms/${id}`, { site_id, name }), delete: (id) => api.req('DELETE', `/rooms/${id}`), }, serverTypes: { list: () => api.req('GET', '/server-types'), create: (name) => api.req('POST', '/server-types', { name }), update: (id, name) => api.req('PUT', `/server-types/${id}`, { name }), delete: (id) => api.req('DELETE', `/server-types/${id}`), }, interfaces: { list: (hostId) => api.req('GET', `/interfaces?host_id=${hostId}`), create: (body) => api.req('POST', '/interfaces', body), update: (id, body) => api.req('PUT', `/interfaces/${id}`, body), delete: (id) => api.req('DELETE', `/interfaces/${id}`), }, }; const IFACE_FIELDS = ['name', 'mac_address', 'ip_address', 'subnet', 'link_speed']; const IFACE_PLACEHOLDERS = { name: 'eth0', mac_address: 'aa:bb:cc:dd:ee:ff', ip_address: '10.0.0.1', subnet: '10.0.0.0/24', link_speed: '1000/full', }; // ── Helpers ─────────────────────────────────────────────────────────────────── const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); function el(tag, attrs = {}, ...children) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'class') node.className = v; else if (k === 'dataset') Object.assign(node.dataset, v); else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v); else if (v === true) node.setAttribute(k, ''); else if (v !== false && v != null) node.setAttribute(k, v); } for (const c of children.flat()) { if (c == null || c === false) continue; node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } return node; } function openModal(id) { const m = document.getElementById(id); m.hidden = false; } function closeModal(id) { const m = document.getElementById(id); m.hidden = true; } // ── Routing ─────────────────────────────────────────────────────────────────── const heroEl = $('#hero'); const resultsEl = $('#results'); const detailEl = $('#detail'); const resultsBody = $('#results-body'); const resultsEmpty = $('#results-empty'); const searchInput = $('#search-input'); let currentView = 'search'; let currentHardwareId = null; function navigate(path, { replace = false } = {}) { history[replace ? 'replaceState' : 'pushState']({}, '', path); handleRoute(); } function handleRoute() { const m = /^\/hosts\/(.+)$/.exec(location.pathname); if (m) showDetail(decodeURIComponent(m[1])); else showSearch(); } function showSearch() { currentView = 'search'; currentHardwareId = null; detailEl.hidden = true; heroEl.hidden = false; // Preserve whatever results are already rendered (don't auto-trigger lookup) const hasInput = searchInput.value.trim().length > 0; heroEl.classList.toggle('compact', hasInput); const hasContent = resultsBody.children.length > 0 || !resultsEmpty.hidden; resultsEl.hidden = !(hasInput && hasContent); } async function showDetail(hardwareId) { currentView = 'detail'; currentHardwareId = hardwareId; heroEl.hidden = true; resultsEl.hidden = true; detailEl.hidden = false; await renderDetail(hardwareId); } window.addEventListener('popstate', handleRoute); // ── Lookup ──────────────────────────────────────────────────────────────────── async function runLookup() { if (currentView !== 'search') return; const q = searchInput.value.trim(); if (!q) return; heroEl.classList.add('compact'); resultsEl.hidden = false; resultsBody.replaceChildren(); resultsEmpty.hidden = false; resultsEmpty.textContent = 'Looking up…'; let rows = []; try { rows = await api.hosts.search(q); } catch (err) { resultsEmpty.textContent = `Lookup failed: ${err.message}`; return; } if (rows.length === 1) { // Clear the lookup-in-progress state so back-navigation lands clean resultsEmpty.hidden = true; resultsEl.hidden = true; navigate(`/hosts/${encodeURIComponent(rows[0].hardware_id)}`); return; } if (rows.length === 0) { resultsEmpty.textContent = 'Host not found.'; return; } renderResults(rows); } function renderResults(rows) { resultsBody.replaceChildren(); resultsEmpty.hidden = rows.length > 0; if (rows.length === 0) { resultsEmpty.textContent = 'Host not found.'; return; } for (const h of rows) { resultsBody.appendChild(renderHostRow(h)); } } function renderHostRow(host) { const link = el('a', { class: 'host-link', href: `/hosts/${encodeURIComponent(host.hardware_id)}`, 'data-nav': '', }, host.hostname); const row = el('tr', { dataset: { id: host.id } }, el('td', {}, link), el('td', {}, host.hardware_id), el('td', {}, host.asset_id), el('td', { class: 'actions-cell' }, el('button', { class: 'btn-link', onclick: () => openHostModal(host) }, 'Edit'), el('button', { class: 'btn-link btn-link-danger', onclick: (e) => beginDelete(e, host) }, 'Delete'), ), ); return row; } function beginDelete(e, host) { const cell = e.target.closest('td'); const original = cell.cloneNode(true); // re-wire the original's button handlers when we restore it const restore = () => { cell.replaceWith(original); // rebind handlers on the restored cell const [editBtn, delBtn] = original.querySelectorAll('button'); editBtn.addEventListener('click', () => openHostModal(host)); delBtn.addEventListener('click', (ev) => beginDelete(ev, host)); }; cell.replaceChildren( el('span', { class: 'confirm-inline' }, 'Delete this host?', el('button', { class: 'btn-link btn-link-danger', onclick: async () => { try { await api.hosts.delete(host.id); cell.closest('tr').remove(); if (resultsBody.children.length === 0) { resultsEmpty.hidden = false; resultsEmpty.textContent = 'No hosts match.'; } } catch (err) { alert(`Delete failed: ${err.message}`); restore(); } }, }, 'Yes'), el('button', { class: 'btn-link', onclick: restore }, 'No'), ), ); } $('#lookup-form').addEventListener('submit', (e) => { e.preventDefault(); runLookup(); }); // ── Detail page ─────────────────────────────────────────────────────────────── async function renderDetail(hardwareId) { detailEl.replaceChildren(el('div', { class: 'manage-empty' }, 'Loading…')); let host; try { host = await api.hosts.getByHardwareId(hardwareId); } catch (err) { detailEl.replaceChildren( el('a', { class: 'back-link', href: '/', 'data-nav': '' }, '\u2190 Back to search'), el('div', { class: 'banner banner-error' }, err.status === 404 ? 'Host not found.' : `Failed to load host: ${err.message}`), ); return; } const back = el('a', { class: 'back-link', href: '/', 'data-nav': '' }, '\u2190 Back to search'); const title = el('h1', { class: 'detail-title' }, host.hostname); const subtitle = el('p', { class: 'detail-subtitle' }, `${host.site_name} \u00b7 ${host.room_name}${host.position ? ` \u00b7 ${host.position}` : ''}`); const list = el('dl', { class: 'detail-list' }, el('dt', {}, 'Hardware ID'), el('dd', {}, host.hardware_id), el('dt', {}, 'Asset ID'), el('dd', {}, host.asset_id), el('dt', {}, 'Site'), el('dd', {}, host.site_name), el('dt', {}, 'Room'), el('dd', {}, host.room_name), el('dt', {}, 'Position'), host.position ? el('dd', {}, host.position) : el('dd', { class: 'empty' }, '\u2014'), el('dt', {}, 'Server Type'), el('dd', {}, host.server_type), el('dt', {}, 'Created'), el('dd', {}, host.created_at), el('dt', {}, 'Updated'), el('dd', {}, host.updated_at), ); const actions = el('div', { class: 'detail-actions', id: 'detail-actions' }, el('button', { class: 'btn btn-primary', onclick: () => openHostModal(host) }, 'Edit'), el('button', { class: 'btn btn-ghost', onclick: () => beginDeleteFromDetail(host) }, 'Delete'), ); const interfacesSection = await renderInterfacesSection(host); detailEl.replaceChildren(back, title, subtitle, list, interfacesSection, actions); } async function renderInterfacesSection(host) { const tbody = el('tbody', { id: 'interfaces-body' }); const empty = el('div', { class: 'results-empty', id: 'interfaces-empty', hidden: true }, 'No interfaces.'); const addBtn = el('button', { class: 'btn btn-ghost btn-sm' }, '+ Add interface'); addBtn.addEventListener('click', () => { empty.hidden = true; tbody.appendChild(buildIfaceEditRow(host, null)); }); const section = el('section', { class: 'interfaces' }, el('div', { class: 'interfaces-header' }, el('h2', {}, 'Network'), addBtn, ), el('table', { class: 'table' }, el('thead', {}, el('tr', {}, el('th', {}, 'Interface'), el('th', {}, 'Ethernet'), el('th', {}, 'IP Address'), el('th', {}, 'Subnet'), el('th', {}, 'Link'), el('th', { class: 'actions-col' }), ), ), tbody, ), empty, ); let ifaces = []; try { ifaces = await api.interfaces.list(host.id); } catch (err) { section.appendChild(el('div', { class: 'iface-error' }, `Failed to load interfaces: ${err.message}`)); return section; } if (ifaces.length === 0) { empty.hidden = false; } else { for (const iface of ifaces) tbody.appendChild(buildIfaceRow(host, iface)); } return section; } function fieldOrDash(value) { return value ? value : el('span', { class: 'muted' }, '\u2014'); } function buildIfaceRow(host, iface) { const row = el('tr', { dataset: { ifaceId: iface.id } }); const cells = IFACE_FIELDS.map((f) => el('td', {}, fieldOrDash(iface[f]))); const actions = el('td', { class: 'actions-cell' }); row.append(...cells, actions); setIfaceDisplayActions(row, host, iface, actions); return row; } function setIfaceDisplayActions(row, host, iface, actionsCell) { actionsCell.replaceChildren( el('button', { class: 'btn-link', onclick: () => enterIfaceEdit(row, host, iface) }, 'Edit'), el('button', { class: 'btn-link btn-link-danger', onclick: () => beginIfaceDelete(row, host, iface, actionsCell), }, 'Delete'), ); } function enterIfaceEdit(row, host, iface) { const editRow = buildIfaceEditRow(host, iface); row.replaceWith(editRow); } function buildIfaceEditRow(host, iface) { const isNew = iface == null; const row = el('tr', { class: 'iface-edit-row' }); const inputs = {}; for (const f of IFACE_FIELDS) { const input = el('input', { class: 'iface-edit-input', type: 'text', name: f, placeholder: IFACE_PLACEHOLDERS[f], value: iface?.[f] ?? '', }); inputs[f] = input; row.appendChild(el('td', {}, input)); } const actions = el('td', { class: 'actions-cell' }); row.appendChild(actions); let errorRow = null; const showError = (msg) => { clearError(); errorRow = el('tr', { class: 'iface-error-row' }, el('td', { colspan: IFACE_FIELDS.length + 1 }, el('div', { class: 'iface-error' }, msg))); row.parentNode.insertBefore(errorRow, row); }; const clearError = () => { if (errorRow) { errorRow.remove(); errorRow = null; } }; const cancel = el('button', { class: 'btn-link', onclick: () => { clearError(); if (isNew) { row.remove(); const tbody = $('#interfaces-body'); if (tbody && tbody.children.length === 0) { const emptyEl = $('#interfaces-empty'); if (emptyEl) emptyEl.hidden = false; } } else { const display = buildIfaceRow(host, iface); row.replaceWith(display); } }, }, 'Cancel'); const save = el('button', { class: 'btn-link', onclick: async () => { clearError(); const payload = { host_id: host.id }; for (const f of IFACE_FIELDS) payload[f] = inputs[f].value.trim(); if (!payload.name) { showError('Interface name is required.'); return; } try { const saved = isNew ? await api.interfaces.create(payload) : await api.interfaces.update(iface.id, payload); const display = buildIfaceRow(host, saved); row.replaceWith(display); const emptyEl = $('#interfaces-empty'); if (emptyEl) emptyEl.hidden = true; } catch (err) { const detailMsg = err.details?.length ? `: ${err.details.join('; ')}` : ''; showError(`${err.message}${detailMsg}`); } }, }, 'Save'); actions.append(save, cancel); return row; } function beginIfaceDelete(row, host, iface, actionsCell) { actionsCell.replaceChildren( el('span', { class: 'confirm-inline' }, 'Delete this interface?', el('button', { class: 'btn-link btn-link-danger', onclick: async () => { try { await api.interfaces.delete(iface.id); row.remove(); const tbody = $('#interfaces-body'); if (tbody && tbody.children.length === 0) { const emptyEl = $('#interfaces-empty'); if (emptyEl) emptyEl.hidden = false; } } catch (err) { alert(`Delete failed: ${err.message}`); setIfaceDisplayActions(row, host, iface, actionsCell); } }, }, 'Yes'), el('button', { class: 'btn-link', onclick: () => setIfaceDisplayActions(row, host, iface, actionsCell), }, 'No'), ), ); } function beginDeleteFromDetail(host) { const actions = $('#detail-actions'); if (!actions) return; actions.replaceChildren( el('span', { class: 'confirm-inline' }, 'Delete this host?', el('button', { class: 'btn btn-danger btn-sm', onclick: async () => { try { await api.hosts.delete(host.id); navigate('/'); } catch (err) { alert(`Delete failed: ${err.message}`); renderDetail(host.hardware_id); } }, }, 'Yes, delete'), el('button', { class: 'btn btn-ghost btn-sm', onclick: () => renderDetail(host.hardware_id) }, 'Cancel'), ), ); } // ── Host modal ──────────────────────────────────────────────────────────────── const hostModal = $('#host-modal'); const hostForm = $('#host-form'); const hostError = $('#host-error'); const hostTitle = $('#host-modal-title'); let hostBeingEdited = null; let cachedSites = []; let cachedServerTypes = []; async function openHostModal(host = null) { hostBeingEdited = host; hostTitle.textContent = host ? 'Edit host' : 'New host'; hostError.hidden = true; hostError.textContent = ''; hostForm.reset(); await loadDropdowns(); if (host) { hostForm.elements.hostname.value = host.hostname; hostForm.elements.hardware_id.value = host.hardware_id; hostForm.elements.asset_id.value = host.asset_id; hostForm.elements.position.value = host.position ?? ''; hostForm.elements.site_id.value = host.site_id; await refreshRoomsSelect(host.site_id, host.room_id); hostForm.elements.server_type_id.value = host.server_type_id; } else { if (cachedSites[0]) { hostForm.elements.site_id.value = cachedSites[0].id; await refreshRoomsSelect(cachedSites[0].id); } if (cachedServerTypes[0]) { hostForm.elements.server_type_id.value = cachedServerTypes[0].id; } } openModal('host-modal'); setTimeout(() => hostForm.elements.hostname.focus(), 50); } async function loadDropdowns() { const [sites, serverTypes] = await Promise.all([ api.sites.list(), api.serverTypes.list(), ]); cachedSites = sites; cachedServerTypes = serverTypes; const siteSel = hostForm.elements.site_id; siteSel.replaceChildren(...sites.map((s) => el('option', { value: s.id }, s.name))); const typeSel = hostForm.elements.server_type_id; typeSel.replaceChildren(...serverTypes.map((t) => el('option', { value: t.id }, t.name))); } async function refreshRoomsSelect(siteId, selectedRoomId = null) { const roomSel = hostForm.elements.room_id; if (!siteId) { roomSel.replaceChildren(); return; } const rooms = await api.rooms.list(siteId); roomSel.replaceChildren(...rooms.map((r) => el('option', { value: r.id }, r.name))); if (selectedRoomId != null) roomSel.value = selectedRoomId; } hostForm.elements.site_id.addEventListener('change', (e) => { refreshRoomsSelect(Number(e.target.value)); }); hostForm.addEventListener('submit', async (e) => { e.preventDefault(); hostError.hidden = true; const fd = new FormData(hostForm); const payload = { hostname: fd.get('hostname').trim(), hardware_id: fd.get('hardware_id').trim(), asset_id: fd.get('asset_id').trim(), room_id: Number(fd.get('room_id')), position: (fd.get('position') ?? '').trim(), server_type_id: Number(fd.get('server_type_id')), }; let saved; try { saved = hostBeingEdited ? await api.hosts.update(hostBeingEdited.id, payload) : await api.hosts.create(payload); } catch (err) { hostError.textContent = err.details?.length ? `${err.message}: ${err.details.join('; ')}` : err.message; hostError.hidden = false; return; } closeModal('host-modal'); // Always land on the saved host's page. If we were already on detail and the // hardware_id didn't change, replace so back doesn't bounce. const url = `/hosts/${encodeURIComponent(saved.hardware_id)}`; const replace = currentView === 'detail' && currentHardwareId === saved.hardware_id; navigate(url, { replace }); }); $('#new-host-btn').addEventListener('click', () => openHostModal(null)); $('.brand').addEventListener('click', () => { searchInput.value = ''; resultsBody.replaceChildren(); resultsEmpty.hidden = true; resultsEmpty.textContent = 'No hosts match.'; }); // ── Manage modal ────────────────────────────────────────────────────────────── const manageModal = $('#manage-modal'); const manageBody = $('#manage-body'); let activeTab = 'sites'; $('#manage-btn').addEventListener('click', async () => { openModal('manage-modal'); await renderManageTab(); }); $$('.tab').forEach((btn) => { btn.addEventListener('click', async () => { $$('.tab').forEach((b) => b.classList.toggle('tab-active', b === btn)); activeTab = btn.dataset.tab; await renderManageTab(); }); }); async function renderManageTab() { manageBody.replaceChildren(el('div', { class: 'manage-empty' }, 'Loading…')); if (activeTab === 'sites') await renderSitesTab(); if (activeTab === 'rooms') await renderRoomsTab(); if (activeTab === 'server-types') await renderTypesTab(); } async function renderSitesTab() { const items = await api.sites.list(); manageBody.replaceChildren(buildLookupList({ items, placeholder: 'New site name', onCreate: (name) => api.sites.create(name), onUpdate: (id, name) => api.sites.update(id, name), onDelete: (id) => api.sites.delete(id), rerender: renderSitesTab, })); } async function renderTypesTab() { const items = await api.serverTypes.list(); manageBody.replaceChildren(buildLookupList({ items, placeholder: 'New server type', onCreate: (name) => api.serverTypes.create(name), onUpdate: (id, name) => api.serverTypes.update(id, name), onDelete: (id) => api.serverTypes.delete(id), rerender: renderTypesTab, })); } function buildLookupList({ items, placeholder, onCreate, onUpdate, onDelete, rerender }) { const list = el('div', { class: 'manage-list' }); // Add row const addInput = el('input', { type: 'text', placeholder, maxlength: 100 }); const addBtn = el('button', { class: 'btn btn-primary btn-sm', onclick: async () => { const name = addInput.value.trim(); if (!name) return; try { await onCreate(name); await rerender(); } catch (err) { alert(err.message); } }, }, 'Add'); list.appendChild(el('div', { class: 'manage-row add-row' }, addInput, addBtn)); if (items.length === 0) { list.appendChild(el('div', { class: 'manage-empty' }, 'No entries yet.')); return list; } for (const item of items) { list.appendChild(buildLookupRow(item, onUpdate, onDelete, rerender)); } return list; } function buildLookupRow(item, onUpdate, onDelete, rerender) { const row = el('div', { class: 'manage-row' }); const labelCell = el('div', { class: 'label' }, item.name); const actions = el('div', {}); const editBtn = el('button', { class: 'btn-link', onclick: () => enterEdit(), }, 'Edit'); const delBtn = el('button', { class: 'btn-link btn-link-danger', onclick: () => confirmDelete(), }, 'Delete'); actions.append(editBtn, delBtn); row.append(labelCell, actions); function enterEdit() { const input = el('input', { type: 'text', value: item.name, maxlength: 100 }); const save = el('button', { class: 'btn-link', onclick: async () => { const name = input.value.trim(); if (!name) return; try { await onUpdate(item.id, name); await rerender(); } catch (err) { alert(err.message); } }, }, 'Save'); const cancel = el('button', { class: 'btn-link', onclick: rerender }, 'Cancel'); labelCell.replaceChildren(input); actions.replaceChildren(save, cancel); } function confirmDelete() { actions.replaceChildren( el('span', { class: 'confirm-inline' }, 'Delete?', el('button', { class: 'btn-link btn-link-danger', onclick: async () => { try { await onDelete(item.id); await rerender(); } catch (err) { alert(err.message); rerender(); } }, }, 'Yes'), el('button', { class: 'btn-link', onclick: rerender }, 'No'), ), ); } return row; } async function renderRoomsTab() { const [rooms, sites] = await Promise.all([api.rooms.list(), api.sites.list()]); if (sites.length === 0) { manageBody.replaceChildren(el('div', { class: 'manage-empty' }, 'Add a site first.')); return; } const list = el('div', { class: 'manage-list' }); // Add row const siteSel = el('select', {}, ...sites.map((s) => el('option', { value: s.id }, s.name))); const nameInput = el('input', { type: 'text', placeholder: 'New room name', maxlength: 100 }); const addBtn = el('button', { class: 'btn btn-primary btn-sm', onclick: async () => { const name = nameInput.value.trim(); if (!name) return; try { await api.rooms.create(Number(siteSel.value), name); await renderRoomsTab(); } catch (err) { alert(err.message); } }, }, 'Add'); list.appendChild(el('div', { class: 'manage-row add-row' }, el('div', { class: 'grow' }, siteSel, nameInput), addBtn, )); if (rooms.length === 0) { list.appendChild(el('div', { class: 'manage-empty' }, 'No rooms yet.')); manageBody.replaceChildren(list); return; } for (const room of rooms) { list.appendChild(buildRoomRow(room, sites)); } manageBody.replaceChildren(list); } function buildRoomRow(room, sites) { const row = el('div', { class: 'manage-row' }); const label = el('div', { class: 'label' }, el('span', { class: 'muted' }, `${room.site_name} ·`), room.name); const actions = el('div', {}); const editBtn = el('button', { class: 'btn-link', onclick: enterEdit }, 'Edit'); const delBtn = el('button', { class: 'btn-link btn-link-danger', onclick: confirmDelete }, 'Delete'); actions.append(editBtn, delBtn); row.append(label, actions); function enterEdit() { const siteSel = el('select', {}, ...sites.map((s) => el('option', { value: s.id, selected: s.id === room.site_id }, s.name))); const input = el('input', { type: 'text', value: room.name, maxlength: 100 }); const save = el('button', { class: 'btn-link', onclick: async () => { const name = input.value.trim(); if (!name) return; try { await api.rooms.update(room.id, Number(siteSel.value), name); await renderRoomsTab(); } catch (err) { alert(err.message); } }, }, 'Save'); const cancel = el('button', { class: 'btn-link', onclick: renderRoomsTab }, 'Cancel'); label.replaceChildren(el('div', { class: 'grow' }, siteSel, input)); actions.replaceChildren(save, cancel); } function confirmDelete() { actions.replaceChildren( el('span', { class: 'confirm-inline' }, 'Delete?', el('button', { class: 'btn-link btn-link-danger', onclick: async () => { try { await api.rooms.delete(room.id); await renderRoomsTab(); } catch (err) { alert(err.message); renderRoomsTab(); } }, }, 'Yes'), el('button', { class: 'btn-link', onclick: renderRoomsTab }, 'No'), ), ); } return row; } // ── Modal close handlers ────────────────────────────────────────────────────── document.addEventListener('click', (e) => { const closeId = e.target.dataset?.closeModal; if (closeId) closeModal(closeId); // click on backdrop if (e.target.classList.contains('modal')) { closeModal(e.target.id); } // SPA navigation for in-app links const link = e.target.closest('a[data-nav]'); if (link && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && e.button === 0) { e.preventDefault(); navigate(link.getAttribute('href')); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { $$('.modal').forEach((m) => { if (!m.hidden) m.hidden = true; }); } }); // ── Bootstrap ───────────────────────────────────────────────────────────────── handleRoute();