186 lines
7.9 KiB
JavaScript
186 lines
7.9 KiB
JavaScript
// 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) ||
|
||
(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.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) {
|
||
// 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.style.display = 'block';
|
||
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, 2, 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, 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('<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 };
|
||
})();
|