← 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>