[object Object]

← back to 4square Admin

feat: search box + load-more pagination

a147034f25cb5209fc8314cb067de12d03373dc0 · 2026-05-14 09:30:47 -0700 · Steve

- /api/products accepts ?q=… → ILIKE on title/SKU/DIG/handle/tags for shopify, dig_number/category/tags for wallco
- /api/products returns has_more boolean
- frontend: search input (220px, debounce 240ms, esc to clear), 'Load more (N remaining)' button at bottom of flat-grid, fliepaper-bugs matrix view skips pagination (small fixed set)

Files touched

Diff

commit a147034f25cb5209fc8314cb067de12d03373dc0
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 14 09:30:47 2026 -0700

    feat: search box + load-more pagination
    
    - /api/products accepts ?q=… → ILIKE on title/SKU/DIG/handle/tags for shopify, dig_number/category/tags for wallco
    - /api/products returns has_more boolean
    - frontend: search input (220px, debounce 240ms, esc to clear), 'Load more (N remaining)' button at bottom of flat-grid, fliepaper-bugs matrix view skips pagination (small fixed set)
---
 index.html | 46 +++++++++++++++++++++++++++++++++++++---------
 server.js  | 16 ++++++++++++++--
 2 files changed, 51 insertions(+), 11 deletions(-)

