Merge fix-38-file-attachments into dev (PR #129)
CI / test (push) Successful in 28s
CI / docker (push) Successful in 36s

This commit is contained in:
fen
2026-09-09 23:20:48 -05:00
12 changed files with 1297 additions and 3 deletions
+187 -1
View File
@@ -68,6 +68,15 @@
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
</div>
</div>
<div class="float side-section">
<h3>Attachment</h3>
<div class="dropzone" id="dropzone" title="Paste (Ctrl+V), drop a file here, or click to browse">
Drop a file here, press Ctrl+V, or click to browse
</div>
<input type="file" id="file-input" class="file-input" aria-label="Choose a file">
<div id="file-chip-slot"></div>
<div class="hint" style="margin-top:6px; font-size:19px; display:none" id="file-text-note">File pastes ignore the text editor (one file per paste)</div>
</div>
<div class="float side-section">
<h3>Custom URL</h3>
<input type="text" id="custom" class="custom-input" placeholder="my-snippet">
@@ -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('<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 || '';
@@ -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 = '<span class="file-chip-name"></span>' +
'<span class="file-chip-size">' + humanSize(attachedFile.size) + '</span>' +
'<button type="button" class="file-chip-remove" title="Remove file">&times;</button>';
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('<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;
+13
View File
@@ -41,6 +41,19 @@
</div>
</div>
{{end}}
{{if .Attachment}}
<div class="float">
<div class="attachment-bar">
<a class="attachment-chip" href="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" data-mime="{{.Attachment.Mime}}">
<span class="attachment-name">{{.Attachment.Filename}}</span>
<span class="attachment-size">{{.Attachment.SizeHuman}}</span>
</a>
{{if or (eq .Attachment.Mime "image/png") (eq .Attachment.Mime "image/jpeg") (eq .Attachment.Mime "image/gif") (eq .Attachment.Mime "image/webp")}}
<div class="attachment-preview"><img src="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" alt="{{.Attachment.Filename}}"></div>
{{end}}
</div>
</div>
{{end}}
<div class="float">
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
</div>