← back to Labj Cre Banks
public/index.html
257 lines
<!doctype html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LABJ CRE + Banking — RENTV Prospect Database</title>
<style>
:root { --scale: 13px; --pad: 6px; }
* { box-sizing: border-box; }
body { margin: 0; font: var(--scale)/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
color: #1a1a1a; background: #f4f2ee; }
header { padding: 14px 18px; background: #12223a; color: #fff; }
header h1 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: .3px; }
header .sub { opacity: .75; font-size: 12px; margin-top: 2px; }
.bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
padding: 10px 18px; background: #fff; border-bottom: 1px solid #e2ddd4; position: sticky; top: 0; z-index: 5; }
.bar input[type=search] { flex: 1 1 240px; min-width: 180px; padding: 7px 10px;
border: 1px solid #cbc4b8; border-radius: 6px; font-size: 13px; }
.bar label { font-size: 12px; color: #555; display: inline-flex; align-items: center; gap: 6px; }
.bar button { padding: 6px 11px; border: 1px solid #cbc4b8; background: #fbfaf7; border-radius: 6px;
cursor: pointer; font-size: 12px; }
.bar button:hover { background: #efece5; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 18px; background: #faf8f4; border-bottom: 1px solid #e8e3da; }
.chip { font-size: 11px; padding: 3px 9px; border: 1px solid #cbc4b8; border-radius: 999px;
background: #fff; cursor: pointer; user-select: none; }
.chip.on { background: #12223a; color: #fff; border-color: #12223a; }
.colpanel { display: none; flex-wrap: wrap; gap: 4px 16px; padding: 10px 18px; background: #fffdf8; border-bottom: 1px solid #e8e3da; }
.colpanel.show { display: flex; }
.colpanel label { font-size: 12px; display: inline-flex; align-items: center; gap: 5px; }
.wrap { overflow: auto; max-height: calc(100vh - 150px); }
table { border-collapse: collapse; width: 100%; background: #fff; }
th, td { padding: var(--pad) 10px; border-bottom: 1px solid #ece7de; text-align: left;
vertical-align: top; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 340px; }
th { position: sticky; top: 0; background: #f0ece3; cursor: pointer; user-select: none;
border-bottom: 2px solid #d8d1c4; font-weight: 600; resize: horizontal; overflow: auto; }
th.drag-over { background: #dfe7f2; }
tr:hover td { background: #fbf9f4; }
td a { color: #123f7a; text-decoration: none; }
td a:hover { text-decoration: underline; }
.score { font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
.s-hi { color: #0a7d3c; } .s-mid { color: #b06a00; } .s-lo { color: #999; }
.cat { font-size: 11px; color: #444; }
tr.detail td { background: #f7f4ee; white-space: normal; }
.detail-grid { display: grid; grid-template-columns: 160px 1fr; gap: 2px 14px; font-size: 12px; }
.detail-grid b { color: #555; font-weight: 600; }
.count { padding: 6px 18px; font-size: 12px; color: #666; background: #faf8f4; }
.prov { padding: 8px 18px; font-size: 12px; line-height: 1.5; color: #3a3a3a;
background: #eef3ea; border-bottom: 1px solid #d6e0cf; }
.prov b { color: #1a1a1a; }
.pill { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 999px; letter-spacing: .3px; }
.orig-labj { background: #12223a; color: #fff; }
.orig-research { background: #e7e2d6; color: #5a5344; }
</style>
</head>
<body>
<header>
<h1>LABJ CRE + Banking — RENTV Prospect Database</h1>
<div class="sub" id="meta">loading…</div>
</header>
<div class="bar">
<input type="search" id="q" placeholder="Search all fields (space = AND)…">
<label>Density <input type="range" id="density" min="11" max="18" value="13"></label>
<button id="toggleCols">Columns ▾</button>
<button id="reset">Reset</button>
<span style="font-size:12px;color:#888">tip: click a row to expand · click a header to sort · drag headers to reorder</span>
</div>
<div class="prov" id="prov">loading provenance…</div>
<div class="chips" id="chips"></div>
<div class="colpanel" id="colpanel"></div>
<div class="count" id="count"></div>
<div class="wrap"><table><thead><tr id="head"></tr></thead><tbody id="body"></tbody></table></div>
<script>
const LS = "labj_viewer_v1";
const ALL_COLS = [
["company_name","Company"], ["categories","Lists"], ["advertiser_fit_score","Fit"],
["city","City"], ["website","Website"], ["main_phone","Phone"],
["top_local_executive","Top Exec"], ["exec_title","Title"],
["marketing_pr_contact","Mktg/PR"], ["public_email","Email"],
["specialties","Specialties"], ["key_metric","Metric"],
["state","State"], ["zip","Zip"], ["address","Address"],
["advertiser_fit_reason","Fit reason"], ["source_company","Sources"],
["origin","Source"], ["last_verified","Verified"]
];
const DEFAULT_COLS = ["company_name","origin","categories","advertiser_fit_score","city",
"website","main_phone","top_local_executive","marketing_pr_contact","public_email","last_verified"];
let DATA = [], state = load();
function load(){ try { return JSON.parse(localStorage.getItem(LS)) || {}; } catch(e){ return {}; } }
function save(){ localStorage.setItem(LS, JSON.stringify(state)); }
function cols(){ return (state.cols && state.cols.length ? state.cols : DEFAULT_COLS.slice()); }
fetch("./labj_master.json").then(r=>r.json()).then(d=>{
DATA = d.records || d;
document.getElementById("meta").textContent =
`${d.count||DATA.length} companies · generated ${d.generated||""} · sorted by RENTV advertiser-fit`;
const labj = DATA.filter(r=>r.origin==="labj_seed").length;
const research = DATA.length - labj;
const withSrc = DATA.filter(r=>(r.source_company||"").trim()).length;
document.getElementById("prov").innerHTML =
`<b>Where this data comes from:</b> LABJ's paid "Book of Lists" is used only as a <i>discovery/ranking pointer</i> — every field is rebuilt from independent public sources with attribution (no LABJ paid product is reproduced). ` +
`<b>${labj}</b> firms were captured from LABJ's <i>public</i> list rows (ranks 1–3, the only rows LABJ shows for free); ` +
`<b>${research}</b> were rebuilt from each firm's own public web presence (LABJ used only to know which firms/categories to research). ` +
`<b>${withSrc}/${DATA.length}</b> carry public source URLs — click any row to see its <i>LABJ list pointer</i> and its <i>data sources</i>. The <span class="pill orig-labj">LABJ</span> / <span class="pill orig-research">Research</span> badge on each row marks which.`;
buildChips(); buildColPanel(); applyDensity(); render();
});
function catList(){
const s = new Set();
DATA.forEach(r => (r.categories||"").split(" | ").forEach(c => c && s.add(c)));
return [...s].sort();
}
function buildChips(){
const box = document.getElementById("chips");
state.cats = state.cats || [];
box.innerHTML = "";
catList().forEach(c=>{
const el = document.createElement("span");
el.className = "chip" + (state.cats.includes(c) ? " on":"");
el.textContent = c;
el.onclick = ()=>{ const i=state.cats.indexOf(c); i<0?state.cats.push(c):state.cats.splice(i,1); save(); buildChips(); render(); };
box.appendChild(el);
});
}
function buildColPanel(){
const p = document.getElementById("colpanel"); p.innerHTML="";
const on = new Set(cols());
ALL_COLS.forEach(([k,label])=>{
const l=document.createElement("label");
l.innerHTML=`<input type="checkbox" ${on.has(k)?"checked":""}> ${label}`;
l.querySelector("input").onchange=e=>{
let c=cols();
if(e.target.checked){ if(!c.includes(k)) c.push(k); } else c=c.filter(x=>x!==k);
state.cols=c; save(); render();
};
p.appendChild(l);
});
}
document.getElementById("toggleCols").onclick=()=>document.getElementById("colpanel").classList.toggle("show");
document.getElementById("q").oninput=render;
document.getElementById("density").oninput=e=>{ state.density=e.target.value; save(); applyDensity(); };
document.getElementById("reset").onclick=()=>{ state={}; save();
document.getElementById("q").value=""; document.getElementById("density").value=13;
buildChips(); buildColPanel(); applyDensity(); render(); };
function applyDensity(){
const v = state.density || 13;
document.getElementById("density").value=v;
document.documentElement.style.setProperty("--scale", v+"px");
document.documentElement.style.setProperty("--pad", Math.round(v*0.5)+"px");
}
function scoreClass(s){ return s>=70?"s-hi":s>=40?"s-mid":"s-lo"; }
function cell(r,k){
let v = r[k]==null?"":String(r[k]);
if(k==="website" && v) return `<a href="${v.startsWith('http')?v:'https://'+v}" target="_blank" rel="noopener noreferrer">${v.replace(/^https?:\/\/(www\.)?/,'')}</a>`;
if(k==="public_email" && v) return `<a href="mailto:${v}">${v}</a>`;
if(k==="main_phone" && v) return `<a href="tel:${v.replace(/[^0-9+]/g,'')}">${v}</a>`;
if(k==="advertiser_fit_score") return `<span class="score ${scoreClass(+v)}">${v}</span>`;
if(k==="categories") return `<span class="cat">${v.replace(/ \| /g,'<br>')}</span>`;
if(k==="origin"){
if(v==="labj_seed") return `<span class="pill orig-labj" title="Captured from LABJ's public list (top-3 rows)">LABJ</span>`;
return `<span class="pill orig-research" title="Rebuilt from independent public sources; LABJ used as a category pointer">Research</span>`;
}
return v;
}
function render(){
const q = document.getElementById("q").value.toLowerCase().split(/\s+/).filter(Boolean);
const cs = cols();
let rows = DATA.filter(r=>{
if(state.cats && state.cats.length){
const rc=(r.categories||"").split(" | ");
if(!state.cats.some(c=>rc.includes(c))) return false;
}
if(q.length){ const blob=Object.values(r).join(" ").toLowerCase(); return q.every(t=>blob.includes(t)); }
return true;
});
const sk = state.sortKey || "advertiser_fit_score", dir = state.sortDir==null?-1:state.sortDir;
rows.sort((a,b)=>{
let x=a[sk], y=b[sk];
if(sk==="advertiser_fit_score"){ x=+x||0; y=+y||0; return (x-y)*dir; }
return String(x||"").localeCompare(String(y||""))*dir;
});
// head
const head=document.getElementById("head"); head.innerHTML="";
cs.forEach(k=>{
const label=(ALL_COLS.find(c=>c[0]===k)||[k,k])[1];
const th=document.createElement("th");
th.textContent=label + (sk===k?(dir<0?" ↓":" ↑"):""); th.dataset.k=k; th.draggable=true;
th.onclick=()=>{ if(state.sortKey===k) state.sortDir=(dir<0?1:-1); else {state.sortKey=k; state.sortDir=(k==="advertiser_fit_score"?-1:1);} save(); render(); };
th.ondragstart=e=>e.dataTransfer.setData("k",k);
th.ondragover=e=>{e.preventDefault(); th.classList.add("drag-over");};
th.ondragleave=()=>th.classList.remove("drag-over");
th.ondrop=e=>{ e.preventDefault(); th.classList.remove("drag-over");
const from=e.dataTransfer.getData("k"); let c=cols(); const fi=c.indexOf(from), ti=c.indexOf(k);
if(fi>=0&&ti>=0){ c.splice(ti,0,c.splice(fi,1)[0]); state.cols=c; save(); render(); } };
head.appendChild(th);
});
// body
const body=document.getElementById("body"); body.innerHTML="";
rows.forEach((r,i)=>{
const tr=document.createElement("tr");
cs.forEach(k=>{ const td=document.createElement("td"); td.innerHTML=cell(r,k);
if(k==="company_name") td.title=r.company_name; body; tr.appendChild(td); });
tr.onclick=e=>{ if(e.target.tagName==="A") return; toggleDetail(tr, r); };
body.appendChild(tr);
});
document.getElementById("count").textContent = `${rows.length} of ${DATA.length} companies shown`;
}
function toggleDetail(tr, r){
if(tr.nextSibling && tr.nextSibling.classList && tr.nextSibling.classList.contains("detail")){ tr.nextSibling.remove(); return; }
const dt=document.createElement("tr"); dt.className="detail";
const td=document.createElement("td"); td.colSpan=cols().length;
const flds=[["Company","company_name"],["Aliases","aliases"],["Lists","categories"],
["Fit score","advertiser_fit_score"],["Fit reason","advertiser_fit_reason"],
["Address","address"],["City/State/Zip",null],["Website","website"],["Phone","main_phone"],
["Top exec","top_local_executive"],["Exec title","exec_title"],
["Mktg/PR contact","marketing_pr_contact"],["Mktg/PR title","marketing_pr_title"],["Email","public_email"],
["Specialties","specialties"],["Key metric","key_metric"],["LA presence","la_presence"],
["Data sources (public)","source_company"],["LABJ list (discovery pointer)","source_labj"],["Record source","origin"],["Verified","last_verified"]];
let h='<div class="detail-grid">';
flds.forEach(([lab,k])=>{
let v = k? (r[k]||"") : `${r.city||""} ${r.state||""} ${r.zip||""}`.trim();
if(k==="website"&&v) v=`<a href="${v}" target="_blank" rel="noopener noreferrer">${v}</a>`;
if((k==="source_company"||k==="source_labj")&&v) v=v.split(/;\s*/).map(u=>u.startsWith("http")?`<a href="${u}" target="_blank" rel="noopener noreferrer">${u}</a>`:u).join("<br>");
if(v) h+=`<b>${lab}</b><span>${v}</span>`;
});
h+="</div>"; td.innerHTML=h; dt.appendChild(td); tr.after(dt);
}
</script>
</body>
</html>