← 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
A public/drill.jsM public/index.html
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →