[object Object]

← back to Wallco Ai

edges-review: multi-select + bulk Reject/Delete bar (prod-gated, disabled with notice off-localhost); fix /api/fix-decision/bulk route shadowed by /:newId

7e26aabb48623467810d02ad49644d3cbc0100df · 2026-05-27 08:58:39 -0700 · Steve Abrams

Files touched

Diff

commit 7e26aabb48623467810d02ad49644d3cbc0100df
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 27 08:58:39 2026 -0700

    edges-review: multi-select + bulk Reject/Delete bar (prod-gated, disabled with notice off-localhost); fix /api/fix-decision/bulk route shadowed by /:newId
---
 public/admin/edges-review.html | 135 +++++++++++++++++++++++++++++++++++++++--
 server.js                      |  12 +++-
 2 files changed, 141 insertions(+), 6 deletions(-)

diff --git a/public/admin/edges-review.html b/public/admin/edges-review.html
index 7b12095..0dfa73f 100644
--- a/public/admin/edges-review.html
+++ b/public/admin/edges-review.html
@@ -71,7 +71,7 @@
   .tile:hover { transform:translateY(-2px); box-shadow:0 6px 16px rgba(0,0,0,.18); }
 
   .badge {
-    position:absolute; top:6px; left:6px;
+    position:absolute; top:6px; left:34px;
     background:var(--red); color:#fff;
     font:700 9px var(--sans); letter-spacing:.16em; text-transform:uppercase;
     padding:3px 7px; border-radius:3px;
@@ -140,6 +140,46 @@
     padding:2px 6px; border-radius:3px;
     white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
   }
