← back to Rentv 2026

public/admin/pr-intelligence/content-studio.html

342 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>CRE PR Intelligence — Content Studio</title>
<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
<style>
  .cs-wrap{display:grid;grid-template-columns:380px 1fr;gap:20px;align-items:start}
  @media(max-width:960px){.cs-wrap{grid-template-columns:1fr}}
  .cs-card{border:1px solid var(--line,#e4e7ea);border-radius:10px;background:var(--card,#fff);padding:16px 18px;margin:0 0 16px}
  .cs-card h3{margin:0 0 4px;font-size:14px} .cs-card .sub{font-size:12px;color:var(--muted,#5b636b);margin:0 0 10px}
  .cs-list{max-height:600px;overflow:auto;margin:0 -6px}
  .cs-art{display:flex;gap:10px;padding:8px;border-radius:8px;cursor:pointer;border:1px solid transparent}
  .cs-art:hover{background:var(--wash,#f5f6f8)} .cs-art.on{background:#eef5ff;border-color:#b9d4ff}
  .cs-art img{width:60px;height:44px;object-fit:cover;border-radius:5px;background:#e6ebf0;flex:0 0 auto}
  .cs-art .t{font-size:12.5px;line-height:1.35;font-weight:600}
  .cs-art .m{font-size:11px;color:var(--muted,#5b636b);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap}
  .cs-art .m .cat{color:#0a66c2;font-weight:600}
  .cs-src{font-size:12.5px;line-height:1.55;max-height:170px;overflow:auto;color:var(--ink,#222);white-space:pre-wrap;background:var(--wash,#f7f8fa);border-radius:8px;padding:10px 12px}
  .cs-field{margin:0 0 12px} .cs-field label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted,#5b636b);margin:0 0 4px}
  .cs-field input,.cs-field select,.cs-field textarea{width:100%;box-sizing:border-box;font:inherit;font-size:13px;padding:8px 10px;border:1px solid var(--line,#d7dde3);border-radius:8px;background:#fff}
  .cs-field textarea{resize:vertical} .cs-body{min-height:200px} .cs-social{min-height:96px}
  .cs-row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
  .cs-meta{font-size:12px;color:var(--muted,#5b636b)} .cs-ok{color:#1c7a3e;font-weight:600}
  .cs-cost{display:inline-block;background:#e7f6ec;color:#1c7a3e;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700}
  .cc{font-size:11px;color:var(--muted,#5b636b);margin-top:4px}
  .hl{margin:0;padding:0;list-style:none} .hl li{position:relative;padding:6px 0 6px 20px;font-size:13px;line-height:1.45;border-bottom:1px solid var(--line,#eee)}
  .hl li:last-child{border-bottom:0} .hl li::before{content:"▸";position:absolute;left:2px;color:#0a66c2}
  .cs-copy{font-size:11px;font-weight:600;color:#0a66c2;text-decoration:none;margin-left:8px;cursor:pointer}
  .cs-copy:hover{text-decoration:underline}
  .spin{display:inline-block;width:13px;height:13px;border:2px solid #c9d3dd;border-top-color:#0a6cff;border-radius:50%;animation:sp .7s linear infinite;vertical-align:-2px}
  @keyframes sp{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<script src="/admin/pr-intelligence/pr-shared.js"></script>
<script>
(async function () {
  const c = PR.frame('content-studio', 'Content Studio',
    'A deep, date-ordered feed of every RENTV story — pick one, pull the highlights, and spin it into an original blog entry + social posts, drafted by the local model ($0). Blog publishes live to /blog on your click; social saves as drafts.');

  c.insertAdjacentHTML('beforeend', '<div class="cs-wrap"><div id="left"></div><div id="right"></div></div>');
  const left = document.getElementById('left'), right = document.getElementById('right');

  // ── date helpers (admin rule: show date + time; full ISO in title) ──────────
  function dayLabel(a) {
    if (a.date) return new Date(a.date + 'T12:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
    if (a.first_seen) return new Date(a.first_seen).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
    return '—';
  }
  function seenLabel(a) { return a.first_seen ? 'seen ' + new Date(a.first_seen).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''; }

  // ── Left: deep news feed (date-ordered) ─────────────────────────────────────
  left.innerHTML = `<div class="cs-card">
    <h3>News feed <span class="cs-meta" id="ncount"></span></h3>
    <div class="sub" id="nsub">Newest first. Deepens as the archive back-fills.</div>
    <input id="q" placeholder="Filter headlines…" style="width:100%;box-sizing:border-box;font:inherit;font-size:13px;padding:7px 10px;border:1px solid var(--line,#d7dde3);border-radius:8px;margin-bottom:6px">
    <select id="catf" style="width:100%;box-sizing:border-box;font:inherit;font-size:12.5px;padding:6px 9px;border:1px solid var(--line,#d7dde3);border-radius:8px;margin:0 0 6px"><option value="">All categories</option></select>
    <label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted,#5b636b);margin:0 0 8px;cursor:pointer"><input type="checkbox" id="showdups"> Show all (incl. cross-source duplicates)</label>
    <div class="cs-list" id="list"></div>
  </div>
  <div class="cs-card" id="srcCard" style="display:none">
    <h3>Source</h3><div class="sub" id="srcMeta"></div><div class="cs-src" id="src">Loading…</div>
  </div>`;

  let articles = [], selected = null;
  async function loadFeed(dedup = true) {
    // Prefer the deep archive; fall back to the live 30-item feed if the archive is empty.
    try {
      const j = await PR.api('/../news/archive?limit=800&dedup=' + (dedup ? '1' : '0'));
      if (j.items && j.items.length) {
        articles = j.items;
        const collapsed = (j.raw_count || 0) - (j.count || 0);
        document.getElementById('nsub').textContent = dedup && collapsed > 0
          ? `Deep archive · ${collapsed} cross-source duplicate${collapsed > 1 ? 's' : ''} collapsed.`
          : 'Deep archive · newest first.';
        return;
      }
    } catch {}
    const n = await fetch('/api/news').then(r => r.json()).catch(() => ({ items: [] }));
    articles = (n.items || n || []);
    document.getElementById('nsub').textContent = 'Live feed (archive still seeding…).';
  }
  await loadFeed(true);
  document.getElementById('ncount').textContent = '· ' + articles.length;
  const listEl = document.getElementById('list'), qEl = document.getElementById('q');
  function paintList() {
    const q = qEl.value.trim().toLowerCase();
    const cf = document.getElementById('catf').value;
    const rows = articles.filter(a => (!q || (a.title || '').toLowerCase().includes(q)) && (!cf || (a.cat || 'RENTV') === cf));
    listEl.innerHTML = rows.length ? rows.map(a => {
      const img = a.image ? (a.image.startsWith('http') ? a.image : location.origin + a.image) : '';
      return `<div class="cs-art${selected && String(selected.id) === String(a.id) ? ' on' : ''}" data-id="${PR.esc(a.id)}" title="${PR.esc(a.first_seen || a.date || '')}">
        ${img ? `<img src="${PR.esc(img)}" onerror="this.style.visibility='hidden'">` : '<div style="width:60px"></div>'}
        <div><div class="t">${PR.esc(a.title || '')}</div>
          <div class="m"><span class="cat">${PR.esc(a.cat || 'RENTV')}</span><span>📰 ${PR.esc(a.source || 'RENTV')}</span><span>🕓 ${PR.esc(dayLabel(a))}</span>${a.related_count ? `<span style="color:#0a66c2;font-weight:600" title="Also covered by: ${PR.esc((a.related||[]).map(r=>r.source).join(', '))}">+${a.related_count} more source${a.related_count>1?'s':''}</span>` : ''}</div></div></div>`;
    }).join('') : '<div class="empty">No matches</div>';
    listEl.querySelectorAll('[data-id]').forEach(el => el.addEventListener('click', () => selectArticle(el.dataset.id)));
  }
  const catf = document.getElementById('catf');
  ['Sales', 'Leases', 'Financing', 'Development', 'Multifamily', 'Office', 'Retail', 'Industrial', 'People', 'Markets'].forEach(x => { const o = document.createElement('option'); o.value = x; o.textContent = x; catf.appendChild(o); });
  catf.addEventListener('change', paintList);
  qEl.addEventListener('input', paintList); paintList();
  document.getElementById('showdups').addEventListener('change', async (e) => {
    await loadFeed(!e.target.checked); // checked = show raw (dedup off)
    document.getElementById('ncount').textContent = '· ' + articles.length;
    selected = null; paintList();
    // reset the generate button so it's not a stale "Regenerate" with nothing selected
    const g = document.getElementById('gen'); if (g) { g.disabled = true; g.textContent = '✨ Pull highlights + draft'; }
  });

  // ── Right: source + highlights + generated content ──────────────────────────
  right.innerHTML = `
    <div class="cs-card">
      <div class="cs-row" style="justify-content:space-between">
        <div><h3 style="margin:0">Generate</h3><div class="sub" style="margin:0">Local model · $0. Pulls highlights + drafts an original rewrite.</div></div>
        <div class="cs-row">
          <select id="tone" title="Writing tone / angle" style="font:inherit;font-size:12.5px;padding:7px 9px;border:1px solid var(--line,#d7dde3);border-radius:8px;background:#fff">
            <option value="news">📰 Straight news</option>
            <option value="analysis">🧭 Analysis</option>
            <option value="punchy">⚡ Punchy</option>
          </select>
          <button class="btn" id="gen" disabled>✨ Pull highlights + draft</button>
        </div>
      </div>
      <div class="cc" id="genmeta"></div>
    </div>
    <div class="cs-card" id="hlCard" style="display:none">
      <h3>Highlights</h3><div class="sub">Key facts pulled from the story — your content seeds.</div>
      <ul class="hl" id="hl"></ul>
    </div>
    <div class="cs-card" id="blogCard" style="display:none">
      <h3>Blog entry</h3><div class="sub" id="blognote">Publishing writes it live to <code>/blog</code> immediately.</div>
      <div class="cs-field"><label>Headline</label><input id="b_title"></div>
      <div class="cs-row">
        <div class="cs-field" style="flex:1;min-width:180px"><label>Category</label>
          <select id="b_cat">${['Sales','Leases','Financing','Development','Multifamily','Office','Retail','Industrial','People','Markets','RENTV'].map(x=>`<option>${x}</option>`).join('')}</select></div>
        <div class="cs-field" style="flex:2;min-width:220px"><label>Author</label><input id="b_author" value="RENTV Staff"></div>
      </div>
      <div class="cs-field"><label>Standfirst (dek)</label><input id="b_dek"></div>
      <div class="cs-field"><label>Image URL <a class="cs-copy" id="findimg" title="News-site photos aren't allowed — pull the building photo from the credited broker/developer's own site">🏢 find building photo (credited)</a></label><input id="b_image"></div>
      <div class="cc" id="imgmeta"></div>
      <div id="imgcands" style="display:flex;flex-wrap:wrap;gap:8px;margin:0 0 10px"></div>
      <div class="cs-field"><label>Photo credit (the firm the image came from)</label><input id="b_credit" placeholder="e.g. CBRE"></div>
      <div class="cs-field"><label>Video link(s) — included in the email to Boom (comma/newline separated)</label><input id="b_videos" placeholder="https://… reel / teaser / video URLs"></div>
      <div class="cs-field"><label>Body <a class="cs-copy" data-copy="b_body">📋 copy</a></label><textarea id="b_body" class="cs-body"></textarea></div>
      <div class="cs-row">
        <button class="btn" id="publish">🚀 Publish to blog</button>
        <button class="btn" id="emailboom">📧 Draft to Boom (steve-office)</button>
        <span class="cs-meta" id="pubmeta"></span>
      </div>
      <div class="cs-row" style="margin-top:8px;border-top:1px dashed var(--line,#e4e7ea);padding-top:10px">
        <button class="btn" id="boomervoice" title="Narrate this post in Boomer's cloned ElevenLabs voice (audio mp3)">🔊 Boomer voice</button>
        <button class="btn" id="boomervideo" title="Boomer voice over his photo, Ken-Burns vertical mp4">🎬 Boomer video</button>
        <span class="cs-meta">Cloned voice · ElevenLabs (paid) — cost shown per clip</span>
      </div>
      <div id="boomermedia" class="cs-meta" style="margin-top:8px"></div>
    </div>
    <div class="cs-card" id="socialCard" style="display:none">
      <h3>Social</h3><div class="sub">Saves to social drafts (feeds the LinkedIn Posting composer). Nothing auto-posts.</div>
      <div class="cs-field"><label>LinkedIn <a class="cs-copy" data-copy="s_li">📋 copy</a></label><textarea id="s_li" class="cs-social"></textarea>
        <div class="cs-row" style="margin-top:6px"><button class="btn sm" id="save_li">💾 Save LinkedIn draft</button><span class="cs-meta" id="li_meta"></span></div></div>
      <div class="cs-field"><label>X / Twitter <a class="cs-copy" data-copy="s_x">📋 copy</a></label><textarea id="s_x" class="cs-social"></textarea>
        <div class="cs-row" style="margin-top:6px"><button class="btn sm" id="save_x">💾 Save X draft</button><span class="cs-meta" id="x_meta"></span></div></div>
    </div>`;

  const gen = document.getElementById('gen');
  const $ = id => document.getElementById(id);

  async function selectArticle(id) {
    selected = articles.find(a => String(a.id) === String(id)); if (!selected) return;
    paintList();
    document.getElementById('srcCard').style.display = '';
    const src = selected.source || 'RENTV';
    document.getElementById('srcMeta').textContent = src + ' · ' + (selected.cat || 'RENTV') + ' · ' + dayLabel(selected);
    const srcEl = document.getElementById('src');
    gen.disabled = false; gen.textContent = '✨ Pull highlights + draft';
    $('b_image').value = selected.image ? (selected.image.startsWith('http') ? selected.image : location.origin + selected.image) : '';
    $('b_credit').value = ''; $('b_videos').value = ''; $('imgcands').innerHTML = ''; $('imgmeta').innerHTML = '';
    // RENTV stories (numeric id) get a full-body fetch; external sources use their RSS summary.
    const isRentv = /^\d+$/.test(String(selected.id)) && src === 'RENTV';
    if (isRentv) {
      srcEl.textContent = 'Loading full article…';
      try {
        const art = await fetch('/api/article/' + encodeURIComponent(String(id).replace(/[^0-9]/g,''))).then(r => r.json());
        const body = Array.isArray(art.body) ? art.body.join('\n\n') : String(art.body || '');
        srcEl.textContent = body || '(no body text extracted — generation will work from the headline)';
        selected._body = body;
      } catch { srcEl.textContent = '(could not load full article — generation will use the headline)'; selected._body = ''; }
    } else {
      selected._body = selected.summary || '';
      srcEl.innerHTML = (selected.summary ? PR.esc(selected.summary) : '(no summary — generation will work from the headline)')
        + (selected.url ? `<br><br><a href="${PR.esc(selected.url)}" target="_blank" rel="noopener noreferrer">View original at ${PR.esc(src)} ↗</a>` : '');
    }
  }

  gen.addEventListener('click', () => PR.guard(async () => {
    if (!selected) return;
    gen.disabled = true; $('genmeta').innerHTML = '<span class="spin"></span> Drafting with the local model… (~20–40s)';
    try {
      const j = await PR.api('/../content/generate', { method: 'POST', body: { article_id: selected.id, title: selected.title, body: selected._body || '', cat: selected.cat || '', source: selected.source || '', tone: document.getElementById('tone').value } });
      gen.textContent = '🔄 Regenerate';
      $('genmeta').innerHTML = `<span class="cs-cost">${PR.esc(j.cost || '$0 (local)')}</span> · ${PR.esc(j.model || '')} · ${(j.ms/1000).toFixed(1)}s`;
      const hls = j.highlights || [];
      $('hl').innerHTML = hls.length ? hls.map(h => `<li>${PR.esc(h)}</li>`).join('') : '<li>(none extracted)</li>';
      document.getElementById('hlCard').style.display = '';
      const bl = j.blog || {};
      $('b_title').value = bl.title || selected.title || '';
      $('b_cat').value = bl.cat || selected.cat || 'RENTV';
      $('b_dek').value = bl.dek || '';
      $('b_body').value = bl.body || '';
      $('s_li').value = j.linkedin || ''; $('s_x').value = j.x || '';
      document.getElementById('blogCard').style.display = '';
      document.getElementById('socialCard').style.display = '';
      // Copyright policy (Steve, Option 1): only RENTV-sourced stories publish LIVE; outside wires
      // save as a DRAFT for review (rewriting another outlet's story and publishing it live is the exposure).
      const isRentv = (selected.source || 'RENTV') === 'RENTV';
      $('publish').textContent = isRentv ? '🚀 Publish to blog' : '💾 Save as draft';
      $('blognote').innerHTML = isRentv
        ? 'Publishing writes it live to <code>/blog</code> immediately.'
        : '📝 Outside-wire story → saves as a <b>draft</b> for review (not published live). Only RENTV-sourced stories publish directly.';
    } finally { gen.disabled = false; }
  }));

  // ── Publish blog (live) ────────────────────────────────────────────────────
  $('publish').addEventListener('click', () => PR.guard(async () => {
    const title = $('b_title').value.trim();
    if (!title) { PR.toast('Headline required', true); return; }
    const isRentv = !!(selected && (selected.source || 'RENTV') === 'RENTV');
    const status = isRentv ? 'published' : 'draft';
    if (!confirm(isRentv ? 'Publish this entry LIVE to /blog now?' : 'Save this outside-wire story as a DRAFT (not published live)?')) return;
    $('publish').disabled = true;
    try {
      const r = await PR.api('/../posts', { method: 'POST', body: {
        title, cat: $('b_cat').value, author: $('b_author').value.trim() || 'RENTV Staff',
        dek: $('b_dek').value.trim(), image: $('b_image').value.trim(), body: $('b_body').value, status,
        photo_credit: $('b_credit').value.trim(), source: selected ? (selected.source || 'RENTV') : 'RENTV',
      } });
      const url = '/post/' + r.post.id;
      $('pubmeta').innerHTML = isRentv
        ? `<span class="cs-ok">✅ Published</span> — <a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
        : `<span class="cs-ok">✅ Saved as draft</span> — review + publish it from the blog admin when ready.`;
      PR.toast(isRentv ? 'Published to blog' : 'Saved as draft');
    } finally { $('publish').disabled = false; }
  }, null));

  // ── Save social drafts ─────────────────────────────────────────────────────
  function saveDraft(platform, textId, metaId) {
    return PR.guard(async () => {
      const text = $(textId).value.trim();
      if (!text) { PR.toast('Nothing to save', true); return; }
      const d = await PR.api('/../social/draft', { method: 'POST', body: { platform, text, topic: ($('b_title').value || selected?.title || '').slice(0,120) } });
      $(metaId).innerHTML = `<span class="cs-ok">✅ Saved</span> · ${PR.esc(d.draft.id)}`;
    }, null);
  }
  $('save_li').addEventListener('click', () => saveDraft('LinkedIn', 's_li', 'li_meta'));
  $('save_x').addEventListener('click', () => saveDraft('X', 's_x', 'x_meta'));

  // ── Track down the building photo from the CREDITED broker/developer (not the news site) ──
  $('findimg').addEventListener('click', () => PR.guard(async () => {
    if (!selected) { PR.toast('Pick an article first', true); return; }
    $('imgmeta').innerHTML = '<span class="spin"></span> Naming the deal parties + searching their sites…';
    $('imgcands').innerHTML = '';
    const j = await PR.api('/../content/find-image', { method: 'POST', body: { article_id: selected.id, title: selected.title, body: selected._body || '', source: selected.source || '' } });
    $('imgmeta').innerHTML = `<span class="cs-cost">${PR.esc(j.cost || '')}</span> · parties: ${PR.esc((j.entities || []).join(', ') || '—')} · ${(j.candidates || []).length} photo${j.candidates && j.candidates.length !== 1 ? 's' : ''} found`;
    const cands = (j.candidates || []).map((c, i) => `<a href="#" data-cand="${i}" title="Credit: ${PR.esc(c.credit)}\n${PR.esc(c.source_page)}" style="display:block;width:120px"><img src="${PR.esc(c.image_url)}" style="width:120px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--line,#ddd)" onerror="this.closest('a').style.display='none'"><span style="font-size:10px;color:var(--muted,#5b636b);display:block">📷 ${PR.esc(c.credit)}</span></a>`).join('');
    const links = (j.pages || []).map(p => `<a href="${PR.esc(p.url)}" target="_blank" rel="noopener noreferrer" class="cs-srclink" data-credit="${PR.esc(p.credit)}" style="display:block;font-size:12px;margin:2px 0">↗ ${PR.esc(p.title || p.url.slice(0, 60))} <span style="color:var(--muted,#5b636b)">— credit ${PR.esc(p.credit)}</span></a>`).join('');
    $('imgcands').innerHTML =
      (cands ? `<div style="display:flex;gap:8px;flex-wrap:wrap;width:100%">${cands}</div>` : '')
      + (links ? `<div style="width:100%;margin-top:8px"><div class="cs-meta" style="margin-bottom:4px">Open a credited source page, grab the building photo, paste its URL above (many CRE sites block auto-pull). Clicking a link pre-fills the credit:</div>${links}</div>` : '')
      + (!cands && !links ? '<span class="cs-meta">No credited source found — paste an image URL from the broker/developer site and set the credit manually.</span>' : '');
    $('imgcands').querySelectorAll('[data-cand]').forEach(a => a.addEventListener('click', (e) => {
      e.preventDefault(); const c = j.candidates[+a.getAttribute('data-cand')];
      $('b_image').value = c.image_url; $('b_credit').value = c.credit; PR.toast('Photo + credit set (' + c.credit + ')');
    }));
    $('imgcands').querySelectorAll('.cs-srclink').forEach(a => a.addEventListener('click', () => { if (!$('b_credit').value.trim()) $('b_credit').value = a.getAttribute('data-credit'); }));
  }, null));

  // ── Email the full assembled post to Boom ──────────────────────────────────
  $('emailboom').addEventListener('click', () => PR.guard(async () => {
    if (!$('b_title').value.trim()) { PR.toast('Generate a post first', true); return; }
    if (!confirm('Create a Gmail draft to Boom (sbloom@rentv.com) in steve-office? You review + send it from Gmail.')) return;
    $('emailboom').disabled = true;
    try {
      const r = await PR.api('/../content/email', { method: 'POST', body: {
        title: $('b_title').value.trim(), dek: $('b_dek').value.trim(), image: $('b_image').value.trim(),
        photo_credit: $('b_credit').value.trim(), videos: $('b_videos').value, body: $('b_body').value,
        linkedin: $('s_li').value, x: $('s_x').value,
      } });
      $('pubmeta').innerHTML = `<span class="cs-ok">✅ Draft created in steve-office → ${PR.esc(r.to)}</span> — review + send from Gmail.`;
      PR.toast('Draft created in steve-office');
    } finally { $('emailboom').disabled = false; }
  }, null));

  // ── Boomer video / voice from the generated post (cloned ElevenLabs voice) ──
  //    Reuses the async social-video job infra; polls until the mp3/mp4 is ready.
  function boomerGen(mode) {
    return PR.guard(async () => {
      if (!$('b_title').value.trim()) { PR.toast('Generate a post first', true); return; }
      const label = mode === 'video' ? 'video' : 'voice';
      if (!confirm(`Generate the Boomer ${label} in his cloned voice? This makes a paid ElevenLabs TTS call (~$0.20–0.35 for this clip; cost shown after).`)) return;
      const btn = $(mode === 'video' ? 'boomervideo' : 'boomervoice'); btn.disabled = true;
      $('boomermedia').innerHTML = `<span class="spin"></span> Queuing Boomer ${label}…`;
      try {
        const hl = [...document.querySelectorAll('#hl li')].map(li => li.textContent.trim()).filter(Boolean);
        const j = await PR.api('/../content/boomer', { method: 'POST', body: { mode, title: $('b_title').value.trim(), body: $('b_body').value, highlights: hl } });
        $('boomermedia').innerHTML = `<span class="cs-cost">${PR.esc(j.estCost || '')}</span> <span class="spin"></span> rendering ${label}… (${j.chars} chars)`;
        const started = Date.now();
        const tick = async () => {
          let s; try { s = await PR.api('/../social/video/' + encodeURIComponent(j.jobId)); } catch { return setTimeout(tick, 2500); }
          if (s.status === 'done') {
            if (s.audioUrl) $('boomermedia').innerHTML = `<div class="cs-ok">✅ Boomer voice ready</div><audio controls src="${PR.esc(s.audioUrl)}" style="width:100%;margin-top:6px"></audio><div class="cs-meta" style="margin-top:4px"><a href="${PR.esc(s.audioUrl)}" download>⬇ download mp3</a> · <span class="cs-cost">${PR.esc(j.estCost || '')}</span></div>`;
            else $('boomermedia').innerHTML = `<div class="cs-ok">✅ Boomer video ready</div><video controls src="${PR.esc(s.videoUrl)}" style="width:220px;margin-top:6px;border-radius:8px"></video><div class="cs-meta" style="margin-top:4px"><a href="${PR.esc(s.videoUrl)}" download>⬇ download mp4</a> · <span class="cs-cost">${PR.esc(j.estCost || '')}</span></div>`;
            btn.disabled = false; return;
          }
          if (s.status === 'error') { $('boomermedia').innerHTML = `<span style="color:#b23a1e">⚠ ${PR.esc(s.error || 'render failed')}</span>`; btn.disabled = false; return; }
          if (Date.now() - started > 180000) { $('boomermedia').innerHTML = '<span style="color:#b23a1e">⚠ Timed out — check the /social video list.</span>'; btn.disabled = false; return; }
          $('boomermedia').firstChild && ($('boomermedia').querySelector('.cs-status') || 0); // keep spinner
          setTimeout(tick, 2500);
        };
        setTimeout(tick, 2500);
      } catch (e) { $('boomermedia').innerHTML = `<span style="color:#b23a1e">⚠ ${PR.esc(e.message)}</span>`; btn.disabled = false; }
    }, null);
  }
  $('boomervoice').addEventListener('click', () => boomerGen('voice'));
  $('boomervideo').addEventListener('click', () => boomerGen('video'));

  // Copy-to-clipboard on blog body + social drafts (the hand-off step).
  document.addEventListener('click', (e) => {
    const a = e.target.closest('.cs-copy'); if (!a) return;
    e.preventDefault();
    const el = document.getElementById(a.getAttribute('data-copy')); if (!el) return;
    if (!navigator.clipboard) { PR.toast('copy needs HTTPS', true); return; } // undefined on insecure origins
    navigator.clipboard.writeText(el.value || '').then(() => {
      const o = a.textContent; a.textContent = '✓ copied'; setTimeout(() => { a.textContent = o; }, 1400);
    }).catch(() => PR.toast('copy failed', true));
  });
})();
</script>
</body>
</html>