diff --git a/public/assets/my.js b/public/assets/my.js
index 7bd0373..69bb540 100644
--- a/public/assets/my.js
+++ b/public/assets/my.js
@@ -225,6 +225,43 @@
}
// ── overview v3 charts: hand-rolled SVG/CSS, real data only ──
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+
+ // Paged lists (Marty, 2026-09-19): 25 rows, then a clearly marked next page, on every list
+ // that grows with the membership. Works on what is already rendered: rows past the page are
+ // hidden and a control bar goes under the list, so a row's buttons and bindings never change.
+ // key remembers the page across re-renders of the same list
+ // host the element the bar is appended to (a wrapper, never a
)
+ // rowSel the rows, queried inside host
+ // opts per (default 25); tail: a selector for a drawer that follows its row and pages
+ // with it; focus: a row predicate, opens on whichever page holds that row
+ const PAGE_SIZE = 25;
+ const PAGE_AT = {};
+ function pageList(key, host, rowSel, opts) {
+ if (!host) return; opts = opts || {}; const per = opts.per || PAGE_SIZE;
+ const rows = [...host.querySelectorAll(rowSel)];
+ const old = host.querySelector(':scope > .pgr'); if (old) old.remove();
+ if (rows.length <= per) { rows.forEach(r => { r.hidden = false; }); return; }
+ const pages = Math.ceil(rows.length / per);
+ let p = PAGE_AT[key] || 1;
+ if (opts.focus) { const i = rows.findIndex(opts.focus); if (i >= 0) p = Math.floor(i / per) + 1; }
+ p = Math.min(Math.max(1, p), pages); PAGE_AT[key] = p;
+ const start = (p - 1) * per;
+ rows.forEach((r, i) => {
+ const on = i >= start && i < start + per; r.hidden = !on;
+ if (opts.tail) { const t = r.nextElementSibling; if (t && t.matches(opts.tail) && !on && !t.hidden) { t.hidden = true; const b = r.querySelector('[aria-expanded]'); if (b) { b.setAttribute('aria-expanded', 'false'); b.textContent = 'Activity \u25BE'; } } }
+ });
+ const bar = document.createElement('div'); bar.className = 'pgr';
+ bar.innerHTML = 'Showing ' + (start + 1) + '\u2013' + Math.min(start + per, rows.length) + ' of ' + rows.length + ''
+ + ''
+ + 'Page ' + p + ' of ' + pages + ''
+ + '';
+ bar.querySelectorAll('[data-go]').forEach(b => b.addEventListener('click', () => {
+ PAGE_AT[key] = Number(b.dataset.go);
+ pageList(key, host, rowSel, Object.assign({}, opts, { focus: null }));
+ host.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }));
+ host.appendChild(bar);
+ }
const CH = { mint: '#43e8c3', cyan: '#54ccff', violet: '#9d7dff', amber: '#ffb238', track: 'rgba(139,166,156,.18)' };
function bars(el, xel, data) { // data: [{v,label,tip,alt}]
if (!el) return;
@@ -564,6 +601,7 @@
+ '
';
el.appendChild(box);
+ pageList('credits', box, 'tbody tr');
} catch (e) {}
}
async function loadCampaigns() {
@@ -883,6 +922,7 @@
: ' ')
+ '').join('') + '';
el.appendChild(tbl);
+ pageList('camps', tbl, 'tbody tr');
const note = document.createElement('p'); note.className = 'muted small';
note.textContent = 'Credits you put into a campaign are set aside the moment you start it, so your Available number drops right away and the campaign spends from its own budget. Featured links and verified-visit packs are paid in full up front; a featured run is sold by the day, so use Extend run to keep it going. Whatever a campaign has not spent comes back to Available when it ends.';
el.appendChild(note);
@@ -1483,6 +1523,7 @@
$('pipeDue').innerHTML = r.due.length ? '
Nothing due. Set a follow-up date on any card and it shows up here.
';
$('pipeSummary').textContent = r.counts.total ? r.counts.total + ' people on your board' + (r.counts.stalled ? ', ' + r.counts.stalled + ' stalled' : '') + '. Cards move on their own when something happens on the ledger; open one to add a note, a follow-up date or a tag.' : 'Nobody on your board yet. Add prospects on My line, and everyone who joins through your link appears here on their own.';
$('pipeBoard').innerHTML = r.columns.map(col => '