← back to Marketing Command Center
public/panels/linkedin.js
192 lines
/* global window, document, navigator, localStorage */
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['linkedin'] = {
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 => (await fetch(ORIGIN + u, { credentials: 'same-origin' })).json();
const msg = (t, err) => { $('#li-msg').textContent = t || ''; $('#li-msg').style.color = err ? '#c0563f' : 'var(--mut)'; };
const fmtWhen = iso => { const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
const deWP = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
let TEMPLATES = [];
async function loadConn() {
try {
const c = await jget('/api/linkedin/connection');
$('#li-banner').innerHTML = c.configured
? `Connected · posting to <b>${esc(c.surface)}</b> — posts still require confirm + approve.`
: 'Draft-only mode — compose here, then Copy + Open LinkedIn to post manually. Live API posting is Phase 2 (needs an approved LinkedIn app + token).';
} catch { $('#li-banner').textContent = 'Could not load status.'; }
}
async function loadTemplates() {
try { TEMPLATES = (await jget('/api/linkedin/templates')).templates || []; } catch { TEMPLATES = []; }
$('#li-tpl').innerHTML = '<option value="">— blank —</option>' + TEMPLATES.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('');
}
$('#li-tpl').addEventListener('change', e => {
const t = TEMPLATES.find(x => x.id === e.target.value);
if (t) { $('#li-text').value = t.body; updateCount(); }
});
// char counter + wallpaper-word guard
function updateCount() {
const v = $('#li-text').value;
const n = v.length;
let note = `${n} chars`;
if (n < 1300) note += ' · aim 1,300–1,900';
else if (n > 1900) note += ' · a touch long';
else note += ' · ✓ sweet spot';
if (/\bwallpaper/i.test(v)) note += ' · ⚠ “wallpaper” → wallcovering on save';
$('#li-count').textContent = note;
}
$('#li-text').addEventListener('input', updateCount);
async function loadDrafts() {
let drafts = [];
try { drafts = (await jget('/api/linkedin/drafts')).drafts || []; } catch { /* */ }
$('#li-dcount').textContent = drafts.length;
$('#li-drafts').innerHTML = drafts.length ? drafts.map(d => `
<div class="card" style="margin:0 0 10px;padding:12px 14px;">
<div style="white-space:pre-wrap;font-size:13px;line-height:1.4;max-height:7.5em;overflow:hidden;">${esc(d.text)}</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap;">
<span class="pill" style="font-size:9.5px;">${d.chars} chars</span>
<span class="when" title="${esc(d.created_at)}" style="font-size:10.5px;color:var(--mut);">🕓 ${esc(fmtWhen(d.created_at))}</span>
<span style="flex:1;"></span>
<button class="btn ghost" data-load="${esc(d.id)}" style="font-size:11px;padding:5px 9px;">Edit</button>
<button class="btn ghost" data-copy="${esc(d.id)}" style="font-size:11px;padding:5px 9px;">Copy</button>
<button class="btn ghost" data-del="${esc(d.id)}" style="font-size:11px;padding:5px 9px;color:#c0563f;">✕</button>
</div>
</div>`).join('') : '<div class="muted">No drafts yet.</div>';
const byId = Object.fromEntries(drafts.map(d => [d.id, d]));
$('#li-drafts').querySelectorAll('[data-load]').forEach(b => b.onclick = () => { $('#li-text').value = byId[b.dataset.load].text; updateCount(); window.scrollTo(0, 0); });
$('#li-drafts').querySelectorAll('[data-copy]').forEach(b => b.onclick = () => copy(byId[b.dataset.copy].text));
$('#li-drafts').querySelectorAll('[data-del]').forEach(b => b.onclick = async () => {
await fetch(ORIGIN + '/api/linkedin/draft/' + encodeURIComponent(b.dataset.del), { method: 'DELETE', credentials: 'same-origin' });
loadDrafts();
});
}
async function copy(t) { try { await navigator.clipboard.writeText(t); msg('Copied to clipboard.'); } catch { msg('Select and ⌘C: ' + t.slice(0, 40)); } }
$('#li-save').onclick = async () => {
const text = $('#li-text').value.trim();
if (!text) { msg('Write something first.', true); return; }
const hashtags = $('#li-tags').value.split(/[\s,]+/).filter(Boolean);
const r = await fetch(ORIGIN + '/api/linkedin/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text, hashtags }) });
const d = await r.json();
if (d.error) { msg('✗ ' + d.error, true); return; }
msg('Draft saved.'); $('#li-text').value = deWP(text); updateCount(); loadDrafts();
};
$('#li-copy').onclick = () => { const t = deWP($('#li-text').value.trim()); if (!t) return msg('Nothing to copy.', true); copy(t); };
$('#li-copytags').onclick = () => { const t = $('#li-tags').value.trim(); if (!t) return msg('No hashtags.', true); copy(t); };
$('#li-open').onclick = () => {
const t = deWP($('#li-text').value.trim());
if (t) copy(t);
window.open('https://www.linkedin.com/feed/?shareActive=true', '_blank', 'noopener');
msg('Text copied — paste it into the LinkedIn composer that just opened.');
};
$('#li-publish').onclick = async () => {
const text = deWP($('#li-text').value.trim());
if (!text) { msg('Write something first.', true); return; }
if (!confirm('Publish to LinkedIn via the API?\n\nFires only if LinkedIn is connected; otherwise it stages. Proceed?')) return;
msg('Publishing…');
const r = await fetch(ORIGIN + '/api/linkedin/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text, confirm: true, approved: true }) });
const d = await r.json();
msg(d.error ? '✗ ' + d.error : (d.posted ? '✓ Posted to LinkedIn.' : (d.message || 'Staged.')), !!d.error);
};
// ── Mode toggle: Compose ⇄ Follow list ──────────────────────────────────
function setMode(m) {
const follow = m === 'follow';
$('#li-mode-compose').hidden = follow;
$('#li-mode-follow').hidden = !follow;
$('#li-mode-compose-btn').className = 'btn ' + (follow ? 'ghost' : 'gold');
$('#li-mode-follow-btn').className = 'btn ' + (follow ? 'gold' : 'ghost');
try { localStorage.setItem('mcc_li_mode', m); } catch {}
if (follow) initFollowList();
}
$('#li-mode-compose-btn').onclick = () => setMode('compose');
$('#li-mode-follow-btn').onclick = () => setMode('follow');
// ── Follow list (from public/data/linkedin-followlist.json) ─────────────
const FL_KEY = 'mcc_li_followed'; // localStorage Set of followed hrefs
const flDone = () => { try { return new Set(JSON.parse(localStorage.getItem(FL_KEY) || '[]')); } catch { return new Set(); } };
const flSave = set => { try { localStorage.setItem(FL_KEY, JSON.stringify([...set])); } catch {} };
let FL = null, flLoaded = false;
async function initFollowList() {
if (flLoaded) return; flLoaded = true;
try { FL = await jget('/data/linkedin-followlist.json'); }
catch { $('#li-fl-sections').innerHTML = '<div class="card muted">Could not load the follow list.</div>'; return; }
$('#li-fl-guidance').textContent = FL.guidance || '';
$('#li-fl-updated').textContent = FL.updated ? `Source: ${esc(FL.source || 'li-clicklist')} · updated ${esc(FL.updated)}` : '';
$('#li-fl-searches').innerHTML = (FL.searches || []).map(g =>
`<div style="margin-bottom:10px;"><div class="muted" style="font-size:11.5px;margin-bottom:4px;">${esc(g.group)}</div>` +
g.links.map(l => `<a class="btn ghost" style="font-size:11px;padding:5px 10px;margin:0 6px 6px 0;display:inline-block;" target="_blank" rel="noopener" href="${esc(l.href)}">${esc(l.label)} ↗</a>`).join('') +
`</div>`).join('');
renderFollow();
$('#li-fl-search').oninput = renderFollow;
$('#li-fl-reset').onclick = () => { if (confirm('Clear all followed checkmarks on this device?')) { flSave(new Set()); renderFollow(); } };
}
function renderFollow() {
const done = flDone();
const q = ($('#li-fl-search').value || '').trim().toLowerCase();
const CAP = 300; // big sections (e.g. 2.4k brokers) render capped; filter to see the rest
const html = (FL.sections || []).map(sec => {
const secDone = sec.people.filter(p => done.has(p.href)).length;
const matches = sec.people.filter(p => !q || p.name.toLowerCase().includes(q) || (p.sub || '').toLowerCase().includes(q));
if (!matches.length) return '';
const shown = matches.slice(0, CAP);
const more = matches.length - shown.length;
const rowHTML = shown.map(p => {
const on = done.has(p.href);
const badge = p.kind === 'salesnav' ? '<span class="pill" style="font-size:9px;">Sales Nav</span>'
: p.kind === 'search' ? '<span class="pill" style="font-size:9px;">name search</span>' : '';
const sub = p.sub ? `<span class="muted" style="font-size:11px;">${esc(p.sub)}</span>` : '';
return `<label class="li-fl-row" style="display:flex;align-items:center;gap:9px;padding:5px 0;border-bottom:1px solid var(--line,#eee);cursor:pointer;${on ? 'opacity:.5;' : ''}">
<input type="checkbox" data-href="${esc(p.href)}" ${on ? 'checked' : ''} style="width:16px;height:16px;flex:none;">
<span style="flex:1;display:flex;flex-direction:column;line-height:1.25;min-width:0;">
<a href="${esc(p.href)}" target="_blank" rel="noopener" style="${on ? 'text-decoration:line-through;' : ''}" onclick="event.stopPropagation()">${esc(p.name)} ↗</a>
${sub}
</span>
${badge}
</label>`;
}).join('');
const moreNote = more > 0 ? `<div class="muted" style="font-size:11px;margin-top:8px;">Showing ${shown.length} of ${matches.length}${q ? ' matches' : ''} — type a name in the filter to narrow.</div>` : '';
return `<div class="card">
<h2 style="font-size:14px;">${esc(sec.title)} <span class="pill" data-secpill="${esc(sec.id)}">${secDone}/${sec.people.length}</span></h2>
<div class="muted" style="font-size:11.5px;margin-bottom:10px;">${esc(sec.note || '')}</div>
<div>${rowHTML}</div>${moreNote}
</div>`;
}).join('');
$('#li-fl-sections').innerHTML = html || '<div class="card muted">No matches.</div>';
updateFollowStats(done);
$('#li-fl-sections').querySelectorAll('input[type=checkbox]').forEach(cb => cb.onchange = () => {
const set = flDone();
cb.checked ? set.add(cb.dataset.href) : set.delete(cb.dataset.href);
flSave(set);
const row = cb.closest('.li-fl-row'); // in-place update — no full rebuild (2.6k rows)
if (row) { row.style.opacity = cb.checked ? '.5' : ''; const a = row.querySelector('a'); if (a) a.style.textDecoration = cb.checked ? 'line-through' : ''; }
updateFollowStats(set);
});
}
function updateFollowStats(done) {
let total = 0, doneCount = 0;
(FL.sections || []).forEach(sec => {
let sd = 0; sec.people.forEach(p => { if (done.has(p.href)) sd++; });
total += sec.people.length; doneCount += sd;
const pill = root.querySelector(`[data-secpill="${sec.id}"]`); if (pill) pill.textContent = `${sd}/${sec.people.length}`;
});
$('#li-fl-progress').textContent = `${doneCount} / ${total} followed`;
$('#li-fl-bar').style.width = total ? (100 * doneCount / total).toFixed(1) + '%' : '0';
}
await loadConn(); await loadTemplates(); await loadDrafts(); updateCount();
// Restore last mode (default compose).
setMode((() => { try { return localStorage.getItem('mcc_li_mode') || 'compose'; } catch { return 'compose'; } })());
},
};