← 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 →