diff --git a/web/static/panel.js b/web/static/panel.js new file mode 100644 index 0000000..f4b8e5a --- /dev/null +++ b/web/static/panel.js @@ -0,0 +1,200 @@ +(() => { + const app = { + loading: false, + refreshTimers: [], + }; + + function qs(selector, root = document) { + return root.querySelector(selector); + } + + function qsa(selector, root = document) { + return Array.from(root.querySelectorAll(selector)); + } + + function sameOriginLink(link) { + if (!link || !link.href) return false; + const url = new URL(link.href, window.location.href); + return url.origin === window.location.origin; + } + + function isAjaxSafeLink(link) { + if (!sameOriginLink(link)) return false; + const url = new URL(link.href, window.location.href); + if (url.pathname.startsWith('/vm/') && url.pathname.endsWith('/console')) return false; + if (url.pathname.startsWith('/static/')) return false; + if (url.pathname.startsWith('/api/')) return false; + if (link.target && link.target !== '_self') return false; + if (link.hasAttribute('download')) return false; + if (link.dataset.noAjax === '1') return false; + return true; + } + + function setLoading(active) { + app.loading = active; + document.documentElement.classList.toggle('ajax-loading', active); + let bar = qs('#ajax-progress'); + if (!bar) { + bar = document.createElement('div'); + bar.id = 'ajax-progress'; + document.body.appendChild(bar); + } + bar.classList.toggle('active', active); + } + + function activeSidebar(pathname = window.location.pathname) { + qsa('.v-nav a').forEach((item) => { + const url = new URL(item.href, window.location.href); + const path = url.pathname; + const active = path === '/' ? pathname === '/' : pathname.startsWith(path); + item.classList.toggle('active', active); + }); + } + + function stopRefreshTimers() { + app.refreshTimers.forEach((timer) => clearInterval(timer)); + app.refreshTimers = []; + } + + function swapPage(html, url, push = true) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newMain = qs('.v-main', doc) || qs('.shell', doc); + const currentMain = qs('.v-main') || qs('.shell'); + + if (!newMain || !currentMain) { + window.location.href = url; + return; + } + + stopRefreshTimers(); + document.title = doc.title || document.title; + currentMain.classList.add('page-exit'); + setTimeout(() => { + currentMain.replaceWith(newMain); + newMain.classList.add('page-enter'); + setTimeout(() => newMain.classList.remove('page-enter'), 220); + activeSidebar(new URL(url, window.location.href).pathname); + if (push) history.pushState({ ajax: true }, '', url); + initDynamicPage(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, 80); + } + + async function ajaxNavigate(url, push = true) { + if (app.loading) return; + setLoading(true); + try { + const response = await fetch(url, { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'text/html,application/xhtml+xml', + }, + cache: 'no-store', + }); + if (!response.ok) throw new Error('HTTP ' + response.status); + const html = await response.text(); + swapPage(html, url, push); + } catch (error) { + window.location.href = url; + } finally { + setLoading(false); + } + } + + function wireAjaxLinks(root = document) { + qsa('a', root).forEach((link) => { + if (link.dataset.ajaxWired === '1') return; + link.dataset.ajaxWired = '1'; + link.addEventListener('click', (event) => { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (!isAjaxSafeLink(link)) return; + event.preventDefault(); + ajaxNavigate(link.href); + }); + }); + } + + function wireAjaxForms(root = document) { + qsa('form', root).forEach((form) => { + if (form.dataset.ajaxWired === '1') return; + if (form.dataset.noAjax === '1') return; + const method = String(form.method || 'GET').toUpperCase(); + if (method !== 'POST') return; + if (form.enctype && form.enctype.includes('multipart')) return; + form.dataset.ajaxWired = '1'; + form.addEventListener('submit', async (event) => { + if (event.defaultPrevented) return; + const submitter = event.submitter; + if (submitter && submitter.classList.contains('danger') && !confirm('Выполнить действие?')) return; + event.preventDefault(); + setLoading(true); + try { + const response = await fetch(form.action, { + method: 'POST', + body: new FormData(form), + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + redirect: 'follow', + }); + const finalUrl = response.url || window.location.href; + if (response.headers.get('content-type')?.includes('text/html')) { + const html = await response.text(); + swapPage(html, finalUrl, true); + } else { + await ajaxNavigate(window.location.href, false); + } + } catch (error) { + form.submit(); + } finally { + setLoading(false); + } + }); + }); + } + + async function refreshFragments() { + const fragments = qsa('[data-refresh-url]'); + for (const fragment of fragments) { + const url = fragment.dataset.refreshUrl; + if (!url) continue; + try { + const response = await fetch(url, { cache: 'no-store', headers: { 'X-Requested-With': 'XMLHttpRequest' }}); + const payload = await response.json(); + if (!payload.ok) continue; + if (payload.html) fragment.innerHTML = payload.html; + if (payload.text) fragment.textContent = payload.text; + } catch (_) {} + } + } + + function startAutoRefresh(root = document) { + const interval = Number(qs('[data-auto-refresh]', root)?.dataset.autoRefresh || 0); + if (interval > 0) { + const timer = setInterval(refreshFragments, interval * 1000); + app.refreshTimers.push(timer); + refreshFragments(); + } + } + + function wireMobileMenu() { + const sidebar = qs('.v-sidebar'); + if (!sidebar || qs('.mobile-menu-toggle')) return; + const btn = document.createElement('button'); + btn.className = 'mobile-menu-toggle'; + btn.type = 'button'; + btn.textContent = '☰ Меню'; + btn.addEventListener('click', () => sidebar.classList.toggle('open')); + document.body.appendChild(btn); + } + + function initDynamicPage() { + wireAjaxLinks(); + wireAjaxForms(); + wireMobileMenu(); + activeSidebar(); + startAutoRefresh(); + } + + window.addEventListener('popstate', () => ajaxNavigate(window.location.href, false)); + document.addEventListener('DOMContentLoaded', initDynamicPage); +})();