← back to Dw Collections Viewer

public/index.html

1267 lines

<!doctype html>
<html lang="en">
<head>
    <link rel="icon" type="image/svg+xml" href="/favicon.svg">

<meta charset="utf-8">
<title>DW Collections Viewer</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root {
  --bg: #0c0a08; --bg2: #15110d; --ink: #f4ecd8; --muted: #a39888; --faint: #5a5246;
  --rule: #2a241c; --gold: #c9b687; --green: #6b8e6f; --red: #c66c4d;
}
* { box-sizing: border-box }
body { margin: 0; background: var(--bg); color: var(--ink);
  font: 300 14px -apple-system, sans-serif; min-height: 100vh; display: flex; flex-direction: column }
a { color: inherit; text-decoration: none }
header { padding: 22px 28px; border-bottom: 1px solid var(--rule); display: flex;
  justify-content: space-between; align-items: center; gap: 16px; background: var(--bg2);
  position: sticky; top: 0; z-index: 5 }
h1 { font: 600 12px/1 inherit; letter-spacing: 0.32em; text-transform: uppercase; margin: 0; color: var(--gold) }
.meta { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--muted); font-weight: 600 }

main { flex: 1; display: grid; grid-template-columns: 1fr 480px; min-height: 0 }
.grid-wrap { padding: 24px 28px; overflow-y: auto }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 18px }

.card { background: var(--bg2); border: 1px solid var(--rule); cursor: pointer;
  display: flex; flex-direction: column; transition: border-color .2s, transform .15s }
.card:hover { border-color: var(--gold); transform: translateY(-1px) }
.card.active { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) }
/* Visually de-emphasize parked / redirected sites in the grid */
.card.is-redirect { opacity: 0.55 }
.card.is-redirect:hover { opacity: 0.85 }
.card .hero .status-banner {
  position: absolute; left: 0; right: 0; bottom: 0; padding: 4px 8px;
  font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 9px;
  letter-spacing: 0.10em; text-transform: uppercase; line-height: 1.3;
  background: rgba(12,10,8,0.82); backdrop-filter: blur(2px);
}
.card .hero .status-banner.redirect { color: var(--gold); border-top: 1px solid var(--gold) }
.card .hero .status-banner.error    { color: var(--red, #c87a6e); border-top: 1px solid var(--red, #c87a6e) }
.card .hero { aspect-ratio: 4/3; background: #000; position: relative; overflow: hidden }
.card .hero img { width: 100%; height: 100%; object-fit: cover; display: block }
.card .hero .empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  color: var(--faint); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase }
.card .info { padding: 12px 14px; border-top: 1px solid var(--rule) }
.card .name { font-size: 13px; font-weight: 500; letter-spacing: 0.02em; margin-bottom: 4px }
.card .stats { font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--muted); font-weight: 600 }
.card .stats .pill { display: inline-block; padding: 2px 6px; margin-left: 4px;
  background: var(--rule); color: var(--gold); border-radius: 0 }
.card .stats .pill.zero { color: var(--red) }
.card .stats .pill.pill-stale {
  color: #c66c4d; border: 1px solid #c66c4d; background: rgba(198,108,77,0.10);
  font-weight: 500; cursor: help;
}
.card .stats .pill.pill-sort {
  color: var(--gold); border: 1px solid var(--gold); background: rgba(212,160,74,0.08);
  font-weight: 500; cursor: help; letter-spacing: 0.04em;
}

aside { border-left: 1px solid var(--rule); background: var(--bg2);
  display: flex; flex-direction: column; min-height: 0 }
.detail-empty { padding: 48px 28px; text-align: center; color: var(--faint); font-size: 12px;
  letter-spacing: 0.18em; text-transform: uppercase; font-weight: 600 }
.detail { padding: 24px 28px; overflow-y: auto; flex: 1 }
.detail h2 { font-size: 18px; font-weight: 400; letter-spacing: -0.01em; margin: 0 0 6px }
.detail .domain { font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase; color: var(--muted);
  font-weight: 600; margin-bottom: 18px }
.detail .hero-large { aspect-ratio: 16/10; background: #000; margin-bottom: 18px; overflow: hidden;
  border: 1px solid var(--rule); position: relative; cursor: pointer }
.detail .hero-large img { width: 100%; height: 100%; object-fit: cover; display: block; transition: opacity .2s }
.detail .hero-large:hover img { opacity: 0.55 }
.detail .hero-large .drop-hint { position: absolute; inset: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 8px;
  color: var(--gold); font: 600 11px/1.4 inherit; letter-spacing: 0.32em; text-transform: uppercase;
  opacity: 0; transition: opacity .2s; pointer-events: none; text-align: center; padding: 20px }
.detail .hero-large .drop-hint .icon { font-size: 24px; letter-spacing: 0 }
.detail .hero-large .drop-hint .sub { font-size: 9px; color: var(--muted); letter-spacing: 0.22em }
.detail .hero-large:hover .drop-hint,
.detail .hero-large.empty-zone .drop-hint { opacity: 1 }
.detail .hero-large.empty-zone { background: var(--bg2); border-style: dashed }
.detail .hero-large.dropzone-active { border-color: var(--gold); box-shadow: inset 0 0 0 2px var(--gold); border-style: solid }
.detail .hero-large.dropzone-active img { opacity: 0.35 }
.detail .hero-large.dropzone-active .drop-hint { opacity: 1; color: var(--gold) }
.detail .hero-large .cover-badge { position: absolute; top: 8px; left: 8px; z-index: 2;
  font: 600 9px/1 inherit; letter-spacing: 0.22em; text-transform: uppercase;
  padding: 4px 7px; background: var(--gold); color: var(--bg); border: 0; cursor: default; pointer-events: none }
.detail .hero-large .cover-clear { position: absolute; top: 8px; right: 8px; z-index: 3;
  font: 600 9px/1 inherit; letter-spacing: 0.22em; text-transform: uppercase;
  padding: 4px 7px; background: rgba(12,10,8,0.78); color: var(--ink); border: 1px solid var(--rule); cursor: pointer }
.detail .hero-large .cover-clear:hover { border-color: var(--red); color: var(--red) }
.detail .hero-large input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; z-index: 1 }
.card .hero { position: relative }
.card .hero.dropzone-active { box-shadow: inset 0 0 0 2px var(--gold) }
.card .hero.dropzone-active::after { content: 'Drop'; position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center; background: rgba(12,10,8,0.78);
  color: var(--gold); font: 600 10px/1 inherit; letter-spacing: 0.32em; pointer-events: none }
.card .hero .cover-dot { position: absolute; top: 6px; right: 6px; width: 6px; height: 6px;
  background: var(--gold); border-radius: 50%; pointer-events: none }

.section { margin-bottom: 22px }
.section h3 { font-size: 10px; letter-spacing: 0.32em; text-transform: uppercase; color: var(--muted);
  font-weight: 600; margin: 0 0 10px }
.coll-search { width: 100%; background: var(--bg); border: 1px solid var(--rule); color: var(--ink);
  padding: 10px 12px; font-family: monospace; font-size: 12px; outline: none }
.coll-search:focus { border-color: var(--gold) }
.coll-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--rule); margin-top: 10px;
  background: var(--bg) }
.coll-item { display: flex; justify-content: space-between; align-items: center;
  padding: 8px 12px; border-bottom: 1px solid var(--rule); cursor: pointer; font-size: 12px;
  transition: background .1s }
.coll-item:last-child { border-bottom: 0 }
.coll-item:hover { background: var(--rule) }
.coll-item .handle { color: var(--gold); font-family: monospace; font-size: 11px }
.coll-item .title { color: var(--muted); margin-left: 8px; font-style: italic }
.coll-item .count { font-size: 10px; color: var(--faint); letter-spacing: 0.06em; font-weight: 600 }
.coll-item .add { font-size: 10px; padding: 3px 8px; background: var(--rule); border: 0;
  color: var(--gold); letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; font-weight: 600 }
.coll-item .add:hover { background: var(--gold); color: var(--bg) }

.assigned { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px }
.chip { background: var(--bg); border: 1px solid var(--gold); color: var(--gold);
  padding: 5px 10px; font-size: 11px; letter-spacing: 0.04em; display: inline-flex; align-items: center; gap: 6px }
.chip button { background: none; border: 0; color: var(--gold); cursor: pointer;
  padding: 0; font-size: 14px; line-height: 1; font-weight: 700 }
.chip button:hover { color: var(--red) }