diff --git a/index.html b/index.html
index 5c86d3d..4d833d2 100644
--- a/index.html
+++ b/index.html
@@ -52,10 +52,12 @@
     background:#0d0c0b; z-index:10;
   }
   .controls label { color:#888; font:500 10.5px/1 -apple-system; text-transform:uppercase; letter-spacing:.12em }
-  .controls select, .controls button {
+  .controls select, .controls button, .controls input[type=search] {
     font:inherit; padding:6px 12px; background:#1a1816; color:var(--ink);
     border:1px solid var(--line); border-radius:4px; cursor:pointer; font-size:12px;
   }
+  .controls input[type=search] { cursor:text; width:220px }
+  .controls input[type=search]:focus { outline:none; border-color:var(--gold) }
   .controls select:hover, .controls button:hover:not(:disabled) { border-color:var(--gold); color:var(--gold) }
   .controls button:disabled { opacity:.32; cursor:not-allowed }
   .controls .density { display:flex; align-items:center; gap:8px }
@@ -128,6 +130,9 @@
       <button class="small" onclick="selectAll()">All</button>
       <button class="small" onclick="selectNone()">None</button>
 
+      <div class="group">
+        <input type="search" id="q" placeholder="Search title / SKU / DIG / tags…" />
+      </div>
       <div class="group">
         <label>Sort</label>
         <select id="sort"></select>
@@ -165,8 +170,9 @@ const $ = s => document.querySelector(s);
 const LS = { sort:'4sq.sort', cols:'4sq.cols', src:'4sq.src' };
 
 let SOURCES = [];
-let CURRENT = { id: localStorage.getItem(LS.src) || 'wallco:fliepaper-bugs', kind:null, rows:[], total:0, spec:null, readonly:false };
+let CURRENT = { id: localStorage.getItem(LS.src) || 'wallco:fliepaper-bugs', kind:null, rows:[], total:0, spec:null, readonly:false, has_more:false, offset:0 };
 let selected = new Set();
+let searchDebounce = null;
 
 const SORT_WALLCO = [
   ['newest','Newest'],['oldest','Oldest'],
@@ -240,14 +246,22 @@ function populateSortOptions(kind) {
   if (saved && opts.find(o => o[0] === saved)) sel.value = saved;
 }
 
-async function loadProducts() {
+async function loadProducts({ append = false } = {}) {
   const sort = $('#sort').value || 'newest';
-  $('#grid-wrap').innerHTML = '<div class="empty-state"><span class="spinner"></span>loading…</div>';
-  $('#bc').textContent = CURRENT.id;
-  const url = `/api/products?source=${encodeURIComponent(CURRENT.id)}&sort=${encodeURIComponent(sort)}&limit=300`;
+  const q    = $('#q').value.trim();
+  if (!append) {
+    $('#grid-wrap').innerHTML = '<div class="empty-state"><span class="spinner"></span>loading…</div>';
+    CURRENT.offset = 0;
+  }
+  $('#bc').textContent = CURRENT.id + (q ? ` · "${q}"` : '');
+  const url = `/api/products?source=${encodeURIComponent(CURRENT.id)}&sort=${encodeURIComponent(sort)}&limit=300&offset=${CURRENT.offset}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
   const j = await fetch(url).then(r => r.json());
   if (!j.ok) { $('#grid-wrap').innerHTML = `<div class="empty-state">error: ${esc(j.error)}</div>`; return; }
-  CURRENT.kind = j.kind; CURRENT.rows = j.rows; CURRENT.total = j.total; CURRENT.spec = j.spec; CURRENT.readonly = !!j.readonly;
+  CURRENT.kind = j.kind;
+  CURRENT.rows = append ? CURRENT.rows.concat(j.rows) : j.rows;
+  CURRENT.total = j.total; CURRENT.spec = j.spec; CURRENT.readonly = !!j.readonly;
+  CURRENT.has_more = !!j.has_more;
+  CURRENT.offset = (j.offset || 0) + j.rows.length;
   populateSortOptions(j.kind);
   $('#sort').value = sort;
   populateRecolor(j.spec);
@@ -255,6 +269,11 @@ async function loadProducts() {
   renderGrid();
 }
 
+async function loadMore() {
+  if (!CURRENT.has_more) return;
+  await loadProducts({ append: true });
+}
+
 function populateRecolor(spec) {
   const grp = $('#recolor-group'); const sel = $('#recolor-target');
   if (spec && spec.palette) {
@@ -278,13 +297,17 @@ function renderGrid() {
   const wrap = $('#grid-wrap');
   if (CURRENT.rows.length === 0) { wrap.innerHTML = '<div class="empty-state">no products in this source</div>'; return; }
 
-  // Matrix layout for fliepaper-bugs
+  // Matrix layout for fliepaper-bugs (no pagination needed — small fixed set)
   if (CURRENT.spec && CURRENT.spec.patterns && CURRENT.spec.palette) {
     return renderMatrix(wrap);
   }
   // Flat grid for everything else
   const cardsHtml = CURRENT.rows.map(r => cardHtml(r)).join('');
-  wrap.innerHTML = `<div class="grid">${cardsHtml}</div>`;
+  const remaining = CURRENT.total - CURRENT.rows.length;
+  const more = CURRENT.has_more
+    ? `<div style="text-align:center; padding:32px 0 16px"><button class="small" id="load-more" onclick="loadMore()" style="padding:10px 24px; font-size:13px">Load more (${remaining.toLocaleString()} remaining)</button></div>`
+    : (CURRENT.total > CURRENT.rows.length ? '' : `<div style="text-align:center; padding:24px 0 16px; color:#555; font-size:11px; font-style:italic">end · ${CURRENT.rows.length.toLocaleString()} of ${CURRENT.total.toLocaleString()}</div>`);
+  wrap.innerHTML = `<div class="grid">${cardsHtml}</div>${more}`;
   attachCardHandlers();
 }
 
@@ -411,6 +434,11 @@ $('#cols').addEventListener('input', e => {
   $('#cols-v').textContent = e.target.value;
   localStorage.setItem(LS.cols, e.target.value);
 });
+$('#q').addEventListener('input', () => {
+  clearTimeout(searchDebounce);
+  searchDebounce = setTimeout(() => loadProducts(), 240);
+});
+$('#q').addEventListener('keydown', e => { if (e.key === 'Escape') { e.target.value = ''; loadProducts(); } });
 
 // hydrate density from localStorage BEFORE first paint
 const savedCols = localStorage.getItem(LS.cols);
diff --git a/server.js b/server.js
index c142e9a..209d08a 100644
--- a/server.js
+++ b/server.js
@@ -98,6 +98,7 @@ app.get('/api/products', async (req, res) => {
     const sort   = String(req.query.sort || 'newest');
     const limit  = Math.min(500, Math.max(20, parseInt(req.query.limit || '120', 10)));
     const offset = Math.max(0, parseInt(req.query.offset || '0', 10));
+    const q      = String(req.query.q || '').trim();
 
     if (source.startsWith('wallco:')) {
       const cat = source.slice(7);
@@ -105,6 +106,10 @@ app.get('/api/products', async (req, res) => {
       const params = [];
       let where = '1=1';
       if (cat !== 'all') { params.push(cat); where = `category = $${params.length}`; }
+      if (q) {
+        params.push(`%${q}%`);
+        where += ` AND (dig_number ILIKE $${params.length} OR category ILIKE $${params.length} OR tags::text ILIKE $${params.length})`;
+      }
       const countSql = `SELECT COUNT(*)::int AS n FROM spoon_all_designs WHERE ${where}`;
       const listSql  = `SELECT id, dig_number, tags, local_path, is_published, created_at,
                                dominant_hex, category, prompt
@@ -125,7 +130,8 @@ app.get('/api/products', async (req, res) => {
       }
       return res.json({
         ok: true, kind: 'wallco', source, sort, limit, offset,
-        total: c.rows[0].n, rows: r.rows,
+        total: c.rows[0].n, has_more: (offset + r.rows.length) < c.rows[0].n,
+        rows: r.rows,
         spec: cat === 'fliepaper-bugs' ? SPEC : null,
       });
     }
@@ -137,6 +143,11 @@ app.get('/api/products', async (req, res) => {
       let where = '1=1';
       if (parts[1] === 'vendor' && parts[2]) { params.push(parts.slice(2).join(':')); where = `vendor = $${params.length}`; }
       else if (parts[1] === 'status' && parts[2]) { params.push(parts[2]); where = `status = $${params.length}`; }
+      if (q) {
+        params.push(`%${q}%`);
+        const i = params.length;
+        where += ` AND (title ILIKE $${i} OR dw_sku ILIKE $${i} OR sku ILIKE $${i} OR handle ILIKE $${i} OR tags ILIKE $${i})`;
+      }
       const countSql = `SELECT COUNT(*)::int AS n FROM shopify_products WHERE ${where}`;
       const listSql  = `SELECT id, shopify_id, handle, title, vendor, sku, dw_sku,
                                status, image_url, price, retail_price, cost_price,
@@ -147,7 +158,8 @@ app.get('/api/products', async (req, res) => {
       const [c, r] = await Promise.all([pool.query(countSql, params), pool.query(listSql, params)]);
       return res.json({
         ok: true, kind: 'shopify', source, sort, limit, offset,
-        total: c.rows[0].n, rows: r.rows, readonly: true,
+        total: c.rows[0].n, has_more: (offset + r.rows.length) < c.rows[0].n,
+        rows: r.rows, readonly: true,
       });
     }
 

← 4f1ef4e initial scaffold: 4Square admin product browser (loopback :9  ·  back to 4square Admin  ·  feat: per-card deep-link hover actions 42f74e8 →