← back to Commercialrealestate

public/linkedin.html

329 lines

<!doctype html>
<html lang="en">
<head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%230a66c2'%3Ein%3C/text%3E%3C/svg%3E">
<title>LinkedIn — Follow list</title>
<style>
  :root{ --bg:#0c0f15; --panel:#141925; --panel2:#1d2230; --fg:#E6E8EC; --muted:#8A93A2; --gold:#C8A24B;
         --active:#70AD47; --draft:#5B9BD5; --in:#0a66c2; --line:#232a38; --del:#E74C3C; }
  *{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}
  header{display:flex;align-items:center;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#10141d,#0c0f15);position:sticky;top:0;z-index:10;}
  header h1{margin:0;font-size:18px;letter-spacing:.5px;} header h1 b{color:var(--in);}
  header .nav{margin-left:auto;display:flex;gap:14px;} header .nav a{color:var(--draft);text-decoration:none;font-size:13px;} header .nav a:hover{color:var(--gold);}
  .wrap{padding:18px 22px 50px;max-width:1100px;margin:0 auto;}
  .intro{color:var(--muted);font-size:13px;margin-bottom:14px;border-left:2px solid var(--in);padding-left:10px;}
  .bar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:14px;}
  .bar input[type=text]{flex:1;min-width:220px;background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:9px;padding:9px 12px;font-size:14px;}
  .seg{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden;}
  .seg button{background:var(--panel2);border:0;color:var(--muted);padding:8px 12px;font-size:12px;cursor:pointer;}
  .seg button.on{background:var(--in);color:#fff;}
  .count{font-size:12px;color:var(--muted);} .count b{color:var(--active);}
  .list{display:flex;flex-direction:column;gap:8px;}
  /* Grid view — same cards, laid out as a responsive grid (view toggle, Steve grid-controls rule) */
  .list.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px;}
  .list.grid .row{flex-direction:column;align-items:flex-start;gap:7px;}
  .list.grid .row .who{width:100%;}
  .row{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:11px 14px;}
  .row.followed{border-color:rgba(112,173,71,.5);background:linear-gradient(90deg,rgba(112,173,71,.07),var(--panel));}
  .row .who{flex:1;min-width:0;}
  .row .nm{font-weight:600;}
  .row .fm{color:var(--muted);font-size:12px;}
  .atype{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:1px 6px;border-radius:9px;margin-left:6px;vertical-align:middle;}
  .atype.res{background:rgba(91,155,213,.16);color:var(--draft);border:1px solid rgba(91,155,213,.4);}
  .atype.com{background:rgba(200,162,75,.14);color:var(--gold);border:1px solid rgba(200,162,75,.4);}
  .lc{font-size:11px;color:var(--muted);white-space:nowrap;}
  .open{display:inline-flex;align-items:center;gap:5px;background:var(--in);color:#fff;text-decoration:none;border-radius:8px;padding:7px 12px;font-size:12px;font-weight:600;white-space:nowrap;}
  .open.srch{background:var(--panel2);color:var(--draft);border:1px solid var(--line);}
  .open:hover{filter:brightness(1.1);}
  .fbtn{background:var(--panel2);border:1px solid var(--line);color:var(--muted);border-radius:8px;padding:7px 11px;font-size:12px;cursor:pointer;white-space:nowrap;}
  .fbtn.on{background:rgba(112,173,71,.16);border-color:var(--active);color:var(--active);}
  .gate{margin-top:16px;font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:12px;}
  .empty{color:var(--muted);padding:30px;text-align:center;}
  /* sort + data-points controls (Steve list-build rule 2026-07-31) */
  #sortsel{background:var(--panel2);color:var(--fg);border:1px solid var(--line);border-radius:9px;padding:8px 10px;font-size:12px;}
  .dpbtn{background:var(--panel2);color:var(--muted);border:1px solid var(--line);border-radius:9px;padding:8px 11px;font-size:12px;cursor:pointer;} .dpbtn:hover{color:var(--fg);border-color:var(--gold);}
  .dppop{position:absolute;z-index:30;margin-top:6px;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:10px 12px;box-shadow:0 8px 30px rgba(0,0,0,.5);min-width:180px;display:none;}
  .dppop.on{display:block;}
  .dptog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--fg);cursor:pointer;padding:3px 4px;border-radius:6px;} .dptog:hover{background:rgba(200,162,75,.08);} .dptog input{accent-color:var(--gold);}
  .dppop .minibtn{background:var(--panel2);color:var(--muted);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;margin-right:5px;margin-top:8px;} .dppop .minibtn:hover{border-color:var(--gold);color:var(--fg);}
  /* card-field rows (toggled by data-points) */
  .row .dp{font-size:11px;color:var(--muted);white-space:nowrap;}
  .row.clk{cursor:pointer;} .row.clk:hover{border-color:rgba(200,162,75,.5);}
  /* ALWAYS-ON contact strip (Steve HARD RULE: phone + email on every people row, non-fabricated) */
  .row .contact{display:flex;flex-wrap:wrap;gap:3px 12px;font-size:11.5px;white-space:nowrap;}
  .row .contact a{color:var(--draft);text-decoration:none;} .row .contact a:hover{text-decoration:underline;}
  .row .contact .none{color:var(--muted);}
  #modal .mcontact{border:1px solid var(--line);border-radius:10px;padding:10px 12px;background:var(--panel2);margin:8px 0 6px;}
  #modal .mcontact h4{margin:0 0 5px;font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);}
  #modal .mcontact a{color:var(--draft);text-decoration:none;margin-right:14px;} #modal .mcontact .none{color:var(--muted);}
  /* detail modal — all fields */
  #ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.62);z-index:60;align-items:flex-start;justify-content:center;padding:44px 14px;overflow-y:auto;}
  #ov.on{display:flex;}
  #modal{width:min(560px,96vw);background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
  #modal h3{margin:0 0 2px;font-size:20px;} #modal .mfirm{color:var(--gold);font-size:13px;}
  #modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--muted);font-size:22px;cursor:pointer;}
  #modal .litem{display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid #20262f;font-size:13px;}
  #modal .litem .m{color:var(--muted);} #modal .litem a{color:var(--draft);text-decoration:none;} #modal .litem a:hover{text-decoration:underline;}
  #modal .mact{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;}
  @media(max-width:680px){ header{flex-wrap:wrap;} header .nav{flex-wrap:wrap;gap:10px;width:100%;} .row{flex-wrap:wrap;} .row .lc{order:5;width:100%;} }