.actions { display: flex; gap: 8px; margin-top: 18px; flex-wrap: wrap }
.btn { background: transparent; border: 1px solid var(--rule); color: var(--ink);
  padding: 10px 16px; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
  cursor: pointer; font-family: inherit; font-weight: 600 }
.btn:hover { border-color: var(--gold); color: var(--gold) }
.btn.go { background: var(--gold); color: var(--bg); border-color: var(--gold) }
.btn.go:hover { background: var(--bg); color: var(--gold) }
.btn:disabled { opacity: 0.4; cursor: not-allowed }

/* Products slider grid (right pane, below the hero) */
.products-slider { position: relative; margin-bottom: 22px }
/* Grid layout — every product visible at once (was horizontal scroll). */
.products-slider .row { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px;
  scroll-snap-type: x mandatory; padding-bottom: 8px;
  scrollbar-width: thin; scrollbar-color: var(--rule) transparent }
.products-slider .row::-webkit-scrollbar { height: 6px }
.products-slider .row::-webkit-scrollbar-thumb { background: var(--rule) }
.products-slider .pcard {
  background: var(--bg); border: 1px solid var(--rule); cursor: pointer;
  display: flex; flex-direction: column; transition: border-color .15s, transform .1s; position: relative }
.products-slider .pcard:hover { border-color: var(--gold) }
.products-slider .pcard { position: relative }
.products-slider .pcard .simbtn { position:absolute; top:4px; right:4px; z-index:2; width:22px; height:22px; padding:0;
  font-size:13px; line-height:20px; text-align:center; border:1px solid var(--rule); border-radius:6px;
  background:rgba(0,0,0,.6); color:var(--gold); cursor:pointer; opacity:0; transition:opacity .12s }
.products-slider .pcard:hover .simbtn { opacity:1 }
.products-slider .pcard .simbtn:hover { border-color:var(--gold); background:#000 }
#simPanel:empty { display:none }
#simPanel { margin:0 0 24px; padding:16px 18px; border:1px solid var(--rule); border-radius:12px; background:rgba(0,0,0,.25) }
#simPanel .simhead { font-size:12px; font-weight:700; color:var(--gold); text-transform:uppercase; letter-spacing:.08em;
  display:flex; align-items:center; gap:10px; margin-bottom:12px }
#simPanel .simx { cursor:pointer; color:var(--rule); font-weight:400; margin-left:auto }
#simPanel .simload, #simPanel .simnone { color:#9a8f7a; font-weight:400; text-transform:none; letter-spacing:0; font-size:13px }
#simPanel .simgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px }
#simPanel .simcard { border:1px solid var(--rule); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; background:#000 }
#simPanel .simcard .simimg { width:100%; aspect-ratio:1/1; background:#111 center/cover no-repeat }
#simPanel .simcard .simb { padding:6px 8px 8px; display:flex; flex-direction:column; gap:2px }
#simPanel .simcard .simsku { font-size:10.5px; font-weight:700; color:#e8e0d2 }
#simPanel .simcard .simttl { font-size:11px; color:#cfc7b8; line-height:1.25; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#simPanel .simcard .simvt { font-size:9.5px; color:#9a8f7a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#simPanel .simcard .simprice { font-size:11px; font-weight:700; color:var(--gold); margin-top:1px }
.products-slider .pcard.selected { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) }
.products-slider .pcard .img { aspect-ratio: 1/1; background: #000; overflow: hidden }
.products-slider .pcard .img img { width: 100%; height: 100%; object-fit: cover; display: block }
.products-slider .pcard .label { padding: 7px 9px; font-size: 10px; line-height: 1.35;
  letter-spacing: 0.02em; color: var(--ink); border-top: 1px solid var(--rule);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  min-height: 32px }
.products-slider .pcard .check { position: absolute; top: 4px; left: 4px; width: 16px; height: 16px;
  border: 1px solid var(--gold); background: rgba(12,10,8,0.78); color: var(--gold);
  font-size: 11px; line-height: 14px; text-align: center; font-weight: 700 }
.products-slider .pcard.selected .check { background: var(--gold); color: var(--bg) }
/* Resolution badge — shown on each product card after image probe */
.products-slider .pcard .res-badge {
  position: absolute; top: 4px; right: 4px;
  font: 600 9px/1.4 ui-monospace, "SF Mono", monospace;
  letter-spacing: 0.06em; padding: 2px 5px; border-radius: 2px;
  background: rgba(12,10,8,0.88); pointer-events: none; backdrop-filter: blur(2px);
}
.products-slider .pcard .res-badge.hd      { color: var(--green, #6b8e6f); border: 1px solid var(--green, #6b8e6f) }
.products-slider .pcard .res-badge.med     { color: var(--gold);          border: 1px solid var(--gold) }
.products-slider .pcard .res-badge.sd      { color: var(--red);           border: 1px solid var(--red) }
.products-slider .pcard .res-badge.probing { color: var(--faint);         border: 1px solid var(--rule) }
.products-slider .pcard.low-res { opacity: 0.55 }
/* Hero resolution warning on detail panel */
.detail .hero-res {
  margin: -12px 0 16px; padding: 8px 12px;
  font: 600 10px/1.4 ui-monospace, monospace; letter-spacing: 0.10em; text-transform: uppercase;
  border-left: 3px solid var(--gold);
}
.detail .hero-res.hd  { color: var(--green); border-color: var(--green); background: rgba(107,142,111,0.06) }
.detail .hero-res.med { color: var(--gold);  background: rgba(201,182,135,0.06) }
.detail .hero-res.sd  { color: var(--red);   border-color: var(--red); background: rgba(198,108,77,0.08) }
.products-slider .pcard.dragging { opacity: 0.45; outline: 1px dashed var(--gold) }
.products-slider .pcard[draggable="true"] { cursor: grab }
.products-slider .pcard[draggable="true"]:active { cursor: grabbing }
/* Pill-style label wrapping select / range inputs in the products bar */
.actions-mini .pill-label {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--mono, monospace); font-size: 10px; letter-spacing: 0.10em;
  text-transform: uppercase; color: var(--muted); border: 1px solid var(--rule);
  padding: 4px 9px; border-radius: 2px;
}
.actions-mini .pill-label select,
.actions-mini .pill-label input[type=range] {
  background: transparent; color: var(--ink); border: none; outline: none;
  font-family: inherit; font-size: 11px; cursor: pointer; min-width: 80px;
}
.actions-mini .pill-label input[type=range] { padding: 0; min-width: 100px; }
.products-slider .empty { padding: 24px; text-align: center; color: var(--faint);
  font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; border: 1px dashed var(--rule) }
.products-bar { display: flex; justify-content: space-between; align-items: center;
  margin-bottom: 10px; gap: 10px }
.products-bar .count { font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--muted); font-weight: 600 }
.products-bar .actions-mini { display: flex; gap: 6px }
.products-bar .actions-mini button { background: transparent; border: 1px solid var(--rule);
  color: var(--ink); padding: 4px 10px; font: 600 9px/1 inherit; letter-spacing: 0.22em;
  text-transform: uppercase; cursor: pointer }
.products-bar .actions-mini button:hover { border-color: var(--gold); color: var(--gold) }
.products-bar .actions-mini button.danger:hover { border-color: var(--red); color: var(--red) }
.toast { position: fixed; bottom: 22px; right: 22px; background: var(--bg2); border: 1px solid var(--gold);
  color: var(--ink); padding: 14px 22px; font-size: 12px; letter-spacing: 0.04em;
  display: none; z-index: 100; max-width: 460px }
.toast.show { display: block }
.toast.err { border-color: var(--red); color: var(--red) }
.toast.up { border-color: var(--green); color: var(--green) }
.toast.down { border-color: var(--red); color: var(--red) }

/* Bulk-mode toolbar */
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap }
.toolbar .search-q { flex: 1; min-width: 180px; background: var(--bg2); border: 1px solid var(--rule);
  color: var(--ink); padding: 8px 12px; font-family: monospace; font-size: 12px; outline: none }
.toolbar .search-q:focus { border-color: var(--gold) }
.toolbar .mode-toggle { background: var(--bg2); border: 1px solid var(--rule); color: var(--muted);
  padding: 8px 14px; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
  cursor: pointer; font-weight: 600; font-family: inherit }
.toolbar .mode-toggle.on { border-color: var(--gold); color: var(--gold) }
.bulk-bar { background: var(--bg2); border: 1px solid var(--gold); padding: 10px 14px; margin-bottom: 14px;
  display: flex; gap: 12px; align-items: center; font-size: 11px; letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--gold); font-weight: 600 }
