← back to Wallco Ai
public/admin/admin-modal-kit.js
238 lines
/* admin-modal-kit.js — Steve 2026-05-31
* Makes EVERY admin modal drag-by-titlebar + popout (un-dim so you can work
* around it) + remember its position across opens. Drop-in: just
* <script src="/admin/admin-modal-kit.js" defer></script>
* on any admin page. No markup changes required — it auto-detects modal panels
* by a selector list and enhances each the first time it becomes visible.
*
* Design notes:
* - We enhance the modal *panel* (the content box), not the full-screen scrim.
* - A MutationObserver catches modals that are injected/shown after load.
* - Position persists in localStorage keyed by pathname + panel identity.
* - Defensive throughout: a bad page must never throw and break the modal.
* - Opt out per element with data-no-drag; mark a custom handle with
* data-amk-handle.
*/
(function () {
'use strict';
if (window.__amkLoaded) return; window.__amkLoaded = true;
// Candidate "panel" selectors (the movable content box). Order matters:
// most-specific first so we don't grab a full-screen scrim by accident.
var PANEL_SEL = [
'.modal-box', '.modal-inner', '.modal-side', '.modal-card', '.modal-content',
'.nsm', '.dialog', '[role="dialog"]', '.popup', '.sheet', '.amk-window'
];
// Bare ".modal" / ".overlay" are usually the SCRIM. Only treat them as the
// panel if they have no inner panel child (some pages style .modal as the box).
var MAYBE_PANEL_SEL = ['.modal', '.overlay', '.backdrop'];
var STORE = 'amk:' + location.pathname.replace(/\/+$/, '');
function load() { try { return JSON.parse(localStorage.getItem(STORE) || '{}'); } catch (e) { return {}; } }
function save(o) { try { localStorage.setItem(STORE, JSON.stringify(o)); } catch (e) {} }
function keyFor(el, idx) {
if (el.id) return '#' + el.id;
if (el.dataset.amkKey) return el.dataset.amkKey;
var k = (el.className || 'panel').toString().trim().split(/\s+/).slice(0, 2).join('.') + ':' + idx;
el.dataset.amkKey = k;
return k;
}
function visible(el) {
if (!el || !el.isConnected) return false;
var cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) return false;
var r = el.getBoundingClientRect();
return r.width > 120 && r.height > 80; // real panel, not a sliver
}
// Is this element a full-screen scrim (cover the viewport, semi-transparent)?
function looksLikeScrim(el) {
var r = el.getBoundingClientRect();
return r.width > innerWidth * 0.9 && r.height > innerHeight * 0.9;
}
function findScrim(panel) {
// Walk up: the first fixed/absolute full-screen ancestor is the backdrop.
var p = panel.parentElement, hop = 0;
while (p && hop++ < 6) {
var cs = getComputedStyle(p);
if ((cs.position === 'fixed' || cs.position === 'absolute') && looksLikeScrim(p)) return p;
p = p.parentElement;
}
return null;
}
function pickHandle(panel) {
if (panel.dataset.amkHandle) {
var h = panel.querySelector(panel.dataset.amkHandle);
if (h) return h;
}
// existing header-ish element
var head = panel.querySelector('.modal-head, .modal-title, .modal-header, header, h1, h2, h3');
if (head && head.offsetParent !== null) return head;
return null;
}
function enhance(panel, idx) {
if (panel.__amk) return;
if (panel.closest('[data-no-drag]') || panel.hasAttribute('data-no-drag')) return;
panel.__amk = true;
var store = load();
var key = keyFor(panel, idx);
// Build the title bar (grip + title + popout + close).
var bar = document.createElement('div');
bar.className = 'amk-bar';
var existingHandle = pickHandle(panel);
var titleTxt = (existingHandle && existingHandle.textContent.trim().slice(0, 60)) ||
panel.getAttribute('aria-label') || 'Move';
bar.innerHTML =
'<span class="amk-grip" title="Drag to move">⠿</span>' +
'<span class="amk-title"></span>' +
'<span class="amk-spacer"></span>' +
'<button type="button" class="amk-btn amk-pop" title="Pop out (un-dim, float free)">⤢</button>' +
'<button type="button" class="amk-btn amk-reset" title="Reset position">⟲</button>' +
'<button type="button" class="amk-btn amk-close" title="Close">✕</button>';
bar.querySelector('.amk-title').textContent = existingHandle ? '' : titleTxt;
// Make the panel positionable.
var cs = getComputedStyle(panel);
if (cs.position === 'static') panel.style.position = 'relative';
panel.classList.add('amk-panel');
panel.insertBefore(bar, panel.firstChild);
// Restore saved geometry.
var saved = store[key];
function applyFixed(left, top) {
panel.style.position = 'fixed';
panel.style.margin = '0';
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
panel.style.right = 'auto'; panel.style.bottom = 'auto';
panel.style.transform = 'none';
}
if (saved && typeof saved.left === 'number') {
applyFixed(saved.left, saved.top);
if (saved.popped) setPopped(true);
}
// ---- Drag ----
var dragging = false, sx = 0, sy = 0, ol = 0, ot = 0;
function onDown(e) {
if (e.target.closest('.amk-btn')) return; // buttons aren't drag
var r = panel.getBoundingClientRect();
applyFixed(r.left, r.top); // pin where it currently is
dragging = true; sx = e.clientX; sy = e.clientY; ol = r.left; ot = r.top;
bar.setPointerCapture && bar.setPointerCapture(e.pointerId);
document.body.style.userSelect = 'none';
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
var nl = ol + (e.clientX - sx), nt = ot + (e.clientY - sy);
var r = panel.getBoundingClientRect();
nl = Math.max(8 - r.width + 60, Math.min(nl, innerWidth - 60)); // keep grabbable
nt = Math.max(0, Math.min(nt, innerHeight - 36));
panel.style.left = nl + 'px'; panel.style.top = nt + 'px';
}
function onUp() {
if (!dragging) return;
dragging = false; document.body.style.userSelect = '';
persist();
}
bar.addEventListener('pointerdown', onDown);
addEventListener('pointermove', onMove);
addEventListener('pointerup', onUp);
// ---- Popout (un-dim the scrim, float free, click-through behind) ----
var scrim = findScrim(panel);
var popped = false;
function setPopped(on) {
popped = on;
panel.classList.toggle('amk-popped', on);
bar.querySelector('.amk-pop').classList.toggle('on', on);
if (scrim) {
scrim.style.background = on ? 'transparent' : '';
scrim.style.pointerEvents = on ? 'none' : ''; // clicks pass through to page
panel.style.pointerEvents = 'auto';
}
if (on && getComputedStyle(panel).position !== 'fixed') {
var r = panel.getBoundingClientRect(); applyFixed(r.left, r.top);
}
}
bar.querySelector('.amk-pop').addEventListener('click', function () { setPopped(!popped); persist(); });
// ---- Reset ----
bar.querySelector('.amk-reset').addEventListener('click', function () {
panel.style.cssText = panel.style.cssText
.replace(/(left|top|right|bottom|position|margin|transform)\s*:[^;]*;?/g, '');
panel.classList.remove('amk-popped');
if (scrim) { scrim.style.background = ''; scrim.style.pointerEvents = ''; }
popped = false; bar.querySelector('.amk-pop').classList.remove('on');
var s = load(); delete s[key]; save(s);
});
// ---- Close (delegate to whatever the page already wires) ----
bar.querySelector('.amk-close').addEventListener('click', function () {
// reset scrim so the page's own close logic isn't fighting transparency
if (scrim) { scrim.style.background = ''; scrim.style.pointerEvents = ''; }
var btn = panel.querySelector('.modal-close, [data-close], .close, .nsm-close');
if (btn) { btn.click(); return; }
if (scrim) { scrim.click(); return; } // many pages close on scrim click
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
});
function persist() {
var s = load(); var r = panel.getBoundingClientRect();
s[key] = { left: r.left, top: r.top, popped: popped };
save(s);
}
}
function scan() {
var sel = PANEL_SEL.join(',');
document.querySelectorAll(sel).forEach(function (el, i) {
if (visible(el) && !el.__amk) enhance(el, i);
});
// bare .modal/.overlay only if it has no inner panel and isn't itself a scrim wrapper of one
document.querySelectorAll(MAYBE_PANEL_SEL.join(',')).forEach(function (el, i) {
if (el.__amk) return;
if (!visible(el)) return;
if (el.querySelector(PANEL_SEL.join(','))) return; // it's a scrim around a real panel
if (looksLikeScrim(el) && el.children.length) return; // pure scrim
enhance(el, 100 + i);
});
}
// Styles
var css = document.createElement('style');
css.textContent =
'.amk-panel{box-shadow:0 18px 60px rgba(0,0,0,.32)!important}' +
'.amk-bar{display:flex;align-items:center;gap:8px;cursor:grab;user-select:none;' +
'padding:6px 8px;margin:-1px -1px 6px;background:linear-gradient(#1c2030,#141826);' +
'color:#e8eaf2;border-radius:10px 10px 0 0;font:600 12px -apple-system,system-ui,sans-serif}' +
'.amk-bar:active{cursor:grabbing}' +
'.amk-grip{opacity:.7;font-size:14px;letter-spacing:-2px}' +
'.amk-title{opacity:.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60%}' +
'.amk-spacer{flex:1}' +
'.amk-btn{border:0;background:rgba(255,255,255,.10);color:#e8eaf2;cursor:pointer;' +
'width:24px;height:22px;border-radius:6px;font-size:12px;line-height:1;padding:0}' +
'.amk-btn:hover{background:rgba(255,255,255,.22)}' +
'.amk-pop.on{background:#34cbf4;color:#06202a}' +
'.amk-popped{z-index:2147483000!important}';
(document.head || document.documentElement).appendChild(css);
var mo = new MutationObserver(function () { clearTimeout(window.__amkT); window.__amkT = setTimeout(scan, 60); });
function start() {
scan();
mo.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['style', 'class', 'hidden', 'aria-hidden'] });
}
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', start);
else start();
})();