← back to Marketing Command Center
public/panels/composer.js
440 lines
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['composer'] = {
async init(root) {
const O = location.origin;
const $ = s => root.querySelector(s);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const jget = async u => { try { const r = await fetch(O + u, { credentials: 'same-origin' }); return r.ok ? await r.json() : null; } catch { return null; } };
const jpost = async (u, b) => { try { const r = await fetch(O + u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) }); return await r.json().catch(() => ({})); } catch (e) { return { error: e.message }; } };
const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set(), handles: new Set(), format: 'post', song: null };
const IG_LS_KEY = 'mccComposerIgHandles'; // persist the account selection across reloads
const NEW_ARRIVALS = 'designerwallcoverings.com/collections/new-arrivals'; // bare → clickable via facets
// ── RIGHT rail: publish targets, connection-aware ──────────────────────────
async function loadTargets() {
const [t, s] = await Promise.all([jget('/api/composer/targets'), jget('/api/channels/status')]);
const targets = (t && t.targets) || [];
const st = (s && (s.platforms || s)) || {};
const wrap = $('#cmp-targets'); wrap.innerHTML = '';
state.connected = [];
targets.forEach(tg => {
const on = !!(st[tg.id] && st[tg.id].connected);
if (on) state.connected.push(tg.id);
const el = document.createElement('label');
el.className = 'cmp-tgt' + (on ? '' : ' off');
el.innerHTML = `<input type="checkbox" ${on ? '' : 'disabled'} data-ch="${tg.id}">
<span class="ico">${tg.icon}</span><span class="nm">${esc(tg.label)}</span>
<span class="st ${on ? 'on' : 'off'}">${on ? 'connected' : 'not connected'}</span>`;
const cb = el.querySelector('input');
cb.addEventListener('change', () => {
cb.checked ? state.selected.add(tg.id) : state.selected.delete(tg.id);
if (tg.id === 'facebook') toggleFbPages();
});
wrap.appendChild(el);
});
toggleFbPages();
}
$('#cmp-all').addEventListener('click', () => {
root.querySelectorAll('#cmp-targets input[data-ch]:not([disabled])').forEach(cb => { cb.checked = true; state.selected.add(cb.dataset.ch); });
toggleFbPages();
});
// ── Facebook Page picker: shown only when Facebook is a selected target ──
function toggleFbPages() {
const on = state.selected.has('facebook');
$('#cmp-fbpages').style.display = on ? '' : 'none';
if (on && !state._pagesLoaded) loadPages();
}
async function loadPages(refresh) {
const box = $('#cmp-fbpages-list'); box.innerHTML = '<div class="loading">Loading…</div>';
const d = await jget('/api/channels/pages' + (refresh ? '?refresh=1' : ''));
const pages = (d && d.pages) || [];
state._pagesLoaded = true;
if (!pages.length) { box.innerHTML = `<div class="bd-empty" style="font-size:11.5px">${d && d.hasToken ? 'No Pages found — hit ↻ to fetch from Meta.' : 'Meta not connected — add a token in the Setup panel.'}</div>`; return; }
box.innerHTML = '';
pages.forEach(p => {
const row = document.createElement('label'); row.className = 'cmp-fbrow';
row.innerHTML = `<input type="checkbox" data-pid="${esc(p.id)}"><span class="nm">${esc(p.name || p.id)}</span>${p.hasIG ? `<span class="ig">@${esc(p.igUsername || 'ig')}</span>` : ''}`;
const cb = row.querySelector('input');
cb.addEventListener('change', () => { cb.checked ? state.pages.add(p.id) : state.pages.delete(p.id); });
box.appendChild(row);
});
}
$('#cmp-fball').addEventListener('click', () => {
root.querySelectorAll('#cmp-fbpages-list input[data-pid]').forEach(cb => { cb.checked = true; state.pages.add(cb.dataset.pid); });
});
$('#cmp-fbrefresh').addEventListener('click', () => { state.pages.clear(); loadPages(true); });
$('#cmp-fbsearch').addEventListener('input', e => {
const q = e.target.value.toLowerCase();
root.querySelectorAll('#cmp-fbpages-list .cmp-fbrow').forEach(r => {
r.style.display = (r.querySelector('.nm').textContent || '').toLowerCase().includes(q) ? '' : 'none';
});
});
// ── DW-owned IG account targets — canonical 35 from window.MCC_ACCOUNTS ─────
async function loadIgAccounts() {
const box = $('#cmp-igaccounts-list'); if (!box) return;
box.innerHTML = '<div class="loading">Loading…</div>';
let accts = [];
try { accts = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || []; }
catch { accts = []; }
if (!accts.length) { box.innerHTML = '<div class="bd-empty" style="font-size:11.5px">No DW accounts available.</div>'; return; }
// restore any previously-chosen handles (persisted across reloads)
let saved = [];
try { saved = JSON.parse(localStorage.getItem(IG_LS_KEY) || '[]'); } catch { saved = []; }
const savedSet = new Set(saved);
state.handles.clear();
box.innerHTML = '';
accts.forEach(a => { // already sorted by the helper
const h = a.handle || '';
if (!h) return;
const row = document.createElement('label'); row.className = 'cmp-igrow';
const checked = savedSet.has(h);
if (checked) state.handles.add(h);
row.innerHTML = `<input type="checkbox" data-handle="${esc(h)}"${checked ? ' checked' : ''}>` +
`<span class="hd">@${esc(h)}</span>` + (a.name ? `<span class="nm">${esc(a.name)}</span>` : '');
const cb = row.querySelector('input');
cb.addEventListener('change', () => { cb.checked ? state.handles.add(h) : state.handles.delete(h); persistHandles(); });
box.appendChild(row);
});
persistHandles();
}
function persistHandles() {
try { localStorage.setItem(IG_LS_KEY, JSON.stringify([...state.handles])); } catch {}
}
$('#cmp-igall').addEventListener('click', () => {
root.querySelectorAll('#cmp-igaccounts-list input[data-handle]').forEach(cb => { cb.checked = true; state.handles.add(cb.dataset.handle); });
persistHandles();
});
$('#cmp-ignone').addEventListener('click', () => {
root.querySelectorAll('#cmp-igaccounts-list input[data-handle]').forEach(cb => { cb.checked = false; });
state.handles.clear(); persistHandles();
});
$('#cmp-igsearch').addEventListener('input', e => {
const q = e.target.value.toLowerCase();
root.querySelectorAll('#cmp-igaccounts-list .cmp-igrow').forEach(r => {
const t = ((r.querySelector('.hd') || {}).textContent + ' ' + ((r.querySelector('.nm') || {}).textContent || '')).toLowerCase();
r.style.display = t.includes(q) ? '' : 'none';
});
});
// ── LEFT rail: assets + banners ────────────────────────────────────────────
async function loadSource(kind) {
const grid = $('#cmp-srcgrid'); grid.innerHTML = '<div class="loading">Loading…</div>';
grid.classList.toggle('reels', kind === 'reels');
if (kind === 'assets') {
const d = await jget('/api/assets/list');
const items = (d && d.assets) || [];
if (!items.length) { grid.innerHTML = '<div class="bd-empty" style="grid-column:1/-1">No assets yet — add some in the Assets panel.</div>'; return; }
grid.innerHTML = '';
items.forEach(a => {
const url = a.src || a.url || a.image || a.thumb || '';
if (!url) return;
const d1 = document.createElement('div');
d1.className = 'cmp-thumb'; d1.dataset.url = url;
d1.innerHTML = `<img loading="lazy" src="${esc(url)}"><span class="lbl">${esc(a.name || a.title || '')}</span>`;
// A dead/placeholder asset URL (e.g. a junk test.jpg) 404s and would show a
// broken tile + spam the console. Drop any thumbnail whose image can't load.
d1.querySelector('img').addEventListener('error', () => d1.remove());
d1.addEventListener('click', () => select(d1, url));
grid.appendChild(d1);
});
} else if (kind === 'reels') {
// Reel / Story assets — the generated portrait videos. Selecting one sets the
// post's videoUrl (a public .mp4 the platforms fetch), not a still image.
const d = await jget('/api/composer/reels');
const items = (d && d.reels) || [];
if (!items.length) { grid.innerHTML = '<div class="bd-empty" style="grid-column:1/-1">No reels found yet — generate some in the Reels app.</div>'; return; }
grid.innerHTML = '';
items.forEach(r => {
const el = document.createElement('div');
el.className = 'cmp-reel'; el.dataset.url = r.url;
// #t=0.1 nudges the <video> to paint a real first-frame poster (not a black tile)
el.innerHTML = `<video src="${esc(r.url)}#t=0.1" muted preload="metadata"></video><span class="play">▶</span><span class="lbl">${esc(r.title || r.file)}</span>`;
el.addEventListener('click', () => selectReel(el, r));
grid.appendChild(el);
});
} else {
// Banners: click a template → render it LIVE (real HTML from /api/layouts/render)
// into the middle preview, using the current caption as the headline and the
// selected asset (if any) as the banner's product photo. This is a genuine
// designed preview. Posting a banner AS AN IMAGE (HTML→PNG) is the next step,
// drafted for Steve — see pending-approval/mcc-composer-banner-image.md.
const d = await jget('/api/layouts/templates');
const items = (d && d.templates) || [];
grid.innerHTML = `<div class="bd-empty" style="grid-column:1/-1;font-size:11.5px">Click a template to preview it here → the caption becomes the headline, a selected asset becomes the photo. <b>Preview only</b> for now; one-click "post as image" is coming.</div>`;
items.forEach(t => {
const b = document.createElement('div');
b.className = 'cmp-tgt'; b.style.gridColumn = '1/-1'; b.style.cursor = 'pointer';
b.innerHTML = `<span class="ico">🎏</span><span class="nm">${esc(t.name || t.id)}</span><span class="st on" style="text-transform:none">${esc(t.aspect || t.kind || '')}</span>`;
b.addEventListener('click', () => renderBanner(t.id, b, t.aspect));
grid.appendChild(b);
});
}
}
function select(el, url) {
root.querySelectorAll('.cmp-thumb.sel, .cmp-reel.sel').forEach(x => x.classList.remove('sel'));
el.classList.add('sel');
state.mediaUrl = url; state.videoUrl = ''; // image chosen → clear any reel
const img = $('#cmp-img'); img.src = url; img.style.display = 'block';
const v = $('#cmp-video'); if (v) { v.style.display = 'none'; v.removeAttribute('src'); }
const bf = $('#cmp-banner'); if (bf) bf.style.display = 'none'; // asset chosen → hide any banner preview
$('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
if (typeof updateMakeHint === 'function') updateMakeHint(); // photo chosen → refresh reel-or-what hint
}
// Pick a reel → the post carries its public video URL (videoUrl), not a still.
function selectReel(el, r) {
root.querySelectorAll('.cmp-reel.sel, .cmp-thumb.sel, .cmp-tgt.sel').forEach(x => x.classList.remove('sel'));
el.classList.add('sel');
state.videoUrl = r.url; state.mediaUrl = '';
const img = $('#cmp-img'); img.style.display = 'none';
const bf = $('#cmp-banner'); if (bf) bf.style.display = 'none';
const v = $('#cmp-video'); if (v) { v.src = r.url; v.style.display = 'block'; } // guard: mirror select()
$('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
// seed the reel's own caption when the box is empty (keeps media + copy aligned)
if (r.caption && !$('#cmp-caption').value.trim()) { $('#cmp-caption').value = r.caption; countIt(); }
if (state.format === 'post') setFormat('reel'); // a reel video ⇒ format is Reel
else updateMakeHint();
}
// Render a Layouts template LIVE into the preview via /api/layouts/render (returns HTML).
async function renderBanner(templateId, el, aspect) {
root.querySelectorAll('.cmp-tgt.sel').forEach(x => x.classList.remove('sel')); if (el) el.classList.add('sel');
// #3 — without a selected asset the banner renders a gradient placeholder where the
// product photo should be; tell the user so it doesn't read as broken.
const note = $('#cmp-bannernote');
if (note) note.textContent = state.mediaUrl ? '' : '⚠ No asset selected — pick one from the Assets tab to fill the banner’s photo slot.';
const caption = ($('#cmp-caption').value || '').trim();
const headline = caption.split('\n')[0].slice(0, 80) || 'Designer Wallcoverings';
const r = await jpost('/api/layouts/render', {
template: templateId, headline, subhead: 'New arrivals', cta: 'Shop the collection',
productName: 'Designer Wallcoverings', imageUrl: state.mediaUrl || '',
});
if (!r || !r.html) return;
const img = $('#cmp-img'); img.style.display = 'none';
$('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
let f = $('#cmp-banner');
if (!f) { f = document.createElement('iframe'); f.id = 'cmp-banner'; f.setAttribute('sandbox', 'allow-same-origin'); f.style.cssText = 'width:100%;border:0;background:#fff;border-radius:12px'; $('#cmp-preview').appendChild(f); }
// #2 — respect the template's real aspect (a 9:16 story ≠ a 4:1 banner in the preview).
f.style.aspectRatio = (aspect || '1/1').replace(' ', '');
f.style.display = 'block'; f.srcdoc = r.html;
}
root.querySelectorAll('.cmp-tab').forEach(tab => tab.addEventListener('click', () => {
root.querySelectorAll('.cmp-tab').forEach(t => t.classList.remove('on'));
tab.classList.add('on'); state.src = tab.dataset.src; loadSource(state.src);
}));
// ── MIDDLE: copy ───────────────────────────────────────────────────────────
const cap = $('#cmp-caption');
const countIt = () => { $('#cmp-count').textContent = cap.value.length; };
cap.addEventListener('input', countIt);
$('#cmp-gen').addEventListener('click', async () => {
$('#cmp-gen').disabled = true; $('#cmp-gen').textContent = '…generating';
const tone = $('#cmp-tone').value;
// kind MUST be a valid copy KIND id (instagram/tiktok/…); an unknown value like
// 'social' silently degrades server-side to a 'headline' — the wrong content type.
const r = await jpost('/api/copy/generate', { kind: 'instagram', tone, topic: `${tone} wallcovering social post for Designer Wallcoverings`, product: 'any' });
const v = (r && r.variants) || [];
let first = v.length ? (typeof v[0] === 'string' ? v[0] : (v[0].text || v[0].body || v[0].caption || '')) : '';
// HARD RULE: always link back to DW new arrivals (the facet detector makes the bare
// domain clickable). Append it if the copy doesn't already reference the site.
if (first && !/designerwallcoverings\.com/i.test(first)) first += `\n\nShop new arrivals → ${NEW_ARRIVALS}`;
if (first) { cap.value = first; countIt(); }
$('#cmp-gen').disabled = false; $('#cmp-gen').textContent = '✨ Generate copy';
});
// ── FORMAT + SONG: "decide if reel or what", and attach a track ────────────
// Photo → details (above) → song → format, all assembled here before publish.
function setFormat(fmt) {
state.format = fmt;
root.querySelectorAll('.cmp-fmt').forEach(b => b.classList.toggle('on', b.dataset.fmt === fmt));
updateMakeHint();
}
root.querySelectorAll('.cmp-fmt').forEach(b => b.addEventListener('click', () => setFormat(b.dataset.fmt)));
function updateMakeHint() {
const h = $('#cmp-make-hint'); if (!h) return;
// A still photo + a song = a reel that has to be RENDERED (photo over music →
// mp4). The composer assembles the recipe; Reel Studio produces the video.
if (state.song && (state.format === 'reel' || state.format === 'story') && state.mediaUrl && !state.videoUrl) {
h.innerHTML = `🎬 Photo + song → this renders to a video. ` +
`<button type="button" id="cmp-buildreel" class="btn gold" style="font-size:11.5px;padding:4px 11px;margin-left:2px">Build this reel →</button> ` +
`<span class="muted" id="cmp-buildreel-msg" style="font-size:11px"></span>`;
} else if (state.song && state.format === 'post') {
h.innerHTML = `🎵 A feed photo can’t carry audio — a song makes it a <b>Reel</b> or <b>Story</b>. Switched the format for you.`;
} else if (state.format === 'reel' && !state.song && !state.videoUrl) {
h.textContent = 'Tip: add a song for the reel, or pick a ready-made reel video on the left.';
} else { h.textContent = ''; }
}
function renderSong() {
const sel = $('#cmp-song-sel'), open = $('#cmp-song-open');
if (state.song) {
sel.style.display = 'flex';
sel.innerHTML = `<span>🎵</span><span class="nm" title="${esc(state.song.name)}">${esc(state.song.name)}</span>` +
`<button type="button" class="pv" title="Preview">▶</button><button type="button" class="x" title="Remove song">×</button>`;
sel.querySelector('.x').onclick = () => { state.song = null; renderSong(); updateMakeHint(); };
sel.querySelector('.pv').onclick = () => { const a = $('#cmp-song-audio'); a.src = state.song.url; a.style.display = 'block'; a.play().catch(() => {}); };
open.textContent = '🎵 Change song';
} else {
sel.style.display = 'none'; sel.innerHTML = '';
open.textContent = '🎵 Add a song';
}
}
let SONGS = null;
async function loadSongs() {
const list = $('#cmp-song-list'); if (!list) return;
if (!SONGS) {
const d = await jget('/api/sounds/catalog');
// flatten grouped sections → {name, url, sec}; keep only playable rows (a url)
const secs = (d && (d.sections || d.catalog || [])) || [];
SONGS = [];
secs.forEach(s => (s.items || []).forEach(it => { if (it && it.url) SONGS.push({ name: it.name || it.title || 'Track', url: it.url, sec: s.title || '' }); }));
}
if (!SONGS.length) { list.innerHTML = '<div class="bd-empty" style="font-size:11.5px">No tracks in the Sounds library yet.</div>'; return; }
list.innerHTML = '';
SONGS.forEach(t => {
const row = document.createElement('div'); row.className = 'cmp-song-row';
row.innerHTML = `<button type="button" class="pv" title="Preview">▶</button><span class="nm" title="${esc(t.name)}">${esc(t.name)}</span><span class="sec">${esc(t.sec)}</span>`;
row.querySelector('.pv').onclick = (e) => { e.stopPropagation(); const a = $('#cmp-song-audio'); a.src = t.url; a.style.display = 'block'; a.play().catch(() => {}); };
row.onclick = () => { // pick this song
state.song = { name: t.name, url: t.url };
if (state.format === 'post') setFormat('reel'); // a song ⇒ this is a reel, not a feed post
$('#cmp-song-pick').style.display = 'none';
renderSong(); updateMakeHint();
};
list.appendChild(row);
});
}
$('#cmp-song-open').addEventListener('click', () => {
const pick = $('#cmp-song-pick');
const showing = pick.style.display !== 'none';
pick.style.display = showing ? 'none' : 'block';
if (!showing) loadSongs();
});
$('#cmp-song-search').addEventListener('input', e => {
const q = e.target.value.toLowerCase();
root.querySelectorAll('#cmp-song-list .cmp-song-row').forEach(r => {
r.style.display = ((r.querySelector('.nm') || {}).textContent || '').toLowerCase().includes(q) ? '' : 'none';
});
});
// "Build this reel →" — hand the photo + song + caption to Reel Studio, which
// renders the still over the track to an mp4 (POST proxied to the reels app;
// the render only runs on the studio host, so on prod it reports that honestly).
// Delegated on #cmp-make-hint because updateMakeHint() rebuilds its innerHTML.
$('#cmp-make-hint').addEventListener('click', async (e) => {
const btn = e.target.closest('#cmp-buildreel'); if (!btn) return;
if (!state.mediaUrl || !state.song) return;
const msg = $('#cmp-buildreel-msg');
btn.disabled = true; btn.textContent = 'Building…';
const r = await jpost('/api/reels/ui/api/build-photo-reel', {
photo: state.mediaUrl, song: state.song.url, songName: state.song.name,
caption: cap.value.trim(), seconds: 15,
});
if (r && r.started) {
if (msg) msg.textContent = '✓ rendering in Reel Studio — opening…';
setTimeout(() => { location.hash = 'reels'; }, 800);
} else {
btn.disabled = false; btn.textContent = 'Build this reel →';
if (msg) msg.textContent = '⚠ ' + ((r && r.error) || 'could not start — reel rendering runs on the studio host');
}
});
// ── PUBLISH ────────────────────────────────────────────────────────────────
$('#cmp-publish').addEventListener('click', async () => {
const out = $('#cmp-result'); out.innerHTML = '';
const channels = [...state.selected];
if (!channels.length) { out.innerHTML = '<span class="warn">Pick at least one connected channel.</span>'; return; }
// HARD RULE: DW never posts text-only — a photo/video/reel is required.
if (!state.mediaUrl && !state.videoUrl) { out.innerHTML = '<span class="warn">📷 A photo, reel, or video is required — DW never posts text-only. Pick an asset or reel on the left first.</span>'; return; }
if (!cap.value.trim()) { out.innerHTML = '<span class="warn">Add a caption.</span>'; return; }
if (channels.includes('facebook') && !state.pages.size) { out.innerHTML = '<span class="warn">📘 Pick at least one Facebook Page to post to.</span>'; return; }
const dry = $('#cmp-dry').checked;
$('#cmp-publish').disabled = true; $('#cmp-publish').textContent = dry ? 'Staging…' : 'Publishing…';
const r = await jpost('/api/channels/publish', {
channels, caption: cap.value.trim(),
mediaUrl: state.mediaUrl, videoUrl: state.videoUrl,
pages: [...state.pages],
// Which DW-owned IG accounts (by @handle) this post targets. Additive field —
// the existing gated publish/stage flow is unchanged; this just carries the
// user's per-account selection through to the server.
handles: [...state.handles],
// The assembled format decision + attached song (name+url). Additive metadata,
// recorded on the staged/outbox entry so the recipe (photo + song + format) is
// captured for review / Reel-Studio render.
format: state.format,
song: state.song || undefined,
confirm: !dry, dryRun: dry,
});
if (r && r.results) {
out.innerHTML = r.results.map(x => {
const cls = x.live && x.ok ? 'ok' : (x.staged ? 'warn' : (x.ok ? 'ok' : 'err'));
const msg = x.live && x.ok ? 'posted live ✓' : (x.staged ? ('staged — ' + (x.reason || '')) : (x.ok ? 'ok' : ('failed' + (x.detail && x.detail[0] && x.detail[0].error ? ': ' + x.detail[0].error : ''))));
return `<div class="${cls}">${esc(x.channel)}: ${esc(msg)}</div>`;
}).join('') + `<div class="muted" style="margin-top:6px;font-size:11px">${esc(r.note || '')}</div>`;
} else {
out.innerHTML = `<span class="err">${esc((r && r.error) || 'publish failed')}</span>`;
}
$('#cmp-publish').disabled = false; $('#cmp-publish').textContent = 'Publish';
});
$('#cmp-refresh').addEventListener('click', () => { loadTargets(); loadSource(state.src); loadIgAccounts(); });
$('#cmp-search').addEventListener('input', e => {
const q = e.target.value.toLowerCase();
root.querySelectorAll('#cmp-srcgrid .cmp-thumb').forEach(t => {
const l = (t.querySelector('.lbl') || {}).textContent || '';
t.style.display = l.toLowerCase().includes(q) ? '' : 'none';
});
});
await Promise.all([loadTargets(), loadSource('assets'), loadIgAccounts()]);
countIt();
renderSong(); updateMakeHint();
// Quick Post — easy per-platform "stage a draft" buttons (gated). Reads the
// live caption + whichever media (reel video or image) is selected.
if (window.MCCQuickPost) {
window.MCCQuickPost.attach($('#cmp-quickpost'),
() => ({ caption: cap.value.trim(), mediaUrl: state.videoUrl || state.mediaUrl, source: 'composer' }),
{ mini: true });
}
// Prefill from Quick Post's "Publish live" handoff — apply caption + media +
// preselect channels, then the human picks the exact Page/IG account, dry-runs,
// and confirms here. This IS the gated live path.
try {
const raw = sessionStorage.getItem('mccComposePrefill');
if (raw) {
sessionStorage.removeItem('mccComposePrefill');
const pf = JSON.parse(raw);
if (pf && Date.now() - (pf.ts || 0) < 120000) {
if (pf.caption) { cap.value = pf.caption; countIt(); }
const u = pf.mediaUrl || '';
if (u) {
const isVid = /\.(mp4|mov|webm|m4v)(\?|$)/i.test(u);
const ph = $('#cmp-preview').querySelector('.cmp-ph'); if (ph) ph.style.display = 'none';
if (isVid) {
state.videoUrl = u; state.mediaUrl = '';
const v = $('#cmp-video'); if (v) { v.src = u; v.style.display = 'block'; }
$('#cmp-img').style.display = 'none';
} else {
state.mediaUrl = u; state.videoUrl = '';
const img = $('#cmp-img'); img.src = u; img.style.display = 'block';
const v = $('#cmp-video'); if (v) { v.style.display = 'none'; v.removeAttribute('src'); }
}
}
(pf.channels || []).forEach(ch => {
const cb = root.querySelector('#cmp-targets input[data-ch="' + ch + '"]');
if (cb && !cb.disabled) { cb.checked = true; state.selected.add(ch); }
});
toggleFbPages();
const note = $('#cmp-bannernote');
if (note) note.textContent = '↗ Prefilled from Quick Post — pick your Page/IG account below, then Publish (leave Dry-run on for the first pass).';
}
}
} catch (_) {}
},
};