.card .check { position: absolute; top: 8px; left: 8px; width: 20px; height: 20px;
  border: 1.5px solid #fff; background: rgba(0,0,0,0.55); display: none; align-items: center;
  justify-content: center; color: #fff; font-size: 14px; font-weight: 700; z-index: 2 }
body.bulk .card .check { display: flex }
body.bulk .card.selected .check { background: var(--gold); border-color: var(--gold); color: var(--bg) }
body.bulk .card.selected { border-color: var(--gold) }

/* Hero history (visual-diff) */
.history-row { display: flex; gap: 8px; margin-top: 10px; overflow-x: auto; padding-bottom: 6px }
.history-thumb { flex: 0 0 auto; width: 90px; height: 60px; border: 1px solid var(--rule);
  background: #000; overflow: hidden; position: relative }
.history-thumb img { width: 100%; height: 100%; object-fit: cover; display: block }
.history-thumb .ts { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(12,10,8,0.9);
  color: var(--muted); font-size: 8px; padding: 2px 4px; letter-spacing: 0.06em; font-weight: 600;
  text-align: center }

.suggest-list { background: var(--bg); border: 1px solid var(--gold); padding: 10px 12px; margin-top: 8px }
.suggest-list .suggest-head { font-size: 10px; letter-spacing: 0.32em; text-transform: uppercase;
  color: var(--gold); font-weight: 600; margin-bottom: 8px }
.suggest-list .coll-item { padding: 6px 8px; border-bottom: 1px solid var(--rule) }
.suggest-list .coll-item:last-child { border-bottom: 0 }

.pin-btn { background: transparent; border: 1px solid var(--rule); color: var(--muted);
  padding: 5px 10px; font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
  cursor: pointer; font-family: inherit; font-weight: 600; margin-top: 8px }
.pin-btn.pinned { color: var(--gold); border-color: var(--gold) }
.pin-btn:hover { color: var(--ink); border-color: var(--gold) }

@media (max-width: 1024px) { main { grid-template-columns: 1fr } aside { border-left: 0; border-top: 1px solid var(--rule) } }
</style>
</head>
<body>

<header>
  <div>
    <h1>DW Collections Viewer</h1>
  </div>
  <div class="meta"><span id="totalSites">—</span> sites · <span id="totalProducts">—</span> products</div>
</header>

<main>
  <div class="grid-wrap">
    <div class="toolbar">
      <input class="search-q" id="siteFilter" type="text" placeholder="filter sites by slug or domain…" oninput="filterSites(this.value)">
      <button class="mode-toggle" id="bulkToggle" onclick="toggleBulk()">☐ Bulk select</button>
      <button class="mode-toggle" onclick="autoSeed(true)" title="Preview without saving">★ Auto-seed (preview)</button>
      <button class="mode-toggle" onclick="autoSeed(false)" title="Assign top-suggested collection to every site that has zero">★ Auto-seed (apply)</button>
      <button class="mode-toggle" id="probeBtn" onclick="probeSites()" title="HEAD-probe every site to flag redirects / offline">📡 Probe live</button>
    </div>
    <div class="bulk-bar" id="bulkBar" style="display:none">
      <span><span id="bulkCount">0</span> selected</span>
      <button class="btn" onclick="selectAllVisible()">All visible</button>
      <button class="btn" onclick="selectNone()">Clear</button>
      <button class="btn" onclick="selectByCount('zero')">With 0 products</button>
      <button class="btn" onclick="selectByCount('thin')">Thin (&lt;30)</button>
      <button class="btn" onclick="selectByCount('stale')">Stale (&gt;30 days)</button>
      <button class="btn go" onclick="bulkRender()">Render selected</button>
    </div>
    <div class="grid" id="grid"></div>
  </div>

  <aside id="aside">
    <div class="detail-empty" id="emptyState">click a site on the left to begin</div>
    <div class="detail" id="detail" style="display:none"></div>
  </aside>
</main>

<div class="toast" id="toast"></div>

<script>
let SITES = {}, COLLS = [], CURRENT = null, COLL_FILTER = '';
let SITE_FILTER = '', BULK_MODE = false, BULK_SELECTED = new Set();
let SUGGESTIONS = {};  // slug → array of suggested collections
// Per-site products grid state (right pane). PRODUCTS holds the most-recently
// fetched, newest-first product list for CURRENT. PRODUCT_SELECTED is a Set of
// SKUs the user has bulk-checked. PRODUCTS_LOADING is true while a fetch is
// in flight (shown as a placeholder).
let PRODUCTS = [], PRODUCT_SELECTED = new Set(), PRODUCTS_LOADING = false;

function esc(s) { return String(s||'').replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c])); }
function toast(msg, ok = true, tone = null) {
  const t = document.getElementById('toast');
  t.textContent = msg;
  let cls = 'toast show';
  if (!ok) cls += ' err';
  else if (tone === 'up') cls += ' up';
  else if (tone === 'down') cls += ' down';
  t.className = cls;
  setTimeout(() => t.classList.remove('show'), 4000);
}

async function loadSites() {
  const r = await fetch('api/sites');
  const j = await r.json();
  SITES = j.sites;
  const slugs = Object.keys(SITES).sort();
  document.getElementById('totalSites').textContent = slugs.length;
  let total = 0; for (const k of slugs) total += SITES[k].product_count;
  document.getElementById('totalProducts').textContent = total.toLocaleString();
  renderGrid();
}

function renderGrid() {
  const slugs = Object.keys(SITES).sort();
  const f = SITE_FILTER.toLowerCase();
  const visible = slugs.filter(s => !f || s.toLowerCase().includes(f) || (SITES[s].domain || '').toLowerCase().includes(f));
  document.getElementById('grid').innerHTML = visible.map(slug => {
    const s = SITES[slug];
    const collCt = (s.collections || []).length;
    const cls = s.product_count === 0 ? ' zero' : '';
    const sel = BULK_SELECTED.has(slug) ? ' selected' : '';
    const pinned = s.hero_sku ? ' · 📌' : '';
    // Surface the live HTTP status — parked / 301-302 redirected sites get a banner
    // so the misleading hero (pulled from the redirect target) is no longer confusing.
    const isRedirect = s.http_status >= 300 && s.http_status < 400;
    const isError    = s.http_status === 0 || (s.http_status >= 500 && s.http_status < 600);
    const statusBanner = isRedirect
      ? `<div class="status-banner redirect">→ ${esc(s.redirect_target || '?')} (${s.http_status})</div>`
      : isError
      ? `<div class="status-banner error">offline · ${esc(s.redirect_target || s.http_status || '?')}</div>`
      : '';
    return `
      <div class="card${CURRENT === slug ? ' active' : ''}${sel}${isRedirect ? ' is-redirect' : ''}" data-slug="${slug}" onclick="cardClick(event,'${slug}')">
        <div class="check">${BULK_SELECTED.has(slug) ? '✓' : ''}</div>
        <div class="hero" data-slug="${slug}">
          ${s.cover_image_url || s.hero ? `<img src="${esc(s.cover_image_url || s.hero.image_url)}" alt="${esc((s.hero && s.hero.title) || slug)}" loading="lazy" onerror="this.style.opacity=0.15">` : `<div class="empty">no hero</div>`}
          ${s.cover_image_url ? `<div class="cover-dot" title="custom cover"></div>` : ''}
          ${statusBanner}
        </div>
        <div class="info">
          <div class="name">${esc(slug)}${pinned}</div>
          <div class="stats">${s.domain}<span class="pill${cls}">${s.product_count}</span><span class="pill">${collCt}c</span>${s.is_stale ? `<span class="pill pill-stale" title="Last rendered ${s.age_days} days ago — needs refresh">⟲ ${s.age_days}d</span>` : ''}${s.sort && s.sort !== 'textures' ? `<span class="pill pill-sort" title="Last deploy used sort=${esc(s.sort)}">↕ ${esc(s.sort)}</span>` : ''}</div>
        </div>
      </div>`;
  }).join('');
}

function filterSites(v) { SITE_FILTER = v; renderGrid(); }

function cardClick(e, slug) {
  if (BULK_MODE) {
    if (BULK_SELECTED.has(slug)) BULK_SELECTED.delete(slug); else BULK_SELECTED.add(slug);
    document.getElementById('bulkCount').textContent = BULK_SELECTED.size;
    renderGrid();
  } else {
    select(slug);
  }
}

