From 2e1ce508fa513119048836fb4ebd7f70c71e295b Mon Sep 17 00:00:00 2001 From: poslop Date: Tue, 8 Sep 2026 22:39:41 -0500 Subject: [PATCH] Saved page: live search + sortable columns via shared table module (#57) - new web/static/table.js (PaletteTable): shared live search, client-side sort with indicators, pagination, row rendering + click-through - history.html and mine.html both consume it; data endpoint, columns, empty-state text and row extras (delete buttons) are page-supplied - verified in-browser: search, sort, delete on /mine; sort, pager on /history Closes #57 --- web/static/table.js | 162 +++++++++++++++++++++++++++++++++++ web/templates/history.html | 171 +++++-------------------------------- web/templates/mine.html | 96 +++++++++------------ 3 files changed, 221 insertions(+), 208 deletions(-) create mode 100644 web/static/table.js diff --git a/web/static/table.js b/web/static/table.js new file mode 100644 index 0000000..bc3fc1f --- /dev/null +++ b/web/static/table.js @@ -0,0 +1,162 @@ +// 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 }; +})(); diff --git a/web/templates/history.html b/web/templates/history.html index 4bcd4ff..5d1e71c 100644 --- a/web/templates/history.html +++ b/web/templates/history.html @@ -27,159 +27,26 @@
+ {{template "foot" .}} diff --git a/web/templates/mine.html b/web/templates/mine.html index 73cc647..e9a6389 100644 --- a/web/templates/mine.html +++ b/web/templates/mine.html @@ -5,34 +5,25 @@

Saved pastes

+
- + - - - - - - + + + + + +
PasteLanguageSizeCreatedURLIDPasteLanguageSizeCreatedURLID
+ {{template "foot" .}}