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:
parent
a12ed888e0
commit
51529e4dad
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>upload.jeffemmett.com</title>
|
<title>upload.jeffemmett.com</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=2">
|
<link rel="stylesheet" href="/static/style.css?v=3">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
@ -18,13 +18,15 @@
|
||||||
<polyline points="17 8 12 3 7 8"/>
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p>Drop a file here or <label for="file-input" class="link">browse</label></p>
|
<p>Drop files here or <label for="file-input" class="link">browse</label></p>
|
||||||
<input type="file" id="file-input" hidden>
|
<input type="file" id="file-input" hidden multiple>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="file-list" class="file-list hidden"></div>
|
||||||
|
|
||||||
<div id="options" class="options hidden">
|
<div id="options" class="options hidden">
|
||||||
<div class="option-group">
|
<div id="slug-group" class="option-group">
|
||||||
<label for="slug">Custom URL</label>
|
<label for="slug">Custom URL</label>
|
||||||
<div class="slug-input">
|
<div class="slug-input">
|
||||||
<span class="slug-prefix">/f/</span>
|
<span class="slug-prefix">/f/</span>
|
||||||
|
|
@ -49,6 +51,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="progress-section" class="hidden">
|
<div id="progress-section" class="hidden">
|
||||||
|
<div id="progress-overall" class="progress-overall"></div>
|
||||||
<div class="file-name" id="progress-filename"></div>
|
<div class="file-name" id="progress-filename"></div>
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-fill" id="progress-fill"></div>
|
<div class="progress-fill" id="progress-fill"></div>
|
||||||
|
|
@ -58,13 +61,19 @@
|
||||||
|
|
||||||
<div id="result" class="result hidden">
|
<div id="result" class="result hidden">
|
||||||
<div class="result-success">
|
<div class="result-success">
|
||||||
<p>Uploaded!</p>
|
<p id="result-heading">Uploaded!</p>
|
||||||
<div class="result-url">
|
<div id="result-single">
|
||||||
<input type="text" id="result-url" readonly>
|
<div class="result-url">
|
||||||
<button id="copy-btn" class="btn btn-small">Copy</button>
|
<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>
|
||||||
<div id="result-delete" class="result-meta"></div>
|
|
||||||
<div id="result-expiry" class="result-meta"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -74,6 +83,6 @@
|
||||||
<a href="/cli">CLI tool</a>
|
<a href="/cli">CLI tool</a>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/upload.js"></script>
|
<script src="/static/upload.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,120 @@ footer a:hover { color: var(--accent); }
|
||||||
|
|
||||||
.hidden { display: none !important; }
|
.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 */
|
/* Download page */
|
||||||
.file-card {
|
.file-card {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,29 @@
|
||||||
(() => {
|
(() => {
|
||||||
const dropzone = document.getElementById('dropzone');
|
const dropzone = document.getElementById('dropzone');
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const fileListEl = document.getElementById('file-list');
|
||||||
const options = document.getElementById('options');
|
const options = document.getElementById('options');
|
||||||
|
const slugGroup = document.getElementById('slug-group');
|
||||||
const uploadBtn = document.getElementById('upload-btn');
|
const uploadBtn = document.getElementById('upload-btn');
|
||||||
const progressSection = document.getElementById('progress-section');
|
const progressSection = document.getElementById('progress-section');
|
||||||
|
const progressOverall = document.getElementById('progress-overall');
|
||||||
const progressFilename = document.getElementById('progress-filename');
|
const progressFilename = document.getElementById('progress-filename');
|
||||||
const progressFill = document.getElementById('progress-fill');
|
const progressFill = document.getElementById('progress-fill');
|
||||||
const progressText = document.getElementById('progress-text');
|
const progressText = document.getElementById('progress-text');
|
||||||
const result = document.getElementById('result');
|
const result = document.getElementById('result');
|
||||||
|
const resultHeading = document.getElementById('result-heading');
|
||||||
|
const resultSingle = document.getElementById('result-single');
|
||||||
const resultUrl = document.getElementById('result-url');
|
const resultUrl = document.getElementById('result-url');
|
||||||
const copyBtn = document.getElementById('copy-btn');
|
const copyBtn = document.getElementById('copy-btn');
|
||||||
const resultDelete = document.getElementById('result-delete');
|
const resultDelete = document.getElementById('result-delete');
|
||||||
const resultExpiry = document.getElementById('result-expiry');
|
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');
|
const errorDiv = document.getElementById('error');
|
||||||
|
|
||||||
let selectedFile = null;
|
let selectedFiles = [];
|
||||||
|
let uploading = false;
|
||||||
|
|
||||||
// Drag and drop
|
// Drag and drop
|
||||||
dropzone.addEventListener('dragover', (e) => {
|
dropzone.addEventListener('dragover', (e) => {
|
||||||
|
|
@ -30,106 +39,221 @@
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropzone.classList.remove('drag-over');
|
dropzone.classList.remove('drag-over');
|
||||||
if (e.dataTransfer.files.length > 0) {
|
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', () => {
|
fileInput.addEventListener('change', () => {
|
||||||
if (fileInput.files.length > 0) {
|
if (fileInput.files.length > 0) {
|
||||||
selectFile(fileInput.files[0]);
|
addFiles(fileInput.files);
|
||||||
}
|
}
|
||||||
|
fileInput.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
function selectFile(file) {
|
function addFiles(fileListObj) {
|
||||||
selectedFile = file;
|
for (const f of fileListObj) {
|
||||||
dropzone.querySelector('p').textContent = file.name + ' (' + formatSize(file.size) + ')';
|
// Skip duplicates by name+size
|
||||||
options.classList.remove('hidden');
|
if (!selectedFiles.some(s => s.name === f.name && s.size === f.size)) {
|
||||||
|
selectedFiles.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderFileList();
|
||||||
result.classList.add('hidden');
|
result.classList.add('hidden');
|
||||||
errorDiv.classList.add('hidden');
|
errorDiv.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadBtn.addEventListener('click', () => {
|
function removeFile(index) {
|
||||||
if (!selectedFile) return;
|
selectedFiles.splice(index, 1);
|
||||||
upload(selectedFile);
|
renderFileList();
|
||||||
});
|
|
||||||
|
|
||||||
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 showResult(data) {
|
function renderFileList() {
|
||||||
result.classList.remove('hidden');
|
if (selectedFiles.length === 0) {
|
||||||
resultUrl.value = data.url;
|
fileListEl.classList.add('hidden');
|
||||||
|
options.classList.add('hidden');
|
||||||
resultDelete.textContent = 'Delete: curl -X DELETE -H "Authorization: Bearer ' + data.delete_token + '" ' + data.delete_url;
|
dropzone.querySelector('p').innerHTML = 'Drop files here or <label for="file-input" class="link">browse</label>';
|
||||||
|
return;
|
||||||
if (data.expires_at) {
|
|
||||||
resultExpiry.textContent = 'Expires: ' + new Date(data.expires_at).toLocaleString();
|
|
||||||
} else {
|
|
||||||
resultExpiry.textContent = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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">×</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.classList.remove('hidden');
|
||||||
dropzone.querySelector('p').innerHTML = 'Drop a file here or <label for="file-input" class="link">browse</label>';
|
dropzone.querySelector('p').innerHTML = 'Drop files here or <label for="file-input" class="link">browse</label>';
|
||||||
selectedFile = null;
|
}
|
||||||
|
|
||||||
|
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) {
|
function showError(msg) {
|
||||||
errorDiv.textContent = msg;
|
errorDiv.textContent = msg;
|
||||||
errorDiv.classList.remove('hidden');
|
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', () => {
|
copyBtn.addEventListener('click', () => {
|
||||||
|
|
@ -146,4 +270,14 @@
|
||||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue