Custom URL
@@ -160,6 +169,8 @@ const ERROR_MESSAGES = {
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.',
+ file_too_large: 'File is too large. The limit is 25 MB.',
+ one_file_only: 'Choose either text or a file for now.',
expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.',
rate_limited: 'Too many tries. Wait a minute and try again.',
};
@@ -228,6 +239,10 @@ $('reguess').addEventListener('click', guessLang);
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();
+ // #38: file attached -> file paste (1 file = 1 paste; text is ignored)
+ if (attachedFile) return createFilePaste();
const body = {
content: content.value,
@@ -258,6 +273,12 @@ async function create() {
toast('Create failed', 'error');
return;
}
+ finishCreate(data);
+}
+
+// shared success handling for both create paths (#38): result box, copy
+// button, password auto-unlock, then redirect to the paste.
+function finishCreate(data) {
const url = location.origin + '/' + (data.custom_slug || data.id);
showResult('
' + url + ' ', 'ok');
$('result').dataset.token = data.deletion_token || '';
@@ -277,7 +298,8 @@ async function create() {
fd.append('password', $('password').value);
fd.append('next', dest);
try {
- await fetch('/' + data.id, {method: 'POST', body: fd});
+ fetch('/' + data.id, {method: 'POST', body: fd}).finally(() => { location.href = dest; });
+ return;
} catch(e) {}
}
// show the paste
@@ -285,6 +307,170 @@ async function create() {
}
$('create').addEventListener('click', create);
+// #38: file attachment support — 1 file = 1 paste. Three input paths:
+// Ctrl+V paste of a file, upload button (file picker), drag-and-drop.
+const MAX_FILE_BYTES = 25 * 1024 * 1024;
+let attachedFile = null; // the single attached File, or null
+
+function humanSize(n) {
+ if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
+ if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
+ return n + ' B';
+}
+
+function setAttachedFile(file) {
+ if (!file) return clearAttachedFile();
+ if (file.size > MAX_FILE_BYTES) {
+ toast('File is too large. The limit is 25 MB.', 'error');
+ return;
+ }
+ attachedFile = file;
+ renderFileChip();
+ $('file-text-note').style.display = 'block';
+}
+
+function clearAttachedFile() {
+ attachedFile = null;
+ $('file-input').value = '';
+ renderFileChip();
+ $('file-text-note').style.display = 'none';
+}
+
+function renderFileChip() {
+ const slot = $('file-chip-slot');
+ slot.innerHTML = '';
+ if (!attachedFile) return;
+ const chip = document.createElement('div');
+ chip.className = 'file-chip';
+ chip.innerHTML = '
' +
+ '
' + humanSize(attachedFile.size) + '' +
+ '
';
+ chip.querySelector('.file-chip-name').textContent = attachedFile.name;
+ chip.querySelector('.file-chip-remove').addEventListener('click', clearAttachedFile);
+ slot.appendChild(chip);
+}
+
+// (1) file picker via the dropzone click
+$('dropzone').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', () => {
+ if ($('file-input').files.length) setAttachedFile($('file-input').files[0]);
+});
+
+// (2) drag-and-drop onto the dropzone (and the page broadly)
+const dz = $('dropzone');
+['dragenter', 'dragover'].forEach(ev => {
+ document.addEventListener(ev, e => {
+ if (!e.dataTransfer || ![...e.dataTransfer.types].includes('Files')) return;
+ e.preventDefault();
+ dz.classList.add('dragover');
+ });
+});
+['dragleave', 'drop'].forEach(ev => {
+ document.addEventListener(ev, e => {
+ if (ev === 'drop') e.preventDefault();
+ if (e.target === dz || ev === 'drop') dz.classList.remove('dragover');
+ });
+});
+document.addEventListener('drop', e => {
+ if (!e.dataTransfer || !e.dataTransfer.files.length) return;
+ e.preventDefault();
+ setAttachedFile(e.dataTransfer.files[0]);
+});
+
+// (3) Ctrl+V of a file anywhere on the page
+document.addEventListener('paste', e => {
+ const files = e.clipboardData && e.clipboardData.files;
+ if (!files || !files.length) return; // normal text paste into the editor
+ e.preventDefault();
+ setAttachedFile(files[0]);
+});
+
+// file create path: POST multipart. 1 file = 1 paste: when a file is
+// attached the editor text is ignored (server enforces this too).
+async function createFilePaste() {
+ const fd = new FormData();
+ fd.append('file', attachedFile);
+ if ($('title').value) fd.append('title', $('title').value);
+ if ($('custom').value) fd.append('custom_slug', $('custom').value);
+ if ($('haspw').checked) fd.append('password', $('password').value);
+ if ($('burn').checked) {
+ fd.append('burn_after_read', 'true');
+ fd.append('burn_after_reads', String(parseInt($('burnreads').value, 10) || 1));
+ }
+ 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; }
+ fd.append('expires_in', dur);
+ } else if (exp) {
+ fd.append('expires_in', exp);
+ }
+ const res = await fetch('/api/pastes', { method: 'POST', body: fd });
+ const data = await res.json();
+ if (!res.ok) {
+ showResult(friendlyError(data), 'err');
+ toast('Create failed', 'error');
+ return;
+ }
+ finishCreate(data);
+}
+
+// #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('
' + url + ' ', '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;
diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html
index 1858aaa..8ee6053 100644
--- a/internal/web/templates/paste.html
+++ b/internal/web/templates/paste.html
@@ -41,6 +41,19 @@
{{end}}
+ {{if .Attachment}}
+