Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35)
CI / test (push) Successful in 19s
CI / docker (push) Failing after 2m7s

This commit is contained in:
2026-09-09 01:33:39 -05:00
parent a3349b4a98
commit 4f1e901f04
43 changed files with 1251 additions and 1203 deletions
+1
View File
@@ -0,0 +1 @@
placeholder
+107
View File
@@ -0,0 +1,107 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="settings-head">
<h1>Admin</h1>
</div>
<div class="settings-body">
<p>Enter the admin key to manage server settings. The key is kept in
sessionStorage for this tab only and is sent as a request header — it is
never stored in a cookie, so it will not accompany normal paste requests.</p>
<form id="admin-key-form">
<label for="admin-key">Admin key</label><br>
<input type="password" id="admin-key" autocomplete="off" style="width:100%">
<button type="submit">Unlock</button>
<span id="admin-key-status"></span>
</form>
<div id="admin-panel" style="display:none">
<h2>Settings</h2>
<form id="admin-settings-form">
<table>
<tr><td>Rate-limit burst</td><td><input type="number" id="rl-burst" min="1" step="1"></td></tr>
<tr><td>Rate-limit refill per minute</td><td><input type="number" id="rl-refill" min="0.1" step="0.1"></td></tr>
<tr><td>Max content bytes</td><td><input type="number" id="max-content" min="1" step="1"></td></tr>
<tr><td>Default expiry</td><td><input type="text" id="default-expiry" placeholder="e.g. 168h, 30m, 0 = never"></td></tr>
<tr><td>Custom URL reservation days</td><td><input type="number" id="slug-days" min="1" step="1"></td></tr>
<tr><td>Burn viewer window (minutes)</td><td><input type="number" id="burn-window" min="1" step="1"></td></tr>
</table>
<button type="submit">Save</button>
<span id="admin-save-status"></span>
</form>
</div>
</div>
</div>
</div>
<script>
(function () {
var KEY = 'palette_admin_key';
var keyInput = document.getElementById('admin-key');
var status = document.getElementById('admin-key-status');
var panel = document.getElementById('admin-panel');
function key() { return sessionStorage.getItem(KEY) || ''; }
function api(path, opts) {
opts = opts || {};
opts.headers = { 'X-Admin-Key': key() };
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts);
}
function loadSettings() {
api('/admin/api/settings').then(function (r) {
if (r.status !== 200) { showLock(); return; }
return r.json();
}).then(function (s) {
if (!s) return;
document.getElementById('rl-burst').value = s.rate_limit_burst;
document.getElementById('rl-refill').value = s.rate_limit_per_minute;
document.getElementById('max-content').value = s.max_content_bytes;
document.getElementById('default-expiry').value = s.default_expiry;
document.getElementById('slug-days').value = s.custom_slug_reservation_days;
document.getElementById('burn-window').value = s.burn_viewer_window_minutes;
panel.style.display = '';
});
}
function showLock() {
panel.style.display = 'none';
sessionStorage.removeItem(KEY);
}
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
e.preventDefault();
sessionStorage.setItem(KEY, keyInput.value);
api('/admin/api/settings').then(function (r) {
if (r.status === 200) {
status.textContent = '✓';
keyInput.value = '';
loadSettings();
} else {
status.textContent = 'invalid key';
showLock();
}
});
});
document.getElementById('admin-settings-form').addEventListener('submit', function (e) {
e.preventDefault();
var body = {
rate_limit_burst: parseFloat(document.getElementById('rl-burst').value),
rate_limit_per_minute: parseFloat(document.getElementById('rl-refill').value),
max_content_bytes: parseInt(document.getElementById('max-content').value, 10),
default_expiry: document.getElementById('default-expiry').value,
custom_slug_reservation_days: parseInt(document.getElementById('slug-days').value, 10),
burn_viewer_window_minutes: parseInt(document.getElementById('burn-window').value, 10)
};
api('/admin/api/settings', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
document.getElementById('admin-save-status').textContent = r.status === 200 ? 'saved' : 'error';
if (r.status !== 200) showLock();
});
});
if (key()) loadSettings();
})();
</script>
{{template "foot" .}}
+21
View File
@@ -0,0 +1,21 @@
{{define "foot"}}<script>
// live relative-time counters (#46): tick any [data-ts] (epoch seconds) every second
(function () {
function fmt(ts) {
const s = Math.max(0, 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 tick() {
document.querySelectorAll('[data-ts]').forEach(el => {
const ts = parseInt(el.dataset.ts, 10);
if (!isNaN(ts)) el.textContent = fmt(ts);
});
}
setInterval(tick, 1000);
document.addEventListener('DOMContentLoaded', tick);
tick();
})();
</script>{{end}}
+52
View File
@@ -0,0 +1,52 @@
{{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 src="/static/table.js"></script>
<script>
const t = PaletteTable.init({
endpoint: '/api/public',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.',
});
t.load();
setInterval(t.load, 30000); // auto-refresh history every 30s
</script>
{{template "foot" .}}
+28
View File
@@ -0,0 +1,28 @@
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css">
<script>
// preset preview hook (#16): ?theme=<name> sets data-preset for screenshots only
(function () {
var t = new URLSearchParams(location.search).get('theme');
if (t) document.documentElement.dataset.preset = t;
})();
</script>
{{end}}
{{define "topbar"}}
<div class="topbar">
<a class="logo" href="/history">Palette <em>/ beta</em></a>
<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="/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">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</a>
</div>
{{end}}
+73
View File
@@ -0,0 +1,73 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Saved 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:150px"><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="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 from this browser yet.</div>
</div>
</div>
<script src="/static/table.js"></script>
<script>
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);
}
const t = PaletteTable.init({
endpoint: '/api/mine',
perPage: 50,
hasPager: false,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.',
emptyAll: 'No pastes from this browser yet.',
});
// delete buttons (viewer-scoped, enforced server-side #37)
document.getElementById('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (!del) return;
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); t.load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
});
t.load();
</script>
{{template "foot" .}}
+267
View File
@@ -0,0 +1,267 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="deck">
<div class="pane-l-col">
<div class="float pane-l-head">
<div class="editor-head">
<input id="title" placeholder="Title">
<select id="language">
<option value="">auto</option>
<option>go</option><option>python</option><option>javascript</option><option>typescript</option>
<option>rust</option><option>c</option><option>cpp</option><option>java</option><option>csharp</option>
<option>bash</option><option>sql</option><option>yaml</option><option>json</option>
<option>html</option><option>css</option><option>xml</option><option>php</option>
<option>ruby</option><option>perl</option><option>lua</option><option>dockerfile</option>
<option>toml</option><option>ini</option><option>diff</option>
<option>markdown</option><option>text</option>
</select>
<button class="btn btn-icon" id="reguess" title="Re-detect language" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg></button>
</div>
</div>
<div class="float editor-wrap">
<div class="gutter" id="gutter">1</div>
<textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
</div>
<div class="created-banner" id="created"></div>
<div class="actionbar">
<span class="hint">Ctrl+Enter to create</span>
<div class="spacer" style="flex:1"></div>
<button class="btn" id="create">Create</button>
</div>
</div>
<div class="pane-r">
<div class="float side-section">
<h3>Expiry</h3>
<div class="seg">
<label><input type="radio" name="exp" value=""> Never</label>
<label><input type="radio" name="exp" value="1h"> 1 hour</label>
<label><input type="radio" name="exp" value="24h"> 1 day</label>
<label><input type="radio" name="exp" value="168h" checked> 1 week</label>
<label><input type="radio" name="exp" value="720h"> 30 days</label>
<label><input type="radio" name="exp" value="custom"> Custom</label>
</div>
<div class="pw-row" id="customexp-row" style="display:none">
<input type="number" id="expnum" min="1" style="width:80px" placeholder="90">
<select id="expunit">
<option value="m">minutes</option>
<option value="h" selected>hours</option>
<option value="d">days</option>
<option value="w">weeks</option>
<option value="mo">months</option>
</select>
<div class="hint" id="customexp-err" style="display:none; color:var(--danger, #c0392b); margin-top:6px;"></div>
</div>
</div>
<div class="float side-section">
<h3>Protection</h3>
<div class="protect">
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
<div class="pw-row" id="pwrow" style="display:none"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/><line class="eye-slash" x1="4" y1="4" x2="20" y2="20"/></svg></button></div></div>
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
<div class="pw-row" id="burnrow" style="display:none"><label class="hint" style="font-size:19px;">Readable <input type="number" id="burnreads" min="1" value="1" style="width:64px"> times</label></div>
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
</div>
</div>
<div class="float side-section">
<h3>Custom URL</h3>
<input type="text" id="custom" class="custom-input" placeholder="/my-snippet">
<div class="hint" style="margin-top:6px; font-size:19px;">Stays reserved while the paste exists</div>
</div>
<div class="float side-section" id="result-card" style="display:none">
<h3>Result</h3>
<div class="hint" id="result" style="word-break:break-all">empty</div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
function updateGutter() {
const lines = content.value.split('\n').length;
let s = '';
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = s;
}
content.addEventListener('input', updateGutter);
updateGutter();
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);
}
$('haspw').addEventListener('change', e => { $('pwrow').style.display = e.target.checked ? 'block' : 'none'; });
$('burn').addEventListener('change', e => { $('burnrow').style.display = e.target.checked ? 'block' : 'none'; });
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
$('customexp-row').style.display = document.querySelector('input[name="exp"]:checked').value === 'custom' ? 'block' : 'none';
$('customexp-err').style.display = 'none';
}));
// compose the expires_in Go-duration string when Custom is checked (#48).
// Returns the string, or null with an inline error shown.
function composeCustomExpiry() {
const n = parseInt($('expnum').value, 10);
const unit = $('expunit').value;
let mins = NaN;
if (n > 0) {
if (unit === 'm') mins = n;
else if (unit === 'h') mins = n * 60;
else if (unit === 'd') mins = n * 1440;
else if (unit === 'w') mins = n * 10080;
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
}
const err = $('customexp-err');
if (!(mins >= 1)) {
err.textContent = 'Enter a duration of at least 1 minute.';
err.style.display = 'block';
return null;
}
if (mins > 525600) { // more than 1 year
err.textContent = 'Custom expiry cannot exceed 1 year.';
err.style.display = 'block';
return null;
}
err.style.display = 'none';
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
const hours = Math.floor(mins / 60), rem = mins % 60;
if (rem === 0) return hours + 'h';
if (hours === 0) return rem + 'm';
return hours + 'h' + rem + 'm';
}
$('pwreveal').addEventListener('click', () => {
const pw = $('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
$('pwreveal').classList.toggle('off', !show);
$('pwreveal').title = show ? 'Hide password' : 'Show password';
});
let guessed = ''; // last auto-detected language, '' = user override
function showResult(html, isError) {
$('result').innerHTML = html;
$('result').dataset.token = isError ? '' : ($('result').dataset.token || '');
$('result-card').style.display = 'block';
}
function defaultFilename(lang) {
const names = {
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
ini: 'config.ini', diff: 'changes.diff',
markdown: 'notes.md', text: 'Text.txt',
};
return names[lang] || '';
}
// fill default filename when title is still blank
function maybeSetDefaultTitle(lang) {
const title = $('title');
if (lang && !title.value.trim()) {
const fn = defaultFilename(lang);
if (fn) title.value = fn;
}
}
async function guessLang() {
if (!content.value.trim()) return;
try {
const res = await fetch('/api/guess-language', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: content.value}),
});
const data = await res.json();
if (res.ok && data.language) {
guessed = data.language;
$('language').value = data.language;
maybeSetDefaultTitle(data.language);
}
} catch(e) {}
}
// refresh button: always re-detect, even if user picked something
$('reguess').addEventListener('click', guessLang);
// auto-guess when pasting into the editor
content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() {
const body = {
content: content.value,
title: $('title').value || null,
language: $('language').value || null,
custom_slug: $('custom').value || null,
burn_after_read: $('burn').checked,
};
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
if ($('haspw').checked) body.password = $('password').value;
const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp === 'custom') {
const dur = composeCustomExpiry();
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
body.expires_in = dur;
} else if (exp) {
body.expires_in = exp;
}
const res = await fetch('/api/pastes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showResult('Error: ' + (data.error || res.status), true);
toast('Create failed', 'error');
return;
}
const url = location.origin + '/' + (data.custom_slug || data.id);
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', false);
$('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok'); // in-place success feedback (#53)
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
const dest = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
// password-protected: unlock now with the password we already have (#26)
if ($('haspw').checked && data.id) {
const fd = new FormData();
fd.append('password', $('password').value);
fd.append('next', dest);
try {
await fetch('/' + data.id, {method: 'POST', body: fd});
} catch(e) {}
}
// show the paste
location.href = dest;
}
$('create').addEventListener('click', create);
// reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
const rc = document.getElementById('result-card');
if (rc) rc.style.display = 'none';
const r = document.getElementById('result');
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
});
</script>
{{template "foot" .}}
+84
View File
@@ -0,0 +1,84 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="paste-title-bar">
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
<div class="spacer"></div>
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
<a class="iconbtn" href="#" id="copy-btn" onclick="copyContent(this); return false;">copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
</div>
</div>
<div class="float">
<div class="stats-pill" id="stats-pill">
<button type="button" class="stats-head" id="stats-toggle" aria-expanded="false" onclick="toggleStats()">
<svg class="stats-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
<span class="stats-summary">{{.StatsSummary}}</span>
</button>
<div class="stats-body" id="stats-body" hidden>
<div class="stats-grid">
<span class="stats-k">Language</span><span class="stats-v">{{if .Language}}{{.Language}}{{else}}text{{end}}</span>
<span class="stats-k">Size</span><span class="stats-v">{{.SizeHuman}} ({{.LineCount}} lines)</span>
<span class="stats-k">Views</span><span class="stats-v">{{.ViewCount}}</span>
<span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>
{{if .ExpiresAt}}<span class="stats-k">Expires</span><span class="stats-v">in {{.ExpiresIn}}</span>{{end}}
<span class="stats-k">Password</span><span class="stats-v">{{if .HasPassword}}protected{{else}}none{{end}}</span>
{{if .BurnAfterRead}}{{if .ReadsLimit}}{{with .ReadsLeftN}}<span class="stats-k">Reads left</span><span class="stats-v">{{.}} of {{$.ReadsTotal}}</span>{{end}}{{else}}<span class="stats-k">Burn</span><span class="stats-v">burn after read</span>{{end}}{{end}}
{{if .CustomSlug}}<span class="stats-k">Custom URL</span><span class="stats-v">/{{.CustomSlug}}</span>{{end}}
<span class="stats-k">Visibility</span><span class="stats-v">{{.Visibility}}</span>
</div>
</div>
</div>
</div>
{{if .JustCreated}}
<div class="float">
<div class="created-banner" style="display:block">
Paste created. Link copied to clipboard: <a href="/{{.ID}}">{{.Host}}/{{.ID}}</a>
{{if .DeletionToken}} · deletion token: <code>{{.DeletionToken}}</code>{{end}}
</div>
</div>
{{end}}
<div class="float">
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
</div>
</div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script>
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
function toggleStats() {
const body = document.getElementById('stats-body');
const pill = document.getElementById('stats-pill');
const btn = document.getElementById('stats-toggle');
const open = body.hidden;
body.hidden = !open;
pill.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function copyContent(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value);
// in-place success feedback (#53)
if (btn) {
btn.classList.add('ok');
btn.textContent = 'Success!';
clearTimeout(btn._okh);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000);
} else {
toast('Copied', 'success');
}
}
function redeem(token) {
if (!confirm('Hard delete this paste immediately?')) return;
fetch('/api/pastes/{{.ID}}/redeem?token=' + encodeURIComponent(token), {method: 'DELETE'})
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
}
</script>
{{template "foot" .}}
+13
View File
@@ -0,0 +1,13 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="settings-head">
<h1>Settings</h1>
</div>
<div class="settings-body">
<p>Settings are under construction.</p>
</div>
</div>
</div>
{{template "foot" .}}
+29
View File
@@ -0,0 +1,29 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float unlock-card">
<div class="inner">
<div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<h1>This paste is locked</h1>
<p class="sub">Enter the password to view <span class="slug">/{{.ID}}</span></p>
<form method="post" action="">
<div class="pw-field">
<input type="password" name="password" id="password" placeholder="Password" autocomplete="current-password" autofocus>
<button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
{{if .Wrong}}<p class="unlock-err">Wrong password. Try again.</p>{{end}}
<button class="btn" type="submit">Unlock</button>
</form>
</div>
<div class="foot">Created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span></div>
</div>
</div>
<script>
document.getElementById('pwreveal').addEventListener('click', () => {
const pw = document.getElementById('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
});
</script>
{{template "foot" .}}