378 lines
17 KiB
HTML
378 lines
17 KiB
HTML
{{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">
|
|
<h3>Can contents</h3>
|
|
<label class="toggle"><input type="checkbox" id="iscan"> Bundle as a can (multiple text items)</label>
|
|
<div class="pw-row" id="canrow" style="display:none">
|
|
<div id="can-items"></div>
|
|
<button class="btn btn-icon" id="can-add" type="button" title="Add item" style="margin-top:6px">+ Add item</button>
|
|
<div class="hint" style="margin-top:6px; font-size:19px;">Each item gets its own card on the can page. File uploads in cans come later.</div>
|
|
</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';
|
|
});
|
|
|
|
// #4: can builder — multiple text items bundled into one shareable page.
|
|
// The main editor becomes the first item; extra items are added below.
|
|
$('iscan').addEventListener('change', e => {
|
|
$('canrow').style.display = e.target.checked ? 'block' : 'none';
|
|
if (e.target.checked && !$('can-items').children.length) addCanItem();
|
|
});
|
|
function addCanItem() {
|
|
const row = document.createElement('div');
|
|
row.className = 'can-item-row';
|
|
row.innerHTML = '<input class="can-item-title" placeholder="Item title">' +
|
|
'<textarea class="can-item-content" placeholder="Item content" rows="3" spellcheck="false"></textarea>';
|
|
$('can-items').appendChild(row);
|
|
}
|
|
$('can-add').addEventListener('click', addCanItem);
|
|
|
|
let guessed = ''; // last auto-detected language, '' = user override
|
|
|
|
// #105: map backend machine-readable error codes to plain-language guidance.
|
|
// Unknown codes fall back to a generic message; the technical detail stays
|
|
// in the API response for API consumers.
|
|
const ERROR_MESSAGES = {
|
|
slug_taken: 'That Custom URL is already taken. Try another.',
|
|
slug_reserved: 'That Custom URL is reserved. Try another.',
|
|
slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.',
|
|
content_empty: 'Write or paste something first.',
|
|
content_too_large: 'This paste is too large. The limit is 5 MB.',
|
|
expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.',
|
|
rate_limited: 'Too many tries. Wait a minute and try again.',
|
|
};
|
|
const GENERIC_ERROR = 'Something went wrong. Please try again.';
|
|
|
|
function friendlyError(data) {
|
|
return ERROR_MESSAGES[data && data.code] || GENERIC_ERROR;
|
|
}
|
|
|
|
// #105: color the result box by outcome — success (ok), error (err),
|
|
// warning (warn) — with a colored left border (CSS .result-ok/.result-err).
|
|
function setResultKind(kind) {
|
|
const card = $('result-card');
|
|
card.classList.remove('result-ok', 'result-err', 'result-warn');
|
|
if (kind) card.classList.add('result-' + kind);
|
|
}
|
|
|
|
function showResult(html, kind) {
|
|
$('result').innerHTML = html;
|
|
$('result').dataset.token = kind === 'ok' ? ($('result').dataset.token || '') : ($('result').dataset.token || '');
|
|
$('result-card').style.display = 'block';
|
|
setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err'));
|
|
}
|
|
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() {
|
|
// #4: can mode — bundle the editor + extra items into a can via multipart
|
|
if ($('iscan').checked) return createCan();
|
|
|
|
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(friendlyError(data), 'err');
|
|
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>', 'ok');
|
|
$('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);
|
|
|
|
// #4: can creation — POST multipart to /api/pastes/can. The main editor is
|
|
// the first item; each extra can-item row is another text item.
|
|
async function createCan() {
|
|
const items = [];
|
|
if (content.value.trim()) {
|
|
items.push({title: $('title').value || 'main', content: content.value, language: $('language').value || ''});
|
|
}
|
|
document.querySelectorAll('#can-items .can-item-row').forEach(row => {
|
|
const t = row.querySelector('.can-item-title').value.trim();
|
|
const c = row.querySelector('.can-item-content').value;
|
|
if (c.trim()) items.push({title: t || ('item-' + (items.length + 1)), content: c});
|
|
});
|
|
if (!items.length) { toast('Nothing to put in the can', 'error'); return; }
|
|
|
|
const fd = new FormData();
|
|
fd.append('title', $('title').value || 'Untitled can');
|
|
fd.append('json_items', JSON.stringify(items));
|
|
if ($('haspw').checked) fd.append('password', $('password').value);
|
|
if ($('unlisted').checked) fd.append('visibility', 'unlisted');
|
|
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; }
|
|
if (dur) fd.append('expires_in', dur);
|
|
} else if (exp) {
|
|
fd.append('expires_in', exp);
|
|
}
|
|
if ($('custom').value.trim()) fd.append('custom_slug', $('custom').value.trim());
|
|
|
|
const res = await fetch('/api/pastes/can', {method: 'POST', body: fd});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
showResult(friendlyError(data), 'err');
|
|
toast('Can create failed', 'error');
|
|
return;
|
|
}
|
|
const url = location.origin + data.url;
|
|
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
|
|
const copyBtn = document.getElementById('result-copy');
|
|
copyBtn.addEventListener('click', () => {
|
|
try {
|
|
navigator.clipboard.writeText(url);
|
|
copyBtn.classList.add('ok');
|
|
copyBtn.textContent = 'Success!';
|
|
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
|
|
} catch(e) { toast('Copy failed', 'error'); }
|
|
});
|
|
// password-protected can: unlock now with the password we already have (#26 parity)
|
|
if ($('haspw').checked && data.id) {
|
|
const pd = new FormData();
|
|
pd.append('password', $('password').value);
|
|
try { await fetch('/can/' + data.id, {method: 'POST', body: pd}); } catch(e) {}
|
|
}
|
|
location.href = data.url;
|
|
}
|
|
// 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" .}}
|