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