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