← back to Li Followhub
public/index.html
413 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LinkedIn Follow Hub</title>
<style>
:root{
--li:#0a66c2; --li-d:#004182; --ink:#1d2226; --mut:#66707a;
--line:#e3e7ea; --bg:#f4f2ee; --card:#fff; --good:#057642; --warn:#915907; --bad:#b24020;
--cols:4;
}
*{box-sizing:border-box}
body{font:15px/1.5 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
margin:0;background:var(--bg);color:var(--ink)}
header{background:var(--card);border-bottom:1px solid var(--line);padding:16px 22px;position:sticky;top:0;z-index:20}
h1{margin:0;font-size:20px;display:flex;align-items:center;gap:10px}
h1 .in{background:var(--li);color:#fff;border-radius:5px;padding:1px 7px;font-weight:800}
.wrap{max-width:1280px;margin:0 auto;padding:0 22px}
.disclaim{background:#fff4e5;border-left:4px solid var(--warn);border-radius:6px;
padding:10px 14px;margin:14px 0;font-size:13.5px;color:#5a4300}
.disclaim b{color:var(--bad)}
/* cadence */
.cadence{display:flex;flex-wrap:wrap;align-items:center;gap:18px;margin:14px 0}
.ring{--p:0;width:64px;height:64px;border-radius:50%;flex:0 0 auto;
background:conic-gradient(var(--li) calc(var(--p)*1%), var(--line) 0);
display:grid;place-items:center;position:relative}
.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--card)}
.ring b{position:relative;font-size:15px}
.cad-txt small{color:var(--mut)}
.cad-txt .big{font-size:22px;font-weight:800}
.cap-warn{background:#fde7e3;border:1px solid var(--bad);color:var(--bad);
border-radius:8px;padding:10px 14px;font-weight:600;margin:6px 0;display:none}
.cap-warn.show{display:block}
.stat{display:flex;gap:22px;flex-wrap:wrap;margin-left:auto}
.stat div{text-align:center}
.stat .n{font-size:20px;font-weight:800;color:var(--li-d)}
.stat .l{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
input[type=number]{width:56px;padding:3px 6px;border:1px solid var(--line);border-radius:6px;font:inherit}
/* tabs */
.tabs{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 2px}
.tab{border:1px solid var(--line);background:var(--card);border-radius:20px;padding:6px 14px;
cursor:pointer;font-weight:600;font-size:13.5px;color:var(--mut)}
.tab.on{background:var(--li);border-color:var(--li);color:#fff}
.tab .c{opacity:.7;font-weight:700;margin-left:5px}
/* controls */
.controls{display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin:14px 0}
.controls input[type=search]{flex:1;min-width:200px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;font:inherit}
.controls select{padding:8px 10px;border:1px solid var(--line);border-radius:8px;font:inherit;background:var(--card)}
.controls label{font-size:12.5px;color:var(--mut);display:flex;align-items:center;gap:7px}
.listnote{color:var(--mut);font-size:13px;margin:2px 0 10px}
.bulk{font-size:12.5px;color:var(--li);cursor:pointer;text-decoration:underline;background:none;border:0;padding:0}
/* grid */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:10px;padding-bottom:60px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 12px;
display:flex;flex-direction:column;gap:7px;break-inside:avoid}
.card.done{background:#f0f7f2;border-color:#bfe3cd}
.card .nm{font-weight:600;word-break:break-word;line-height:1.25}
.card .row{display:flex;align-items:center;gap:8px;margin-top:auto}
.open{flex:1;text-align:center;background:var(--li);color:#fff;border-radius:20px;
padding:6px 10px;font-weight:600;font-size:13px;text-decoration:none}
.open:hover{background:var(--li-d)}
.open.salesnav{background:#5a4300;}
.chk{display:flex;align-items:center;gap:5px;font-size:12.5px;color:var(--good);cursor:pointer;user-select:none;white-space:nowrap}
.chk input{width:17px;height:17px;accent-color:var(--good)}
.tag{font-size:10.5px;padding:1px 6px;border-radius:10px;font-weight:700;align-self:flex-start}
.tag.sn{background:#fff4e5;color:var(--warn)}
.tag.soft{background:#eef3f8;color:var(--li-d)}
.when{font-size:11px;color:var(--good);display:flex;align-items:center;gap:4px}
.chips a{display:inline-block;background:var(--li);color:#fff;padding:7px 13px;border-radius:20px;
margin:5px 8px 5px 0;font-weight:600;font-size:13px;text-decoration:none}
.chips a:hover{background:var(--li-d)}
/* drill links — every displayed data point is an <a href> to its filtered view */
a.drill{color:inherit;text-decoration:none;cursor:pointer}
a.drill:hover{text-decoration:underline}
.card .nm a.drill:hover{color:var(--li)}
.tag a.drill,.when a.drill{color:inherit}
.tag a.drill:hover,.when a.drill:hover{text-decoration:underline}
.activefilters{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:2px 0 8px;font-size:12.5px}
.activefilters .fpill{background:#eef3f8;border:1px solid var(--line);color:var(--li-d);
border-radius:14px;padding:3px 10px;font-weight:600;display:inline-flex;gap:6px;align-items:center}
.activefilters .fpill a{color:var(--bad);text-decoration:none;font-weight:800}
.activefilters .fclear{color:var(--li);text-decoration:underline;cursor:pointer}
.empty{color:var(--mut);padding:30px;text-align:center}
.foot{color:var(--mut);font-size:12px;margin:20px 0}
h2.sec{font-size:15px;color:var(--li-d);margin:26px 0 4px;border-top:1px solid var(--line);padding-top:18px}
/* refresh panel */
.refresh-panel{display:none;background:var(--card);border:1px solid var(--li);border-radius:10px;
padding:14px 16px;margin:2px 0 10px}
.refresh-panel.open{display:block}
.rp-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:6px 0}
.rp-btn{background:#eef3f8;border:1px solid var(--line);color:var(--li-d);border-radius:8px;
padding:7px 14px;font:inherit;font-weight:600;cursor:pointer}
.rp-btn.primary{background:var(--li);border-color:var(--li);color:#fff}
.rp-btn:hover{filter:brightness(.97)}
.rp-hint{font-size:12.5px;color:var(--mut)}
.rp-note{font-size:12.5px;color:#5a4300;background:#fff4e5;border-left:3px solid var(--warn);
border-radius:5px;padding:8px 11px;margin:8px 0}
#pasteBox{width:100%;min-height:76px;border:1px solid var(--line);border-radius:8px;
padding:9px 11px;font:13px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
</style>
</head>
<body>
<header>
<div class="wrap">
<h1><span class="in">in</span> Follow Hub <span style="font-weight:400;color:var(--mut);font-size:14px">— manual, resumable, cadence-guarded</span></h1>
<div class="disclaim">
<b>You</b> click Follow — this page never does. Open each profile in your <b>normal Chrome</b>
(logged into LinkedIn), <b>not</b> the orange openclaw window. On the profile hit <b>Follow</b>
(or <b>More → Follow</b>) — never <b>Connect</b> (that sends an invite). Then tick the box here so it's remembered.
</div>
<div class="cadence">
<div class="ring" id="ring"><b id="ringtxt">0</b></div>
<div class="cad-txt">
<div><span class="big" id="todayN">0</span> <small>followed today</small></div>
<small>Daily cap <input type="number" id="target" min="1" max="200" value="20"> — keep it human-paced</small>
</div>
<div class="stat">
<div><div class="n" id="totN">0</div><div class="l">Total done</div></div>
<div><div class="n" id="remN">0</div><div class="l">Remaining</div></div>
</div>
</div>
<div class="cap-warn" id="capwarn">⚠️ Daily cap reached. Stop for today — bunched follows are what re-trips LinkedIn's scraping flag.</div>
<div class="tabs" id="tabs"></div>
</div>
</header>
<div class="wrap">
<div class="controls">
<input type="search" id="q" placeholder="Search name…">
<select id="sort">
<option value="undone">Not-followed first</option>
<option value="name">Name A→Z</option>
<option value="done">Followed first</option>
</select>
<label><input type="checkbox" id="hideDone"> Hide followed</label>
<label>Density <input type="range" id="density" min="2" max="7" value="4"></label>
<button class="bulk" id="markVisible">✓ Mark all visible as followed</button>
<button class="bulk" id="clearVisible" style="color:var(--bad)">↺ Unmark all visible</button>
<button class="bulk" id="refreshBtn" style="font-weight:700">⟳ Refresh leads</button>
</div>
<div class="refresh-panel" id="refreshPanel">
<div class="rp-row">
<button class="rp-btn" id="reloadDisk">↻ Reload from disk</button>
<span class="rp-hint">Re-reads your <code>~/li-*.json</code> — picks up anything the collector or you added, no restart.</span>
</div>
<div class="rp-note">Can't auto-scrape Sales Nav (that trips the bot flag). To grow the list: in your <b>normal Chrome</b>, open a
<b>Find more</b> search below, copy the profile links, and paste them here — one per line, optionally <code>Name, https://…/in/…</code>.</div>
<textarea id="pasteBox" placeholder="Jane Smith, https://www.linkedin.com/in/janesmith/ https://www.linkedin.com/in/another-designer/"></textarea>
<div class="rp-row">
<button class="rp-btn primary" id="addLeads">+ Add pasted leads</button>
<span class="rp-hint" id="rpResult"></span>
</div>
</div>
<div class="listnote" id="listnote"></div>
<div class="activefilters" id="activefilters"></div>
<div class="grid" id="grid"></div>
<div id="searches"></div>
<div class="foot" id="foot"></div>
</div>
<script>
const $ = s => document.querySelector(s);
let STATE = { lists:[], searches:{}, followed:{}, dailyTarget:20, todayCount:0, totalFollowed:0 };
let activeTab = localStorage.getItem('lfh.tab') || 'reps';
const prefs = {
sort: localStorage.getItem('lfh.sort') || 'undone',
hideDone: localStorage.getItem('lfh.hideDone') === '1',
density: +(localStorage.getItem('lfh.density') || 4),
q: '',
};
// --- href-drill: URL-addressable, shareable, deep-linkable filter state ---
const esc0 = s => encodeURIComponent(String(s==null?'':s));
// drill-only facets (each filters the grid down to matching rows)
const FIL = { status:'', type:'' };
const STATUS_LABEL = { done:'Followed', todo:'Not followed' };
const TYPE_LABEL = { direct:'Direct profile', salesnav:'Sales Nav' };
// build ?list=&status=&type=&q=&sort=&hideDone= from current controls+FIL, override with `over`
function qstr(over){
const p = Object.assign({ list:activeTab, status:FIL.status, type:FIL.type,
q:prefs.q, sort:prefs.sort, hideDone:prefs.hideDone?'1':'' }, over||{});
const qs = [];
if(p.list) qs.push('list='+esc0(p.list));
if(p.status) qs.push('status='+esc0(p.status));
if(p.type) qs.push('type='+esc0(p.type));
if(p.q) qs.push('q='+esc0(p.q));
if(p.sort && p.sort!=='undone') qs.push('sort='+esc0(p.sort));
if(p.hideDone) qs.push('hideDone=1');
return qs.length ? '?'+qs.join('&') : location.pathname;
}
// a drill atom = an <a href> to the deeper (filtered) view of a data point
function drill(field, val, label, cls){
return `<a class="drill ${cls||''}" data-filter="${field}" data-v="${esc0(val)}" href="${qstr({[field]:val})}">${label}</a>`;
}
function readURL(){
const p = new URLSearchParams(location.search);
if(p.has('list')){ activeTab = p.get('list'); localStorage.setItem('lfh.tab',activeTab); }
FIL.status = p.get('status')||'';
FIL.type = p.get('type')||'';
prefs.q = p.get('q')||'';
if(p.has('sort')) prefs.sort = p.get('sort');
if(p.has('hideDone')) prefs.hideDone = p.get('hideDone')==='1';
}
function writeURL(push){
const url = qstr();
if(push) history.pushState(null,'',url); else history.replaceState(null,'',url);
}
async function load(){
const r = await fetch(location.origin + '/api/data'); STATE = await r.json();
$('#target').value = STATE.dailyTarget;
readURL();
applyPrefs(); renderTabs(); render(); renderSearches(); renderFoot();
}
function applyPrefs(){
$('#q').value = prefs.q;
$('#sort').value = prefs.sort;
$('#hideDone').checked = prefs.hideDone;
$('#density').value = prefs.density;
document.documentElement.style.setProperty('--cols', prefs.density);
}
function totalItems(){ return STATE.lists.reduce((n,l)=>n+l.items.length,0); }
function updateCadence(){
const cap = STATE.dailyTarget||20;
const p = Math.min(100, Math.round(STATE.todayCount/cap*100));
$('#ring').style.setProperty('--p', p);
$('#ringtxt').textContent = STATE.todayCount;
$('#todayN').textContent = STATE.todayCount;
$('#totN').textContent = STATE.totalFollowed;
$('#remN').textContent = Math.max(0, totalItems() - STATE.totalFollowed);
$('#capwarn').classList.toggle('show', STATE.todayCount >= cap);
}
function renderTabs(){
$('#tabs').innerHTML = STATE.lists.map(l=>{
const done = l.items.filter(i=>STATE.followed[i.id]).length;
return `<div class="tab ${l.key===activeTab?'on':''}" data-k="${l.key}">${l.label}
<span class="c">${done}/${l.items.length}</span></div>`;
}).join('');
$('#tabs').querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
activeTab=t.dataset.k; localStorage.setItem('lfh.tab',activeTab);
FIL.status=''; FIL.type=''; // reset drill facets when switching lists
writeURL(true); renderTabs(); render();
});
}
function currentList(){ return STATE.lists.find(l=>l.key===activeTab) || STATE.lists[0]; }
function visibleItems(){
const l = currentList(); if(!l) return [];
let items = l.items.slice();
const q = prefs.q.trim().toLowerCase();
if(q) items = items.filter(i=>(i.name||'').toLowerCase().includes(q));
if(prefs.hideDone) items = items.filter(i=>!STATE.followed[i.id]);
// drill facets
if(FIL.status==='done') items = items.filter(i=>!!STATE.followed[i.id]);
else if(FIL.status==='todo') items = items.filter(i=>!STATE.followed[i.id]);
if(FIL.type==='direct') items = items.filter(i=>i.resolved!==false);
else if(FIL.type==='salesnav') items = items.filter(i=>i.resolved===false);
const done = i => STATE.followed[i.id]?1:0;
if(prefs.sort==='name') items.sort((a,b)=>(a.name||'').localeCompare(b.name||''));
else if(prefs.sort==='done') items.sort((a,b)=>done(b)-done(a)||(a.name||'').localeCompare(b.name||''));
else items.sort((a,b)=>done(a)-done(b)||(a.name||'').localeCompare(b.name||'')); // undone first
return items;
}
function fmtWhen(ts){
try{ return new Date(ts).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}
catch{ return ''; }
}
function render(){
const l = currentList();
$('#listnote').textContent = l ? l.note : '';
renderActiveFilters();
const items = visibleItems();
updateCadence();
if(!items.length){ $('#grid').innerHTML = `<div class="empty">Nothing here — try clearing search / “Hide followed” / a filter above.</div>`; return; }
$('#grid').innerHTML = items.map(i=>{
const f = STATE.followed[i.id];
const sn = i.resolved===false;
// every displayed data point drills to its filtered view
const typeTag = sn
? drill('type','salesnav','Sales Nav','tag sn')
: (i.softDone&&!f ? '<span class="tag soft">likely already</span>' : drill('type','direct','Direct','tag soft'));
const nameDrill = drill('status', f?'done':'todo', escapeHtml(i.name||'(profile)'), '');
return `<div class="card ${f?'done':''}" data-id="${encodeURIComponent(i.id)}">
${typeTag}
<div class="nm">${nameDrill}</div>
${f?`<div class="when">🕓 ${drill('status','done','followed '+fmtWhen(f.ts))}</div>`:''}
<div class="row">
<a class="open ${sn?'salesnav':''}" target="_blank" rel="noopener noreferrer" href="${i.url}">${sn?'SalesNav ↗':'Open ↗'}</a>
<label class="chk"><input type="checkbox" ${f?'checked':''}> done</label>
</div>
</div>`;
}).join('');
$('#grid').querySelectorAll('.card input[type=checkbox]').forEach(inp=>{
const id = decodeURIComponent(inp.closest('.card').dataset.id);
inp.onchange = e => toggle(id, e.target.checked);
});
}
function renderActiveFilters(){
const parts = [];
if(FIL.status) parts.push(`<span class="fpill">${STATUS_LABEL[FIL.status]||FIL.status}
<a data-clear="status" href="${qstr({status:''})}" title="clear">✕</a></span>`);
if(FIL.type) parts.push(`<span class="fpill">${TYPE_LABEL[FIL.type]||FIL.type}
<a data-clear="type" href="${qstr({type:''})}" title="clear">✕</a></span>`);
if(prefs.q) parts.push(`<span class="fpill">“${escapeHtml(prefs.q)}”
<a data-clear="q" href="${qstr({q:''})}" title="clear">✕</a></span>`);
if(parts.length) parts.push(`<a class="fclear" data-clear="all" href="${qstr({status:'',type:'',q:''})}">clear all</a>`);
$('#activefilters').innerHTML = parts.length ? '<span style="color:var(--mut)">Filtered:</span> '+parts.join(' ') : '';
}
function escapeHtml(s){return String(s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m]));}
async function toggle(id, done){
// optimistic
if(done) STATE.followed[id]={ts:new Date().toISOString()}; else delete STATE.followed[id];
recount(); renderTabs(); render();
await fetch('/api/toggle',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id,done})});
}
function recount(){
STATE.totalFollowed = Object.keys(STATE.followed).length;
const today = new Date().toLocaleDateString('en-CA');
STATE.todayCount = Object.values(STATE.followed)
.filter(f=>f.ts && new Date(f.ts).toLocaleDateString('en-CA')===today).length;
}
async function bulk(ids, done){
const ts=new Date().toISOString();
ids.forEach(id=>{ if(done) STATE.followed[id]={ts}; else delete STATE.followed[id]; });
recount(); renderTabs(); render();
await fetch('/api/bulk',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({ids,done})});
}
function renderSearches(){
const s = STATE.searches||{};
const chip = a => (a||[]).map(([t,u])=>`<a target="_blank" rel="noopener noreferrer" href="${u}">${t}</a>`).join('');
$('#searches').innerHTML = `
<h2 class="sec">Find more — designers & architects</h2>
<div class="chips">${chip(s.designers)}</div>
<h2 class="sec">Find more — hospitality reps</h2>
<div class="chips">${chip(s.hospitality)}</div>
<p class="listnote">Open a search → open each person's profile → Follow → paste them into your queue next time you refresh leads.</p>`;
}
function renderFoot(){
$('#foot').innerHTML = `Progress saved to <code>data/progress.json</code> — survives reload & reboot ·
reads live from <code>~/li-*.json</code> · last server write ${STATE.updatedAt?fmtWhen(STATE.updatedAt):'—'} ·
<b>$0 (local)</b>. LinkedIn = manual + tooling only (TOS).`;
}
// controls
$('#q').oninput = e => { prefs.q=e.target.value; writeURL(false); render(); };
$('#sort').onchange = e => { prefs.sort=e.target.value; localStorage.setItem('lfh.sort',prefs.sort); writeURL(false); render(); };
$('#hideDone').onchange = e => { prefs.hideDone=e.target.checked; localStorage.setItem('lfh.hideDone',prefs.hideDone?'1':'0'); writeURL(false); render(); };
// delegated drill-click: modified-click opens in a new tab; plain click filters in place + pushes URL
document.addEventListener('click', e => {
const a = e.target.closest('a.drill, .activefilters a[data-clear]');
if(!a) return;
if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button===1) return; // let href open a new tab
e.preventDefault();
const clear = a.getAttribute('data-clear');
if(clear){
if(clear==='all'){ FIL.status=''; FIL.type=''; prefs.q=''; $('#q').value=''; }
else if(clear==='q'){ prefs.q=''; $('#q').value=''; }
else FIL[clear]='';
} else {
const field = a.getAttribute('data-filter');
const v = decodeURIComponent(a.getAttribute('data-v')||'');
if(field in FIL) FIL[field] = (FIL[field]===v ? '' : v); // toggle
}
writeURL(true); render();
});
// back/forward restores the exact filtered set
addEventListener('popstate', () => { readURL(); applyPrefs(); renderTabs(); render(); });
$('#density').oninput = e => { prefs.density=+e.target.value; localStorage.setItem('lfh.density',prefs.density);
document.documentElement.style.setProperty('--cols',prefs.density); };
$('#target').onchange = async e => {
const v=Math.max(1,Math.min(200,+e.target.value||20)); STATE.dailyTarget=v; updateCadence();
await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dailyTarget:v})});
};
$('#markVisible').onclick = () => { if(confirm('Mark every card currently shown as followed?')) bulk(visibleItems().map(i=>i.id), true); };
$('#clearVisible').onclick = () => { if(confirm('Unmark every card currently shown?')) bulk(visibleItems().map(i=>i.id), false); };
// --- refresh leads ---
$('#refreshBtn').onclick = () => $('#refreshPanel').classList.toggle('open');
if(location.hash === '#refresh') $('#refreshPanel').classList.add('open');
$('#reloadDisk').onclick = async () => {
const before = totalItems();
await load();
const diff = totalItems() - before;
$('#rpResult').textContent = diff > 0 ? `Reloaded — +${diff} new` : 'Reloaded — no change';
$('#rpResult').style.color = diff > 0 ? 'var(--good)' : 'var(--mut)';
};
$('#addLeads').onclick = async () => {
const text = $('#pasteBox').value.trim();
if(!text){ $('#rpResult').textContent='Paste some LinkedIn /in/ links first.'; $('#rpResult').style.color='var(--bad)'; return; }
$('#addLeads').disabled = true;
const r = await fetch('/api/add-leads',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})});
const j = await r.json();
$('#addLeads').disabled = false;
$('#rpResult').style.color = j.added ? 'var(--good)' : 'var(--mut)';
$('#rpResult').textContent = j.added
? `Added ${j.added} → "Added by me" tab` + (j.added>j.names.length?'':` (${j.names.join(', ')})`)
: 'Nothing new — those links are dups or not /in/ profiles.';
if(j.added){ $('#pasteBox').value=''; activeTab='manual'; localStorage.setItem('lfh.tab','manual'); await load(); }
};
load();
</script>
</body>
</html>