← 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 →