← back to Li Followhub
public/index.html
318 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)}
.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="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: '',
};
async function load(){
const r = await fetch('/api/data'); STATE = await r.json();
$('#target').value = STATE.dailyTarget;
applyPrefs(); renderTabs(); render(); renderSearches(); renderFoot();
}
function applyPrefs(){
$('#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); 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]);
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 : '';
const items = visibleItems();
updateCadence();
if(!items.length){ $('#grid').innerHTML = `<div class="empty">Nothing here — try clearing search / “Hide followed”.</div>`; return; }
$('#grid').innerHTML = items.map(i=>{
const f = STATE.followed[i.id];
const sn = i.resolved===false;
return `<div class="card ${f?'done':''}" data-id="${encodeURIComponent(i.id)}">
${sn?'<span class="tag sn">Sales Nav</span>':(i.softDone&&!f?'<span class="tag soft">likely already</span>':'')}
<div class="nm">${escapeHtml(i.name||'(profile)')}</div>
${f?`<div class="when">🕓 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').forEach(c=>{
const id = decodeURIComponent(c.dataset.id);
c.querySelector('input').onchange = e => toggle(id, e.target.checked);
});
}
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; render(); };
$('#sort').onchange = e => { prefs.sort=e.target.value; localStorage.setItem('lfh.sort',prefs.sort); render(); };
$('#hideDone').onchange = e => { prefs.hideDone=e.target.checked; localStorage.setItem('lfh.hideDone',prefs.hideDone?'1':'0'); 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>