Files
palette/internal/web/static/table.js
T
fen 0008b8ce0c
CI / test (pull_request) Successful in 43s
CI / docker (pull_request) Skipped
#235: rename Language column to Type; attachment pastes show file extension
- history/mine table column 'Language' -> 'Type' (data-sort key 'type')
- /api/public and /api/mine rows gain a 'type' field: file extension for
  attachment pastes, stored language otherwise (default 'text')
- paste page stats label 'Language' -> 'Type'; summary line uses the same
  label; get-paste JSON gains 'type'
- store list queries LEFT JOIN attachments to expose the filename
- table.js sorting accepts the 'type' key
2026-09-10 17:13:37 -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, 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 };
})();