212 lines
7.6 KiB
JavaScript
212 lines
7.6 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
// ── Track setIntervals so navigation can clear them ───────────────────────
|
||
const _origSetInterval = window.setInterval.bind(window);
|
||
const _origClearInterval = window.clearInterval.bind(window);
|
||
let _intervals = [];
|
||
|
||
window.setInterval = function (fn, ms, ...args) {
|
||
const id = _origSetInterval(fn, ms, ...args);
|
||
_intervals.push(id);
|
||
return id;
|
||
};
|
||
|
||
function clearTrackedIntervals() {
|
||
_intervals.forEach(id => _origClearInterval(id));
|
||
_intervals = [];
|
||
}
|
||
|
||
// ── Cleanup state left behind by the outgoing page ────────────────────────
|
||
function cleanupPage() {
|
||
clearTrackedIntervals();
|
||
|
||
// DataTables 2.x
|
||
if (window.DataTable) {
|
||
try { DataTable.tables({ api: true }).destroy(); } catch (_) {}
|
||
}
|
||
|
||
// Dropzone
|
||
if (window.Dropzone) {
|
||
try { [...Dropzone.instances].forEach(dz => dz.destroy()); } catch (_) {}
|
||
}
|
||
|
||
// Bootstrap modals – hide any open one and remove leftover backdrops
|
||
document.querySelectorAll('.modal.show').forEach(el => {
|
||
try { bootstrap.Modal.getInstance(el)?.hide(); } catch (_) {}
|
||
});
|
||
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||
document.body.classList.remove('modal-open');
|
||
document.body.style.removeProperty('padding-right');
|
||
}
|
||
|
||
function swapPageModals(doc) {
|
||
document.querySelectorAll('[data-ajax-modal="true"]').forEach(el => el.remove());
|
||
doc.querySelectorAll('body > .modal').forEach(old => {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.innerHTML = old.outerHTML;
|
||
const modal = wrapper.firstElementChild;
|
||
if (!modal) return;
|
||
modal.setAttribute('data-ajax-modal', 'true');
|
||
document.body.appendChild(modal);
|
||
});
|
||
}
|
||
|
||
// ── Per-page inline <style> management ───────────────────────────────────
|
||
const STYLE_ID = 'ajax-page-style';
|
||
function setPageStyles(cssText) {
|
||
let tag = document.getElementById(STYLE_ID);
|
||
if (!cssText) { tag?.remove(); return; }
|
||
if (!tag) {
|
||
tag = document.createElement('style');
|
||
tag.id = STYLE_ID;
|
||
document.head.appendChild(tag);
|
||
}
|
||
tag.textContent = cssText;
|
||
}
|
||
|
||
// ── Load external <script src> tags not already on the page ──────────────
|
||
const _loadedSrcs = new Set(
|
||
[...document.querySelectorAll('script[src]')].map(s => s.src)
|
||
);
|
||
|
||
function loadScript(src) {
|
||
const abs = new URL(src, location.href).href;
|
||
if (_loadedSrcs.has(abs)) return Promise.resolve();
|
||
_loadedSrcs.add(abs);
|
||
return new Promise((resolve, reject) => {
|
||
const s = document.createElement('script');
|
||
s.src = src;
|
||
s.onload = resolve;
|
||
s.onerror = reject;
|
||
document.head.appendChild(s);
|
||
});
|
||
}
|
||
|
||
// ── Load external <link rel="stylesheet"> tags not already on the page ───
|
||
const _loadedHrefs = new Set(
|
||
[...document.querySelectorAll('link[rel="stylesheet"]')].map(l => l.href)
|
||
);
|
||
|
||
function loadStylesheet(href) {
|
||
const abs = new URL(href, location.href).href;
|
||
if (_loadedHrefs.has(abs)) return Promise.resolve();
|
||
_loadedHrefs.add(abs);
|
||
return new Promise((resolve, reject) => {
|
||
const l = document.createElement('link');
|
||
l.rel = 'stylesheet';
|
||
l.href = href;
|
||
l.onload = resolve;
|
||
l.onerror = reject;
|
||
document.head.appendChild(l);
|
||
});
|
||
}
|
||
|
||
// ── Re-execute inline scripts from the fetched document ──────────────────
|
||
function runInlineScripts(doc) {
|
||
doc.querySelectorAll('body script:not([src])').forEach(old => {
|
||
const code = old.textContent;
|
||
|
||
// Wrap in an IIFE so top-level `let`/`const` are function-scoped and
|
||
// don't collide with bindings from a previous visit to the same page.
|
||
// Then re-expose every named function declaration to `window` so that
|
||
// inline onclick="foo()" handlers continue to resolve them globally.
|
||
const funcNames = [
|
||
...code.matchAll(/^\s*(?:async\s+)?function\s+(\w+)\s*\(/gm),
|
||
].map(m => m[1]);
|
||
|
||
const expose = funcNames
|
||
.map(n => `try{window.${n}=${n};}catch(_){}`)
|
||
.join('\n');
|
||
|
||
const s = document.createElement('script');
|
||
s.textContent = `(function(){\n${code}\n${expose}\n})();`;
|
||
document.head.appendChild(s);
|
||
s.remove();
|
||
});
|
||
}
|
||
|
||
// ── Update which nav link is highlighted as active ────────────────────────
|
||
function setActiveNav(filename) {
|
||
document.querySelectorAll('.nav-link-custom, .bottom-nav a').forEach(el => {
|
||
const hf = (el.getAttribute('href') || '').split('/').pop();
|
||
el.classList.toggle('active', hf === filename);
|
||
});
|
||
}
|
||
|
||
// ── Core AJAX navigation ──────────────────────────────────────────────────
|
||
let _navigating = false;
|
||
|
||
async function navigateTo(href) {
|
||
if (_navigating) return;
|
||
_navigating = true;
|
||
|
||
const mainInner = document.querySelector('.main-inner');
|
||
if (!mainInner) { window.location.href = href; return; }
|
||
|
||
// Dim content while loading
|
||
mainInner.style.transition = 'opacity 0.12s';
|
||
mainInner.style.opacity = '0.35';
|
||
|
||
try {
|
||
const r = await fetch(href, { redirect: 'manual' });
|
||
if (r.type === 'opaqueredirect') { window.location.reload(); return; }
|
||
if (!r.ok) { window.location.href = href; return; }
|
||
|
||
const text = await r.text();
|
||
const doc = new DOMParser().parseFromString(text, 'text/html');
|
||
|
||
// Load any external libs/styles the new page needs that aren't already loaded.
|
||
// Scripts must load sequentially (order matters: e.g. jQuery before DataTables).
|
||
// Stylesheets have no dependency order so can load in parallel.
|
||
for (const s of doc.querySelectorAll('script[src]')) {
|
||
await loadScript(s.getAttribute('src'));
|
||
}
|
||
await Promise.all(
|
||
[...doc.querySelectorAll('link[rel="stylesheet"]')].map(l => loadStylesheet(l.getAttribute('href')))
|
||
);
|
||
|
||
// Swap page-specific inline styles
|
||
const styles = [...doc.head.querySelectorAll('style')].map(s => s.textContent).join('\n');
|
||
setPageStyles(styles || null);
|
||
|
||
// Cleanup outgoing page, then inject new content
|
||
cleanupPage();
|
||
mainInner.innerHTML = doc.querySelector('.main-inner')?.innerHTML ?? '';
|
||
swapPageModals(doc);
|
||
document.title = doc.title;
|
||
|
||
const filename = href.split('/').pop();
|
||
setActiveNav(filename);
|
||
|
||
// Execute page scripts from the centralized app bundle when available.
|
||
if (typeof window.__runPageScripts === 'function') {
|
||
window.__runPageScripts(filename);
|
||
} else {
|
||
// Fallback for legacy pages that still carry inline scripts.
|
||
runInlineScripts(doc);
|
||
}
|
||
|
||
} catch {
|
||
// Fallback to hard navigation on any error
|
||
window.location.href = href;
|
||
} finally {
|
||
mainInner.style.opacity = '1';
|
||
_navigating = false;
|
||
}
|
||
}
|
||
|
||
// ── Intercept all local .html link clicks ────────────────────────────────
|
||
document.addEventListener('click', e => {
|
||
const link = e.target.closest('a[href]');
|
||
if (!link) return;
|
||
const href = link.getAttribute('href');
|
||
if (!href || href.startsWith('#') || href.includes('://') || !href.endsWith('.html')) return;
|
||
e.preventDefault();
|
||
navigateTo(href);
|
||
}, true);
|
||
|
||
// History API intentionally omitted — no pushState/popstate.
|
||
|
||
})();
|