← back to Marketing Command Center
public/panels/board.js
205 lines
window.MCC_PANELS = window.MCC_PANELS || {};
// Branded "media unavailable" tile shown when a post image fails to load (e.g. an
// expired IG/fbcdn signed URL that 403s). Inline data-URI so it needs no request;
// URL-encoded so it can drop straight into an <img src>. Reads as intentional,
// not blank. The durable fix is lib/media-cache (localizes fresh URLs); this is
// the graceful fallback for images that were already dead before caching.
const PLACEHOLDER_SVG = encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="220" viewBox="0 0 400 220">` +
`<rect width="400" height="220" fill="#f4f1ea"/>` +
`<rect x="0" y="0" width="400" height="220" fill="url(#g)" opacity="0.5"/>` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#efe9dc"/><stop offset="1" stop-color="#e2dccb"/></linearGradient></defs>` +
`<g fill="none" stroke="#b8ad93" stroke-width="2"><rect x="163" y="86" width="74" height="52" rx="6"/><circle cx="200" cy="112" r="13"/><path d="M175 86l7-10h36l7 10"/></g>` +
`<text x="200" y="170" font-family="Georgia,serif" font-size="13" fill="#9a8f76" text-anchor="middle">image unavailable</text></svg>`
);
window.MCC_PANELS['board'] = {
async init(root) {
const ORIGIN = 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(ORIGIN + u, { credentials: 'same-origin' }); return r.ok ? await r.json() : null; } catch { return null; } };
// created date + time in the admin's local tz (admin-card convention)
const fmtWhen = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
const pickDate = o => o && (o.at || o.created_at || o.createdAt || o.date || o.scheduled_at || o.scheduledAt || o.scheduled || o.send_time || o.when || '');
const pickText = o => o && (o.caption || o.body || o.text || o.message || o.subject || o.name || o.title || '');
// persisted controls
const board = $('#bd-board');
const setWidth = w => { board.style.setProperty('--bd-w', w + 'px'); };
let WIDTH = +(localStorage.getItem('mcc_board_w') || 300);
let SORT = localStorage.getItem('mcc_board_sort') || 'newest';
$('#bd-width').value = WIDTH; setWidth(WIDTH);
$('#bd-sort').value = SORT;
// ── IG re-pull health strip (job that keeps published-IG thumbnails durable) ──
// Inserted once above the board; shows last-run time + result + a health dot,
// and a "Run now" fix-it button. Amber if stale (>36h) or never run, red on
// Graph/download errors, green when the last run completed clean.
if (!$('#bd-repull-health')) {
const strip = document.createElement('div');
strip.id = 'bd-repull-health';
strip.style.cssText = 'display:flex;align-items:center;gap:10px;margin:0 0 10px;padding:7px 12px;border:1px solid #e4ddcd;border-radius:8px;background:#faf7f0;font-size:12.5px;color:#6b6350';
board.parentNode.insertBefore(strip, board);
}
const healthEl = $('#bd-repull-health');
async function loadRepullHealth() {
if (!healthEl) return;
const s = await jget('/api/channels/ig-repull-status') || {};
const ageH = s.ranAt ? (Date.now() - new Date(s.ranAt).getTime()) / 3600000 : Infinity;
let dot = '#4a8f5f', label; // green
if (!s.ranAt) { dot = '#c9a227'; label = 'never run'; }
else if (s.ok === false) { dot = '#c0392b'; label = `error (graphErr ${s.graphErr}, dead ${s.downloadDead})`; }
else if (ageH > 36) { dot = '#c9a227'; label = `stale — last ran ${fmtWhen(s.ranAt)}`; }
else label = `ran ${fmtWhen(s.ranAt)} · ${s.recovered || 0} recovered`;
healthEl.innerHTML =
`<span style="width:9px;height:9px;border-radius:50%;background:${dot};flex:0 0 auto"></span>` +
`<span style="flex:1 1 auto">🔄 IG image re-pull · <strong>${esc(label)}</strong>${s.unrecoverable ? ` · ${s.unrecoverable} unrecoverable` : ''}</span>` +
`<button id="bd-repull-run" class="btn" style="font-size:12px;padding:3px 10px">Run now</button>`;
$('#bd-repull-run').addEventListener('click', async () => {
const btn = $('#bd-repull-run'); btn.disabled = true; btn.textContent = '…running';
try {
const r = await fetch(ORIGIN + '/api/channels/ig-repull-run', { method: 'POST', credentials: 'same-origin' });
if (r.status === 409) { btn.textContent = 'already running'; }
else { await new Promise(res => setTimeout(res, 4000)); await loadRepullHealth(); await load(); return; }
} catch { btn.textContent = 'failed'; }
setTimeout(() => { if ($('#bd-repull-run')) { $('#bd-repull-run').disabled = false; $('#bd-repull-run').textContent = 'Run now'; } }, 2500);
});
}
let DATA = {};
async function load() {
board.innerHTML = '<div class="loading">Loading streams…</div>';
const [cols, status, outbox, cc, ccCal, li] = await Promise.all([
jget('/api/board/columns'),
jget('/api/channels/status'),
jget('/api/channels/outbox'),
jget('/api/constant-contact/campaigns'),
jget('/api/constant-contact/calendar'),
jget('/api/linkedin/drafts'),
]);
DATA = {
columns: (cols && cols.columns) || [],
platforms: (status && status.platforms) || {},
outbox: (outbox && outbox.items) || [],
campaigns: (cc && (cc.campaigns || cc.items)) || [],
ccCal: (ccCal && (ccCal.items || ccCal.campaigns || ccCal.events)) || [],
drafts: (li && li.drafts) || [],
};
render();
}
function sortItems(arr) {
const a = arr.slice();
a.sort((x, y) => {
const dx = pickDate(x) || '', dy = pickDate(y) || '';
return SORT === 'oldest' ? String(dx).localeCompare(dy) : String(dy).localeCompare(dx);
});
return a;
}
function statusClass(s) {
s = (s || '').toLowerCase();
if (s.includes('post')) return 'posted';
if (s.includes('fail')) return 'failed';
if (s.includes('draft')) return 'draft';
if (s.includes('schedul')) return 'staged';
return 'pending';
}
function cardHTML({ when, text, media, status, metric }) {
const w = fmtWhen(when);
return `<div class="bd-card">
${w ? `<div class="when" title="${esc(when)}">🕓 ${esc(w)}</div>` : ''}
<div class="txt">${esc((text || '').slice(0, 220)) || '<span class="bd-metric">(no text)</span>'}</div>
${media ? `<img class="media" src="${esc(media)}" loading="lazy" onerror="this.onerror=null;this.classList.add('media-missing');this.src='data:image/svg+xml;utf8,${PLACEHOLDER_SVG}'">` : ''}
<div class="foot">
${metric ? `<span class="bd-metric">${esc(metric)}</span>` : '<span></span>'}
${status ? `<span class="bd-status ${statusClass(status)}">${esc(status)}</span>` : ''}
</div>
</div>`;
}
// build the cards + header pill for one column
function columnContent(col) {
// ── Threads / Bluesky: no publish path yet ──
if (col.soon) {
return { pill: { cls: 'soon', txt: 'Coming soon' }, count: 0,
body: `<div class="bd-soonmsg">🔌 <b>${esc(col.label)}</b> isn't wired for publishing yet.<br>The column is here so it slots in the moment the connector lands.</div>` };
}
// ── LinkedIn: drafts from the linkedin module ──
if (col.kind === 'linkedin') {
const items = sortItems(DATA.drafts);
const body = items.length
? items.map(d => cardHTML({ when: pickDate(d), text: pickText(d), status: d.status || 'draft' })).join('')
: `<div class="bd-empty">No LinkedIn drafts yet.</div>`;
return { pill: { cls: 'stage', txt: 'Drafts · manual' }, count: items.length, body };
}
// ── Email: Constant Contact campaigns (reports) + scheduled calendar ──
if (col.kind === 'email') {
const cal = sortItems(DATA.ccCal).map(e => cardHTML({
when: pickDate(e), text: pickText(e), status: 'scheduled',
}));
const camps = DATA.campaigns.slice(0, 40).map(c => cardHTML({
when: pickDate(c), text: c.name || pickText(c),
metric: [c.sends != null ? c.sends + ' sent' : null,
c.opens != null ? c.opens + ' opens' : null,
c.open_rate != null ? Math.round(c.open_rate * 100) + '% open' : null].filter(Boolean).join(' · '),
status: 'report',
}));
const all = cal.concat(camps);
const has = DATA.campaigns.length || DATA.ccCal.length;
return { pill: { cls: has ? 'on' : 'stage', txt: has ? 'Reports loaded' : 'Import CSV' },
count: all.length, body: all.length ? all.join('') : `<div class="bd-empty">No campaigns yet — import a CSV in the Email panel.</div>` };
}
// ── Social: facebook / instagram / tiktok / youtube from the channels outbox ──
const pf = DATA.platforms[col.id];
const items = sortItems(DATA.outbox.filter(it => (it.channel || '').toLowerCase() === col.id));
let pill;
if (pf && pf.connected) pill = { cls: 'on', txt: 'Connected · live' };
else if (pf) pill = { cls: 'stage', txt: 'Stages (not connected)' };
else pill = { cls: 'off', txt: 'Not connected' };
const body = items.length
? items.map(it => cardHTML({ when: it.at, text: it.caption, media: it.mediaUrl, status: it.status })).join('')
: `<div class="bd-empty">Nothing queued for ${esc(col.label)}.</div>`;
return { pill, count: items.length, body };
}
function render() {
if (!DATA.columns.length) { board.innerHTML = `<div class="muted-banner">No columns configured.</div>`; return; }
board.innerHTML = DATA.columns.map(col => {
const c = columnContent(col);
return `<div class="bd-col ${col.soon ? 'soon' : ''}">
<div class="bd-colhead">
<div class="row1"><span class="ico">${col.icon || '▪'}</span>
<span class="nm">${esc(col.label)}</span>
<span class="cnt">${c.count}</span></div>
<span class="bd-pill ${c.pill.cls}">${esc(c.pill.txt)}</span>
</div>
<div class="bd-body">${c.body}</div>
</div>`;
}).join('');
}
// controls
$('#bd-width').addEventListener('input', e => { WIDTH = +e.target.value; setWidth(WIDTH); localStorage.setItem('mcc_board_w', WIDTH); });
$('#bd-sort').addEventListener('change', e => { SORT = e.target.value; localStorage.setItem('mcc_board_sort', SORT); render(); });
$('#bd-refresh').addEventListener('click', load);
// Clear staged/pending/failed cards (keeps posted history), then reload the board.
$('#bd-clear').addEventListener('click', async () => {
const btn = $('#bd-clear'); const label = btn.textContent;
if (!confirm('Remove all staged / pending / failed cards? (Posted history is kept.)')) return;
btn.disabled = true; btn.textContent = '…clearing';
try {
const r = await fetch(ORIGIN + '/api/channels/outbox/clear', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: 'staged' }),
});
await r.json().catch(() => ({}));
} finally { btn.disabled = false; btn.textContent = label; await load(); }
});
await load();
loadRepullHealth();
},
};