← 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>