186 lines
7.5 KiB
JavaScript
186 lines
7.5 KiB
JavaScript
/**
|
|
* page-modal.js — open info pages (ranking, rules, about, privacy, terms) in a modal over the content area
|
|
* (below the navbar, between the sidebars).
|
|
*
|
|
* Normal navigation swaps #main, which kills a playing video/audio. Links marked [data-page-modal]
|
|
* instead fetch the page, lift its #main content (with the page's <style>s and the #main class, which
|
|
* some page CSS hangs on) into the modal and re-run its inline scripts. The page underneath is untouched.
|
|
*
|
|
* Inside the modal, links to other modal pages load in place; any other link closes the modal and
|
|
* navigates as usual. Direct visits to these URLs are unaffected.
|
|
*/
|
|
(() => {
|
|
if (window.f0ckPageModal) return;
|
|
|
|
const MODAL_PAGES = /^\/(ranking|rules|about|privacy|terms)\/?$/;
|
|
|
|
const modal = document.getElementById('page-modal');
|
|
if (!modal) return;
|
|
const body = modal.querySelector('.pgm-body');
|
|
const titleEl = modal.querySelector('.pgm-title');
|
|
let currentUrl = null;
|
|
let loadSeq = 0;
|
|
|
|
const isModalPath = (href) => {
|
|
try {
|
|
const u = new URL(href, location.origin);
|
|
return u.origin === location.origin && MODAL_PAGES.test(u.pathname);
|
|
} catch { return false; }
|
|
};
|
|
|
|
const runScripts = (root) => {
|
|
root.querySelectorAll('script').forEach((old) => {
|
|
// JSON data blocks are read by the page scripts, not executed
|
|
if (old.type && old.type !== 'text/javascript' && old.type !== 'module') return;
|
|
const s = document.createElement('script');
|
|
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
|
s.textContent = old.textContent;
|
|
old.replaceWith(s);
|
|
});
|
|
};
|
|
|
|
const load = async (url, title) => {
|
|
const seq = ++loadSeq;
|
|
currentUrl = url;
|
|
titleEl.textContent = title || '';
|
|
body.innerHTML = '<div class="pgm-loading"><i class="fa-solid fa-spinner fa-spin"></i></div>';
|
|
try {
|
|
const res = await fetch(url, { credentials: 'same-origin' });
|
|
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
|
if (seq !== loadSeq) return;
|
|
const main = doc.getElementById('main');
|
|
if (!res.ok || !main) throw new Error(`HTTP ${res.status}`);
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = `${main.className} pgm-main`.trim();
|
|
wrap.innerHTML = main.innerHTML;
|
|
body.innerHTML = '';
|
|
body.appendChild(wrap);
|
|
body.scrollTop = 0;
|
|
if (!title) {
|
|
const h = wrap.querySelector('h1, h2, h3');
|
|
titleEl.textContent = h ? h.textContent.trim() : '';
|
|
}
|
|
runScripts(wrap);
|
|
} catch (e) {
|
|
if (seq !== loadSeq) return;
|
|
body.innerHTML = `<div class="pgm-loading">Could not load this page. <a href="${url}" class="pgm-fallback">Open it directly</a>.</div>`;
|
|
}
|
|
};
|
|
|
|
// ── Placement: the modal fills the content area only, so the navbar and sidebars stay usable ──
|
|
const visibleRect = (sel) => {
|
|
for (const el of document.querySelectorAll(sel)) {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden') return r;
|
|
}
|
|
return null;
|
|
};
|
|
const place = () => {
|
|
const vw = window.innerWidth;
|
|
const nav = visibleRect('body > nav.navbar, nav.navbar');
|
|
const top = nav ? Math.max(0, Math.round(nav.bottom)) : 0;
|
|
// Right sidebar: counts only while it is on screen (hidden = slid out past the right edge)
|
|
const rs = visibleRect('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
|
|
const right = rs && rs.left < vw - 1 && rs.left > vw / 2 ? Math.round(vw - rs.left) : 0;
|
|
// Left (comments) sidebar on item pages
|
|
const ls = visibleRect('.item-sidebar-left');
|
|
const left = ls && ls.right > 1 && ls.right < vw / 2 ? Math.round(ls.right) : 0;
|
|
modal.style.setProperty('--pgm-top', `${top}px`);
|
|
modal.style.setProperty('--pgm-right', `${right}px`);
|
|
modal.style.setProperty('--pgm-left', `${left}px`);
|
|
};
|
|
// Follow sidebar slide animations for a moment after a toggle
|
|
let followUntil = 0;
|
|
const follow = () => {
|
|
if (modal.style.display === 'none') return;
|
|
place();
|
|
if (performance.now() < followUntil) requestAnimationFrame(follow);
|
|
};
|
|
const followFor = (ms) => {
|
|
const running = performance.now() < followUntil;
|
|
followUntil = performance.now() + ms;
|
|
if (!running) requestAnimationFrame(follow);
|
|
};
|
|
window.addEventListener('resize', () => { if (modal.style.display !== 'none') place(); }, { passive: true });
|
|
new MutationObserver(() => followFor(600)).observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
|
|
|
// The navbar and sidebars change size on their own (mobile burger menu expanding/collapsing, sidebar resize):
|
|
// follow every size change so the modal's edges stay glued to them
|
|
const LAYOUT_SEL = 'nav.navbar, .global-sidebar-right, .item-sidebar-right, .index-sidebar-right, .item-sidebar-left';
|
|
const watched = new WeakSet();
|
|
const ro = typeof ResizeObserver === 'function'
|
|
? new ResizeObserver(() => { if (modal.style.display !== 'none') place(); })
|
|
: null;
|
|
const watchLayout = () => {
|
|
document.querySelectorAll(LAYOUT_SEL).forEach((el) => {
|
|
if (watched.has(el)) return;
|
|
watched.add(el);
|
|
if (ro) ro.observe(el);
|
|
// Class/style toggles inside the navbar (collapse "show", inline heights) may animate: follow for a bit
|
|
if (el.matches('nav.navbar')) {
|
|
new MutationObserver(() => { if (modal.style.display !== 'none') followFor(600); })
|
|
.observe(el, { attributes: true, attributeFilter: ['class', 'style'], subtree: true });
|
|
}
|
|
});
|
|
};
|
|
|
|
const open = (url, title) => {
|
|
// Item pages bring their own sidebars after AJAX navigation, so (re)attach observers on each open
|
|
watchLayout();
|
|
place();
|
|
modal.style.display = 'flex';
|
|
followFor(300);
|
|
load(url, title);
|
|
};
|
|
|
|
const close = () => {
|
|
modal.style.display = 'none';
|
|
body.innerHTML = '';
|
|
currentUrl = null;
|
|
};
|
|
|
|
// Capture phase: runs before the global AJAX link handler
|
|
document.addEventListener('click', (e) => {
|
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
const a = e.target.closest?.('a[href]');
|
|
if (!a) return;
|
|
|
|
if (a.hasAttribute('data-page-modal')) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
open(a.getAttribute('href'), a.getAttribute('title') || '');
|
|
return;
|
|
}
|
|
|
|
if (modal.style.display === 'none' || a.target === '_blank') return;
|
|
|
|
// Outside the modal (navbar brand, sidebar links, …): a real navigation closes it so the new page shows
|
|
if (!modal.contains(a)) {
|
|
const href = a.getAttribute('href') || '';
|
|
if (href.startsWith('#') || href.startsWith('javascript:')) return;
|
|
try { if (new URL(a.href, location.origin).origin !== location.origin) return; } catch { return; }
|
|
close();
|
|
return;
|
|
}
|
|
|
|
if (isModalPath(a.href)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
load(a.getAttribute('href'), '');
|
|
return;
|
|
}
|
|
// Leaving the modal for a regular page: close it and let normal navigation take over
|
|
close();
|
|
}, true);
|
|
|
|
modal.querySelector('.pgm-close').addEventListener('click', close);
|
|
// Back/forward changes the page underneath: don't leave the modal covering it
|
|
window.addEventListener('popstate', () => { if (modal.style.display !== 'none') close(); });
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modal.style.display !== 'none') close();
|
|
});
|
|
|
|
window.f0ckPageModal = { open, close, get url() { return currentUrl; } };
|
|
})();
|