← back to 4square Admin
index.html
857 lines
<!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, .controls input[type=search] {
font:inherit; padding:6px 12px; background:#1a1816; color:var(--ink);
border:1px solid var(--line); border-radius:4px; cursor:pointer; font-size:12px;
}
.controls input[type=search] { cursor:text; width:220px }
.controls input[type=search]:focus { outline:none; border-color:var(--gold) }
.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 }
.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; flex-wrap:wrap;
}
.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 }
.source-bar .colorbar { margin-left:auto; display:flex; gap:5px; align-items:center }
.source-bar .colorbar .clbl { color:#888; font:500 10.5px/1 -apple-system; text-transform:uppercase; letter-spacing:.1em; margin-right:4px }
.color-chip {
width:20px; height:20px; border-radius:50%; cursor:pointer; border:2px solid transparent;
transition:border-color .12s, transform .12s;
}
.color-chip:hover { border-color:#888; transform:scale(1.1) }
.color-chip.active { border-color:var(--gold); transform:scale(1.12); box-shadow:0 0 8px rgba(210,177,92,.5) }
.color-chip.clear { background:transparent !important; border:1px dashed #555; font:600 11px/1 -apple-system; color:#888; display:flex; align-items:center; justify-content:center }
.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.focused { box-shadow:0 0 0 2px rgba(125,216,125,.45) }
.card.focused.selected { box-shadow:0 0 0 2px var(--gold) }
.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 }
.card .actions { position:absolute; bottom:32%; right:6px; display:none; gap:4px; z-index:2 }
.card:hover .actions { display:flex }
.card .actions a, .card .actions button {
width:22px; height:22px; padding:0; display:flex; align-items:center; justify-content:center;
background:rgba(15,14,12,.92); border:1px solid var(--line); color:var(--ink); border-radius:4px;
text-decoration:none; font:600 11px/1 -apple-system; cursor:pointer;
}
.card .actions a:hover, .card .actions button:hover { border-color:var(--gold); color:var(--gold); background:#1a1816 }
.card.readonly { cursor:default }
/* ── 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) } }
/* ── modal ───────────────────────────────────────────────────────── */
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.65); backdrop-filter:blur(4px); z-index:60; display:none; align-items:center; justify-content:center; padding:40px }
.modal-bg.open { display:flex }
.modal { background:#13110f; border:1px solid var(--gold); border-radius:8px; max-width:1100px; width:100%; max-height:88vh; overflow:hidden; display:grid; grid-template-columns:minmax(280px, 1fr) 480px; box-shadow:0 24px 64px rgba(0,0,0,.6) }
.modal .img-side { background:#0a0908; display:flex; align-items:center; justify-content:center; overflow:hidden; min-height:300px }
.modal .img-side img { max-width:100%; max-height:88vh; object-fit:contain }
.modal .info-side { padding:24px 28px; overflow-y:auto; max-height:88vh }
.modal h2 { font:300 22px/1.2 'Cormorant Garamond','Playfair Display',serif; margin:0 0 4px; color:var(--gold) }
.modal .sub { color:#888; font:500 11px/1.4 'SF Mono',Menlo,monospace; letter-spacing:.04em; word-break:break-all }
.modal .row { display:flex; gap:8px; padding:9px 0; border-bottom:1px solid var(--line); align-items:baseline }
.modal .row .k { color:#888; font:500 10.5px/1.4 -apple-system; text-transform:uppercase; letter-spacing:.1em; min-width:110px; flex-shrink:0 }
.modal .row .v { color:var(--ink); word-break:break-word; flex:1 }
.modal .chips { display:flex; flex-wrap:wrap; gap:5px; margin-top:4px }
.modal .chip { padding:3px 9px; background:#1a1816; border:1px solid var(--line); border-radius:11px; font:500 10.5px/1.4 -apple-system; color:#bba }
.modal .swatch { display:inline-block; width:14px; height:14px; border-radius:3px; border:1px solid #444; vertical-align:middle; margin-right:6px }
.modal pre { margin:6px 0 0; padding:10px 12px; background:#0d0c0b; border:1px solid var(--line); border-radius:4px; font:500 11px/1.5 'SF Mono',Menlo,monospace; color:#bba; white-space:pre-wrap; max-height:160px; overflow-y:auto }
.modal .acts { display:flex; gap:8px; padding:14px 0 0; margin-top:14px; border-top:1px solid var(--line) }
.modal .acts a, .modal .acts button { font:inherit; padding:8px 16px; background:#1a1816; color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer; font-size:12px; text-decoration:none }
.modal .acts a:hover, .modal .acts button:hover { border-color:var(--gold); color:var(--gold) }
.modal .close { position:absolute; top:18px; right:24px; color:#888; font-size:22px; cursor:pointer; background:transparent; border:0; padding:0; line-height:1 }
.modal .close:hover { color:var(--gold) }
@media (max-width:880px) { .modal { grid-template-columns:1fr; max-height:90vh } .modal .img-side { max-height:40vh } }
/* ── 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">
<input type="search" id="q" placeholder="Search title / SKU / DIG / tags…" />
</div>
<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 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>
<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>
</main>
</div>
<div class="modal-bg" id="modal-bg" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal" style="position:relative">
<button class="close" onclick="closeModal()" title="Close (Esc)">×</button>
<div class="img-side" id="m-img"></div>
<div class="info-side" id="m-info"></div>
</div>
</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, has_more:false, offset:0 };
let selected = new Set();
let searchDebounce = null;
let colorFilter = null; // null | 'red' | 'blue' | ...
const COLOR_BUCKETS = [
['black', '#0a0908'], ['white', '#f3f0ea'], ['grey', '#7a7672'],
['brown', '#6b4a2a'], ['beige', '#d8c6a0'], ['yellow', '#e6c948'],
['orange', '#d97a2a'], ['red', '#c43c3c'], ['pink', '#e0a5c0'],
['purple', '#7a5eb0'], ['blue', '#4f7bc4'], ['green', '#5a8e5a'],
];
function hexToBucket(hex) {
if (!hex) return null;
const m = /#?([0-9a-f]{6})/i.exec(hex);
if (!m) return null;
const r = parseInt(m[1].slice(0,2),16)/255,
g = parseInt(m[1].slice(2,4),16)/255,
b = parseInt(m[1].slice(4,6),16)/255;
const max = Math.max(r,g,b), min = Math.min(r,g,b), L = (max+min)/2;
const d = max - min;
if (d < 0.05) {
if (L < 0.18) return 'black';
if (L > 0.85) return 'white';
return 'grey';
}
const S = L > 0.5 ? d/(2-max-min) : d/(max+min);
let H;
if (max === r) H = ((g-b)/d + (g<b?6:0))*60;
else if (max === g) H = ((b-r)/d + 2)*60;
else H = ((r-g)/d + 4)*60;
if (S < 0.18) return L < 0.2 ? 'black' : L > 0.85 ? 'white' : 'grey';
// brown/beige: low-saturation oranges in narrow band
if (H >= 15 && H < 50 && L < 0.4) return 'brown';
if (H >= 15 && H < 60 && S < 0.45 && L > 0.55) return 'beige';
if (H < 15 || H >= 345) return 'red';
if (H < 40) return 'orange';
if (H < 65) return 'yellow';
if (H < 170) return 'green';
if (H < 250) return 'blue';
if (H < 290) return 'purple';
return 'pink';
}
function rowBucket(r) {
if (CURRENT.kind === 'wallco') return hexToBucket(r.dominant_hex);
if (CURRENT.kind === 'patternbank' && Array.isArray(r.colors_hex)) {
// pick the most-saturated swatch
let best = null, bestS = -1;
for (const h of r.colors_hex) {
const b = hexToBucket(h);
if (b) { return b; } // first non-null is fine; could be improved
}
return null;
}
// shopify/vintage: parse tags for "color-X" pattern if present
const tags = Array.isArray(r.tags) ? r.tags : (typeof r.tags === 'string' ? r.tags.split(',') : []);
for (const t of tags) {
const m = /color[-_:](\w+)/i.exec(String(t).trim());
if (m && COLOR_BUCKETS.find(b => b[0] === m[1].toLowerCase())) return m[1].toLowerCase();
}
return null;
}
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 SORT_PATTERNBANK = [
['newest','Newest ingested'],['oldest','Oldest ingested'],
['title','Title A→Z'],['title-desc','Title Z→A'],
['designer','Designer'],['category','Category'],
];
const SORT_VINTAGE = [
['newest','Newest ingested'],['oldest','Oldest ingested'],
['title','Title A→Z'],['title-desc','Title Z→A'],
['era','Era (oldest first)'],['era-desc','Era (newest first)'],
['vendor','Vendor'],
];
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() {
let j;
try {
j = await fetch('/api/sources').then(r => r.json());
} catch (e) {
toast('Could not load sources: ' + e.message, 'err');
return;
}
if (!j.ok) { toast('Sources error: ' + (j.error || 'unknown'), 'err'); return; }
SOURCES = j.sources;
// Resilience: never land on a non-existent / empty source. A stale localStorage
// pick or the old default 'wallco:fliepaper-bugs' (renamed away, 0 rows) would
// otherwise render an empty grid with publish/unpublish permanently disabled.
const validIds = new Set(
SOURCES.flatMap(g => (g.children || [])
.filter(c => c.id !== '__divider__' && (c.n == null || c.n > 0))
.map(c => c.id))
);
if (!validIds.has(CURRENT.id)) {
const fallback =
(SOURCES.find(g => g.id === 'wallco')?.children || []).find(c => c.id === 'wallco:all')?.id
|| [...validIds][0];
if (fallback) { CURRENT.id = fallback; localStorage.setItem(LS.src, fallback); }
}
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
: kind === 'patternbank' ? SORT_PATTERNBANK
: kind === 'vintage' ? SORT_VINTAGE
: 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({ append = false } = {}) {
const sort = $('#sort').value || 'newest';
const q = $('#q').value.trim();
if (!append) {
$('#grid-wrap').innerHTML = '<div class="empty-state"><span class="spinner"></span>loading…</div>';
CURRENT.offset = 0;
}
$('#bc').textContent = CURRENT.id + (q ? ` · "${q}"` : '');
const url = `/api/products?source=${encodeURIComponent(CURRENT.id)}&sort=${encodeURIComponent(sort)}&limit=300&offset=${CURRENT.offset}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
let j;
try {
j = await fetch(url).then(r => r.json());
} catch (e) {
$('#grid-wrap').innerHTML = `<div class="empty-state">request failed: ${esc(e.message)}</div>`;
return;
}
if (!j.ok) { $('#grid-wrap').innerHTML = `<div class="empty-state">error: ${esc(j.error)}</div>`; return; }
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);
$('#sort').value = sort;
populateRecolor(j.spec);
renderControls();
renderGrid();
}
async function loadMore() {
if (!CURRENT.has_more) return;
await loadProducts({ append: true });
}
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 visibleRows = visibleRowSet();
const showing = visibleRows.length;
$('#stat').textContent = colorFilter
? `${showing.toLocaleString()} matching "${colorFilter}" of ${CURRENT.rows.length.toLocaleString()} loaded`
: `${showing.toLocaleString()} of ${CURRENT.total.toLocaleString()}`;
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' : '';
$('#b-csv').disabled = selected.size === 0;
$('#b-tag').disabled = selected.size === 0 || CURRENT.readonly;
// 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>';
const chips = `<span class="colorbar">
<span class="clbl">Hue</span>
${COLOR_BUCKETS.map(([name, hex]) =>
`<span class="color-chip ${colorFilter===name?'active':''}" style="background:${hex}" title="${name}" onclick="toggleColor('${name}')"></span>`).join('')}
<span class="color-chip clear ${colorFilter?'':'active'}" title="clear" onclick="toggleColor(null)">×</span>
</span>`;
bar.innerHTML = `<strong style="color:var(--gold);font-weight:500">${esc(CURRENT.id)}</strong> ${ro} · ${showing.toLocaleString()} of ${CURRENT.total.toLocaleString()} ${lic}${chips}`;
}
function visibleRowSet() {
if (!colorFilter) return CURRENT.rows;
return CURRENT.rows.filter(r => rowBucket(r) === colorFilter);
}
function toggleColor(c) {
colorFilter = (c === colorFilter) ? null : c;
renderGrid(); // re-render with filter applied
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', {
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');
const objectUrl = URL.createObjectURL(blob);
a.href = objectUrl;
a.download = `4square-${CURRENT.kind}-${Date.now()}.csv`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(objectUrl);
toast(`Exported ${selected.size} row(s) as CSV`, 'ok');
}
function renderGrid() {
const wrap = $('#grid-wrap');
if (CURRENT.rows.length === 0) { wrap.innerHTML = '<div class="empty-state">no products in this source</div>'; return; }
if (CURRENT.spec && CURRENT.spec.patterns && CURRENT.spec.palette) {
return renderMatrix(wrap);
}
const vis = visibleRowSet();
if (vis.length === 0) {
wrap.innerHTML = `<div class="empty-state">No "${esc(colorFilter)}" cards in loaded set. Hint: load more first, or pick a different hue.</div>`;
return;
}
const cardsHtml = vis.map(r => cardHtml(r)).join('');
const remaining = CURRENT.total - CURRENT.rows.length;
const filtNote = colorFilter ? ` <span style="color:var(--gold);font-style:italic">(hue: ${esc(colorFilter)})</span>` : '';
const more = CURRENT.has_more
? `<div style="text-align:center; padding:32px 0 16px"><button class="small" id="load-more" onclick="loadMore()" style="padding:10px 24px; font-size:13px">Load more (${remaining.toLocaleString()} remaining)${filtNote}</button></div>`
: (CURRENT.total > CURRENT.rows.length ? '' : `<div style="text-align:center; padding:24px 0 16px; color:#555; font-size:11px; font-style:italic">end · ${CURRENT.rows.length.toLocaleString()} of ${CURRENT.total.toLocaleString()}${filtNote}</div>`);
wrap.innerHTML = `<div class="grid">${cardsHtml}</div>${more}`;
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();
}
const SHOPIFY_STORE_SLUG = 'designer-laboratory-sandbox';
function deepLinks(r, kind) {
if (kind === 'shopify') {
const links = [];
if (r.shopify_id) links.push(`<a target="_blank" rel="noopener noreferrer" href="https://admin.shopify.com/store/${SHOPIFY_STORE_SLUG}/products/${r.shopify_id}" title="Open in Shopify Admin">S</a>`);
if (r.handle) links.push(`<a target="_blank" rel="noopener noreferrer" href="https://designerwallcoverings.com/products/${r.handle}" title="Open on DW storefront">↗</a>`);
return links.join('');
}
if (kind === 'patternbank' || kind === 'vintage') {
return r.source_url ? `<a target="_blank" rel="noopener noreferrer" href="${esc(r.source_url)}" title="Open source page">↗</a>` : '';
}
return `<a target="_blank" rel="noopener noreferrer" href="https://wallco.ai/design/${r.id}" title="Open on wallco.ai">↗</a>`;
}
function cardHtml(r, opts={}) {
const ro = CURRENT.readonly ? 'readonly' : '';
const sel = selected.has(r.id) ? 'selected' : '';
const kind = CURRENT.kind;
const imgSrc = `/img/${encodeURIComponent(r.id)}?kind=${kind}`;
let badge = '', dig = '', title = '', sub = '';
if (kind === 'shopify') {
const cls = `shopify-${(r.status||'').toLowerCase()}`;
badge = `<span class="badge ${cls}">${esc((r.status||'?').toUpperCase())}</span>`;
dig = r.dw_sku || r.sku || r.handle || r.shopify_id;
title = r.title;
sub = `${esc(r.vendor||'')}${r.price?' · $'+Number(r.price).toFixed(2):''}`;
} else if (kind === 'patternbank') {
badge = `<span class="badge" style="background:#3a2a4a;color:#c89cd8;left:6px">PB</span>`;
dig = r.slug || r.id;
title = r.title || '';
sub = `${esc(r.designer||'')}${r.category?' · '+esc(r.category):''}`;
} else if (kind === 'vintage') {
badge = `<span class="badge" style="background:#3a3a16;color:#d8d27a;left:6px">${r.era_decade?esc(r.era_decade)+'s':'VINTAGE'}</span>`;
dig = r.handle || `#${r.id}`;
title = r.title || '';
sub = `${esc(r.vendor||'')}${r.era_year?' · '+esc(r.era_year):''}`;
} else {
badge = `<span class="badge ${r.is_published?'pub':'draft'}">${r.is_published?'PUBLISHED':'DRAFT'}</span>`;
dig = r.dig_number || `#${r.id}`;
title = opts.matrixLabel || r.category || '';
}
return `
<div class="card ${sel} ${ro}" data-id="${r.id}">
${badge}
<span class="cb">✓</span>
<div class="actions" onclick="event.stopPropagation()">${deepLinks(r, kind)}</div>
<img loading="lazy" src="${imgSrc}" alt="">
<div class="meta">
<div class="dig">${esc(dig)}</div>
<div class="lbl" title="${esc(title)}">${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 => {
const id = parseInt(el.dataset.id, 10);
el.addEventListener('click', (e) => {
// sync cursor on click for keyboard nav continuity
cursor = CURRENT.rows.findIndex(r => r.id === id);
if (cursor < 0) cursor = 0;
if (e.shiftKey || el.classList.contains('readonly')) { openModal(id); return; }
if (selected.has(id)) selected.delete(id); else selected.add(id);
el.classList.toggle('selected');
updateSelUI();
});
el.addEventListener('dblclick', (e) => { e.preventDefault(); openModal(id); });
});
// reset focus to first card when grid rerenders
if (cursor >= CURRENT.rows.length) cursor = 0;
paintCursor();
updateSelUI();
}
async function openModal(id) {
const j = await fetch(`/api/product/${id}?kind=${CURRENT.kind}`).then(r => r.json());
if (!j.ok) { toast('Detail load failed: ' + j.error, 'err'); return; }
const r = j.row;
$('#m-img').innerHTML = `<img src="/img/${id}?kind=${CURRENT.kind}" alt="">`;
$('#m-info').innerHTML = j.kind === 'shopify' ? modalShopify(r) : modalWallco(r);
$('#modal-bg').classList.add('open');
}
function closeModal() { $('#modal-bg').classList.remove('open'); }
function chips(arr) {
if (!arr) return '';
const list = Array.isArray(arr) ? arr : String(arr).split(',').map(s=>s.trim()).filter(Boolean);
if (!list.length) return '<span style="color:#666;font-style:italic">none</span>';
return `<div class="chips">${list.map(t => `<span class="chip">${esc(t)}</span>`).join('')}</div>`;
}
function modalWallco(r) {
const swatch = r.dominant_hex ? `<span class="swatch" style="background:${esc(r.dominant_hex)}"></span><code>${esc(r.dominant_hex)}</code>` : '<span style="color:#666;font-style:italic">none</span>';
return `
<h2>${esc(r.dig_number || `wallco #${r.id}`)}</h2>
<div class="sub">${esc(r.category||'')} · ${esc(r.brand||'')} · ${esc(r.generator||'')}</div>
<div class="row"><span class="k">Published</span><span class="v">${r.is_published ? '<span style="color:#7dd87d">PUBLISHED</span>' : '<span style="color:#d2b15c">DRAFT</span>'}</span></div>
<div class="row"><span class="k">Created</span><span class="v">${esc(r.created_at||'')}</span></div>
<div class="row"><span class="k">Dominant</span><span class="v">${swatch}</span></div>
<div class="row"><span class="k">Dimensions</span><span class="v">${r.width_in||'?'}″ × ${r.height_in||'?'}″ · ${r.panels||1} panel(s)</span></div>
<div class="row"><span class="k">Generator</span><span class="v">${esc(r.generator||'')} · seed=${esc(r.seed||'')}</span></div>
<div class="row"><span class="k">Tags</span><span class="v">${chips(r.tags)}</span></div>
<div class="row"><span class="k">Motifs</span><span class="v">${chips(r.motifs)}</span></div>
${r.parent_design_id ? `<div class="row"><span class="k">Parent</span><span class="v">#${r.parent_design_id}</span></div>` : ''}
${r.prompt ? `<div class="row"><span class="k">Prompt</span><span class="v"><pre>${esc(r.prompt)}</pre></span></div>` : ''}
${r.negative_prompt ? `<div class="row"><span class="k">Negative</span><span class="v"><pre>${esc(r.negative_prompt)}</pre></span></div>` : ''}
${r.notes ? `<div class="row"><span class="k">Notes</span><span class="v">${esc(r.notes)}</span></div>` : ''}
<div class="acts">
<a target="_blank" rel="noopener noreferrer" href="https://wallco.ai/design/${r.id}">↗ Open on wallco.ai</a>
<button onclick="modalAction(${r.id}, '${r.is_published?'unpublish':'publish'}')">${r.is_published ? '↻ Unpublish' : '✓ Publish'}</button>
<button class="del" onclick="modalAction(${r.id}, 'delete')" style="color:#e08070; border-color:#4a2a26">🗑 Remove</button>
</div>`;
}
function modalShopify(r) {
const url = `https://designerwallcoverings.com/products/${esc(r.handle||'')}`;
return `
<h2>${esc(r.title || 'Untitled')}</h2>
<div class="sub">${esc(r.vendor||'')} · ${esc(r.status||'?')}</div>
<div class="row"><span class="k">Shopify ID</span><span class="v"><code>${esc(r.shopify_id||'')}</code></span></div>
<div class="row"><span class="k">DW SKU</span><span class="v"><code>${esc(r.dw_sku||'')}</code></span></div>
<div class="row"><span class="k">Mfr SKU</span><span class="v"><code>${esc(r.mfr_sku||r.sku||'')}</code></span></div>
<div class="row"><span class="k">Handle</span><span class="v"><code>${esc(r.handle||'')}</code></span></div>
<div class="row"><span class="k">Vendor</span><span class="v">${esc(r.vendor||'')} (prefix: ${esc(r.vendor_prefix||'?')})</span></div>
<div class="row"><span class="k">Type</span><span class="v">${esc(r.product_type||'')}</span></div>
<div class="row"><span class="k">Status</span><span class="v">${esc(r.status||'?')}</span></div>
<div class="row"><span class="k">Price</span><span class="v">${r.price?'$'+Number(r.price).toFixed(2):'?'} ${r.retail_price?`(retail $${Number(r.retail_price).toFixed(2)})`:''} ${r.cost_price?`(cost $${Number(r.cost_price).toFixed(2)})`:''}</span></div>
<div class="row"><span class="k">Pattern</span><span class="v">${esc(r.pattern_name||'')}</span></div>
<div class="row"><span class="k">Tags</span><span class="v">${chips(r.tags)}</span></div>
<div class="row"><span class="k">Full Monty</span><span class="v">${r.full_monty_at ? `✓ ${esc(r.full_monty_at)}` : '<span style="color:#666;font-style:italic">never</span>'}</span></div>
<div class="row"><span class="k">FM metafields</span><span class="v">${r.fm_metafields_verified ? '✓ verified' : (r.fm_metafields_missing ? `missing: ${esc(JSON.stringify(r.fm_metafields_missing))}` : '<span style="color:#666;font-style:italic">unset</span>')}</span></div>
<div class="row"><span class="k">Created (SP)</span><span class="v">${esc(r.created_at_shopify||'')}</span></div>
<div class="row"><span class="k">Synced</span><span class="v">${esc(r.synced_at||'')}</span></div>
<div class="acts">
<a target="_blank" rel="noopener noreferrer" href="https://admin.shopify.com/store/${SHOPIFY_STORE_SLUG}/products/${esc(r.shopify_id||'')}">S Open in Shopify Admin</a>
<a target="_blank" rel="noopener noreferrer" href="${url}">↗ Open on DW storefront</a>
</div>`;
}
async function modalAction(id, action) {
if (action === 'delete' && !confirm('Hard-delete this design? File → ~/.wallco-deleted/, DB row dropped.')) return;
const j = await fetch(`/api/bulk/${action}`, {
method:'POST', headers:{'content-type':'application/json'},
body: JSON.stringify({ ids:[id] })
}).then(r => r.json());
if (!j.ok) { toast('Error: ' + j.error, 'err'); return; }
toast(`${action}: ok`, 'ok');
closeModal();
await loadProducts();
}
// ── keyboard navigation ──────────────────────────────────────────────
let cursor = 0; // index into CURRENT.rows
function moveCursor(delta) {
if (!CURRENT.rows.length) return;
cursor = Math.max(0, Math.min(CURRENT.rows.length - 1, cursor + delta));
paintCursor();
}
function paintCursor() {
document.querySelectorAll('.card.focused').forEach(el => el.classList.remove('focused'));
if (!CURRENT.rows[cursor]) return;
const id = CURRENT.rows[cursor].id;
const el = document.querySelector(`.card[data-id="${id}"]`);
if (el) {
el.classList.add('focused');
el.scrollIntoView({ block:'nearest', behavior:'smooth' });
}
}
function toggleCursor() {
const r = CURRENT.rows[cursor]; if (!r || CURRENT.readonly) return;
if (selected.has(r.id)) selected.delete(r.id); else selected.add(r.id);
paintSelected(); updateSelUI();
}
window.addEventListener('keydown', e => {
// modal-open: Esc closes; everything else swallowed
if ($('#modal-bg').classList.contains('open')) {
if (e.key === 'Escape') closeModal();
return;
}
// search box focused: only handle Esc (handled by input's own listener) and don't intercept other keys
if (document.activeElement === $('#q')) return;
switch (e.key) {
case '/': e.preventDefault(); $('#q').focus(); $('#q').select(); break;
case 'j': case 'ArrowDown': e.preventDefault(); moveCursor(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cols'),10) || 1); break;
case 'k': case 'ArrowUp': e.preventDefault(); moveCursor(-(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cols'),10) || 1)); break;
case 'h': case 'ArrowLeft': e.preventDefault(); moveCursor(-1); break;
case 'l': case 'ArrowRight': e.preventDefault(); moveCursor(1); break;
case ' ': case 'x': e.preventDefault(); toggleCursor(); break;
case 'Enter': e.preventDefault(); if (CURRENT.rows[cursor]) openModal(CURRENT.rows[cursor].id); break;
case 'a': selectAll(); break;
case 'n': selectNone(); break;
case 'p': if (!CURRENT.readonly && selected.size) bulk('publish'); break;
case 'u': if (!CURRENT.readonly && selected.size) bulk('unpublish'); break;
case 'd': if (!CURRENT.readonly && selected.size) bulk('delete'); break;
case '?': toast('/=search · j/k/h/l=navigate · space/x=toggle · enter=detail · a=all · n=none · p=pub · u=unpub · d=del · 3-9=density', 'ok'); break;
default:
if (/^[3-9]$/.test(e.key)) {
const v = e.key; $('#cols').value = v; $('#cols-v').textContent = v;
document.documentElement.style.setProperty('--cols', v);
localStorage.setItem(LS.cols, v);
}
}
});
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);
});
$('#q').addEventListener('input', () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => loadProducts(), 240);
});
$('#q').addEventListener('keydown', e => { if (e.key === 'Escape') { e.target.value = ''; loadProducts(); } });
// 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>