← back to Commercialrealestate
condos.html: add compact-list view (3rd mode) + shared three-view helper — grid/list/table + all-col sort, facets/search/notes/modal/drill preserved
badc059553c109c4e2e43b91c1e71e3c775e381c · 2026-08-13 08:14:05 -0700 · steve
Files touched
M public/condos.htmlA public/three-view.cssA public/three-view.js
Diff
commit badc059553c109c4e2e43b91c1e71e3c775e381c
Author: steve <steve@designerwallcoverings.com>
Date: Thu Aug 13 08:14:05 2026 -0700
condos.html: add compact-list view (3rd mode) + shared three-view helper — grid/list/table + all-col sort, facets/search/notes/modal/drill preserved
---
public/condos.html | 47 ++++++++++++---
public/three-view.css | 37 ++++++++++++
public/three-view.js | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 239 insertions(+), 8 deletions(-)
diff --git a/public/condos.html b/public/condos.html
index beb8fa5..1b7326f 100644
--- a/public/condos.html
+++ b/public/condos.html
@@ -132,6 +132,8 @@
#modal .litem a.dl,#modal .maddr a.dl{color:var(--blue);text-decoration:none;} #modal .litem a.dl:hover,#modal .maddr a.dl:hover{text-decoration:underline;}
#modal .src{font-size:12px;color:var(--mut);margin-top:12px;border-top:1px solid var(--line);padding-top:10px;}
</style>
+ <link rel="stylesheet" href="/three-view.css">
+ <script src="/three-view.js"></script>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
@@ -182,7 +184,7 @@
<main>
<div class="topbar">
<input type="text" id="q" placeholder="search all fields — project, city, zip, address, broker, firm, status… (space = AND)">
- <span class="viewseg" id="viewseg"><button data-view="grid" class="active">▦ Grid</button><button data-view="list">☰ List</button></span>
+ <span class="viewseg tv-seg" id="viewseg"></span>
<button id="shortbtn" title="Show only your shortlisted (starred) listings — shared with the Explorer & MLS">⭐ Shortlist (0)</button>
<button id="exportbtn" title="Download the current filtered + sorted set as a CSV">⬇ Export CSV</button>
<button id="draftbtn" title="Queue DRAFT outreach emails for the current FHA-matched set in the Solicitation Studio — nothing sends">🎯 Draft outreach</button>
@@ -201,7 +203,7 @@
<span class="count" id="count">…</span>
</div>
<div class="statsbar" id="statsbar"></div>
- <div class="gridwrap"><div class="grid" id="grid"><div class="empty">loading…</div></div></div>
+ <div class="gridwrap"><div class="grid" id="grid"><div class="empty">loading…</div></div><div id="condoList"></div></div>
</main>
</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>
@@ -212,7 +214,11 @@ const $$=s=>Array.from(document.querySelectorAll(s));
const esc=s=>String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
const PROXY='FHA/VA-approval-based proxy, NOT lender-verified Fannie/Freddie warrantability.';
const NOW_YEAR=new Date().getFullYear();
-let DATA=[], CURRENT=[], MODE='db', VIEW=localStorage.getItem('condoView')||'grid', bannerDefault='';
+// v2 3-view migration (TK-10526): the old bespoke 'list' WAS the dense spreadsheet.
+// The new model is grid(cards) / list(compact) / table(dense). Re-point a stored
+// legacy 'list' → 'table' exactly once so returning users keep the dense view they had.
+(function(){ try{ if(localStorage.getItem('condoView')==='list' && !localStorage.getItem('condoView3')){ localStorage.setItem('condoView','table'); } localStorage.setItem('condoView3','1'); }catch(e){} })();
+let DATA=[], CURRENT=[], MODE='db', VIEW=localStorage.getItem('condoView')||'grid', bannerDefault='', TV=null;
let SORTKEY=localStorage.getItem('condoSort')||''; // hoisted so early apply() during boot never hits TDZ
function statusBadge(s){
@@ -507,9 +513,14 @@ function dbList(rows){
+cols.map(f=>`<td>${fcell(c,f)}</td>`).join('')+'</tr>').join('');
return `<div class="listwrap"><table class="listtbl" data-no-sort><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
-function paint(rows){ const g=$('#grid');
+// paint owns the GRID (cards) + TABLE (dense spreadsheet) views; the COMPACT LIST
+// view is owned by ThreeView into #condoList. The active container is shown/hidden
+// by ThreeView.render(); paint just fills #grid for grid/table.
+function paint(rows){ const g=$('#grid'), lst=$('#condoList');
+ if(VIEW==='list'){ if(TV) TV.render(); if(g) g.style.display='none'; if(lst) lst.style.display=''; return; }
+ if(lst) lst.style.display='none'; if(g) g.style.display='';
if(!rows.length){ g.className='grid'; g.innerHTML='<div class="empty">no condos match these filters.</div>'; lastColSig=''; return; }
- if(VIEW==='list'){
+ if(VIEW==='table'){
g.className='grid listhost'; g.innerHTML=dbList(rows);
// hand the freshly-rendered table to col-resize.js when the visible columns/order change
const csig=orderedFields().filter(f=>f.col&&fvis(f.k)).map(f=>f.k).join(',');
@@ -521,7 +532,7 @@ function paint(rows){ const g=$('#grid');
// applies a click-header sort on the raw field value, any type. Runs on top of the
// top-bar sort select so a header click always wins in List view.
function listSort(rows){
- if(VIEW!=='list' || !LSORTKEY) return rows;
+ if(VIEW!=='table' || !LSORTKEY) return rows;
const f=FIELDS.find(x=>x.k===LSORTKEY); if(!f) return rows;
return rows.slice().sort((a,b)=>{
let x=fval(a,f), y=fval(b,f);
@@ -700,8 +711,26 @@ function sortRows(rows){
(function(){ const s=$('#csort'); if(s){ s.value=SORTKEY; s.addEventListener('change',()=>{ SORTKEY=s.value; localStorage.setItem('condoSort',SORTKEY); apply(); }); } })();
$('#cols').oninput=e=>{ document.documentElement.style.setProperty('--cols',e.target.value); localStorage.setItem('condoCols',e.target.value); };
const savedCols=localStorage.getItem('condoCols'); if(savedCols){ $('#cols').value=savedCols; document.documentElement.style.setProperty('--cols',savedCols); }
-$$('#viewseg button').forEach(b=>b.classList.toggle('active',b.dataset.view===VIEW));
-$('#viewseg').onclick=e=>{ const b=e.target.closest('button[data-view]'); if(!b) return; VIEW=b.dataset.view; localStorage.setItem('condoView',VIEW); $$('#viewseg button').forEach(x=>x.classList.toggle('active',x.dataset.view===VIEW)); apply(); };
+// ── 3-view toggle (grid cards / compact list / dense table) via the shared helper.
+// ThreeView owns the segmented buttons + the compact-list renderer into #condoList;
+// paint() still owns grid + table. All three modes stay in sync with the page's
+// filters (getRows→CURRENT) and column-visibility toggles (fvis). ──
+// compact-list cells reuse the page's rich fcell() (mailto / DRE-link / drill-span
+// HTML), so the List view matches the cards + table exactly (html:true tells the
+// helper the calc value is already-formatted HTML and passes it through unescaped).
+// ThreeView owns ONLY the compact-list container (#condoList) + the segmented
+// buttons; the page's paint() owns #grid (cards + dense table) show/hide + render.
+// onView flips VIEW then re-runs apply() so paint() re-renders grid/table on switch.
+TV=ThreeView.mount({
+ seg:'#viewseg', listMount:'#condoList',
+ storageKey:'condo', defaultView:VIEW, views:['grid','list','table'],
+ fields:FIELDS.map(f=>({k:f.k,l:f.l,badge:f.badge,html:true,calc:c=>fcell(c,f)})),
+ getRows:()=>CURRENT, fvis:fvis,
+ title:c=>({name:c.project_name||c.address||'Condo', sub:[c.address,c.city,c.zip].filter(Boolean).join(', ')}),
+ badgeHtml:c=>fvis('warrantable_status')?statusBadge(c.warrantable_status||c.warrant_signal):'',
+ onView:v=>{ VIEW=v; apply(); },
+ rowClick:c=>{ if(c && c.id!=null) openDetail(c.id); },
+});
// shortlist stars (unified) + toggle
$('#grid').addEventListener('click',e=>{ const st=e.target.closest('[data-star]'); if(st){ toggleStar(st.dataset.star); st.classList.toggle('on',SHORT.has(st.dataset.star)); updateShortBtn(); if(F.shortlist) apply(); } });
$('#shortbtn').addEventListener('click',()=>{ F.shortlist=!F.shortlist; apply(); });
@@ -762,6 +791,8 @@ function openDetail(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'); });
// row/card click opens detail (but not when the click is a star, header, or link)
+// drill-to-filter also works from the compact-list rows (their cells reuse fcell → drillSpan)
+$('#condoList').addEventListener('click',e=>{ const dr=e.target.closest('[data-drill]'); if(dr){ e.stopPropagation(); applyDrill(dr.dataset.drill,dr.dataset.val); } },true);
$('#grid').addEventListener('click',e=>{
const dr=e.target.closest('[data-drill]');
if(dr){ e.stopPropagation(); applyDrill(dr.dataset.drill,dr.dataset.val); return; } // datum drill → filter; the return (same listener) suppresses the modal, stopPropagation guards future sibling listeners
diff --git a/public/three-view.css b/public/three-view.css
new file mode 100644
index 0000000..090ce93
--- /dev/null
+++ b/public/three-view.css
@@ -0,0 +1,37 @@
+/* three-view.css — styles for the additive 3-view helper (TK-10526). Namespaced
+ * .tv-* so it never collides with a host page. Colors read the host page's own CSS
+ * vars with safe fallbacks, so the compact list inherits each page's theme
+ * (condos dark, fha-loans liquid-glass, broker/mls) automatically. */
+
+/* segmented Grid/List/Table toggle — reuses whatever wrapper the page gives; the
+ * buttons themselves are styled so pages that had only a 2-view toggle look right. */
+[data-tvseg] , .tv-seg { display:inline-flex; border:1px solid var(--tv-line,var(--line,var(--glassb,#2a313c))); border-radius:8px; overflow:hidden; }
+[data-tvseg] button, .tv-seg button {
+ background:var(--tv-card,var(--card,var(--glass,rgba(255,255,255,.05))));
+ color:var(--tv-mut,var(--mut,var(--muted,#8b949e)));
+ border:0; border-right:1px solid var(--tv-line,var(--line,var(--glassb,#2a313c)));
+ padding:7px 12px; font-size:12px; cursor:pointer; }
+[data-tvseg] button:last-child, .tv-seg button:last-child { border-right:0; }
+[data-tvseg] button.active, .tv-seg button.active { background:var(--tv-acc,var(--blue,#1f6feb)); color:#fff; }
+
+/* compact list — one scannable row per record, inline key/value pairs */
+.tv-rows { display:flex; flex-direction:column; }
+.tv-lrow {
+ display:flex; align-items:baseline; gap:14px; flex-wrap:wrap;
+ padding:10px 16px; border-bottom:1px solid var(--tv-line,var(--line,var(--glassb,rgba(255,255,255,.07))));
+ cursor:pointer; }
+.tv-lrow:hover { background:var(--tv-hover,var(--hover,rgba(120,160,255,.06))); }
+.tv-lmain { display:flex; align-items:baseline; gap:8px; flex-wrap:wrap; min-width:210px; flex:0 0 auto; }
+.tv-la { font-weight:600; color:var(--tv-ink,var(--ink,#e6edf3)); }
+.tv-la a { color:inherit; text-decoration:none; }
+.tv-la a:hover { text-decoration:underline; }
+.tv-lsub { font-size:11.5px; color:var(--tv-mut,var(--mut,var(--muted,#8b949e))); }
+.tv-lmeta { display:flex; flex-wrap:wrap; gap:6px 16px; flex:1 1 320px; min-width:0; }
+.tv-lm { font-size:12.5px; color:var(--tv-ink,var(--ink,#e6edf3)); white-space:nowrap; }
+.tv-lmk { color:var(--tv-mut,var(--mut,var(--muted,#8b949e))); font-size:10px; text-transform:uppercase; letter-spacing:.5px; margin-right:5px; }
+.tv-lm a { color:var(--tv-acc,var(--blue,#58a6ff)); text-decoration:none; }
+.tv-lm a:hover { text-decoration:underline; }
+.tv-empty { padding:50px; text-align:center; color:var(--tv-mut,var(--mut,var(--muted,#8b949e))); }
+
+/* density: the page's existing --cols / --tsz slider also tightens the list rows */
+@media (max-width:820px){ .tv-lmain{ min-width:100%; } }
diff --git a/public/three-view.js b/public/three-view.js
new file mode 100644
index 0000000..3c87c50
--- /dev/null
+++ b/public/three-view.js
@@ -0,0 +1,163 @@
+/* three-view.js — additive 3-view + all-column-sort helper for the CRCP bespoke
+ * browse pages (condos, broker-grid, mls, fha-loans). TK-10526, DTD verdict B
+ * (additive enhancement — DON'T rip-and-replace the bespoke pages' notes / modal /
+ * drill / find-email / outreach features, just ADD the missing view modes + a
+ * guaranteed all-field sort menu).
+ *
+ * A bespoke page already renders cards ("grid") and/or a dense spreadsheet ("table").
+ * What it usually lacks is (1) a distinct COMPACT LIST view — one scannable row per
+ * record, every visible field as inline key/value pairs — and (2) a single topbar
+ * Sort <select> covering EVERY field. This helper owns those two, plus the
+ * Grid / List / Table segmented toggle, and delegates grid/table rendering back to
+ * the page. Zero dependencies. Theme-agnostic (reads the page's own CSS with
+ * .tv-* fallbacks in three-view.css).
+ *
+ * ThreeView.mount({
+ * seg: '#viewseg', // container the segmented [Grid|List|Table] buttons render into
+ * sortSel: '#tvSort', // OPTIONAL <select> el/selector to populate with an all-field sort menu
+ * listMount:'#tvList', // container the compact-list DOM renders into (helper owns it)
+ * gridMount:'#gridView', // page's existing cards container (helper shows/hides)
+ * tableMount:'#tableView', // page's existing table container (helper shows/hides)
+ * fields: [{k,l,calc,money,num,pct,date,badge,html}], // field metadata; html:true -> value is raw HTML
+ * getRows: () => rows, // CURRENT filtered rows (in the order the page wants for grid/table)
+ * title: row => ({name,sub,href}), // compact-list row title + subline
+ * badgeHtml:row => '<span>…'|'', // optional status badge for the list row
+ * fvis: k => bool, // is field k currently visible (mirrors the page's column toggles)
+ * renderGrid: rows => {}, // page renders its cards into gridMount
+ * renderTable:rows => {}, // page renders its table into tableMount
+ * onView: view => {}, // OPTIONAL notified after a view switch
+ * onSort: (k,dir) => {}, // called when the all-field Sort select changes (dir 1|-1, k='' = default)
+ * rowClick: (row,el) => {}, // OPTIONAL compact-list row click handler
+ * storageKey:'condo', // localStorage namespace (View + Sort persisted)
+ * defaultView:'grid',
+ * views: ['grid','list','table'], // which segments to show (default all three)
+ * })
+ * -> returns { render(), setView(v), getView() }.
+ */
+(function (global) {
+ const $ = (s, r) => (r || document).querySelector(s);
+ const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
+
+ function fmt(v, f) {
+ if (f.html) return v == null ? '—' : String(v); // page-formatted HTML passes through
+ if (v == null || v === '') return '—';
+ if (f.money) return '$' + (+v).toLocaleString() + (f.suf || '');
+ if (f.pct) return (+v).toFixed(2) + '%';
+ if (f.num) return (+v).toLocaleString() + (f.suf || '');
+ if (f.date) { try { return new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) { return '—'; } }
+ return esc(v) + (f.suf || '');
+ }
+
+ function ThreeView(O) {
+ const KEY = O.storageKey || 'tv';
+ const VIEWS = O.views || ['grid', 'list', 'table'];
+ const LABELS = { grid: '▦ Grid', list: '☰ List', table: '▤ Table' };
+ const fval = (r, f) => f.calc ? f.calc(r) : r[f.k];
+ const fvis = O.fvis || (() => true);
+
+ let VIEW = (function () {
+ let s = null; try { s = localStorage.getItem(KEY + 'View'); } catch (e) {}
+ if (VIEWS.includes(s)) return s;
+ return VIEWS.includes(O.defaultView) ? O.defaultView : VIEWS[0];
+ })();
+ let SORT = null; // {k, dir}
+ try { const s = JSON.parse(localStorage.getItem(KEY + 'Sort') || 'null'); if (s && typeof s.k === 'string') SORT = s; } catch (e) {}
+
+ // ---- segmented view toggle ----
+ function buildSeg() {
+ const el = $(O.seg); if (!el) return;
+ el.innerHTML = VIEWS.map(v => `<button type="button" data-tvview="${v}" class="${VIEW === v ? 'active' : ''}">${LABELS[v] || v}</button>`).join('');
+ el.addEventListener('click', e => {
+ const b = e.target.closest('button[data-tvview]'); if (!b) return;
+ setView(b.dataset.tvview);
+ });
+ }
+
+ // ---- all-field sort <select> ----
+ function buildSort() {
+ const sel = $(O.sortSel); if (!sel) return;
+ const sortable = O.fields.filter(f => !f.badge && f.k !== 'title');
+ sel.innerHTML = `<option value="">Sort…</option>` + sortable.map(f =>
+ `<option value="${esc(f.k)}:desc">${esc(f.l)} ↓</option><option value="${esc(f.k)}:asc">${esc(f.l)} ↑</option>`).join('');
+ sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
+ sel.addEventListener('change', e => {
+ const v = e.target.value;
+ if (!v) SORT = null; else { const [k, d] = v.split(':'); SORT = { k, dir: d === 'desc' ? -1 : 1 }; }
+ try { localStorage.setItem(KEY + 'Sort', JSON.stringify(SORT)); } catch (e2) {}
+ if (O.onSort) O.onSort(SORT ? SORT.k : '', SORT ? SORT.dir : 1);
+ render();
+ });
+ }
+
+ // sort applied by the helper so the all-field Sort select works in EVERY view,
+ // including the helper-owned compact list. The page can still apply its own
+ // secondary sort (e.g. header-click in table view) on top via getRows().
+ function applySort(rows) {
+ if (!SORT || !SORT.k) return rows;
+ const f = O.fields.find(x => x.k === SORT.k); if (!f) return rows;
+ const dir = SORT.dir || 1;
+ return rows.slice().sort((a, b) => {
+ let x = fval(a, f), y = fval(b, f);
+ const na = x == null || x === '', nb = y == null || y === '';
+ if (na && nb) return 0; if (na) return 1; if (nb) return -1; // nulls last
+ const xn = !isNaN(+x) && x !== true, yn = !isNaN(+y) && y !== true;
+ if ((typeof x === 'number' || typeof y === 'number' || f.money || f.num || f.pct) || (xn && yn))
+ return ((+x) - (+y)) * dir;
+ return String(x).localeCompare(String(y)) * dir;
+ });
+ }
+
+ // ---- compact-list renderer (the missing 3rd mode) ----
+ function listCompact(rows) {
+ const mount = $(O.listMount); if (!mount) return;
+ const vis = O.fields.filter(f => fvis(f.k) && !f.badge && f.k !== 'title');
+ if (!rows.length) { mount.innerHTML = '<div class="tv-empty">No matches for these filters.</div>'; return; }
+ mount.innerHTML = `<div class="tv-rows">` + rows.map((r, i) => {
+ const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
+ const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer">${esc(t.name)}</a>` : esc(t.name);
+ return `<div class="tv-lrow" data-tvri="${i}">` +
+ `<div class="tv-lmain"><span class="tv-la">${nm}</span>${t.sub ? `<span class="tv-lsub">${esc(t.sub)}</span>` : ''}${O.badgeHtml ? (O.badgeHtml(r) || '') : ''}</div>` +
+ `<div class="tv-lmeta">` +
+ vis.map(f => `<span class="tv-lm"><span class="tv-lmk">${esc(f.l)}</span>${fmt(fval(r, f), f)}</span>`).join('') +
+ `</div></div>`;
+ }).join('') + `</div>`;
+ if (O.rowClick) {
+ mount.querySelectorAll('.tv-lrow').forEach(el => el.addEventListener('click', e => {
+ if (e.target.closest('a')) return;
+ O.rowClick(rows[+el.dataset.tvri], el);
+ }));
+ }
+ }
+
+ function showHide() {
+ const set = (sel, on) => { const el = sel && $(sel); if (el) el.style.display = on ? '' : 'none'; };
+ set(O.gridMount, VIEW === 'grid');
+ set(O.listMount, VIEW === 'list');
+ set(O.tableMount, VIEW === 'table');
+ }
+
+ function render() {
+ const raw = O.getRows ? (O.getRows() || []) : [];
+ const rows = applySort(raw);
+ showHide();
+ if (VIEW === 'grid' && O.renderGrid) O.renderGrid(rows);
+ else if (VIEW === 'table' && O.renderTable) O.renderTable(rows);
+ else if (VIEW === 'list') listCompact(rows);
+ // keep the seg buttons + sort select reflecting current state
+ const seg = $(O.seg); if (seg) seg.querySelectorAll('button[data-tvview]').forEach(b => b.classList.toggle('active', b.dataset.tvview === VIEW));
+ const sel = $(O.sortSel); if (sel) sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
+ }
+
+ function setView(v) {
+ if (!VIEWS.includes(v)) return;
+ VIEW = v; try { localStorage.setItem(KEY + 'View', v); } catch (e) {}
+ if (O.onView) O.onView(v);
+ render();
+ }
+
+ buildSeg(); buildSort();
+ return { render, setView, getView: () => VIEW, getSort: () => SORT, applySort };
+ }
+
+ global.ThreeView = { mount: O => new ThreeView(O) };
+})(window);
← 680d442 nav-agent: add universal grid-controls drop-in (field-toggle
·
back to Commercialrealestate
·
broker-grid.html: add compact-list view (3rd mode) — grid/li ca654ac →