[object Object]

← back to Wallco Ai

add: color slider + style slider (Traditional→Modern) + admin per-card bulk-select checkbox + drag-rubber-band selection + bulk-action toolbar (tag/publish/unpublish/delete) + /api/designs/bulk-action endpoint

a9ef495390164c314b5f8a749fba4f1b87613cd2 · 2026-05-20 10:36:58 -0700 · Steve Abrams

Files touched

Diff

commit a9ef495390164c314b5f8a749fba4f1b87613cd2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 10:36:58 2026 -0700

    add: color slider + style slider (Traditional→Modern) + admin per-card bulk-select checkbox + drag-rubber-band selection + bulk-action toolbar (tag/publish/unpublish/delete) + /api/designs/bulk-action endpoint
---
 server.js | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 182 insertions(+)

diff --git a/server.js b/server.js
index 9f4737a..f842c62 100644
--- a/server.js
+++ b/server.js
@@ -1524,6 +1524,41 @@ app.post('/api/design/:id/remove', (req, res) => {
   }
 });
 
+// Bulk action — admin-only — apply one action to N designs at once.
+// Body: { ids: number[], action: 'delete'|'publish'|'unpublish'|'tag' }
+app.post('/api/designs/bulk-action', express.json({ limit: '64kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const ids = Array.isArray(req.body?.ids) ? req.body.ids.map((x) => parseInt(x, 10)).filter((n) => Number.isFinite(n) && n > 0) : [];
+  const action = String(req.body?.action || '').toLowerCase();
+  if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
+  if (!['delete', 'publish', 'unpublish', 'tag'].includes(action)) return res.status(400).json({ error: 'unknown action' });
+  const idList = ids.join(',');
+  try {
+    let affected = 0;
+    if (action === 'delete') {
+      psqlExecLocal(`UPDATE spoon_all_designs SET user_removed=TRUE WHERE id IN (${idList});`);
+      affected = ids.length;
+      // also drop from in-memory cache
+      for (const id of ids) {
+        const idx = DESIGNS.findIndex((x) => x.id === id);
+        if (idx >= 0) DESIGNS.splice(idx, 1);
+      }
+    } else if (action === 'publish') {
+      psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id IN (${idList});`);
+      affected = ids.length;
+    } else if (action === 'unpublish') {
+      psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id IN (${idList});`);
+      affected = ids.length;
+    } else if (action === 'tag') {
+      // For now — accept but don't implement tag-write path (needs UI for tag input)
+      return res.status(501).json({ error: 'tag action not yet implemented — coming soon' });
+    }
+    res.json({ ok: true, action, affected, ids });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ── TRADE LOGIN (separate from admin layer) — magic-link, no passwords.
 // Sister cookie to dw_auth; different name (`wallco_trade_session`), different
 // table (wallco_trade_users), so admin and trade tiers never collide.
@@ -4398,6 +4433,153 @@ ${(req.query.source === 'all') ? `
   </div>
   ` : ''}
 
+  <!-- ─── Slider filter + admin bulk-select + drag rubber-band (inline) ─── -->
+  <script>
+  (function(){
+    var grid = document.getElementById('catalog-grid');
+    if (!grid) return;
+    var isAdmin = ${_isAdmin ? 'true' : 'false'};
+    var hueSlider = document.getElementById('scrub-hue');
+    var styleSlider = document.getElementById('scrub-style');
+    var hueLabel = document.getElementById('scrub-hue-label');
+    var styleLabel = document.getElementById('scrub-style-label');
+    var countLabel = document.getElementById('scrub-count');
+    var hueClear = document.getElementById('scrub-hue-clear');
+    var styleClear = document.getElementById('scrub-style-clear');
+    var hueActive = false, styleActive = false;
+    var hueTarget = 180, styleTarget = 50;
+    var HUE_TOL = 30, STYLE_TOL = 15;
+    function applyFilter(){
+      var cards = grid.querySelectorAll('.design-card');
+      var shown = 0;
+      cards.forEach(function(c){
+        var hue = parseInt(c.getAttribute('data-hue') || '-1', 10);
+        var rank = parseInt(c.getAttribute('data-style-rank') || '50', 10);
+        var hueOk = !hueActive || (hue >= 0 && Math.min(Math.abs(hue - hueTarget), 360 - Math.abs(hue - hueTarget)) <= HUE_TOL);
+        var styleOk = !styleActive || Math.abs(rank - styleTarget) <= STYLE_TOL;
+        if (hueOk && styleOk) { c.style.display = ''; shown++; }
+        else { c.style.display = 'none'; }
+      });
+      if (countLabel) countLabel.textContent = (!hueActive && !styleActive) ? ('Showing all ' + cards.length + ' designs on this page') : ('Showing ' + shown + ' of ' + cards.length);
+    }
+    function hueName(deg){
+      if (deg < 12 || deg >= 350) return 'rose / red';
+      if (deg < 50)  return 'amber / orange';
+      if (deg < 70)  return 'gold / yellow';
+      if (deg < 160) return 'green / sage';
+      if (deg < 200) return 'teal / cyan';
+      if (deg < 250) return 'blue / sapphire';
+      if (deg < 290) return 'mauve / violet';
+      if (deg < 330) return 'plum / magenta';
+      return 'pink / rose';
+    }
+    function styleName(rank){
+      if (rank < 15) return 'Traditional';
+      if (rank < 35) return 'Classical';
+      if (rank < 55) return 'Transitional';
+      if (rank < 75) return 'Eclectic';
+      if (rank < 90) return 'Modern';
+      return 'Contemporary';
+    }
+    if (hueSlider) hueSlider.addEventListener('input', function(){
+      hueActive = true; hueTarget = parseInt(hueSlider.value, 10);
+      if (hueLabel) hueLabel.textContent = hueTarget + '° · ' + hueName(hueTarget);
+      applyFilter();
+    });
+    if (styleSlider) styleSlider.addEventListener('input', function(){
+      styleActive = true; styleTarget = parseInt(styleSlider.value, 10);
+      if (styleLabel) styleLabel.textContent = styleName(styleTarget);
+      applyFilter();
+    });
+    if (hueClear) hueClear.addEventListener('click', function(){ hueActive = false; if (hueLabel) hueLabel.textContent = 'all colors'; applyFilter(); });
+    if (styleClear) styleClear.addEventListener('click', function(){ styleActive = false; if (styleLabel) styleLabel.textContent = 'all styles'; applyFilter(); });
+
+    if (!isAdmin) return;
+    var selected = new Set();
+    var toolbar = document.getElementById('bulk-toolbar');
+    var bulkCount = document.getElementById('bulk-count');
+    function injectCheckboxes(){
+      grid.querySelectorAll('.design-card:not(.has-bulk-cb)').forEach(function(c){
+        c.classList.add('has-bulk-cb');
+        var cb = document.createElement('label');
+        cb.className = 'bulk-cb';
+        cb.style.cssText = 'position:absolute;top:8px;left:8px;z-index:5;background:rgba(255,255,255,.92);border:1px solid #1a1816;border-radius:4px;width:22px;height:22px;display:flex;align-items:center;justify-content:center;cursor:pointer;font:600 14px/1 var(--sans,system-ui);user-select:none;box-shadow:0 1px 4px rgba(0,0,0,.18)';
+        cb.innerHTML = '';
+        cb.addEventListener('click', function(e){ e.stopPropagation(); e.preventDefault(); toggleCard(c); });
+        if (getComputedStyle(c).position === 'static') c.style.position = 'relative';
+        c.appendChild(cb);
+      });
+    }
+    function toggleCard(c, forceOn){
+      var id = c.getAttribute('data-design-id') || c.getAttribute('data-id');
+      var on = forceOn != null ? forceOn : !selected.has(id);
+      if (on) { selected.add(id); c.classList.add('bulk-selected'); }
+      else { selected.delete(id); c.classList.remove('bulk-selected'); }
+      var box = c.querySelector('.bulk-cb'); if (box) box.innerHTML = on ? '✓' : '';
+      if (toolbar) { toolbar.style.display = selected.size > 0 ? 'flex' : 'none'; bulkCount.textContent = selected.size + ' selected'; }
+    }
+    function clearSelection(){
+      grid.querySelectorAll('.design-card.bulk-selected').forEach(function(c){ c.classList.remove('bulk-selected'); var box = c.querySelector('.bulk-cb'); if (box) box.innerHTML = ''; });
+      selected.clear(); if (toolbar) toolbar.style.display = 'none';
+    }
+    injectCheckboxes();
+    new MutationObserver(injectCheckboxes).observe(grid, { childList: true });
+
+    var rb = null, rbStartX = 0, rbStartY = 0, dragging = false;
+    grid.addEventListener('mousedown', function(e){
+      if (e.button !== 0) return;
+      if (e.target.closest('a, button, .bulk-cb, .fav-btn')) return;
+      dragging = true; rbStartX = e.pageX; rbStartY = e.pageY;
+      rb = document.createElement('div');
+      rb.style.cssText = 'position:absolute;background:rgba(218,165,32,.18);border:1.5px dashed #b48848;pointer-events:none;z-index:60;left:' + rbStartX + 'px;top:' + rbStartY + 'px;width:0;height:0';
+      document.body.appendChild(rb); e.preventDefault();
+    });
+    document.addEventListener('mousemove', function(e){
+      if (!dragging || !rb) return;
+      var x = Math.min(e.pageX, rbStartX), y = Math.min(e.pageY, rbStartY);
+      var w = Math.abs(e.pageX - rbStartX), h = Math.abs(e.pageY - rbStartY);
+      rb.style.left = x + 'px'; rb.style.top = y + 'px'; rb.style.width = w + 'px'; rb.style.height = h + 'px';
+    });
+    document.addEventListener('mouseup', function(){
+      if (!dragging) return; dragging = false;
+      if (!rb) return;
+      var rect = rb.getBoundingClientRect();
+      grid.querySelectorAll('.design-card').forEach(function(c){
+        if (c.style.display === 'none') return;
+        var cr = c.getBoundingClientRect();
+        var overlap = !(cr.right < rect.left || cr.left > rect.right || cr.bottom < rect.top || cr.top > rect.bottom);
+        if (overlap) toggleCard(c, true);
+      });
+      rb.remove(); rb = null;
+    });
+
+    if (toolbar) toolbar.addEventListener('click', function(e){
+      var btn = e.target.closest('[data-bulk]'); if (!btn) return;
+      var action = btn.getAttribute('data-bulk');
+      if (action === 'cancel') { clearSelection(); return; }
+      var ids = Array.from(selected); if (!ids.length) return;
+      var labels = { tag:'Tag', publish:'Publish', unpublish:'Unpublish', 'delete':'Delete' };
+      if (!confirm(labels[action] + ' ' + ids.length + ' design(s)?')) return;
+      btn.disabled = true; btn.style.opacity = '.6';
+      fetch('/api/designs/bulk-action', {
+        method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ ids: ids, action: action })
+      }).then(function(r){ return r.json(); }).then(function(j){
+        btn.disabled = false; btn.style.opacity = '1';
+        if (j && j.ok) {
+          alert(labels[action] + ' applied to ' + (j.affected || ids.length) + ' design(s).');
+          if (action === 'delete') ids.forEach(function(id){ var c = grid.querySelector('[data-design-id="' + id + '"]'); if (c) c.remove(); });
+          clearSelection();
+        } else { alert('Bulk action failed: ' + (j && j.error || 'unknown')); }
+      }).catch(function(err){ btn.disabled = false; btn.style.opacity = '1'; alert('Network error: ' + err.message); });
+    });
+
+    var st = document.createElement('style');
+    st.textContent = '.design-card.bulk-selected { outline: 3px solid #daa520; outline-offset: -3px; }';
+    document.head.appendChild(st);
+  })();
+  </script>
+
   <!-- Floating scroll-to-top button — appears after the user has scrolled past two viewport heights. -->
   <button type="button" id="scroll-top-btn" aria-label="Back to top of catalog" title="Back to top"
     style="position:fixed;right:18px;bottom:18px;z-index:80;width:42px;height:42px;border-radius:50%;border:1px solid var(--line,#e0d9cd);background:var(--bg,#fff);color:var(--ink,#111);box-shadow:0 4px 12px rgba(0,0,0,.12);cursor:pointer;display:none;align-items:center;justify-content:center;font:600 18px/1 var(--sans,system-ui);transition:opacity .18s ease,transform .12s ease">↑</button>

← 615bf37 wallco mural: viewport 20:11 + cover-fit init at zoom 1  ·  back to Wallco Ai  ·  wallco mural: update LEVELS[0].label to '20 ft × 11 ft' too 0c4e701 →