Files
palette/web/templates/history.html
T
poslop 76d7ac12e7
CI / test (push) Successful in 21s
CI / docker (push) Skipped
Small fixes: gutter alignment, paste column, sort arrows, copy feedback, stats bar, title slug (#50-#55)
- #50 gutter/code line misalignment: shared --code-lh/--code-fs tokens;
  .code .gutter drops its own vertical padding (was 14px vs 0, 14px offset)
- #51 history Paste column shows only the paste name, or slug pill when untitled
- #52 sort indicator arrow sits LEFT of the column label for all sortable columns
- #53 copy buttons give in-place success feedback ('Success!' in --ok for 2s):
  paste view copy button and new-page result copy button
- #54 collapsed stats summary bar: roomier padding (14px 18px) and item gap (16px);
  wraps gracefully on mobile
- #55 paste view title bar: slug pill only shown for custom slugs, id not repeated

Closes #50, closes #51, closes #52, closes #53, closes #54, closes #55
2026-09-08 22:35:43 -05:00

186 lines
7.3 KiB
HTML
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.
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Public pastes</h1>
<span class="count" id="count"></span>
</div>
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<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>
<thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
<th data-sort="size" class="sortable"><span class="sort-ind"></span>Size</th>
<th data-sort="view_count" class="sortable"><span class="sort-ind"></span>Views</th>
<th data-sort="created_at" class="sortable"><span class="sort-ind"></span>Created</th>
<th data-sort="custom_slug" class="sortable"><span class="sort-ind"></span>URL</th>
<th data-sort="id" class="sortable"><span class="sort-ind"></span>ID</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div>
</div>
<div class="pager float">
<span id="showing"></span>
<div class="pg" id="pg"></div>
</div>
</div>
<script>
const PER = 25;
let page = 1, total = 0;
const $ = id => document.getElementById(id);
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function 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'; }
function 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';
}
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) {
if (!filter) return true;
const f = filter.toLowerCase();
return (it.title || '').toLowerCase().includes(f) || (it.id || '').toLowerCase().includes(f);
}
async function load() {
const spinner = document.getElementById('search-spinner');
spinner.style.visibility = 'visible';
try {
const filtered = filter.length > 0;
const off = (page - 1) * PER;
const url = (filtered || sortKey)
? '/api/public?limit=500&offset=0'
: '/api/public?limit=' + PER + '&offset=' + off;
const res = await fetch(url);
const data = await res.json();
total = data.total;
let items = filtered ? data.items.filter(matchesFilter) : data.items;
items = sortItems(items);
$('count').textContent = filtered
? items.length.toLocaleString() + ' matches (of ' + total.toLocaleString() + ' total)'
: total.toLocaleString() + ' total';
const rows = $('rows');
if (items.length === 0) {
rows.innerHTML = '';
$('empty').style.display = 'block';
$('empty').textContent = filtered ? 'No pastes match your search.' : 'No pastes yet. Create the first one.';
} else {
$('empty').style.display = 'none';
rows.innerHTML = items.map(it =>
`<tr class="row" data-href="/${esc(it.id)}"><td>` +
(it.title
? `${esc(it.title)}`
: `<a class="slug" href="/${esc(it.id)}">${esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${esc(it.language || 'text')}</span></td>` +
`<td class="dim">${fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${esc(it.custom_slug)}">/${esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${esc(it.id)}">${esc(it.id)}</a></td></tr>`
).join('');
}
const pages = Math.max(1, Math.ceil(total / PER));
if (filtered || sortKey) {
$('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 = '';
} else {
$('showing').textContent = total === 0 ? 'Nothing here yet' :
`Showing ${off+1}${Math.min(off+PER, total)} of ${total.toLocaleString()} · page ${page} of ${pages}`;
const btns = [];
const add = (label, target, opts={}) => btns.push(`<button ${opts.on?'class="on"':''} ${opts.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', page-1, {dis: page===1});
const win = new Set([1, 2, page-1, page, 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===page});
last = i;
}
}
add('', page+1, {dis: page===pages});
$('pg').innerHTML = btns.join('');
}
renderSortIndicators();
} finally {
spinner.style.visibility = 'hidden';
}
}
$('pg').addEventListener('click', e => {
const b = e.target.closest('button[data-p]');
if (!b || b.disabled) return;
page = parseInt(b.dataset.p);
load();
window.scrollTo(0, 0);
});
$('rows').addEventListener('click', e => {
const tr = e.target.closest('tr.row[data-href]');
if (!tr || e.target.closest('a')) return; // let real links (e.g. middle-click) work
window.location.href = tr.dataset.href;
});
let filterTimer = null;
$('filter').addEventListener('input', e => {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => {
filter = e.target.value.trim();
page = 1;
load();
}, 200);
});
load();
setInterval(load, 30000); // auto-refresh history every 30s
</script>
{{template "foot" .}}