← 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
})();