Add Improve UI error handling, add download resuming, improve cleanup
code
This commit is contained in:
+273
-56
@@ -6,15 +6,19 @@
|
||||
<title>Beam - File Upload Service</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { color-scheme: light; }
|
||||
body {
|
||||
font-family: monospace;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
h1 { margin-bottom: 30px; font-size: 2em; }
|
||||
h2 { margin: 30px 0 15px 0; font-size: 1.3em; }
|
||||
h3 { margin: 0 0 10px 0; font-size: 1.05em; }
|
||||
.section {
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
@@ -30,12 +34,13 @@
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.note { margin-top: 10px; }
|
||||
.upload-area {
|
||||
border: 3px dashed #ccc;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: border-color 0.3s, background 0.3s;
|
||||
}
|
||||
.upload-area.dragover {
|
||||
border-color: #333;
|
||||
@@ -44,6 +49,12 @@
|
||||
.upload-area:hover {
|
||||
border-color: #666;
|
||||
}
|
||||
.upload-area.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.upload-area.disabled:hover { border-color: #ccc; }
|
||||
.hint { margin-top: 10px; font-size: 0.85em; color: #666; }
|
||||
input[type="file"] { display: none; }
|
||||
button {
|
||||
background: #333;
|
||||
@@ -57,13 +68,24 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
button:hover { background: #555; }
|
||||
button:disabled { background: #999; cursor: not-allowed; }
|
||||
:focus-visible {
|
||||
outline: 3px solid #0066cc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.progress-container.active { display: flex; }
|
||||
.progress-track {
|
||||
flex: 1;
|
||||
background: #ddd;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
height: 20px;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
@@ -72,6 +94,7 @@
|
||||
width: 0;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.progress-text { font-size: 0.85em; min-width: 4ch; text-align: right; }
|
||||
.results {
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -82,17 +105,43 @@
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.result-item a {
|
||||
.result-item.error {
|
||||
background: #fdecea;
|
||||
color: #8a1f11;
|
||||
border-left: 4px solid #c0392b;
|
||||
}
|
||||
.result-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.result-row a {
|
||||
color: #0066cc;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.copy-button {
|
||||
margin-top: 0;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.85em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.version {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
bottom: 6px;
|
||||
color: #999;
|
||||
color: #767676;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; }
|
||||
}
|
||||
|
||||
@media (max-height: 800px) {
|
||||
body { margin: 15px auto; padding: 15px; }
|
||||
h1 { margin-bottom: 15px; font-size: 1.6em; }
|
||||
@@ -110,37 +159,44 @@
|
||||
code { padding: 10px; font-size: 0.85em; }
|
||||
.upload-area { padding: 20px 15px; }
|
||||
button { padding: 12px 20px; }
|
||||
.copy-button { padding: 8px 12px; }
|
||||
.result-row { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🚀 Beam - File Upload Service</h1>
|
||||
<h1><span aria-hidden="true">🚀</span> Beam - File Upload Service</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Upload with curl</h2>
|
||||
<code>curl --upload-file myfile.txt {{ .Proto }}://{{ .Host }}/myfile.txt</code>
|
||||
<p style="margin-top: 10px;">The server will respond with a download URL.</p>
|
||||
<p style="margin-top: 10px;">Maximum upload size: <strong id="maxUploadSizeLabel" data-max-bytes="{{ .MaxUploadSize }}">{{ .MaxUploadSize }} bytes</strong></p>
|
||||
<p class="note">The server responds with the download URL.</p>
|
||||
<p class="note">Maximum upload size: <strong id="maxUploadSizeLabel" data-max-bytes="{{ .MaxUploadSize }}">{{ .MaxUploadSize }} bytes</strong>.
|
||||
Uploaded files are deleted after <strong>{{ .Expiry }}</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Download a file</h2>
|
||||
<code>curl {{ .Proto }}://{{ .Host }}/x123abcx/myfile.txt -o myfile.txt</code>
|
||||
<p style="margin-top: 10px;">Or simply open the URL in your browser.</p>
|
||||
<p class="note">Or simply open the URL in your browser.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Web Upload</h2>
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<p>📁 Click to select files or drag and drop here</p>
|
||||
<p style="margin-top: 10px; font-size: 0.85em; color: #666;">Maximum file size: <span id="maxUploadSizeText"></span></p>
|
||||
<div class="upload-area" id="uploadArea" role="button" tabindex="0" aria-label="Select files to upload, or drag and drop them here">
|
||||
<p><span aria-hidden="true">📁</span> Click to select files or drag and drop here</p>
|
||||
<p class="hint">Maximum total size: <span id="maxUploadSizeText"></span> · deleted after {{ .Expiry }}</p>
|
||||
<input type="file" id="fileInput" multiple>
|
||||
<button type="button" onclick="event.stopPropagation(); document.getElementById('fileInput').click()">Select Files</button>
|
||||
<button type="button" id="selectButton">Select Files</button>
|
||||
<button type="button" id="cancelButton" hidden>Cancel</button>
|
||||
</div>
|
||||
<div class="progress-container" id="progressContainer">
|
||||
<div class="progress-bar" id="progressBar"></div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-bar" id="progressBar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Upload progress"></div>
|
||||
</div>
|
||||
<span class="progress-text" id="progressText">0%</span>
|
||||
</div>
|
||||
<div class="results" id="results"></div>
|
||||
<div class="results" id="results" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="version">v{{ .Version }}</div>
|
||||
@@ -148,11 +204,18 @@
|
||||
<script>
|
||||
const uploadArea = document.getElementById('uploadArea');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const selectButton = document.getElementById('selectButton');
|
||||
const cancelButton = document.getElementById('cancelButton');
|
||||
const progressContainer = document.getElementById('progressContainer');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const results = document.getElementById('results');
|
||||
const maxUploadSize = parseInt(document.getElementById('maxUploadSizeLabel').dataset.maxBytes, 10) || 0;
|
||||
|
||||
let currentXhr = null;
|
||||
let uploading = false;
|
||||
let dragDepth = 0;
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return 'unknown';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
@@ -168,43 +231,200 @@
|
||||
document.getElementById('maxUploadSizeLabel').textContent = formatBytes(maxUploadSize);
|
||||
document.getElementById('maxUploadSizeText').textContent = formatBytes(maxUploadSize);
|
||||
|
||||
function clearResults() {
|
||||
results.replaceChildren();
|
||||
function setProgress(percent) {
|
||||
const rounded = Math.round(percent);
|
||||
progressBar.style.width = percent + '%';
|
||||
progressBar.setAttribute('aria-valuenow', rounded);
|
||||
progressText.textContent = rounded + '%';
|
||||
}
|
||||
|
||||
function showMessage(message) {
|
||||
function showProgress() {
|
||||
progressContainer.classList.add('active');
|
||||
setProgress(0);
|
||||
}
|
||||
|
||||
// Hide rather than leave a stale bar behind: a failed upload must not
|
||||
// display a full, complete-looking bar.
|
||||
function hideProgress() {
|
||||
progressContainer.classList.remove('active');
|
||||
setProgress(0);
|
||||
}
|
||||
|
||||
function setUploading(active) {
|
||||
uploading = active;
|
||||
selectButton.disabled = active;
|
||||
cancelButton.hidden = !active;
|
||||
uploadArea.classList.toggle('disabled', active);
|
||||
uploadArea.setAttribute('aria-disabled', String(active));
|
||||
}
|
||||
|
||||
function showMessage(message, isError) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'result-item';
|
||||
div.className = isError ? 'result-item error' : 'result-item';
|
||||
div.textContent = message;
|
||||
results.replaceChildren(div);
|
||||
}
|
||||
|
||||
function filenameFromURL(url) {
|
||||
try {
|
||||
const path = new URL(url, window.location.href).pathname;
|
||||
return decodeURIComponent(path.substring(path.lastIndexOf('/') + 1));
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
// The async Clipboard API requires a secure context, so it is
|
||||
// unavailable when beam is reached over plain HTTP.
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (e) { /* fall through to the legacy path */ }
|
||||
}
|
||||
|
||||
const scratch = document.createElement('textarea');
|
||||
scratch.value = text;
|
||||
scratch.setAttribute('readonly', '');
|
||||
scratch.style.position = 'fixed';
|
||||
scratch.style.opacity = '0';
|
||||
document.body.appendChild(scratch);
|
||||
scratch.select();
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand('copy');
|
||||
} catch (e) {
|
||||
copied = false;
|
||||
}
|
||||
document.body.removeChild(scratch);
|
||||
return copied;
|
||||
}
|
||||
|
||||
function resultRow(url) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'result-item';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'result-name';
|
||||
name.textContent = filenameFromURL(url);
|
||||
div.appendChild(name);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'result-row';
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = url;
|
||||
row.appendChild(link);
|
||||
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.type = 'button';
|
||||
copyButton.className = 'copy-button';
|
||||
copyButton.textContent = 'Copy';
|
||||
copyButton.setAttribute('aria-label', 'Copy download URL for ' + filenameFromURL(url));
|
||||
copyButton.addEventListener('click', async () => {
|
||||
const copied = await copyToClipboard(url);
|
||||
copyButton.textContent = copied ? 'Copied!' : 'Press Ctrl+C';
|
||||
if (!copied) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(link);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
setTimeout(() => { copyButton.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
row.appendChild(copyButton);
|
||||
|
||||
div.appendChild(row);
|
||||
return div;
|
||||
}
|
||||
|
||||
function displayResults(urls, failed) {
|
||||
if (!Array.isArray(urls)) {
|
||||
showMessage('Upload failed: Unexpected response from server', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = urls.length === 1 ? '1 file uploaded' : urls.length + ' files uploaded';
|
||||
fragment.appendChild(heading);
|
||||
|
||||
urls.forEach(url => fragment.appendChild(resultRow(url)));
|
||||
|
||||
(failed || []).forEach(name => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'result-item error';
|
||||
div.textContent = 'Failed to upload: ' + name;
|
||||
fragment.appendChild(div);
|
||||
});
|
||||
|
||||
results.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
selectButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
cancelButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (currentXhr) currentXhr.abort();
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
if (uploading) return;
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('keydown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
if (!uploading) fileInput.click();
|
||||
});
|
||||
|
||||
// Track enter/leave depth: without it, dragging over the child
|
||||
// elements fires dragleave and the highlight flickers.
|
||||
uploadArea.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
dragDepth++;
|
||||
if (!uploading) uploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('dragover');
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (dragDepth === 0) uploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
uploadArea.classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
uploadFiles(files);
|
||||
uploadFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
uploadFiles(e.target.files);
|
||||
// Reset so re-selecting the same file fires change again.
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
function uploadFiles(files) {
|
||||
if (uploading) {
|
||||
showMessage('An upload is already in progress.', true);
|
||||
return;
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
|
||||
const oversizedFiles = [];
|
||||
@@ -218,13 +438,15 @@
|
||||
|
||||
if (oversizedFiles.length > 0) {
|
||||
showMessage('Upload failed: the following file(s) exceed the maximum size of ' +
|
||||
formatBytes(maxUploadSize) + ': ' + oversizedFiles.join(', '));
|
||||
formatBytes(maxUploadSize) + ': ' + oversizedFiles.join(', '), true);
|
||||
return;
|
||||
}
|
||||
|
||||
// The server limits the whole request body, so the total matters
|
||||
// even when each individual file fits.
|
||||
if (maxUploadSize && totalSize > maxUploadSize) {
|
||||
showMessage('Upload failed: total upload size (' + formatBytes(totalSize) +
|
||||
') exceeds the maximum of ' + formatBytes(maxUploadSize));
|
||||
') exceeds the maximum of ' + formatBytes(maxUploadSize), true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -233,27 +455,29 @@
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
progressContainer.style.display = 'block';
|
||||
progressBar.style.width = '0%';
|
||||
clearResults();
|
||||
showProgress();
|
||||
results.replaceChildren();
|
||||
setUploading(true);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
currentXhr = xhr;
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = (e.loaded / e.total) * 100;
|
||||
progressBar.style.width = percent + '%';
|
||||
setProgress((e.loaded / e.total) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 200) {
|
||||
let response;
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
displayResults(response.urls);
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
showMessage('Upload failed: Unexpected response from server');
|
||||
showMessage('Upload failed: Unexpected response from server', true);
|
||||
return;
|
||||
}
|
||||
displayResults(response.urls, response.failed);
|
||||
} else {
|
||||
let message = xhr.statusText;
|
||||
try {
|
||||
@@ -266,37 +490,30 @@
|
||||
message = xhr.responseText;
|
||||
}
|
||||
}
|
||||
showMessage('Upload failed: ' + message);
|
||||
showMessage('Upload failed: ' + message, true);
|
||||
}
|
||||
progressBar.style.width = '100%';
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
showMessage('Upload failed: Network error');
|
||||
showMessage('Upload failed: Network error', true);
|
||||
});
|
||||
|
||||
xhr.addEventListener('abort', () => {
|
||||
showMessage('Upload cancelled.', true);
|
||||
});
|
||||
|
||||
// Runs after load/error/abort, so every outcome clears the bar.
|
||||
// No xhr.timeout: it caps the whole request and would kill
|
||||
// legitimate multi-gigabyte uploads.
|
||||
xhr.addEventListener('loadend', () => {
|
||||
currentXhr = null;
|
||||
setUploading(false);
|
||||
hideProgress();
|
||||
});
|
||||
|
||||
xhr.open('POST', '/upload');
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
function displayResults(urls) {
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = 'Download URLs:';
|
||||
results.replaceChildren(heading);
|
||||
|
||||
urls.forEach(url => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'result-item';
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = url;
|
||||
div.appendChild(link);
|
||||
results.appendChild(div);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user