[object Object]

← back to Rentv

directory.html: add Sort select + Grid/List/Table views + click-to-sort table headers over the usre firm/broker registry (server-side sort via proxy ?sort=&order=; Load-more pagination + density preserved)

0169120de8b1e981850ff846b88bfd8124c3554b · 2026-08-13 08:26:45 -0700 · steve

Files touched

Diff

commit 0169120de8b1e981850ff846b88bfd8124c3554b
Author: steve <steve@designerwallcoverings.com>
Date:   Thu Aug 13 08:26:45 2026 -0700

    directory.html: add Sort select + Grid/List/Table views + click-to-sort table headers over the usre firm/broker registry (server-side sort via proxy ?sort=&order=; Load-more pagination + density preserved)
---
 public/directory.html | 105 ++++++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 94 insertions(+), 11 deletions(-)

diff --git a/public/directory.html b/public/directory.html
index 290d2442..5de8d61a 100644
--- a/public/directory.html
+++ b/public/directory.html
@@ -57,6 +57,24 @@
   .row .lic{color:var(--sub);font-size:11px;margin-top:3px;font-variant-numeric:tabular-nums}
   .row .st{display:inline-block;font-size:10px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;padding:2px 8px;border-radius:20px;background:var(--wash);color:#4a4f55}
   .row .st.ok{background:rgba(15,138,77,.12);color:var(--up)}
+  /* sort select + view toggle (TK-10526) */
+  .ctlbar select{font:700 12px 'Inter';padding:7px 10px;border:1.5px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);cursor:pointer}
+  .ctlbar select:focus{border-color:var(--red);outline:0}
+  .seg{display:inline-flex;border:1.5px solid var(--line);border-radius:9px;overflow:hidden}
+  .seg button{border:0;background:#fff;font:800 11px 'Inter';letter-spacing:.04em;color:var(--sub);padding:7px 11px;cursor:pointer}
+  .seg button.on{background:var(--red);color:#fff}
+  /* compact LIST view — one dense scannable row */
+  .rows.view-list{grid-template-columns:1fr!important;gap:6px}
+  .rows.view-list .row{grid-template-columns:1fr auto;padding:9px 13px}
+  .rows.view-list .row .nm{font-size:14px}
+  /* dense TABLE view — all columns, click-to-sort headers */
+  .rows.view-table{display:block}
+  .tblwrap{overflow-x:auto;border:1px solid var(--line);border-radius:11px;background:#fff;margin:6px 0 8px}
+  table.dtbl{width:100%;border-collapse:collapse;font-size:12.5px}
+  table.dtbl th{position:sticky;top:0;background:var(--wash);text-align:left;font:800 11px 'Inter';letter-spacing:.04em;text-transform:uppercase;color:#4a4f55;padding:9px 12px;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap}
+  table.dtbl th:hover{color:var(--red)}
+  table.dtbl td{padding:8px 12px;border-bottom:1px solid var(--wash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px}
+  table.dtbl tr:hover td{background:#fafbfc}
   .more{display:block;width:100%;margin:14px 0 60px;padding:13px;border:1.5px solid var(--line);border-radius:10px;background:#fff;font:800 13px 'Inter';color:var(--ink);cursor:pointer}
   .more:hover{border-color:var(--red);color:var(--red)}
   .more[disabled]{opacity:.5;cursor:default}
@@ -105,11 +123,18 @@
     <input id="q" type="search" autocomplete="off" placeholder="Search firms by name or city…">
   </div>
   <div class="meta" id="meta"></div>
-  <!-- Density slider per Steve's "every grid gets sort + density" rule. Sort is intentionally
-       omitted here: /api/firms + /api/brokers proxy to the usre registry (34k firms / 382k brokers,
-       server-paginated) which ignores a ?sort param — a client-side sort would only reorder the
-       loaded page and mislead. A real Sort control needs usre to support server-side ordering. -->
+  <!-- Sort + 3 views + density (TK-10526). The rentv proxy now sorts the usre page
+       server-side (?sort=&order=, strict whitelist) so Sort is real registry-order, not
+       a misleading page reorder. Grid / List / Table views + click-to-sort table headers. -->
   <div class="ctlbar">
+    <label class="ctl">Sort
+      <select id="sort" aria-label="Sort directory"></select>
+    </label>
+    <span class="seg" id="viewSeg" role="group" aria-label="View mode">
+      <button data-view="grid" class="on">▦ Grid</button>
+      <button data-view="list">☰ List</button>
+      <button data-view="table">▤ Table</button>
+    </span>
     <label class="ctl">Density <input id="dens" type="range" min="2" max="4" step="1" value="2" aria-label="Directory grid density (columns)"></label>
   </div>
   <section class="rows" id="rows"></section>
@@ -126,37 +151,92 @@ const close=()=>{drawer.classList.remove('open');scrim.classList.remove('open')}
 $('dx').onclick=close;scrim.onclick=close;document.addEventListener('keydown',e=>{if(e.key==='Escape')close()});
 
 const PAGE=25;
-let tab='firms', q='', offset=0, total=0, seq=0;
+let tab='firms', q='', offset=0, total=0, seq=0, allRows=[];
 let dens=String(Math.max(2,Math.min(4,parseInt(localStorage.getItem('rentv.directory.dens'),10)||2)));
+let view=(['grid','list','table'].includes(localStorage.getItem('rentv.directory.view'))?localStorage.getItem('rentv.directory.view'):'grid');
+// sort state: {k,dir} per tab (dir 'asc'|'desc'). null = registry default order.
+let sort=null;
 function applyDens(){ $('rows').style.setProperty('--cols',dens); }
 
+// Per-tab sort options + table columns (every displayed field). Keys map to the
+// backend ?sort= whitelist so sorting is real, server-side registry order.
+const FIELDS={
+  firms:[
+    {k:'name',l:'Firm'},{k:'city',l:'City'},{k:'state',l:'State'},
+    {k:'agent_count',l:'Agents',num:true},{k:'license_no',l:'DRE #'},
+    {k:'phone',l:'Phone'},{k:'email',l:'Email'},{k:'website',l:'Website'},
+  ],
+  brokers:[
+    {k:'name',l:'Broker'},{k:'firm_name',l:'Firm'},{k:'city',l:'City'},
+    {k:'state_code',l:'State'},{k:'license_status',l:'Status'},
+    {k:'license_type',l:'Type'},{k:'license_no',l:'DRE #'},
+    {k:'broker_phone',l:'Phone'},{k:'broker_email',l:'Email'},{k:'broker_website',l:'Website'},
+  ],
+};
+
 const p=new URLSearchParams(location.search);
 if(p.get('tab')==='brokers')tab='brokers';
 if(p.get('q'))q=p.get('q');
 $('q').value=q;
 
-function setTab(t){ tab=t; offset=0; $('tabFirms').classList.toggle('on',t==='firms'); $('tabBrokers').classList.toggle('on',t==='brokers'); $('q').placeholder=t==='firms'?'Search firms by name or city…':'Search brokers by name, city or firm…'; sync(); load(true); }
+function buildSortOpts(){
+  const fl=FIELDS[tab];
+  $('sort').innerHTML='<option value="">Sort: registry order</option>'+
+    fl.map(f=>`<option value="${f.k}:asc">${esc(f.l)} ${f.num?'↑':'A→Z'}</option><option value="${f.k}:desc">${esc(f.l)} ${f.num?'↓':'Z→A'}</option>`).join('');
+  $('sort').value=sort?sort.k+':'+sort.dir:'';
+}
+
+function setTab(t){ tab=t; offset=0; sort=null; $('tabFirms').classList.toggle('on',t==='firms'); $('tabBrokers').classList.toggle('on',t==='brokers'); $('q').placeholder=t==='firms'?'Search firms by name or city…':'Search brokers by name, city or firm…'; buildSortOpts(); sync(); load(true); }
 $('tabFirms').onclick=()=>setTab('firms'); $('tabBrokers').onclick=()=>setTab('brokers');
 
 let timer=null;
 $('q').addEventListener('input',e=>{q=e.target.value.trim();clearTimeout(timer);timer=setTimeout(()=>{offset=0;sync();load(true);},220);});
 
-function sync(){ const u=new URLSearchParams(); u.set('tab',tab); if(q)u.set('q',q); history.replaceState(null,'','/directory?'+u.toString()); }
+$('sort').addEventListener('change',e=>{ const v=e.target.value; if(!v){sort=null;}else{const[k,d]=v.split(':');sort={k,dir:d};} offset=0; sync(); load(true); });
+
+// view toggle
+$('viewSeg').addEventListener('click',e=>{ const b=e.target.closest('button[data-view]'); if(!b)return; view=b.dataset.view; localStorage.setItem('rentv.directory.view',view); [...$('viewSeg').children].forEach(x=>x.classList.toggle('on',x.dataset.view===view)); render(); });
+
+function sync(){ const u=new URLSearchParams(); u.set('tab',tab); if(q)u.set('q',q); if(sort)u.set('sort',sort.k+':'+sort.dir); history.replaceState(null,'','/directory?'+u.toString()); }
 
 function firmRow(f){ return `<div class="row"><div><div class="nm">${esc(cap(f.name))}</div><div class="sub">${esc(cap(f.hq_city||f.city||''))}${f.hq_state?', '+esc(f.hq_state):''}</div></div><div class="r">${f.agent_count?`<span class="badge">${(+f.agent_count).toLocaleString()} agents</span>`:''}<div class="lic">DRE #${esc(f.license_no||'—')}</div></div></div>`; }
 function brokerRow(b){ const ok=/active/i.test(b.license_status||''); return `<div class="row"><div><div class="nm">${esc(cap(b.name))}</div><div class="sub">${[cap(b.firm_name||''),cap(b.city||''),b.state_code].filter(Boolean).map(esc).join(' · ')}</div></div><div class="r"><span class="st${ok?' ok':''}">${esc(b.license_status||b.license_type||'Lic')}</span><div class="lic">DRE #${esc(b.license_no||'—')}</div></div></div>`; }
 
+// dense TABLE view — every field a column, click header to sort (server-side).
+function tableView(rows){
+  const fl=FIELDS[tab];
+  const arrow=k=>(sort&&sort.k===k)?(sort.dir==='desc'?' ▼':' ▲'):'';
+  const head=fl.map(f=>`<th data-k="${f.k}">${esc(f.l)}${arrow(f.k)}</th>`).join('');
+  const cell=(r,f)=>{ let v=r[f.k]; if(v==null||v==='')return '—'; if(f.num)return (+v).toLocaleString(); if(/name|firm|city/.test(f.k))v=cap(v); return esc(v); };
+  const body=rows.map(r=>`<tr>${fl.map(f=>`<td>${cell(r,f)}</td>`).join('')}</tr>`).join('');
+  return `<div class="tblwrap"><table class="dtbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
+}
+
+// re-render the loaded rows into the current view (no refetch)
+function render(){
+  const rows=$('rows');
+  rows.classList.remove('view-grid','view-list','view-table');
+  rows.classList.add('view-'+view);
+  if(!allRows.length){ rows.innerHTML='<div class="empty"><h3>No '+tab+' found</h3><div>Try a different name or city.</div></div>'; return; }
+  if(view==='table'){ rows.innerHTML=tableView(allRows); }
+  else { rows.innerHTML=allRows.map(tab==='firms'?firmRow:brokerRow).join(''); }
+}
+
+// click-to-sort table headers (delegated; server-side sort)
+$('rows').addEventListener('click',e=>{ const th=e.target.closest('th[data-k]'); if(!th)return; const k=th.dataset.k; sort=(sort&&sort.k===k)?{k,dir:sort.dir==='desc'?'asc':'desc'}:{k,dir:'asc'}; $('sort').value=sort.k+':'+sort.dir; offset=0; sync(); load(true); });
+
 async function load(reset){
   const my=++seq;
-  if(reset){$('rows').innerHTML='';$('meta').innerHTML='<span class="spin"></span>Searching…';$('more').style.display='none';}
+  if(reset){$('rows').innerHTML='';$('meta').innerHTML='<span class="spin"></span>Searching…';$('more').style.display='none';allRows=[];}
   const u=new URLSearchParams(); if(q)u.set('q',q); u.set('limit',PAGE); u.set('offset',offset);
+  if(sort){ u.set('sort',sort.k); u.set('order',sort.dir); }
   let d; try{ d=await (await fetch('/api/'+tab+'?'+u.toString())).json(); }catch(e){ if(my===seq){$('meta').textContent='Directory service unavailable — try again.';} return; }
   if(my!==seq) return;
   total=d.total||0;
   const rows=d.rows||[];
-  const html=rows.map(tab==='firms'?firmRow:brokerRow).join('');
-  if(reset)$('rows').innerHTML=html; else $('rows').insertAdjacentHTML('beforeend',html);
-  if(!total){ $('meta').textContent=''; $('rows').innerHTML='<div class="empty"><h3>No '+tab+' found</h3><div>Try a different name or city.</div></div>'; return; }
+  allRows=reset?rows:allRows.concat(rows);
+  render();
+  if(!total){ $('meta').textContent=''; return; }
   $('meta').innerHTML=`${total.toLocaleString()} ${tab}${q?` matching “${esc(q)}”`:''} · showing ${Math.min(offset+rows.length,total).toLocaleString()}`;
   offset+=rows.length;
   $('more').style.display=offset<total?'block':'none';
@@ -165,6 +245,9 @@ $('more').onclick=()=>{ $('more').disabled=true; $('more').textContent='Loading
 
 $('tabFirms').classList.toggle('on',tab==='firms'); $('tabBrokers').classList.toggle('on',tab==='brokers');
 $('q').placeholder=tab==='firms'?'Search firms by name or city…':'Search brokers by name, city or firm…';
+if(p.get('sort')){ const[k,dr]=p.get('sort').split(':'); if(k&&FIELDS[tab].some(f=>f.k===k)) sort={k,dir:dr==='desc'?'desc':'asc'}; }
+buildSortOpts();
+[...$('viewSeg').children].forEach(x=>x.classList.toggle('on',x.dataset.view===view));
 $('dens').value=dens; applyDens();
 $('dens').oninput=e=>{dens=e.target.value;localStorage.setItem('rentv.directory.dens',dens);applyDens();};
 load(true);

← de764576 RENTV APIs: add injection-safe ?sort=&order= to /api/deals,  ·  back to Rentv  ·  desk.html: add Grid/List/Table views + Sort select + click-t 1a02bbf9 →