feat: Support multi-file uploads with batch progress and results

Frontend now accepts multiple files via drag-drop or browse, uploads
them sequentially with per-file progress tracking, and shows batch
results with "Copy all URLs". Includes 429 rate-limit auto-retry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-07 15:09:13 -04:00
parent a12ed888e0
commit 51529e4dad
3 changed files with 346 additions and 89 deletions

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=2">
<link rel="stylesheet" href="/static/style.css?v=3">
</head>
<body>
<div class="container">
@ -18,13 +18,15 @@
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p>Drop a file here or <label for="file-input" class="link">browse</label></p>
<input type="file" id="file-input" hidden>
<p>Drop files here or <label for="file-input" class="link">browse</label></p>
<input type="file" id="file-input" hidden multiple>
</div>
</div>
<div id="file-list" class="file-list hidden"></div>
<div id="options" class="options hidden">
<div class="option-group">
<div id="slug-group" class="option-group">
<label for="slug">Custom URL</label>
<div class="slug-input">
<span class="slug-prefix">/f/</span>
@ -49,6 +51,7 @@
</div>
<div id="progress-section" class="hidden">
<div id="progress-overall" class="progress-overall"></div>
<div class="file-name" id="progress-filename"></div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
@ -58,13 +61,19 @@
<div id="result" class="result hidden">
<div class="result-success">
<p>Uploaded!</p>
<div class="result-url">
<input type="text" id="result-url" readonly>
<button id="copy-btn" class="btn btn-small">Copy</button>
<p id="result-heading">Uploaded!</p>
<div id="result-single">
<div class="result-url">
<input type="text" id="result-url" readonly>
<button id="copy-btn" class="btn btn-small">Copy</button>
</div>
<div id="result-delete" class="result-meta"></div>
<div id="result-expiry" class="result-meta"></div>
</div>
<div id="result-multi" class="hidden">
<div id="result-list" class="result-list"></div>
<button id="copy-all-btn" class="btn btn-small" style="margin-top: 0.75rem;">Copy all URLs</button>
</div>
<div id="result-delete" class="result-meta"></div>
<div id="result-expiry" class="result-meta"></div>
</div>
</div>
@ -74,6 +83,6 @@
<a href="/cli">CLI tool</a>
</footer>
</div>
<script src="/static/upload.js"></script>
<script src="/static/upload.js?v=2"></script>
</body>
</html>

View File

