← 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
M public/admin/edges-review.htmlM server.js
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>
@@ -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 →