/* vibedns management interface behaviour. * * Deliberately small and dependency-free beyond Bootstrap's own bundle: theme * persistence, toast display, confirmation dialogs, bulk selection, the * record-type editor, and a couple of small conveniences. There is no build * step and nothing is fetched from the network. */ (function () { 'use strict'; // --- Theme ------------------------------------------------------------ var THEME_KEY = 'vibedns.theme'; function storedTheme() { try { return localStorage.getItem(THEME_KEY); } catch (e) { return null; } } function applyTheme(theme) { document.documentElement.setAttribute('data-bs-theme', theme); try { localStorage.setItem(THEME_KEY, theme); } catch (e) { /* private mode */ } } function initTheme() { var saved = storedTheme(); if (!saved) { saved = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } document.documentElement.setAttribute('data-bs-theme', saved); var toggle = document.getElementById('themeToggle'); if (toggle) { toggle.addEventListener('click', function () { var current = document.documentElement.getAttribute('data-bs-theme'); applyTheme(current === 'dark' ? 'light' : 'dark'); }); } } // Apply before first paint to avoid a flash of the wrong theme. (function () { var saved = storedTheme(); if (saved) { document.documentElement.setAttribute('data-bs-theme', saved); } })(); // --- Toasts ----------------------------------------------------------- function initToasts() { document.querySelectorAll('#toastContainer .toast').forEach(function (el) { var autohide = el.getAttribute('data-autohide') !== 'false'; new bootstrap.Toast(el, { autohide: autohide, delay: 6000 }).show(); }); } /** Shows a transient message without a page reload. */ window.vibednsToast = function (level, message) { // Successful actions already update the page, so avoid distracting green // confirmation boxes. Warnings and errors remain visible. if (level === 'success') { return; } var container = document.getElementById('toastContainer'); if (!container) { return; } var el = document.createElement('div'); el.className = 'toast align-items-center border-0 text-bg-' + level; el.setAttribute('role', 'alert'); var body = document.createElement('div'); body.className = 'd-flex'; var text = document.createElement('div'); text.className = 'toast-body'; text.textContent = message; var close = document.createElement('button'); close.type = 'button'; close.className = 'btn-close btn-close-white me-2 m-auto'; close.setAttribute('data-bs-dismiss', 'toast'); close.setAttribute('aria-label', 'Close'); body.appendChild(text); body.appendChild(close); el.appendChild(body); container.appendChild(el); new bootstrap.Toast(el, { delay: 5000 }).show(); el.addEventListener('hidden.bs.toast', function () { el.remove(); }); }; // --- Confirmation dialogs -------------------------------------------- function initConfirmations() { var modalEl = document.getElementById('confirmModal'); if (!modalEl) { return; } var modal = new bootstrap.Modal(modalEl); var bodyEl = document.getElementById('confirmModalBody'); var acceptEl = document.getElementById('confirmModalAccept'); var pendingForm = null; var pendingButton = null; function ask(form, button, message) { pendingForm = form; pendingButton = button; bodyEl.textContent = message || 'This action cannot be undone.'; acceptEl.textContent = (form && form.dataset.confirmLabel) || 'Confirm'; modal.show(); } // A whole form marked js-confirm (single-action buttons such as Delete). document.addEventListener('submit', function (ev) { var form = ev.target; if (!form.classList || !form.classList.contains('js-confirm')) { return; } if (form.dataset.confirmed === 'true') { return; } ev.preventDefault(); ask(form, null, form.dataset.confirm); }); // An individual submit button inside a multi-action form (bulk toolbars), // where only one of the buttons is destructive. document.addEventListener('click', function (ev) { var btn = ev.target.closest('button.js-confirm-bulk'); if (!btn || btn.dataset.confirmed === 'true') { return; } var form = btn.form; if (!form) { return; } ev.preventDefault(); ask(form, btn, btn.dataset.confirm); }); acceptEl.addEventListener('click', function () { if (!pendingForm) { return; } modal.hide(); if (pendingButton) { pendingButton.dataset.confirmed = 'true'; // requestSubmit keeps the button's name/value in the submission, which // is how the server knows which bulk action was chosen. pendingForm.requestSubmit(pendingButton); } else { pendingForm.dataset.confirmed = 'true'; pendingForm.submit(); } pendingForm = null; pendingButton = null; }); modalEl.addEventListener('hidden.bs.modal', function () { pendingForm = null; pendingButton = null; }); } // --- Filter forms ----------------------------------------------------- /** Submits a filter form shortly after the user stops typing. */ function initAutoFilters() { document.querySelectorAll('form[data-autosubmit]').forEach(function (form) { var timer = null; form.querySelectorAll('input[type="search"], input[type="text"]').forEach(function (input) { input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(function () { form.requestSubmit(); }, 400); }); }); form.querySelectorAll('select').forEach(function (select) { select.addEventListener('change', function () { form.requestSubmit(); }); }); }); } // --- Bulk selection --------------------------------------------------- function initBulkSelect() { document.querySelectorAll('[data-bulk-scope]').forEach(function (scope) { var master = scope.querySelector('[data-bulk-all]'); var boxes = function () { return scope.querySelectorAll('[data-bulk-item]'); }; var bar = scope.querySelector('[data-bulk-bar]'); var count = scope.querySelector('[data-bulk-count]'); function refresh() { var selected = scope.querySelectorAll('[data-bulk-item]:checked').length; if (bar) { bar.classList.toggle('is-visible', selected > 0); } if (count) { count.textContent = String(selected); } if (master) { var total = boxes().length; master.checked = total > 0 && selected === total; master.indeterminate = selected > 0 && selected < total; } } if (master) { master.addEventListener('change', function () { boxes().forEach(function (b) { b.checked = master.checked; }); refresh(); }); } scope.addEventListener('change', function (ev) { if (ev.target.matches('[data-bulk-item]')) { refresh(); } }); refresh(); }); } // --- Copy to clipboard ------------------------------------------------ function initCopyButtons() { document.addEventListener('click', function (ev) { var btn = ev.target.closest('[data-copy]'); if (!btn) { return; } ev.preventDefault(); var text = btn.getAttribute('data-copy'); var target = btn.getAttribute('data-copy-target'); if (target) { var el = document.querySelector(target); if (el) { text = el.textContent.trim(); } } if (!text) { return; } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { window.vibednsToast('success', 'Copied to clipboard.'); }, function () { window.vibednsToast('warning', 'The browser refused clipboard access.'); }); } else { window.vibednsToast('warning', 'Clipboard access needs a secure (HTTPS) connection.'); } }); } // --- Reverse zone preview -------------------------------------------- /** Shows which reverse zone a subnet will produce, before submitting. */ function initReversePreview() { var input = document.getElementById('reverseCidr'); var out = document.getElementById('reversePreview'); if (!input || !out) { return; } var timer = null; function update() { var value = input.value.trim(); if (!value) { out.textContent = ''; return; } fetch('/api/v1/tools/reverse-zone?cidr=' + encodeURIComponent(value), { headers: { 'Accept': 'application/json' } }).then(function (r) { return r.json(); }).then(function (data) { if (data.error) { out.className = 'form-text text-danger'; out.textContent = data.error.message || 'That is not a valid subnet.'; return; } out.className = 'form-text text-success'; out.textContent = 'Zone: ' + data.zone + (data.note ? ' — ' + data.note : ''); }).catch(function () { out.textContent = ''; }); } input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(update, 300); }); if (input.value) { update(); } } // --- Page data -------------------------------------------------------- /* Page data is delivered in data- attributes rather than inline