function toggleBulk() {
  BULK_MODE = !BULK_MODE;
  document.body.classList.toggle('bulk', BULK_MODE);
  document.getElementById('bulkToggle').textContent = BULK_MODE ? '☑ Bulk on' : '☐ Bulk select';
  document.getElementById('bulkToggle').classList.toggle('on', BULK_MODE);
  document.getElementById('bulkBar').style.display = BULK_MODE ? 'flex' : 'none';
  if (!BULK_MODE) BULK_SELECTED.clear();
  renderGrid();
}

function selectAllVisible() {
  const f = SITE_FILTER.toLowerCase();
  Object.keys(SITES).forEach(s => {
    if (!f || s.toLowerCase().includes(f) || (SITES[s].domain || '').toLowerCase().includes(f)) BULK_SELECTED.add(s);
  });
  document.getElementById('bulkCount').textContent = BULK_SELECTED.size;
  renderGrid();
}
function selectNone() { BULK_SELECTED.clear(); document.getElementById('bulkCount').textContent = 0; renderGrid(); }
function selectByCount(mode) {
  Object.entries(SITES).forEach(([slug, s]) => {
    if (
      (mode === 'zero'  && s.product_count === 0) ||
      (mode === 'thin'  && s.product_count > 0 && s.product_count < 30) ||
      (mode === 'stale' && s.is_stale)
    ) BULK_SELECTED.add(slug);
  });
  document.getElementById('bulkCount').textContent = BULK_SELECTED.size;
  renderGrid();
}
async function probeSites() {
  const btn = document.getElementById('probeBtn');
  const orig = btn.textContent;
  btn.disabled = true; btn.textContent = '📡 probing…';
  try {
    const r = await fetch('api/sites/probe', { method: 'POST' });
    const j = await r.json();
    if (!r.ok) throw new Error(j.error || 'probe failed');
    toast(`Probed ${j.total} sites · ${j.counts.ok} ok · ${j.counts.redirect} redirects · ${j.counts.error} offline`, true);
    await loadSites();
  } catch (e) {
    toast('probe failed: ' + e.message, false);
  } finally {
    btn.disabled = false; btn.textContent = orig;
  }
}

async function autoSeed(dryRun) {
  const verb = dryRun ? 'Preview auto-seed' : 'Apply auto-seed';
  if (!dryRun && !confirm('Apply auto-seed across the fleet?\n\nThis assigns the top-scoring DW Shopify collection to every site that currently has zero collections. Sites with no high-confidence match are skipped (gaps reported).')) return;
  toast(`${verb}…`);
  const r = await fetch('api/auto-seed', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ dry_run: !!dryRun, min_score: 6 })
  });
  const j = await r.json();
  if (!r.ok) return toast('auto-seed failed: ' + (j.error || r.status), false);
  const lines = [
    `${dryRun ? 'PREVIEW' : 'APPLIED'} · ${j.assigned.length} assigned · ${j.gaps.length} gaps · ${j.skipped.length} skipped`,
    ...j.assigned.slice(0, 6).map(a => `  ✓ ${a.slug} → ${a.handle} (score ${a.score})`),
    ...(j.gaps.slice(0, 4).map(g => `  ⚠ ${g.slug} → no high-confidence match`)),
  ];
  console.log(lines.join('\n'));
  toast(lines.slice(0, 3).join(' · '));
  if (!dryRun) await loadSites();
}

async function bulkRender() {
  const slugs = Array.from(BULK_SELECTED);
  if (!slugs.length) return toast('select at least one site first', false);
  const withColls = slugs.filter(s => (SITES[s].collections || []).length > 0);
  const withoutColls = slugs.length - withColls.length;
  const msg = `Render ${withColls.length} site(s)?` + (withoutColls ? `\n\n${withoutColls} of the selected sites have NO collections assigned and will be skipped (use Save first).` : '\n\nThis will hit Shopify for each, deploy to Kamatera, and pm2-restart each site.');
  if (!confirm(msg)) return;
  toast(`bulk render: ${withColls.length} site(s) starting…`);
  const r = await fetch('api/render-bulk', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ slugs: withColls, deploy: true })
  });
  const j = await r.json();
  if (!r.ok) return toast('bulk render failed: ' + (j.error || r.status), false);
  const ok = j.results.filter(x => x.ok).length;
  const fail = j.results.length - ok;
  toast(`bulk done · ${ok} succeeded · ${fail} failed`);
  await loadSites();
}

async function loadCollections() {
  if (COLLS.length) return;
  toast('loading 250 DW Shopify collections…');
  const r = await fetch('api/collections');
  const j = await r.json();
  COLLS = j.collections || [];
  toast(`loaded ${COLLS.length} collections`);
}

async function select(slug) {
  CURRENT = slug;
  PRODUCT_SELECTED = new Set();
  PRODUCTS = [];
  document.querySelectorAll('.card').forEach(c => c.classList.toggle('active', c.dataset.slug === slug));
  document.getElementById('emptyState').style.display = 'none';
  document.getElementById('detail').style.display = 'block';
  await loadCollections();
  // Lazy-fetch suggestions when site has no collections assigned
  if ((SITES[slug].collections || []).length === 0 && !SUGGESTIONS[slug]) {
    try {
      const r = await fetch(`api/site/${slug}/suggest`);
      const j = await r.json();
      SUGGESTIONS[slug] = j.suggestions || [];
    } catch (e) { SUGGESTIONS[slug] = []; }
  }
  renderDetail();
  // Kick off products fetch in background so the UI isn't blocked
  fetchProducts(slug);
}

async function fetchProducts(slug) {
  PRODUCTS_LOADING = true;
  renderProductsSlider();
  try {
    const r = await fetch(`api/site/${slug}/products`);
    const j = await r.json();
    if (CURRENT !== slug) return;  // selection changed mid-fetch
    PRODUCTS = j.products || [];
  } catch (e) {
    PRODUCTS = [];
  } finally {
    PRODUCTS_LOADING = false;
    renderProductsSlider();
  }
}

// Sort + sizing state — persisted in localStorage so it survives reloads.
let PRODUCTS_SORT  = localStorage.getItem('dwc_sort') || 'newest';
let PRODUCTS_SIZE  = parseInt(localStorage.getItem('dwc_size') || '140', 10);
let HD_ONLY        = localStorage.getItem('dwc_hdonly') === '1';
function setHdOnly(v) { HD_ONLY = !!v; localStorage.setItem('dwc_hdonly', v ? '1' : '0'); renderProductsSlider(); }

// Image-resolution cache: sku → { w, h, tier }. Persists in sessionStorage.
const IMG_DIMS = (() => {
  try { return new Map(Object.entries(JSON.parse(sessionStorage.getItem('dwc_dims') || '{}'))); }
  catch { return new Map(); }
})();
function saveDims() {
  try { sessionStorage.setItem('dwc_dims', JSON.stringify(Object.fromEntries(IMG_DIMS))); } catch {}
}
function tierFor(w, h) {
  const m = Math.max(w || 0, h || 0);
  if (m >= 1500) return 'hd';
  if (m >= 800)  return 'med';
  return 'sd';
}
function tierLabel(t, w, h) {
  if (t === 'hd')  return `HD ${w}×${h}`;
  if (t === 'med') return `MED ${w}×${h}`;
  if (t === 'sd')  return `SD ${w}×${h}`;
  return '…';
}

// Upgrade Shopify CDN URL to its largest available variant.
// Strips any `_NxN` size suffix so the master is requested.
function largestShopifyImage(url) {
  if (!url || typeof url !== 'string') return url;
  if (!/cdn\.shopify\.com|designerwallcoverings\.com/.test(url)) return url;
  // Strip _200x200, _480x480, _800x, etc. before extension
  return url.replace(/(_\d+x\d*)(\.(jpg|jpeg|png|webp|gif))(\?|$)/i, '$2$4');
}

// Concurrent-limited image dimension probe
const _probeQueue = []; let _probeInFlight = 0;
function probeImage(url, sku, onDone) {
  if (IMG_DIMS.has(sku)) { onDone(IMG_DIMS.get(sku)); return; }
  _probeQueue.push({ url, sku, onDone });
  drainProbe();
}
function drainProbe() {
  while (_probeInFlight < 6 && _probeQueue.length) {
    const { url, sku, onDone } = _probeQueue.shift();
    _probeInFlight++;
    const im = new Image();
    im.onload = () => {
      const w = im.naturalWidth, h = im.naturalHeight;
      const rec = { w, h, tier: tierFor(w, h) };
      IMG_DIMS.set(sku, rec);
      saveDims();
      _probeInFlight--; drainProbe();
      onDone(rec);
    };
    im.onerror = () => { _probeInFlight--; drainProbe(); onDone({ w: 0, h: 0, tier: 'sd' }); };
    im.src = largestShopifyImage(url);
  }
}

