← back to Rentv Adintel
public/js/table.js
514 lines
/* table.js — Adjustable-columns engine for RENTV Advertiser Intelligence.
Mirrors the broker-grid.html pattern (Steve rule 2026-07-31):
resize + drag-reorder + toggle-visibility + persist localStorage + Reset.
Client-side search-all (space=AND) + multi-sort + density slider + expandable rows.
Reusable across all list pages: advertisers, contacts, ads, sources.
Usage:
<script src="/js/table.js"></script>
<script>
const T = new ATable({
tableId: 'main-tbl',
tbodyId: 'main-tbody',
theadId: 'main-thead',
countId: 'row-count',
searchId: 'tbl-search',
sortSelId: 'sort-sel',
fieldsId: 'field-toggles',
storageKey: 'advTable', // localStorage prefix
cols: [ { k:'company', l:'Company', t:'s', g:'Identity', def:1 }, ... ],
data: [], // initial dataset
onRowClick: (row) => {},
renderCell: (row, col) => 'string or null for default',
});
T.setData(rows);
</script>
*/
'use strict';
(function (global) {
// ── Helpers ────────────────────────────────────────────────────────────────
const esc = (s) => (s == null ? '' : String(s))
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const fmtDate = (s) => {
if (!s) return '—';
try {
const d = new Date(s);
if (isNaN(d)) return String(s);
return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
} catch (e) { return String(s); }
};
const fmtDateOnly = (s) => {
if (!s) return '—';
try {
const d = new Date(s);
if (isNaN(d)) return String(s);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch (e) { return String(s); }
};
const tel = (v) => v ? `<a href="tel:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
const mail = (v) => v ? `<a href="mailto:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
const web = (v) => {
if (!v) return '<span class="miss">—</span>';
const h = /^https?:/.test(v) ? v : 'https://' + v;
let label = v;
try { label = new URL(h).hostname.replace(/^www\./, ''); } catch (_) { label = v; }
return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer">${esc(label)} ↗</a>`;
};
const li = (v) => v ? `<a href="${esc(v)}" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a>` : '<span class="miss">—</span>';
const STATUS_BADGE_CLASS = {
VERIFIED_ADVERTISER: 'badge-verified-advertiser',
VERIFIED_CONFERENCE_SPONSOR: 'badge-verified-sponsor',
VERIFIED_EXHIBITOR: 'badge-verified-exhibitor',
VERIFIED_MEDIA_PARTNER: 'badge-verified-media',
VERIFIED_CONTENT_PARTNER: 'badge-content-partner',
SPEAKER_OR_PANELIST_ONLY: 'badge-panelist',
PAST_ADVERTISER: 'badge-past-advertiser',
LIKELY_PROSPECT: 'badge-likely-prospect',
RESEARCH_NEEDED: 'badge-research-needed',
DISQUALIFIED: 'badge-disqualified',
};
const STATUS_LABELS = {
VERIFIED_ADVERTISER: 'Verified advertiser',
VERIFIED_CONFERENCE_SPONSOR: 'Verified conf. sponsor',
VERIFIED_EXHIBITOR: 'Verified exhibitor',
VERIFIED_MEDIA_PARTNER: 'Verified media partner',
VERIFIED_CONTENT_PARTNER: 'Content partner',
SPEAKER_OR_PANELIST_ONLY: 'Speaker / panelist',
PAST_ADVERTISER: 'Past advertiser',
LIKELY_PROSPECT: 'Likely prospect',
RESEARCH_NEEDED: 'Research needed',
DISQUALIFIED: 'Disqualified',
};
function statusBadge(status) {
const cls = STATUS_BADGE_CLASS[status] || 'badge-research-needed';
const lbl = STATUS_LABELS[status] || status || '—';
return `<span class="badge ${cls}">${esc(lbl)}</span>`;
}
function scoreBadge(score) {
if (score == null) return '<span class="miss">—</span>';
const n = Number(score);
const cls = n >= 70 ? 'score-high' : n >= 40 ? 'score-mid' : 'score-low';
return `<span class="score-badge ${cls}">${n}</span>`;
}
// Default cell renderer — custom renderCell overrides first.
function defaultCell(row, col) {
const v = row[col.k];
switch (col.t) {
case 'status': return statusBadge(v);
case 'score': return scoreBadge(v);
case 'tel': return tel(v);
case 'mail': return mail(v);
case 'web': return web(v);
case 'li': return li(v);
case 'dt': return `<span title="${esc(v ? new Date(v).toISOString() : '')}">${fmtDate(v)}</span>`;
case 'date': return fmtDateOnly(v);
case 'n': return `<span class="num">${v == null ? '—' : Number(v).toLocaleString()}</span>`;
case 'link': return v ? `<a href="${esc(v)}">${esc(col.linkLabel || v)}</a>` : '<span class="miss">—</span>';
default: return esc(v == null ? '—' : v);
}
}
// ── ATable class ───────────────────────────────────────────────────────────
class ATable {
constructor(opts) {
this.tableId = opts.tableId;
this.theadId = opts.theadId;
this.tbodyId = opts.tbodyId;
this.countId = opts.countId;
this.searchId = opts.searchId;
this.sortSelId = opts.sortSelId;
this.fieldsId = opts.fieldsId;
this.storageKey = opts.storageKey || 'aTable';
this.cols = opts.cols || [];
this.data = opts.data || [];
this._renderCell = opts.renderCell || null;
this.onRowClick = opts.onRowClick || null;
this.expandedIds = new Set();
// Sort state
this.sortKey = opts.defaultSortKey || (this.cols[0] && this.cols[0].k) || '';
this.sortDir = opts.defaultSortDir || 1;
// Search
this.q = '';
// Persist column visibility + order
this._loadPrefs();
// Last rendered column signature (for resize invalidation)
this._lastColSig = '';
// Drag state
this._dragKey = null;
this._init();
}
// ── Prefs persistence ────────────────────────────────────────────────────
_loadPrefs() {
try {
this._viscol = JSON.parse(localStorage.getItem(this.storageKey + ':viscol') || '{}');
this._colorder = JSON.parse(localStorage.getItem(this.storageKey + ':colorder') || '[]');
const sk = localStorage.getItem(this.storageKey + ':sortKey');
const sd = localStorage.getItem(this.storageKey + ':sortDir');
if (sk) this.sortKey = sk;
if (sd === '1' || sd === '-1') this.sortDir = +sd;
} catch (_) {
this._viscol = {};
this._colorder = [];
}
}
_saveViscol() { try { localStorage.setItem(this.storageKey + ':viscol', JSON.stringify(this._viscol)); } catch (_) {} }
_saveColorder() { try { localStorage.setItem(this.storageKey + ':colorder', JSON.stringify(this._colorder)); } catch (_) {} }
_saveSort() {
try {
localStorage.setItem(this.storageKey + ':sortKey', this.sortKey);
localStorage.setItem(this.storageKey + ':sortDir', String(this.sortDir));
} catch (_) {}
}
// ── Column helpers ────────────────────────────────────────────────────────
_colVis(k) {
if (k in this._viscol) return !!this._viscol[k];
const col = this.cols.find((c) => c.k === k);
return col ? (col.def !== 0) : false; // def:0 = off by default
}
_syncColOrder() {
const keys = this.cols.map((c) => c.k);
this._colorder = this._colorder.filter((k) => keys.includes(k));
keys.forEach((k) => { if (!this._colorder.includes(k)) this._colorder.push(k); });
}
_orderedCols() {
this._syncColOrder();
return this._colorder.map((k) => this.cols.find((c) => c.k === k)).filter(Boolean);
}
_visCols() { return this._orderedCols().filter((c) => this._colVis(c.k)); }
// ── Init ─────────────────────────────────────────────────────────────────
_init() {
this._bindSearch();
this._bindSort();
this._bindHeader();
}
_el(id) { return id ? document.getElementById(id) : null; }
_bindSearch() {
const el = this._el(this.searchId);
if (!el) return;
// Pre-fill from ?q= URL param
const urlQ = new URLSearchParams(location.search).get('q');
if (urlQ) { el.value = urlQ; this.q = urlQ.trim(); }
el.addEventListener('input', () => { this.q = el.value.trim(); this.render(); });
}
_bindSort() {
const sel = this._el(this.sortSelId);
if (!sel) return;
this._buildSortSel(sel);
sel.value = this.sortKey;
sel.addEventListener('change', () => {
this.sortKey = sel.value;
const c = this.cols.find((x) => x.k === this.sortKey);
this.sortDir = (c && (c.t === 'n' || c.t === 'dt' || c.t === 'date')) ? -1 : 1;
this._saveSort();
this.render();
});
}
_buildSortSel(sel) {
if (!sel) return;
const groups = {};
this.cols.forEach((c) => { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
sel.innerHTML = Object.entries(groups).map(([g, cs]) =>
`<optgroup label="${esc(g)}">` + cs.map((c) => `<option value="${esc(c.k)}">${esc(c.l)}</option>`).join('') + '</optgroup>'
).join('');
if (Array.from(sel.options).some((o) => o.value === this.sortKey)) sel.value = this.sortKey;
}
_bindHeader() {
const thead = this._el(this.theadId);
if (!thead) return;
// Sort click
thead.addEventListener('click', (e) => {
if (document.body.classList.contains('cr-dragging')) return;
const th = e.target.closest('th');
if (!th) return;
const k = th.dataset.k;
if (!k) return;
if (this.sortKey === k) this.sortDir *= -1;
else { this.sortKey = k; this.sortDir = 1; }
this._saveSort();
// Sync external sort select
const sel = this._el(this.sortSelId);
if (sel && Array.from(sel.options).some((o) => o.value === this.sortKey)) sel.value = this.sortKey;
this.render();
});
// Drag-to-reorder
let dragKey = null;
thead.addEventListener('dragstart', (e) => {
if (document.body.classList.contains('cr-dragging')) { e.preventDefault(); return; }
const th = e.target.closest('th');
if (!th) return;
dragKey = th.dataset.k;
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', dragKey); } catch (_) {}
th.classList.add('dragging');
});
thead.addEventListener('dragover', (e) => {
if (!dragKey) return;
const th = e.target.closest('th');
if (!th || th.dataset.k === dragKey) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const r = th.getBoundingClientRect(), after = (e.clientX - r.left) > r.width / 2;
th.classList.toggle('dropR', after); th.classList.toggle('dropL', !after);
});
thead.addEventListener('dragleave', (e) => {
const th = e.target.closest('th'); if (th) { th.classList.remove('dropL', 'dropR'); }
});
thead.addEventListener('drop', (e) => {
if (!dragKey) return;
const th = e.target.closest('th'); if (!th) return;
e.preventDefault();
const tgt = th.dataset.k;
if (tgt && tgt !== dragKey) {
const r = th.getBoundingClientRect(), after = (e.clientX - r.left) > r.width / 2;
this._syncColOrder();
const from = this._colorder.indexOf(dragKey);
if (from >= 0) {
this._colorder.splice(from, 1);
let to = this._colorder.indexOf(tgt);
if (after) to += 1;
this._colorder.splice(to, 0, dragKey);
this._saveColorder();
this._lastColSig = '';
this.render();
}
}
dragKey = null;
});
thead.addEventListener('dragend', () => {
document.querySelectorAll(`#${this.theadId} th.dragging, #${this.theadId} th.dropL, #${this.theadId} th.dropR`)
.forEach((x) => x.classList.remove('dragging', 'dropL', 'dropR'));
dragKey = null;
});
// Expandable rows (tbody event delegation)
const tbody = this._el(this.tbodyId);
if (tbody) {
tbody.addEventListener('click', (e) => {
// Don't open expand on link/button clicks
if (e.target.closest('a') || e.target.closest('button')) return;
const tr = e.target.closest('tr[data-row-id]');
if (!tr) return;
const id = tr.dataset.rowId;
const exp = tr.nextElementSibling;
if (exp && exp.classList.contains('row-expand')) {
exp.remove();
this.expandedIds.delete(id);
} else {
// close others
this.expandedIds.clear();
document.querySelectorAll(`#${this.tbodyId} .row-expand`).forEach((x) => x.remove());
this.expandedIds.add(id);
const row = this.data.find((r) => String(r.id) === id);
if (row && this.onRowClick) {
const expTr = document.createElement('tr');
expTr.className = 'row-expand';
const td = document.createElement('td');
td.colSpan = 999;
td.innerHTML = '<em>Loading…</em>';
expTr.appendChild(td);
tr.after(expTr);
Promise.resolve(this.onRowClick(row, td));
}
}
});
}
}
// ── Field-toggle panel ────────────────────────────────────────────────────
buildFieldToggles() {
const el = this._el(this.fieldsId);
if (!el) return;
const groups = {};
this.cols.forEach((c) => { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
el.innerHTML = Object.entries(groups).map(([g, cs]) =>
`<div class="fgrp">${esc(g)}</div>` +
cs.map((c) => `<label class="ftog"><input type="checkbox" data-ck="${esc(c.k)}" ${this._colVis(c.k) ? 'checked' : ''}><span>${esc(c.l)}</span></label>`).join('')
).join('');
el.addEventListener('change', (e) => {
const cb = e.target.closest('input[data-ck]'); if (!cb) return;
this._viscol[cb.dataset.ck] = cb.checked;
this._saveViscol();
this.render();
});
}
resetColumns() {
this._viscol = {};
this._colorder = [];
this._saveViscol();
this._saveColorder();
this._lastColSig = '';
// Clear column-resize localStorage keys
try {
Object.keys(localStorage).forEach((k) => {
if (k.startsWith('cr:' + location.pathname + ':')) localStorage.removeItem(k);
});
} catch (_) {}
this.buildFieldToggles();
this.render();
}
// ── Data + filter ─────────────────────────────────────────────────────────
setData(rows) {
this.data = rows || [];
this.render();
}
_filtered() {
let rows = this.data.slice();
// search-all-fields, space=AND
if (this.q) {
const terms = this.q.toLowerCase().split(/\s+/).filter(Boolean);
rows = rows.filter((r) => {
const hay = Object.keys(r).map((k) => {
const v = r[k]; return v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
}).join(' ').toLowerCase();
return terms.every((t) => hay.includes(t));
});
}
// sort
const col = this.cols.find((c) => c.k === this.sortKey) || this.cols[0];
if (col) {
rows.sort((a, b) => {
let x = a[col.k], y = b[col.k];
const xE = (x == null || x === ''), yE = (y == null || y === '');
if (xE && yE) return 0; if (xE) return 1; if (yE) return -1;
const xn = !isNaN(+x), yn = !isNaN(+y);
if (xn && yn) { x = +x; y = +y; }
else { x = String(x).toLowerCase(); y = String(y).toLowerCase(); }
if (x < y) return -this.sortDir; if (x > y) return this.sortDir; return 0;
});
}
return rows;
}
// ── Render ────────────────────────────────────────────────────────────────
render() {
const rows = this._filtered();
const cols = this._visCols();
// count
const countEl = this._el(this.countId);
if (countEl) countEl.textContent = `${rows.length.toLocaleString()} of ${this.data.length.toLocaleString()} records`;
// thead
const thead = this._el(this.theadId);
if (thead) {
thead.innerHTML = '<tr>' + cols.map((c) =>
`<th data-k="${esc(c.k)}" draggable="true" class="dragcol${this.sortKey === c.k ? (this.sortDir > 0 ? ' asc' : ' desc') : ''}">${esc(c.l)}</th>`
).join('') + '</tr>';
}
// tbody
const tbody = this._el(this.tbodyId);
if (tbody) {
tbody.innerHTML = rows.map((r) => {
const id = r.id || r.id || '';
return `<tr data-row-id="${esc(String(id))}">` +
cols.map((c) => {
let html = null;
if (this._renderCell) html = this._renderCell(r, c);
if (html == null) html = defaultCell(r, c);
const numCls = (c.t === 'n') ? ' class="num"' : '';
return `<td${numCls}>${html}</td>`;
}).join('') +
'</tr>';
}).join('');
}
// column-sig check for resize invalidation
const sig = cols.map((c) => c.k).join(',');
if (sig !== this._lastColSig) {
this._lastColSig = sig;
// Invalidate ColResize if present
if (window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
}
// sync field toggles visibility
this.buildFieldToggles();
}
}
// ── Density slider helper ────────────────────────────────────────────────────
function initDensitySlider(sliderId, storageKey, minPx, maxPx, defaultVal) {
const sl = document.getElementById(sliderId);
if (!sl) return;
minPx = minPx || 200; maxPx = maxPx || 450;
const stored = parseInt(localStorage.getItem(storageKey + ':density'), 10);
const val = (stored >= 1 && stored <= 10) ? stored : (defaultVal || 5);
sl.value = val;
function apply(v) {
const px = Math.round(maxPx - (Math.max(1, Math.min(10, v)) - 1) * (maxPx - minPx) / 9);
document.documentElement.style.setProperty('--card-min', px + 'px');
}
apply(val);
sl.addEventListener('input', () => { apply(+sl.value); localStorage.setItem(storageKey + ':density', sl.value); });
}
// ── Export helpers ────────────────────────────────────────────────────────────
function exportCSV(rows, cols, filename) {
const lines = [
cols.map((c) => '"' + c.l.replace(/"/g, '""') + '"').join(','),
...rows.map((r) => cols.map((c) => {
const v = r[c.k]; const s = v == null ? '' : String(v);
return '"' + s.replace(/"/g, '""') + '"';
}).join(','))
];
const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename || 'export.csv';
a.click();
URL.revokeObjectURL(a.href);
}
// Expose globally
global.ATable = ATable;
global.initDensitySlider = initDensitySlider;
global.tblExportCSV = exportCSV;
global.statusBadge = statusBadge;
global.scoreBadge = scoreBadge;
global.tblEsc = esc;
global.tblFmtDate = fmtDate;
global.tblFmtDateOnly = fmtDateOnly;
global.tblTel = tel;
global.tblMail = mail;
global.tblWeb = web;
global.tblLi = li;
}(window));