[object Object]

← back to Hollywood Wallcoverings

Custom Creator gallery: full-catalog search + category filter + pagination

24bd0dd4b5ca37f6d7fa46fff2d5eb74f1b04272 · 2026-06-21 06:58:33 -0700 · Steve

Browse all 11,936 wallco.ai designs (was capped at newest 600). Server-side
search (title/handle/category/style), category dropdown with counts, sort
(newest/color/style/sku/title) across the whole catalog, and Load-more
pagination (120/page). New /api/categories facet endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 24bd0dd4b5ca37f6d7fa46fff2d5eb74f1b04272
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 21 06:58:33 2026 -0700

    Custom Creator gallery: full-catalog search + category filter + pagination
    
    Browse all 11,936 wallco.ai designs (was capped at newest 600). Server-side
    search (title/handle/category/style), category dropdown with counts, sort
    (newest/color/style/sku/title) across the whole catalog, and Load-more
    pagination (120/page). New /api/categories facet endpoint.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 customcreator.js           | 51 +++++++++++++++++++++++++++----
 customcreator/gallery.html | 75 ++++++++++++++++++++++++++--------------------
 2 files changed, 87 insertions(+), 39 deletions(-)

diff --git a/customcreator.js b/customcreator.js
index d737019..4517069 100644
--- a/customcreator.js
+++ b/customcreator.js
@@ -27,7 +27,7 @@ const DRAFT_TOKEN = process.env.SHOPIFY_DRAFT_TOKEN || process.env.SHOPIFY_ADMIN
 const RATE_SQM = 83.38, SAMPLE = 4.25, CM_PER_IN = 2.54;
 const MIN_CM = 50, MAX_CM = 1200;
 const HOUSE = 'Made to Measure';            // customer-facing vendor label (never "wallco.ai")
-const GALLERY_LIMIT = 600;                  // newest-N shown in the browse grid (catalog is ~12k)
+const GALLERY_LIMIT = 120;                  // page size for the browse grid (catalog is ~12k, paginated)
 
 function ftIn(inch) { let f = Math.floor(inch / 12), i = Math.round(inch - f * 12); if (i === 12) { f++; i = 0; } return `${f}′ ${i}″`; }
 const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
@@ -42,6 +42,36 @@ try {
 
 const imgUrl = file => '/CustomCreator/img/' + file;
 
+// Category facets (sorted by count) for the browse dropdown.
+const CATS = (() => {
+  const m = new Map();
+  for (const d of DESIGNS) { const c = (d.category || '').trim(); if (c) m.set(c, (m.get(c) || 0) + 1); }
+  return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([category, count]) => ({ category, count }));
+})();
+
+// Server-side sort (full-catalog, not just a page). Cached per mode so 12k isn't re-sorted each request.
+function colorKey(hex) {
+  if (!hex) return 500;
+  const m = String(hex).replace('#', '').match(/.{2}/g); if (!m || m.length < 3) return 500;
+  const [r, g, b] = m.map(x => parseInt(x, 16));
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 510, d = mx - mn;
+  if (d < 18) return l > 0.8 ? 0 : (l < 0.18 ? 900 : 400 + Math.round((1 - l) * 80));
+  let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; if (h < 0) h += 360;
+  return 50 + Math.round(h / 5);
+}
+const _sortCache = new Map();
+function sorted(mode) {
+  if (_sortCache.has(mode)) return _sortCache.get(mode);
+  const a = DESIGNS.slice(); // DESIGNS is already newest-first
+  if (mode === 'title') a.sort((x, y) => String(x.title).localeCompare(String(y.title)));
+  else if (mode === 'style') a.sort((x, y) => String(x.style || '~').localeCompare(String(y.style || '~')) || String(x.title).localeCompare(String(y.title)));
+  else if (mode === 'sku') a.sort((x, y) => String(x.handle).localeCompare(String(y.handle)));
+  else if (mode === 'color') a.sort((x, y) => colorKey(x.hex) - colorKey(y.hex) || String(x.title).localeCompare(String(y.title)));
+  // 'newest' = DESIGNS order (no sort)
+  _sortCache.set(mode, a);
+  return a;
+}
+
 function toCard(d) {
   return { handle: d.handle, sku: d.handle, title: d.title, image: imgUrl(d.file), createdAt: d.created_at || '', style: d.style || 'Original', colorHex: d.hex || '', colorName: '', vendor: HOUSE };
 }