</style>
  <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</head>
<body>
<header>
  <h1><b>in</b> Follow List</h1>
  <div class="nav">
    <a href="/" title="Investment Explorer">← Explorer</a>
    <a href="/crcp.html" title="Control Panel">🛰️ CRCP</a>
    <a href="/brokers.html" title="Broker map">🕸️ Broker map</a>
  </div>
</header>
<div class="wrap">
  <div class="intro">Every broker &amp; agent in the CRE graph, ready to follow on LinkedIn.
    Open each profile and follow manually — this list tracks who you've followed (saved in this browser).
    <b>No automated following</b> — LinkedIn's TOS prohibits 3rd-party automation, so this is a links-only playbook.</div>
  <div class="bar">
    <input type="text" id="q" placeholder="search ALL fields — name, firm, type, listings… (space = AND)">
    <div class="seg" id="filt">
      <button data-f="all" class="on">All</button>
      <button data-f="profile">Has profile</button>
      <button data-f="followed">Followed</button>
    </div>
    <select id="sortsel" title="Sort the list"></select>
    <div class="seg" id="viewseg" title="List / Grid view">
      <button data-v="list" class="on">☰ List</button>
      <button data-v="grid">▦ Grid</button>
    </div>
    <div style="position:relative">
      <button class="dpbtn" id="dpbtn" title="Show / hide data points on each card">⚙ Data points</button>
      <div class="dppop" id="dppop"></div>
    </div>
    <div class="count" id="count">loading…</div>
  </div>
  <div class="list" id="list"></div>
  <div class="gate">⚑ Manual &amp; TOS-compliant: profile/search links only; the followed state is stored locally in this browser.
    Actual connecting/messaging happens in LinkedIn by hand (or via Sales Navigator) — never automated here.</div>
