← back to Marketing Command Center

public/panels/ig-activity.js

186 lines

// IG Posts panel — show every fleet Instagram post with sort + account filter +
// density (persisted), each card stamped with its post date+time, and a per-post
// delete that routes through Norma's gated/serialized flow via /api/ig-activity.
// "Remove from board" = tombstone (safe, IG untouched); "Delete on Instagram" =
// the real, gated delete (Norma 403s it unless armed, 409s if one's already running).
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['ig-activity'] = {
  init(root) {
    const $ = s => root.querySelector(s);
    const api = (p, opts) => fetch(`/api/ig-activity${p}`, opts).then(r => r.json());
    const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
    const fmtTime = t => { try { return new Date(t).toLocaleString(undefined,
      { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return t || ''; } };

    let DATA = [];
    let CANON = [];         // canonical DW-owned accounts (the 35) — so the filter lists ALL owned accounts, not just posted ones
    let SORT = localStorage.getItem('ig_sort') || 'ts|desc';
    let ACCT = localStorage.getItem('ig_acct') || '';
    let target = null;      // the post being deleted (modal context)
    let normaUp = true;     // is the Norma delete host reachable from this server?

    $('#ig-sort').value = SORT;
    const den = localStorage.getItem('ig_density') || '4';
    $('#ig-density').value = den;
    root.style.setProperty('--ig-cols', den);
    // panels render into a container; set the var on that container too
    const host = root.closest('[data-panel]') || document.documentElement;
    host.style.setProperty('--ig-cols', den);

    function filtered() {
      let list = DATA.slice();
      if (ACCT) list = list.filter(x => x.handle === ACCT);
      const [k, dir] = SORT.split('|');
      list.sort((a, b) => {
        let x = a[k], y = b[k];
        if (k === 'ts') { x = new Date(x); y = new Date(y); }
        else { x = (x || '').toString().toLowerCase(); y = (y || '').toString().toLowerCase(); }
        return (x < y ? -1 : x > y ? 1 : 0) * (dir === 'desc' ? -1 : 1);
      });
      return list;
    }

    function card(x) {
      const img = esc((x.image_url || '').replace(/'/g, ''));
      const link = esc(x.permalink || '#');
      return `<div class="card" style="padding:0;overflow:hidden;display:flex;flex-direction:column">
        <a href="${link}" target="_blank" rel="noopener noreferrer" style="display:block;aspect-ratio:1/1;background:#111 center/cover no-repeat;background-image:url('${img}')"></a>
        <div style="padding:11px 12px 12px;display:flex;flex-direction:column;gap:6px;flex:1">
          <a class="acct" href="https://www.instagram.com/${esc(x.handle)}/" target="_blank" rel="noopener noreferrer"
             style="color:var(--gold,#c9a86a);font-weight:600;font-size:13px;text-decoration:none">@${esc(x.handle)}</a>
          <div style="font-size:12.5px;line-height:1.35;min-height:2.4em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden"
               title="${esc(x.product_title)}">${esc(x.product_title) || '—'}</div>
          <div class="muted" style="font-size:11px" title="${esc(x.ts)}">🕓 ${fmtTime(x.ts)} · ${esc(x.kind || 'IMAGE')}</div>
          <div class="row" style="gap:6px;justify-content:space-between;align-items:center;margin-top:auto;padding-top:8px">
            <a class="btn ghost" href="${link}" target="_blank" rel="noopener noreferrer" style="padding:4px 10px;font-size:11.5px">View ↗</a>
            <button class="btn ig-del" data-key="${esc(x.permalink || x.media_id)}" style="padding:4px 10px;font-size:11.5px;color:#b3261e;border-color:#e3b7b3">Delete</button>
          </div>
        </div>
      </div>`;
    }

    function render() {
      const list = filtered();
      $('#ig-grid').innerHTML = list.length
        ? list.map(card).join('')
        : '<div class="loading">No posts.</div>';
      $('#ig-grid').querySelectorAll('.ig-del').forEach(b =>
        b.onclick = () => openModal(b.dataset.key));
    }

    function fillAccounts() {
      // Union the canonical DW-owned accounts (all 35) with any handle that has
      // posted — so every owned account is selectable even with zero posts (it
      // then shows the "No posts." empty state). Canonical accounts with no posts
      // are marked so it's obvious nothing has published yet.
      const posted = new Set(DATA.map(x => x.handle));
      const canon = CANON.map(a => a.handle);
      const accts = [...new Set([...canon, ...posted])].sort();
      const sel = $('#ig-acct');
      sel.innerHTML = '<option value="">All accounts</option>' +
        accts.map(a => `<option value="${esc(a)}">@${esc(a)}${posted.has(a) ? '' : ' · no posts'}</option>`).join('');
      sel.value = ACCT;
    }

    // Delete of any kind proxies to Norma (the Instagram automation host). Norma
    // is a Mac2-local service, so on hosts that can't reach it (e.g. Kamatera)
    // delete is unavailable — say so honestly rather than fail per-click.
    async function checkNorma() {
      try { const s = await api('/delete-status'); normaUp = !!(s && s.ok); }
      catch { normaUp = false; }
      $('#ig-banner').innerHTML = normaUp ? '' :
        `<div class="card" style="border-color:#e3b7b3;background:#fbf1f0">
           <b>Delete is unavailable from this server.</b>
           <span class="muted" style="font-size:12.5px">Deleting an Instagram post runs through Norma
           (the automation host on Mac2), which this server can’t reach. Posts are shown for reference;
           run delete from the local command center where Norma is reachable.</span>
         </div>`;
    }

    // ── delete modal ──────────────────────────────────────────────────────────
    function openModal(key) {
      target = DATA.find(x => (x.permalink || x.media_id) === key);
      if (!target) return;
      $('#ig-modal-title').textContent = 'Delete this Instagram post?';
      $('#ig-modal-body').innerHTML =
        `<b>@${esc(target.handle)}</b> — ${esc(target.product_title) || 'post'}<br>` +
        `<span style="font-size:12px">${esc(target.permalink || '')}</span><br><br>` +
        `<b>Remove from board</b> hides it here only (Instagram untouched). ` +
        `<b>Delete on Instagram</b> is the real, permanent delete — it runs through ` +
        `Norma's serialized real-Chrome flow and only fires if live delete is armed on Norma.`;
      $('#ig-modal-status').innerHTML = normaUp ? '' :
        '<span style="color:#b3261e">Norma (delete host) is not reachable from this server — delete is disabled here.</span>';
      setBusy(false);
      $('#ig-remove').disabled = !normaUp;
      $('#ig-live').disabled = !normaUp;
      $('#ig-modal').style.display = 'flex';
    }
    function closeModal() { $('#ig-modal').style.display = 'none'; target = null; }
    function setBusy(b) {
      ['#ig-remove', '#ig-live', '#ig-cancel'].forEach(s => $(s).disabled = b);
    }

    async function doDelete(live) {
      if (!target) return;
      setBusy(true);
      $('#ig-modal-status').innerHTML = `<span class="muted">${live ? 'Deleting on Instagram (this can take ~a minute)…' : 'Removing…'}</span>`;
      try {
        const j = await api('/delete', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ permalink: target.permalink, media_id: target.media_id, handle: target.handle, live }),
        });
        if (j.ok && (j.mode === 'tombstone' || j.verified)) {
          const key = target.permalink || target.media_id;
          DATA = DATA.filter(x => (x.permalink || x.media_id) !== key);
          closeModal(); fillAccounts(); render(); updateMeta();
        } else {
          $('#ig-modal-status').innerHTML = `<span style="color:#b3261e">${esc(j.error || 'Delete did not complete.')}</span>`;
          setBusy(false);
        }
      } catch (e) {
        $('#ig-modal-status').innerHTML = `<span style="color:#b3261e">${esc(e.message)}</span>`;
        setBusy(false);
      }
    }

    function updateMeta() {
      $('#ig-meta').textContent =
        `${DATA.length} posts · ${new Set(DATA.map(x => x.handle)).size} accounts`;
    }

    // ── wiring ────────────────────────────────────────────────────────────────
    $('#ig-sort').onchange = () => { SORT = $('#ig-sort').value; localStorage.setItem('ig_sort', SORT); render(); };
    $('#ig-acct').onchange = () => { ACCT = $('#ig-acct').value; localStorage.setItem('ig_acct', ACCT); render(); };
    $('#ig-density').oninput = () => {
      const v = $('#ig-density').value;
      localStorage.setItem('ig_density', v);
      root.style.setProperty('--ig-cols', v);
      host.style.setProperty('--ig-cols', v);
    };
    $('#ig-refresh').onclick = load;
    $('#ig-cancel').onclick = closeModal;
    $('#ig-remove').onclick = () => doDelete(false);
    $('#ig-live').onclick = () => doDelete(true);
    $('#ig-modal').onclick = e => { if (e.target === $('#ig-modal')) closeModal(); };

    async function load() {
      $('#ig-grid').innerHTML = '<div class="loading">Loading posts…</div>';
      try {
        if (!CANON.length && window.MCC_ACCOUNTS) { try { CANON = await window.MCC_ACCOUNTS.load(); } catch {} }
        const j = await api('/posts');
        DATA = j.posts || [];
        $('#ig-meta').textContent =
          `${j.total_posts ?? DATA.length} posts · ${j.accounts_touched ?? new Set(DATA.map(x => x.handle)).size} accounts` +
          (j.generated_at ? ` · updated ${fmtTime(j.generated_at)}` : '') +
          (j.removed ? ` · ${j.removed} removed` : '');
        fillAccounts(); render();
      } catch (e) {
        $('#ig-grid').innerHTML = `<div class="loading">Failed to load: ${esc(e.message)}</div>`;
      }
      checkNorma();
    }
    load();
  },
};