// Color buckets for sort=color. Tags include literal color names like "Dark Green",
// "Beige", "Olive". We rank by where they fall on a hue cycle so similar colors
// cluster together in the grid.
const COLOR_RANK = {
  black: 0, gray: 1, grey: 1, silver: 2, white: 3, ivory: 4, cream: 5, beige: 6,
  brown: 7, tan: 8, khaki: 9, copper: 10,
  red: 20, pink: 21, coral: 22, orange: 23, peach: 24, salmon: 25,
  yellow: 30, gold: 31, mustard: 32, olive: 33,
  green: 40, lime: 41, mint: 42, teal: 50, aqua: 51, turquoise: 52, cyan: 53,
  blue: 60, navy: 61, indigo: 62, periwinkle: 63,
  purple: 70, violet: 71, lavender: 72, plum: 73, magenta: 74, fuchsia: 75,
};
function colorRank(p) {
  const tags = (p.tags || []).map(t => String(t).toLowerCase());
  for (const t of tags) for (const k of Object.keys(COLOR_RANK)) if (t.includes(k)) return COLOR_RANK[k];
  return 999;
}
const STYLE_TAGS = ['traditional','transitional','modern','contemporary','minimalist','art deco','victorian','mid-century','mid century','rustic','industrial','farmhouse','bohemian','boho','geometric','floral','botanical','damask','toile','grasscloth','silk','linen','vinyl','natural'];
function styleKey(p) {
  const tags = (p.tags || []).map(t => String(t).toLowerCase());
  for (const s of STYLE_TAGS) if (tags.some(t => t.includes(s))) return s;
  return 'zzz';
}

function sortProducts(products, mode) {
  const arr = [...products];
  if (mode === 'sku')   arr.sort((a, b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')));
  else if (mode === 'color') arr.sort((a, b) => colorRank(a) - colorRank(b) || String(a.title || '').localeCompare(String(b.title || '')));
  else if (mode === 'style') arr.sort((a, b) => styleKey(a).localeCompare(styleKey(b)) || String(a.title || '').localeCompare(String(b.title || '')));
  // 'newest' = leave as-returned (server already orders newest-first)
  return arr;
}

function renderProductsSlider() {
  const el = document.getElementById('products-slider');
  if (!el) return;
  if (PRODUCTS_LOADING) {
    el.innerHTML = `<div class="empty">loading products…</div>`;
    return;
  }
  if (!PRODUCTS.length) {
    el.innerHTML = `<div class="empty">no products yet · add a collection then save+render</div>`;
    return;
  }
  const sorted = sortProducts(PRODUCTS, PRODUCTS_SORT);
  // Optional filter — show only HD-grade source images
  const filtered = HD_ONLY ? sorted.filter(p => {
    const sku = String(p.sku || p.handle || '');
    const d = IMG_DIMS.get(sku);
    return d && d.tier === 'hd';
  }) : sorted;
  const cards = filtered.map(p => {
    const sku = String(p.sku || p.handle || '');
    const sel = PRODUCT_SELECTED.has(sku) ? ' selected' : '';
    const dim = IMG_DIMS.get(sku);
    const tier = dim ? dim.tier : null;
    const lowResCls = tier === 'sd' ? ' low-res' : '';
    // Use the largest Shopify variant for the card thumbnail too — sharper preview
    const bigUrl = largestShopifyImage(p.image_url || '');
    const img = bigUrl ? `<img src="${esc(bigUrl)}" alt="${esc(p.title || '')}" loading="lazy" data-probe-sku="${esc(sku)}" onerror="this.style.opacity=0.15">` : '';
    const badge = tier
      ? `<span class="res-badge ${tier}">${esc(tierLabel(tier, dim.w, dim.h))}</span>`
      : `<span class="res-badge probing" data-probe-badge="${esc(sku)}">…</span>`;
    const tipLines = [p.title || sku];
    if (p.sku) tipLines.push(`SKU: ${p.sku}`);
    if (dim) tipLines.push(`Image: ${dim.w}×${dim.h} (${dim.tier.toUpperCase()})`);
    if ((p.tags || []).length) tipLines.push(`Tags: ${(p.tags || []).slice(0, 6).join(', ')}`);
    tipLines.push('Drag to cover image to set as hero · pick HD for cover');
    const ts = p.published_at || p.created_at;
    if (ts) {
      const d = new Date(ts);
      if (!isNaN(d)) tipLines.push(`Published: ${d.toISOString().slice(0,16).replace('T', ' ')} UTC`);
    }
    return `<div class="pcard${sel}${lowResCls}" draggable="true" data-sku="${esc(sku)}" data-image="${esc(bigUrl || '')}" data-title="${esc(p.title || '')}" onclick="toggleProductSelect('${esc(sku)}')" title="${esc(tipLines.join('\n'))}">
      <div class="check">${PRODUCT_SELECTED.has(sku) ? '✓' : ''}</div>
      ${badge}
      <button class="simbtn" title="Search the catalog for a similar pattern" onclick="event.stopPropagation();loadSim('${esc(sku)}')">≈</button>
      <div class="img">${img}</div>
      <div class="label">${esc(p.title || sku)}</div>
    </div>`;
  }).join('');
  const selCount = PRODUCT_SELECTED.size;
  const sortLabel = { newest:'newest first', sku:'SKU A→Z', color:'by color', style:'by style' }[PRODUCTS_SORT] || PRODUCTS_SORT;
  // Count of HD/MED/SD already known so the bar shows progress
  const dimCount = sorted.reduce((acc, p) => {
    const sku = String(p.sku || p.handle || '');
    const d = IMG_DIMS.get(sku);
    if (!d) acc.unknown++;
    else acc[d.tier]++;
    return acc;
  }, { hd: 0, med: 0, sd: 0, unknown: 0 });
  el.innerHTML = `
    <div class="products-bar">
      <div class="count">${PRODUCTS.length} products${selCount ? ` · ${selCount} selected` : ''} · ${sortLabel} · <span style="color:var(--green)">${dimCount.hd} HD</span> · <span style="color:var(--gold)">${dimCount.med} MED</span> · <span style="color:var(--red)">${dimCount.sd} SD</span>${dimCount.unknown ? ` · <span style="color:var(--faint)">${dimCount.unknown} probing</span>` : ''}</div>
      <div class="actions-mini">
        <label class="pill-label">Sort:
          <select onchange="setSort(this.value)">
            <option value="newest" ${PRODUCTS_SORT==='newest'?'selected':''}>Newest</option>
            <option value="color"  ${PRODUCTS_SORT==='color' ?'selected':''}>Color</option>
            <option value="style"  ${PRODUCTS_SORT==='style' ?'selected':''}>Style</option>
            <option value="sku"    ${PRODUCTS_SORT==='sku'   ?'selected':''}>SKU</option>
          </select>
        </label>
        <label class="pill-label">Size:
          <input type="range" min="80" max="260" step="10" value="${PRODUCTS_SIZE}" oninput="setGridSize(this.value)" title="Grid card width" />
        </label>
        <label class="pill-label" title="Show only products whose source image is ≥1500px (HD enough for full-page hero)">
          <input type="checkbox" ${HD_ONLY ? 'checked' : ''} onchange="setHdOnly(this.checked)" /> HD only
        </label>
        ${selCount ? `<button onclick="clearProductSelection()">Clear (${selCount})</button>` : ''}
        ${selCount ? `<button onclick="copySelectedSkus()">Copy SKUs</button>` : ''}
        ${selCount ? `<button onclick="removeSelectedFromList()">Remove (${selCount})</button>` : ''}
        <button onclick="selectAllProducts()">Select all</button>
      </div>
    </div>
    <div class="row" style="grid-template-columns:repeat(auto-fill,minmax(${PRODUCTS_SIZE}px,1fr))">${cards}</div>
  `;
  wireProductDrag();
  // Kick off concurrent dimension probes for any cards whose dims aren't cached.
  for (const p of filtered) {
    const sku = String(p.sku || p.handle || '');
    if (IMG_DIMS.has(sku)) continue;
    const url = largestShopifyImage(p.image_url || '');
    if (!url) continue;
    probeImage(url, sku, rec => {
      const badge = document.querySelector(`[data-probe-badge="${CSS.escape(sku)}"]`);
      if (badge) {
        badge.className = 'res-badge ' + rec.tier;
        badge.textContent = tierLabel(rec.tier, rec.w, rec.h);
      }
      const card = document.querySelector(`.pcard[data-sku="${CSS.escape(sku)}"]`);
      if (card && rec.tier === 'sd') card.classList.add('low-res');
    });
  }
}

// "≈" on a product tile → proxied all.dw /api/similar → attribute-scored LIVE substitutes
// rendered in a shared panel below the slider (tiles are too small for an inline drawer).
async function loadSim(sku) {
  const box = document.getElementById('simPanel');
  if (!box) return;
  box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  box.innerHTML = `<div class="simhead">Similar live patterns — ${esc(sku)} <span class="simload">searching…</span><span class="simx" title="close">✕</span></div>`;
  box.querySelector('.simx').onclick = () => { box.innerHTML = ''; };
  try {
    const d = await (await fetch('/api/similar?sku=' + encodeURIComponent(sku) + '&limit=12')).json();
    const rows = (d && d.rows) || [];
    const ld = box.querySelector('.simload'); if (ld) ld.remove();
    if (!rows.length) { box.insertAdjacentHTML('beforeend', '<div class="simnone">No similar live patterns found.</div>'); return; }
    const g = document.createElement('div'); g.className = 'simgrid'; box.appendChild(g);
    rows.forEach(r => {
      const price = r.price ? ('$' + Number(r.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })) : '';
      const img = r.image || (r.images && r.images[0]) || '';
      const c = document.createElement('div'); c.className = 'simcard';
      c.innerHTML = '<div class="simimg"></div><div class="simb"><div class="simsku">' + esc(r.sku || '—') + '</div>'
        + '<div class="simttl" title="' + esc(r.title || '') + '">' + esc(r.title || '') + '</div>'
        + '<div class="simvt">' + esc([r.vendor, r.type].filter(Boolean).join(' · ')) + '</div>'
        + (price ? '<div class="simprice">' + price + '</div>' : '') + '</div>';
      if (img) c.querySelector('.simimg').style.backgroundImage = 'url("' + largestShopifyImage(img) + '")';
      if (r.url) { c.style.cursor = 'pointer'; c.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
      g.appendChild(c);
    });
  } catch (e) {
    box.innerHTML = '<div class="simnone">Similarity search unavailable — try again.</div>';
  }
}

function setSort(v) {
  PRODUCTS_SORT = v;
  localStorage.setItem('dwc_sort', v);
  renderProductsSlider();
}

function setGridSize(v) {
  PRODUCTS_SIZE = parseInt(v, 10);
  localStorage.setItem('dwc_size', String(PRODUCTS_SIZE));
  // Live update without re-render so dragging the slider doesn't tear the grid
  const row = document.querySelector('#products-slider .row');
  if (row) row.style.gridTemplateColumns = `repeat(auto-fill,minmax(${PRODUCTS_SIZE}px,1fr))`;
}

// Wire HTML5 drag from .pcard → drop on .hero-large
function wireProductDrag() {
  document.querySelectorAll('#products-slider .pcard').forEach(card => {
    card.addEventListener('dragstart', e => {
      const sku = card.dataset.sku;
      const image = card.dataset.image;
      const title = card.dataset.title;
      e.dataTransfer.setData('application/x-dwc-product', JSON.stringify({ sku, image, title }));
      e.dataTransfer.setData('text/plain', sku);
      e.dataTransfer.effectAllowed = 'copy';
      card.classList.add('dragging');
    });
    card.addEventListener('dragend', () => card.classList.remove('dragging'));
  });
  const hero = document.querySelector('#detail .hero-large');
  if (!hero) return;
  hero.addEventListener('dragover', e => {
    if (![...e.dataTransfer.types].includes('application/x-dwc-product')) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'copy';
    hero.classList.add('dropzone-active');
  });
  hero.addEventListener('dragleave', () => hero.classList.remove('dropzone-active'));
  hero.addEventListener('drop', async e => {
    e.preventDefault();
    hero.classList.remove('dropzone-active');
    let payload;
    try { payload = JSON.parse(e.dataTransfer.getData('application/x-dwc-product')); } catch { return; }
    if (!payload?.sku) return;
    toast(`pinning ${payload.title || payload.sku} as cover…`);
    const r = await fetch(`api/site/${CURRENT}/hero`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ sku: payload.sku }),
    });
    const j = await r.json();
    if (!r.ok) return toast('drop failed: ' + (j.error || r.status), false);
    SITES[CURRENT].hero_sku = payload.sku;
    if (payload.image && SITES[CURRENT].hero) SITES[CURRENT].hero = { ...SITES[CURRENT].hero, sku: payload.sku, title: payload.title || SITES[CURRENT].hero.title, image_url: payload.image };
    toast(`✓ pinned ${payload.title || payload.sku}`, true);
    renderDetail();
  });
}

