← back to La Socrata Ingester
Viewer: saved views — named snapshots of the full view state
bcf4edb9cfd0f468bb7eab94ca4099564fe5164b · 2026-08-12 11:46:39 -0700 · Steve Abrams
- 📑 Views popover: name + save the current filters/sort/view/color/columns/density
as a preset, apply any saved view in one click, delete with ✕. Persists to
localStorage. Since syncURL() already serializes the whole view state to the
querystring, a saved view is just a named snapshot of that query.
- Refactored boot into reusable hydrateFromQuery(strict) + syncControls(), shared
by page load (persisted-pref fallback) and saved-view apply (exact snapshot).
Author: Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M viewer/public/index.html
Diff
commit bcf4edb9cfd0f468bb7eab94ca4099564fe5164b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 11:46:39 2026 -0700
Viewer: saved views — named snapshots of the full view state
- 📑 Views popover: name + save the current filters/sort/view/color/columns/density
as a preset, apply any saved view in one click, delete with ✕. Persists to
localStorage. Since syncURL() already serializes the whole view state to the
querystring, a saved view is just a named snapshot of that query.
- Refactored boot into reusable hydrateFromQuery(strict) + syncControls(), shared
by page load (persisted-pref fallback) and saved-view apply (exact snapshot).
Author: Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
viewer/public/index.html | 67 ++++++++++++++++++++++++++++++++++++++----------
1 file changed, 53 insertions(+), 14 deletions(-)
diff --git a/viewer/public/index.html b/viewer/public/index.html
index 94bffa4..f34b1c2 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -125,6 +125,12 @@
.colspop label:hover{background:#f0f3f5}
kbd{font:11px/1 ui-monospace,Menlo,monospace;background:#eef1f4;border:1px solid var(--line);border-bottom-width:2px;
border-radius:4px;padding:1px 5px;color:#495057}
+ #savedpop{min-width:236px}
+ .colspop .savedrow{display:flex;justify-content:space-between;align-items:center}
+ .colspop .savedrow a.savedapply{flex:1;padding:5px 8px;cursor:pointer;border-radius:6px;color:var(--ink);text-decoration:none;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .colspop .savedrow a.savedapply:hover{background:#f0f3f5}
+ .colspop .savedrow a.saveddel{padding:5px 8px;color:var(--muted);cursor:pointer;text-decoration:none}
+ .colspop .savedrow a.saveddel:hover{color:#dc2626}
</style>
</head>
<body>
@@ -146,6 +152,7 @@
<option value="work_type">Work type</option>
</select></span>
<span class="ctl" id="densctl">Density <input id="density" type="range" min="26" max="46" value="34"></span>
+ <span class="ctl" id="savedwrap" style="position:relative"><button id="savedbtn" class="loadmore" style="margin:0;padding:5px 12px" title="Save the current filters/sort/layout as a named view, or apply a saved one">📑 Views</button><div id="savedpop" class="colspop hidden"></div></span>
<span class="ctl" id="colswrap" style="position:relative"><button id="colsbtn" class="loadmore" style="margin:0;padding:5px 12px" title="Show / hide table columns">▦ Columns</button><div id="colspop" class="colspop hidden"></div></span>
<span class="ctl"><button id="csv" class="loadmore" style="margin:0;padding:5px 12px" title="Download the current filtered + sorted results as a CSV (up to 5,000 rows)">⬇ CSV</button></span>
<span class="ctl"><button id="copylink" class="loadmore" style="margin:0;padding:5px 12px" title="Copy a shareable link to this exact view (filters + sort + view + color)">🔗 Copy link</button></span>
@@ -506,27 +513,59 @@ document.addEventListener('click',e=>{ if(!e.target.closest('#colswrap')) $('#co
document.addEventListener('keydown',e=>{
const tag=(e.target.tagName||'').toLowerCase(), typing=tag==='input'||tag==='select'||tag==='textarea';
if(e.key==='/' && !typing){ e.preventDefault(); $('#q').focus(); $('#q').select(); }
- else if(e.key==='Escape'){ $('#mbg').style.display=$('#modal').style.display='none'; $('#colspop').classList.add('hidden'); if(e.target.id==='q') e.target.blur(); }
+ else if(e.key==='Escape'){ $('#mbg').style.display=$('#modal').style.display='none'; $('#colspop').classList.add('hidden'); $('#savedpop').classList.add('hidden'); if(e.target.id==='q') e.target.blur(); }
});
function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
function escapeAttr(s){return escapeHtml(s).replace(/'/g,''')}
-(function(){
- const u=new URLSearchParams(location.search); // shareable deep-links: filters + sort + view + color
- if(u.get('view')) state.view=u.get('view');
- if(u.get('color')) state.colorBy=u.get('color');
+// ---- hydrate the whole view-state from a query string (shared by boot + saved views) ----
+// strict=true (saved views): absent params reset to defaults, so a snapshot restores exactly.
+// strict=false (boot): absent density/columns fall back to the user's persisted localStorage prefs.
+function hydrateFromQuery(qstr, strict){
+ const u=new URLSearchParams(qstr);
+ state.filters={};
['q','era','work_type','building_type','status','cd','zip','min_value','since'].forEach(k=>{ if(u.get(k)) state.filters[k]=u.get(k); });
- if(u.get('sort')) state.sort={col:u.get('sort'), dir:u.get('dir')==='asc'?'asc':'desc'};
- if(u.get('cols')){ // deep-linked visible column set + order
- const vis=u.get('cols').split(',').filter(k=>colDef(k));
- if(vis.length){ const rest=ALLCOLS.map(c=>c.key).filter(k=>!vis.includes(k)); colOrder=vis.concat(rest); colHidden=new Set(rest); }
- }
- if(state.filters.q) $('#q').value=state.filters.q;
- const d=u.get('d')||LS.get('density','34'); document.documentElement.style.setProperty('--rowh',d+'px');
- document.documentElement.style.setProperty('--cols',Math.max(2,Math.round((72-d)/8))); $('#density').value=d;
- $('#colorby').value=state.colorBy;
+ state.view=u.get('view')||'list';
+ state.colorBy=u.get('color')||'lead_score';
+ state.sort=u.get('sort')?{col:u.get('sort'), dir:u.get('dir')==='asc'?'asc':'desc'}:{col:'lead_score',dir:'desc'};
+ if(u.get('cols')){ const vis=u.get('cols').split(',').filter(k=>colDef(k));
+ if(vis.length){ const rest=ALLCOLS.map(c=>c.key).filter(k=>!vis.includes(k)); colOrder=vis.concat(rest); colHidden=new Set(rest); } }
+ else if(strict){ colOrder=ALLCOLS.map(c=>c.key); colHidden=new Set(); }
+ state._density = u.get('d') || (strict ? '34' : LS.get('density','34'));
+}
+function syncControls(){ // push state back into the visible UI controls
+ $('#q').value = state.filters.q || '';
+ const d = state._density || '34';
+ document.documentElement.style.setProperty('--rowh', d+'px');
+ document.documentElement.style.setProperty('--cols', Math.max(2,Math.round((72-d)/8)));
+ $('#density').value = d;
+ $('#colorby').value = state.colorBy;
$('#views').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x.dataset.v===state.view));
+}
+
+// ---- saved views: named snapshots of the full querystring ----
+const getSaved = () => LS.get('saved', []);
+function applySavedView(q){ hydrateFromQuery('?'+q, true); syncControls(); state.page=1; loadFacets(); loadRows(false); }
+function renderSavedMenu(){
+ const pop=$('#savedpop'), list=getSaved();
+ pop.innerHTML = `<div class="hd"><span>SAVED VIEWS</span></div>`+
+ `<div style="padding:2px 8px 8px;display:flex;gap:6px"><input id="savedname" placeholder="Name this view…" style="flex:1;min-width:0;padding:5px 8px"><button id="savedadd" class="loadmore" style="margin:0;padding:5px 11px">Save</button></div>`+
+ (list.length ? list.map((v,i)=>`<div class="savedrow"><a class="savedapply" data-i="${i}" title="${escapeAttr(v.q)}">${escapeHtml(v.name)}</a><a class="saveddel" data-i="${i}" title="Delete">✕</a></div>`).join('')
+ : `<div style="padding:6px 8px;color:var(--muted);font-size:12px">No saved views yet — filter, then Save.</div>`);
+ const save=()=>{ const n=$('#savedname').value.trim(); if(!n) return; syncURL(); const q=location.search.replace(/^\?/,''); const arr=getSaved(); arr.push({name:n,q}); LS.set('saved',arr); renderSavedMenu(); };
+ $('#savedadd').onclick=save;
+ $('#savedname').onkeydown=e=>{ if(e.key==='Enter') save(); };
+ pop.querySelectorAll('.savedapply').forEach(a=>a.onclick=()=>{ const v=getSaved()[+a.dataset.i]; if(v){ applySavedView(v.q); pop.classList.add('hidden'); } });
+ pop.querySelectorAll('.saveddel').forEach(a=>a.onclick=()=>{ const arr=getSaved(); arr.splice(+a.dataset.i,1); LS.set('saved',arr); renderSavedMenu(); });
+}
+$('#savedbtn').onclick=e=>{ e.stopPropagation(); const pop=$('#savedpop');
+ if(pop.classList.contains('hidden')){ renderSavedMenu(); pop.classList.remove('hidden'); } else pop.classList.add('hidden'); };
+document.addEventListener('click',e=>{ if(!e.target.closest('#savedwrap')) $('#savedpop').classList.add('hidden'); });
+
+(function(){
+ hydrateFromQuery(location.search, false); // boot: deep-link params win, else persisted prefs
+ syncControls();
loadFacets(); loadRows(false);
})();
</script>
← d7a05ae Viewer: column show/hide menu, / search shortcut, density+co
·
back to La Socrata Ingester
·
Viewer: per-column filter inputs in the List header 48e81f3 →