[object Object]

← back to 4square Admin

feat: source-context header bar + CSV export of selected rows

05980583f70b611165e39c8d6e13d8777f5c6e32 · 2026-05-14 09:39:02 -0700 · Steve

- new sub-header below controls shows: source id, editable/read-only pill, license note (italic gold) for archives
- new POST /api/export → kind-specific column projection (wallco: dig/category/pub/hex/tags/prompt; shopify: shopify_id/handle/title/vendor/sku/status/price/tags; patternbank: pattern_id/slug/designer/category/colors_hex; vintage: handle/title/vendor/era), proper RFC-4180 escaping, attachment filename
- new '⤓ CSV' button in controls bar, enabled while selection > 0 across ALL source kinds (including read-only ones — export is non-destructive)
- frontend exposes license_note from API into source-bar

Files touched

Diff

commit 05980583f70b611165e39c8d6e13d8777f5c6e32
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 14 09:39:02 2026 -0700

    feat: source-context header bar + CSV export of selected rows
    
    - new sub-header below controls shows: source id, editable/read-only pill, license note (italic gold) for archives
    - new POST /api/export → kind-specific column projection (wallco: dig/category/pub/hex/tags/prompt; shopify: shopify_id/handle/title/vendor/sku/status/price/tags; patternbank: pattern_id/slug/designer/category/colors_hex; vintage: handle/title/vendor/era), proper RFC-4180 escaping, attachment filename
    - new '⤓ CSV' button in controls bar, enabled while selection > 0 across ALL source kinds (including read-only ones — export is non-destructive)
    - frontend exposes license_note from API into source-bar
---
 index.html | 37 +++++++++++++++++++++++++++++++++++--
 server.js  | 46 ++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 81 insertions(+), 2 deletions(-)