function toggleProductSelect(sku) {
  if (PRODUCT_SELECTED.has(sku)) PRODUCT_SELECTED.delete(sku);
  else PRODUCT_SELECTED.add(sku);
  renderProductsSlider();
}
function clearProductSelection() { PRODUCT_SELECTED.clear(); renderProductsSlider(); }
function selectAllProducts() {
  PRODUCTS.forEach(p => PRODUCT_SELECTED.add(String(p.sku || p.handle || '')));
  renderProductsSlider();
}
function copySelectedSkus() {
  const skus = Array.from(PRODUCT_SELECTED).join('\n');
  navigator.clipboard.writeText(skus).then(() => toast(`copied ${PRODUCT_SELECTED.size} SKUs to clipboard`));
}
async function removeSelectedFromList() {
  const slug = CURRENT;
  const skus = Array.from(PRODUCT_SELECTED);
  if (!skus.length) return;
  if (!confirm(`Remove ${skus.length} product(s) from ${slug}'s local products.json? Live site is unchanged until you redeploy.`)) return;
  toast(`removing ${skus.length} from ${slug}…`);
  const r = await fetch(`api/site/${slug}/products/remove`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ skus }),
  });
  const j = await r.json();
  if (!r.ok) return toast('remove failed: ' + (j.error || r.status), false);
  toast(`removed ${j.removed} · ${j.before} → ${j.after} (not deployed)`, true, j.removed > 0 ? 'down' : null);
  PRODUCT_SELECTED.clear();
  await fetchProducts(slug);
}

