← back to Crezana Internal
crezana-internal: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
7cf32f3f848e78f8403196ba0c6a16181f4f2a81 · 2026-07-31 12:18:06 -0700 · Steve Abrams
Files touched
A public/drill.jsM public/index.html
Diff
commit 7cf32f3f848e78f8403196ba0c6a16181f4f2a81
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 31 12:18:06 2026 -0700
crezana-internal: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
---
public/drill.js | 36 +++++++++++++++
public/index.html | 134 +++++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 153 insertions(+), 17 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 5fc986a..53d2a95 100644
--- a/public/index.html
+++ b/public/index.html
@@ -77,6 +77,10 @@
#toast.ok{border-color:#2a6a4a} #toast.err{border-color:#7a2a2a}
.card .nm{font-size:calc(13px*var(--fs)*var(--scale));font-weight:600;line-height:1.25;margin-bottom:3px;color:var(--txt)}
.card .sub{font-size:calc(11px*var(--fs)*var(--scale));color:var(--mut)}
+ /* ── drill atom: every displayed data point is an href to its filtered (deeper) view ── */
+ a.drill{color:inherit;text-decoration:none;cursor:pointer;border-bottom:1px dotted transparent;transition:color .12s,border-color .12s}
+ a.drill:hover{color:var(--accent);border-bottom-color:currentColor}
+ .fitem .lbl a.drill{display:block;color:inherit}
.card .when{font-size:calc(10px*var(--fs)*var(--scale));color:var(--mut);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid #0006;vertical-align:middle;margin-right:5px}
.card .tags{margin-top:5px;display:flex;flex-wrap:wrap;gap:3px}
@@ -130,6 +134,7 @@
<div class="empty" id="empty" style="display:none">No matches.</div>
</main>
</div>
+<script src="/drill.js"></script>
<script>
const API = location.origin;
let ALL = [], LAST_FACETS = {};
@@ -146,6 +151,77 @@ const state = {
tags:loadSet(SETS.tags), images:loadSet(SETS.images),
};
function persistFacets(){ state.types=state.types; for(const [k,key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
+
+/* ── href-to-deeper-data primitives (shared /drill.js), ADAPTED to this viewer's ──────────
+ MULTI-SELECT Set model. Reference viewers (astek/schumacher) use a single-value FIL object;
+ Crezana's facets are localStorage-persisted Sets (multi-select), so we OVERRIDE the module's
+ qstr/readURL/writeURL/setF with Set-aware versions AFTER /drill.js has loaded. drill.js's
+ drill() still builds every atom link — it calls the (now Set-aware) qstr at click time.
+ DRILL_FIELDS maps a drillable data atom → [URL param, state Set key, isArrayField]. */
+const DRILL_FIELDS = [
+ ['type', 'types', false], // p.product_type (scalar)
+ ['collection', 'collections', false], // p.collection (scalar)
+ ['color', 'colors', true ], // p.colors[] (family list)
+ ['style', 'styles', true ], // p.styles[]
+ ['material', 'materials', true ], // p.materials[]
+ ['tag', 'tags', true ], // p.tags[]
+];
+const DF_BY_PARAM = Object.fromEntries(DRILL_FIELDS.map(f=>[f[0],f]));
+const $ = (sel)=>document.querySelector(sel); // drill.js reads $('#q').value
+
+// Build the shareable URL from the current multi-select state (comma-joins a Set's values).
+function qstr(_ignored, q){
+ const u=new URLSearchParams();
+ for(const [param,setKey] of DRILL_FIELDS){ const s=state[setKey]; if(s&&s.size) u.set(param,[...s].join(',')); }
+ const query=(q!=null?q:state.q).trim(); if(query) u.set('q',query);
+ const s=u.toString(); return s?('?'+s):location.pathname;
+}
+// Build the href for "add this value to this field's set" (what a drill atom links to).
+function qstrAdd(param,val){
+ const u=new URLSearchParams();
+ for(const [p,setKey] of DRILL_FIELDS){
+ const cur=new Set(state[setKey]);
+ if(p===param) cur.add(String(val));
+ if(cur.size) u.set(p,[...cur].join(','));
+ }
+ if(state.q.trim()) u.set('q',state.q.trim());
+ const s=u.toString(); return s?('?'+s):location.pathname;
+}
+// Build the href for a facet-ROW toggle (remove val if already active, else add it).
+function hrefToggle(param,val){
+ const u=new URLSearchParams();
+ for(const [p,setKey] of DRILL_FIELDS){
+ const cur=new Set(state[setKey]);
+ if(p===param){ cur.has(String(val))?cur.delete(String(val)):cur.add(String(val)); }
+ if(cur.size) u.set(p,[...cur].join(','));
+ }
+ if(state.q.trim()) u.set('q',state.q.trim());
+ const s=u.toString(); return s?('?'+s):location.pathname;
+}
+// Read URL → state Sets + search box (deep-link / back-button).
+function readURL(){
+ const u=new URLSearchParams(location.search);
+ for(const [param,setKey] of DRILL_FIELDS){
+ const raw=u.get(param);
+ state[setKey]=new Set(raw?raw.split(',').map(s=>s.trim()).filter(Boolean):[]);
+ }
+ state.q=(u.get('q')||'').trim(); const qb=document.getElementById('q'); if(qb) qb.value=state.q;
+}
+// Reflect current state → URL (push = new history entry on a filter pick, else replace).
+function writeURL(push){ history[push?'pushState':'replaceState']({},'', qstr(null)); }
+// Atom drill target: ADD this value to the field's set (multi-select drill-in), then re-filter.
+function setF(param,val){
+ const df=DF_BY_PARAM[param]; if(!df) return; const set=state[df[1]];
+ if(set.has(String(val))) return; set.add(String(val)); applyFilterChange('push');
+}
+// Re-point drill.js's globals at the Set-aware overrides (module ran first at load).
+window.qstr=qstr; window.readURL=readURL; window.writeURL=writeURL; window.setF=setF; window.$=$;
+// Make drill.js's drill(field,val,label) emit an ADD-to-set href for OUR model.
+window.drill=function(param,val,label,cls){
+ if(val==null||val==='') return '';
+ const href=qstrAdd(param,val);
+ return `<a class="drill ${cls||''}" href="${href}" data-filter="${param}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${label!=null?esc(label):esc(val)}</a>`;
+};
// per-row UI state: expanded? + type-ahead needle
const rowUI = {
collectionRow:{expand:false,find:''}, colorRow:{expand:false,find:''}, styleRow:{expand:false,find:'',typeahead:true},
@@ -199,8 +275,8 @@ async function load(){
}
/* ── facet list rows: checkbox + count + type-ahead + cap/expand ── (ported from all.dw) */
-function toggleSet(set,val){ set.has(val)?set.delete(val):set.add(val); applyFilterChange(); }
-function applyFilterChange(){ persistFacets(); renderFacets(LAST_FACETS); render(); }
+function toggleSet(set,val){ set.has(val)?set.delete(val):set.add(val); applyFilterChange('push'); }
+function applyFilterChange(nav){ persistFacets(); if(nav!=='none') writeURL(nav==='push'); renderFacets(LAST_FACETS); render(); }
function facetList(rowId, items, set, opts={}){
const row=document.getElementById(rowId); row.innerHTML='';
const ui=rowUI[rowId]||{expand:false,find:''};
@@ -221,8 +297,13 @@ function facetList(rowId, items, set, opts={}){
shown.forEach(([val,ct,dot])=>{
const el=document.createElement('div'); el.className='fitem'+(set.has(val)?' on':'')+(ct?'':' zero');
const sw = dot ? `<span class="swatch" style="background:${dot}"></span>` : '';
- el.innerHTML=`<span class="box">${set.has(val)?'✓':''}</span>${sw}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${(ct||0).toLocaleString()}</span>`;
- el.onclick=()=>toggleSet(set,val);
+ // URL-addressable facet row: the label is a real <a href=?param=value> (toggle-href) so
+ // cmd/middle-click opens the filtered view; plain click toggles in place (delegated below).
+ let lbl;
+ if(opts.param){ const href=hrefToggle(opts.param,val); lbl=`<a class="drill" href="${href}" data-ftoggle="${esc(opts.param)}" data-v="${esc(val)}">${esc(val)}</a>`; }
+ else lbl=esc(val);
+ el.innerHTML=`<span class="box">${set.has(val)?'✓':''}</span>${sw}<span class="lbl" title="${esc(val)}">${lbl}</span><span class="ct">${(ct||0).toLocaleString()}</span>`;
+ el.onclick=(ev)=>{ if(ev.metaKey||ev.ctrlKey||ev.shiftKey||ev.button===1) return; ev.preventDefault(); toggleSet(set,val); };
row.appendChild(el);
});
if(hidden>0){ const el=document.createElement('span'); el.className='more'; el.textContent=`See ${hidden.toLocaleString()} more ▾`; el.onclick=()=>{ ui.expand=true; renderFacets(LAST_FACETS); }; row.appendChild(el); }
@@ -231,13 +312,13 @@ function facetList(rowId, items, set, opts={}){
function renderFacets(f){
LAST_FACETS=f;
- facetList('typeRow', f.product_type, state.types, {cap:6});
- facetList('collectionRow', f.collection, state.collections, {cap:12});
- facetList('colorRow', f.color, state.colors, {order:f.family_order||FAMILY_ORDER, cap:16});
- facetList('styleRow', f.style, state.styles, {cap:12});
- facetList('matRow', f.material, state.materials, {cap:12});
+ facetList('typeRow', f.product_type, state.types, {cap:6, param:'type'});
+ facetList('collectionRow', f.collection, state.collections, {cap:12, param:'collection'});
+ facetList('colorRow', f.color, state.colors, {order:f.family_order||FAMILY_ORDER, cap:16, param:'color'});
+ facetList('styleRow', f.style, state.styles, {cap:12, param:'style'});
+ facetList('matRow', f.material, state.materials, {cap:12, param:'material'});
facetList('priceRow', f.price_band, state.prices, {cap:4});
- facetList('tagsRow', f.tags, state.tags, {cap:14});
+ facetList('tagsRow', f.tags, state.tags, {cap:14, param:'tag'});
facetList('imgRow', f.image_state, state.images, {cap:4});
renderActive();
}
@@ -253,11 +334,11 @@ function renderActive(){
n++;
const el=document.createElement('span'); el.className='afilter';
el.innerHTML=`<b>${label}:</b> ${esc(v)} ✕`;
- el.onclick=()=>{ state[k].delete(v); applyFilterChange(); };
+ el.onclick=()=>{ state[k].delete(v); applyFilterChange('push'); };
row.appendChild(el);
});
}
- if(n){ const c=document.createElement('button'); c.className='clearall'; c.textContent='Clear all'; c.onclick=()=>{ DIMS.forEach(([,k])=>state[k].clear()); applyFilterChange(); }; row.appendChild(c); }
+ if(n){ const c=document.createElement('button'); c.className='clearall'; c.textContent='Clear all'; c.onclick=()=>{ DIMS.forEach(([,k])=>state[k].clear()); applyFilterChange('push'); }; row.appendChild(c); }
}
/* ── filter predicate (client-side, all rows in RAM) ── */
@@ -280,11 +361,16 @@ function render(){
document.getElementById('empty').style.display=rows.length?'none':'block';
grid.innerHTML = rows.map(p=>{
const hex=p.dominant_color_hex||p.color_hex;
- const tags=(p.styles||[]).concat(p.tags||[]).slice(0,4);
+ // Each displayed data atom drills to its filtered (deeper) view via the shared drill() helper.
+ // Style chips drill to ?style=…, tag chips to ?tag=…; the collection drills to ?collection=….
+ const styleChips=(p.styles||[]).slice(0,2).map(s=>`<span class="tag">${drill('style',s,s)}</span>`);
+ const tagChips=(p.tags||[]).slice(0,Math.max(0,4-styleChips.length)).map(t=>`<span class="tag">${drill('tag',t,t)}</span>`);
+ const chips=styleChips.concat(tagChips);
+ const collDrill=p.collection?drill('collection',p.collection,p.collection):'';
const body = fieldOn.nm||fieldOn.sub||fieldOn.tags||fieldOn.when ? `<div class="body">
${fieldOn.nm?`<div class="nm">${esc(p.pattern_name||p.mfr_sku)}</div>`:''}
- ${fieldOn.sub?`<div class="sub">${fieldOn.dot&&hex?`<span class="dot" style="background:${esc(hex)}"></span>`:''}${esc(p.collection||'')} · ${esc(p.dw_sku||p.mfr_sku)}</div>`:''}
- ${fieldOn.tags&&tags.length?`<div class="tags">${tags.map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>`:''}
+ ${fieldOn.sub?`<div class="sub">${fieldOn.dot&&hex?`<span class="dot" style="background:${esc(hex)}"></span>`:''}${collDrill}${p.collection?' · ':''}${esc(p.dw_sku||p.mfr_sku)}</div>`:''}
+ ${fieldOn.tags&&chips.length?`<div class="tags">${chips.join('')}</div>`:''}
${fieldOn.when?`<div class="when" title="${esc(p.crawled_at||'')}">🕓 ${fmtDate(p.crawled_at)}</div>`:''}
</div>`:'';
return `<div class="card" data-sku="${esc(p.dw_sku||'')}" data-mfr="${esc(p.mfr_sku||'')}" data-title="${esc(p.pattern_name||p.mfr_sku||'')}">
@@ -298,6 +384,14 @@ function render(){
}).join('');
}
+// ── card-atom drill: plain click filters the grid to that value; cmd/ctrl/shift/middle-click keeps the real href ──
+document.getElementById('grid').addEventListener('click',(e)=>{
+ const d=e.target.closest('a.drill'); if(!d) return;
+ if(e.metaKey||e.ctrlKey||e.shiftKey||e.button===1) return; // let the browser open the href in a new tab
+ e.preventDefault();
+ setF(d.dataset.filter,d.dataset.v); // ADD the value to that field's set (from /drill.js, Set-aware)
+});
+
// ── Purchasing actions on each chip: Memo / Stock / Price → /api/request ──
document.getElementById('grid').addEventListener('click', async (e)=>{
const btn=e.target.closest('.act'); if(!btn) return;
@@ -320,7 +414,8 @@ function toast(msg, ok){
/* ── top-bar wiring ── */
const qEl=document.getElementById('q');
-qEl.oninput=()=>{ state.q=qEl.value.trim().toLowerCase(); render(); };
+let qURLt=null;
+qEl.oninput=()=>{ state.q=qEl.value.trim().toLowerCase(); render(); clearTimeout(qURLt); qURLt=setTimeout(()=>writeURL(false),350); };
const sortEl=document.getElementById('sort'); sortEl.value=state.sort;
sortEl.onchange=e=>{ state.sort=e.target.value; localStorage.setItem('crz_sort',state.sort); render(); };
const dirBtn=document.getElementById('dir');
@@ -342,5 +437,10 @@ document.body.classList.toggle('imgonly',imgonlyEl.checked);
imgonlyEl.onchange=e=>{ document.body.classList.toggle('imgonly',e.target.checked); localStorage.setItem('crz_imgonly',e.target.checked?'1':'0'); };
document.getElementById('railToggle').onclick=()=>document.getElementById('rail').classList.toggle('open');
-load();
+// Deep-link / back-button: URL is the source of truth for filter state on load + popstate.
+// A URL with any drill param OVERRIDES the localStorage-persisted Sets (shareable link wins).
+const HAS_URL_FILTERS = DRILL_FIELDS.some(([p])=>new URLSearchParams(location.search).has(p)) || new URLSearchParams(location.search).has('q');
+addEventListener('popstate',()=>{ readURL(); persistFacets(); renderFacets(LAST_FACETS); render(); });
+
+load().then(()=>{ if(HAS_URL_FILTERS){ readURL(); persistFacets(); renderFacets(LAST_FACETS); render(); } });
</script></body></html>
← 66097a4 crezana-internal: add Memo/Stock/Price purchasing actions on
·
back to Crezana Internal
·
nav-agent: universal grid-controls drop-in on internal viewe b7d1c7c →