[object Object]

← back to Re Flyer Aggregator

sources dashboard: add view-mode switch (Table/Grid/List/KV) + density slider — full-width grid that condenses+stacks (Steve: sliders/grid/list/kv + expand L-R + condense/stack); numbers clickable in every mode; view+density persisted; verified all modes in Chrome (TK-10708)

e87dd4392d675e9bc9450ed1ef1ad6806cabb2c5 · 2026-08-19 23:06:18 -0700 · Steve Abrams

Files touched

Diff

commit e87dd4392d675e9bc9450ed1ef1ad6806cabb2c5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 19 23:06:18 2026 -0700

    sources dashboard: add view-mode switch (Table/Grid/List/KV) + density slider — full-width grid that condenses+stacks (Steve: sliders/grid/list/kv + expand L-R + condense/stack); numbers clickable in every mode; view+density persisted; verified all modes in Chrome (TK-10708)
---
 public/sources/index.html | 63 ++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 51 insertions(+), 12 deletions(-)

diff --git a/public/sources/index.html b/public/sources/index.html
index 10e8765..1403a3a 100644
--- a/public/sources/index.html
+++ b/public/sources/index.html
@@ -65,7 +65,21 @@
  #nav .col{display:flex;justify-content:space-between;align-items:center;padding:6px 14px;cursor:pointer;font-size:13px;color:#445}
  #nav .col:hover{background:#eef1f8} #nav .col .sw{width:30px;height:16px;border-radius:9px;background:#ccd3e6;position:relative;transition:.15s;flex:none}
  #nav .col.on .sw{background:#0E7C4A} #nav .col .sw::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:#fff;transition:.15s} #nav .col.on .sw::after{left:16px}
- @media(max-width:820px){.grid{grid-template-columns:1fr}}
+ /* ---- view-mode switch + density (grid/list/kv), full-width, condense+stack ---- */
+ .vsw{display:inline-flex;border:1px solid #d6dced;border-radius:7px;overflow:hidden}
+ .vsw button{background:#fff;border:none;padding:5px 10px;font-size:12px;cursor:pointer;color:#445;border-right:1px solid #eef1f8} .vsw button:last-child{border-right:none} .vsw button.on{background:var(--navy);color:#fff}
+ .dens{font-size:12px;color:#6B7280;display:inline-flex;align-items:center;gap:6px} .dens input{vertical-align:middle}
+ #srccards{display:none;width:100%}
+ #srccards.grid{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:12px}
+ #srccards.kv{display:grid;grid-template-columns:repeat(var(--cols,2),minmax(0,1fr));gap:12px}
+ #srccards.list{display:flex;flex-direction:column;gap:6px}
+ .scard{background:#fff;border:1px solid #e6e9f2;border-radius:9px;padding:13px 15px;box-shadow:0 1px 6px rgba(0,0,0,.05);min-width:0}
+ .scard .sn{font-weight:700;color:var(--navy);font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .scard .smeta{font-size:11px;color:#6B7280;margin:3px 0 7px}
+ .scard .snums{display:flex;gap:16px;font-size:12px;margin-bottom:6px} .scard .snums b{color:var(--blue)} .scard .snums .g{color:#0E7C4A} .scard .snums span{cursor:pointer}
+ .scard .ss{font-size:11px;color:#555;border-top:1px solid #f1f3f9;padding-top:5px;overflow:hidden} .scard .ss a{color:#16357A;text-decoration:none}
+ .slist{display:flex;align-items:center;gap:12px;background:#fff;border:1px solid #eef1f8;border-radius:7px;padding:7px 12px;font-size:12px;flex-wrap:wrap}
+ .kvc{background:#fff;border:1px solid #e6e9f2;border-radius:9px;padding:10px 15px;min-width:0} .kvc .kvr{display:flex;justify-content:space-between;gap:10px;padding:3px 0;border-top:1px solid #f4f6fb;font-size:12px} .kvc .kvr:first-child{border-top:none} .kvc .kvr .k{color:#6B7280} .kvc .kvr .v{color:#141414;font-weight:600;text-align:right;overflow:hidden;text-overflow:ellipsis}
+ @media(max-width:820px){.grid{grid-template-columns:1fr}#srccards.grid,#srccards.kv{grid-template-columns:1fr}}
 </style>
 <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
 </head><body>
@@ -84,15 +98,19 @@
   <div class="card"><h3>Broker crawl breakdown</h3><canvas id="gCrawl"></canvas></div>
  </div>
  <h2>DATA SOURCES — RSS feeds &amp; proxies</h2>
- <div class="controls"><input id="q" placeholder="search source…" size="18"><span class="clr" id="clr">clear filters</span><div class="stat" id="sstat"></div></div>
- <table><thead><tr id="thead"></tr></thead><tbody id="tb"></tbody></table>
+ <div class="controls"><input id="q" placeholder="search source…" size="18"><span class="clr" id="clr">clear filters</span>
+  <span class="vsw"><button data-v="table" class="on">▦ Table</button><button data-v="grid">▤ Grid</button><button data-v="list">☰ List</button><button data-v="kv">⋮ KV</button></span>
+  <label class="dens">Density <input type="range" id="dens" min="1" max="5" step="1" value="3"></label>
+  <div class="stat" id="sstat"></div></div>
+ <table id="srctable"><thead><tr id="thead"></tr></thead><tbody id="tb"></tbody></table>
+ <div id="srccards"></div>
  <h2>DATABASE CRAWL — brokers (ads + flyers on their sites)</h2>
  <div class="card" id="crawl">Loading crawl status…</div>
 </div>
 <div id="modal" class="mask"><div class="box"><div class="mh"><span id="mtitle"></span><button id="mx" title="close">✕</button></div><div id="mbody" class="mbody"></div></div></div>
 <script>
 const MB={'RSS direct':'direct','RSS (social-UA)':'socialua','Google News proxy':'proxy'};
-let SRC=null,sortK='deals',dir=-1; let F={method:'',region:''};
+let SRC=null,sortK='deals',dir=-1; let F={method:'',region:''}; let VIEW=localStorage.srcView||'table';
 const esc=s=>(s||'').replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
 const dt=s=>{if(!s)return'';const d=new Date(s);return isNaN(d)?(''+s).slice(0,11):d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'2-digit'});};
 let CR=null; // broker-crawl records, kept for drill-down
@@ -180,19 +198,29 @@ function renderFlow(d){document.getElementById('flow').innerHTML=
   <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>`;
 }
-function renderSrc(){ if(!SRC)return;
- const q=document.getElementById('q').value.toLowerCase();
+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));
  f.sort((a,b)=>{ if(sortK==='name'||sortK==='method'||sortK==='region')return (''+a[sortK]).localeCompare(''+b[sortK])*dir;
   if(sortK==='latest'||sortK==='recent')return (latestOf(a)-latestOf(b))*dir; return ((a[sortK]||0)-(b[sortK]||0))*dir; });
- const max=Math.max(1,...SRC.feeds.map(x=>x.pulled)); const ck=cols();
- document.getElementById('tb').innerHTML=f.map(x=>{ const cells=ck.map(k=>cellHTML(x,k,max)).join('');
-  return `<tr class="srow${EXP.has(x.name)?' exp':''}" data-n="${esc(x.name)}"><td class="xc"><span class="car">${EXP.has(x.name)?'▾':'▸'}</span></td>${cells}</tr>`
-   +(EXP.has(x.name)?detailRow(x,ck.length+1):''); }).join('');
+ return f; }
+const ld=x=>latestOf(x)?dt(new Date(latestOf(x)).toISOString()):'—';
+function cardHTML(x){
+ const samp=(x.samples||[]).slice(0,VIEW==='list'?1:3).map(s=>`<div><span style="color:#8A5A00">${dt(s.date)}</span> ${s.price?'<b>'+esc(s.price)+'</b> ':''}<a href="${s.link}" target="_blank" rel="noopener">${esc((s.title||'').slice(0,64))}</a></div>`).join('');
+ if(VIEW==='kv')return `<div class="kvc" data-n="${esc(x.name)}"><div class="kvr"><span class="k">Source</span><span class="v">${esc(x.name)}</span></div><div class="kvr"><span class="k">Method</span><span class="v">${x.method}</span></div><div class="kvr"><span class="k">Market</span><span class="v">${esc(x.region)}</span></div><div class="kvr num" data-modal="pulled" style="cursor:pointer"><span class="k">Items pulled</span><span class="v" style="color:var(--blue)">${x.pulled}</span></div><div class="kvr num" data-modal="deals" style="cursor:pointer"><span class="k">Deals kept</span><span class="v" style="color:#0E7C4A">${x.deals||0}</span></div><div class="kvr"><span class="k">Latest</span><span class="v">${ld(x)}</span></div></div>`;
+ if(VIEW==='list')return `<div class="slist" data-n="${esc(x.name)}"><b style="min-width:150px">${esc(x.name)}</b><span class="badge ${MB[x.method]}">${x.method}</span><span style="color:#6B7280">${esc(x.region)}</span><span class="num" data-modal="pulled" style="cursor:pointer">pulled <b style="color:var(--blue)">${x.pulled}</b></span><span class="num" data-modal="deals" style="cursor:pointer">kept <b style="color:#0E7C4A">${x.deals||0}</b></span><span style="color:#8A5A00">${ld(x)}</span><span style="flex:1;min-width:180px;color:#555;overflow:hidden">${samp}</span></div>`;
+ return `<div class="scard" data-n="${esc(x.name)}"><div class="sn">${esc(x.name)}</div><div class="smeta"><span class="badge ${MB[x.method]}">${x.method}</span> · ${esc(x.region)} · ${ld(x)}</div><div class="snums"><span class="num" data-modal="pulled">pulled <b>${x.pulled}</b></span><span class="num" data-modal="deals">kept <b class="g">${x.deals||0}</b></span></div><div class="ss">${samp||'<span style="color:#c3c9d6">—</span>'}</div></div>`;
+}
+function renderSrc(){ if(!SRC)return; const f=feedsFiltered();
+ const tbl=document.getElementById('srctable'),cards=document.getElementById('srccards');
+ if(VIEW==='table'){ tbl.style.display=''; cards.style.display='none';
+  const max=Math.max(1,...SRC.feeds.map(x=>x.pulled)); const ck=cols();
+  document.getElementById('tb').innerHTML=f.map(x=>{ const cells=ck.map(k=>cellHTML(x,k,max)).join('');
+   return `<tr class="srow${EXP.has(x.name)?' exp':''}" data-n="${esc(x.name)}"><td class="xc"><span class="car">${EXP.has(x.name)?'▾':'▸'}</span></td>${cells}</tr>`+(EXP.has(x.name)?detailRow(x,ck.length+1):''); }).join('');
+ } else { tbl.style.display='none'; cards.style.display=''; cards.className=VIEW; cards.innerHTML=f.map(cardHTML).join(''); }
  const fil=(F.method||F.region)?` · filtered ${F.method||''} ${F.region||''}`:'';
- document.getElementById('sstat').textContent=`${f.length} of ${SRC.feeds.length} sources${fil}`;
+ document.getElementById('sstat').textContent=`${f.length} of ${SRC.feeds.length} sources${fil} · ${VIEW}`;
 }
-// click: a number cell -> modal of its items; anywhere else on the row -> expand/collapse
+// TABLE click: number cell -> modal; else expand/collapse row
 document.getElementById('tb').addEventListener('click',e=>{ if(e.target.closest('a'))return;
  const row=e.target.closest('tr.srow'); if(!row)return; const n=row.dataset.n; const x=SRC.feeds.find(s=>s.name===n); if(!x)return;
  const num=e.target.closest('td.num');
@@ -200,6 +228,17 @@ document.getElementById('tb').addEventListener('click',e=>{ if(e.target.closest(
    else openModal(n+' — deals kept',(x.samples||[]).length,x.samples||[],'deal'); return; }
  EXP.has(n)?EXP.delete(n):EXP.add(n); renderSrc();
 });
+// CARD click: number -> modal
+document.getElementById('srccards').addEventListener('click',e=>{ if(e.target.closest('a'))return;
+ const num=e.target.closest('.num'); const host=e.target.closest('[data-n]'); if(!num||!host)return; const x=SRC.feeds.find(s=>s.name===host.dataset.n); if(!x)return;
+ if(num.dataset.modal==='pulled')openModal(x.name+' — items pulled',(x.pulled_list||[]).length,x.pulled_list||[],'deal');
+ else openModal(x.name+' — deals kept',(x.samples||[]).length,x.samples||[],'deal'); });
+// VIEW switch + DENSITY
+document.querySelectorAll('.vsw button').forEach(b=>b.onclick=()=>{VIEW=b.dataset.v;localStorage.srcView=VIEW;document.querySelectorAll('.vsw button').forEach(z=>z.classList.toggle('on',z.dataset.v===VIEW));renderSrc();});
+function setDens(){document.documentElement.style.setProperty('--cols',document.getElementById('dens').value);localStorage.srcDens=document.getElementById('dens').value;}
+document.getElementById('dens').oninput=setDens;
+document.querySelectorAll('.vsw button').forEach(z=>z.classList.toggle('on',z.dataset.v===VIEW));
+if(localStorage.srcDens)document.getElementById('dens').value=localStorage.srcDens; setDens();
 document.getElementById('q').oninput=renderSrc;
 // ---- charts (Chart.js 2D canvas — robust, destroy-before-recreate = no leak) ----
 const charts={};

← 56f09c5 sources dashboard: FULL NAV AGENT (Steve ASAP) — draggable/m  ·  back to Re Flyer Aggregator  ·  property flyers: commit the daily cron wrapper (crawl + rsyn 68ac0d4 →