← back to New Import Viewer
new-import-viewer: adopt href-to-deeper-data primitives (TK-10093)
c4b93a3448400d3c4267a9602de1a879e5731117 · 2026-08-01 20:20:43 -0700 · Steve Abrams
Files touched
A public/drill.jsM public/index.htmlM server.js
Diff
commit c4b93a3448400d3c4267a9602de1a879e5731117
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 1 20:20:43 2026 -0700
new-import-viewer: adopt href-to-deeper-data primitives (TK-10093)
---
public/drill.js | 36 +++++++++++++++++++++++++
public/index.html | 79 ++++++++++++++++++++++++++++++++++++++++++++++---------
server.js | 5 ++++
3 files changed, 108 insertions(+), 12 deletions(-)
diff --git a/public/drill.js b/public/drill.js
new file mode 100644
index 0000000..e63cdd3
--- /dev/null
+++ b/public/drill.js
@@ -0,0 +1,36 @@
+// ── href-to-deeper-data primitives — SINGLE SOURCE OF TRUTH (HARD rule, 2026-07-31) ──
+// Every displayed data point must be an href to its deeper (filtered) data. These are the
+// reusable atoms; a viewer's index.html loads this file, then defines FIELDS, FIL, $, esc,
+// and applyFilters (which these reference at call time via the shared classic-script global
+// scope). Canonical copy: ~/Projects/_shared/web/drill.js → each viewer's public/drill.js.
+// Memory: all-data-points-href-to-deeper-data. Reference viewers: astek-landing, schumacher-internal.
+
+// Build the URL for a given filter set — any count/atom becomes a real, shareable link.
+function qstr(fil, q, pat) {
+ const u = new URLSearchParams();
+ for (const [k] of FIELDS) { if (fil[k]) u.set(k, fil[k]); }
+ if (q) u.set('q', q); if (pat) u.set('pat', pat);
+ const s = u.toString(); return s ? ('?' + s) : location.pathname;
+}
+// Read the URL's filter state back into FIL + the search inputs (deep-link / back-button).
+function readURL() {
+ const u = new URLSearchParams(location.search);
+ for (const [k] of FIELDS) FIL[k] = u.get(k) || '';
+ $('#q').value = u.get('q') || ''; $('#pattern').value = u.get('pat') || '';
+}
+// Reflect the current filter state into the URL (push = new history entry, else replace).
+function writeURL(push) {
+ const url = qstr(FIL, $('#q').value.trim(), $('#pattern').value.trim());
+ history[push ? 'pushState' : 'replaceState']({}, '', url);
+}
+// drill(field,value,label,cls) → an <a> whose href IS the filtered view for that value.
+// cmd/ctrl/middle-click opens it in a new tab; plain click filters in place (delegated listener).
+function drill(field, val, label, cls) {
+ if (val == null || val === '') return '';
+ const href = qstr({ ...FIL, [field]: String(val) }, $('#q').value.trim(), $('#pattern').value.trim());
+ return `<a class="drill ${cls || ''}" href="${href}" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${label != null ? esc(label) : esc(val)}</a>`;
+}
+// Atom drill target: SET the filter to this value (drill in), vs pickF which TOGGLES.
+function setF(k, v) { if (FIL[k] === String(v)) return; FIL[k] = String(v); applyFilters('push'); }
+
+window.qstr = qstr; window.readURL = readURL; window.writeURL = writeURL; window.drill = drill; window.setF = setF;
diff --git a/public/index.html b/public/index.html
index 7722ce7..2240962 100644
--- a/public/index.html
+++ b/public/index.html
@@ -42,8 +42,12 @@ aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-
max-width:104px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fbody{padding:2px 0 8px}
.frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
- cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none}
+ cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none;text-decoration:none}
+a.frow{color:var(--mut)}
.frow:hover{background:var(--panel);color:var(--ink)}
+/* in-card drill atoms — dotted-underline affordance, inherit surrounding type */
+.b a.drill{color:inherit;text-decoration:none;cursor:pointer;border-bottom:1px dotted rgba(120,150,220,.5)}
+.b a.drill:hover{color:var(--acc);border-bottom-color:var(--acc)}
.frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
.frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
@@ -254,6 +258,7 @@ body.listview #listhead{display:grid}
<button id="bulkgo">Apply ▸</button>
<span class="muted" id="bulkmsg"></span>
</div>
+<script src="/drill.js"></script>
<script>
const $=s=>document.querySelector(s);
const grid=$('#grid'), state={offset:0,limit:120,count:0,loading:false,live:false,liveActions:[],store:''};
@@ -432,10 +437,10 @@ function card(p){
return `<div class="card${checked?' sel':''}" data-id="${p.id}" data-st="${st}" data-img="${p.image?1:0}">
<label class="pick"><input type="checkbox" ${checked} onchange="togglePick(${p.id},this)"></label>
${img}<div class="b">
- <span class="vend">${esc(fmtVendor(p.vendor))}</span>
+ <span class="vend">${p.vendor?dc('vendor',p.vendor,fmtVendor(p.vendor)):'—'}</span>
<div class="ttl">${title}</div>
<span class="sub when" title="last crawl: ${esc(p.crawled||'unknown')}${p.firstSeen?(' · first seen: '+esc(p.firstSeen)):''}">🕓 ${esc(p.crawled||p.firstSeen||'—')}</span>
- <span class="lcell lcolor" title="Color">${esc(p.color||'—')}</span>
+ <span class="lcell lcolor" title="Color">${p.color?dc('color',p.color,p.color):'—'}</span>
<div class="chips">
<span class="chip st-${st}">${STATUS_LABEL[st]||st}</span>
<span class="chip toggle" onclick="togglePane(this,'info')">ⓘ details</span>
@@ -443,9 +448,9 @@ function card(p){
</div>
<span class="lcell lsku" title="Vendor SKU">${esc(p.sku||'—')}</span>
<span class="lcell ldw" title="DW SKU">${esc(p.dwSku||'—')}</span>
- <span class="lcell lcoll" title="Collection: ${esc(p.collection||'')}">${esc(p.collection||'—')}</span>
- <span class="lcell ltype" title="Type">${esc(p.type||'—')}</span>
- <span class="lcell lsync" title="Sync status">${esc(p.syncStatus||'—')}</span>
+ <span class="lcell lcoll" title="Collection: ${esc(p.collection||'')}">${p.collection?dc('collection',p.collection,p.collection):'—'}</span>
+ <span class="lcell ltype" title="Type">${p.type?dc('ptype',p.type,p.type):'—'}</span>
+ <span class="lcell lsync" title="Sync status">${p.syncStatus?dc('sync',p.syncStatus,p.syncStatus):'—'}</span>
<span class="lcell lshopid" title="Shopify product id">${esc(p.shopifyId||'—')}</span>
<span class="lcell lfirst" title="First seen: ${esc(p.firstSeen||'')}">${esc(p.firstSeen||'—')}</span>
<span class="lcell lcrawl" title="Last crawl: ${esc(p.crawled||'')}">🕓 ${esc(p.crawled||'—')}</span>
@@ -508,6 +513,44 @@ function filsParams(p){
const pat=$('#pattern').value.trim(); if(pat)p.set('pattern',pat);
return p;
}
+// ── href-to-deeper-data URL addressing (HARD rule) ───────────────────────────
+// drill.js supplies drill()/setF()/qstr; setF() calls applyFilters (defined here),
+// and the anchor href is built by drill.js qstr from FIL+FIELDS. This viewer also
+// carries STATUS + q + pattern, so we own the full URL read/write below (naming
+// them so they don't collide with drill.js's #pattern-vs-`pat` closure).
+function nipURL(){
+ const p=filsParams(new URLSearchParams());
+ if(STATUS&&STATUS!=='netnew') p.set('status',STATUS);
+ const qv=$('#q').value.trim(); if(qv) p.set('q',qv);
+ const s=p.toString(); return s?('?'+s):location.pathname;
+}
+// in-card drill atom (card is NOT an <a> per DOM-split note → real <a class="drill">).
+// href carries FULL state incl. STATUS/q; the delegated click routes through setF.
+function dc(field,val,label){
+ if(val==null||val==='') return '';
+ return `<a class="drill" href="${nipHrefFor(field,val)}" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${esc(label!=null?label:val)}</a>`;
+}
+function nipWriteURL(push){ history[push?'pushState':'replaceState']({}, '', nipURL()); }
+// href for a facet OR in-card atom: current URL with field k toggled to v
+function nipHrefFor(k,v){
+ const p=new URLSearchParams();
+ for(const [f] of FIELDS){ const nv=(f===k)?(FIL[f]===String(v)?'':String(v)):FIL[f]; if(nv)p.set(f,nv); }
+ if(STATUS&&STATUS!=='netnew') p.set('status',STATUS);
+ const qv=$('#q').value.trim(); if(qv) p.set('q',qv);
+ const pat=$('#pattern').value.trim(); if(pat) p.set('pattern',pat);
+ const s=p.toString(); return s?('?'+s):location.pathname;
+}
+function nipReadURL(){
+ const u=new URLSearchParams(location.search);
+ for(const [k] of FIELDS) FIL[k]=u.get(k)||'';
+ const st=u.get('status'); if(st&&STATUS_DEFS.some(([k])=>k===st)) STATUS=st;
+ $('#q').value=u.get('q')||''; $('#pattern').value=u.get('pattern')||'';
+}
+// applyFilters — the single place that reflects state → URL → re-query (drill.js setF calls this)
+function applyFilters(nav){
+ if(nav!=='none') nipWriteURL(nav==='push');
+ renderStatusBox(); loadFacets(); load(true); refreshPatternList();
+}
async function load(reset){
if(state.loading) return; state.loading=true;
if(reset){ state.offset=0; grid.innerHTML=''; }
@@ -545,7 +588,7 @@ function renderStatusBox(){
}
function pickStatus(k){
STATUS=k; localStorage.setItem('nip.status',k);
- renderStatusBox(); loadFacets(); load(true); refreshPatternList();
+ applyFilters('push');
}
function renderSide(){
renderStatusBox();
@@ -555,8 +598,10 @@ function renderSide(){
const rows=all.slice(0,FCAP).map(([v,n,wi])=>{
const pct=(k==='vendor'&&n)?Math.round((wi||0)/n*100):null;
const img=pct!=null?`<span class="imgpct${pct<100?' warn':''}" title="${wi||0} of ${n} rows have an image">${pct}%🖼</span>`:'';
- return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
- <span class="fl">${esc(k==='vendor'?fmtVendor(v):v)}</span>${img}<span class="n">${n.toLocaleString()}</span></div>`;
+ // facet option row is a real <a href> (drill-atom contract) — href toggles this value
+ const href=nipHrefFor(k,v);
+ return `<a class="frow drill${FIL[k]===String(v)?' active':''}" href="${href}" data-filter="${k}" data-v="${esc(v)}">
+ <span class="fl">${esc(k==='vendor'?fmtVendor(v):v)}</span>${img}<span class="n">${n.toLocaleString()}</span></a>`;
}).join('');
const more=all.length>FCAP?`<div class="fmore">+ ${(all.length-FCAP).toLocaleString()} more — use search</div>`:'';
const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(k==='vendor'?fmtVendor(FIL[k]):FIL[k])}</span>`:'';
@@ -564,11 +609,11 @@ function renderSide(){
}).join('');
$('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value||STATUS!=='netnew')?'block':'none';
}
-function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);renderSide();load(true);refreshPatternList();}
+function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters('push');}
$('#clearf').onclick=()=>{
FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';
STATUS='netnew';localStorage.setItem('nip.status',STATUS);
- renderStatusBox();loadFacets();load(true);
+ applyFilters('push');
};
// server-backed pattern type-ahead (126k distinct patterns — datalist refreshes
// as you type, scoped to the current status + filters)
@@ -584,7 +629,7 @@ async function refreshPatternList(){
}
$('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(()=>{refreshPatternList();renderSide();load(true);},300);};
$('#sort').onchange=()=>{state.sort=$('#sort').value||'newest';localStorage.setItem('nip.sort',state.sort);renderListHead();load(true);};
-let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>{renderSide();load(true);},300);};
+let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>{nipWriteURL(false);renderSide();load(true);},300);};
$('#more').onclick=()=>load(false);
function applyView(){const list=localStorage.getItem('nip.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listview',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();pinListHead();}
$('#view').onclick=()=>{localStorage.setItem('nip.view',localStorage.getItem('nip.view')==='list'?'grid':'list');applyView();};
@@ -674,6 +719,16 @@ $('#bulkgo').onclick=async()=>{
$('#bulkgo').disabled=false;
};
addEventListener('scroll',()=>{if(innerHeight+scrollY>=document.body.offsetHeight-600)load(false);});
+// ── drill wiring: delegated <a class="drill"> (facet rows + in-card atoms) ──
+document.addEventListener('click',e=>{
+ const a=e.target.closest('a.drill'); if(!a)return;
+ if(e.metaKey||e.ctrlKey||e.button===1)return; // cmd/ctrl/middle → let the href open a new tab
+ e.preventDefault();
+ // toggle semantics match the href built by nipHrefFor (drill in / drill out)
+ pickF(a.dataset.filter, a.dataset.v);
+});
+addEventListener('popstate',()=>{ nipReadURL(); renderStatusBox(); loadFacets(); load(true); refreshPatternList(); });
+nipReadURL(); // deep-link: seed FIL/STATUS/q/pattern from the URL before boot
syncSel();
boot();
</script>
diff --git a/server.js b/server.js
index b46000f..39ae758 100644
--- a/server.js
+++ b/server.js
@@ -489,6 +489,11 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/' || u.pathname === '/index.html') {
return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
}
+ if (u.pathname === '/drill.js') {
+ // href-to-deeper-data primitives (shared module) — served static for index.html
+ try { return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'drill.js')), 'text/javascript; charset=utf-8'); }
+ catch { return send(res, 404, '// not found', 'text/javascript'); }
+ }
if (u.pathname === '/img') { serveImg(u.searchParams.get('u') || '', res); return; }
if (u.pathname === '/api/similar') {
const sku = u.searchParams.get('sku');
← fae9d94 TK-10039: phase8-fix/shopify-push.js migrates width to metaf
·
back to New Import Viewer
·
nav-agent: universal grid-controls drop-in on internal viewe a665308 →