← back to 4square Agentabrams

public/index.html

857 lines

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
  <title>4Square — Agent Abrams</title>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@400;500;600&display=swap">
  <style>
    :root {
      --bg: #0e0d0b;
      --panel: #14130f;
      --card: #19181640;
      --rule: #2a2825;
      --rule-strong: #3a3631;
      --fg: #f3ecdd;
      --muted: #9d9586;
      --muted-dim: #6e665a;
      --accent: #d9b14e;
      --accent-dim: #8a6f30;
      --selected: #2a2415;
      --font-display: 'Playfair Display', serif;
      --font-ui: 'Inter', system-ui, sans-serif;
      --rad: 12px;
      --rail-w: 240px;
      --moodboard-h: 200px;
    }
    * { box-sizing: border-box; }
    html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: var(--font-ui); }
    body { display: flex; flex-direction: column; min-height: 100vh; }

    /* ─ Header ─ */
    header.top {
      display: flex; align-items: center; justify-content: space-between;
      padding: 12px 20px; border-bottom: 1px solid var(--rule);
      background: linear-gradient(180deg, #1c1a17 0%, #14130f 100%);
      flex: 0 0 auto;
    }
    header.top h1 { font-family: var(--font-display); margin: 0; font-size: 1.4rem; color: var(--accent); }
    header.top .sub { color: var(--muted); font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase; }
    header .save-status { color: var(--muted); font-size: 0.75rem; }
    header .save-status.flash { color: var(--accent); }
    .action-btn {
      background: transparent; color: var(--fg); border: 1px solid var(--rule);
      padding: 6px 12px; border-radius: 8px; cursor: pointer; font: 600 0.75rem var(--font-ui);
      letter-spacing: 0.05em;
    }
    .action-btn:hover { border-color: var(--accent); color: var(--accent); }

    /* ─ Main 3-pane (left rail + splitter + main + bottom moodboard) ─ */
    .workspace { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
    .panes { display: flex; flex: 1 1 auto; min-height: 0; }

    /* Left rail — site nav */
    .rail {
      width: var(--rail-w); min-width: 180px; max-width: 50vw;
      background: var(--panel); border-right: 1px solid var(--rule);
      display: flex; flex-direction: column; flex: 0 0 auto; min-height: 0;
    }
    .rail h2 {
      font-family: var(--font-display); margin: 0; font-size: 0.95rem;
      letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted);
      padding: 14px 16px 8px;
    }
    .rail .site-filter {
      margin: 0 12px 8px; padding: 7px 10px; background: #100f0d; color: var(--fg);
      border: 1px solid var(--rule); border-radius: 8px; font: 0.84rem var(--font-ui);
    }
    .rail .site-filter:focus { outline: 0; border-color: var(--accent-dim); }
    .rail .meta { color: var(--muted-dim); font-size: 0.7rem; padding: 0 16px 6px; }
    .site-list { overflow-y: auto; flex: 1 1 auto; padding: 0 6px 12px; }
    .site-item {
      display: flex; align-items: center; gap: 8px;
      padding: 7px 10px; border-radius: 8px; cursor: pointer;
      font: 0.84rem var(--font-ui); color: var(--fg);
      border-left: 2px solid transparent;
      margin: 1px 0;
    }
    .site-item:hover { background: #1a1815; }
    .site-item.active { background: var(--selected); border-left-color: var(--accent); }
    .site-item .dot {
      width: 7px; height: 7px; border-radius: 999px;
      background: var(--rule-strong); flex: 0 0 7px;
    }
    .site-item.wired .dot { background: var(--accent); }
    .site-item .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; }
    .site-item .kind {
      font-size: 0.62rem; color: var(--muted-dim);
      letter-spacing: 0.06em; text-transform: uppercase;
    }

    /* Splitter — drag to resize left rail */
    .splitter {
      width: 6px; flex: 0 0 6px; cursor: col-resize;
      background: var(--bg); border-right: 1px solid var(--rule);
      transition: background 0.12s ease;
    }
    .splitter:hover, .splitter.dragging { background: var(--accent-dim); }

    /* Main — search + grid */
    .main {
      flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column;
      background: var(--bg);
    }
    .search-bar {
      display: flex; align-items: center; gap: 12px;
      padding: 14px 22px; border-bottom: 1px solid var(--rule);
      background: var(--panel);
    }
    .search-bar .search-icon { color: var(--muted); font-size: 1.1rem; }
    .search-bar input[type=search] {
      flex: 1 1 auto; background: #100f0d; color: var(--fg);
      border: 1px solid var(--rule); border-radius: 10px;
      padding: 10px 14px; font: 0.95rem var(--font-ui);
    }
    .search-bar input[type=search]:focus { outline: 0; border-color: var(--accent-dim); }
    .search-bar .count { color: var(--muted); font-size: 0.78rem; min-width: 110px; text-align: right; }
    .search-bar .source-label {
      color: var(--accent); font: 600 0.78rem var(--font-ui); letter-spacing: 0.05em;
      text-transform: uppercase; padding: 4px 10px; border: 1px solid var(--accent-dim); border-radius: 999px;
      max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }

    .grid-scroll { flex: 1 1 auto; overflow-y: auto; padding: 18px 22px 22px; }
    .grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
      gap: 14px;
    }
    .card {
      aspect-ratio: 1/1.15; background: #100f0d; border: 1px solid var(--rule);
      border-radius: var(--rad); overflow: hidden; position: relative; cursor: grab;
      transition: border-color 0.15s ease, transform 0.15s ease;
    }
    .card:active { cursor: grabbing; }
    .card:hover { border-color: var(--accent-dim); transform: translateY(-1px); }
    .card.dragging { opacity: 0.4; }
    .card img { width: 100%; height: 100%; object-fit: cover; display: block; }
    .card .card-label {
      position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 9px;
      font: 600 0.7rem var(--font-ui); color: #fff;
      background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.78) 100%);
      letter-spacing: 0.02em;
    }
    .card .card-label .by { color: rgba(255,255,255,0.65); font-weight: 400; font-size: 0.62rem; display: block; margin-top: 1px; }

    .empty-state {
      padding: 60px 30px; text-align: center; color: var(--muted);
      max-width: 480px; margin: 0 auto;
    }
    .empty-state h3 { font-family: var(--font-display); color: var(--fg); margin: 0 0 8px; }
    .empty-state .lead { font-size: 0.92rem; line-height: 1.5; }
    .empty-state .domain { color: var(--accent); font-weight: 600; }

    /* Moodboard dock at bottom */
    .moodboard {
      flex: 0 0 auto; min-height: var(--moodboard-h);
      border-top: 1px solid var(--rule); background: var(--panel);
      display: flex; flex-direction: column;
    }
    .moodboard.collapsed { min-height: 38px; }
    .moodboard-header {
      display: flex; align-items: center; justify-content: space-between;
      padding: 8px 18px; border-bottom: 1px solid var(--rule);
      cursor: pointer; user-select: none;
    }
    .moodboard-header h2 {
      font-family: var(--font-display); margin: 0; font-size: 0.95rem;
      letter-spacing: 0.04em; color: var(--accent);
    }
    .moodboard-header .toggle {
      color: var(--muted); font-size: 0.8rem; transition: transform 0.18s ease;
    }
    .moodboard.collapsed .toggle { transform: rotate(180deg); }
    .moodboard-body {
      display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
      padding: 12px 18px 14px; flex: 1 1 auto;
    }
    .moodboard.collapsed .moodboard-body { display: none; }

    .slot {
      position: relative; background: #131210; border: 2px dashed var(--rule); border-radius: 10px;
      overflow: hidden; min-height: 150px;
      transition: border-color 0.15s ease;
    }
    .slot.empty::after {
      content: attr(data-empty-label); position: absolute; inset: 0;
      display: flex; align-items: center; justify-content: center;
      color: var(--muted-dim); font: 700 1.4rem var(--font-display);
      pointer-events: none;
    }
    .slot.over { border-color: var(--accent); }
    .slot.filled { border-style: solid; border-color: var(--rule); cursor: grab; }
    .slot.filled:active { cursor: grabbing; }
    .slot.dragging-source { opacity: 0.5; }
    .slot img { width: 100%; height: 100%; object-fit: cover; display: block; }
    .slot .badge {
      position: absolute; top: 6px; left: 6px; background: rgba(20,18,15,0.85);
      color: var(--accent); font: 700 0.6rem var(--font-ui); letter-spacing: 0.1em;
      padding: 2px 7px; border-radius: 999px;
    }
    .slot .info {
      position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 10px;
      font: 0.72rem var(--font-ui); color: #fff;
      background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.82) 100%);
    }
    .slot .clear {
      position: absolute; top: 6px; right: 6px;
      background: rgba(20,18,15,0.85); color: var(--fg); border: 1px solid var(--rule);
      width: 22px; height: 22px; border-radius: 999px; cursor: pointer;
      display: none; align-items: center; justify-content: center; font-size: 0.9rem; line-height: 1;
    }
    .slot.filled .clear { display: flex; }
    .slot .clear:hover { background: #c54; color: #fff; }

    /* ─ Live hero preview (rendered inside 4Square — no iframe) ─ */
    .live-hero {
      position: relative;
      border-bottom: 1px solid var(--rule);
      background: #0a0908;
      display: flex; flex-direction: column;
    }
    .live-hero-header {
      display: flex; align-items: center; gap: 12px;
      padding: 8px 18px; border-bottom: 1px solid var(--rule);
      font: 0.78rem var(--font-ui);
    }
    .live-hero-header .domain-pill {
      color: var(--accent); font-weight: 600; letter-spacing: 0.04em;
      padding: 4px 10px; border: 1px solid var(--accent-dim); border-radius: 999px;
    }
    .live-hero-header .hint { color: var(--muted); flex: 1 1 auto; }
    .live-hero-header .push-status { color: var(--muted); font-size: 0.72rem; }
    .live-hero-header .push-status.ok { color: #7dd29e; }
    .live-hero-header .push-status.err { color: #e57373; }
    /* The 2×2 hero canvas — same rendering rules the live rotator uses:
       crop top 12% (never show image headers), high-res cover, gucci-style
       wordmark beneath. */
    .hero-canvas-wrap { padding: 14px 18px; }
    .hero-canvas {
      width: 100%; aspect-ratio: 16/7;
      display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr;
      gap: 2px; background: #1a1815;
      border-radius: 10px; overflow: hidden;
      box-shadow: 0 6px 22px rgba(0,0,0,0.45);
    }
    .hero-cell {
      position: relative; background: #15140f;
      background-size: cover; background-position: center 12%;
      transition: outline 0.14s ease, transform 0.14s ease;
      outline: 2px solid transparent; outline-offset: -2px;
      cursor: pointer;
    }
    .hero-cell:empty::after, .hero-cell.empty::after {
      content: attr(data-empty-label);
      position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
      color: var(--muted-dim); font: 700 1.6rem var(--font-display);
      pointer-events: none;
    }
    .hero-cell.over { outline-color: var(--accent); transform: scale(0.98); }
    .hero-cell .cell-info {
      position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 10px;
      font: 0.7rem var(--font-ui); color: #fff; line-height: 1.2;
      background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.78) 100%);
      pointer-events: none;
    }
    .hero-cell .cell-kind {
      position: absolute; top: 6px; left: 6px;
      background: rgba(10,10,10,0.75); color: var(--accent);
      font: 700 0.58rem var(--font-ui); letter-spacing: 0.16em;
      padding: 3px 8px; border-radius: 999px; text-transform: uppercase;
    }
    .hero-cell .cell-clear {
      position: absolute; top: 6px; right: 6px;
      width: 22px; height: 22px; border-radius: 999px;
      background: rgba(20,18,15,0.85); color: var(--fg); border: 1px solid var(--rule);
      cursor: pointer; display: none; align-items: center; justify-content: center;
      font-size: 0.95rem; line-height: 1;
    }
    .hero-cell:hover .cell-clear { display: inline-flex; }
    .hero-cell .cell-clear:hover { background: #c54; color: #fff; }
    /* Wordmark + tagline below the canvas — same anatomy as the live site */
    .hero-wordmark-preview {
      text-align: center; padding: 14px 8px 18px;
      font-family: 'Cormorant Garamond', 'Playfair Display', Georgia, serif;
      font-size: clamp(28px, 4vw, 56px); font-weight: 500; letter-spacing: -0.005em;
      line-height: 1; color: var(--fg);
    }
    .hero-wordmark-preview small {
      display: block; margin-top: 6px;
      font-family: 'Playfair Display', Georgia, serif; font-style: italic;
      font-weight: 400; font-size: 0.78rem; color: var(--muted);
      letter-spacing: 0.04em; text-transform: none;
    }
    .live-hero.collapsed .hero-canvas-wrap,
    .live-hero.collapsed .hero-wordmark-preview { display: none; }
    .live-hero-toggle {
      background: transparent; border: 1px solid var(--rule); color: var(--muted);
      padding: 3px 9px; border-radius: 6px; font: 0.7rem var(--font-ui);
      cursor: pointer; letter-spacing: 0.04em;
    }
    .live-hero-toggle:hover { color: var(--accent); border-color: var(--accent-dim); }

    @media (max-width: 880px) {
      .panes { flex-direction: column; }
      .rail { width: 100% !important; max-width: none; max-height: 36vh; }
      .splitter { display: none; }
      .search-bar { padding: 10px 14px; }
      .search-bar .count { display: none; }
      .grid-scroll { padding: 12px 14px; }
      .moodboard-body { grid-template-columns: repeat(2, 1fr); }
      .live-hero iframe { height: 220px; }
    }
  </style>
    <link rel="icon" type="image/svg+xml" href="/favicon.svg">
  </head>
<body>
  <header class="top">
    <div>
      <h1>4Square</h1>
      <div class="sub">Agent Abrams · pick a site → drag products into the moodboard</div>
    </div>
    <div style="display:flex;align-items:center;gap:14px">
      <a class="action-btn" href="/wall" title="See every site's hero on one page">Hero Wall</a>
      <span class="save-status" id="save-status">moodboard autosaves to this browser</span>
      <button class="action-btn" id="reset-all" title="Clear all four moodboard squares">Reset moodboard</button>
    </div>
  </header>

  <div class="workspace">
    <div class="panes">
      <aside class="rail" id="rail">
        <h2>Sites</h2>
        <input class="site-filter" id="site-filter" type="search" placeholder="Filter domains…" autocomplete="off">
        <div class="meta" id="rail-meta">loading…</div>
        <div class="site-list" id="site-list"></div>
      </aside>

      <div class="splitter" id="splitter" aria-label="Drag to resize"></div>

      <main class="main">
        <section class="live-hero" id="live-hero" style="display:none">
          <div class="live-hero-header">
            <span class="domain-pill" id="hero-domain">—</span>
            <span class="hint" id="hero-hint">2×2 hero on this site. Drop products into the squares — each drop publishes to the live front page.</span>
            <span class="push-status" id="push-status"></span>
            <a id="open-live" class="live-hero-toggle" target="_blank" rel="noopener noreferrer">View live ↗</a>
            <button class="live-hero-toggle" id="hero-toggle">hide preview</button>
          </div>
          <div class="hero-canvas-wrap">
            <div class="hero-canvas" id="hero-canvas">
              <div class="hero-cell empty" data-hero-idx="0" data-empty-label="Room 1"></div>
              <div class="hero-cell empty" data-hero-idx="1" data-empty-label="Pattern 1"></div>
              <div class="hero-cell empty" data-hero-idx="2" data-empty-label="Room 2"></div>
              <div class="hero-cell empty" data-hero-idx="3" data-empty-label="Pattern 2"></div>
            </div>
          </div>
          <div class="hero-wordmark-preview" id="hero-wordmark-preview">linenwallpaper<small>curated wallcoverings · free memo samples</small></div>
        </section>
        <div class="search-bar">
          <span class="search-icon" aria-hidden="true">⌕</span>
          <input id="q" type="search" placeholder="Type anything to bring a product up…" autocomplete="off">
          <span class="source-label" id="source-label">no site selected</span>
          <span class="count" id="result-count"></span>
        </div>
        <div class="grid-scroll" id="grid-scroll">
          <div class="grid" id="grid"></div>
          <div class="empty-state" id="empty-state" style="display:none">
            <h3>Pick a site from the left rail.</h3>
            <p class="lead">Wired sites have a yellow dot. Other sites are listed by name and will populate when their catalog API is connected.</p>
          </div>
        </div>
      </main>
    </div>

    <section class="moodboard" id="moodboard">
      <div class="moodboard-header" id="moodboard-header">
        <h2>Moodboard — 4 Squares</h2>
        <span class="toggle">▾</span>
      </div>
      <div class="moodboard-body">
        <div class="slot empty" data-slot="1" data-empty-label="1"></div>
        <div class="slot empty" data-slot="2" data-empty-label="2"></div>
        <div class="slot empty" data-slot="3" data-empty-label="3"></div>
        <div class="slot empty" data-slot="4" data-empty-label="4"></div>
      </div>
    </section>
  </div>

  <script>
    const STORAGE_KEY = '4square.agentabrams.layout.v1';
    const SITES_KEY   = '4square.agentabrams.selectedSite.v1';
    const RAIL_W_KEY  = '4square.agentabrams.railWidth.v1';
    const MOOD_KEY    = '4square.agentabrams.moodCollapsed.v1';

    let layout = loadLayout();
    let sites = [];
    let selectedSite = null;
    let railItems = [];
    let queryToken = 0;

    function loadLayout() {
      try {
        const raw = localStorage.getItem(STORAGE_KEY);
        if (!raw) return { 1:null, 2:null, 3:null, 4:null };
        const p = JSON.parse(raw);
        return { 1: p[1]||null, 2: p[2]||null, 3: p[3]||null, 4: p[4]||null };
      } catch { return { 1:null, 2:null, 3:null, 4:null }; }
    }
    function saveLayout() {
      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); flashSaved(); }
      catch (e) { document.getElementById('save-status').textContent = 'autosave failed'; }
      // If this site is wired, ALSO push to the live site's hero-4grid.json.
      // The local-state save is the optimistic UI; the network push is the truth.
      if (selectedSite && selectedSite.source && selectedSite.source.endpoint) {
        pushHeroGridToServer();
      }
    }
    function flashSaved() {
      const el = document.getElementById('save-status');
      el.textContent = 'saved ✓'; el.classList.add('flash');
      clearTimeout(flashSaved._t);
      flashSaved._t = setTimeout(() => {
        el.classList.remove('flash');
        el.textContent = 'moodboard autosaves to this browser';
      }, 1200);
    }
    function escapeHtml(s) {
      return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
    }

    // ── Resizable rail ─────────────────────────────────────────────
    (function rail() {
      const saved = parseInt(localStorage.getItem(RAIL_W_KEY) || '240', 10);
      if (saved >= 180 && saved <= 600) document.documentElement.style.setProperty('--rail-w', saved + 'px');
      const split = document.getElementById('splitter');
      let dragging = false, startX = 0, startW = 0;
      split.addEventListener('mousedown', (e) => {
        dragging = true; split.classList.add('dragging');
        startX = e.clientX;
        startW = document.getElementById('rail').getBoundingClientRect().width;
        e.preventDefault();
      });
      window.addEventListener('mousemove', (e) => {
        if (!dragging) return;
        const newW = Math.max(180, Math.min(600, startW + (e.clientX - startX)));
        document.documentElement.style.setProperty('--rail-w', newW + 'px');
      });
      window.addEventListener('mouseup', () => {
        if (!dragging) return;
        dragging = false; split.classList.remove('dragging');
        const w = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--rail-w'), 10);
        localStorage.setItem(RAIL_W_KEY, String(w));
      });
    })();

    // ── Moodboard ──────────────────────────────────────────────────
    const board = document.querySelector('.moodboard-body');
    function renderSlots() {
      board.querySelectorAll('.slot').forEach(slot => {
        const n = slot.dataset.slot;
        const item = layout[n];
        slot.innerHTML = '';
        slot.classList.toggle('filled', !!item);
        slot.classList.toggle('empty', !item);
        slot.setAttribute('draggable', item ? 'true' : 'false');
        if (item) {
          const img = document.createElement('img');
          img.src = item.imageProxy; img.alt = item.title || ''; img.loading = 'lazy';
          const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = '#'+n;
          const info = document.createElement('div'); info.className = 'info';
          info.innerHTML = escapeHtml(item.title || 'Untitled');
          const clear = document.createElement('button'); clear.className='clear'; clear.title='Clear'; clear.textContent='×';
          clear.addEventListener('click', (e) => {
            e.stopPropagation();
            layout[n] = null; saveLayout(); renderSlots();
          });
          slot.append(img, badge, info, clear);
        }
      });
    }
    function attachSlotDnD() {
      board.querySelectorAll('.slot').forEach(slot => {
        slot.addEventListener('dragstart', (e) => {
          const n = slot.dataset.slot;
          if (!layout[n]) { e.preventDefault(); return; }
          e.dataTransfer.effectAllowed = 'move';
          e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind:'slot', from:n }));
          slot.classList.add('dragging-source');
        });
        slot.addEventListener('dragend', () => slot.classList.remove('dragging-source'));
        slot.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect='move'; slot.classList.add('over'); });
        slot.addEventListener('dragleave', () => slot.classList.remove('over'));
        slot.addEventListener('drop', (e) => {
          e.preventDefault(); slot.classList.remove('over');
          const raw = e.dataTransfer.getData('application/x-4sq');
          if (!raw) return;
          let payload; try { payload = JSON.parse(raw); } catch { return; }
          const target = slot.dataset.slot;
          if (payload.kind === 'tile') layout[target] = payload.item;
          else if (payload.kind === 'slot' && payload.from !== target) {
            const tmp = layout[target];
            layout[target] = layout[payload.from];
            layout[payload.from] = tmp;
          }
          saveLayout(); renderSlots(); renderHeroCanvas();
        });
      });
    }
    attachSlotDnD(); renderSlots(); renderHeroCanvas();

    // Moodboard collapse toggle
    (function moodCollapse() {
      const mb = document.getElementById('moodboard');
      const hdr = document.getElementById('moodboard-header');
      if (localStorage.getItem(MOOD_KEY) === 'true') mb.classList.add('collapsed');
      hdr.addEventListener('click', () => {
        mb.classList.toggle('collapsed');
        localStorage.setItem(MOOD_KEY, mb.classList.contains('collapsed') ? 'true' : 'false');
      });
    })();
    document.getElementById('reset-all').addEventListener('click', () => {
      if (!confirm('Clear all 4 moodboard squares?')) return;
      layout = { 1:null, 2:null, 3:null, 4:null };
      saveLayout(); renderSlots();
    });

    // ── Sites rail ─────────────────────────────────────────────────
    const $list = document.getElementById('site-list');
    const $railMeta = document.getElementById('rail-meta');
    const $siteFilter = document.getElementById('site-filter');

    async function loadSites() {
      try {
        // Cache-bust on every load so wiring changes (e.g. new site added to
        // sites.json) show up without a hard-refresh. 4Square owns the wiring
        // list and updates frequently; the prod cache layer must NOT pin a
        // stale copy in front of the user.
        const r = await fetch('/data/sites.json?cb=' + Date.now(), { cache: 'no-store' });
        const j = await r.json();
        sites = j.sites || [];
        const wired = sites.filter(s => s.source).length;
        $railMeta.textContent = `${sites.length} sites · ${wired} wired`;
        renderSites();
        // restore selection (skip if deep-link will override below)
        if (!new URLSearchParams(location.search).get('site')) {
          const saved = localStorage.getItem(SITES_KEY);
          const initial = sites.find(s => s.domain === saved) || sites.find(s => s.source) || sites[0];
          if (initial) selectSite(initial);
        }
      } catch (e) {
        $railMeta.textContent = 'failed to load sites';
      }
    }

    function renderSites() {
      const filter = $siteFilter.value.toLowerCase().trim();
      const matches = filter ? sites.filter(s => s.domain.toLowerCase().includes(filter)) : sites;
      $list.innerHTML = '';
      for (const s of matches) {
        const div = document.createElement('div');
        div.className = 'site-item' + (s.source ? ' wired' : '') + (selectedSite && selectedSite.domain === s.domain ? ' active' : '');
        div.title = s.domain + (s.source ? ' (catalog wired)' : ' (catalog pending)');
        div.innerHTML = `<span class="dot"></span><span class="name">${escapeHtml(s.domain)}</span><span class="kind">${escapeHtml(s.kind)}</span>`;
        div.addEventListener('click', () => selectSite(s));
        $list.appendChild(div);
      }
    }
    $siteFilter.addEventListener('input', renderSites);

    function selectSite(s) {
      selectedSite = s;
      localStorage.setItem(SITES_KEY, s.domain);
      document.getElementById('source-label').textContent = s.domain;
      renderSites();
      showLiveHero(s);
      hydrateMoodboardFromServer(s);
      fetchProducts();
    }

    // ── Live hero preview (in-DOM 2×2 canvas + wordmark, no iframe) ──
    function showLiveHero(s) {
      const wrap = document.getElementById('live-hero');
      const pill = document.getElementById('hero-domain');
      const hint = document.getElementById('hero-hint');
      const openA = document.getElementById('open-live');
      const word = document.getElementById('hero-wordmark-preview');
      wrap.style.display = '';
      pill.textContent = s.domain;
      openA.href = 'https://' + s.domain + '/?fsq=' + Date.now();
      const isWired = !!(s.source && s.source.endpoint);
      hint.textContent = isWired
        ? 'Drop products from the rail below into the 4 squares — each drop pushes to ' + s.domain + ' in real time.'
        : 'Drag-publish unavailable until ' + s.domain + ' has /api/products wired. Picks are saved locally for now.';
      // Update the wordmark preview to match the site's domain
      const stem = s.domain.replace(/\.[a-z]+$/, '');
      word.innerHTML = escapeHtml(stem) + '<small>' + escapeHtml(s.tagline || pickTagline(s)) + '</small>';
    }
    function pickTagline(s) {
      const k = s.kind || '';
      if (k === 'wallcovering') return 'curated wallcoverings · free memo samples';
      if (k === 'film')         return 'set decoration · archived & sold';
      return 'browse · drop · publish';
    }
    document.getElementById('hero-toggle').addEventListener('click', () => {
      const wrap = document.getElementById('live-hero');
      const btn = document.getElementById('hero-toggle');
      wrap.classList.toggle('collapsed');
      btn.textContent = wrap.classList.contains('collapsed') ? 'show preview' : 'hide preview';
    });

    // Render the 2×2 hero canvas from the current `layout` object (which is
    // already hydrated from the site's hero-4grid.json by hydrateMoodboardFromServer).
    // Each cell is a drop target — drop a product card onto it → updates layout → saveLayout
    // pushes to /api/site/:domain/hero-grid → live site picks up on next /hero-4grid.json fetch.
    function renderHeroCanvas() {
      const cells = document.querySelectorAll('#hero-canvas .hero-cell');
      cells.forEach((cell, i) => {
        const n = String(i + 1);
        const item = layout[n];
        cell.innerHTML = '';
        cell.classList.toggle('empty', !item);
        if (item) {
          cell.style.backgroundImage = "url('" + item.imageProxy + "')";
          const kind = document.createElement('div');
          kind.className = 'cell-kind';
          kind.textContent = item.kind || (i % 2 === 0 ? 'Room' : 'Pattern');
          const info = document.createElement('div');
          info.className = 'cell-info';
          info.textContent = item.title || '';
          const clear = document.createElement('button');
          clear.className = 'cell-clear'; clear.title = 'Clear'; clear.textContent = '×';
          clear.addEventListener('click', (e) => {
            e.stopPropagation();
            layout[n] = null; saveLayout(); renderHeroCanvas(); renderSlots();
          });
          cell.append(kind, info, clear);
        } else {
          cell.style.backgroundImage = '';
        }
      });
    }
    function attachHeroDnD() {
      document.querySelectorAll('#hero-canvas .hero-cell').forEach((cell) => {
        cell.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; cell.classList.add('over'); });
        cell.addEventListener('dragleave', () => cell.classList.remove('over'));
        cell.addEventListener('drop', (e) => {
          e.preventDefault(); cell.classList.remove('over');
          const raw = e.dataTransfer.getData('application/x-4sq');
          if (!raw) return;
          let payload; try { payload = JSON.parse(raw); } catch { return; }
          const target = String(parseInt(cell.dataset.heroIdx, 10) + 1);
          if (payload.kind === 'tile') {
            layout[target] = payload.item;
          } else if (payload.kind === 'slot' && payload.from !== target) {
            const tmp = layout[target];
            layout[target] = layout[payload.from];
            layout[payload.from] = tmp;
          }
          saveLayout(); renderHeroCanvas(); renderSlots();
        });
      });
    }
    attachHeroDnD();

    // ── Server-backed hero-grid sync ───────────────────────────────
    // For wired sites, the moodboard mirrors /api/site/:domain/hero-grid
    // (which is the same hero-4grid.json the live site renders).
    // For non-wired sites, fall back to the legacy localStorage layout.
    async function hydrateMoodboardFromServer(s) {
      if (!s || !s.source || !s.source.endpoint) {
        // Non-wired: keep localStorage layout
        layout = loadLayout();
        renderSlots();
        return;
      }
      try {
        const r = await fetch('/api/site/' + encodeURIComponent(s.domain) + '/hero-grid?cb=' + Date.now(), { cache: 'no-store' });
        const j = await r.json();
        if (!j || !j.grid || !Array.isArray(j.grid.cells)) {
          layout = { 1:null, 2:null, 3:null, 4:null };
        } else {
          layout = { 1:null, 2:null, 3:null, 4:null };
          j.grid.cells.forEach((c, i) => {
            if (!c || !c.image_url) return;
            layout[String(i+1)] = {
              id: c.sku || ('cell-' + i),
              title: c.title || c.kind || '',
              artist: c.kind || '',
              imageProxy: c.image_url,
              sku: c.sku || null,
              kind: c.kind || (i % 2 === 0 ? 'room' : 'pattern'),
            };
          });
        }
        renderSlots();
        renderHeroCanvas();
      } catch (e) {
        layout = loadLayout();
        renderSlots();
        renderHeroCanvas();
      }
    }

    async function pushHeroGridToServer() {
      if (!selectedSite || !selectedSite.source || !selectedSite.source.endpoint) return false;
      const cells = ['1','2','3','4'].map((n, i) => {
        const item = layout[n];
        return {
          kind: (item && item.kind) || (i % 2 === 0 ? 'room' : 'pattern'),
          sku: (item && item.sku) || null,
          image_url: (item && item.imageProxy) || null,
          title: (item && item.title) || null,
        };
      });
      // Pull rotation_pool from the current product rail (newest first)
      const rotation_pool = (railItems || []).slice(0, 16).filter(p => p.image_url).map(p => ({
        sku: p.sku || p.handle || null,
        image_url: p.image_url,
        title: p.title || '',
      }));
      const grid = { cells, rotation_pool, autocycle_seconds: 6 };
      const $ps = document.getElementById('push-status');
      $ps.textContent = 'pushing…'; $ps.className = 'push-status';
      try {
        const r = await fetch('/api/site/' + encodeURIComponent(selectedSite.domain) + '/hero-grid', {
          method: 'POST', headers: { 'content-type': 'application/json' },
          body: JSON.stringify(grid),
        });
        const j = await r.json();
        if (j.ok) {
          $ps.textContent = '✓ live on ' + selectedSite.domain; $ps.className = 'push-status ok';
          // Reload iframe after a beat so the new JSON serves
          setTimeout(() => {
            const iframe = document.getElementById('hero-iframe');
            iframe.src = 'https://' + selectedSite.domain + '/?fsq=' + Date.now();
          }, 700);
          return true;
        } else {
          $ps.textContent = '✗ ' + (j.error || 'push failed'); $ps.className = 'push-status err';
          return false;
        }
      } catch (e) {
        $ps.textContent = '✗ ' + e.message; $ps.className = 'push-status err';
        return false;
      }
    }

    // ── Product grid ───────────────────────────────────────────────
    const $grid = document.getElementById('grid');
    const $count = document.getElementById('result-count');
    const $empty = document.getElementById('empty-state');
    const $q = document.getElementById('q');

    let qDebounce;
    $q.addEventListener('input', () => {
      clearTimeout(qDebounce);
      qDebounce = setTimeout(fetchProducts, 200);
    });

    async function fetchProducts() {
      if (!selectedSite) { $grid.innerHTML = ''; $empty.style.display = ''; return; }
      $empty.style.display = 'none';
      // Always re-lookup the freshest source for this domain from `sites` —
      // protects against a stale selectedSite snapshot where source was null
      // before the site got wired. (Caused the "Catalog connection pending"
      // misfire on 2026-05-14 — metallicwallpaper was wired in sites.json
      // but a cached selectedSite still showed pending.)
      const fresh = sites.find(s => s.domain === selectedSite.domain);
      if (fresh) selectedSite = fresh;
      const src = selectedSite.source;
      if (!src) {
        $grid.innerHTML = '';
        $empty.innerHTML = `<h3>Catalog connection pending</h3>
          <p class="lead">Site <span class="domain">${escapeHtml(selectedSite.domain)}</span> is in the directory, but its product catalog isn't wired into 4Square yet. Connect its API to enable browsing here.</p>`;
        $empty.style.display = '';
        $count.textContent = '';
        return;
      }
      const token = ++queryToken;
      $count.textContent = 'loading…';
      const params = new URLSearchParams();
      params.set('site', selectedSite.domain);
      if ($q.value) params.set('q', $q.value);
      params.set('limit', '120');
      try {
        const r = await fetch('/api/site-products?' + params.toString());
        const j = await r.json();
        if (token !== queryToken) return;
        // Normalize shape: TSD returns {total, items}, DW returns either {items} or [].
        const items = Array.isArray(j) ? j : (j.items || []);
        const total = (j && j.total) || items.length;
        railItems = items;
        $count.textContent = `${items.length} of ${total.toLocaleString()}`;
        renderGrid();
      } catch (e) { $count.textContent = 'load failed: ' + e.message; }
    }

    function renderGrid() {
      $grid.innerHTML = '';
      if (!railItems.length) {
        $empty.innerHTML = `<h3>No matches</h3><p class="lead">Try a different search term.</p>`;
        $empty.style.display = '';
        return;
      }
      $empty.style.display = 'none';
      for (const it of railItems) {
        const div = document.createElement('div');
        div.className = 'card';
        div.draggable = true;
        const rawImg = it.image_url || it.image || '';
        if (!rawImg) continue; // skip items with no image — keeps the grid clean
        // Absolute https URLs (Shopify CDN, etc.) used directly; TSD /api/art-archive paths go through our /img proxy.
        const imageProxy = /^https?:\/\//.test(rawImg) ? rawImg : ('/img?path=' + encodeURIComponent(rawImg));
        const lite = {
          id: it.id || it.sku || it.handle,
          title: it.title || it.product_title || 'Untitled',
          artist: it.artist || it.vendor || '',
          year: it.year, era: it.era,
          imageProxy,
        };
        div.dataset.item = JSON.stringify(lite);
        const img = document.createElement('img');
        img.src = imageProxy; img.loading = 'lazy'; img.alt = it.title || '';
        const label = document.createElement('div');
        label.className = 'card-label';
        label.innerHTML = `${escapeHtml(it.title || 'Untitled')}<span class="by">${escapeHtml(it.artist || '')}${it.year ? ' · ' + it.year : ''}</span>`;
        div.append(img, label);
        div.addEventListener('dragstart', (e) => {
          e.dataTransfer.effectAllowed = 'copy';
          e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind:'tile', item: lite }));
          div.classList.add('dragging');
        });
        div.addEventListener('dragend', () => div.classList.remove('dragging'));
        // Double-click = place into first empty slot
        div.addEventListener('dblclick', () => {
          for (const n of ['1','2','3','4']) {
            if (!layout[n]) { layout[n] = lite; saveLayout(); renderSlots(); return; }
          }
        });
        $grid.appendChild(div);
      }
    }

    // ── boot ───────────────────────────────────────────────────────
    loadSites().then(() => {
      // Deep-link support: /?site=<domain> jumps straight to that site (used by wall.html)
      const params = new URLSearchParams(location.search);
      const deepSite = params.get('site');
      if (deepSite) {
        const hit = sites.find(s => s.domain === deepSite);
        if (hit) selectSite(hit);
      }
    });
  </script>
</body>
</html>