← back to 4square Admin
initial scaffold: 4Square admin product browser (loopback :9906)
4f1ef4e2ecd60e2eb635cbc7b138390cf7db0b82 · 2026-05-14 09:28:35 -0700 · Steve
- left-rail site picker: wallco.ai (32 categories) + DW Shopify (38 vendor/status slices)
- right-pane grid with sort + density slider per Steve's standing rule
- fliepaper-bugs 6x6 matrix mode + flat-grid for all other categories
- bulk publish/unpublish/delete/recolor on wallco rows; Shopify is read-only
- localhost-only bind, no auth (loopback == admin per wallco admin-gate)
Files touched
A .gitignoreA index.htmlA package.jsonA server.js
Diff
commit 4f1ef4e2ecd60e2eb635cbc7b138390cf7db0b82
Author: Steve <steve@designerwallcoverings.com>
Date: Thu May 14 09:28:35 2026 -0700
initial scaffold: 4Square admin product browser (loopback :9906)
- left-rail site picker: wallco.ai (32 categories) + DW Shopify (38 vendor/status slices)
- right-pane grid with sort + density slider per Steve's standing rule
- fliepaper-bugs 6x6 matrix mode + flat-grid for all other categories
- bulk publish/unpublish/delete/recolor on wallco rows; Shopify is read-only
- localhost-only bind, no auth (loopback == admin per wallco admin-gate)
---
.gitignore | 8 ++
index.html | 429 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 8 ++
server.js | 242 +++++++++++++++++++++++++++++++++
4 files changed, 687 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..5c86d3d
--- /dev/null
+++ b/index.html
@@ -0,0 +1,429 @@
+<!doctype html>
+<html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>4Square — admin product browser</title>
+<style>
+ :root {
+ --ink:#e8e2d6; --bg:#0f0e0c; --gold:#d2b15c; --line:#2a2825;
+ --pub:#3aa83a; --danger:#c45c4a; --rail:240px;
+ --cols: 6;
+ }
+ * { box-sizing:border-box }
+ html, body { margin:0; height:100%; background:var(--bg); color:var(--ink); font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; overflow:hidden }
+ a { color:inherit; text-decoration:none }
+
+ /* ── top bar ─────────────────────────────────────────────────────── */
+ header.top {
+ height:54px; padding:0 22px; display:flex; align-items:center; gap:18px;
+ border-bottom:1px solid var(--line); background:rgba(15,14,12,.96); backdrop-filter:blur(6px);
+ position:relative; z-index:30;
+ }
+ header.top h1 { font:300 22px/1 'Cormorant Garamond','Playfair Display',Didot,serif; margin:0; color:var(--gold); letter-spacing:.01em }
+ header.top .crumb { color:#888; font-size:11.5px; text-transform:uppercase; letter-spacing:.14em }
+ header.top .crumb b { color:var(--ink); font-weight:500 }
+
+ /* ── layout ──────────────────────────────────────────────────────── */
+ .layout { display:grid; grid-template-columns: var(--rail) 1fr; height: calc(100% - 54px) }
+
+ /* ── left rail ───────────────────────────────────────────────────── */
+ aside.rail {
+ border-right:1px solid var(--line); overflow-y:auto; padding:14px 0 60px;
+ background:#0b0a09;
+ }
+ aside.rail h3 { font:500 10.5px/1 -apple-system; text-transform:uppercase; letter-spacing:.18em; color:#888; margin:18px 18px 8px; cursor:pointer; user-select:none; display:flex; align-items:center; gap:6px }
+ aside.rail h3 .chev { transition:transform .15s; color:#555 }
+ aside.rail h3.collapsed .chev { transform:rotate(-90deg) }
+ aside.rail h3.collapsed + ul { display:none }
+ aside.rail h3 .total { margin-left:auto; color:#555; font-weight:400; font-size:10px }
+ aside.rail ul { list-style:none; margin:0; padding:0 }
+ aside.rail li { padding:6px 18px 6px 28px; cursor:pointer; font-size:12.5px; color:#bba; display:flex; align-items:center; gap:8px; transition:background .1s }
+ aside.rail li:hover { background:#161412; color:var(--ink) }
+ aside.rail li.active { background:#1a1715; color:var(--gold); border-left:2px solid var(--gold); padding-left:26px }
+ aside.rail li .n { margin-left:auto; color:#555; font:500 10.5px/1 'SF Mono',Menlo,monospace }
+ aside.rail li.active .n { color:var(--gold) }
+ aside.rail li.div { height:1px; background:var(--line); margin:8px 18px; padding:0; cursor:default }
+ aside.rail li.div:hover { background:var(--line) }
+ aside.rail li.special::after { content:'★'; color:var(--gold); font-size:9px; margin-left:-4px }
+
+ /* ── right pane ──────────────────────────────────────────────────── */
+ main.pane { display:flex; flex-direction:column; overflow:hidden }
+ .controls {
+ padding:12px 22px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:14px; flex-wrap:wrap;
+ background:#0d0c0b; z-index:10;
+ }
+ .controls label { color:#888; font:500 10.5px/1 -apple-system; text-transform:uppercase; letter-spacing:.12em }
+ .controls select, .controls button {
+ font:inherit; padding:6px 12px; background:#1a1816; color:var(--ink);
+ border:1px solid var(--line); border-radius:4px; cursor:pointer; font-size:12px;
+ }
+ .controls select:hover, .controls button:hover:not(:disabled) { border-color:var(--gold); color:var(--gold) }
+ .controls button:disabled { opacity:.32; cursor:not-allowed }
+ .controls .density { display:flex; align-items:center; gap:8px }
+ .controls .density input[type=range] { width:120px; accent-color:var(--gold) }
+ .controls .density .vw { color:var(--gold); font:500 11px 'SF Mono',Menlo,monospace; min-width:24px; text-align:right }
+ .controls .stat { color:#888; font:500 11px 'SF Mono',Menlo,monospace }
+ .controls .sel { color:var(--gold); font:600 12px 'SF Mono',Menlo,monospace; min-width:88px }
+ .controls .spacer { flex:1 }
+ .controls .group { display:flex; gap:6px; align-items:center; padding-left:12px; margin-left:6px; border-left:1px solid var(--line) }
+ .controls .pub { color:#7dd87d; border-color:#2a4a2a }
+ .controls .pub:hover:not(:disabled) { background:#1e3a1e; color:#9deD9d }
+ .controls .del { color:#e08070; border-color:#4a2a26 }
+ .controls .del:hover:not(:disabled) { background:#3a2020; color:#ffa090 }
+ .controls .recolor { color:var(--gold); border-color:#3a2a16 }
+ .controls .small { padding:4px 10px; font-size:11px }
+
+ .grid-wrap { overflow-y:auto; padding:18px 22px 80px; flex:1 }
+ .grid { display:grid; grid-template-columns: repeat(var(--cols), 1fr); gap:10px }
+ .card {
+ position:relative; background:#1a1816; border:1.5px solid var(--line); border-radius:6px;
+ overflow:hidden; cursor:pointer; transition:border-color .12s; aspect-ratio:1/1.18;
+ }
+ .card:hover { border-color:#555 }
+ .card.selected { border-color:var(--gold); box-shadow:0 0 0 2px rgba(210,177,92,.22) }
+ .card.readonly { cursor:default }
+ .card img { width:100%; height:74%; object-fit:cover; display:block; background:#222; pointer-events:none }
+ .card .meta { padding:7px 9px; font-size:10.5px }
+ .card .dig { color:var(--gold); font:500 10px/1.2 'SF Mono',Menlo,monospace; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
+ .card .lbl { color:#bba; font-size:10.5px; line-height:1.25; margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
+ .badge { position:absolute; top:6px; padding:2px 7px; border-radius:3px; font:600 9px/1.4 -apple-system; letter-spacing:.08em }
+ .badge.pub { left:6px; background:#1e4a1e; color:#7dd87d }
+ .badge.draft { left:6px; background:#3a2a16; color:#d2b15c }
+ .badge.shopify-active { left:6px; background:#1e3a4a; color:#7ad0e0 }
+ .badge.shopify-draft { left:6px; background:#3a3a16; color:#d8d27a }
+ .badge.shopify-archived { left:6px; background:#3a2a2a; color:#c08070 }
+ .cb { position:absolute; top:6px; right:6px; width:20px; height:20px; border-radius:4px; background:rgba(15,14,12,.85); border:1.5px solid #555; display:flex; align-items:center; justify-content:center; font:700 14px/1 -apple-system; color:transparent }
+ .card.selected .cb { background:var(--gold); border-color:var(--gold); color:#0f0e0c }
+ .card.readonly .cb { display:none }
+
+ /* ── matrix mode (fliepaper-bugs) ───────────────────────────────── */
+ .matrix table { border-collapse:separate; border-spacing:6px; width:100% }
+ .matrix th { font:400 11px/1.3 -apple-system; text-transform:uppercase; letter-spacing:.12em; color:var(--gold); text-align:center; padding:8px 6px; cursor:pointer; user-select:none }
+ .matrix th:hover { color:#fff }
+ .matrix th.rowhead { text-align:right; padding-right:14px; vertical-align:middle; min-width:170px; font-size:12px; letter-spacing:.06em }
+ .matrix td { padding:0; vertical-align:top }
+ .matrix .empty { display:flex; align-items:center; justify-content:center; min-height:200px; color:#666; font-style:italic; aspect-ratio:1/1.18 }
+
+ /* ── empty / loading ─────────────────────────────────────────────── */
+ .empty-state { padding:80px; text-align:center; color:#666; font-style:italic }
+ .spinner { display:inline-block; width:14px; height:14px; border:2px solid #444; border-top-color:var(--gold); border-radius:50%; animation:spin .8s linear infinite; vertical-align:middle; margin-right:8px }
+ @keyframes spin { to { transform:rotate(360deg) } }
+
+ /* ── toast ───────────────────────────────────────────────────────── */
+ #toast { position:fixed; bottom:24px; right:24px; background:#1a1816; border:1px solid var(--gold); color:var(--ink); padding:14px 20px; border-radius:6px; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,.5); z-index:50; opacity:0; transform:translateY(10px); transition:.2s; pointer-events:none }
+ #toast.show { opacity:1; transform:translateY(0); pointer-events:auto }
+ #toast.err { border-color:var(--danger) }
+ #toast.ok { border-color:var(--pub) }
+</style></head>
+<body>
+<header class="top">
+ <h1>4Square</h1>
+ <span class="crumb">ADMIN PRODUCT BROWSER · <b id="bc">loading…</b></span>
+</header>
+
+<div class="layout">
+ <aside class="rail" id="rail"></aside>
+ <main class="pane">
+ <div class="controls">
+ <span class="sel" id="selcount">0 selected</span>
+ <button class="small" onclick="selectAll()">All</button>
+ <button class="small" onclick="selectNone()">None</button>
+
+ <div class="group">
+ <label>Sort</label>
+ <select id="sort"></select>
+ </div>
+ <div class="density">
+ <label>Density</label>
+ <input type="range" id="cols" min="3" max="12" value="6">
+ <span class="vw" id="cols-v">6</span>
+ </div>
+ <span class="stat" id="stat"></span>
+
+ <span class="spacer"></span>
+
+ <div class="group" id="bulk-actions">
+ <button class="pub" id="b-pub" onclick="bulk('publish')" disabled>✓ Publish</button>
+ <button id="b-unpub" onclick="bulk('unpublish')" disabled>↻ Unpublish</button>
+ <button class="del" id="b-del" onclick="bulk('delete')" disabled>🗑 Remove</button>
+ </div>
+ <div class="group" id="recolor-group" style="display:none">
+ <label>Recolor →</label>
+ <select id="recolor-target"><option value="">choose…</option></select>
+ <button class="recolor" id="b-rec" onclick="bulk('recolor')" disabled>⟳ Regen</button>
+ </div>
+ </div>
+ <div class="grid-wrap" id="grid-wrap">
+ <div class="empty-state"><span class="spinner"></span>loading…</div>
+ </div>
+ </main>
+</div>
+
+<div id="toast"></div>
+
+<script>
+const $ = s => document.querySelector(s);
+const LS = { sort:'4sq.sort', cols:'4sq.cols', src:'4sq.src' };
+
+let SOURCES = [];
+let CURRENT = { id: localStorage.getItem(LS.src) || 'wallco:fliepaper-bugs', kind:null, rows:[], total:0, spec:null, readonly:false };
+let selected = new Set();
+
+const SORT_WALLCO = [
+ ['newest','Newest'],['oldest','Oldest'],
+ ['published','Published first'],['category','Category'],
+ ['sku','SKU (DIG) A→Z'],['sku-desc','SKU Z→A'],
+ ['title','Title A→Z'],['title-desc','Title Z→A'],
+ ['light','Color: Light→Dark'],['dark','Color: Dark→Light'],
+];
+const SORT_SHOPIFY = [
+ ['newest','Newest'],['oldest','Oldest'],
+ ['title','Title A→Z'],['title-desc','Title Z→A'],
+ ['sku','DW SKU A→Z'],['sku-desc','DW SKU Z→A'],
+ ['vendor','Vendor'],['status','Status'],
+ ['price-up','Price ↑'],['price-down','Price ↓'],
+];
+
+const toast = (msg, kind='ok') => {
+ const t = $('#toast'); t.textContent = msg; t.className = 'show ' + kind;
+ setTimeout(() => t.className = kind, 3500);
+};
+const esc = s => String(s ?? '').replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
+
+async function loadSources() {
+ const j = await fetch('/api/sources').then(r => r.json());
+ SOURCES = j.sources;
+ renderRail();
+}
+
+function renderRail() {
+ const rail = $('#rail');
+ rail.innerHTML = '';
+ for (const grp of SOURCES) {
+ const h = document.createElement('h3');
+ h.innerHTML = `<span class="chev">▾</span> ${esc(grp.label)} <span class="total">${grp.total.toLocaleString()}</span>`;
+ h.onclick = () => h.classList.toggle('collapsed');
+ rail.appendChild(h);
+ const ul = document.createElement('ul');
+ for (const c of grp.children) {
+ const li = document.createElement('li');
+ if (c.id === '__divider__') { li.className = 'div'; ul.appendChild(li); continue; }
+ li.dataset.id = c.id;
+ if (c.special) li.classList.add('special');
+ li.innerHTML = `<span class="lbl">${esc(c.label)}</span><span class="n">${(c.n||0).toLocaleString()}</span>`;
+ li.onclick = () => switchTo(c.id);
+ if (c.id === CURRENT.id) li.classList.add('active');
+ ul.appendChild(li);
+ }
+ rail.appendChild(ul);
+ }
+}
+
+function switchTo(srcId) {
+ CURRENT.id = srcId;
+ localStorage.setItem(LS.src, srcId);
+ selected.clear();
+ // mark active in rail
+ document.querySelectorAll('aside.rail li').forEach(li => li.classList.toggle('active', li.dataset.id === srcId));
+ loadProducts();
+}
+
+function populateSortOptions(kind) {
+ const sel = $('#sort');
+ sel.innerHTML = '';
+ const opts = kind === 'shopify' ? SORT_SHOPIFY : SORT_WALLCO;
+ for (const [v, l] of opts) {
+ const o = document.createElement('option');
+ o.value = v; o.textContent = l;
+ sel.appendChild(o);
+ }
+ const saved = localStorage.getItem(LS.sort);
+ if (saved && opts.find(o => o[0] === saved)) sel.value = saved;
+}
+
+async function loadProducts() {
+ const sort = $('#sort').value || 'newest';
+ $('#grid-wrap').innerHTML = '<div class="empty-state"><span class="spinner"></span>loading…</div>';
+ $('#bc').textContent = CURRENT.id;
+ const url = `/api/products?source=${encodeURIComponent(CURRENT.id)}&sort=${encodeURIComponent(sort)}&limit=300`;
+ const j = await fetch(url).then(r => r.json());
+ if (!j.ok) { $('#grid-wrap').innerHTML = `<div class="empty-state">error: ${esc(j.error)}</div>`; return; }
+ CURRENT.kind = j.kind; CURRENT.rows = j.rows; CURRENT.total = j.total; CURRENT.spec = j.spec; CURRENT.readonly = !!j.readonly;
+ populateSortOptions(j.kind);
+ $('#sort').value = sort;
+ populateRecolor(j.spec);
+ renderControls();
+ renderGrid();
+}
+
+function populateRecolor(spec) {
+ const grp = $('#recolor-group'); const sel = $('#recolor-target');
+ if (spec && spec.palette) {
+ grp.style.display = '';
+ sel.innerHTML = '<option value="">choose…</option>' + spec.palette.map(c => `<option value="${esc(c.slug)}">${esc(c.name)}</option>`).join('');
+ } else {
+ grp.style.display = 'none';
+ }
+}
+
+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)' : '';
+}
+
+function renderGrid() {
+ const wrap = $('#grid-wrap');
+ if (CURRENT.rows.length === 0) { wrap.innerHTML = '<div class="empty-state">no products in this source</div>'; return; }
+
+ // Matrix layout for fliepaper-bugs
+ if (CURRENT.spec && CURRENT.spec.patterns && CURRENT.spec.palette) {
+ return renderMatrix(wrap);
+ }
+ // Flat grid for everything else
+ const cardsHtml = CURRENT.rows.map(r => cardHtml(r)).join('');
+ wrap.innerHTML = `<div class="grid">${cardsHtml}</div>`;
+ attachCardHandlers();
+}
+
+function renderMatrix(wrap) {
+ const spec = CURRENT.spec;
+ const headerCells = '<th></th>' + spec.palette.map(c => `<th onclick="selectMatrixCol('${c.slug}')">${esc(c.name)}</th>`).join('');
+ const rowsHtml = spec.patterns.map(p => {
+ const cells = spec.palette.map(c => {
+ const r = CURRENT.rows.find(x => x.pattern === p.slug && x.colorway === c.slug);
+ return `<td>${r ? cardHtml(r, { matrixLabel: `${esc(p.name)} · ${esc(c.name)}` }) : '<div class="empty">missing</div>'}</td>`;
+ }).join('');
+ return `<tr><th class="rowhead" onclick="selectMatrixRow('${p.slug}')">${esc(p.name)}</th>${cells}</tr>`;
+ }).join('');
+ const variants = CURRENT.rows.filter(r => r.is_variant);
+ const variantsHtml = variants.length
+ ? `<h3 style="font:300 16px/1.2 'Cormorant Garamond',serif; margin:36px 0 12px; color:var(--gold)">Hue-shift variants <span style="font-size:11px;color:#888;font-style:italic">(not in 6×6 grid)</span></h3>
+ <div class="grid">${variants.map(r => cardHtml(r)).join('')}</div>` : '';
+ wrap.innerHTML = `<div class="matrix"><table><thead><tr>${headerCells}</tr></thead><tbody>${rowsHtml}</tbody></table></div>${variantsHtml}`;
+ attachCardHandlers();
+}
+
+function cardHtml(r, opts={}) {
+ const ro = CURRENT.readonly ? 'readonly' : '';
+ const sel = selected.has(r.id) ? 'selected' : '';
+ const isShopify = CURRENT.kind === 'shopify';
+ const imgSrc = `/img/${r.id}?kind=${CURRENT.kind}`;
+ let badge = '';
+ if (isShopify) {
+ const cls = `shopify-${(r.status||'').toLowerCase()}`;
+ badge = `<span class="badge ${cls}">${esc((r.status||'?').toUpperCase())}</span>`;
+ } else {
+ badge = `<span class="badge ${r.is_published?'pub':'draft'}">${r.is_published?'PUBLISHED':'DRAFT'}</span>`;
+ }
+ const dig = isShopify ? (r.dw_sku || r.sku || r.handle || r.shopify_id) : (r.dig_number || `#${r.id}`);
+ const title = isShopify ? r.title : (opts.matrixLabel || r.category || '');
+ const sub = isShopify ? `${esc(r.vendor||'')}${r.price?' · $'+Number(r.price).toFixed(2):''}` : '';
+ return `
+ <div class="card ${sel} ${ro}" data-id="${r.id}">
+ ${badge}
+ <span class="cb">✓</span>
+ <img loading="lazy" src="${imgSrc}" alt="">
+ <div class="meta">
+ <div class="dig">${esc(dig)}</div>
+ <div class="lbl">${title ? esc(title) : ''}</div>
+ ${sub ? `<div class="lbl" style="color:#888;font-size:10px">${sub}</div>` : ''}
+ </div>
+ </div>`;
+}
+
+function attachCardHandlers() {
+ document.querySelectorAll('.card').forEach(el => {
+ if (el.classList.contains('readonly')) return;
+ el.addEventListener('click', () => {
+ const id = parseInt(el.dataset.id, 10);
+ if (selected.has(id)) selected.delete(id); else selected.add(id);
+ el.classList.toggle('selected');
+ updateSelUI();
+ });
+ });
+ updateSelUI();
+}
+
+function selectMatrixRow(slug) {
+ const ids = CURRENT.rows.filter(r => r.pattern === slug).map(r => r.id);
+ const allIn = ids.length && ids.every(id => selected.has(id));
+ if (allIn) ids.forEach(id => selected.delete(id));
+ else ids.forEach(id => selected.add(id));
+ paintSelected(); updateSelUI();
+}
+function selectMatrixCol(slug) {
+ const ids = CURRENT.rows.filter(r => r.colorway === slug).map(r => r.id);
+ const allIn = ids.length && ids.every(id => selected.has(id));
+ if (allIn) ids.forEach(id => selected.delete(id));
+ else ids.forEach(id => selected.add(id));
+ paintSelected(); updateSelUI();
+}
+function selectAll() { CURRENT.rows.forEach(r => selected.add(r.id)); paintSelected(); updateSelUI(); }
+function selectNone() { selected.clear(); paintSelected(); updateSelUI(); }
+function paintSelected() {
+ document.querySelectorAll('.card').forEach(el => el.classList.toggle('selected', selected.has(parseInt(el.dataset.id,10))));
+}
+function updateSelUI() {
+ $('#selcount').textContent = `${selected.size} selected`;
+ renderControls();
+}
+
+async function bulk(action) {
+ const ids = Array.from(selected);
+ if (!ids.length) return;
+ if (CURRENT.readonly) { toast('Read-only source', 'err'); return; }
+ if (action === 'delete' && !confirm(`Hard-delete ${ids.length} design(s)? Files → ~/.wallco-deleted/, DB rows dropped.`)) return;
+
+ const body = { ids };
+ if (action === 'recolor') {
+ const t = $('#recolor-target').value;
+ if (!t) { toast('Pick a target colorway first', 'err'); return; }
+ body.target_color = t;
+ }
+ try {
+ const j = await fetch(`/api/bulk/${action}`, {
+ method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body)
+ }).then(r => r.json());
+ if (!j.ok) { toast(`Error: ${j.error}`, 'err'); return; }
+ const msg = action === 'publish' ? `Published ${j.changed} design(s).`
+ : action === 'unpublish' ? `Unpublished ${j.changed} design(s).`
+ : action === 'delete' ? `Deleted ${j.deleted}; ${j.files_moved} file(s) → ${j.trash_dir}.`
+ : action === 'recolor' ? `Queued ${j.queued} regen job(s) → ${body.target_color}.`
+ : 'ok';
+ toast(msg, 'ok');
+ selected.clear();
+ await loadProducts();
+ } catch (e) {
+ toast('Request failed: ' + e.message, 'err');
+ }
+}
+
+// ── controls wiring ──────────────────────────────────────────────────
+$('#sort').addEventListener('change', e => {
+ localStorage.setItem(LS.sort, e.target.value);
+ loadProducts();
+});
+$('#cols').addEventListener('input', e => {
+ document.documentElement.style.setProperty('--cols', e.target.value);
+ $('#cols-v').textContent = e.target.value;
+ localStorage.setItem(LS.cols, e.target.value);
+});
+
+// hydrate density from localStorage BEFORE first paint
+const savedCols = localStorage.getItem(LS.cols);
+if (savedCols) {
+ $('#cols').value = savedCols;
+ $('#cols-v').textContent = savedCols;
+ document.documentElement.style.setProperty('--cols', savedCols);
+}
+
+(async () => {
+ await loadSources();
+ populateSortOptions('wallco'); // default before first fetch decides
+ await loadProducts();
+})();
+</script>
+</body></html>
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..caceac1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "4square-admin",
+ "version": "0.1.0",
+ "description": "Local admin product browser — multi-site grid, sort, density, bulk actions.",
+ "main": "server.js",
+ "scripts": { "start": "node server.js" },
+ "private": true
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..c142e9a
--- /dev/null
+++ b/server.js
@@ -0,0 +1,242 @@
+// 4Square-style multi-site admin browser.
+// Left rail: data sources (wallco.ai categories + DW Shopify vendors + archives).
+// Right pane: product grid with sort + density + bulk actions per Steve's
+// CLAUDE.md standing rule ("every product grid MUST have sort + density slider").
+// Loopback-bound (127.0.0.1) — admin by convention per wallco-ai admin-gate.
+'use strict';
+const path = require('path');
+const fs = require('fs');
+
+const WALLCO = '/Users/stevestudio2/Projects/wallco-ai';
+process.chdir(WALLCO);
+const express = require(path.join(WALLCO, 'node_modules', 'express'));
+const { Pool } = require(path.join(WALLCO, 'node_modules', 'pg'));
+
+const PORT = 9906;
+const QUEUE = path.join(WALLCO, 'data', 'fliepaper-bugs', 'queue');
+const TRASH = path.join(require('os').homedir(), '.wallco-deleted', new Date().toISOString().slice(0,10));
+const SPEC = JSON.parse(fs.readFileSync(path.join(WALLCO, 'data/fliepaper-bugs/spec.json'),'utf8'));
+fs.mkdirSync(QUEUE, { recursive: true });
+fs.mkdirSync(TRASH, { recursive: true });
+
+const pool = new Pool({ host: '/tmp', user: 'stevestudio2', database: 'dw_unified' });
+const app = express();
+app.use(express.json());
+
+// ── /api/sources ──────────────────────────────────────────────────────
+// Tree of selectable data sources for the left rail.
+app.get('/api/sources', async (_req, res) => {
+ try {
+ const [wallcoCats, wallcoTotal, shopifyVendors, shopifyTotal, shopifyByStatus] = await Promise.all([
+ pool.query(`SELECT category, COUNT(*)::int AS n FROM spoon_all_designs WHERE category IS NOT NULL GROUP BY category ORDER BY 2 DESC`),
+ pool.query(`SELECT COUNT(*)::int AS n FROM spoon_all_designs`),
+ pool.query(`SELECT vendor, COUNT(*)::int AS n FROM shopify_products WHERE vendor IS NOT NULL GROUP BY vendor ORDER BY 2 DESC LIMIT 30`),
+ pool.query(`SELECT COUNT(*)::int AS n FROM shopify_products`),
+ pool.query(`SELECT status, COUNT(*)::int AS n FROM shopify_products WHERE status IS NOT NULL GROUP BY status ORDER BY 2 DESC`),
+ ]);
+ res.json({
+ ok: true,
+ sources: [
+ {
+ id: 'wallco', label: 'wallco.ai', kind: 'wallco', total: wallcoTotal.rows[0].n,
+ children: [
+ { id: 'wallco:all', label: 'all designs', n: wallcoTotal.rows[0].n },
+ ...wallcoCats.rows.map(r => ({ id: `wallco:${r.category}`, label: r.category, n: r.n, special: r.category === 'fliepaper-bugs' ? 'matrix' : null }))
+ ],
+ },
+ {
+ id: 'shopify', label: 'DW Shopify', kind: 'shopify', total: shopifyTotal.rows[0].n, readonly: true,
+ children: [
+ { id: 'shopify:all', label: 'all products', n: shopifyTotal.rows[0].n },
+ ...shopifyByStatus.rows.map(r => ({ id: `shopify:status:${r.status}`, label: `status: ${r.status}`, n: r.n })),
+ { id: '__divider__' },
+ ...shopifyVendors.rows.map(r => ({ id: `shopify:vendor:${r.vendor}`, label: r.vendor, n: r.n }))
+ ],
+ },
+ ],
+ });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// ── /api/products ─────────────────────────────────────────────────────
+// Unified product fetch. source = "wallco:<cat>" | "wallco:all"
+// | "shopify:all" | "shopify:vendor:<v>" | "shopify:status:<s>"
+function sortClauseWallco(sort) {
+ return ({
+ 'newest': 'created_at DESC',
+ 'oldest': 'created_at ASC',
+ 'title': `COALESCE(tags::text, '') ASC, id ASC`,
+ 'title-desc': `COALESCE(tags::text, '') DESC, id ASC`,
+ 'sku': 'dig_number ASC NULLS LAST',
+ 'sku-desc': 'dig_number DESC NULLS LAST',
+ 'light': 'dominant_hex ASC NULLS LAST',
+ 'dark': 'dominant_hex DESC NULLS LAST',
+ 'category': 'category ASC NULLS LAST, id DESC',
+ 'published': 'is_published DESC, id DESC',
+ })[sort] || 'created_at DESC NULLS LAST';
+}
+function sortClauseShopify(sort) {
+ return ({
+ 'newest': 'created_at_shopify DESC NULLS LAST',
+ 'oldest': 'created_at_shopify ASC NULLS LAST',
+ 'title': 'title ASC NULLS LAST',
+ 'title-desc': 'title DESC NULLS LAST',
+ 'sku': 'dw_sku ASC NULLS LAST',
+ 'sku-desc': 'dw_sku DESC NULLS LAST',
+ 'price-up': 'price ASC NULLS LAST',
+ 'price-down': 'price DESC NULLS LAST',
+ 'vendor': 'vendor ASC NULLS LAST, title ASC',
+ 'status': 'status ASC NULLS LAST, title ASC',
+ })[sort] || 'created_at_shopify DESC NULLS LAST';
+}
+
+app.get('/api/products', async (req, res) => {
+ try {
+ const source = String(req.query.source || 'wallco:all');
+ const sort = String(req.query.sort || 'newest');
+ const limit = Math.min(500, Math.max(20, parseInt(req.query.limit || '120', 10)));
+ const offset = Math.max(0, parseInt(req.query.offset || '0', 10));
+
+ if (source.startsWith('wallco:')) {
+ const cat = source.slice(7);
+ const orderBy = sortClauseWallco(sort);
+ const params = [];
+ let where = '1=1';
+ if (cat !== 'all') { params.push(cat); where = `category = $${params.length}`; }
+ const countSql = `SELECT COUNT(*)::int AS n FROM spoon_all_designs WHERE ${where}`;
+ const listSql = `SELECT id, dig_number, tags, local_path, is_published, created_at,
+ dominant_hex, category, prompt
+ FROM spoon_all_designs WHERE ${where}
+ ORDER BY ${orderBy}
+ LIMIT ${limit} OFFSET ${offset}`;
+ const [c, r] = await Promise.all([pool.query(countSql, params), pool.query(listSql, params)]);
+ // Tag fliepaper-bugs rows with pattern/colorway slugs for matrix view
+ if (cat === 'fliepaper-bugs') {
+ const PATTERNS = SPEC.patterns.map(p => p.slug);
+ const PALETTE = SPEC.palette.map(c => c.slug);
+ for (const row of r.rows) {
+ const t = (row.tags || []).join(' ');
+ row.pattern = PATTERNS.find(p => t.includes(p)) || null;
+ row.colorway = PALETTE.find(c => t.includes(c)) || null;
+ row.is_variant = !row.pattern || !row.colorway;
+ }
+ }
+ return res.json({
+ ok: true, kind: 'wallco', source, sort, limit, offset,
+ total: c.rows[0].n, rows: r.rows,
+ spec: cat === 'fliepaper-bugs' ? SPEC : null,
+ });
+ }
+
+ if (source.startsWith('shopify:')) {
+ const orderBy = sortClauseShopify(sort);
+ const parts = source.split(':');
+ const params = [];
+ let where = '1=1';
+ if (parts[1] === 'vendor' && parts[2]) { params.push(parts.slice(2).join(':')); where = `vendor = $${params.length}`; }
+ else if (parts[1] === 'status' && parts[2]) { params.push(parts[2]); where = `status = $${params.length}`; }
+ const countSql = `SELECT COUNT(*)::int AS n FROM shopify_products WHERE ${where}`;
+ const listSql = `SELECT id, shopify_id, handle, title, vendor, sku, dw_sku,
+ status, image_url, price, retail_price, cost_price,
+ created_at_shopify, tags
+ FROM shopify_products WHERE ${where}
+ ORDER BY ${orderBy}
+ LIMIT ${limit} OFFSET ${offset}`;
+ const [c, r] = await Promise.all([pool.query(countSql, params), pool.query(listSql, params)]);
+ return res.json({
+ ok: true, kind: 'shopify', source, sort, limit, offset,
+ total: c.rows[0].n, rows: r.rows, readonly: true,
+ });
+ }
+
+ res.status(400).json({ ok:false, error: 'unknown source' });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// ── /img/:id?kind=wallco|shopify ──────────────────────────────────────
+app.get('/img/:id', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const kind = String(req.query.kind || 'wallco');
+ if (!Number.isFinite(id)) return res.status(400).send('bad id');
+ try {
+ if (kind === 'shopify') {
+ const { rows } = await pool.query(`SELECT image_url FROM shopify_products WHERE id=$1`, [id]);
+ const u = rows[0]?.image_url;
+ if (!u) return res.status(404).send('no image');
+ return res.redirect(302, u);
+ }
+ const { rows } = await pool.query(`SELECT local_path FROM spoon_all_designs WHERE id=$1`, [id]);
+ const p = rows[0]?.local_path;
+ if (!p || !fs.existsSync(p)) return res.status(404).send('img missing');
+ res.type(path.extname(p) === '.jpg' ? 'jpg' : 'png');
+ return fs.createReadStream(p).pipe(res);
+ } catch (e) {
+ res.status(500).send(e.message);
+ }
+});
+
+// ── bulk writes (wallco.ai only — Shopify is read-only) ───────────────
+function parseIds(req, res) {
+ const ids = (req.body?.ids || []).map(n => parseInt(n,10)).filter(Number.isFinite);
+ if (!ids.length) { res.status(400).json({ ok:false, error:'ids required' }); return null; }
+ return ids;
+}
+
+app.post('/api/bulk/publish', async (req, res) => {
+ const ids = parseIds(req, res); if (!ids) return;
+ const r = await pool.query(`UPDATE spoon_all_designs SET is_published=true WHERE id=ANY($1) RETURNING id`, [ids]);
+ res.json({ ok:true, action:'publish', changed: r.rows.length, ids: r.rows.map(x=>x.id) });
+});
+app.post('/api/bulk/unpublish', async (req, res) => {
+ const ids = parseIds(req, res); if (!ids) return;
+ const r = await pool.query(`UPDATE spoon_all_designs SET is_published=false WHERE id=ANY($1) RETURNING id`, [ids]);
+ res.json({ ok:true, action:'unpublish', changed: r.rows.length, ids: r.rows.map(x=>x.id) });
+});
+app.post('/api/bulk/delete', async (req, res) => {
+ const ids = parseIds(req, res); if (!ids) return;
+ const { rows } = await pool.query(`SELECT id, local_path FROM spoon_all_designs WHERE id=ANY($1)`, [ids]);
+ const moved = [];
+ for (const r of rows) {
+ if (r.local_path && fs.existsSync(r.local_path)) {
+ const dest = path.join(TRASH, `${r.id}__${path.basename(r.local_path)}`);
+ try { fs.renameSync(r.local_path, dest); moved.push(dest); } catch {}
+ }
+ }
+ 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/recolor', async (req, res) => {
+ const ids = parseIds(req, res); if (!ids) return;
+ const target = String(req.body?.target_color || '').trim();
+ const cw = SPEC.palette.find(c => c.slug === target);
+ if (!cw) return res.status(400).json({ ok:false, error:`target_color must be one of ${SPEC.palette.map(c=>c.slug).join(',')}` });
+ const { rows } = await pool.query(`SELECT id, tags FROM spoon_all_designs WHERE id=ANY($1) AND category='fliepaper-bugs'`, [ids]);
+ const jobs = [];
+ for (const r of rows) {
+ const t = (r.tags || []).join(' ');
+ const pat = SPEC.patterns.find(p => t.includes(p.slug));
+ if (!pat) continue;
+ const job_id = `${Date.now()}_recolor_${r.id}_${target}`;
+ const job = {
+ job_id, source: '4square-admin', action: 'recolor', previous_design_id: r.id,
+ pattern: pat, colorway: cw, queued_at: new Date().toISOString(),
+ note: 'Settlement gate must run before SDXL hit (yolo-runner enforces).',
+ };
+ fs.writeFileSync(path.join(QUEUE, `${job_id}.json`), JSON.stringify(job, null, 2));
+ jobs.push(job_id);
+ }
+ res.json({ ok:true, action:'recolor', target_color: target, queued: jobs.length, jobs });
+});
+
+// ── UI ────────────────────────────────────────────────────────────────
+app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'index.html')));
+
+app.listen(PORT, '127.0.0.1', () => {
+ console.log(`4square admin browser → http://127.0.0.1:${PORT}`);
+ console.log(` trash: ${TRASH}`);
+ console.log(` queue: ${QUEUE}`);
+});
(oldest)
·
back to 4square Admin
·
feat: search box + load-more pagination a147034 →