- Move all inline <script> blocks (layout head/theme, topbar dark toggle, foot, paste, new, history, mine, settings, admin, unlock) to external files under internal/web/static/. Page data reaches scripts via data-* attributes (data-paste-id, data-default-dark) instead of template vars. - Replace inline onclick handlers (copy, delete, stats toggle) with addEventListener wiring. - Convert inline style="" attributes to CSS utility classes; swatch colors are now set via CSSOM/DOM APIs instead of innerHTML strings. - script-src/style-src are now plain 'self'; img-src data: stays for the SVG data-URI backgrounds. Verified with headless chromium: zero CSP violations on all pages in dark and light presets, theme swatches, admin lock, tables and paste view render correctly.
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.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 };
|
||
})();
|