β back to Re Flyer Aggregator
add global fast-search page (/search) β chat-style instant search across all 3870+ deals & articles (title/source/market/buyer/seller/price/date), match highlighting; LEFT nav with 'π All Listings' + type filters (Sales/Listings/Leases/Loans/Dev/News) + top-source filters; merges deal-store + article-archive (grows over time); Search link on dashboard (Steve) (TK-10708)
2cda3b359f882033b94a19562270f3067dc0343f Β· 2026-08-20 00:11:27 -0700 Β· Steve Abrams
Files touched
A public/search/index.htmlM public/sources/index.htmlM serve-viewers.mjs
Diff
commit 2cda3b359f882033b94a19562270f3067dc0343f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 20 00:11:27 2026 -0700
add global fast-search page (/search) β chat-style instant search across all 3870+ deals & articles (title/source/market/buyer/seller/price/date), match highlighting; LEFT nav with 'π All Listings' + type filters (Sales/Listings/Leases/Loans/Dev/News) + top-source filters; merges deal-store + article-archive (grows over time); Search link on dashboard (Steve) (TK-10708)
---
public/search/index.html | 81 +++++++++++++++++++++++++++++++++++++++++++++++
public/sources/index.html | 2 +-
serve-viewers.mjs | 7 ++--
3 files changed, 87 insertions(+), 3 deletions(-)
diff --git a/public/search/index.html b/public/search/index.html
new file mode 100644
index 0000000..f646f6d
--- /dev/null
+++ b/public/search/index.html
@@ -0,0 +1,81 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Search Β· All Listings β CRE Β· Abrams</title>
+<style>
+ :root{--navy:#0E2350;--blue:#16357A;--gold:#E8A81C;--gray:#6B7280} *{box-sizing:border-box}
+ body{margin:0;font-family:Inter,-apple-system,Helvetica,Arial,sans-serif;background:#f4f6fb;color:#141414}
+ /* left nav */
+ #side{position:fixed;top:0;left:0;bottom:0;width:210px;background:var(--navy);color:#cfe0ff;overflow-y:auto;z-index:20}
+ #side h1{font-size:15px;color:#fff;margin:0;padding:18px 16px 4px;letter-spacing:.5px} #side .sub{color:var(--gold);font-size:10px;padding:0 16px 12px;letter-spacing:1px}
+ #side .nav a{display:flex;justify-content:space-between;align-items:center;padding:9px 16px;color:#bcd0f5;text-decoration:none;font-size:13px;cursor:pointer;border-left:3px solid transparent}
+ #side .nav a:hover{background:#16264a} #side .nav a.on{background:#1b2f5c;color:#fff;border-left-color:var(--gold);font-weight:600}
+ #side .nav a .c{background:#0e2a4a;color:#8fb4ea;border-radius:10px;padding:1px 8px;font-size:11px}
+ #side .grp{font-size:10px;color:#6f86b8;text-transform:uppercase;letter-spacing:1px;padding:14px 16px 4px}
+ .main{margin-left:210px}
+ header{background:#fff;border-bottom:1px solid #e6e9f2;padding:14px 26px;position:sticky;top:0;z-index:10}
+ .searchbar{display:flex;align-items:center;gap:10px;background:#f4f6fb;border:2px solid #dbe2f2;border-radius:26px;padding:10px 18px;transition:.15s}
+ .searchbar:focus-within{border-color:var(--blue);background:#fff;box-shadow:0 2px 12px rgba(22,53,122,.12)}
+ .searchbar input{flex:1;border:none;background:transparent;font-size:16px;outline:none;color:#141414}
+ .searchbar .ic{font-size:18px;color:var(--gray)} .searchbar .clr{cursor:pointer;color:var(--gray);font-size:13px}
+ .stat{padding:8px 26px 0;color:var(--gray);font-size:12px;font-variant-numeric:tabular-nums}
+ .rows{padding:8px 26px 60px}
+ .r{display:grid;grid-template-columns:96px 90px 78px 1fr 150px 130px;gap:12px;align-items:center;padding:9px 10px;border-bottom:1px solid #eef0f6;font-size:13px}
+ .r:hover{background:#fbfcff} .r .d{color:#8A5A00;font-size:12px;white-space:nowrap} .r .p{font-weight:800;color:var(--blue)}
+ .r .chip{font-size:10px;font-weight:800;padding:2px 7px;border-radius:5px;color:#fff;text-align:center}
+ .r .t a{color:#141414;text-decoration:none} .r .t a:hover{color:var(--blue)} .r .mk{color:#445;font-size:12px} .r .src{color:#8A5A00;font-size:12px}
+ mark{background:#fff2ab;padding:0 1px}
+ @media(max-width:820px){#side{width:56px} #side h1,#side .sub,#side .grp,#side .nav a .lbl{display:none} .main{margin-left:56px} .r{grid-template-columns:80px 1fr}}
+</style></head><body>
+<div id="side">
+ <h1>CRE Search</h1><div class="sub">ABRAMS Β· ALL LISTINGS</div>
+ <div class="grp">Views</div>
+ <div class="nav" id="nav"></div>
+ <div class="grp">Sources</div>
+ <div class="nav" id="srcnav"></div>
+</div>
+<div class="main">
+ <header><div class="searchbar"><span class="ic">π</span><input id="q" placeholder="Search all CRE listings & articles β address, buyer, market, sourceβ¦" autofocus autocomplete="off"><span class="clr" id="clr">clear</span></div></header>
+ <div class="stat" id="stat">loadingβ¦</div>
+ <div class="rows" id="rows"></div>
+</div>
+<script>
+const TC={Sale:'#0E7C4A',Listing:'#0ea5e9',Lease:'#16357A',Loan:'#8A5A00',Development:'#6d28d9',News:'#64748b',Post:'#E8A81C',Filing:'#6B7280',CRE:'#6B7280'};
+const money=n=>!n?'':n>=1e9?'$'+(n/1e9).toFixed(1)+'B':n>=1e6?'$'+Math.round(n/1e6)+'M':'$'+Math.round(n/1e3)+'K';
+const esc=s=>(s||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]));
+const dt=s=>{const d=new Date(s);return isNaN(d)?'':d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'2-digit'});};
+let DATA=[],VIEW='all',SRC='',Q='';const CAP=800;
+const VIEWS=[['all','π All Listings'],['Sale','Sales'],['Listing','New Listings'],['Lease','Leases'],['Loan','Loans/Finance'],['Development','Development'],['News','News']];
+function hl(t,q){ if(!q)return esc(t); const i=t.toLowerCase().indexOf(q); if(i<0)return esc(t); return esc(t.slice(0,i))+'<mark>'+esc(t.slice(i,i+q.length))+'</mark>'+esc(t.slice(i+q.length)); }
+function match(x){ if(VIEW!=='all'&&x.type!==VIEW)return false; if(SRC&&x.source!==SRC)return false;
+ if(!Q)return true; return [x.title,x.source,x.market,x.type,x.price_label,x.buyer,x.seller,dt(x.date)].join(' ').toLowerCase().includes(Q); }
+function render(){
+ const res=DATA.filter(match);
+ document.getElementById('stat').textContent=`${res.length.toLocaleString()} of ${DATA.length.toLocaleString()} records${Q?` Β· β${Q}β`:''}${VIEW!=='all'?' Β· '+VIEW:''}${SRC?' Β· '+SRC:''}${res.length>CAP?` Β· showing first ${CAP}`:''}`;
+ const rows=document.getElementById('rows');
+ rows.innerHTML=res.slice(0,CAP).map(x=>{const c=TC[x.type]||'#6B7280';const t=x.link?`<a href="${x.link}" target="_blank" rel="noopener">${hl(x.title||'',Q)}</a>`:hl(x.title||'',Q);
+ return `<div class="r"><span class="d">${dt(x.date)}</span><span class="p">${x.price_label||money(x.price)||''}</span><span class="chip" style="background:${c}">${(x.type||'CRE').toUpperCase()}</span><span class="t">${t}</span><span class="mk">${esc(x.market||'')}</span><span class="src">${esc(x.source||'')}</span></div>`;}).join('')||'<div style="padding:24px;color:#9aa">No matches.</div>';
+}
+function counts(){const c={all:DATA.length};VIEWS.forEach(([k])=>{if(k!=='all')c[k]=DATA.filter(x=>x.type===k).length;});return c;}
+function buildNav(){const c=counts();
+ document.getElementById('nav').innerHTML=VIEWS.map(([k,lbl])=>`<a data-v="${k}" class="${VIEW===k?'on':''}"><span class="lbl">${lbl}</span><span class="c">${(c[k]||0).toLocaleString()}</span></a>`).join('');
+ const bySrc={};DATA.forEach(x=>bySrc[x.source]=(bySrc[x.source]||0)+1);
+ const top=Object.entries(bySrc).sort((a,b)=>b[1]-a[1]).slice(0,18);
+ document.getElementById('srcnav').innerHTML=`<a data-s="" class="${SRC===''?'on':''}"><span class="lbl">All sources</span></a>`+top.map(([s,n])=>`<a data-s="${esc(s)}" class="${SRC===s?'on':''}"><span class="lbl">${esc(s)}</span><span class="c">${n}</span></a>`).join('');
+ document.querySelectorAll('#nav a').forEach(a=>a.onclick=()=>{VIEW=a.dataset.v;buildNav();render();});
+ document.querySelectorAll('#srcnav a').forEach(a=>a.onclick=()=>{SRC=a.dataset.s;buildNav();render();});
+}
+let deb;document.getElementById('q').oninput=e=>{clearTimeout(deb);deb=setTimeout(()=>{Q=e.target.value.trim().toLowerCase();render();},120);};
+document.getElementById('clr').onclick=()=>{document.getElementById('q').value='';Q='';render();document.getElementById('q').focus();};
+// load deals (flyers.json) + article archive if present, merge + dedup
+async function load(){
+ const [deals,arch]=await Promise.all([
+ fetch('/flyers.json?t='+Date.now()).then(r=>r.json()).catch(()=>[]),
+ fetch('/article-archive.json?t='+Date.now()).then(r=>r.ok?r.json():[]).catch(()=>[])
+ ]);
+ const seen=new Set(),out=[];
+ for(const d of [...deals,...arch]){ const k=(d.title||'').toLowerCase().slice(0,50); if(k&&!seen.has(k)){seen.add(k);out.push(d);} }
+ out.sort((a,b)=>(Date.parse(b.date)||0)-(Date.parse(a.date)||0));
+ DATA=out; buildNav(); render();
+}
+load();
+</script></body></html>
diff --git a/public/sources/index.html b/public/sources/index.html
index ced51ac..02a3bc6 100644
--- a/public/sources/index.html
+++ b/public/sources/index.html
@@ -212,7 +212,7 @@ function renderFlow(d){document.getElementById('flow').innerHTML=
<div class="n"><b>${d.total_deals}</b>CRE deals</div><span class="a">β</span>
<div class="n">email Β· flyers Β· viz</div>
<div class="n" style="background:transparent;border:none;color:#8aa">updated ${new Date(d.generated).toLocaleString()}</div>`;
- document.getElementById('links').innerHTML=`<a href="/live.html">β deal viz</a> <a href="/advertising">broker ad intel βΈ</a> <a href="/advertising/broker-ad-signals.csv" download>β¬ broker CSV</a>`;
+ document.getElementById('links').innerHTML=`<a href="/search" style="font-weight:700;color:#0E7C4A">π Search Β· All Listings</a> <a href="/live.html">β deal viz</a> <a href="/advertising">broker ad intel βΈ</a> <a href="/advertising/broker-ad-signals.csv" download>β¬ broker CSV</a>`;
}
function feedsFiltered(){ const q=document.getElementById('q').value.toLowerCase();
let f=SRC.feeds.filter(x=>x.name.toLowerCase().includes(q)&&(!F.method||x.method===F.method)&&(!F.region||(x.region||'β')===F.region));
diff --git a/serve-viewers.mjs b/serve-viewers.mjs
index 69cfe98..f22c3b5 100644
--- a/serve-viewers.mjs
+++ b/serve-viewers.mjs
@@ -1,11 +1,14 @@
import http from 'node:http'; import { readFileSync, statSync } from 'node:fs'; import { join, extname, dirname } from 'node:path'; import { fileURLToPath } from 'node:url';
const PUB = join(dirname(fileURLToPath(import.meta.url)), 'public');
const MIME = {'.html':'text/html','.json':'application/json','.csv':'text/csv','.js':'text/javascript','.css':'text/css','.png':'image/png'};
+const ROOT = dirname(fileURLToPath(import.meta.url));
const ALIAS = { '/':'/sources/index.html', '/sources':'/sources/index.html', '/live.html':'/flyers/live.html',
- '/flyers.json':'/flyers/flyers.json', '/advertising':'/advertising/index.html', '/property-flyers':'/flyers-found/index.html', '/flyers-found':'/flyers-found/index.html', '/flyers':'/flyers/index.html' };
+ '/flyers.json':'/flyers/flyers.json', '/advertising':'/advertising/index.html', '/property-flyers':'/flyers-found/index.html', '/flyers-found':'/flyers-found/index.html', '/flyers':'/flyers/index.html',
+ '/search':'/search/index.html', '/listings':'/search/index.html' };
http.createServer((q,s)=>{
let u = decodeURIComponent(q.url.split('?')[0]); u = ALIAS[u] || u;
- let fp = join(PUB, u);
+ // the growing article archive lives in data/ (outside public) β serve it for the global search
+ let fp = u === '/article-archive.json' ? join(ROOT, 'data', 'article-archive.json') : join(PUB, u);
try { if (statSync(fp).isDirectory()) fp = join(fp,'index.html'); } catch {}
try { const b = readFileSync(fp); s.writeHead(200,{'content-type':MIME[extname(fp)]||'text/html','cache-control':'no-store','access-control-allow-origin':'*'}); s.end(b); }
catch(e){ s.writeHead(404); s.end('not found: '+u); }
β 3721276 add refine-loop.sh β overnight loop that records every artic
Β·
back to Re Flyer Aggregator
Β·
refine-loop cycle 1: +articles/deals 08-20 00:12 1f8efdb β