← 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 & 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
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>