function renderDetail() {
  const s = SITES[CURRENT];
  if (!s) return;
  const rawCoverUrl = s.cover_image_url || (s.hero && s.hero.image_url) || null;
  const coverUrl = rawCoverUrl ? largestShopifyImage(rawCoverUrl) : null;
  const coverTitle = s.cover_image_url ? 'custom cover' : (s.hero && s.hero.title) || '';
  const isEmpty = !coverUrl;
  // Hero resolution warning — probe in background and inject a banner.
  const heroSku = (s.hero && s.hero.sku) || 'site-cover-' + CURRENT;
  const heroDim = IMG_DIMS.get(heroSku);
  const hint = `<div class="drop-hint">
        <div class="icon">⤓</div>
        <div>${isEmpty ? 'Drop image · or click to choose' : 'Drop · or click to replace'}</div>
        <div class="sub">jpg · png · webp · gif · ≤8MB</div>
      </div>`;
  const fileInput = `<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" onchange="onCoverFilePicked(event)">`;
  const heroImg = coverUrl
    ? `<img src="${esc(coverUrl)}" alt="${esc(coverTitle)}">${s.cover_image_url ? '<div class="cover-badge">custom</div><button class="cover-clear" onclick="clearCover(event)">clear</button>' : ''}${hint}${fileInput}`
    : `${hint}${fileInput}`;
  const heroTitle = coverTitle ? `<div style="font-size:11px;color:var(--muted);margin-bottom:18px;font-style:italic">${esc(coverTitle)}</div>` : '';

  const assigned = s.collections || [];
  const assignedChips = assigned.length ? assigned.map(h => {
    const cobj = COLLS.find(c => c.handle === h);
    return `<span class="chip">${esc(h)}${cobj ? ` <span style="opacity:0.5">${esc(cobj.title.slice(0,28))}</span>` : ''}<button onclick="rmColl('${esc(h)}')" title="Remove">×</button></span>`;
  }).join('') : `<span style="color:var(--faint);font-size:11px;letter-spacing:0.18em;text-transform:uppercase">no collections assigned · this site still uses regex curation</span>`;

  // Filter unassigned collections
  const f = COLL_FILTER.toLowerCase();
  const available = COLLS.filter(c => !assigned.includes(c.handle) && (!f || c.handle.includes(f) || c.title.toLowerCase().includes(f))).slice(0, 60);
  const collList = available.map(c => `
    <div class="coll-item">
      <div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
        <span class="handle">${esc(c.handle)}</span>
        <span class="title">${esc(c.title)}</span>
      </div>
      <span class="count">${c.products_count}</span>
      <button class="add" onclick="addColl('${esc(c.handle)}')">+ add</button>
    </div>`).join('') || '<div style="padding:18px;color:var(--faint);text-align:center;font-size:11px;letter-spacing:0.18em;text-transform:uppercase">no matches</div>';

  // Hero resolution banner: text varies based on probed dimensions
  let heroResBanner = '';
  if (coverUrl) {
    if (heroDim) {
      const t = heroDim.tier;
      const msg = t === 'hd'  ? `✓ Hero is HD (${heroDim.w}×${heroDim.h}) — looks sharp full-page`
                : t === 'med' ? `△ Hero is MED (${heroDim.w}×${heroDim.h}) — may look soft on large screens; pick HD if available`
                              : `✗ Hero is SD (${heroDim.w}×${heroDim.h}) — will look BLURRY on full-page hero. Drag a HD-tagged product onto the cover.`;
      heroResBanner = `<div class="hero-res ${t}">${esc(msg)}</div>`;
    } else {
      heroResBanner = `<div class="hero-res med" id="heroResBanner">… probing hero resolution</div>`;
    }
  }
  document.getElementById('detail').innerHTML = `
    <h2>${esc(CURRENT)}</h2>
    <div class="domain">${esc(s.domain)} · ${s.product_count} products live</div>
    <div class="hero-large${isEmpty ? ' empty-zone' : ''}">${heroImg}</div>
    ${heroResBanner}
    ${heroTitle}

    <div class="section">
      <h3>All products · newest first</h3>
      <div class="products-slider" id="products-slider"></div>
      <div id="simPanel"></div>
    </div>

    <div class="section">
      <h3>Current regex rules</h3>
      <div style="font-family:monospace;font-size:11px;color:var(--muted);line-height:1.6;background:var(--bg);padding:10px 12px;border:1px solid var(--rule)">
        tags · <span style="color:var(--gold)">${esc(s.tags_re)}</span><br>
        title · <span style="color:var(--gold)">${esc(s.title_re)}</span><br>
        mode · <span style="color:var(--gold)">${esc(s.match_mode)}</span>
      </div>
    </div>

    <div class="section">
      <h3>Hero pin</h3>
      <button class="pin-btn ${s.hero_sku ? 'pinned' : ''}" onclick="togglePin()">
        ${s.hero_sku ? `📌 pinned: ${esc(s.hero_sku.slice(0, 36))} · click to unpin` : '📌 pin current hero'}
      </button>
    </div>

    ${(s.hero_history && s.hero_history.length) ? `
      <div class="section">
        <h3>Hero history (last ${s.hero_history.length})</h3>
        <div class="history-row">
          ${s.hero_history.map(h => `
            <div class="history-thumb">
              <img src="${esc(h.image_url)}" alt="${esc(h.title)}" loading="lazy">
              <div class="ts">${esc(new Date(h.ts).toLocaleDateString('en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }))}</div>
            </div>
          `).join('')}
        </div>
      </div>
    ` : ''}

    <div class="section">
      <h3>Assigned Shopify Collections (${assigned.length})</h3>
      <div class="assigned">${assignedChips}</div>
    </div>

    ${(assigned.length === 0 && (SUGGESTIONS[CURRENT] || []).length) ? `
      <div class="suggest-list">
        <div class="suggest-head">★ suggested for ${esc(CURRENT)}</div>
        ${SUGGESTIONS[CURRENT].map(c => `
          <div class="coll-item">
            <div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
              <span class="handle">${esc(c.handle)}</span>
              <span class="title">${esc(c.title)}</span>
            </div>
            <span class="count">${c.products_count}</span>
            <button class="add" onclick="addColl('${esc(c.handle)}')">+ add</button>
          </div>
        `).join('')}
      </div>
    ` : ''}

    <div class="section">
      <h3>Add a Shopify Collection</h3>
      <input class="coll-search" type="text" placeholder="search ${COLLS.length} DW collections… (press C)" oninput="filterColls(this.value)" value="${esc(COLL_FILTER)}">
      <div class="coll-list">${collList}</div>
    </div>

    <div class="actions">
      <button class="btn" onclick="saveColls()">Save collections</button>
      <label class="deploy-sort" style="display:inline-flex;align-items:center;gap:6px;font-size:11px;letter-spacing:.06em;color:#999;text-transform:uppercase">
        Sort:
        <select id="deploySortSel" onchange="localStorage.setItem('dwc_deploy_sort', this.value)" style="background:#0e0e10;color:#f4f1ea;border:1px solid #2a2a32;padding:5px 8px;font-family:inherit;font-size:11px">
          <option value="textures">textures first (default)</option>
          <option value="newest">newest</option>
          <option value="title">title A→Z</option>
          <option value="sku">SKU A→Z</option>
          <option value="priceUp">price ↑</option>
          <option value="priceDown">price ↓</option>
          <option value="random">random</option>
        </select>
      </label>
      <button class="btn go" onclick="saveAndDeploy()" ${assigned.length === 0 ? 'disabled' : ''}>💾 Save &amp; Deploy</button>
      <button class="btn" onclick="previewSort()" ${assigned.length === 0 ? 'disabled' : ''} title="Re-render local products.json with the chosen sort, but do NOT deploy. Lets you see the order before pushing live.">👁 Preview sort</button>
      <button class="btn" onclick="renderSite()" ${assigned.length === 0 ? 'disabled' : ''}>Render now (live)</button>
      <a class="btn" href="https://${s.domain}" target="_blank" rel="noopener noreferrer" style="text-decoration:none">Open site ↗</a>
    </div>
    <` + `script>
      // Restore saved deploy sort. Precedence: per-site rules.sort (server) > localStorage > default.
      (function(){
        const sel = document.getElementById('deploySortSel');
        if (!sel) return;
        const perSite = (window.SITES && window.CURRENT && SITES[CURRENT]?.sort) || null;
        const fallback = localStorage.getItem('dwc_deploy_sort');
        const v = perSite || fallback;
        if (v && Array.from(sel.options).some(o => o.value === v)) sel.value = v;
      })();
    <\/script>
  `;
  // Hydrate the products slider after the detail HTML is in the DOM.
  renderProductsSlider();
  // Probe hero resolution if not already cached, then update the banner in place.
  if (coverUrl && !heroDim) {
    probeImage(coverUrl, heroSku, rec => {
      const banner = document.getElementById('heroResBanner');
      if (!banner) return;
      const t = rec.tier;
      const msg = t === 'hd'  ? `✓ Hero is HD (${rec.w}×${rec.h}) — looks sharp full-page`
                : t === 'med' ? `△ Hero is MED (${rec.w}×${rec.h}) — may look soft on large screens; pick HD if available`
                              : `✗ Hero is SD (${rec.w}×${rec.h}) — will look BLURRY on full-page hero. Drag a HD-tagged product onto the cover.`;
      banner.className = 'hero-res ' + t;
      banner.textContent = msg;
    });
  }
}

function filterColls(v) { COLL_FILTER = v; renderDetail(); }

async function togglePin() {
  const s = SITES[CURRENT];
  const newSku = s.hero_sku ? null : (s.hero && s.hero.sku ? s.hero.sku : null);
  if (!newSku && !s.hero_sku) return toast('no hero to pin yet', false);
  const r = await fetch(`api/site/${CURRENT}/hero`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ sku: newSku })
  });
  const j = await r.json();
  if (!r.ok) return toast('pin failed: ' + (j.error || r.status), false);
  s.hero_sku = newSku;
  toast(newSku ? `pinned hero · ${newSku.slice(0,30)}` : 'unpinned · sort will reshuffle on next render');
  renderDetail();
  renderGrid();
}
async function addColl(h) {
  const s = SITES[CURRENT]; if (!s.collections) s.collections = [];
  if (!s.collections.includes(h)) s.collections.push(h);
  renderDetail();
  await persistCollsAndRefresh();
}
async function rmColl(h) {
  const s = SITES[CURRENT]; s.collections = (s.collections || []).filter(x => x !== h);
  renderDetail();
  await persistCollsAndRefresh();
}

