[object Object]

← back to Li Followhub

auto-save: 2026-07-12T08:47:12 (3 files) — public/index.html server.js data/

295e1559fb8f26f91078321a60660425f8ce50a0 · 2026-07-12 08:47:15 -0700 · Steve Abrams

Files touched

Diff

commit 295e1559fb8f26f91078321a60660425f8ce50a0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 08:47:15 2026 -0700

    auto-save: 2026-07-12T08:47:12 (3 files) — public/index.html server.js data/
---
 data/manual-leads.json |  1 +
 public/index.html      | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js              | 40 +++++++++++++++++++++++++++++++++++++
 3 files changed, 95 insertions(+)

diff --git a/data/manual-leads.json b/data/manual-leads.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/data/manual-leads.json
@@ -0,0 +1 @@
+[]
diff --git a/public/index.html b/public/index.html
index 84c71a0..f2c8bc9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -73,6 +73,20 @@
   .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>
@@ -114,7 +128,23 @@
     <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>
 
@@ -257,6 +287,30 @@ $('#target').onchange = async e => {
 $('#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>
diff --git a/server.js b/server.js
index 49049cc..40ec43b 100644
--- a/server.js
+++ b/server.js
@@ -22,6 +22,7 @@ const PORT = process.env.PORT || 9819;
 const HOST = '127.0.0.1';
 const DATA_DIR = path.join(__dirname, 'data');
 const PROGRESS_FILE = path.join(DATA_DIR, 'progress.json');
+const MANUAL_FILE = path.join(DATA_DIR, 'manual-leads.json');
 
 // ---- helpers ---------------------------------------------------------------
 function readJSON(file, fallback) {
@@ -52,6 +53,7 @@ function buildLists() {
   const reps = readJSON(`${HOME}/li-hospitality-reps.json`, []);
   const designerQueue = readJSON(`${HOME}/li-designer-queue.json`, []);
   const designersFollowed = readJSON(`${HOME}/li-designers-followed.json`, []);
+  const manualLeads = readJSON(MANUAL_FILE, []); // hub-owned, grown via the Refresh/Add box
 
   // Set of profiles the earlier collector recorded as follow-events (soft hint only)
   const softFollowed = new Set(
@@ -109,6 +111,16 @@ function buildLists() {
       note: 'Connecting auto-follows, so these are largely done already. Confirm-only.',
       items: warmLeads.map(l => ({ ...leadItem(l, 'warm'), softDone: true })),
     },
+    {
+      key: 'manual',
+      label: 'Added by me',
+      note: 'Leads you pasted in from a normal Sales Nav / search session. Grow this instead of auto-scraping.',
+      items: (Array.isArray(manualLeads) ? manualLeads : []).map(m => ({
+        id: baseProfile(m.url), name: m.name || baseProfile(m.url).replace(/^https?:\/\/(www\.)?linkedin\.com\/in\//, '').replace(/\/$/, ''),
+        url: baseProfile(m.url), resolved: true, kind: 'profile',
+        softDone: softFollowed.has(baseProfile(m.url)),
+      })),
+    },
   ];
 
   const searches = {
@@ -185,6 +197,34 @@ const server = http.createServer(async (req, res) => {
     return sendJSON(res, 200, { ok: true, count: ids.length, totalFollowed: Object.keys(p.followed).length });
   }
 
+  if (url.pathname === '/api/add-leads' && req.method === 'POST') {
+    const body = await readBody(req);
+    const text = String(body.text || '');
+    // known ids already in the hub (all source lists) so we never add a dup
+    const { lists } = buildLists();
+    const known = new Set();
+    lists.forEach(l => l.items.forEach(i => { known.add(i.id); if (i.url) known.add(baseProfile(i.url)); }));
+    // parse each line: find a linkedin /in/ URL; name = text before it (if any)
+    const added = [];
+    const seen = new Set();
+    text.split(/[\r\n]+/).forEach(line => {
+      const m = line.match(/https?:\/\/[^\s,]*linkedin\.com\/in\/[^\s,)"']+/i);
+      if (!m) return;
+      const profile = baseProfile(m[0]);
+      if (known.has(profile) || seen.has(profile)) return;
+      seen.add(profile);
+      // name = the chunk before the URL, trimmed of separators
+      let name = line.slice(0, m.index).replace(/[\s,;|\-–—]+$/, '').replace(/^[\s,;|]+/, '').trim();
+      added.push({ name: name || null, url: profile, list: 'manual', addedAt: new Date().toISOString() });
+    });
+    const existing = readJSON(MANUAL_FILE, []);
+    const merged = (Array.isArray(existing) ? existing : []).concat(added);
+    fs.mkdirSync(DATA_DIR, { recursive: true });
+    fs.writeFileSync(MANUAL_FILE, JSON.stringify(merged, null, 2));
+    return sendJSON(res, 200, { ok: true, added: added.length, totalManual: merged.length,
+      names: added.map(a => a.name || a.url).slice(0, 8) });
+  }
+
   if (url.pathname === '/api/config' && req.method === 'POST') {
     const body = await readBody(req);
     const p = loadProgress();

← 2480865 auto-save: 2026-07-12T08:17:05 (1 files) — preview.png  ·  back to Li Followhub  ·  (newest)