[object Object]

← back to 4square Admin

feat: bulk tag editor for wallco rows

b358ef8bad982cfc66cd921a7d8118cd3b601a0b · 2026-05-14 09:42:17 -0700 · Steve

- POST /api/bulk/tag {ids, add:[], remove:[]}
  - 'add' is idempotent (appends only if not already in tags array; per-tag scan via CASE WHEN $2 = ANY(tags))
  - 'remove' uses array_remove for each tag in turn
  - returns counts of added/removed/rows-affected
- new '+ Tag' button next to '⤓ CSV', enabled when selection > 0 AND source is editable (wallco only)
- frontend uses two prompt() inputs for add/remove comma-lists (low-friction, no modal); blank skips
- smoke-tested with 4 rows, verified add + remove round-trip cleanly

Files touched

Diff

commit b358ef8bad982cfc66cd921a7d8118cd3b601a0b
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 14 09:42:17 2026 -0700

    feat: bulk tag editor for wallco rows
    
    - POST /api/bulk/tag {ids, add:[], remove:[]}
      - 'add' is idempotent (appends only if not already in tags array; per-tag scan via CASE WHEN $2 = ANY(tags))
      - 'remove' uses array_remove for each tag in turn
      - returns counts of added/removed/rows-affected
    - new '+ Tag' button next to '⤓ CSV', enabled when selection > 0 AND source is editable (wallco only)
    - frontend uses two prompt() inputs for add/remove comma-lists (low-friction, no modal); blank skips
    - smoke-tested with 4 rows, verified add + remove round-trip cleanly
---
 index.html | 22 ++++++++++++++++++++++
 server.js  | 27 +++++++++++++++++++++++++++
 2 files changed, 49 insertions(+)

diff --git a/index.html b/index.html
index 7d2703c..3f56ee4 100644
--- a/index.html
+++ b/index.html
@@ -207,6 +207,7 @@
         <button class="recolor" id="b-rec" onclick="bulk('recolor')" disabled>⟳ Regen</button>
       </div>
       <div class="group">
+        <button class="small" id="b-tag" onclick="openTagEditor()" disabled title="Bulk add/remove tags">+ Tag</button>
         <button class="small" id="b-csv" onclick="exportCSV()" disabled title="Export selected rows as CSV">⤓ CSV</button>
       </div>
     </div>
@@ -431,6 +432,7 @@ function renderControls() {
   $('#bulk-actions').style.opacity = CURRENT.readonly ? .35 : 1;
   $('#bulk-actions').title = CURRENT.readonly ? 'Read-only source' : '';
   $('#b-csv').disabled = selected.size === 0;
+  $('#b-tag').disabled = selected.size === 0 || CURRENT.readonly;
 
   // Source bar
   const bar = $('#src-bar');
@@ -456,6 +458,26 @@ function toggleColor(c) {
   renderControls();
 }
 
+async function openTagEditor() {
+  if (!selected.size || CURRENT.readonly) return;
+  const add = prompt(`Add tags to ${selected.size} selected wallco row(s) — comma-separated (or leave blank to skip):`, '');
+  if (add === null) return;
+  const remove = prompt(`Remove tags from ${selected.size} selected row(s) — comma-separated (or leave blank to skip):`, '');
+  if (remove === null) return;
+  const addList    = add.split(',').map(s=>s.trim()).filter(Boolean);
+  const removeList = remove.split(',').map(s=>s.trim()).filter(Boolean);
+  if (!addList.length && !removeList.length) return;
+  try {
+    const j = await fetch('/api/bulk/tag', {
+      method:'POST', headers:{'content-type':'application/json'},
+      body: JSON.stringify({ ids: Array.from(selected), add: addList, remove: removeList })
+    }).then(r => r.json());
+    if (!j.ok) { toast('Tag op failed: '+j.error, 'err'); return; }
+    toast(`Tagged ${j.ids} row(s): +[${addList.join(',')}] -[${removeList.join(',')}]`, 'ok');
+    await loadProducts();
+  } catch (e) { toast('Tag request failed: '+e.message, 'err'); }
+}
+
 async function exportCSV() {
   if (!selected.size) return;
   const r = await fetch('/api/export', {
diff --git a/server.js b/server.js
index 7c988f1..969a923 100644
--- a/server.js
+++ b/server.js
@@ -403,6 +403,33 @@ app.post('/api/bulk/delete', async (req, res) => {
   const d = await pool.query(`DELETE FROM spoon_all_designs WHERE id=ANY($1) RETURNING id`, [ids]);
   res.json({ ok:true, action:'delete', deleted: d.rows.length, files_moved: moved.length, trash_dir: TRASH });
 });
+app.post('/api/bulk/tag', async (req, res) => {
+  const ids = parseIds(req, res); if (!ids) return;
+  const add    = (req.body?.add    || []).map(s => String(s).trim()).filter(Boolean);
+  const remove = (req.body?.remove || []).map(s => String(s).trim()).filter(Boolean);
+  if (!add.length && !remove.length) return res.status(400).json({ ok:false, error:'add or remove required' });
+  let changed = 0;
+  try {
+    if (add.length) {
+      // Append-only-if-not-present per row
+      for (const t of add) {
+        const r = await pool.query(
+          `UPDATE spoon_all_designs SET tags = CASE WHEN $2 = ANY(tags) THEN tags ELSE COALESCE(tags,'{}') || $2 END
+            WHERE id=ANY($1) RETURNING id`, [ids, t]);
+        changed += r.rows.length;
+      }
+    }
+    if (remove.length) {
+      for (const t of remove) {
+        await pool.query(`UPDATE spoon_all_designs SET tags = array_remove(tags, $2) WHERE id=ANY($1)`, [ids, t]);
+      }
+    }
+    res.json({ ok:true, action:'tag', ids: ids.length, added: add, removed: remove });
+  } catch (e) {
+    res.status(500).json({ ok:false, error:e.message });
+  }
+});
+
 app.post('/api/bulk/recolor', async (req, res) => {
   const ids = parseIds(req, res); if (!ids) return;
   const target = String(req.body?.target_color || '').trim();

← 198d58c feat: 12-chip color filter strip (right-aligned in source ba  ·  back to 4square Admin  ·  fix: revoke CSV blob object URL after download to prevent me d04b35d →