← 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/&#10;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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 &amp; 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>