Files
palette/internal/web/static/table.js
T
fen b840bffeba
CI / test (pull_request) Successful in 44s
CI / docker (pull_request) Skipped
#238: drop hardcoded page 2 from pagination window
2026-09-10 17:29:28 -05:00

186 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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' || k === 'type') 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) ||
(it.custom_slug || '').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=' + (opts.fetchLimit || 100) + '&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.classList.remove('hidden');
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
} else {
empty.classList.add('hidden');
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) {
// Pagination operates over the FILTERED set (#133): never hide the bar.
const filtPages = Math.max(1, Math.ceil(items.length / opts.perPage));
if (state.page > filtPages) state.page = filtPages;
const fOff = (state.page - 1) * opts.perPage;
const view = items.slice(fOff, fOff + opts.perPage);
rows.innerHTML = view.length ? view.map(opts.rowHtml).join('') : '';
if (!view.length) {
empty.classList.remove('hidden');
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
}
showing.textContent = `Showing ${view.length === 0 ? 0 : fOff+1}${fOff+view.length} of ${items.length.toLocaleString()} ${filtered ? 'matches' : 'sorted'} · page ${state.page} of ${filtPages}`;
const btns = [];
const add = (label, target, o={}) => btns.push(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', state.page-1, {dis: state.page===1});
const win = new Set([1, state.page-1, state.page, state.page+1, filtPages]);
let last = 0;
for (let i = 1; i <= filtPages; i++) {
if (win.has(i)) {
if (last && i - last > 1) btns.push('<span class="dim">…</span>');
add(String(i), i, {on: i===state.page});
last = i;
}
}
add('', state.page+1, {dis: state.page===filtPages});
pager.innerHTML = btns.join('');
} 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(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', state.page-1, {dis: state.page===1});
const win = new Set([1, 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('<span class="dim">…</span>');
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; }
state.page = 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 };
})();