@ -251,6 +251,120 @@ footer a:hover { color: var(--accent); }
.hidden { display: none !important; }
/* File list */
.file-list {
margin-top: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.file-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: var(--surface);
font-size: 0.8125rem;
color: var(--text-dim);
border-bottom: 1px solid var(--border);
}
.file-list-clear {
background: none;
border: none;
font-size: 0.75rem;
cursor: pointer;
}
.file-list-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
border-bottom: 1px solid var(--border);
}
.file-list-item:last-child {
border-bottom: none;
}
.file-list-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-list-size {
color: var(--text-dim);
flex-shrink: 0;
font-size: 0.75rem;
}
.file-list-remove {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 0.25rem;
flex-shrink: 0;
}
.file-list-remove:hover {
color: var(--error);
}
.progress-overall {
font-size: 0.8125rem;
color: var(--text-dim);
margin-bottom: 0.25rem;
}
/* Multi-file results */
.result-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.result-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.375rem 0;
border-bottom: 1px solid var(--border);
}
.result-item:last-child {
border-bottom: none;
}
.result-item-name {
font-size: 0.8125rem;
color: var(--accent);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-item-name:hover {
text-decoration: underline;
}
.result-item-url {
font-size: 0.75rem;
color: var(--text-dim);
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Download page */
.file-card {
margin-top: 0;

View File

@ -1,20 +1,29 @@
(() => {
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('file-input');
const fileListEl = document.getElementById('file-list');
const options = document.getElementById('options');
const slugGroup = document.getElementById('slug-group');
const uploadBtn = document.getElementById('upload-btn');
const progressSection = document.getElementById('progress-section');
const progressOverall = document.getElementById('progress-overall');
const progressFilename = document.getElementById('progress-filename');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const result = document.getElementById('result');
const resultHeading = document.getElementById('result-heading');
const resultSingle = document.getElementById('result-single');
const resultUrl = document.getElementById('result-url');
const copyBtn = document.getElementById('copy-btn');
const resultDelete = document.getElementById('result-delete');
const resultExpiry = document.getElementById('result-expiry');
const resultMulti = document.getElementById('result-multi');
const resultList = document.getElementById('result-list');
const copyAllBtn = document.getElementById('copy-all-btn');
const errorDiv = document.getElementById('error');
let selectedFile = null;
let selectedFiles = [];
let uploading = false;
// Drag and drop
dropzone.addEventListener('dragover', (e) => {
@ -30,106 +39,221 @@
e.preventDefault();
dropzone.classList.remove('drag-over');
if (e.dataTransfer.files.length > 0) {
selectFile(e.dataTransfer.files[0]);
addFiles(e.dataTransfer.files);
}
});
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('click', () => {
if (!uploading) fileInput.click();
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length > 0) {
selectFile(fileInput.files[0]);
addFiles(fileInput.files);
}
fileInput.value = '';
});
function selectFile(file) {
selectedFile = file;
dropzone.querySelector('p').textContent = file.name + ' (' + formatSize(file.size) + ')';
options.classList.remove('hidden');
function addFiles(fileListObj) {
for (const f of fileListObj) {
// Skip duplicates by name+size
if (!selectedFiles.some(s => s.name === f.name && s.size === f.size)) {
selectedFiles.push(f);
}
}
renderFileList();
result.classList.add('hidden');
errorDiv.classList.add('hidden');
}
uploadBtn.addEventListener('click', () => {
if (!selectedFile) return;
upload(selectedFile);
});
function upload(file) {
const formData = new FormData();
const slug = document.getElementById('slug').value.trim();
if (slug) formData.append('slug', slug);
formData.append('file', file);
const expires = document.getElementById('expires').value;
if (expires) formData.append('expires_in', expires);
const password = document.getElementById('password').value;
if (password) formData.append('password', password);
const xhr = new XMLHttpRequest();
// Show progress
options.classList.add('hidden');
dropzone.classList.add('hidden');
progressSection.classList.remove('hidden');
progressFilename.textContent = file.name;
errorDiv.classList.add('hidden');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = pct + '%';
progressText.textContent = pct + '% — ' + formatSize(e.loaded) + ' / ' + formatSize(e.total);
}
});
xhr.addEventListener('load', () => {
progressSection.classList.add('hidden');
if (xhr.status === 201) {
const data = JSON.parse(xhr.responseText);
showResult(data);
} else {
showError(xhr.responseText || 'Upload failed');
}
});
xhr.addEventListener('error', () => {
progressSection.classList.add('hidden');
showError('Network error — upload failed');
});
xhr.open('POST', '/upload');
xhr.send(formData);
function removeFile(index) {
selectedFiles.splice(index, 1);
renderFileList();
}
function showResult(data) {
result.classList.remove('hidden');
resultUrl.value = data.url;
resultDelete.textContent = 'Delete: curl -X DELETE -H "Authorization: Bearer ' + data.delete_token + '" ' + data.delete_url;
if (data.expires_at) {
resultExpiry.textContent = 'Expires: ' + new Date(data.expires_at).toLocaleString();
} else {
resultExpiry.textContent = '';
function renderFileList() {
if (selectedFiles.length === 0) {
fileListEl.classList.add('hidden');
options.classList.add('hidden');
dropzone.querySelector('p').innerHTML = 'Drop files here or <label for="file-input" class="link">browse</label>';
return;
}
// Reset for another upload
fileListEl.classList.remove('hidden');
options.classList.remove('hidden');
// Hide custom slug for multi-file uploads
slugGroup.classList.toggle('hidden', selectedFiles.length > 1);
if (selectedFiles.length > 1) {
document.getElementById('slug').value = '';
}
const totalSize = selectedFiles.reduce((sum, f) => sum + f.size, 0);
fileListEl.innerHTML = '<div class="file-list-header">' +
'<span>' + selectedFiles.length + ' file' + (selectedFiles.length > 1 ? 's' : '') +
' (' + formatSize(totalSize) + ')</span>' +
'<button class="file-list-clear link" type="button">Clear all</button>' +
'</div>' +
selectedFiles.map((f, i) =>
'<div class="file-list-item">' +
'<span class="file-list-name" title="' + escapeAttr(f.name) + '">' + escapeHtml(f.name) + '</span>' +
'<span class="file-list-size">' + formatSize(f.size) + '</span>' +
'<button class="file-list-remove" data-index="' + i + '" type="button" title="Remove">&times;</button>' +
'</div>'
).join('');
fileListEl.querySelector('.file-list-clear').addEventListener('click', () => {
selectedFiles = [];
renderFileList();
});
fileListEl.querySelectorAll('.file-list-remove').forEach(btn => {
btn.addEventListener('click', () => removeFile(parseInt(btn.dataset.index)));
});
dropzone.querySelector('p').innerHTML = 'Drop more files or <label for="file-input" class="link">browse</label>';
}
uploadBtn.addEventListener('click', () => {
if (selectedFiles.length === 0 || uploading) return;
uploadAll();
});
async function uploadAll() {
uploading = true;
const files = [...selectedFiles];
const results = [];
const errors = [];
options.classList.add('hidden');
fileListEl.classList.add('hidden');
dropzone.classList.add('hidden');
progressSection.classList.remove('hidden');
errorDiv.classList.add('hidden');
result.classList.add('hidden');
const expires = document.getElementById('expires').value;
const password = document.getElementById('password').value;
const slug = document.getElementById('slug').value.trim();
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (files.length > 1) {
progressOverall.textContent = 'File ' + (i + 1) + ' of ' + files.length;
} else {
progressOverall.textContent = '';
}
progressFilename.textContent = file.name;
progressFill.style.width = '0%';
progressText.textContent = '0%';
try {
const data = await uploadOne(file, {
expires,
password,
slug: files.length === 1 ? slug : ''
});
results.push(data);
} catch (err) {
errors.push({ name: file.name, error: err.message });
}
}
progressSection.classList.add('hidden');
uploading = false;
if (results.length > 0) {
showResults(results);
}
if (errors.length > 0) {
showError(errors.map(e => e.name + ': ' + e.error).join('\n'));
}
// Reset
selectedFiles = [];
dropzone.classList.remove('hidden');
dropzone.querySelector('p').innerHTML = 'Drop a file here or <label for="file-input" class="link">browse</label>';
selectedFile = null;
dropzone.querySelector('p').innerHTML = 'Drop files here or <label for="file-input" class="link">browse</label>';
}
function uploadOne(file, opts) {
return new Promise((resolve, reject) => {
const formData = new FormData();
if (opts.slug) formData.append('slug', opts.slug);
formData.append('file', file);
if (opts.expires) formData.append('expires_in', opts.expires);
if (opts.password) formData.append('password', opts.password);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = pct + '%';
progressText.textContent = pct + '% — ' + formatSize(e.loaded) + ' / ' + formatSize(e.total);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 201) {
resolve(JSON.parse(xhr.responseText));
} else if (xhr.status === 429) {
// Rate limited — retry after a short delay
setTimeout(() => {
uploadOne(file, opts).then(resolve, reject);
}, 1500);
} else {
reject(new Error(xhr.responseText || 'Upload failed'));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Network error'));
});
xhr.open('POST', '/upload');
xhr.send(formData);
});
}
function showResults(results) {
result.classList.remove('hidden');
if (results.length === 1) {
resultHeading.textContent = 'Uploaded!';
resultSingle.classList.remove('hidden');
resultMulti.classList.add('hidden');
const data = results[0];
resultUrl.value = data.url;
resultDelete.textContent = 'Delete: curl -X DELETE -H "Authorization: Bearer ' + data.delete_token + '" ' + data.delete_url;
resultExpiry.textContent = data.expires_at
? 'Expires: ' + new Date(data.expires_at).toLocaleString()
: '';
} else {
resultHeading.textContent = results.length + ' files uploaded!';
resultSingle.classList.add('hidden');
resultMulti.classList.remove('hidden');
resultList.innerHTML = results.map(data =>
'<div class="result-item">' +
'<a href="' + escapeAttr(data.url) + '" target="_blank" class="result-item-name">' + escapeHtml(data.filename) + '</a>' +
'<span class="result-item-url">' + escapeHtml(data.url) + '</span>' +
'</div>'
).join('');
copyAllBtn.onclick = () => {
const urls = results.map(d => d.url).join('\n');
navigator.clipboard.writeText(urls).then(() => {
copyAllBtn.textContent = 'Copied!';
setTimeout(() => { copyAllBtn.textContent = 'Copy all URLs'; }, 2000);
});
};
}
}
function showError(msg) {
errorDiv.textContent = msg;
errorDiv.classList.remove('hidden');
dropzone.classList.remove('hidden');
dropzone.querySelector('p').innerHTML = 'Drop a file here or <label for="file-input" class="link">browse</label>';
selectedFile = null;
}
copyBtn.addEventListener('click', () => {
@ -146,4 +270,14 @@
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function escapeAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
})();