History: sortable column headers with client-side sort (#42)
This commit is contained in:
@@ -299,3 +299,10 @@ td a.slug:hover { color: var(--accent); }
|
|||||||
td .url-link { font-size: 19.8px; }
|
td .url-link { font-size: 19.8px; }
|
||||||
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
|
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
|
||||||
td .id-link:hover { color: var(--accent); }
|
td .id-link:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* sortable column headers (#42) */
|
||||||
|
th.sortable { cursor: pointer; user-select: none; }
|
||||||
|
th.sortable:hover { color: var(--fg); }
|
||||||
|
.sort-ind { display: inline-block; width: 0; height: 0; margin-left: 6px; vertical-align: middle; border-left: 5px solid transparent; border-right: 5px solid transparent; }
|
||||||
|
th.sorted.asc .sort-ind { border-bottom: 6px solid var(--accent); }
|
||||||
|
th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
|
||||||
|
|||||||
@@ -9,7 +9,15 @@
|
|||||||
<div class="float">
|
<div class="float">
|
||||||
<table>
|
<table>
|
||||||
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:96px"><col style="width:140px"><col style="width:190px"><col style="width:100px"></colgroup>
|
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:96px"><col style="width:140px"><col style="width:190px"><col style="width:100px"></colgroup>
|
||||||
<thead><tr><th>Paste</th><th>Language</th><th>Size</th><th>Views</th><th>Created</th><th>URL</th><th>ID</th></tr></thead>
|
<thead><tr>
|
||||||
|
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="language" class="sortable">Language<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="size" class="sortable">Size<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="view_count" class="sortable">Views<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="created_at" class="sortable">Created<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="custom_slug" class="sortable">URL<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="id" class="sortable">ID<span class="sort-ind"></span></th>
|
||||||
|
</tr></thead>
|
||||||
<tbody id="rows"></tbody>
|
<tbody id="rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div>
|
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div>
|
||||||
@@ -35,6 +43,44 @@ function ago(ts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let filter = '';
|
let filter = '';
|
||||||
|
let sortKey = null, sortDir = 1;
|
||||||
|
|
||||||
|
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 sortItems(items) {
|
||||||
|
if (!sortKey) return items;
|
||||||
|
const k = sortKey;
|
||||||
|
return items.slice().sort((a, b) => {
|
||||||
|
let va = sortVal(a, k), vb = sortVal(b, k);
|
||||||
|
const na = va == null, nb = vb == null;
|
||||||
|
if (na && nb) return 0;
|
||||||
|
if (na) return 1; // nulls always last
|
||||||
|
if (nb) return -1;
|
||||||
|
if (va < vb) return -1 * sortDir;
|
||||||
|
if (va > vb) return 1 * sortDir;
|
||||||
|
return (a.created_at || 0) < (b.created_at || 0) ? 1 : -1; // stable tiebreak: newest first
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderSortIndicators() {
|
||||||
|
document.querySelectorAll('th.sortable').forEach(th => {
|
||||||
|
th.classList.toggle('sorted', th.dataset.sort === sortKey);
|
||||||
|
th.classList.toggle('asc', th.dataset.sort === sortKey && sortDir === 1);
|
||||||
|
th.classList.toggle('desc', th.dataset.sort === sortKey && sortDir === -1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelector('thead').addEventListener('click', e => {
|
||||||
|
const th = e.target.closest('th.sortable');
|
||||||
|
if (!th) return;
|
||||||
|
const k = th.dataset.sort;
|
||||||
|
if (sortKey === k) { sortDir = -sortDir; } else { sortKey = k; sortDir = 1; }
|
||||||
|
renderSortIndicators();
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
function matchesFilter(it) {
|
function matchesFilter(it) {
|
||||||
if (!filter) return true;
|
if (!filter) return true;
|
||||||
@@ -48,14 +94,15 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const filtered = filter.length > 0;
|
const filtered = filter.length > 0;
|
||||||
const off = (page - 1) * PER;
|
const off = (page - 1) * PER;
|
||||||
const url = filtered
|
const url = (filtered || sortKey)
|
||||||
? '/api/public?limit=200&offset=0'
|
? '/api/public?limit=500&offset=0'
|
||||||
: '/api/public?limit=' + PER + '&offset=' + off;
|
: '/api/public?limit=' + PER + '&offset=' + off;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
total = data.total;
|
total = data.total;
|
||||||
|
|
||||||
const items = filtered ? data.items.filter(matchesFilter) : data.items;
|
let items = filtered ? data.items.filter(matchesFilter) : data.items;
|
||||||
|
items = sortItems(items);
|
||||||
|
|
||||||
$('count').textContent = filtered
|
$('count').textContent = filtered
|
||||||
? items.length.toLocaleString() + ' matches (of ' + total.toLocaleString() + ' total)'
|
? items.length.toLocaleString() + ' matches (of ' + total.toLocaleString() + ' total)'
|
||||||
@@ -78,8 +125,10 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pages = Math.max(1, Math.ceil(total / PER));
|
const pages = Math.max(1, Math.ceil(total / PER));
|
||||||
if (filtered) {
|
if (filtered || sortKey) {
|
||||||
$('showing').textContent = 'Showing ' + items.length.toLocaleString() + ' matches for "' + filter + '"';
|
$('showing').textContent = sortKey
|
||||||
|
? 'Sorted by ' + sortKey + ' (' + (sortDir === 1 ? 'ascending' : 'descending') + ') · ' + items.length.toLocaleString() + ' of ' + total.toLocaleString()
|
||||||
|
: 'Showing ' + items.length.toLocaleString() + ' matches for "' + filter + '"';
|
||||||
$('pg').innerHTML = '';
|
$('pg').innerHTML = '';
|
||||||
} else {
|
} else {
|
||||||
$('showing').textContent = total === 0 ? 'Nothing here yet' :
|
$('showing').textContent = total === 0 ? 'Nothing here yet' :
|
||||||
@@ -100,6 +149,7 @@ async function load() {
|
|||||||
add('›', page+1, {dis: page===pages});
|
add('›', page+1, {dis: page===pages});
|
||||||
$('pg').innerHTML = btns.join('');
|
$('pg').innerHTML = btns.join('');
|
||||||
}
|
}
|
||||||
|
renderSortIndicators();
|
||||||
} finally {
|
} finally {
|
||||||
spinner.style.visibility = 'hidden';
|
spinner.style.visibility = 'hidden';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user