← back to Dw Hero Admin

public/index.html

563 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DW Hero Grid Admin</title>
<style>
  :root {
    --bg: #0a0a0a;
    --panel: #14110c;
    --paper: #f5f1e8;
    --muted: #8a8378;
    --line: rgba(245,241,232,0.10);
    --accent: #B0A080;
    --slot-bg: #1c1814;
    --slot-bd: rgba(176,160,128,0.35);
    --hot: #d49a5c;
  }
  * { box-sizing: border-box; }
  html,body { margin:0; height:100%; background:var(--bg); color:var(--paper); font:14px/1.4 -apple-system,"SF Pro Text",system-ui,sans-serif; }
  a { color:var(--accent); }
  .app { display:grid; grid-template-columns: 240px 1fr; height:100vh; min-height: 0; }
  /* ─ sidebar ─ */
  .sidebar { background:var(--panel); border-right:1px solid var(--line); overflow-y:auto; padding:16px 0; }
  .sidebar h2 { font-size:11px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted); margin:0 16px 12px; font-weight:500; }
  .site-list { list-style:none; padding:0; margin:0; }
  .site-list li { padding:8px 16px; cursor:pointer; border-left:3px solid transparent; transition:background .14s,border-color .14s; font-size:13px; }
  .site-list li:hover { background:rgba(245,241,232,0.04); }
  .site-list li.active { background:rgba(176,160,128,0.10); border-left-color:var(--accent); color:#fff; }
  .site-list li small { display:block; font-size:10px; color:var(--muted); margin-top:2px; }
  .sidebar-search { padding:0 16px 12px; }
  .sidebar-search input { width:100%; padding:8px 10px; background:transparent; border:1px solid var(--line); color:var(--paper); font:inherit; outline:none; border-radius:3px; }
  /* ─ main ─ */
  .main { display:flex; flex-direction:column; overflow:hidden; }
  .topbar { flex-shrink:0; padding:14px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; justify-content:space-between; }
  .topbar h1 { margin:0; font-size:18px; font-weight:500; letter-spacing:-0.005em; }
  .topbar .meta { color:var(--muted); font-size:12px; letter-spacing:0.04em; }
  .save-status { font-size:12px; color:var(--muted); }
  .save-status.ok { color:#7ed3a3; }
  .save-status.err { color:#ff8c8c; }
  /* ─ brand picker (custom dropdown — native <select> can't render color dots) ─ */
  .brand-picker { position:relative; }
  .brand-picker-btn { display:inline-flex; align-items:center; gap:10px; background:transparent; border:1px solid var(--line); color:var(--paper); padding:7px 12px; cursor:pointer; font:inherit; font-size:12px; letter-spacing:0.04em; border-radius:3px; min-width:240px; justify-content:space-between; }
  .brand-picker-btn:hover { border-color:var(--accent); }
  .brand-picker-btn .dots { display:inline-flex; gap:3px; }
  .brand-picker-btn .dot { width:12px; height:12px; border-radius:50%; border:1px solid rgba(255,255,255,0.15); }
  .brand-picker-btn .label { flex:1; text-align:left; }
  .brand-picker-btn .caret { opacity:0.5; }
  .brand-menu { position:absolute; top:calc(100% + 4px); right:0; background:#15110b; border:1px solid var(--slot-bd); border-radius:6px; padding:6px; min-width:340px; max-height:min(70vh, 540px); overflow-y:auto; z-index:30; box-shadow:0 12px 32px rgba(0,0,0,0.5); }
  .brand-menu[hidden] { display:none; }
  .brand-option { display:flex; align-items:center; gap:12px; padding:8px 10px; cursor:pointer; border-radius:4px; transition:background .12s; }
  .brand-option:hover { background:rgba(176,160,128,0.10); }
  .brand-option.selected { background:rgba(176,160,128,0.18); }
  .brand-option .name { flex:1; font-size:13px; color:var(--paper); }
  .brand-option .name small { display:block; font-size:10px; color:var(--muted); margin-top:2px; letter-spacing:0.05em; }
  .brand-option .dots { display:inline-flex; gap:4px; }
  .brand-option .dot { width:18px; height:18px; border-radius:50%; border:1px solid rgba(255,255,255,0.18); }
  .brand-option.clear { color:var(--muted); font-style:italic; border-top:1px solid var(--line); margin-top:4px; padding-top:10px; }
  /* applied theme — palette swap on admin chrome */
  body.brand-theme { background:var(--theme-bg, var(--bg)); }
  body.brand-theme .topbar { background:var(--theme-panel, transparent); border-bottom-color:var(--theme-line, var(--line)); }
  body.brand-theme .sidebar { background:var(--theme-panel, var(--panel)); }
  body.brand-theme .slots-wrap { background:var(--theme-panel, #0d0a07); }
  body.brand-theme .save-status:not(.ok):not(.err) { color:var(--theme-accent, var(--muted)); }
  body.brand-theme .toolbar button.primary { background:var(--theme-accent, var(--accent)); border-color:var(--theme-accent, var(--accent)); color:#0a0a0a; }
  /* ─ slot grid ─ */
  .slots-wrap { flex-shrink:0; padding:18px 24px; border-bottom:1px solid var(--line); background:#0d0a07; }
  .slots-label { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
  .slots-label h3 { margin:0; font-size:12px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted); font-weight:500; }
  .slots-label .hint { font-size:11px; color:var(--muted); }
  .slots { display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; gap:8px; width:100%; max-width:520px; aspect-ratio:1.6/1; margin:0 auto; }
  .slot { position:relative; background:var(--slot-bg); border:1px dashed var(--slot-bd); overflow:hidden; cursor:default; transition:border-color .14s, background .14s; }
  .slot.over { border-color:var(--hot); border-style:solid; background:rgba(212,154,92,0.08); }
  .slot img { width:100%; height:100%; object-fit:cover; object-position:center 12%; display:block; }
  .slot .empty { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:11px; letter-spacing:0.12em; text-transform:uppercase; pointer-events:none; }
  .slot .badge { position:absolute; top:6px; left:6px; background:rgba(10,10,10,0.78); padding:3px 8px; font-size:9px; letter-spacing:0.18em; text-transform:uppercase; border-radius:2px; }
  .slot .clear { position:absolute; top:6px; right:6px; background:rgba(10,10,10,0.78); color:#fff; border:0; cursor:pointer; width:22px; height:22px; border-radius:50%; font-size:12px; line-height:1; display:none; }
  .slot.has-img:hover .clear { display:block; }
  .slot .title-strip { position:absolute; bottom:0; left:0; right:0; padding:6px 8px; background:linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.7) 100%); font-size:10px; line-height:1.2; }
  .toolbar { display:flex; gap:8px; margin-top:12px; align-items:center; justify-content:center; }
  .toolbar button { background:transparent; border:1px solid var(--line); color:var(--paper); padding:6px 14px; cursor:pointer; font:inherit; font-size:12px; letter-spacing:0.06em; border-radius:3px; transition:all .14s; }
  .toolbar button:hover { background:rgba(245,241,232,0.06); border-color:var(--accent); }
  .toolbar button.primary { background:var(--accent); color:#0a0a0a; border-color:var(--accent); font-weight:500; }
  .toolbar button.primary:hover { background:#c0b090; }
  /* ─ products grid ─ */
  .products-wrap { flex:1; overflow-y:auto; padding:18px 24px; }
  .products-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; gap:12px; flex-wrap:wrap; }
  .products-header h3 { margin:0; font-size:12px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted); font-weight:500; }
  .products-controls { display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
  .products-controls label { display:inline-flex; align-items:center; gap:6px; font-size:11px; letter-spacing:0.1em; text-transform:uppercase; color:var(--muted); }
  .products-controls select { padding:5px 10px; background:transparent; border:1px solid var(--line); color:var(--paper); font:inherit; font-size:12px; outline:none; border-radius:3px; }
  .products-controls input[type=range] { width:120px; accent-color:var(--accent); }
  .product-search input { padding:6px 10px; background:transparent; border:1px solid var(--line); color:var(--paper); font:inherit; outline:none; border-radius:3px; width:220px; font-size:12px; }
  .products { display:grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-w, 140px), 1fr)); gap:10px; }
  .product { position:relative; aspect-ratio:1/1.15; background:var(--slot-bg); border:1px solid var(--line); overflow:hidden; cursor:grab; transition:border-color .14s; }
  .product:active { cursor:grabbing; }
  .product:hover { border-color:var(--accent); }
  .product.dragging { opacity:0.4; }
  .product img { width:100%; height:100%; object-fit:cover; object-position:center 12%; display:block; pointer-events:none; }
  .product .strip { position:absolute; bottom:0; left:0; right:0; padding:5px 7px; background:linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 100%); font-size:10px; line-height:1.2; }
  .product .strip .sku { color:var(--muted); font-size:9px; letter-spacing:0.04em; }
  .empty-state { display:flex; align-items:center; justify-content:center; height:60vh; color:var(--muted); }
  .loading { padding:40px; text-align:center; color:var(--muted); }
  /* ─ controls ─ */
  .kind-toggle { background:rgba(10,10,10,0.78); border:0; color:#fff; padding:3px 7px; font-size:9px; letter-spacing:0.18em; text-transform:uppercase; cursor:pointer; border-radius:2px; margin-left:6px; }
  .kind-toggle:hover { background:var(--accent); color:#0a0a0a; }
  /* mobile — let the whole page scroll naturally instead of trapping the user
     inside a 40px products-wrap. Shrink sidebar + slot grid so the products
     grid lands inside the first viewport without scrolling. */
  @media (max-width:780px) {
    html, body { height:auto; min-height:100%; }
    .app { grid-template-columns: 1fr; grid-template-rows: auto auto; height:auto; min-height:100vh; }
    .sidebar { max-height:22vh; }
    .main { overflow:visible; }
    .products-wrap { overflow:visible; flex:none; }
    .slots { max-width:360px; aspect-ratio:1.6/1; }
    .slots-label .hint { display:none; }
  }
</style>
    <link rel="icon" type="image/svg+xml" href="/favicon.svg">
  </head>
<body>
<div class="app">
  <aside class="sidebar">
    <h2>DW Fleet</h2>
    <div class="sidebar-search"><input id="siteSearch" type="search" placeholder="Filter sites..."></div>
    <ul class="site-list" id="siteList"></ul>
  </aside>
  <main class="main">
    <div class="topbar">
      <div>
        <h1 id="siteName">— pick a site —</h1>
        <div class="meta" id="siteMeta"></div>
      </div>
      <div style="display:flex; align-items:center; gap:14px;">
        <div class="brand-picker" id="brandPicker" hidden>
          <button id="brandBtn" class="brand-picker-btn" type="button">
            <span class="label">Apply style guide…</span>
            <span class="dots" id="brandBtnDots"></span>
            <span class="caret">▾</span>
          </button>
          <div id="brandMenu" class="brand-menu" hidden></div>
        </div>
        <div class="save-status" id="saveStatus"></div>
      </div>
    </div>
    <div class="slots-wrap" id="slotsWrap" style="display:none">
      <div class="slots-label">
        <h3>Hero 2×2 — drag products in</h3>
        <span class="hint">crop 12% from top · rule: never display image with header</span>
      </div>
      <div class="slots" id="slots">
        <div class="slot" data-idx="0"><span class="badge">Room 1</span><div class="empty">Drop room photo</div></div>
        <div class="slot" data-idx="1"><span class="badge">Pattern 1</span><div class="empty">Drop pattern</div></div>
        <div class="slot" data-idx="2"><span class="badge">Room 2</span><div class="empty">Drop room photo</div></div>
        <div class="slot" data-idx="3"><span class="badge">Pattern 2</span><div class="empty">Drop pattern</div></div>
      </div>
      <div class="toolbar">
        <button id="saveBtn" class="primary">Save & push to prod</button>
        <button id="resetBtn">Reset to newest 4</button>
        <button id="openSiteBtn">Open live site ↗</button>
      </div>
    </div>
    <div class="products-wrap" id="productsWrap">
      <div class="empty-state">Pick a site on the left to start.</div>
    </div>
  </main>
</div>

<script>
(function () {
  'use strict';
  var LS = 'dw-hero-admin.';
  var state = {
    site: null,
    products: [],
    grid: null,
    filter: '',
    sort: localStorage.getItem(LS + 'sort') || 'newest',
    cardW: parseInt(localStorage.getItem(LS + 'cardW') || '140', 10),
    brands: [],
    assignments: {},   // { site_id: { brand_id, updated_at } }
  };
  document.documentElement.style.setProperty('--card-w', state.cardW + 'px');
  var els = {
    siteList: document.getElementById('siteList'),
    siteSearch: document.getElementById('siteSearch'),
    siteName: document.getElementById('siteName'),
    siteMeta: document.getElementById('siteMeta'),
    slotsWrap: document.getElementById('slotsWrap'),
    slots: document.getElementById('slots'),
    productsWrap: document.getElementById('productsWrap'),
    saveStatus: document.getElementById('saveStatus'),
    saveBtn: document.getElementById('saveBtn'),
    resetBtn: document.getElementById('resetBtn'),
    openSiteBtn: document.getElementById('openSiteBtn'),
    brandPicker: document.getElementById('brandPicker'),
    brandBtn: document.getElementById('brandBtn'),
    brandBtnDots: document.getElementById('brandBtnDots'),
    brandMenu: document.getElementById('brandMenu'),
  };

  function api(url, opts) {
    return fetch(url, opts).then(function (r) {
      return r.json().catch(function () { return {}; }).then(function (body) {
        if (!r.ok) {
          var err = new Error((body && body.error) || ('HTTP ' + r.status));
          err.body = body;
          throw err;
        }
        return body;
      });
    });
  }

  function loadSites() {
    return api('/api/sites').then(function (data) {
      var sites = data.sites || [];
      els.siteList.innerHTML = sites.map(function (s) {
        return '<li data-site="' + s + '">' + s + '<small>' + s + '.com</small></li>';
      }).join('');
      Array.prototype.forEach.call(els.siteList.querySelectorAll('li'), function (li) {
        li.addEventListener('click', function () { pickSite(li.dataset.site); });
      });
    });
  }

  function loadBrandsAndAssignments() {
    return Promise.all([
      api('/api/brands').catch(function () { return { brands: [] }; }),
      api('/api/brand-assignments').catch(function () { return {}; }),
    ]).then(function (results) {
      state.brands = (results[0] && results[0].brands) || [];
      state.assignments = results[1] || {};
      renderBrandMenu();
    });
  }

  function renderBrandMenu() {
    if (!state.brands.length) return;
    var current = state.site ? (state.assignments[state.site] || {}).brand_id : null;
    var rows = state.brands.map(function (b) {
      var dots = b.palette.slice(0, 5).map(function (c) {
        return '<span class="dot" style="background:' + escapeHtml(c.hex) + '" title="' + escapeHtml(c.name) + '"></span>';
      }).join('');
      var cls = 'brand-option' + (b.id === current ? ' selected' : '');
      return '<div class="' + cls + '" data-brand="' + escapeHtml(b.id) + '">\
        <div class="name">' + escapeHtml(b.name) + '<small>' + escapeHtml(b.signature_motif || '').slice(0, 60) + '</small></div>\
        <div class="dots">' + dots + '</div>\
      </div>';
    }).join('');
    rows += '<div class="brand-option clear" data-brand=""><div class="name">— clear assignment —</div></div>';
    els.brandMenu.innerHTML = rows;
    Array.prototype.forEach.call(els.brandMenu.querySelectorAll('.brand-option'), function (row) {
      row.addEventListener('click', function () {
        applyBrand(row.dataset.brand || null);
        els.brandMenu.hidden = true;
      });
    });
    refreshBrandBtn();
  }

  function refreshBrandBtn() {
    var assign = state.site ? state.assignments[state.site] : null;
    var brand = assign ? state.brands.find(function (b) { return b.id === assign.brand_id; }) : null;
    var label = els.brandBtn.querySelector('.label');
    var dots = els.brandBtnDots;
    if (brand) {
      label.textContent = brand.name;
      dots.innerHTML = brand.palette.slice(0, 4).map(function (c) {
        return '<span class="dot" style="background:' + escapeHtml(c.hex) + '"></span>';
      }).join('');
      applyTheme(brand);
    } else {
      label.textContent = 'Apply style guide…';
      dots.innerHTML = '';
      applyTheme(null);
    }
  }

  function applyTheme(brand) {
    var root = document.documentElement.style;
    if (!brand) {
      document.body.classList.remove('brand-theme');
      ['--theme-bg','--theme-panel','--theme-accent','--theme-line'].forEach(function (k) { root.removeProperty(k); });
      return;
    }
    document.body.classList.add('brand-theme');
    var primary = brand.palette.find(function (c) { return c.role === 'primary'; }) || brand.palette[0];
    var accent = brand.palette.find(function (c) { return c.role === 'accent'; }) ||
                 brand.palette.find(function (c) { return c.role === 'accent-soft'; }) ||
                 brand.palette[1] || primary;
    var ground = brand.palette.find(function (c) { return c.role === 'ground'; }) || null;
    // Mix the primary at ~10% with the dark base so admin chrome remains dark-readable
    root.setProperty('--theme-bg', mixWithBlack(primary.hex, 0.92));
    root.setProperty('--theme-panel', mixWithBlack(primary.hex, 0.86));
    root.setProperty('--theme-accent', accent.hex);
    root.setProperty('--theme-line', mixWithBlack(ground ? ground.hex : primary.hex, 0.7));
  }

  function mixWithBlack(hex, blackAmount) {
    var c = hex.replace('#','');
    var r = parseInt(c.slice(0,2), 16);
    var g = parseInt(c.slice(2,4), 16);
    var b = parseInt(c.slice(4,6), 16);
    r = Math.round(r * (1 - blackAmount));
    g = Math.round(g * (1 - blackAmount));
    b = Math.round(b * (1 - blackAmount));
    return 'rgb(' + r + ',' + g + ',' + b + ')';
  }

  function applyBrand(brandId) {
    if (!state.site) return;
    if (brandId) state.assignments[state.site] = { brand_id: brandId, updated_at: new Date().toISOString() };
    else delete state.assignments[state.site];
    refreshBrandBtn();
    renderBrandMenu();
    // persist server-side
    api('/api/site/' + encodeURIComponent(state.site) + '/brand', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ brand_id: brandId }),
    }).then(function (r) {
      flashStatus(brandId ? ('✓ applied ' + (state.brands.find(function (b) { return b.id === brandId; }) || {}).name) : '✓ cleared style guide', 'ok');
    }).catch(function (e) { flashStatus('✗ save assignment: ' + e.message, 'err'); });
  }

  function pickSite(site) {
    state.site = site;
    Array.prototype.forEach.call(els.siteList.querySelectorAll('li'), function (li) {
      li.classList.toggle('active', li.dataset.site === site);
    });
    els.siteName.textContent = site;
    els.siteMeta.textContent = 'loading…';
    els.productsWrap.innerHTML = '<div class="loading">Loading products…</div>';
    return api('/api/site/' + encodeURIComponent(site)).then(function (data) {
      state.products = data.products || [];
      state.grid = data.grid;
      els.siteMeta.textContent = data.productCount + ' products · newest first';
      els.slotsWrap.style.display = 'block';
      els.brandPicker.hidden = false;
      renderSlots();
      renderProductsHeader();
      renderProductsGrid();
      renderBrandMenu();
      refreshBrandBtn();
    }).catch(function (e) {
      els.productsWrap.innerHTML = '<div class="empty-state">Error: ' + e.message + '</div>';
    });
  }

  function renderSlots() {
    var cells = (state.grid && state.grid.cells) || [];
    var kinds = ['Room 1', 'Pattern 1', 'Room 2', 'Pattern 2'];
    Array.prototype.forEach.call(els.slots.querySelectorAll('.slot'), function (slot, idx) {
      slot.innerHTML = '<span class="badge">' + kinds[idx] + '<button class="kind-toggle" title="Toggle room/pattern">⇆</button></span>';
      var cell = cells[idx];
      if (cell && cell.image_url) {
        slot.classList.add('has-img');
        var img = document.createElement('img');
        img.src = cell.image_url;
        img.alt = cell.title || '';
        slot.appendChild(img);
        var clear = document.createElement('button');
        clear.className = 'clear';
        clear.textContent = '×';
        clear.title = 'Clear';
        clear.addEventListener('click', function (ev) { ev.stopPropagation(); clearSlot(idx); });
        slot.appendChild(clear);
        var strip = document.createElement('div');
        strip.className = 'title-strip';
        strip.textContent = cell.title || cell.sku || '';
        slot.appendChild(strip);
      } else {
        slot.classList.remove('has-img');
        var emp = document.createElement('div');
        emp.className = 'empty';
        emp.textContent = idx % 2 === 0 ? 'Drop room photo' : 'Drop pattern';
        slot.appendChild(emp);
      }
      // toggle kind
      var tog = slot.querySelector('.kind-toggle');
      if (tog) tog.addEventListener('click', function (ev) {
        ev.stopPropagation();
        if (!state.grid) return;
        var c = state.grid.cells[idx] || {};
        c.kind = (c.kind === 'room') ? 'pattern' : 'room';
        state.grid.cells[idx] = c;
        flashStatus('toggled — click Save', '');
      });
      // drag handlers
      slot.addEventListener('dragover', function (ev) { ev.preventDefault(); slot.classList.add('over'); });
      slot.addEventListener('dragleave', function () { slot.classList.remove('over'); });
      slot.addEventListener('drop', function (ev) {
        ev.preventDefault();
        slot.classList.remove('over');
        var sku = ev.dataTransfer.getData('text/sku');
        var hit = state.products.find(function (p) { return p.sku === sku; });
        if (!hit) return;
        setSlot(idx, hit, idx % 2 === 0 ? 'room' : 'pattern');
      });
    });
  }

  function setSlot(idx, product, kind) {
    if (!state.grid) state.grid = { cells: [{}, {}, {}, {}], rotation_pool: [], autocycle_seconds: 6 };
    state.grid.cells[idx] = {
      kind: kind || state.grid.cells[idx]?.kind || (idx % 2 === 0 ? 'room' : 'pattern'),
      sku: product.sku,
      image_url: product.image_url,
      title: product.title,
    };
    renderSlots();
    flashStatus('unsaved — click Save to push to prod', '');
  }

  function clearSlot(idx) {
    if (!state.grid || !state.grid.cells) return;
    state.grid.cells[idx] = { kind: state.grid.cells[idx]?.kind || (idx % 2 === 0 ? 'room' : 'pattern'), sku: null, image_url: null, title: null };
    renderSlots();
    flashStatus('unsaved', '');
  }

  // Header is rendered ONCE per site pick — preserves focus on the search
  // input across keystroke-driven re-renders of the grid.
  function renderProductsHeader() {
    els.productsWrap.innerHTML = '\
      <div class="products-header">\
        <h3 id="prodCount">All products</h3>\
        <div class="products-controls">\
          <label>Sort\
            <select id="prodSort">\
              <option value="newest">Newest first</option>\
              <option value="title">Title A→Z</option>\
              <option value="sku">SKU A→Z</option>\
              <option value="vendor">Vendor A→Z</option>\
            </select>\
          </label>\
          <label>Size\
            <input id="prodDensity" type="range" min="90" max="320" step="10" value="' + state.cardW + '">\
          </label>\
          <div class="product-search"><input id="prodFilter" type="search" placeholder="Filter pattern, vendor, tag..." value="' + escapeHtml(state.filter || '') + '"></div>\
        </div>\
      </div>\
      <div class="products" id="prodGrid"></div>\
    ';
    var pf = document.getElementById('prodFilter');
    pf.addEventListener('input', function () {
      state.filter = pf.value;
      renderProductsGrid();
    });
    var ps = document.getElementById('prodSort');
    ps.value = state.sort;
    ps.addEventListener('change', function () {
      state.sort = ps.value;
      localStorage.setItem(LS + 'sort', state.sort);
      renderProductsGrid();
    });
    var pd = document.getElementById('prodDensity');
    pd.addEventListener('input', function () {
      state.cardW = parseInt(pd.value, 10);
      localStorage.setItem(LS + 'cardW', state.cardW);
      document.documentElement.style.setProperty('--card-w', state.cardW + 'px');
    });
  }

  // Only re-renders the grid + count — never touches the header DOM, so
  // the user's keystroke focus stays in #prodFilter while typing.
  function renderProductsGrid() {
    var q = (state.filter || '').toLowerCase();
    var items = state.products.filter(function (p) {
      if (!q) return true;
      return ((p.title || '') + ' ' + (p.sku || '') + ' ' + (p.vendor || '') + ' ' + (p.tags || []).join(' ')).toLowerCase().indexOf(q) !== -1;
    });
    var cmp = function (a, b, key) { return (a[key] || '').localeCompare(b[key] || ''); };
    if (state.sort === 'title') items = items.slice().sort(function (a, b) { return cmp(a, b, 'title'); });
    else if (state.sort === 'sku') items = items.slice().sort(function (a, b) { return cmp(a, b, 'sku'); });
    else if (state.sort === 'vendor') items = items.slice().sort(function (a, b) { return cmp(a, b, 'vendor') || cmp(a, b, 'title'); });
    // newest = leave api ordering (server already reversed products.json)
    var label = ({ newest:'newest first', title:'title A→Z', sku:'SKU A→Z', vendor:'vendor A→Z' })[state.sort] || 'newest first';
    var count = document.getElementById('prodCount');
    if (count) count.textContent = 'All products — ' + label + ' (' + items.length + ')';
    var grid = document.getElementById('prodGrid');
    if (!grid) return;
    grid.innerHTML = items.slice(0, 400).map(function (p) {
      return '<div class="product" draggable="true" data-sku="' + escapeHtml(p.sku) + '">\
        <img src="' + escapeHtml(p.image_url) + '" alt="' + escapeHtml(p.title || '') + '" loading="lazy">\
        <div class="strip">' + escapeHtml((p.title || '').slice(0, 36)) + '<div class="sku">' + escapeHtml(p.sku) + '</div></div>\
      </div>';
    }).join('');
    Array.prototype.forEach.call(grid.querySelectorAll('.product'), function (el) {
      el.addEventListener('dragstart', function (ev) {
        el.classList.add('dragging');
        ev.dataTransfer.setData('text/sku', el.dataset.sku);
        ev.dataTransfer.effectAllowed = 'copy';
      });
      el.addEventListener('dragend', function () { el.classList.remove('dragging'); });
    });
  }

  function flashStatus(msg, cls) {
    els.saveStatus.textContent = msg;
    els.saveStatus.className = 'save-status ' + (cls || '');
  }

  function escapeHtml(s) {
    return String(s || '').replace(/[&<>"']/g, function (c) {
      return ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[c];
    });
  }

  els.saveBtn.addEventListener('click', function () {
    if (!state.site || !state.grid) return;
    flashStatus('saving…', '');
    api('/api/site/' + encodeURIComponent(state.site) + '/grid', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(state.grid),
    }).then(function () {
      flashStatus('✓ saved + pushed to prod', 'ok');
    }).catch(function (e) { flashStatus('✗ ' + e.message, 'err'); });
  });

  els.resetBtn.addEventListener('click', function () {
    if (!state.site) return;
    var first4 = state.products.slice(0, 4);
    state.grid = {
      cells: first4.map(function (p, i) {
        return { kind: i % 2 === 0 ? 'room' : 'pattern', sku: p.sku, image_url: p.image_url, title: p.title };
      }),
      rotation_pool: state.products.slice(0, 16).map(function (p) { return { sku: p.sku, image_url: p.image_url, title: p.title }; }),
      autocycle_seconds: 6,
    };
    renderSlots();
    flashStatus('reset (unsaved)', '');
  });

  els.openSiteBtn.addEventListener('click', function () {
    if (state.site) window.open('https://' + state.site + '.com', '_blank', 'noopener,noreferrer');
  });

  els.siteSearch.addEventListener('input', function () {
    var q = els.siteSearch.value.toLowerCase();
    Array.prototype.forEach.call(els.siteList.querySelectorAll('li'), function (li) {
      li.style.display = !q || li.dataset.site.indexOf(q) !== -1 ? '' : 'none';
    });
  });

  els.brandBtn.addEventListener('click', function (ev) {
    ev.stopPropagation();
    els.brandMenu.hidden = !els.brandMenu.hidden;
  });
  document.addEventListener('click', function (ev) {
    if (!els.brandPicker.contains(ev.target)) els.brandMenu.hidden = true;
  });

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