← 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();
})();