diff --git a/index.html b/index.html
index 42ddfa1..0a6e5c1 100644
--- a/index.html
+++ b/index.html
@@ -74,6 +74,13 @@
   .controls .recolor { color:var(--gold); border-color:#3a2a16 }
   .controls .small { padding:4px 10px; font-size:11px }
 
+  .source-bar {
+    padding:10px 22px; border-bottom:1px solid var(--line); background:#0a0908;
+    font-size:11px; color:#888; display:flex; align-items:center; gap:12px;
+  }
+  .source-bar .lic { color:#d2b15c; font-style:italic }
+  .source-bar .pill { padding:2px 9px; border-radius:11px; border:1px solid var(--line); color:#bba; font-size:10.5px }
+  .source-bar .pill.ro { color:#e08070; border-color:#4a2a26 }
   .grid-wrap { overflow-y:auto; padding:18px 22px 80px; flex:1 }
   .grid { display:grid; grid-template-columns: repeat(var(--cols), 1fr); gap:10px }
   .card {
@@ -190,7 +197,11 @@
         <select id="recolor-target"><option value="">choose…</option></select>
         <button class="recolor" id="b-rec" onclick="bulk('recolor')" disabled>⟳ Regen</button>
       </div>
+      <div class="group">
+        <button class="small" id="b-csv" onclick="exportCSV()" disabled title="Export selected rows as CSV">⤓ CSV</button>
+      </div>
     </div>
+    <div class="source-bar" id="src-bar"></div>
     <div class="grid-wrap" id="grid-wrap">
       <div class="empty-state"><span class="spinner"></span>loading…</div>
     </div>
@@ -316,6 +327,7 @@ async function loadProducts({ append = false } = {}) {
   CURRENT.kind = j.kind;
   CURRENT.rows = append ? CURRENT.rows.concat(j.rows) : j.rows;
   CURRENT.total = j.total; CURRENT.spec = j.spec; CURRENT.readonly = !!j.readonly;
+  CURRENT.license_note = j.license_note || '';
   CURRENT.has_more = !!j.has_more;
   CURRENT.offset = (j.offset || 0) + j.rows.length;
   populateSortOptions(j.kind);
@@ -343,10 +355,31 @@ function populateRecolor(spec) {
 function renderControls() {
   const showing = CURRENT.rows.length;
   $('#stat').textContent = `${showing.toLocaleString()} of ${CURRENT.total.toLocaleString()}`;
-  // Disable bulk actions for read-only sources
   document.querySelectorAll('#bulk-actions button').forEach(b => b.disabled = CURRENT.readonly || selected.size === 0);
   $('#bulk-actions').style.opacity = CURRENT.readonly ? .35 : 1;
-  $('#bulk-actions').title = CURRENT.readonly ? 'Read-only source (Shopify)' : '';
+  $('#bulk-actions').title = CURRENT.readonly ? 'Read-only source' : '';
+  $('#b-csv').disabled = selected.size === 0;
+
+  // Source bar
+  const bar = $('#src-bar');
+  const lic = CURRENT.license_note ? `<span class="lic">${esc(CURRENT.license_note)}</span>` : '';
+  const ro  = CURRENT.readonly ? '<span class="pill ro">read-only</span>' : '<span class="pill" style="color:#7dd87d;border-color:#2a4a2a">editable</span>';
+  bar.innerHTML = `<strong style="color:var(--gold);font-weight:500">${esc(CURRENT.id)}</strong> ${ro} · ${showing.toLocaleString()} of ${CURRENT.total.toLocaleString()} ${lic}`;
+}
+
+async function exportCSV() {
+  if (!selected.size) return;
+  const r = await fetch('/api/export', {
+    method:'POST', headers:{'content-type':'application/json'},
+    body: JSON.stringify({ kind: CURRENT.kind, ids: Array.from(selected) })
+  });
+  if (!r.ok) { toast('CSV export failed', 'err'); return; }
+  const blob = await r.blob();
+  const a = document.createElement('a');
+  a.href = URL.createObjectURL(blob);
+  a.download = `4square-${CURRENT.kind}-${Date.now()}.csv`;
+  document.body.appendChild(a); a.click(); document.body.removeChild(a);
+  toast(`Exported ${selected.size} row(s) as CSV`, 'ok');
 }
 
 function renderGrid() {
diff --git a/server.js b/server.js
index 3b10317..7c988f1 100644
--- a/server.js
+++ b/server.js
@@ -252,6 +252,52 @@ app.get('/api/products', async (req, res) => {
   }
 });
 
+// ── /api/export?source=…&ids=…  CSV export of selected rows ─────────
+app.post('/api/export', async (req, res) => {
+  const kind = String(req.body?.kind || 'wallco');
+  const ids  = (req.body?.ids || []).map(x => kind === 'patternbank' ? String(x) : parseInt(x,10)).filter(Boolean);
+  if (!ids.length) return res.status(400).json({ ok:false, error:'ids required' });
+  try {
+    let rows = [], cols = [];
+    if (kind === 'wallco') {
+      const r = await pool.query(
+        `SELECT id, dig_number, category, is_published, dominant_hex, tags::text AS tags, prompt, created_at
+           FROM spoon_all_designs WHERE id=ANY($1)`, [ids]);
+      rows = r.rows; cols = ['id','dig_number','category','is_published','dominant_hex','tags','prompt','created_at'];
+    } else if (kind === 'shopify') {
+      const r = await pool.query(
+        `SELECT id, shopify_id, handle, title, vendor, dw_sku, sku, status, price, tags, created_at_shopify
+           FROM shopify_products WHERE id=ANY($1)`, [ids]);
+      rows = r.rows; cols = ['id','shopify_id','handle','title','vendor','dw_sku','sku','status','price','tags','created_at_shopify'];
+    } else if (kind === 'patternbank') {
+      const r = await poolPB.query(
+        `SELECT pattern_id AS id, slug, title, designer_display_name AS designer, category, source_url, tags::text AS tags, colors_hex::text AS colors_hex
+           FROM patterns WHERE pattern_id=ANY($1)`, [ids]);
+      rows = r.rows; cols = ['id','slug','title','designer','category','source_url','tags','colors_hex'];
+    } else if (kind === 'vintage') {
+      const r = await poolVW.query(
+        `SELECT shopify_product_id AS id, handle, title, vendor, era_decade, era_year, source_url, tags::text AS tags
+           FROM products WHERE shopify_product_id=ANY($1)`, [ids]);
+      rows = r.rows; cols = ['id','handle','title','vendor','era_decade','era_year','source_url','tags'];
+    } else {
+      return res.status(400).json({ ok:false, error:'unknown kind' });
+    }
+    // Build CSV
+    const esc = (v) => {
+      if (v === null || v === undefined) return '';
+      const s = String(v).replace(/"/g, '""');
+      return /[",\n\r]/.test(s) ? `"${s}"` : s;
+    };
+    const lines = [cols.join(',')].concat(rows.map(r => cols.map(c => esc(r[c])).join(',')));
+    const csv = lines.join('\n') + '\n';
+    res.type('text/csv; charset=utf-8');
+    res.setHeader('Content-Disposition', `attachment; filename="4square-${kind}-${Date.now()}.csv"`);
+    res.send(csv);
+  } catch (e) {
+    res.status(500).json({ ok:false, error: e.message });
+  }
+});
+
 // ── /api/product/:id?kind=… — full single-row detail for modal ──────
 app.get('/api/product/:id', async (req, res) => {
   const id = parseInt(req.params.id, 10);

← 724d1f8 feat: patternbank + vintage-wallpaper archive sources  ·  back to 4square Admin  ·  feat: 12-chip color filter strip (right-aligned in source ba 198d58c →