← back to Commercialrealestate

public/user-badge.js

242 lines

/* user-badge.js — top-right "who's signed in" chrome for every CRCP page (Steve 2026-08-20).
 * Self-contained, zero-dependency. Injected into every served HTML page by serve.js, so it needs
 * no edits to any page file. Fetches /api/me; if a real user is signed in it renders a fixed
 * top-right person-icon pill (name + caret) that opens a small menu with the user's details and a
 * Sign out action. Hides any page's legacy inline #auth indicator so there's one consistent badge.
 */
(function () {
  'use strict';
  if (window.__crcpUserBadge) return; window.__crcpUserBadge = true;
  // Never on the sign-in page itself.
  if (/\/login\.html$/.test(location.pathname)) return;

  var prettyRole = function (r) {
    if (!r) return '';
    return String(r).replace(/[_-]+/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
  };
  var esc = function (s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
    });
  };

  // localStorage keys (plus every `navagent:` key) that together define a page's layout+filters,
  // so a saved search restores the exact view — the field-visibility set (cre_fields), column order,
  // view/group/density, and the filter/scenario/heat state. Filters also live in the URL (qs).
  var LAYOUT_KEYS = ['cre_fields', 'idxColOrder', 'cre_view', 'cre_group', 'cre_density',
    'cre_filters', 'cre_scenario', 'cre_heat', 'cre_shortlist'];

  // Snapshot the current page's restorable state: path + querystring (filters sync to the URL) plus
  // the layout localStorage keys (field visibility = cre_fields, column order, view/group/density…)
  // and every `navagent:` key. Stored verbatim inside a saved search's `filters` object.
  function captureSnapshot() {
    var layout = {};
    try {
      LAYOUT_KEYS.forEach(function (k) { var v = localStorage.getItem(k); if (v != null) layout[k] = v; });
      for (var i = 0; i < localStorage.length; i++) {
        var key = localStorage.key(i);
        if (key && key.indexOf('navagent:') === 0) { var val = localStorage.getItem(key); if (val != null) layout[key] = val; }
      }
    } catch (e) {}
    return { v: 1, path: location.pathname, qs: location.search || '', layout: layout };
  }

  // Restore a saved search's `filters`. New records = {v,path,qs,layout}; legacy records (e.g. the
  // seeded {use,uMin} deals-flow default) carry no navigable target → do nothing destructive and
  // return false so the caller can tell the user. Writes layout keys back, then navigates/reloads.
  function restoreSnapshot(f) {
    if (!f || typeof f !== 'object') return false;
    if (f.layout && typeof f.layout === 'object') {
      try { Object.keys(f.layout).forEach(function (k) { try { localStorage.setItem(k, String(f.layout[k])); } catch (e) {} }); } catch (e) {}
    }
    var hasPath = typeof f.path === 'string', hasQs = typeof f.qs === 'string';
    if (!hasPath && !hasQs) return false;
    var target = (hasPath ? f.path : location.pathname) + (hasQs ? f.qs : '');
    var cur = location.pathname + (location.search || '');
    if (target === cur) location.reload(); else location.assign(target);
    return true;
  }

  function savedRowsHtml(arr) {
    return arr.map(function (s) {
      var star = s.is_default ? '<span class="ssdef" title="Default">★</span>' : '';
      return '<div class="ssrow" data-id="' + esc(s.id) + '">' +
        '<button class="ssname" type="button" title="Restore this view">' + star + '📁 ' + esc(s.name) + '</button>' +
        '<button class="ssdel" type="button" title="Delete saved search" aria-label="Delete saved search">✕</button>' +
      '</div>';
    }).join('');
  }

  var PERSON = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" ' +
    'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
    '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>';
  var CARET = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" ' +
    'stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
    '<polyline points="6 9 12 15 18 9"></polyline></svg>';

  function injectStyle() {
    if (document.getElementById('crcp-ub-style')) return;
    var css = '' +
      '#crcp-ub{position:fixed;top:8px;right:12px;z-index:99999;font:13px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}' +
      '@media(max-width:640px){#crcp-ub{top:60px;right:8px}}' + +
      '#crcp-ub .pill{display:flex;align-items:center;gap:7px;padding:6px 10px;border-radius:999px;cursor:pointer;' +
      'background:rgba(19,24,34,.92);color:#eef2f8;border:1px solid rgba(217,178,95,.45);backdrop-filter:blur(6px);' +
      '-webkit-backdrop-filter:blur(6px);box-shadow:0 4px 14px rgba(0,0,0,.35);user-select:none;white-space:nowrap}' +
      '#crcp-ub .pill:hover{border-color:#d9b25f}' +
      '#crcp-ub .ico{display:grid;place-items:center;width:22px;height:22px;border-radius:50%;' +
      'background:linear-gradient(135deg,#d9b25f,#a67c2e);color:#1a130a;flex:0 0 auto}' +
      '#crcp-ub .nm{font-weight:600;max-width:180px;overflow:hidden;text-overflow:ellipsis}' +
      '#crcp-ub .cr{opacity:.7;display:flex}' +
      '#crcp-ub .menu{position:absolute;top:calc(100% + 6px);right:0;min-width:200px;background:#131822;' +
      'border:1px solid #232c3b;border-radius:12px;box-shadow:0 18px 44px rgba(0,0,0,.5);padding:12px 14px;display:none}' +
      '#crcp-ub.open .menu{display:block}' +
      '#crcp-ub .mline{color:#8b97a8;font-size:12px;margin:2px 0}' +
      '#crcp-ub .mname{color:#eef2f8;font-size:14px;font-weight:600;margin-bottom:2px}' +
      '#crcp-ub .mco{color:#d9b25f}' +
      '#crcp-ub .divider{height:1px;background:#232c3b;margin:10px -14px}' +
      '#crcp-ub .out{display:block;width:100%;text-align:left;background:none;border:0;color:#3b7dff;' +
      'font-size:13px;cursor:pointer;padding:2px 0}' +
      '#crcp-ub .out:hover{text-decoration:underline}' +
      '#crcp-ub .ss-h{color:#d9b25f;font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;margin:2px 0 6px}' +
      '#crcp-ub .sslist{max-height:190px;overflow-y:auto;margin:0 -4px 8px;padding:0 4px}' +
      '#crcp-ub .ssrow{display:flex;align-items:center;gap:6px;margin-bottom:4px}' +
      '#crcp-ub .ssname{flex:1;min-width:0;text-align:left;background:#0e131c;border:1px solid #232c3b;color:#eef2f8;' +
      'border-radius:8px;padding:6px 9px;font-size:12px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
      '#crcp-ub .ssname:hover{border-color:#d9b25f;color:#d9b25f}' +
      '#crcp-ub .ssdef{color:#d9b25f;margin-right:4px}' +
      '#crcp-ub .ssdel{background:none;border:0;color:#8b97a8;cursor:pointer;font-size:13px;line-height:1;padding:2px 4px;flex:0 0 auto}' +
      '#crcp-ub .ssdel:hover{color:#ff6b6b}' +
      '#crcp-ub .ssdel:disabled{opacity:.4;cursor:default}' +
      '#crcp-ub .ssempty{color:#8b97a8;font-size:11px;padding:2px 0}' +
      '#crcp-ub .ssmsg{color:#ffb27a;font-size:11px;padding:2px 0}' +
      '#crcp-ub .sssave{display:block;width:100%;text-align:left;background:none;border:0;color:#3b7dff;' +
      'font-size:13px;cursor:pointer;padding:2px 0}' +
      '#crcp-ub .sssave:hover{text-decoration:underline}' +
      '#crcp-ub .sssave:disabled{opacity:.5;cursor:default;text-decoration:none}';
    var s = document.createElement('style'); s.id = 'crcp-ub-style'; s.textContent = css;
    document.head.appendChild(s);
  }

  function render(me) {
    // Hide any legacy inline sign-in indicator so we don't show it twice.
    var legacy = document.getElementById('auth'); if (legacy) legacy.style.display = 'none';

    var name = me.name || me.username || me.email;
    var role = prettyRole(me.role);
    var wrap = document.createElement('div'); wrap.id = 'crcp-ub';
    wrap.innerHTML =
      '<div class="pill" role="button" tabindex="0" aria-haspopup="true" aria-expanded="false" ' +
      'title="Signed in as ' + esc(name) + '">' +
        '<span class="ico">' + PERSON + '</span>' +
        '<span class="nm">' + esc(name) + '</span>' +
        '<span class="cr">' + CARET + '</span>' +
      '</div>' +
      '<div class="menu" role="menu">' +
        '<div class="mname">' + esc(name) + '</div>' +
        (me.company ? '<div class="mline mco">' + esc(me.company) + '</div>' : '') +
        (role ? '<div class="mline">' + esc(role) + '</div>' : '') +
        '<div class="mline">' + esc(me.email || me.username) + '</div>' +
        '<div class="divider"></div>' +
        '<div class="ss-h">Saved Searches</div>' +
        '<div class="sslist" id="crcp-ss-list"><div class="ssempty">Loading…</div></div>' +
        '<button class="sssave" type="button">💾 Save current view</button>' +
        '<div class="ssmsg" id="crcp-ss-msg" style="display:none"></div>' +
        '<div class="divider"></div>' +
        '<button class="out" type="button">Sign out</button>' +
      '</div>';
    document.body.appendChild(wrap);

    var pill = wrap.querySelector('.pill');

    // ── Saved Searches wiring (per-user, server-backed via /api/saved-searches) ──
    var ssList = wrap.querySelector('#crcp-ss-list');
    var ssSaveBtn = wrap.querySelector('.sssave');
    var ssMsg = wrap.querySelector('#crcp-ss-msg');
    var savedCache = [];
    var ssLoaded = false, ssMsgTimer = null;
    function ssFlash(text) {
      if (!ssMsg) return;
      ssMsg.textContent = text; ssMsg.style.display = 'block';
      if (ssMsgTimer) clearTimeout(ssMsgTimer);
      ssMsgTimer = setTimeout(function () { ssMsg.style.display = 'none'; }, 2600);
    }
    function ssPaint() {
      ssList.innerHTML = savedCache.length ? savedRowsHtml(savedCache)
        : '<div class="ssempty">None yet — save your current view.</div>';
    }
    function ssRefresh() {
      ssList.innerHTML = '<div class="ssempty">Loading…</div>';
      fetch('/api/saved-searches', { headers: { Accept: 'application/json' } })
        .then(function (r) { return r.ok ? r.json() : null; })
        .then(function (j) {
          if (!j || !Array.isArray(j.saved)) { ssList.innerHTML = '<div class="ssmsg">Couldn’t load saved searches.</div>'; savedCache = []; return; }
          savedCache = j.saved; ssPaint();
        })
        .catch(function () { ssList.innerHTML = '<div class="ssmsg">Couldn’t load saved searches.</div>'; });
    }
    if (ssList) {
      ssList.addEventListener('click', function (e) {
        e.stopPropagation();
        var del = e.target.closest('.ssdel');
        if (del) {
          var drow = del.closest('.ssrow'); var did = drow && drow.getAttribute('data-id'); if (!did) return;
          del.disabled = true;
          fetch('/api/saved-searches/' + encodeURIComponent(did), { method: 'DELETE', headers: { Accept: 'application/json' } })
            .then(function (r) { return r.ok ? r.json() : null; })
            .then(function () { savedCache = savedCache.filter(function (s) { return s.id !== did; }); ssPaint(); })
            .catch(function () { del.disabled = false; });
          return;
        }
        var nm = e.target.closest('.ssname');
        if (nm) {
          var nrow = nm.closest('.ssrow'); var nid = nrow && nrow.getAttribute('data-id');
          var rec = savedCache.filter(function (s) { return s.id === nid; })[0];
          if (rec && !restoreSnapshot(rec.filters)) ssFlash('This saved item has no restorable view.');
        }
      });
    }
    if (ssSaveBtn) {
      ssSaveBtn.addEventListener('click', function (e) {
        e.stopPropagation();
        var name = (window.prompt('Name this saved search:', 'Saved search') || '').trim();
        if (!name) return;
        ssSaveBtn.disabled = true;
        fetch('/api/saved-searches', {
          method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
          body: JSON.stringify({ name: name.slice(0, 80), filters: captureSnapshot() })
        })
          .then(function (r) { return r.ok ? r.json() : null; })
          .then(function (j) {
            ssSaveBtn.disabled = false;
            if (j && j.search) { savedCache.unshift(j.search); ssLoaded = true; ssPaint(); ssFlash('Saved.'); }
            else ssFlash('Save failed.');
          })
          .catch(function () { ssSaveBtn.disabled = false; ssFlash('Save failed.'); });
      });
    }

    var toggle = function () {
      var open = wrap.classList.toggle('open');
      pill.setAttribute('aria-expanded', open ? 'true' : 'false');
      if (open && !ssLoaded) { ssLoaded = true; ssRefresh(); }   // lazy-load on first open
    };
    pill.addEventListener('click', function (e) { e.stopPropagation(); toggle(); });
    pill.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
    document.addEventListener('click', function () { wrap.classList.remove('open'); pill.setAttribute('aria-expanded', 'false'); });
    wrap.querySelector('.menu').addEventListener('click', function (e) { e.stopPropagation(); });
    wrap.querySelector('.out').addEventListener('click', async function () {
      try { await fetch('/auth/logout', { method: 'POST' }); } catch (_) {}
      location.href = '/login.html';
    });
  }

  function boot() {
    fetch('/api/me', { headers: { Accept: 'application/json' } })
      .then(function (r) { return r.ok ? r.json() : null; })
      .then(function (me) { if (me && (me.email || me.username)) { injectStyle(); render(me); } })
      .catch(function () {});
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  else boot();
})();