← 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 (_) {}
  },
};