← back to Rentv

mockups/assets/chrome.js

170 lines

/* RENTV redesign — shared chrome + numbering engine.
   Include once per layout:  <script src="assets/chrome.js"></script>
   Then call:  RENTV.mount({ dark:false, layout:'L03' })
   - builds the top bar (hamburger upper-left = ALL tabs+data, user icon upper-right = sign-in)
   - loads the REAL live data snapshot from ./data/*.json and exposes RENTV.data
   - scans [data-eln] elements, injects clickable number badges, wires the "My Picks" tray */
window.RENTV = (function () {
  // ---- canonical navigation model (single source of truth for every drawer) ----
  const NAV = {
    Sections: [
      ['Sales', '/sales'], ['Leases', '/leases'], ['Financing', '/financing'],
      ['Development', '/development'], ['Multifamily', '/multifamily'],
      ['Industrial', '/industrial'], ['Retail', '/retail'],
    ],
    Intelligence: [
      ['Markets', '/markets'], ['LA Commercial', '/la-commercial'], ['The REview', '/review'],
      ['CRE Talk', '/cre-talk'], ['Conferences', '/conferences'], ['Blog', '/blog'],
    ],
    Company: [
      ['About', '/about'], ['Advertise', '/advertise'],
      ['Subscribe / Newsletters', '/subscribe'], ['Versions', '/versions'],
    ],
  };
  const usd = n => n == null ? '—' : '$' + Number(n).toLocaleString();
  const shortUsd = n => { n = Number(n) || 0; return n >= 1e9 ? '$' + (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : usd(n); };
  const dstr = d => d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '—';

  async function loadData() {
    // live-first: same-origin /api/<ep> (works on the real rentv-v1 domain, browser carries auth);
    // fall back to the bundled snapshot so the gallery also renders anywhere static.
    const get = (api, file) => fetch('/api/' + api).then(r => { if (!r.ok) throw 0; return r.json(); })
      .catch(() => fetch('data/' + file).then(r => r.json()).catch(() => null));
    const [news, videos, la, ticker] = await Promise.all([
      get('news', 'news.json'), get('videos', 'videos.json'),
      get('la-commercial', 'la-commercial.json'), get('ticker', 'ticker.json')]);
    return { news: news || { items: [] }, videos: videos || { items: [] }, la: la || {}, ticker: ticker || {} };
  }

  // ticker.json is an object of {label,value,dir?,change_pct?,as_of?} metrics
  function tickerMetrics(t) {
    return Object.entries(t || {})
      .filter(([, m]) => m && typeof m === 'object' && m.label && m.value != null)
      .map(([, m]) => m);
  }

  function buildChrome(opts) {
    const d = RENTV.data, t = d.ticker || {};
    const metrics = tickerMetrics(t);
    const topAsset = (d.la.marquee || [])[0];
    const snapCells = metrics.map(m => {
      const cls = m.dir === 'up' ? ' up' : m.dir === 'down' ? ' dn' : '';
      const chg = m.change_pct != null ? ` <span style="font-size:11px">${m.change_pct > 0 ? '+' : ''}${m.change_pct}%</span>` : '';
      return `<div class="m"><div class="k">${m.label}</div><div class="v${cls}">${m.value}${chg}</div></div>`;
    }).join('') +
      `<div class="m"><div class="k">Live Stories</div><div class="v">${(d.news.items || []).length}</div></div>` +
      `<div class="m"><div class="k">Top LA Asset</div><div class="v">${topAsset ? shortUsd(topAsset.assessed_total) : '—'}</div></div>`;
    // top bar
    const bar = document.createElement('header');
    bar.className = 'rv-top' + (opts.dark ? ' dark' : '');
    bar.innerHTML = `
      <button class="rv-burger" id="rvBurger" aria-label="Menu">
        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
        <span class="lbl">Menu</span>
      </button>
      <a class="rv-brand" href="/">REN<b>TV</b></a>
      <button class="rv-user" id="rvUser" title="Sign in" aria-label="Sign in">
        <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="3.5"/><path d="M4.5 20a7.5 7.5 0 0 1 15 0" stroke-linecap="round"/></svg>
      </button>`;
    document.body.prepend(bar);
    // scrim + drawer
    const scrim = document.createElement('div'); scrim.className = 'rv-scrim'; scrim.id = 'rvScrim';
    const grp = (name) => `<div class="rv-grp"><div class="gt">${name}</div></div><nav>` +
      NAV[name].map(([label, href]) => `<a href="${href}">${label}</a>`).join('') + `</nav>`;
    const drawer = document.createElement('aside'); drawer.className = 'rv-drawer'; drawer.id = 'rvDrawer';
    drawer.innerHTML = `
      <div class="dh"><span class="w">REN<b>TV</b></span><button id="rvClose" aria-label="Close">✕</button></div>
      <div class="rv-snap">${snapCells}</div>
      ${grp('Sections')}${grp('Intelligence')}${grp('Company')}
      <div class="df">RENTV.com — Commercial Real Estate News · data live from rentv.com + LA County Assessor + CA DRE</div>`;
    document.body.append(scrim, drawer);
    const open = () => { scrim.classList.add('open'); drawer.classList.add('open'); };
    const close = () => { scrim.classList.remove('open'); drawer.classList.remove('open'); };
    document.getElementById('rvBurger').onclick = open;
    document.getElementById('rvClose').onclick = close;
    scrim.onclick = close;
    document.getElementById('rvUser').onclick = () => alert('Sign in — the ONLY thing the upper-right icon does. All navigation + data lives in the upper-left hamburger.');
  }

  // ---- numbering + pick tray ----
  function initNumbers(layout) {
    const KEY = 'rentvPicks';
    const picks = new Set(JSON.parse(localStorage.getItem(KEY) || '[]'));
    let n = 0;
    document.querySelectorAll('[data-eln]').forEach(el => {
      n++;
      const code = layout + '·' + String(n).padStart(2, '0');
      el.dataset.elnCode = code;
      const badge = document.createElement('span');
      badge.className = 'eln' + (picks.has(code) ? ' picked' : '');
      badge.textContent = code;
      badge.title = 'Click to add/remove from your picks';
      badge.onclick = (e) => { e.stopPropagation(); toggle(code, badge); };
      el.appendChild(badge);
    });
    // FAB + tray
    const fab = document.createElement('div'); fab.className = 'rv-fab';
    fab.innerHTML = `<button class="t-num">① Numbers</button><button class="t-pick">★ My Picks (<span id="rvPickN">${picks.size}</span>)</button>`;
    document.body.append(fab);
    const tray = document.createElement('div'); tray.className = 'rv-tray'; tray.id = 'rvTray';
    tray.innerHTML = `<div class="th"><b>My Picks — elements to keep in the final design</b><button class="x" id="rvTrayX">✕</button></div>
      <div class="chips" id="rvChips"></div>
      <div class="acts"><button class="copy" id="rvCopy">Copy picks</button><button class="clr" id="rvClr">Clear all</button></div>`;
    document.body.append(tray);
    const numBtn = fab.querySelector('.t-num'), pickBtn = fab.querySelector('.t-pick');
    numBtn.onclick = () => { document.body.classList.toggle('eln-on'); numBtn.classList.toggle('on'); };
    pickBtn.onclick = () => tray.classList.add('open');
    document.getElementById('rvTrayX').onclick = () => tray.classList.remove('open');
    document.body.classList.add('eln-on'); numBtn.classList.add('on'); // numbers ON by default

    function save() { localStorage.setItem(KEY, JSON.stringify([...picks])); document.getElementById('rvPickN').textContent = picks.size; renderChips(); }
    function toggle(code, badge) { if (picks.has(code)) picks.delete(code); else picks.add(code); if (badge) badge.classList.toggle('picked'); save(); }
    function renderChips() {
      const box = document.getElementById('rvChips');
      if (!picks.size) { box.innerHTML = '<span class="empty">No picks yet. Turn on Numbers, then click the ▦ badge on any element you want in the final build.</span>'; return; }
      box.innerHTML = [...picks].sort().map(c => `<span class="chip">${c}<button data-c="${c}">✕</button></span>`).join('');
      box.querySelectorAll('button').forEach(b => b.onclick = () => {
        picks.delete(b.dataset.c);
        const badge = [...document.querySelectorAll('.eln')].find(e => e.dataset && e.textContent === b.dataset.c);
        document.querySelectorAll('.eln').forEach(e => { if (e.textContent === b.dataset.c) e.classList.remove('picked'); });
        save();
      });
    }
    document.getElementById('rvCopy').onclick = () => {
      const txt = [...picks].sort().join(', ');
      navigator.clipboard?.writeText(txt); alert('Copied ' + picks.size + ' picks:\n\n' + (txt || '(none)'));
    };
    document.getElementById('rvClr').onclick = () => { picks.clear(); document.querySelectorAll('.eln.picked').forEach(e => e.classList.remove('picked')); save(); };
    renderChips();
  }

  const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const SECT_COLOR = { Financing: '#2563eb', Sales: '#c8102e', Leases: '#0f8a4d', Development: '#b45309', Multifamily: '#7c3aed', Industrial: '#0891b2', Retail: '#db2777' };
  const sectionColor = c => SECT_COLOR[c] || '#5b636b';
  // image with graceful fallback to a section-tinted gradient
  const imgTag = (src, cls, seed) => {
    const hue = ((String(seed || src).length * 37) % 360);
    const grad = `linear-gradient(135deg,hsl(${hue} 45% 32%),hsl(${(hue + 40) % 360} 50% 20%))`;
    return `<span class="${cls || ''}" style="display:block;background:${grad};background-size:cover;background-position:center"${src ? ` data-bg="${esc(src)}"` : ''}></span>`;
  };
  function hydrateImages() { // set bg images after mount; hide broken ones gracefully (gradient remains)
    document.querySelectorAll('[data-bg]').forEach(el => {
      const im = new Image(); const url = el.getAttribute('data-bg');
      im.onload = () => { el.style.backgroundImage = `url("${url}")`; };
      im.src = url;
    });
  }

  return {
    NAV, usd, shortUsd, dstr, esc, sectionColor, imgTag, hydrateImages, data: null,
    async mount(opts) {
      opts = opts || {};
      this.data = await loadData();
      buildChrome(opts);
      if (opts.render) opts.render(this.data);
      this.hydrateImages();
      initNumbers(opts.layout || 'L');
    },
  };
})();