@@ -118,13 +148,22 @@ function mount(app, express) {
   app.get(['/CustomCreator', '/CustomCreator/'], sendHtml('gallery.html'));
   app.get('/CustomCreator/configure', sendHtml('configure.html'));
 
-  // Browse grid — newest GALLERY_LIMIT (optionally ?category= / ?limit=).
+  // Category facets for the browse dropdown.
+  app.get('/CustomCreator/api/categories', (req, res) => res.json({ total: DESIGNS.length, categories: CATS }));
+
+  // Browse grid — full-catalog search/filter/sort with pagination.
+  // ?q= (title contains) · ?category= · ?sort=newest|title|style|sku|color · ?offset= · ?limit=
   app.get('/CustomCreator/api/collection', (req, res) => {
-    const cat = (req.query.category || '').toLowerCase();
-    const limit = Math.min(parseInt(req.query.limit, 10) || GALLERY_LIMIT, 2000);
-    let rows = DESIGNS;
+    const q = String(req.query.q || '').trim().toLowerCase();
+    const cat = String(req.query.category || '').trim().toLowerCase();
+    const sort = ['newest', 'title', 'style', 'sku', 'color'].includes(req.query.sort) ? req.query.sort : 'newest';
+    const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
+    const limit = Math.min(Math.max(1, parseInt(req.query.limit, 10) || GALLERY_LIMIT), 600);
+    let rows = sorted(sort);
     if (cat) rows = rows.filter(d => String(d.category).toLowerCase() === cat);
-    res.json(rows.slice(0, limit).map(toCard));
+    if (q) rows = rows.filter(d => String(d.title).toLowerCase().includes(q) || String(d.handle).toLowerCase().includes(q) || String(d.category).toLowerCase().includes(q) || String(d.style).toLowerCase().includes(q));
+    const total = rows.length;
+    res.json({ items: rows.slice(offset, offset + limit).map(toCard), total, offset, limit });
   });
 
   // Single design (by handle or numeric id).
diff --git a/customcreator/gallery.html b/customcreator/gallery.html
index c5a7335..6cd6430 100644
--- a/customcreator/gallery.html
+++ b/customcreator/gallery.html
@@ -75,6 +75,10 @@
     <div class="bar" id="grid">
       <div class="count" id="count">Loading patterns…</div>
       <div class="ctrls">
+        <div class="ctrl"><input id="q" type="search" placeholder="Search designs…" style="font-family:var(--sans);font-size:13px;border:1px solid var(--line);border-radius:3px;padding:7px 12px;min-width:170px"></div>
+        <div class="ctrl"><label for="cat">Category</label>
+          <select id="cat"><option value="">All</option></select>
+        </div>
         <div class="ctrl"><label for="sort">Sort</label>
           <select id="sort">
             <option value="newest">Newest</option>
@@ -88,53 +92,58 @@
       </div>
     </div>
     <div class="grid" id="cards"></div>
+    <div style="text-align:center;margin-top:34px">
+      <button id="more" style="display:none;font-family:var(--sans);font-size:13px;letter-spacing:.06em;border:1px solid var(--ink);background:var(--paper);color:var(--ink);padding:13px 34px;border-radius:3px;cursor:pointer">Load more</button>
+    </div>
   </div>
 
 <script>
-let DATA=[];
 const $=id=>document.getElementById(id);
 const esc=s=>String(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+const PAGE=120;
+let offset=0,total=0,loading=false,first=true;
 
-function colorKey(hex){
-  if(!hex) return 500;
-  const m=hex.replace('#','').match(/.{2}/g); if(!m||m.length<3) return 500;
-  const [r,g,b]=m.map(x=>parseInt(x,16));
-  const mx=Math.max(r,g,b),mn=Math.min(r,g,b),l=(mx+mn)/510,d=mx-mn;
-  if(d<18) return l>0.8?0:(l<0.18?900:400+Math.round((1-l)*80)); // white..gray..black
-  let h=mx===r?((g-b)/d)%6:mx===g?(b-r)/d+2:(r-g)/d+4; h*=60; if(h<0)h+=360;
-  return 50+Math.round(h/5); // warm→cool by hue, between white(0) and dark(900)
-}
-function sortData(mode){
-  const a=[...DATA];
-  if(mode==='newest') a.sort((x,y)=>(y.createdAt||'').localeCompare(x.createdAt||''));
-  else if(mode==='color') a.sort((x,y)=>colorKey(x.colorHex)-colorKey(y.colorHex)||x.title.localeCompare(y.title));
-  else if(mode==='style') a.sort((x,y)=>(x.style||'~').localeCompare(y.style||'~')||x.title.localeCompare(y.title));
-  else if(mode==='sku') a.sort((x,y)=>(x.sku||'').localeCompare(y.sku||''));
-  else if(mode==='title') a.sort((x,y)=>x.title.localeCompare(y.title));
-  return a;
-}
-function render(){
-  const mode=$('sort').value; const rows=sortData(mode);
-  $('cards').innerHTML=rows.map(p=>`
+function cardHtml(p){return `
     <a class="card" href="/CustomCreator/configure?handle=${encodeURIComponent(p.handle)}">
       <div class="card-img" style="--bgimg:url('${esc(p.image)}')"></div>
       <div class="card-name">${esc(p.title)}</div>
-      <div class="card-sub">${esc(p.style||'Mural')}${p.colorName?' · '+esc(p.colorName):''}</div>
-    </a>`).join('');
-  $('count').textContent=rows.length+' mural patterns';
+      <div class="card-sub">${esc(p.style||'Original')}${p.colorName?' · '+esc(p.colorName):''}</div>
+    </a>`;}
+
+function query(reset){
+  if(loading) return; loading=true;
+  if(reset){offset=0;$('cards').innerHTML='';}
+  const u=new URLSearchParams({q:$('q').value.trim(),category:$('cat').value,sort:$('sort').value,offset,limit:PAGE});
+  $('count').textContent='Loading…';
+  fetch('/CustomCreator/api/collection?'+u).then(r=>r.json()).then(d=>{
+    total=d.total; const items=d.items||[];
+    $('cards').insertAdjacentHTML('beforeend',items.map(cardHtml).join(''));
+    offset+=items.length;
+    $('count').textContent=total? (offset+' of '+total.toLocaleString()+' designs') : 'No designs match.';
+    $('more').style.display=offset<total?'inline-block':'none';
+    if(first&&items[0]){$('startBtn').href='/CustomCreator/configure?handle='+encodeURIComponent(items[0].handle);first=false;}
+    loading=false;
+  }).catch(()=>{$('count').textContent='Could not load designs.';loading=false;});
+}
+
+function loadCategories(){
+  fetch('/CustomCreator/api/categories').then(r=>r.json()).then(d=>{
+    const cap=s=>String(s||'').replace(/\b\w/g,c=>c.toUpperCase());
+    (d.categories||[]).forEach(c=>{const o=document.createElement('option');o.value=c.category;o.textContent=cap(c.category.replace(/-/g,' '))+' ('+c.count.toLocaleString()+')';$('cat').appendChild(o);});
+  }).catch(()=>{});
 }
+
 function init(){
-  // restore persisted controls
   const sv=localStorage.getItem('cc-sort'); if(sv)$('sort').value=sv;
+  const cv=localStorage.getItem('cc-cat'); // applied after categories load
   const dv=localStorage.getItem('cc-density'); if(dv){$('density').value=dv;document.documentElement.style.setProperty('--card',dv+'px');}
-  $('sort').onchange=()=>{localStorage.setItem('cc-sort',$('sort').value);render();};
+  let t; $('q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>query(true),280);};
+  $('cat').onchange=()=>{localStorage.setItem('cc-cat',$('cat').value);query(true);};
+  $('sort').onchange=()=>{localStorage.setItem('cc-sort',$('sort').value);query(true);};
   $('density').oninput=()=>{document.documentElement.style.setProperty('--card',$('density').value+'px');localStorage.setItem('cc-density',$('density').value);};
-  // first pattern → personalize the hero CTA
-  fetch('/CustomCreator/api/collection').then(r=>r.json()).then(list=>{
-    DATA=list||[];
-    if(DATA[0]) $('startBtn').href='/CustomCreator/configure?handle='+encodeURIComponent(DATA[0].handle);
-    render();
-  }).catch(()=>{$('count').textContent='Could not load patterns.';});
+  $('more').onclick=()=>query(false);
+  loadCategories();
+  query(true);
 }
 init();
 </script>

← 16691ff Custom Creator: re-source to wallco.ai designs ONLY (drop Re  ·  back to Hollywood Wallcoverings  ·  Custom Creator: self-host design images via id-keyed write-t fae2b73 →