← back to Dw Marketing Reels

public/index.html

551 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DW Marketing · New Arrivals Reels</title>
<style>
  :root { --ink:#1c1a17; --cream:#f4efe6; --gold:#b08d57; --line:#e2dccf; --cols:4; --side:248px; }
  * { box-sizing:border-box; }
  body { margin:0; font:15px/1.5 "Helvetica Neue",system-ui,sans-serif; color:var(--ink); background:var(--cream); }

  /* ── left side panel ── */
  .side { position:fixed; top:0; left:0; bottom:0; width:var(--side); background:#fff; border-right:1px solid var(--line);
    display:flex; flex-direction:column; padding:22px 18px; gap:22px; overflow-y:auto; z-index:20; }
  .side .mark { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:23px; line-height:1.15; }
  .side .mark b { color:var(--gold); font-weight:400; }
  .side .sub { font-size:10.5px; letter-spacing:.2em; text-transform:uppercase; color:#8a8272; margin-top:4px; }
  .side nav { display:flex; flex-direction:column; gap:2px; }
  .side nav a { text-decoration:none; color:#5c5648; font-size:13.5px; padding:8px 10px; border-radius:8px; letter-spacing:.02em; }
  .side nav a:hover { background:var(--cream); color:var(--ink); }
  .side .grp { font-size:10px; letter-spacing:.2em; text-transform:uppercase; color:#a79f8d; margin:2px 0 4px 10px; }
  .side .acts { display:flex; flex-direction:column; gap:8px; margin-top:auto; }
  .side .acts button { width:100%; }

  button { font:inherit; border:1px solid var(--ink); background:var(--ink); color:#fff; padding:9px 16px; border-radius:999px; cursor:pointer; letter-spacing:.04em; }
  button.ghost { background:#fff; color:var(--ink); }
  button.gold { background:var(--gold); border-color:var(--gold); }
  button:disabled { opacity:.5; cursor:not-allowed; }
  button.tiny { font-size:11px; padding:5px 10px; }

  .wrap { margin-left:var(--side); }
  main { padding:28px 34px; max-width:1400px; }
  h2 { font-family:"Didot","Bodoni 72",Georgia,serif; font-weight:400; font-size:26px; margin:34px 0 14px; scroll-margin-top:20px; }
  h2:first-of-type { margin-top:6px; }
  .status { font-size:13px; color:#6a6355; min-height:20px; margin:6px 0 0; position:sticky; top:0; background:var(--cream); padding:8px 0; z-index:5; }
  .reels { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:20px; }
  .reel { background:#fff; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
  .reel video { width:100%; display:block; background:#000; aspect-ratio:9/16; object-fit:cover; }
  .reel .body { padding:12px 14px; }
  .reel .when { font-size:12px; color:#8a8272; }
  .reel .titles { font-size:12.5px; color:#4c463c; margin-top:6px; max-height:60px; overflow:hidden; }
  .reel .row { display:flex; gap:10px; margin-top:10px; align-items:center; }
  .reel a { font-size:12px; color:var(--gold); text-decoration:none; letter-spacing:.06em; text-transform:uppercase; }
  .badges { display:flex; gap:6px; margin:8px 0; flex-wrap:wrap; }
  .badge { font-size:11px; padding:2px 8px; border-radius:999px; letter-spacing:.02em; }
  .badge.ok { background:#e5f3e8; color:#1f7a3d; } .badge.sim { background:#eef0f7; color:#4b52a0; }
  .badge.wait { background:#f6efe0; color:#8a6d2f; } .badge.err { background:#fbe6e4; color:#a3382c; }
  .cap-box { background:#faf7f0; border:1px solid var(--line); border-radius:8px; padding:8px 10px; margin:8px 0; }
  .cap-txt { font-size:12px; color:#4c463c; white-space:pre-wrap; max-height:96px; overflow:auto; }
  .copied { background:#e5f3e8 !important; color:#1f7a3d !important; border-color:#1f7a3d !important; }
  .controls { display:flex; gap:22px; align-items:center; flex-wrap:wrap; margin:6px 0 16px; }
  .controls label { font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; margin-right:8px; }
  select, input[type=range] { font:inherit; }
  select { padding:7px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }
  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:14px; }
  .card { background:#fff; border:1px solid var(--line); border-radius:10px; overflow:hidden; }
  .card .thumb { width:100%; aspect-ratio:4/5; object-fit:cover; display:block; background:#eee; }
  .card .info { padding:9px 11px; }
  .card .pat { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:17px; }
  .card .met { font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin-top:3px; }
  .card .when { font-size:11px; color:#9a917f; margin-top:7px; }
  .accts { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin:6px 0 4px; }
  .acct { display:flex; gap:8px; align-items:center; background:#fff; border:1px solid var(--line); border-radius:999px; padding:6px 12px; font-size:12.5px; }
  .acct .dot { width:8px; height:8px; border-radius:50%; background:#c9a35a; }
  .acct.ready .dot { background:#2f9e57; }
  .acct .h { color:#8a8272; font-size:11.5px; }
  .acct button { font-size:11px; padding:2px 8px; border-radius:999px; }
  .postto { display:flex; gap:8px; align-items:center; margin-top:10px; }
  .postto label { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; }
  .postto select { font-size:12.5px; padding:5px 8px; }

  /* ── suggestions ── */
  .sg-day { margin:18px 0 8px; display:flex; align-items:baseline; gap:10px; }
  .sg-day h3 { margin:0; font-family:"Didot","Bodoni 72",Georgia,serif; font-weight:400; font-size:18px; }
  .sg-day .n { font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:#a79f8d; }
  .sg-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:16px; }
  .sg { background:#fff; border:1px solid var(--line); border-radius:12px; overflow:hidden; display:flex; flex-direction:column; }
  .sg.done { border-color:#cfe6d5; }
  .sg .thumbs { display:flex; gap:2px; background:#eee; aspect-ratio:16/7; overflow:hidden; }
  .sg .thumbs img { flex:1; min-width:0; object-fit:cover; height:100%; }
  .sg .b { padding:12px 14px; display:flex; flex-direction:column; gap:7px; flex:1; }
  .sg .kind { font-size:10.5px; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); }
  .sg .t { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:17px; line-height:1.25; }
  .sg .idea { font-size:12.5px; color:#4c463c; }
  .sg .when { font-size:11px; color:#9a917f; }
  .sg .used { font-size:11.5px; color:#1f7a3d; background:#e5f3e8; border-radius:8px; padding:5px 9px; }
  .sg .row { display:flex; gap:8px; align-items:center; margin-top:auto; padding-top:4px; }

  /* ── modals ── */
  .overlay { position:fixed; inset:0; background:rgba(28,26,23,.44); display:none; align-items:center; justify-content:center; z-index:40; padding:20px; }
  .overlay.open { display:flex; }
  .modal { background:#fff; border-radius:14px; width:min(460px,100%); max-height:90vh; overflow:auto; box-shadow:0 20px 60px rgba(0,0,0,.25); }
  .modal .mhd { display:flex; align-items:center; padding:18px 22px; border-bottom:1px solid var(--line); }
  .modal .mhd h3 { margin:0; font-family:"Didot","Bodoni 72",Georgia,serif; font-weight:400; font-size:22px; flex:1; }
  .modal .mhd .x { background:none; border:none; color:#8a8272; font-size:22px; cursor:pointer; padding:0 4px; }
  .modal .mbd { padding:20px 22px; display:flex; flex-direction:column; gap:14px; }
  .modal label.fld { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; display:block; margin-bottom:5px; }
  .modal input[type=text], .modal select { width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:8px; background:#fff; font:inherit; }
  .modal .mft { display:flex; gap:10px; justify-content:flex-end; padding:0 22px 20px; }
  .modal .note { font-size:12px; color:#8a8272; }
  .modal .out { font-size:12.5px; color:#4c463c; background:#faf7f0; border:1px solid var(--line); border-radius:8px; padding:10px 12px; white-space:pre-wrap; max-height:160px; overflow:auto; display:none; }
  .modal .out.show { display:block; }
  .modal .out a { color:var(--gold); }
</style>
</head>
<body>

<!-- ── LEFT SIDE PANEL ── -->
<aside class="side">
  <div>
    <div class="mark">Designer <b>Wallcoverings</b></div>
    <div class="sub">Marketing · Reels</div>
  </div>
  <nav>
    <div class="grp">Browse</div>
    <a href="#sec-suggest">🎥 Suggested videos</a>
    <a href="#sec-reels">🎞 Reels</a>
    <a href="#sec-videos">🎬 Studio videos</a>
    <a href="#sec-accts">📷 Instagram accounts</a>
    <a href="#sec-na">🆕 New Arrivals</a>
  </nav>
  <div class="acts">
    <div class="grp" style="margin:0 0 2px 0">Video Studio</div>
    <button class="gold" id="btnSuggest">✨ Suggest 4 for today</button>
    <button id="btnBuild">▶ New Arrivals reel</button>
    <button class="ghost" id="btnStudioReel">🎬 IG reel · 9:16</button>
    <button class="ghost" id="btnStudioUrl">🔗 URL → reel</button>
    <button class="ghost" id="btnStudioEml">📧 Newsletter → video</button>
    <div class="grp" style="margin:10px 0 2px 0">Distribute</div>
    <button class="gold" id="btnPublish">✦ Publish to Instagram</button>
    <button class="ghost" id="btnAddAcct">+ Add account</button>
    <button class="ghost" id="btnRefresh">↻ Refresh feed</button>
  </div>
</aside>

<div class="wrap">
<main>
  <div class="status" id="status"></div>

  <h2 id="sec-suggest">Suggested videos <span id="sgInfo" style="font-size:13px;color:#8a8272"></span></h2>
  <p style="font-size:12.5px;color:#8a8272;margin:0 0 6px">Four fresh video ideas a day, built from the live New Arrivals catalog. Every idea is dated and kept forever; “Use it” queues the $0 local HyperFrames render and date-stamps when &amp; where it was used.</p>
  <div id="suggest"></div>

  <h2 id="sec-accts">Instagram accounts <span style="font-size:13px;color:#8a8272">(choose per reel below)</span></h2>
  <div class="accts" id="accts"></div>

  <h2 id="sec-reels">Reels</h2>
  <div class="reels" id="reels"></div>

  <h2 id="sec-videos">Studio videos <span id="jobsInfo" style="font-size:13px;color:#8a8272"></span></h2>
  <div id="jobs" class="badges"></div>
  <div class="reels" id="videos"></div>

  <h2 id="sec-na">Current New Arrivals <span id="naCount" style="font-size:13px;color:#8a8272"></span></h2>
  <div class="controls">
    <div><label>Sort</label>
      <select id="sort">
        <option value="newest">Newest</option>
        <option value="pattern">Pattern A→Z</option>
        <option value="vendor">Vendor</option>
        <option value="color">Color</option>
      </select>
    </div>
    <div><label>Density</label>
      <input type="range" id="density" min="2" max="7" step="1" />
    </div>
  </div>
  <div class="grid" id="grid"></div>
</main>
</div>

<!-- ── PUBLISH MODAL ── -->
<div class="overlay" id="ovPublish">
  <div class="modal">
    <div class="mhd"><h3>Publish to Instagram</h3><button class="x" data-close>×</button></div>
    <div class="mbd">
      <div><label class="fld">Reel</label><select id="pubReel"></select></div>
      <div><label class="fld">Account</label><select id="pubAcct"></select></div>
      <div class="note">Posts the reel as an Instagram Reel to the selected account. The live post only fires when the studio host is armed (<code>SOCIAL_LIVE_ARMED=1</code>); otherwise this runs a safe dry-check.</div>
      <div class="out" id="pubOut"></div>
    </div>
    <div class="mft">
      <button class="ghost" data-close>Cancel</button>
      <button class="gold" id="pubGo">Publish now</button>
    </div>
  </div>
</div>

<!-- ── ADD ACCOUNT MODAL ── -->
<div class="overlay" id="ovAcct">
  <div class="modal">
    <div class="mhd"><h3 id="acctTitle">Add Instagram account</h3><button class="x" data-close>×</button></div>
    <div class="mbd">
      <div><label class="fld">Business / account name</label><input type="text" id="acctLabel" placeholder="e.g. Phillipe Romano" /></div>
      <div><label class="fld">Instagram handle (without @)</label><input type="text" id="acctHandle" placeholder="philipperomano" /></div>
      <div class="note" id="acctNote">New accounts start as “needs creds” — wire the token to activate.</div>
    </div>
    <div class="mft">
      <button class="ghost" data-close>Cancel</button>
      <button class="gold" id="acctSave">Save</button>
    </div>
  </div>
</div>

<!-- ── VIDEO STUDIO MODAL ── -->
<div class="overlay" id="ovStudio">
  <div class="modal">
    <div class="mhd"><h3 id="studioTitle">Make a video</h3><button class="x" data-close>×</button></div>
    <div class="mbd">
      <div id="studioUrlWrap"><label class="fld">Product or collection URL</label>
        <input type="text" id="studioUrl" placeholder="https://designerwallcoverings.com/collections/new-arrivals" /></div>
      <div id="studioEmlWrap" style="display:none"><label class="fld">Newsletter .eml file path</label>
        <input type="text" id="studioEml" placeholder="~/Downloads/this-weeks-newsletter.eml" /></div>
      <div><label class="fld">Format</label>
        <select id="studioFmt"><option value="9:16">9:16 · Instagram Reels</option><option value="1:1">1:1 · Feed / Square</option></select></div>
      <div class="note">Queues a <b>$0 local</b> render on the studio host. It runs automatically only when the host is armed (<code>VIDEO_AUTORUN=1</code>); otherwise it waits for a studio session. Nothing posts to Instagram — publishing stays a separate approved step.</div>
      <div class="out" id="studioOut"></div>
    </div>
    <div class="mft">
      <button class="ghost" data-close>Cancel</button>
      <button class="gold" id="studioGo">Queue video</button>
    </div>
  </div>
</div>

<script>
const $ = s => document.querySelector(s);
const fmt = iso => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso||''; } };
const escapeHtml = s => String(s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
const escapeAttr = s => String(s||'').replace(/"/g,'&quot;').replace(/\n/g,'&#10;');
const STMAP = { posted:['✓ posted','ok'], simulated:['◐ simulated','sim'], 'pending-creds':['◷ needs creds','wait'],
  'pending-reconnect':['◷ reconnect','wait'], 'ready-pending-arm':['🔒 armed?','sim'], 'pending-review':['👁 review','sim'],
  'no-creds-for-account':['⚠ no acct creds','err'],
  pending:['◷ pending','wait'], error:['✗ error','err'], skipped:['– skipped','wait'] };
function pubBadges(pub){
  if(!pub) return '';
  const ch = { instagram:'IG', tiktok:'TT' };
  const bits = Object.entries(ch).map(([k,lbl])=>{
    const st = (pub[k]&&pub[k].status)||'pending'; const [txt,cls]=STMAP[st]||[st,'wait'];
    return `<span class="badge ${cls}" title="${escapeAttr((pub[k]&&pub[k].note)||st)}">${lbl} ${txt}</span>`;
  });
  return `<div class="badges">${bits.join('')}</div>`;
}

// ── modal helpers ──
function openModal(id){ $(id).classList.add('open'); }
function closeModal(el){ el.classList.remove('open'); }
document.querySelectorAll('.overlay').forEach(ov => {
  ov.addEventListener('click', e => { if (e.target === ov || e.target.hasAttribute('data-close')) closeModal(ov); });
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.overlay.open').forEach(closeModal); });

// ── instagram accounts ──
let ACCTS = [], REELS = [], VAUTO = false;
async function loadAccounts() {
  ACCTS = await (await fetch('api/ig-accounts')).json();
  $('#accts').innerHTML = ACCTS.map(a => `
    <div class="acct ${a.status==='ready'?'ready':''}" title="${escapeAttr(a.note||'')}">
      <span class="dot"></span>
      <span>${escapeHtml(a.label)}</span>
      <span class="h">${a.handle ? '@'+escapeHtml(a.handle) : 'handle TBD'} · ${a.status==='ready'?'connected':'needs creds'}</span>
      <button class="ghost tiny edit-acct" data-id="${a.id}">✎</button>
    </div>`).join('') || '<span style="color:#8a8272">No accounts yet.</span>';
}
function acctOptions(sel) {
  return ACCTS.map(a => `<option value="${a.id}" ${a.id===sel?'selected':''}>${escapeHtml(a.label)}${a.status==='ready'?'':' (needs creds)'}</option>`).join('');
}
// edit existing account → reuse the account modal
$('#accts').addEventListener('click', e => {
  const ed = e.target.closest('.edit-acct'); if (!ed) return;
  const a = ACCTS.find(x => x.id === ed.dataset.id); if (!a) return;
  $('#acctTitle').textContent = 'Edit ' + a.label;
  $('#acctLabel').value = a.label; $('#acctLabel').disabled = true;
  $('#acctHandle').value = a.handle || '';
  $('#acctSave').dataset.editId = a.id;
  openModal('#ovAcct');
});
// add account button
$('#btnAddAcct').addEventListener('click', () => {
  $('#acctTitle').textContent = 'Add Instagram account';
  $('#acctLabel').value = ''; $('#acctLabel').disabled = false;
  $('#acctHandle').value = ''; delete $('#acctSave').dataset.editId;
  openModal('#ovAcct');
});
// save account (add or edit)
$('#acctSave').addEventListener('click', async () => {
  const editId = $('#acctSave').dataset.editId;
  const handle = $('#acctHandle').value.trim().replace(/^@/,'') || null;
  let body;
  if (editId) { body = { id: editId, handle }; }
  else {
    const label = $('#acctLabel').value.trim(); if (!label) { $('#acctLabel').focus(); return; }
    const id = label.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
    body = { id, label, handle, status:'pending-creds', note:'added from console' };
  }
  await fetch('api/ig-accounts', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
  closeModal($('#ovAcct')); loadAccounts();
});

// ── publish modal ──
$('#btnPublish').addEventListener('click', () => {
  if (!REELS.length) { $('#status').textContent = 'No reels to publish yet.'; return; }
  $('#pubReel').innerHTML = REELS.map(r => `<option value="${escapeAttr(r.file)}">${escapeHtml(r.file)}</option>`).join('');
  $('#pubAcct').innerHTML = acctOptions('dw');
  const out = $('#pubOut'); out.className = 'out'; out.textContent = '';
  $('#pubGo').disabled = false; $('#pubGo').textContent = 'Publish now';
  openModal('#ovPublish');
});
$('#pubGo').addEventListener('click', async () => {
  const file = $('#pubReel').value; const out = $('#pubOut');
  $('#pubGo').disabled = true; $('#pubGo').textContent = 'Publishing…';
  out.className = 'out show'; out.textContent = '→ starting publish for ' + file + '…';
  const r = await fetch('api/publish', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ file }) });
  if (!r.ok) { const j = await r.json().catch(()=>({})); out.textContent = '✗ ' + (j.error||'failed to start'); $('#pubGo').disabled=false; $('#pubGo').textContent='Publish now'; return; }
  // poll the job log until it finishes, streaming the tail into the modal
  const tick = async () => {
    const st = await (await fetch('api/status')).json();
    const tail = (st.log||'').split('\n').filter(Boolean).slice(-6).join('\n');
    out.textContent = tail || 'working…';
    if (st.building) { setTimeout(tick, 1500); }
    else {
      const link = (st.log.match(/https:\/\/www\.instagram\.com\/reel\/[\w-]+\/?/)||[])[0];
      if (link) out.innerHTML = escapeHtml(tail).replace(link, `<a href="${link}" target="_blank" rel="noopener noreferrer">${link}</a>`);
      $('#pubGo').disabled = false; $('#pubGo').textContent = 'Publish again';
      loadReels();
    }
  };
  tick();
});

// ── reels gallery ──
async function loadReels() {
  REELS = await (await fetch('api/reels')).json();
  $('#reels').innerHTML = REELS.length ? REELS.map(r => `
    <div class="reel">
      <video src="reels/${encodeURIComponent(r.file)}" controls preload="metadata" playsinline></video>
      <div class="body">
        <div class="when" title="${r.created_at}">🕓 ${fmt(r.created_at)}</div>
        <div class="titles">${(r.titles||[]).join(' · ')}</div>
        ${pubBadges(r.publish)}
        <div class="postto"><label>Post to</label>
          <select class="pick-acct" data-file="${escapeAttr(r.file)}">${acctOptions(r.igAccount||'dw')}</select>
        </div>
        ${r.caption ? `<div class="cap-box"><div class="cap-txt">${escapeHtml(r.caption)}</div>
          <button class="ghost tiny copy" data-cap="${escapeAttr(r.caption)}">Copy caption</button></div>` : ''}
        <div class="row"><a href="reels/${encodeURIComponent(r.file)}" download>Download</a>
          <button class="ghost tiny pub-one" data-file="${escapeAttr(r.file)}">Publish ↗</button>
          <span style="font-size:12px;color:#9a917f">${r.products} products · ${r.seconds}s</span></div>
      </div>
    </div>`).join('') : '<p style="color:#8a8272">No reels yet — hit “Generate reel”.</p>';
}
// per-reel account picker + copy caption + quick-publish (delegated)
$('#reels').addEventListener('change', async e => {
  const sel = e.target.closest('.pick-acct'); if (!sel) return;
  const r = await fetch('api/reel-account', { method:'POST', headers:{'content-type':'application/json'},
    body: JSON.stringify({ file: sel.dataset.file, accountId: sel.value }) });
  $('#status').textContent = r.ok ? `✓ ${sel.dataset.file} → ${sel.options[sel.selectedIndex].text}` : '✗ failed to set account';
});
$('#reels').addEventListener('click', e => {
  const c = e.target.closest('.copy');
  if (c) { navigator.clipboard.writeText(c.dataset.cap || '').then(() => {
    const o = c.textContent; c.textContent = '✓ Copied'; c.classList.add('copied');
    setTimeout(() => { c.textContent = o; c.classList.remove('copied'); }, 1500); }); return; }
  const p = e.target.closest('.pub-one');
  if (p) { $('#btnPublish').click(); $('#pubReel').value = p.dataset.file; }
});

// ── Video Studio (queue jobs + rendered-video gallery) ──
async function loadVideos() {
  let vids = []; try { vids = await (await fetch('api/videos')).json(); } catch {}
  $('#videos').innerHTML = vids.length ? vids.map(v => `
    <div class="reel">
      <video src="${v.url}" controls preload="none" playsinline></video>
      <div class="body">
        <div class="when">🕓 ${fmt(new Date(v.mtime).toISOString())}</div>
        <div class="titles">${escapeHtml(v.path)}</div>
        <div class="row"><a href="${v.url}" download>Download</a>
          <span style="font-size:12px;color:#9a917f">${v.kb} KB</span></div>
      </div>
    </div>`).join('') : '<p style="color:#8a8272">No studio videos yet — queue one from the Video Studio buttons.</p>';
}
async function loadJobs() {
  let jobs = []; try { jobs = await (await fetch('api/video/jobs')).json(); } catch {}
  const active = jobs.filter(j => j.status === 'queued' || j.status === 'running');
  $('#jobs').innerHTML = active.map(j => {
    const cls = j.status === 'running' ? 'wait' : 'sim';
    const what = j.kind === 'new-arrivals-reel' ? 'New Arrivals reel'
      : j.kind === 'url-reel' ? ('URL → ' + (j.url || '').slice(0, 44))
      : ('Newsletter → ' + (j.eml || '').slice(0, 44));
    return `<span class="badge ${cls}" title="queued ${escapeAttr(j.created_at)}">${j.status === 'running' ? '⚙' : '◷'} ${escapeHtml(what)} · ${j.format} · 🕓 ${fmt(j.created_at)}</span>`;
  }).join('');
  if (active.length && !VAUTO) $('#jobs').insertAdjacentHTML('afterbegin',
    '<span class="badge err" title="Set VIDEO_AUTORUN=1 on the studio host to auto-render">⚠ host not armed — queued jobs wait for a studio session</span>');
  // surface the last few errored renders (with reason) so failures don't vanish silently
  const failed = jobs.filter(j => j.status === 'error').slice(0, 3);
  $('#jobs').insertAdjacentHTML('beforeend', failed.map(j =>
    `<span class="badge err" title="${escapeAttr(j.note||'render failed')}">✗ ${escapeHtml(j.kind)} · ${escapeHtml(j.note||'failed')}</span>`).join(''));
  $('#jobsInfo').textContent = active.length ? `(${active.length} queued / running)` : '';
  if (active.length) setTimeout(() => { loadJobs(); loadVideos(); }, 4000);
}
async function queueVideo(body) {
  const out = $('#studioOut'); out.className = 'out show'; out.textContent = '→ queueing…';
  const r = await fetch('api/video/queue', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
  const j = await r.json().catch(()=>({}));
  if (!r.ok) { out.textContent = '✗ ' + (j.error || 'failed'); return false; }
  out.textContent = '✓ queued (' + j.queued.id + '). It renders on the studio host — watch “Studio videos”.';
  loadJobs(); return true;
}
$('#btnStudioReel').addEventListener('click', () => queueVideo({ kind:'new-arrivals-reel', format:'9:16' })
  .then(ok => { if (ok) $('#status').textContent = '🎬 New Arrivals reel queued'; }));
function openStudio(mode) {
  $('#studioTitle').textContent = mode === 'eml' ? 'Newsletter → video' : 'URL → reel';
  $('#studioUrlWrap').style.display = mode === 'url' ? '' : 'none';
  $('#studioEmlWrap').style.display = mode === 'eml' ? '' : 'none';
  $('#studioGo').dataset.mode = mode;
  const out = $('#studioOut'); out.className = 'out'; out.textContent = '';
  openModal('#ovStudio');
}
$('#btnStudioUrl').addEventListener('click', () => openStudio('url'));
$('#btnStudioEml').addEventListener('click', () => openStudio('eml'));
$('#studioGo').addEventListener('click', async () => {
  const mode = $('#studioGo').dataset.mode, format = $('#studioFmt').value;
  if (mode === 'url') { const url = $('#studioUrl').value.trim(); if (!url) { $('#studioUrl').focus(); return; }
    if (await queueVideo({ kind:'url-reel', url, format })) closeModal($('#ovStudio')); }
  else { const eml = $('#studioEml').value.trim(); if (!eml) { $('#studioEml').focus(); return; }
    if (await queueVideo({ kind:'newsletter-video', eml, format })) closeModal($('#ovStudio')); }
});

// ── Suggested videos ("hypergen" 4-a-day ideas) ──
const KINDLBL = { 'new-arrivals-reel':'New Arrivals reel', 'url-reel':'URL → reel', 'newsletter-video':'Newsletter → video' };
const dayLabel = d => { try { return new Date(d+'T12:00:00').toLocaleDateString(undefined,{weekday:'short',year:'numeric',month:'long',day:'numeric'}); } catch { return d; } };
let CANBUILD = true;
async function loadSuggestions() {
  let list = []; try { list = await (await fetch('api/video/suggestions')).json(); } catch {}
  const days = [...new Set(list.map(s => s.date))];
  $('#sgInfo').textContent = list.length ? `(${list.length} ideas · ${days.length} day${days.length>1?'s':''} · ${list.filter(s=>s.used).length} used)` : '';
  if (!list.length) { $('#suggest').innerHTML = '<p style="color:#8a8272">No suggestions yet — hit “✨ Suggest 4 for today”.</p>'; return; }
  $('#suggest').innerHTML = days.map(d => {
    const items = list.filter(s => s.date === d).sort((a,b)=>a.slot-b.slot);
    const cards = items.map(s => {
      const thumbs = (s.refs||[]).filter(r=>r.image).slice(0,4)
        .map(r => `<img loading="lazy" src="${escapeAttr(r.image)}" alt="${escapeAttr(r.pattern)}"/>`).join('')
        || `<img src="${escapeAttr(s.hero_image||'')}" alt=""/>`;
      const used = s.used ? `<div class="used" title="${escapeAttr(s.used_at)}">✓ Used ${fmt(s.used_at)} → ${escapeHtml((KINDLBL[s.used_where?.kind]||s.used_where?.kind||''))} · ${escapeHtml(s.used_where?.format||'')} <span style="color:#7a8f80">(${escapeHtml(s.used_where?.jobId||'')})</span></div>` : '';
      const btn = s.used
        ? `<button class="ghost tiny use-sg" data-id="${escapeAttr(s.id)}">Queue again</button>`
        : `<button class="gold tiny use-sg" data-id="${escapeAttr(s.id)}"${CANBUILD?'':' disabled'}>Use it ↗</button>`;
      return `<div class="sg ${s.used?'done':''}">
        <div class="thumbs">${thumbs}</div>
        <div class="b">
          <div class="kind">${escapeHtml(KINDLBL[s.kind]||s.kind)} · ${escapeHtml(s.format)}</div>
          <div class="t">${escapeHtml(s.title)}</div>
          <div class="idea">${escapeHtml(s.idea)}</div>
          <div class="when" title="${escapeAttr(s.created_at)}">🕓 Suggested ${fmt(s.created_at)}</div>
          ${used}
          <div class="row">${btn}
            ${s.url?`<a href="${escapeAttr(s.url)}" target="_blank" rel="noopener noreferrer" style="font-size:11px;color:var(--gold);text-transform:uppercase;letter-spacing:.06em;text-decoration:none">Source ↗</a>`:''}
          </div>
        </div></div>`;
    }).join('');
    return `<div class="sg-day"><h3>${dayLabel(d)}</h3><span class="n">${items.length} idea${items.length>1?'s':''}</span></div>
      <div class="sg-grid">${cards}</div>`;
  }).join('');
}
// Use a suggestion → queue its render + date-stamp it (delegated)
$('#suggest').addEventListener('click', async e => {
  const u = e.target.closest('.use-sg'); if (!u) return;
  u.disabled = true; const o = u.textContent; u.textContent = '⏳ queueing…';
  const r = await fetch('api/video/suggestions/use', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ id: u.dataset.id }) });
  const j = await r.json().catch(()=>({}));
  if (!r.ok) { $('#status').textContent = '✗ ' + (j.error||'failed to queue'); u.disabled=false; u.textContent=o; return; }
  $('#status').textContent = `✓ queued ${j.queued.id} — renders on the studio host (watch “Studio videos”).`;
  loadSuggestions(); loadJobs();
});
// Sidebar: generate today's 4 on demand
$('#btnSuggest').addEventListener('click', async () => {
  $('#status').textContent = '✨ generating today’s suggestions…'; $('#status').dataset.wasBusy='1';
  await fetch('api/video/suggest', { method:'POST', headers:{'content-type':'application/json'}, body:'{}' });
  const tick = async () => {
    const st = await (await fetch('api/status')).json();
    if (st.building) { setTimeout(tick, 1200); }
    else { $('#status').dataset.wasBusy=''; $('#status').textContent = '✓ suggestions updated'; loadSuggestions(); }
  };
  tick();
});

// ── new arrivals grid (sort + density, persisted) ──
let NA = [];
function renderGrid() {
  const s = $('#sort').value;
  const items = [...NA];
  const by = { pattern:(a,b)=>a.pattern.localeCompare(b.pattern), vendor:(a,b)=>(a.vendor||'').localeCompare(b.vendor||''),
    color:(a,b)=>(a.color||'').localeCompare(b.color||''), newest:(a,b)=>new Date(b.created_at)-new Date(a.created_at) };
  items.sort(by[s]||by.newest);
  $('#grid').innerHTML = items.map(it => `
    <div class="card">
      <img class="thumb" loading="lazy" src="${it.image_hi||it.image}" alt="${it.pattern}" onerror="this.src='${it.image}'"/>
      <div class="info">
        <div class="pat">${it.pattern}</div>
        <div class="met">${[it.color,it.vendor].filter(Boolean).join(' · ')}</div>
        <div class="when" title="${it.created_at}">🕓 ${fmt(it.created_at)}</div>
      </div>
    </div>`).join('');
}
async function loadNA() {
  const d = await (await fetch('api/new-arrivals')).json();
  NA = d.items||[]; $('#naCount').textContent = `(${NA.length} · ${d.fetched_at?('feed '+fmt(d.fetched_at)):''})`;
  renderGrid();
}

// persisted controls
$('#sort').value = localStorage.getItem('dwr_sort')||'newest';
$('#density').value = localStorage.getItem('dwr_density')||'4';
document.documentElement.style.setProperty('--cols', $('#density').value);
$('#sort').addEventListener('change', e=>{ localStorage.setItem('dwr_sort',e.target.value); renderGrid(); });
$('#density').addEventListener('input', e=>{ localStorage.setItem('dwr_density',e.target.value); document.documentElement.style.setProperty('--cols', e.target.value); });

// ── jobs ──
async function poll() {
  const st = await (await fetch('api/status')).json();
  const busy = st.building;
  $('#btnBuild').disabled = busy; $('#btnRefresh').disabled = busy;
  if (busy) { $('#status').textContent = '⏳ working… ' + (st.log.split('\n').filter(Boolean).slice(-1)[0]||''); setTimeout(poll, 1500); }
  else if ($('#status').dataset.wasBusy==='1') { $('#status').dataset.wasBusy=''; $('#status').textContent='✓ done'; loadReels(); loadNA(); }
}
async function job(path, label) {
  $('#status').textContent = label; $('#status').dataset.wasBusy='1';
  await fetch(path,{method:'POST'}); poll();
}
$('#btnRefresh').addEventListener('click', ()=>job('api/refresh','↻ refreshing feed…'));
$('#btnBuild').addEventListener('click', ()=>job('api/build','▶ generating reel (≈30s)…'));

// read-only public deploy hides the generation + publish controls
(async () => {
  try {
    const cfg = await (await fetch('api/config')).json();
    VAUTO = !!cfg.videoAutorun; CANBUILD = !!cfg.allowBuild; loadJobs();
    if (!cfg.allowBuild) {
      ['#btnBuild','#btnRefresh','#btnPublish','#btnStudioReel','#btnStudioUrl','#btnStudioEml','#btnSuggest']
        .forEach(s => { const el = $(s); if (el) el.style.display = 'none'; });
      $('#status').textContent = 'Reels are rendered + published on the studio host.';
      loadSuggestions();   // re-render so “Use it” buttons show disabled on the read-only deploy
    }
  } catch {}
})();

loadAccounts().then(loadReels); loadNA(); loadVideos(); loadJobs(); loadSuggestions();
</script>
</body>
</html>