My pastes page /mine with anonymous viewer cookie (#37)
- vwr cookie middleware: random browser id set on first visit (reused by #49) - pastes table gains viewer_id column, set server-side at creation from the cookie - GET /api/mine lists pastes for the requesting browser (title/lang/size/created) - DELETE enforcement: 403 when client-sent vwr doesn't match the paste's viewer_id - /mine page reuses history table styling, delete buttons, empty state - nav: 'Saved' item between Public and Git; Git gets external-link arrow (#56) - tests: create-with-cookie appears in /mine, other cookie doesn't, delete enforcement Closes #37
This commit is contained in:
@@ -379,6 +379,9 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
|
||||
.settings-head { padding: 12px 18px; }
|
||||
.settings-body { padding: 16px 18px; }
|
||||
|
||||
/* topbar: Git external-link arrow (#56) */
|
||||
.topbar nav a .ext { width: 14px; height: 14px; margin-left: 4px; opacity: .55; vertical-align: -1px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { font-size: 16px; }
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
<nav>
|
||||
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
|
||||
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
|
||||
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git</a>
|
||||
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</a>
|
||||
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<a class="iconbtn gear" href="/settings" title="Settings" aria-label="Settings">
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{{template "head" .}}
|
||||
{{template "topbar" .}}
|
||||
<div class="page">
|
||||
<div class="head-row">
|
||||
<h1>Saved pastes</h1>
|
||||
<span class="count" id="count"></span>
|
||||
</div>
|
||||
<div class="float">
|
||||
<table>
|
||||
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><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>Created</th>
|
||||
<th>URL</th>
|
||||
<th>ID</th>
|
||||
</tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
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';
|
||||
}
|
||||
function toast(msg, kind) {
|
||||
let t = document.querySelector('.toast');
|
||||
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
|
||||
t.textContent = msg;
|
||||
t.classList.remove('success', 'error');
|
||||
if (kind === 'success') t.classList.add('success');
|
||||
if (kind === 'error') t.classList.add('error');
|
||||
t.classList.add('show');
|
||||
clearTimeout(t._h);
|
||||
t._h = setTimeout(() => t.classList.remove('show'), 2000);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await fetch('/api/mine');
|
||||
const data = await res.json();
|
||||
$('count').textContent = data.total.toLocaleString() + ' total';
|
||||
const rows = $('rows');
|
||||
if (!data.items.length) {
|
||||
rows.innerHTML = '';
|
||||
$('empty').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
$('empty').style.display = 'none';
|
||||
rows.innerHTML = data.items.map(it =>
|
||||
`<tr class="row" data-href="/${esc(it.id)}"><td><a class="slug" href="/${esc(it.id)}">${esc(it.id)}</a>` +
|
||||
(it.title ? `<div class="paste-sub">${esc(it.title)}</div>` : `<div class="paste-sub dim">none</div>`) + `</td>` +
|
||||
`<td><span class="badge">${esc(it.language || 'text')}</span></td>` +
|
||||
`<td class="dim">${fmtSize(it.size)}</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>` +
|
||||
`<td><button class="btn btn-icon del" data-id="${esc(it.id)}" title="Delete paste" aria-label="Delete paste">×</button></td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
$('rows').addEventListener('click', async e => {
|
||||
const del = e.target.closest('button.del');
|
||||
if (del) {
|
||||
e.stopPropagation();
|
||||
del.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
|
||||
if (res.ok) { toast('Deleted', 'success'); load(); }
|
||||
else { toast('Delete failed', 'error'); del.disabled = false; }
|
||||
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
|
||||
return;
|
||||
}
|
||||
const tr = e.target.closest('tr.row[data-href]');
|
||||
if (!tr || e.target.closest('a')) return;
|
||||
window.location.href = tr.dataset.href;
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
{{template "foot" .}}
|
||||
Reference in New Issue
Block a user