[object Object]

← back to Wallco Ai

design page: sticky admin toolbar (Unpublish / Smart-fix / Sell-on-Etsy-bucket) at top of /design/:id + POST /api/design/:id/unpublish toggle. Local only — wallco.ai deploy gated.

88d2c1faad92ce035fe2d02d1c9b701b9270db0e · 2026-05-28 09:53:20 -0700 · Steve Abrams

Files touched

Diff

commit 88d2c1faad92ce035fe2d02d1c9b701b9270db0e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 09:53:20 2026 -0700

    design page: sticky admin toolbar (Unpublish / Smart-fix / Sell-on-Etsy-bucket) at top of /design/:id + POST /api/design/:id/unpublish toggle. Local only — wallco.ai deploy gated.
---
 server.js | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 103 insertions(+)

diff --git a/server.js b/server.js
index 600d248..c3e30cb 100644
--- a/server.js
+++ b/server.js
@@ -10656,6 +10656,81 @@ app.get('/design/:id', (req, res) => {
 <body>
 ${htmlHeader('/designs')}
 <main class="detail-main">
+${_isAdmin ? `
+<!-- Admin quick-action toolbar — Steve 2026-05-28: surface unpublish/fix/etsy
+     prominently on the front-end instead of buried in the Distribute drawer.
+     Sticky so it stays in reach while scrolling. Admin-gated, never shown
+     to anonymous buyers. -->
+<div id="admin-toolbar" style="position:sticky;top:0;z-index:80;display:flex;gap:10px;align-items:center;padding:10px 14px;background:#1a1816;border-bottom:1px solid #3a2f1c;color:#f0e6cc;font:13px var(--sans,system-ui);box-shadow:0 2px 6px rgba(0,0,0,.18)">
+  <span style="font:600 10px ui-sans-serif,system-ui;letter-spacing:.18em;text-transform:uppercase;color:#c98a00">Admin</span>
+  <code style="font:11px ui-monospace,Menlo,monospace;color:#a08b6a">#${design.id}</code>
+  <span id="atb-state" style="font:11px ui-sans-serif,system-ui;color:${design.is_published ? '#7ad48a' : '#d8a060'};letter-spacing:.04em">${design.is_published ? '● live' : '○ unpublished'}</span>
+  <div style="flex:1"></div>
+  <button type="button" id="atb-unpub"  data-id="${design.id}" data-state="${design.is_published ? 'live' : 'down'}"
+          style="padding:7px 14px;border:1px solid #d8a060;border-radius:5px;background:transparent;color:#d8a060;font:600 12px var(--sans);cursor:pointer;letter-spacing:.04em">
+    ${design.is_published ? '🔻 Unpublish' : '🔼 Republish'}
+  </button>
+  <button type="button" id="atb-fix"    data-id="${design.id}"
+          style="padding:7px 14px;border:1px solid #c98a00;border-radius:5px;background:transparent;color:#c98a00;font:600 12px var(--sans);cursor:pointer;letter-spacing:.04em">
+    🔧 Smart-fix
+  </button>
+  <button type="button" id="atb-etsy"   data-id="${design.id}"
+          style="padding:7px 14px;border:1px solid #6c8a4c;border-radius:5px;background:transparent;color:#9bc46b;font:600 12px var(--sans);cursor:pointer;letter-spacing:.04em"
+          title="Add to wallco_etsy_bucket with eligibility check + DPI/upscale plan. Does NOT call the Etsy API.">
+    🛒 Sell on Etsy
+  </button>
+  <span id="atb-status" style="margin-left:6px;font:11px ui-sans-serif,system-ui;color:#a08b6a;min-width:160px"></span>
+</div>
+<script>
+(function(){
+  var stat = document.getElementById('atb-status');
+  function setStatus(msg, cls){ stat.textContent = msg; stat.style.color = cls === 'err' ? '#e08070' : cls === 'ok' ? '#9bc46b' : '#a08b6a'; }
+  function onClick(btnId, handler){
+    var b = document.getElementById(btnId); if (!b) return;
+    b.addEventListener('click', async function(){
+      b.disabled = true; var orig = b.style.opacity; b.style.opacity = '0.5';
+      try { await handler(b); } catch(e){ setStatus('error: '+e.message, 'err'); }
+      finally { b.disabled = false; b.style.opacity = orig || '1'; }
+    });
+  }
+  onClick('atb-unpub', async function(b){
+    var live = b.dataset.state === 'live';
+    if (!confirm(live ? 'Unpublish #' + b.dataset.id + ' from the live storefront?' : 'Republish #' + b.dataset.id + ' to the live storefront?')) return;
+    setStatus(live ? 'Unpublishing…' : 'Republishing…');
+    var r = await fetch('/api/design/' + b.dataset.id + '/unpublish', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ unpublish: live }) });
+    var j = await r.json();
+    if (r.ok && j.ok) {
+      setStatus((j.is_published ? '● live' : '○ unpublished') + ' (saved)', 'ok');
+      // Update toolbar state without a full reload
+      document.getElementById('atb-state').textContent = j.is_published ? '● live' : '○ unpublished';
+      document.getElementById('atb-state').style.color = j.is_published ? '#7ad48a' : '#d8a060';
+      b.textContent = j.is_published ? '🔻 Unpublish' : '🔼 Republish';
+      b.dataset.state = j.is_published ? 'live' : 'down';
+    } else setStatus('failed: ' + (j.error || r.status), 'err');
+  });
+  onClick('atb-fix', async function(b){
+    if (!confirm('Run smart-fix on #' + b.dataset.id + '? Generates a corrected child design — original stays put.')) return;
+    setStatus('Smart-fix running… (~30–60s)');
+    var r = await fetch('/api/design/' + b.dataset.id + '/smart-fix', { method:'POST', headers:{'Content-Type':'application/json'}, body: '{}' });
+    var j = await r.json();
+    if (r.ok && j.ok) setStatus('✓ smart-fix → #' + (j.new_id || j.id || '?') + (j.kind ? ' (' + j.kind + ')' : ''), 'ok');
+    else setStatus('smart-fix failed: ' + (j.error || j.reason || r.status), 'err');
+  });
+  onClick('atb-etsy', async function(b){
+    if (!confirm('Queue #' + b.dataset.id + ' in wallco_etsy_bucket (eligibility-checked, upscaled to 300 DPI)? Does NOT create a live Etsy listing.')) return;
+    setStatus('Queueing for Etsy bucket…');
+    var r = await fetch('/api/etsy-bucket/add', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ id: parseInt(b.dataset.id, 10) }) });
+    var j = await r.json();
+    if (r.ok && j.ok) {
+      var first = (j.results && j.results[0]) || {};
+      if (first.ok && first.already_in) setStatus('already in bucket (' + first.already_in + ')', 'ok');
+      else if (first.ok) setStatus('✓ queued · ' + first.target_dpi + ' DPI · ' + first.upscale_px + 'px upscale', 'ok');
+      else setStatus('skipped: ' + (first.reason || 'not eligible'), 'err');
+    } else setStatus('etsy queue failed: ' + (j.error || r.status), 'err');
+  });
+})();
+</script>
+` : ''}
 <style>
   /* Collapse-pane — tappable header row + chevron, closed by default.
      Wraps non-essential panels (specs / calculator / coordinates / room
@@ -18342,6 +18417,34 @@ app.post('/api/spoonflower/upload', requireAdmin, (req, res) => {
   res.json({ ok: true, job_id: jobId, dry, log_url: `/api/spoonflower/status/${jobId}` });
 });
 
+// ── POST /api/design/:id/unpublish — admin toggle for is_published.
+//   body: { unpublish: true }   → set is_published=FALSE
+//   body: { unpublish: false }  → set is_published=TRUE (republish)
+//   body: {}                    → just flip whatever the current state is
+// Used by the front-end admin toolbar. Does NOT delete or quarantine the row;
+// re-pub is fully reversible. Hot-reloads the in-memory DESIGNS entry so the
+// next render reflects the new state without a full server restart.
+app.post('/api/design/:id/unpublish', requireAdmin, express.json({ limit: '1kb' }), (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  try {
+    const cur = psqlQuery(`SELECT is_published FROM all_designs WHERE id=${id};`);
+    if (!cur) return res.status(404).json({ error: 'design not found' });
+    const isLive = String(cur).trim() === 't';
+    const targetUnpub = (typeof req.body?.unpublish === 'boolean') ? req.body.unpublish : isLive;
+    const next = targetUnpub ? false : true;
+    psqlExecLocal(`UPDATE all_designs SET is_published=${next ? 'TRUE' : 'FALSE'}, updated_at=NOW() WHERE id=${id};`);
+    // Bump in-memory snapshot too so subsequent renders agree
+    try {
+      const d = DESIGNS.find(x => x.id === id);
+      if (d) d.is_published = next;
+    } catch {}
+    res.json({ ok: true, id, is_published: next });
+  } catch (e) {
+    res.status(500).json({ error: String(e.message || e).slice(0, 200) });
+  }
+});
+
 // ── POST /api/design/:id/etsy-tag — mark a design for Etsy digital-file resale
 //
 // Admin-only. Tags the row with 'etsy-digital-file' and appends to data/etsy-queue.jsonl

← 54a2c78 trade-login: wire PureLyMail SMTP (smtp.purelymail.com:465 S  ·  back to Wallco Ai  ·  unpublish endpoint: drop updated_at column (doesn't exist on 696e739 →