+
+  /* Multi-select */
+  .tile .sel {
+    position:absolute; top:6px; left:6px; z-index:3;
+    width:22px; height:22px; margin:0; cursor:pointer;
+    accent-color:var(--gold);
+  }
+  .tile.selected { outline-width:4px; box-shadow:0 0 0 3px var(--gold) inset; }
+  .tile.selected::after {
+    content:'✓'; position:absolute; inset:0; z-index:1;
+    display:flex; align-items:center; justify-content:center;
+    font:700 48px var(--sans); color:rgba(201,161,75,.85);
+    text-shadow:0 2px 8px rgba(0,0,0,.5); pointer-events:none;
+  }
+  .thumb { display:block; width:100%; height:100%; }
+
+  .ctl-btn {
+    padding:5px 11px; border:1px solid var(--line); border-radius:4px;
+    background:#fff; color:var(--ink); font:600 11.5px var(--sans); cursor:pointer;
+  }
+  .ctl-btn:hover { border-color:var(--gold); }
+
+  /* Bulk action bar */
+  .bulkbar {
+    position:fixed; left:50%; transform:translateX(-50%); bottom:18px; z-index:20;
+    display:flex; gap:10px; align-items:center;
+    background:var(--ink); color:#fff; padding:10px 16px; border-radius:10px;
+    box-shadow:0 8px 28px rgba(0,0,0,.35); font:13px var(--sans);
+  }
+  .bulkbar[hidden] { display:none; }
+  .bulkbar b { color:var(--gold); }
+  .bulkbar button {
+    padding:7px 13px; border:0; border-radius:6px; cursor:pointer;
+    font:600 12px var(--sans);
+  }
+  .bulkbar .b-reject { background:#3a3322; color:#f0e6cf; }
+  .bulkbar .b-delete { background:var(--red); color:#fff; }
+  .bulkbar .b-clear  { background:transparent; color:#bbb; border:1px solid #555; }
+  .bulkbar button:disabled { opacity:.4; cursor:not-allowed; }
+  .bulkbar .prod-note { color:#ffd68a; font:600 11.5px var(--sans); }
 </style>
 </head>
 <body>
@@ -184,6 +224,8 @@
     <input id="density" type="range" min="120" max="380" step="20" value="200">
     <span class="density-val" id="density-val">200</span>
 
+    <button class="ctl-btn" id="select-page" title="Select every card on this page">☑ Select page</button>
+
     <div class="legend">
       <span><span class="sw mids"></span>mids-only</span>
       &nbsp;
@@ -199,6 +241,14 @@
   <div id="pager" class="pager"></div>
 </main>
 
+<div class="bulkbar" id="bulkbar" hidden>
+  <span><b id="sel-count">0</b> selected</span>
+  <button class="b-reject" id="b-reject">Reject (keep unpublished)</button>
+  <button class="b-delete" id="b-delete">Delete (quarantine)</button>
+  <button class="b-clear" id="b-clear">Clear</button>
+  <span class="prod-note" id="prod-note" hidden>⚠ read-only on prod — curate on localhost:9905</span>
+</div>
+
 <script>
 (function(){
   const PER = 200;
@@ -224,6 +274,8 @@
     return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
   }
 
+  const selected = new Set();  // selected design ids (survives re-render/pagination)
+
   function classifyGrid(item) {
     if (!item.ok) return 'err';
     const e = item.edges_max || 0, m = item.mids_max || 0;
@@ -292,16 +344,20 @@
         const reason = item.reason || (item.ok === false ? (item.error || 'scan error') : 'edges_fail');
         const when = fmtDate(item.created_at);
         const whenChip = when ? `<span class="when" title="${item.created_at}">🕓 ${when}</span>` : '';
+        const isSel = selected.has(item.id);
         return `
-          <a class="tile grid-${g}" href="/design/${item.id}" target="_blank" rel="noopener" title="${reason}">
-            <img src="/designs/img/by-id/${item.id}" loading="lazy" alt="">
+          <div class="tile grid-${g}${isSel ? ' selected' : ''}" data-id="${item.id}" title="${reason}">
+            <input type="checkbox" class="sel" data-id="${item.id}"${isSel ? ' checked' : ''} aria-label="select #${item.id}">
+            <a class="thumb" href="/design/${item.id}" target="_blank" rel="noopener">
+              <img src="/designs/img/by-id/${item.id}" loading="lazy" alt="">
+            </a>
             <span class="badge">${badge}</span>
             <span class="id-tag">#${item.id}</span>
             ${whenChip}
             <div class="tooltip">
               ${lensRows || `<div class="row"><span class="k">error</span><span class="v fail">${item.error || 'unknown'}</span></div>`}
             </div>
-          </a>`;
+          </div>`;
       }).join('');
     }
 
@@ -351,6 +407,76 @@
     applyDensity(state.density);
   });
 
+  // ── Multi-select + bulk actions ──
+  // Actions mutate the catalog on whatever host runs the server. The flagged
+  // rows + PNGs live on Mac2, so on prod (wallco.ai) they 404/no-op — gate
+  // destructive buttons off there and show a notice.
+  const IS_PROD = !/^(localhost|127\.0\.0\.1|::1|\[::1\])$/.test(location.hostname);
+  const bulkbar = document.getElementById('bulkbar');
+
+  function updateBulkBar() {
+    document.getElementById('sel-count').textContent = selected.size;
+    bulkbar.hidden = selected.size === 0;
+  }
+  function toggleSelect(id, on) {
+    id = parseInt(id, 10);
+    if (on) selected.add(id); else selected.delete(id);
+    const tile = document.querySelector('.tile[data-id="' + id + '"]');
+    if (tile) tile.classList.toggle('selected', on);
+    updateBulkBar();
+  }
+  document.getElementById('grid').addEventListener('change', e => {
+    if (e.target.classList && e.target.classList.contains('sel'))
+      toggleSelect(e.target.dataset.id, e.target.checked);
+  });
+  document.getElementById('select-page').addEventListener('click', () => {
+    document.querySelectorAll('#grid .sel').forEach(cb => { cb.checked = true; toggleSelect(cb.dataset.id, true); });
+  });
+  document.getElementById('b-clear').addEventListener('click', () => {
+    selected.clear();
+    document.querySelectorAll('#grid .sel:checked').forEach(cb => cb.checked = false);
+    document.querySelectorAll('#grid .tile.selected').forEach(t => t.classList.remove('selected'));
+    updateBulkBar();
+  });
+
+  function gateProd() {
+    if (!IS_PROD) return;
+    document.getElementById('b-reject').disabled = true;
+    document.getElementById('b-delete').disabled = true;
+    document.getElementById('prod-note').hidden = false;
+  }
+  gateProd();
+
+  function bulkAction(verdict, label) {
+    if (IS_PROD) return;
+    const ids = [...selected];
+    if (!ids.length) return;
+    if (!confirm(`${label} ${ids.length} design(s)?\nRuns against the LOCAL catalog (then redeploy the snapshot).`)) return;
+    const btns = bulkbar.querySelectorAll('button');
+    btns.forEach(b => b.disabled = true);
+    fetch('/api/fix-decision/bulk', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ ids, verdict })
+    })
+      .then(r => r.json())
+      .then(j => {
+        if (!j.ok) { alert('Bulk ' + verdict + ' failed: ' + (j.error || 'unknown')); return; }
+        const gone = new Set(ids);
+        state.cache[state.source] = (state.cache[state.source] || []).filter(it => !gone.has(it.id));
+        state.items = state.items.filter(it => !gone.has(it.id));
+        const n = document.getElementById('n-' + state.source);
+        if (n) n.textContent = state.cache[state.source].length;
+        selected.clear();
+        render();
+        updateBulkBar();
+        alert(`${label}: ${j.applied != null ? j.applied : ids.length} done` + (j.errored ? `, ${j.errored} errored` : ''));
+      })
+      .catch(err => alert('Network error: ' + err.message))
+      .finally(() => { btns.forEach(b => b.disabled = false); gateProd(); });
+  }
+  document.getElementById('b-reject').addEventListener('click', () => bulkAction('reject', 'Reject (keep unpublished)'));
+  document.getElementById('b-delete').addEventListener('click', () => bulkAction('delete', 'Delete (quarantine)'));
+
   // ── Source tabs (Live DB vs shipped Snapshot) ──
   function setActiveTab() {
     document.querySelectorAll('#tabs button').forEach(b =>
@@ -362,6 +488,7 @@
     localStorage.setItem('edges-review-source', src);
     setActiveTab();
     state.page = 1;
+    selected.clear(); updateBulkBar();
     if (!force && state.cache[src]) { state.items = state.cache[src]; render(); return; }
     const grid = document.getElementById('grid');
     grid.innerHTML = '<div class="loading">loading flagged designs…</div>';
diff --git a/server.js b/server.js
index a6ef2cc..1256c9d 100644
--- a/server.js
+++ b/server.js
@@ -881,6 +881,8 @@ app.get('/api/admin/inspirations/list', (req, res) => {
         LEFT JOIN shopify_color_enrichment sce
           ON sce.shopify_id = sp.shopify_id
         WHERE d.inspired_by_sku IS NOT NULL
+          -- exclude seam-healer derivatives (blurred seam line / fuzzy edges)
+          AND d.local_path NOT LIKE '%midheal_%' AND d.local_path NOT LIKE '%edgeheal_%' AND d.local_path NOT LIKE '%smartfix_%'
         ORDER BY d.id DESC
         LIMIT ${limit} OFFSET ${offset}
       ) t;
@@ -947,7 +949,11 @@ app.get('/new', (req, res) => {
 // accept: no-op (already published).
 // reject: unpublish the result and republish the parent (rollback).
 // Logs to data/fix-decisions.jsonl so audit shows what survived curation.
-app.post('/api/fix-decision/:newId', express.json({ limit: '2kb' }), (req, res) => {
+app.post('/api/fix-decision/:newId', express.json({ limit: '2kb' }), (req, res, next) => {
+  // '/api/fix-decision/bulk' shares this prefix and is registered after this
+  // route — without this guard Express matches 'bulk' as :newId (→ NaN → 400)
+  // and the bulk route is never reached. Fall through for any non-numeric id.
+  if (!/^[0-9]+$/.test(String(req.params.newId))) return next();
   if (!adminRatingGate(req, res)) return;
   const newId = parseInt(req.params.newId, 10);
   if (!Number.isFinite(newId) || newId < 1) return res.status(400).json({ error: 'bad id' });
@@ -1092,7 +1098,9 @@ app.get('/api/admin/dogs/list', (req, res) => {
       "SELECT COALESCE(json_agg(t ORDER BY t.category, t.id), '[]'::json) FROM (" +
         "SELECT id, category, dominant_hex, is_published, " +
         "COALESCE(split_part(notes,'DOGS_EDGES:',2),'') AS edges " +
-        "FROM spoon_all_designs WHERE category LIKE 'dogs · %'" +
+        "FROM spoon_all_designs WHERE category LIKE 'dogs · %' " +
+        // exclude seam-healer derivatives (blurred seam line / fuzzy edges)
+        "AND local_path NOT LIKE '%midheal_%' AND local_path NOT LIKE '%edgeheal_%' AND local_path NOT LIKE '%smartfix_%'" +
       ") t;"
     );
     res.json({ ok: true, items: JSON.parse(rawJson || '[]') });

← 6e28424 cactus-curator: exclude midheal_/edgeheal_/smartfix_ derivat  ·  back to Wallco Ai  ·  cactus curator: click any card to view full details modal 83da7a0 →