</div>
<div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
<script>
const $=s=>document.querySelector(s);
const FOLLOW_KEY='crcp_li_followed';
// one-time migration off the legacy persona key
try{const _o=localStorage.getItem('frank_li_followed');if(_o!==null&&localStorage.getItem(FOLLOW_KEY)===null){localStorage.setItem(FOLLOW_KEY,_o);localStorage.removeItem('frank_li_followed');}}catch(_){}
let followed=new Set(JSON.parse(localStorage.getItem(FOLLOW_KEY)||'[]'));
let people=[], filt='all', q='', sortKey='listings', sortDir=-1;
const atype=t=>t==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const clean=v=>{const s=(v==null?'':String(v)).trim();return(!s||/^null/i.test(s))?null:s;};
const weburl=w=>{w=clean(w);return w?(/^https?:\/\//i.test(w)?w:'http://'+w):null;};
const bnorm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'').replace(/(inc|llc|corp|corporation|company|co|realty|realestate|group|properties|partners|associates)$/,'');
// ── firm-confirmed contact join (Steve HARD RULE + non-fabrication gate) ──
// Authoritative source: /api/brokers/all, which shares the SAME broker `id` as /api/brokers/linkedin
// (verified: 800/800 name-agreement, 0 mismatch) → id-join is a same-record match, not name-alone.
// Fallback: name + firm agreement (never name alone). Never invent a phone/email.
let CBYID={}, CBYNF={};
function contactFor(p){
  if(!p) return null;
  const b=CBYID[String(p.id)];
  if(b && (b.phone||b.email)) return b;                    // same-record id match (authoritative)
  const nf=CBYNF[bnorm(p.name)+'|'+bnorm(p.firm)];         // name + firm agreement
  if(nf && (nf.phone||nf.email)) return nf;
  return null;
}
function saveFollowed(){ localStorage.setItem(FOLLOW_KEY, JSON.stringify([...followed])); }
function toggle(id){ id=String(id); followed.has(id)?followed.delete(id):followed.add(id); saveFollowed(); render(); }

// ── Data points (card fields) — START TIGHT: only a couple show on first load, the
// user toggles the rest on. A saved pref in liFields always wins. (Steve list-build rule)
const FIELDS=[
  {k:'agent_type',l:'Type',t:'atype'},
  {k:'firm',l:'Company / Firm',t:'s'},
  {k:'listings',l:'Listings',t:'n'},
  {k:'linkedin',l:'Profile',t:'prof'},
];
// Type/Firm/Listings/Profile are card fields; phone+email render in the ALWAYS-ON .contact strip
// (they are NOT user-hideable — Steve hard rule).
const DEFAULT_ON=new Set(['firm','listings']);
let VISF=(function(){let s={};try{s=JSON.parse(localStorage.getItem('liFields')||'{}');}catch(e){}return s;})();
function fVis(k){ if(k in VISF) return !!VISF[k]; return DEFAULT_ON.has(k); }
function saveFields(){ try{localStorage.setItem('liFields',JSON.stringify(VISF));}catch(e){} }
const dpFmt=(p,f)=>{
  const v=p[f.k];
  if(f.t==='atype') return v==='residential'?'Residential':'Commercial';
  if(f.t==='n') return (v||0)+' listing'+((v||0)===1?'':'s');
  if(f.t==='prof') return p.linkedin?'has profile':'search only';
  return v==null||v===''?'—':String(v);
};
// ALWAYS-ON contact strip — phone + email, firm-confirmed (id/name+firm join), never fabricated.
function contactStrip(p){
  const c=contactFor(p);
  const ph=c&&c.phone?`<a href="tel:${esc(String(c.phone).replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">📞 ${esc(c.phone)}</a>`:'<span class="none">📞 via profile ↗</span>';
  const em=c&&c.email?`<a href="mailto:${esc(c.email)}" onclick="event.stopPropagation()">✉️ ${esc(c.email)}</a>`:'<span class="none">✉️ via profile ↗</span>';
  return `<span class="contact">${ph}${em}</span>`;
}

function visible(){
  let rows=people.filter(p=>{
    if(filt==='profile' && !p.linkedin) return false;
    if(filt==='followed' && !followed.has(String(p.id))) return false;
    return true;
  });
  if(q){
    // Search ALL fields (Steve list-build rule): every field the API returned on a row,
    // incl. nested objects, via JSON.stringify. Space = AND.
    const terms=q.toLowerCase().split(/\s+/).filter(Boolean);
    rows=rows.filter(p=>{
      const c=contactFor(p);
      const hay=(Object.keys(p).map(k=>{const v=p[k];return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v));}).join('  ')
        +'  '+[c&&c.phone,c&&c.email,c&&c.name].filter(Boolean).join('  ')).toLowerCase();
      return terms.every(t=>hay.includes(t));
    });
  }
  rows=rows.slice().sort((a,b)=>{
    let x=a[sortKey], y=b[sortKey];
    const xE=(x==null||x===''), yE=(y==null||y==='');
    if(xE&&yE) return 0; if(xE) return 1; if(yE) return -1;
    const xn=!isNaN(+x), yn=!isNaN(+y);
    if(xn&&yn){ x=+x; y=+y; } else { x=String(x).toLowerCase(); y=String(y).toLowerCase(); }
    if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
  });
  return rows;
}
function render(){
  const rows=visible();
  $('#count').innerHTML=`<b>${followed.size}</b> followed · ${rows.length} shown · ${people.length} total`;
  if(!rows.length){ $('#list').innerHTML='<div class="empty">no matches</div>'; return; }
  const dpCols=FIELDS.filter(f=>fVis(f.k) && f.k!=='firm'); // firm renders in the .who block
  $('#list').innerHTML=rows.map(p=>{
    const isF=followed.has(String(p.id));
    const link=p.linkedin
      ? `<a class="open" href="${esc(p.linkedin)}" target="_blank" rel="noopener noreferrer">in · Open profile</a>`
      : `<a class="open srch" href="${esc(p.search)}" target="_blank" rel="noopener noreferrer">🔎 Find on LinkedIn</a>`;
    const dps=dpCols.map(f=>`<span class="dp">${esc(f.l)}: ${esc(dpFmt(p,f))}</span>`).join('');
    const firmLine=fVis('firm')?`<div class="fm">${esc(p.firm||'—')}</div>`:'';
    return `<div class="row clk${isF?' followed':''}" data-id="${esc(p.id)}">
      <div class="who"><span class="nm">${esc(p.name)}</span>${atype(p.agent_type)}${firmLine}</div>
      ${dps}
      ${contactStrip(p)}
      ${link}
      <button class="fbtn${isF?' on':''}" onclick="event.stopPropagation();toggle('${p.id}')">${isF?'✓ Followed':'+ Follow'}</button>
    </div>`;
  }).join('');
}
// ── card-click → detail modal (ALL fields) ──
function openDetail(id){
  const p=people.find(x=>String(x.id)===String(id)); if(!p) return;
  const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on');
  const skip=new Set(['name','firm','agent_type']);
  const link=p.linkedin
    ? `<a class="open" href="${esc(p.linkedin)}" target="_blank" rel="noopener noreferrer">in · Open profile</a>`
    : `<a class="open srch" href="${esc(p.search)}" target="_blank" rel="noopener noreferrer">🔎 Find on LinkedIn</a>`;
  const isF=followed.has(String(p.id));
  const rowsHtml=Object.keys(p).filter(k=>!skip.has(k)).map(k=>{
    let v=p[k]; if(v==null||v==='') v='—';
    else if(typeof v==='object') v=JSON.stringify(v);
    else if(/^https?:\/\//.test(v)) v=`<a href="${esc(v)}" target="_blank" rel="noopener noreferrer">${esc(v)}↗</a>`;
    else v=esc(v);
    return `<div class="litem"><span class="m">${esc(k)}</span><span>${v}</span></div>`;
  }).join('');
  const c=contactFor(p);
  const cph=c&&c.phone?`<a href="tel:${esc(String(c.phone).replace(/[^0-9+]/g,''))}">📞 ${esc(c.phone)}</a>`:'<span class="none">📞 phone via profile ↗</span>';
  const cem=c&&c.email?`<a href="mailto:${esc(c.email)}">✉️ ${esc(c.email)}</a>`:'<span class="none">✉️ email via profile ↗</span>';
  const cweb=c&&c.website?`<a href="${esc(c.website)}" target="_blank" rel="noopener noreferrer">🌐 site</a>`:'';
  const contactBlock=`<div class="mcontact"><h4>Contact${c&&c.name&&bnorm(c.name)!==bnorm(p.name)?` — ${esc(c.name)}`:''} (firm-confirmed)</h4><div>${cph}${cem}${cweb}</div></div>`;
  mb.innerHTML=`<h3>${esc(p.name)}${atype(p.agent_type)}</h3>`
    +`<div class="mfirm">${esc(p.firm||'—')}</div>`
    +contactBlock
    +rowsHtml
    +`<div class="mact">${link}<button class="fbtn${isF?' on':''}" onclick="toggle('${p.id}');document.getElementById('ov').classList.remove('on')">${isF?'✓ Followed':'+ Follow'}</button></div>`;
}
async function loadContacts(){
  // Build the firm-confirmed contact index from /api/brokers/all (same broker ids as the linkedin feed).
  try{
    const d=await (await fetch('/api/brokers/all')).json();
    const arr=(d&&(d.brokers||d.results||d))||[];
    (Array.isArray(arr)?arr:[]).forEach(b=>{
      const ph=clean(b.phone),em=clean(b.email);
      if(!ph&&!em) return;
      const rec={name:clean(b.name),firm:clean(b.firm),phone:ph,email:em,website:weburl(b.website)};
      if(b.id!=null && !CBYID[String(b.id)]) CBYID[String(b.id)]=rec;   // authoritative same-record id join
      const k=bnorm(b.name)+'|'+bnorm(b.firm);                          // name + firm agreement fallback
      if(k.length>2 && !CBYNF[k]) CBYNF[k]=rec;
    });
  }catch(e){ /* contacts optional — rows still render with the via-profile fallback */ }
}
async function load(){
  $('#count').textContent='loading…';
  try{
    await loadContacts();
    const d=await (await fetch('/api/brokers/linkedin?limit=800')).json();
    people=d.people||[]; render();
  }catch(e){ $('#list').innerHTML='<div class="empty">failed to load — is the server up? '+e.message+'</div>'; }
}
// Search is client-side now (search ALL fields) — no re-fetch; the API also filters by name/firm
// but we keep the full 800-row set client-side so JSON.stringify search covers every field.
$('#q').oninput=()=>{ q=$('#q').value.trim(); render(); };
$('#filt').onclick=e=>{ const b=e.target.closest('button'); if(!b) return;
  document.querySelectorAll('#filt button').forEach(x=>x.classList.toggle('on',x===b)); filt=b.dataset.f; render(); };
// ── view toggle (List / Grid) — persisted (Steve grid-controls rule) ──
(function(){
  const seg=$('#viewseg'), list=$('#list');
  const apply=v=>{ list.classList.toggle('grid',v==='grid'); document.querySelectorAll('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===v)); };
  let v=localStorage.getItem('liView')||'list'; apply(v);
  seg.onclick=e=>{ const b=e.target.closest('button'); if(!b) return; v=b.dataset.v; localStorage.setItem('liView',v); apply(v); };
})();

// ── sort <select> (Steve list-build rule: every grid gets a sort control) ──
(function(){
  const sel=$('#sortsel');
  const opts=[
    {k:'listings',dir:-1,l:'Listings ↓'},
    {k:'listings',dir:1,l:'Listings ↑'},
    {k:'name',dir:1,l:'Name A→Z'},
    {k:'name',dir:-1,l:'Name Z→A'},
    {k:'firm',dir:1,l:'Firm A→Z'},
    {k:'agent_type',dir:1,l:'Type'},
  ];
  sel.innerHTML=opts.map((o,i)=>`<option value="${i}">${esc(o.l)}</option>`).join('');
  const sv=localStorage.getItem('liSort'); if(sv!==null && opts[+sv]){ sel.value=sv; sortKey=opts[+sv].k; sortDir=opts[+sv].dir; }
  sel.onchange=()=>{ const o=opts[+sel.value]; sortKey=o.k; sortDir=o.dir; localStorage.setItem('liSort',sel.value); render(); };
})();

// ── data-points show/hide popover ──
(function(){
  const pop=$('#dppop'), btn=$('#dpbtn');
  function build(){
    pop.innerHTML=FIELDS.map(f=>`<label class="dptog"><input type="checkbox" data-fk="${f.k}" ${fVis(f.k)?'checked':''}><span>${esc(f.l)}</span></label>`).join('')
      +`<div><button class="minibtn" id="dpAll">All</button><button class="minibtn" id="dpNone">None</button><button class="minibtn" id="dpReset">↺ Reset</button></div>`;
  }
  build();
  btn.onclick=e=>{ e.stopPropagation(); pop.classList.toggle('on'); };
  pop.onclick=e=>{ e.stopPropagation();
    const cb=e.target.closest('input[data-fk]');
    if(cb){ VISF[cb.dataset.fk]=cb.checked; saveFields(); render(); return; }
    if(e.target.id==='dpAll'){ FIELDS.forEach(f=>VISF[f.k]=true); saveFields(); build(); render(); }
    if(e.target.id==='dpNone'){ FIELDS.forEach(f=>VISF[f.k]=false); saveFields(); build(); render(); }
    if(e.target.id==='dpReset'){ VISF={}; saveFields(); build(); render(); }
  };
  document.addEventListener('click',()=>pop.classList.remove('on'));
})();

// ── card click → detail modal ──
$('#list').addEventListener('click',e=>{
  if(e.target.closest('a')||e.target.closest('button')) return;
  const row=e.target.closest('.row'); if(row&&row.dataset.id) openDetail(row.dataset.id);
});
$('#ov').addEventListener('click',e=>{ if(e.target.id==='ov') $('#ov').classList.remove('on'); });
document.addEventListener('keydown',e=>{ if(e.key==='Escape') $('#ov').classList.remove('on'); });

load();
</script>
<script src="/corner-nav.js" defer></script>
  <script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
</body>
</html>