// Auto-persist + auto-render (no deploy) so the products grid updates immediately
// when the user adds/removes a collection. Live site unchanged until the
// "Render now (live)" button is clicked.
//
// Coalesces rapid clicks: if a render is already in flight, mark _persistPending
// and re-run with the latest state when the current cycle finishes. Without this
// coalescing, fast +ADD clicks would silently drop their changes because the
// fetch body was bound to an older SITES snapshot.
let _persistInflight = null;
let _persistPending = false;
async function persistCollsAndRefresh() {
  if (_persistInflight) { _persistPending = true; return _persistInflight; }
  _persistInflight = (async () => {
    try {
      do {
        _persistPending = false;
        const slug = CURRENT;
        const before = (PRODUCTS && PRODUCTS.length) || 0;
        const r1 = await fetch(`api/site/${slug}/collections`, {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ collections: SITES[slug].collections }),
        });
        if (!r1.ok) { toast('save failed', false); return; }
        PRODUCTS_LOADING = true; renderProductsSlider();
        const r2 = await fetch(`api/site/${slug}/render`, {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ deploy: false }),
        });
        const j2 = await r2.json();
        if (!r2.ok) { toast('preview render failed: ' + (j2.error || r2.status), false); return; }
        const after = j2.total_unique || 0;
        const delta = after - before;
        const sign = delta > 0 ? `+${delta}` : `${delta}`;
        const tone = delta > 0 ? 'up' : (delta < 0 ? 'down' : null);
        toast(`previewed · ${before} → ${after} products (${sign}, not deployed)`, true, tone);
        await fetchProducts(slug);
        await loadSites();
      } while (_persistPending && CURRENT);
    } finally {
      _persistInflight = null;
    }
  })();
  return _persistInflight;
}

async function saveColls() {
  const r = await fetch(`api/site/${CURRENT}/collections`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ collections: SITES[CURRENT].collections })
  });
  const j = await r.json();
  if (!r.ok) return toast('save failed: ' + (j.error || r.status), false);
  toast(`saved · ${j.collections.length} collections assigned to ${CURRENT}`);
}

function getDeploySort() {
  const sel = document.getElementById('deploySortSel');
  return (sel && sel.value) || localStorage.getItem('dwc_deploy_sort') || 'textures';
}

// Preview a sort locally without deploying. Re-fetches Shopify with the chosen sort, writes
// the local products.json (but skips scp + pm2 restart), then reloads the products slider so
// the user can eyeball the new order before committing to a live deploy.
async function previewSort() {
  const sort = getDeploySort();
  toast(`previewing ${CURRENT} (sort=${sort}, no deploy)…`);
  const r = await fetch(`api/site/${CURRENT}/render`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ deploy: false, sort })
  });
  const j = await r.json();
  if (!r.ok) return toast('preview failed: ' + (j.error || r.status), false);
  toast(`preview ready · ${j.total_unique} products in "${sort}" order (NOT deployed — hit Save & Deploy when ready)`);
  await loadSites();
  renderDetail();
}

async function renderSite() {
  const sort = getDeploySort();
  if (!confirm(`Re-render ${CURRENT} from ${SITES[CURRENT].collections.length} Shopify collection(s), sorted "${sort}"? Overwrites products.json + scp + pm2 restart.`)) return;
  toast(`rendering ${CURRENT} (sort=${sort})…`);
  const r = await fetch(`api/site/${CURRENT}/render`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ deploy: true, sort })
  });
  const j = await r.json();
  if (!r.ok) return toast('render failed: ' + (j.error || r.status), false);
  toast(`${CURRENT} rendered · ${j.total_unique} products · sort=${sort} · deployed: ${j.deployed}`);
  await loadSites();
  renderDetail();
}

// One-click: persist collection list, then render+deploy live. Single confirm.
async function saveAndDeploy() {
  const slug = CURRENT;
  const colls = SITES[slug].collections || [];
  if (!colls.length) return toast('no collections assigned', false);
  const sort = getDeploySort();
  if (!confirm(`Save & deploy ${slug} (sort: ${sort})? Persists ${colls.length} collection(s), re-renders products.json from Shopify in "${sort}" order, scp + pm2 restart.`)) return;
  toast(`saving ${slug}…`);
  const r1 = await fetch(`api/site/${slug}/collections`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ collections: colls }),
  });
  const j1 = await r1.json();
  if (!r1.ok) return toast('save failed: ' + (j1.error || r1.status), false);
  toast(`saved · deploying ${slug} (sort=${sort})…`);
  const r2 = await fetch(`api/site/${slug}/render`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ deploy: true, sort }),
  });
  const j2 = await r2.json();
  if (!r2.ok) return toast('deploy failed: ' + (j2.error || r2.status), false);
  toast(`${slug} live · ${j2.total_unique} products · sort=${sort} · deployed: ${j2.deployed}`);
  await loadSites();
  renderDetail();
}

// ── Drag-and-drop / click-to-browse cover replacement ───────────────────────
// Delegated dragover/drop on document so freshly-rendered tiles work without rebinding.
const COVER_MAX_BYTES = 8 * 1024 * 1024;
const COVER_OK_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];

function findDropTarget(target) {
  const big = target.closest && target.closest('.detail .hero-large');
  if (big) {
    const slug = CURRENT;
    return slug ? { el: big, slug } : null;
  }
  const card = target.closest && target.closest('.card .hero');
  if (card) {
    const slug = card.dataset.slug || (card.closest('.card') && card.closest('.card').dataset.slug);
    return slug ? { el: card, slug } : null;
  }
  return null;
}

async function uploadCoverFile(slug, file) {
  if (!file) return toast('no file selected', false);
  if (!COVER_OK_TYPES.includes(file.type)) return toast(`unsupported type: ${file.type || 'unknown'}`, false);
  if (file.size > COVER_MAX_BYTES) return toast(`image too large (${(file.size/1024/1024).toFixed(1)}MB > 8MB)`, false);

  toast(`uploading cover for ${slug}…`);
  const dataUrl = await new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(r.result);
    r.onerror = reject;
    r.readAsDataURL(file);
  });
  const r = await fetch(`api/site/${slug}/cover`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ dataUrl }),
  });
  const j = await r.json();
  if (!r.ok) return toast(`cover upload failed: ${j.error || r.status}`, false);
  toast(`cover set for ${slug} · ${(j.bytes/1024).toFixed(1)}KB`);
  await loadSites();
  if (CURRENT === slug) renderDetail();
}

function onCoverFilePicked(e) {
  if (!CURRENT) return;
  const file = e.target.files && e.target.files[0];
  e.target.value = '';  // reset so picking the same file again still fires change
  if (file) uploadCoverFile(CURRENT, file);
}

document.addEventListener('dragover', (e) => {
  const t = findDropTarget(e.target);
  if (!t) return;
  if (!Array.from(e.dataTransfer.types).includes('Files')) return;
  e.preventDefault();
  e.dataTransfer.dropEffect = 'copy';
  t.el.classList.add('dropzone-active');
});
document.addEventListener('dragleave', (e) => {
  const t = findDropTarget(e.target);
  if (t && !t.el.contains(e.relatedTarget)) t.el.classList.remove('dropzone-active');
});
document.addEventListener('drop', async (e) => {
  const t = findDropTarget(e.target);
  if (!t) return;
  e.preventDefault();
  t.el.classList.remove('dropzone-active');
  const file = e.dataTransfer.files && e.dataTransfer.files[0];
  await uploadCoverFile(t.slug, file);
});

// Press "C" anywhere outside an input → focus the collection search.
document.addEventListener('keydown', (e) => {
  if (e.key !== 'c' && e.key !== 'C') return;
  if (e.metaKey || e.ctrlKey || e.altKey) return;
  const t = e.target;
  const tag = (t && t.tagName || '').toLowerCase();
  if (tag === 'input' || tag === 'textarea' || (t && t.isContentEditable)) return;
  const search = document.querySelector('.coll-search');
  if (!search) return;
  e.preventDefault();
  search.focus();
  search.select();
});

async function clearCover(e) {
  if (e) { e.stopPropagation(); e.preventDefault(); }
  if (!CURRENT) return;
  const slug = CURRENT;
  if (!confirm(`Clear custom cover for ${slug}?\n\nReverts to the auto-derived hero from products[0].`)) return;
  const r = await fetch(`api/site/${slug}/cover`, { method: 'DELETE' });
  const j = await r.json();
  if (!r.ok) return toast(`clear failed: ${j.error || r.status}`, false);
  toast(`cover cleared for ${slug}`);
  await loadSites();
  if (CURRENT === slug) renderDetail();
}

loadSites();
</script>
</body>
</html>