← back to Marketing Command Center
public/quickpost.js
154 lines
// MCCQuickPost — the shared "easy post" component reused in 3 places (the Quick
// Post panel, the Compose panel, and each asset/video card). Renders a row of
// per-platform buttons; a click STAGES A DRAFT (gated) via /api/quickpost/draft
// and shows a toast. Live posting is intentionally NOT wired here — staging is
// the safe default that honors the "social posting is Steve-gated" rule.
(function () {
const ORIGIN = location.origin;
const PLATFORMS = [
{ id: 'instagram', label: 'Instagram', icon: '📷' },
{ id: 'tiktok', label: 'TikTok', icon: '🎵' },
{ id: 'facebook', label: 'Facebook', icon: '📘' },
{ id: 'linkedin', label: 'LinkedIn', icon: '💼' },
{ id: 'threads', label: 'Threads', icon: '🧵' },
{ id: 'bluesky', label: 'Bluesky', icon: '🦋' },
];
// Live posting hands off to the Composer (targeted account picker + dry-run +
// confirm) — Quick Post never fires a live post itself. This is the set of
// platforms enabled for the live handoff (Steve, per-platform go).
const LIVE_ENABLED = ['instagram', 'tiktok', 'facebook', 'linkedin', 'threads', 'bluesky'];
// one-time CSS
if (!document.getElementById('qp-css')) {
const s = document.createElement('style'); s.id = 'qp-css';
s.textContent = `
.qp-row{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.qp-btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line,#e3ddd0);
background:#fff;border-radius:9px;cursor:pointer;font:600 12px/1 Inter,sans-serif;color:var(--ink,#14110f);
padding:8px 12px;transition:background .12s,border-color .12s}
.qp-btn:hover{background:var(--cream,#f4efe7);border-color:var(--accent,#8a5a44)}
.qp-btn.busy{opacity:.6;pointer-events:none}
.qp-btn.ok{background:#e3efe0;border-color:#bcd8b6;color:#2f6b34}
.qp-btn .qp-i{font-size:14px}
.qp-btn.mini{padding:5px 8px;font-size:10.5px;border-radius:7px}
.qp-all{border-style:dashed;color:var(--accent,#8a5a44)}
.qp-btn.qp-live{border-color:var(--accent,#8a5a44);color:var(--accent,#8a5a44);font-weight:700}
.qp-btn.qp-live:hover{background:var(--accent,#8a5a44);color:#fff}
.qp-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-left:4px;flex:none;background:#c9bfa8}
.qp-dot.valid{background:#3a6b3a}
.qp-dot.invalid{background:#c0563f}
.qp-legend{font-size:10.5px;color:var(--mut,#8a8275);margin-top:6px;display:flex;gap:12px;flex-wrap:wrap}
.qp-legend b{font-weight:600;color:var(--ink,#14110f)}
.qp-hint{font-size:11px;color:var(--mut,#8a8275)}
#qp-toast{position:fixed;right:18px;bottom:18px;z-index:9999;display:flex;flex-direction:column;gap:8px}
#qp-toast .t{background:var(--ink,#14110f);color:#fff;border-radius:10px;padding:11px 15px;font:600 12.5px/1.3 Inter,sans-serif;
box-shadow:0 10px 30px rgba(0,0,0,.25);opacity:0;transform:translateY(8px);transition:opacity .2s,transform .2s;max-width:340px}
#qp-toast .t.show{opacity:1;transform:none}
#qp-toast .t .g{color:var(--gold-soft,#d8c19a)}`;
document.head.appendChild(s);
}
function toast(msg) {
let box = document.getElementById('qp-toast');
if (!box) { box = document.createElement('div'); box.id = 'qp-toast'; document.body.appendChild(box); }
const t = document.createElement('div'); t.className = 't'; t.innerHTML = msg;
box.appendChild(t);
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 250); }, 2600);
}
async function stage(platform, payload) {
const r = await fetch(ORIGIN + '/api/quickpost/draft', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform, ...payload }),
});
return r.json().catch(() => ({ error: 'bad response' }));
}
// Cached real token-validity per platform (GET /api/channels/health). One fetch
// shared across every mount so the dots don't hammer the endpoint.
let _healthPromise = null;
function getHealth() {
if (!_healthPromise) _healthPromise = fetch(ORIGIN + '/api/channels/health', { credentials: 'same-origin' })
.then(r => r.json()).then(d => (d && d.platforms) || {}).catch(() => ({}));
return _healthPromise;
}
function decorateHealth(mount) {
getHealth().then(h => {
mount.querySelectorAll('.qp-btn[data-qp]').forEach(btn => {
const id = btn.getAttribute('data-qp');
const st = h[id] && h[id].state;
if (!st) return;
const dot = document.createElement('span');
dot.className = 'qp-dot ' + (st === 'valid' ? 'valid' : st === 'invalid' ? 'invalid' : '');
dot.title = 'Token: ' + st;
btn.appendChild(dot);
btn.title = (btn.title || '') + ' · token ' + st;
});
});
}
// Render a button row into `mount`. getPayload(platform) → {caption, mediaUrl, source}.
// opts: { mini:false, includeAll:true, live:false, health:true }
function attach(mount, getPayload, opts) {
opts = opts || {};
const cls = opts.mini ? 'qp-btn mini' : 'qp-btn';
let html = PLATFORMS.map(p =>
`<button type="button" class="${cls}" data-qp="${p.id}" title="Stage a draft post to ${p.label}">` +
`<span class="qp-i">${p.icon}</span>${opts.mini ? '' : p.label}</button>`).join('');
if (opts.includeAll !== false && !opts.mini)
html += `<button type="button" class="qp-btn qp-all" data-qp="__all" title="Stage a draft to all platforms">+ All</button>`;
// Live handoff → opens the Composer prefilled (targeted account + confirm live there)
if (opts.live) {
const live = opts.mini
? `<button type="button" class="qp-btn mini qp-live" data-qp="__live" title="Publish live in Composer (pick account + confirm there)">↗ Live</button>`
: `<button type="button" class="qp-btn qp-live" data-qp="__live" title="Publish live in Composer — pick the exact account + confirm there">↗ Publish live in Composer…</button>`;
html += live;
}
mount.insertAdjacentHTML('beforeend', `<div class="qp-row">${html}</div>`);
mount.querySelectorAll('[data-qp]').forEach(btn => btn.addEventListener('click', async () => {
const which = btn.getAttribute('data-qp');
const payload = (typeof getPayload === 'function' ? getPayload() : getPayload) || {};
// LIVE handoff — hand media+caption to the Composer, preselect the enabled
// platforms, switch there. The human picks the exact Page/IG account, does a
// dry-run, and confirms in the Composer. Quick Post never posts live itself.
if (which === '__live') {
if (!payload.mediaUrl && !payload.caption) { toast('Nothing to hand off — add a caption or pick media first.'); return; }
try {
sessionStorage.setItem('mccComposePrefill', JSON.stringify({
caption: payload.caption || '', mediaUrl: payload.mediaUrl || '',
channels: LIVE_ENABLED, source: payload.source || 'quickpost', ts: Date.now(),
}));
} catch (_) {}
toast('↗ Opening Composer — pick the account, then <span class="g">dry-run & confirm</span> to post live.');
location.hash = 'compose';
return;
}
const targets = which === '__all' ? PLATFORMS.map(p => p.id) : [which];
if (!payload.mediaUrl && !payload.caption) { toast('Nothing to post — add a caption or pick media first.'); return; }
btn.classList.add('busy'); const orig = btn.innerHTML;
let ok = 0;
for (const pl of targets) { const res = await stage(pl, payload); if (res && res.ok) ok++; }
btn.classList.remove('busy'); btn.classList.add('ok');
btn.innerHTML = '✓ Staged';
const label = which === '__all' ? `all ${ok} platforms` : PLATFORMS.find(p => p.id === which).label;
toast(`🚀 Draft staged for <span class="g">${label}</span> — awaiting your approval.`);
setTimeout(() => { btn.classList.remove('ok'); btn.innerHTML = orig; }, 1600);
document.dispatchEvent(new CustomEvent('qp:staged'));
}));
// token-validity dots (green=valid · red=expired · grey=unverified/not set) —
// shown on the full (non-mini) rows so tiny asset-card buttons stay clean.
if (opts.health !== false && !opts.mini) {
decorateHealth(mount);
mount.insertAdjacentHTML('beforeend',
`<div class="qp-legend"><span><span class="qp-dot valid"></span> token valid</span>` +
`<span><span class="qp-dot invalid"></span> expired</span>` +
`<span><span class="qp-dot"></span> not set / unverified</span></div>`);
}
}
window.MCCQuickPost = { PLATFORMS, attach, stage, toast, getHealth };
})();