// Shared table logic for history (/api/public) and saved (/api/mine) pages (#57). // Provides: live search, client-side sort with indicators, row rendering via // a page-supplied rowHtml(), pagination state, and row click-through. const PaletteTable = (() => { const $ = id => document.getElementById(id); const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }; const fmtSize = n => { if (n == null) return 'none'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; }; const ago = ts => { const s = Math.floor(Date.now()/1000) - ts; if (s < 60) return s + 's ago'; if (s < 3600) return Math.floor(s/60) + 'm ago'; if (s < 86400) return Math.floor(s/3600) + 'h ago'; return Math.floor(s/86400) + 'd ago'; }; const sortVal = (it, k) => { let v = it[k]; if (k === 'title' || k === 'custom_slug') v = (v == null || v === '') ? null : String(v).toLowerCase(); if (k === 'language') v = (v == null || v === '') ? 'text' : String(v).toLowerCase(); if (k === 'size' || k === 'view_count' || k === 'created_at') return v == null ? -1 : v; return v == null ? null : v; }; function init(opts) { // opts: {endpoint, perPage, hasPager, rowHtml(it), emptyFiltered, emptyAll} const state = { filter: '', sortKey: null, sortDir: 1, page: 1, total: 0 }; let timer = null; function sortItems(items) { if (!state.sortKey) return items; const k = state.sortKey, dir = state.sortDir; return items.slice().sort((a, b) => { const va = sortVal(a, k), vb = sortVal(b, k); const na = va == null, nb = vb == null; if (na && nb) return 0; if (na) return 1; if (nb) return -1; if (va < vb) return -1 * dir; if (va > vb) return 1 * dir; return (a.created_at || 0) < (b.created_at || 0) ? 1 : -1; }); } function matches(it) { if (!state.filter) return true; const f = state.filter.toLowerCase(); return (it.title || '').toLowerCase().includes(f) || (it.id || '').toLowerCase().includes(f); } function renderSortIndicators() { document.querySelectorAll('th.sortable').forEach(th => { th.classList.toggle('sorted', th.dataset.sort === state.sortKey); th.classList.toggle('asc', th.dataset.sort === state.sortKey && state.sortDir === 1); th.classList.toggle('desc', th.dataset.sort === state.sortKey && state.sortDir === -1); }); } async function load() { const spinner = $('search-spinner'); if (spinner) spinner.style.visibility = 'visible'; try { const filtered = state.filter.length > 0; const off = (state.page - 1) * opts.perPage; const url = (filtered || state.sortKey) ? opts.endpoint + '?limit=500&offset=0' : opts.endpoint + '?limit=' + opts.perPage + '&offset=' + off; const res = await fetch(url); const data = await res.json(); state.total = data.total; let items = filtered ? data.items.filter(matches) : data.items; items = sortItems(items); const count = $('count'); if (count) count.textContent = filtered ? items.length.toLocaleString() + ' matches (of ' + state.total.toLocaleString() + ' total)' : state.total.toLocaleString() + ' total'; const rows = $('rows'), empty = $('empty'); if (!items.length) { rows.innerHTML = ''; empty.style.display = 'block'; empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll; } else { empty.style.display = 'none'; rows.innerHTML = items.map(opts.rowHtml).join(''); } const pager = $('pg'), showing = $('showing'); if (opts.hasPager && pager && showing) { const pages = Math.max(1, Math.ceil(state.total / opts.perPage)); if (filtered || state.sortKey) { showing.textContent = state.sortKey ? 'Sorted by ' + state.sortKey + ' (' + (state.sortDir === 1 ? 'ascending' : 'descending') + ') · ' + items.length.toLocaleString() + ' of ' + state.total.toLocaleString() : 'Showing ' + items.length.toLocaleString() + ' matches for "' + state.filter + '"'; pager.innerHTML = ''; } else { showing.textContent = state.total === 0 ? 'Nothing here yet' : `Showing ${off+1}–${Math.min(off+opts.perPage, state.total)} of ${state.total.toLocaleString()} · page ${state.page} of ${pages}`; const btns = []; const add = (label, target, o={}) => btns.push(``); add('‹', state.page-1, {dis: state.page===1}); const win = new Set([1, 2, state.page-1, state.page, state.page+1, pages]); let last = 0; for (let i = 1; i <= pages; i++) { if (win.has(i)) { if (last && i - last > 1) btns.push(''); add(String(i), i, {on: i===state.page}); last = i; } } add('›', state.page+1, {dis: state.page===pages}); pager.innerHTML = btns.join(''); } } else if (showing) { showing.textContent = ''; } renderSortIndicators(); } finally { if (spinner) spinner.style.visibility = 'hidden'; } } document.querySelector('thead').addEventListener('click', e => { const th = e.target.closest('th.sortable'); if (!th) return; const k = th.dataset.sort; if (state.sortKey === k) { state.sortDir = -state.sortDir; } else { state.sortKey = k; state.sortDir = 1; } renderSortIndicators(); load(); }); const rows = $('rows'); if (rows) rows.addEventListener('click', e => { const tr = e.target.closest('tr.row[data-href]'); if (!tr || e.target.closest('a') || e.target.closest('button')) return; window.location.href = tr.dataset.href; }); const pg = $('pg'); if (pg) pg.addEventListener('click', e => { const b = e.target.closest('button[data-p]'); if (!b || b.disabled) return; state.page = parseInt(b.dataset.p); load(); window.scrollTo(0, 0); }); const filter = $('filter'); if (filter) filter.addEventListener('input', e => { clearTimeout(timer); timer = setTimeout(() => { state.filter = e.target.value.trim(); state.page = 1; load(); }, 200); }); return { load, state, esc, fmtSize, ago }; } return { init, esc, fmtSize, ago }; })();