[object Object]

← back to Vahallan Line Viewer

vahallan-line-viewer: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)

ac676e51ad1a87d5e4737e6c01bd1c6a4d777bca · 2026-07-31 12:16:33 -0700 · Steve Abrams

Files touched

Diff

commit ac676e51ad1a87d5e4737e6c01bd1c6a4d777bca
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 31 12:16:33 2026 -0700

    vahallan-line-viewer: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
---
 public/drill.js   | 36 ++++++++++++++++++++++++++++++++++++
 public/index.html | 53 +++++++++++++++++++++++++++++++++++++++++++----------
 2 files changed, 79 insertions(+), 10 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 0e0e1f7..8dbeef9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -35,6 +35,10 @@ select { background:var(--card); color:var(--fg); border:1px solid var(--line);
 body.imgonly .meta { display:none; }
 .count { color:var(--dim); font-size:12px; }
 label.tog { font-size:12px; color:var(--dim); display:flex; gap:5px; align-items:center; cursor:pointer; }
+/* href-to-deeper-data atoms: every facet-valued cell is a link to its filtered view */
+a.drill { color:inherit; text-decoration:none; border-bottom:1px dotted transparent; cursor:pointer; }
+a.drill:hover { color:var(--teal); border-bottom-color:var(--teal); }
+a.drill.on { color:var(--teal); }
 </style>
 </head>
 <body>
@@ -60,11 +64,20 @@ label.tog { font-size:12px; color:var(--dim); display:flex; gap:5px; align-items
   </aside>
   <div id="grid"></div>
 </main>
+<!-- hidden stub so shared /drill.js's $('#pattern') references are no-ops (this viewer has no pattern-text input) -->
+<input type="hidden" id="pattern" value="">
+<script src="/drill.js"></script>
 <script>
 let ALL = [], filters = {};
-const FACET_FIELDS = [ ['pattern','Pattern'], ['shopstatus','Shopify status'] ];
+const FACET_FIELDS = [ ['pattern','Pattern'], ['colorway','Colorway'], ['shopstatus','Shopify status'] ];
 const $ = (s) => document.querySelector(s);
 const fmtDate = (d) => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
+// ── adapter: shared /drill.js references FIELDS / FIL / esc / applyFilters at call time
+//    against this viewer's real globals (FACET_FIELDS / filters / render). ──
+const esc = (s) => String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const FIELDS = FACET_FIELDS;   // [field,label] pairs, same shape drill.js iterates
+const FIL = filters;           // field -> active value ('' / undefined = off)
+function applyFilters(nav) { render(); buildSelected(); if (nav !== 'none') writeURL(nav === 'push'); }
 
 async function boot() {
   const r = await fetch(location.origin + '/api/products');
@@ -72,7 +85,7 @@ async function boot() {
   ALL = j.products.map(p => ({ ...p,
     shopstatus: p.already_on_shopify ? 'ACTIVE (pre-onboard)' : (p.shopify_product_id ? 'DRAFT (on cadence)' : 'not imported') }));
   $('#count').textContent = j.count + ' SKUs · loaded ' + fmtDate(j.loadedAt);
-  buildFacets(); render();
+  buildFacets(); readURL(); applyFilters('none');
 }
 function buildFacets() {
   const box = $('#facets'); box.innerHTML = '';
@@ -87,7 +100,7 @@ function buildFacets() {
     div.querySelectorAll('tr').forEach(tr => tr.onclick = () => {
       const f = tr.dataset.f, v = tr.dataset.v;
       if (filters[f] === v) delete filters[f]; else filters[f] = v;
-      buildSelected(); render();
+      applyFilters('push');
     });
     box.appendChild(div);
   }
@@ -96,10 +109,13 @@ function buildFacets() {
 function buildSelected() {
   document.querySelectorAll('.facet tr').forEach(tr =>
     tr.classList.toggle('on', filters[tr.dataset.f] === tr.dataset.v));
+  // reflect active atom-drill links (data-filter/data-v set by drill()) as .on
+  document.querySelectorAll('a.drill[data-filter]').forEach(a =>
+    a.classList.toggle('on', filters[a.dataset.filter] === a.dataset.v));
 }
 function render() {
   const q = $('#q').value.toLowerCase();
-  let rows = ALL.filter(p => Object.entries(filters).every(([f,v]) => (p[f]||'(none)') === v));
+  let rows = ALL.filter(p => Object.entries(filters).every(([f,v]) => !v || (p[f]||'(none)') === v));
   if (q) rows = rows.filter(p => [p.pattern,p.colorway,p.title,p.mfr_sku,p.dw_sku].join(' ').toLowerCase().includes(q));
   const s = $('#sort').value;
   rows.sort((a,b) =>
@@ -108,22 +124,39 @@ function render() {
     s==='title' ? (a.title||'').localeCompare(b.title||'') :
     (a.dw_sku||'').localeCompare(b.dw_sku||''));
   $('#grid').innerHTML = rows.map(p => `
-    <div class="card" title="${p.mfr_sku} · created ${p.created_at||''}" onclick="window.open('${p.product_url}','_blank')">
-      ${p.image_url ? `<img loading="lazy" src="${p.image_url}">` : '<div style="aspect-ratio:1;background:#222"></div>'}
+    <div class="card" data-href="${esc(p.product_url||'')}" title="${esc(p.mfr_sku)} · created ${p.created_at||''}">
+      ${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}">` : '<div style="aspect-ratio:1;background:#222"></div>'}
       <div class="meta">
-        <div class="pr">${p.title || '<i>untitled</i>'} <span class="st ${p.already_on_shopify||p.shopify_product_id ? (p.already_on_shopify?'live':'draft') : ''}">${p.shopstatus}</span></div>
-        <div class="internal">${p.pattern||''}${p.colorway ? ' · '+p.colorway : ''} · ${p.mfr_sku}</div>
-        <div class="internal">${p.dw_sku||'no DW SKU'}</div>
+        <div class="pr">${p.title ? esc(p.title) : '<i>untitled</i>'} ${drill('shopstatus', p.shopstatus, p.shopstatus, 'st '+(p.already_on_shopify||p.shopify_product_id ? (p.already_on_shopify?'live':'draft') : ''))}</div>
+        <div class="internal">${drill('pattern', p.pattern, p.pattern)}${p.colorway ? ' · '+drill('colorway', p.colorway, p.colorway) : ''} · ${esc(p.mfr_sku)}</div>
+        <div class="internal">${p.dw_sku ? esc(p.dw_sku) : 'no DW SKU'}</div>
         <div class="when">🕓 ${fmtDate(p.imported_at || p.created_at)}</div>
       </div>
     </div>`).join('');
   $('#count').textContent = rows.length + ' / ' + ALL.length + ' SKUs';
 }
-$('#q').oninput = render; $('#sort').onchange = () => { render(); localStorage.setItem('vah.sort', $('#sort').value); };
+$('#q').oninput = () => applyFilters('replace');
+$('#sort').onchange = () => { localStorage.setItem('vah.sort', $('#sort').value); applyFilters('replace'); };
 $('#density').oninput = (e) => { document.documentElement.style.setProperty('--cols', e.target.value); localStorage.setItem('vah.cols', e.target.value); };
 $('#imgonly').onchange = (e) => document.body.classList.toggle('imgonly', e.target.checked);
 if (localStorage.getItem('vah.cols')) { $('#density').value = localStorage.getItem('vah.cols'); document.documentElement.style.setProperty('--cols', $('#density').value); }
 if (localStorage.getItem('vah.sort')) $('#sort').value = localStorage.getItem('vah.sort');
+
+// ── grid delegated clicks: atom drill-in (plain click) + card open (elsewhere) ──
+const grid = $('#grid');
+grid.addEventListener('click', e => {
+  const d = e.target.closest('a.drill');
+  if (d) {
+    if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return; // keep real href → new tab
+    e.preventDefault(); e.stopPropagation();
+    setF(d.dataset.filter, d.dataset.v);   // drill.js: SET filter → applyFilters('push')
+    return;
+  }
+  const c = e.target.closest('.card[data-href]');
+  if (c && c.dataset.href) window.open(c.dataset.href, '_blank');
+});
+
+addEventListener('popstate', () => { readURL(); render(); buildSelected(); });
 boot();
 </script>
 </body>

← f751706 vahallan-line-viewer :9982 — internal curation surface over  ·  back to Vahallan Line Viewer  ·  vahallan-line-viewer: facet values are real <a href> hrefs, 52b42eb →