181 lines
6.8 KiB
JavaScript
181 lines
6.8 KiB
JavaScript
// Progressive enhancement for the upload form. With JavaScript off the plain
|
|
// multipart POST still works; everything here is additive.
|
|
(function () {
|
|
'use strict';
|
|
|
|
// Copy buttons appear on the server-rendered result page too, which has no
|
|
// upload form, so they are wired before anything form-specific bails out.
|
|
// They ship hidden so that without this script there is no dead button.
|
|
function wireCopy() {
|
|
var buttons = document.querySelectorAll('button.copy[data-copy]');
|
|
Array.prototype.forEach.call(buttons, function (button) {
|
|
var target = document.getElementById(button.dataset.copy);
|
|
if (!target) return;
|
|
button.hidden = false;
|
|
|
|
var label = button.textContent, timer;
|
|
function say(word) {
|
|
button.textContent = word;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(function () { button.textContent = label; }, 1500);
|
|
}
|
|
|
|
// Copying the selection needs neither a permission nor a secure context,
|
|
// so it answers for both of the ways the clipboard API can let us down:
|
|
// missing entirely (this service is reached over plain HTTP as well as
|
|
// HTTPS, and the API exists only on the secure side) or present and
|
|
// refusing (an unfocused document, a permissions policy). If even that
|
|
// fails, the text is selected either way, so say what to press.
|
|
function copySelection() {
|
|
var ok = false;
|
|
try { ok = document.execCommand('copy'); } catch (e) { /* ok stays false */ }
|
|
say(ok ? 'Copied' : 'Press Ctrl-C');
|
|
}
|
|
|
|
button.addEventListener('click', function () {
|
|
target.select();
|
|
target.setSelectionRange(0, target.value.length); // iOS ignores select()
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(target.value).then(function () {
|
|
say('Copied');
|
|
}, copySelection);
|
|
} else {
|
|
copySelection();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
wireCopy();
|
|
|
|
// Destructive buttons ask first. The attribute carries the question, so the
|
|
// markup decides what is worth confirming and this stays generic.
|
|
Array.prototype.forEach.call(document.querySelectorAll('[data-confirm]'), function (button) {
|
|
button.addEventListener('click', function (e) {
|
|
if (!window.confirm(button.dataset.confirm)) e.preventDefault();
|
|
});
|
|
});
|
|
|
|
var form = document.getElementById('upload');
|
|
if (!form) return;
|
|
|
|
var fileInput = document.getElementById('file');
|
|
var drop = document.getElementById('drop');
|
|
var dropHint = document.getElementById('drop-hint');
|
|
var progress = document.getElementById('progress');
|
|
var barFill = document.getElementById('bar-fill');
|
|
var progressText = document.getElementById('progress-text');
|
|
var submit = document.getElementById('submit');
|
|
var errorBox = document.getElementById('error');
|
|
|
|
// The session's limits are rendered by the server, so this script never has
|
|
// to ask who the visitor is. Zero means unlimited.
|
|
var maxSize = parseInt(form.dataset.maxSize, 10) || 0;
|
|
|
|
function formatSize(n) {
|
|
var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'], i = 0;
|
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
|
return (i === 0 ? n : n.toFixed(2).replace(/\.00$/, '')) + ' ' + units[i];
|
|
}
|
|
|
|
function showError(msg) {
|
|
errorBox.textContent = msg;
|
|
errorBox.hidden = !msg;
|
|
}
|
|
|
|
// --- drag and drop ------------------------------------------------------
|
|
function describeSelection() {
|
|
var f = fileInput.files[0];
|
|
dropHint.textContent = f ? f.name + ' - ' + formatSize(f.size)
|
|
: 'Choose a file, or drop one here.';
|
|
}
|
|
|
|
['dragenter', 'dragover'].forEach(function (name) {
|
|
drop.addEventListener(name, function (e) {
|
|
e.preventDefault();
|
|
drop.classList.add('over');
|
|
});
|
|
});
|
|
['dragleave', 'drop'].forEach(function (name) {
|
|
drop.addEventListener(name, function () { drop.classList.remove('over'); });
|
|
});
|
|
drop.addEventListener('drop', function (e) {
|
|
e.preventDefault();
|
|
if (!e.dataTransfer.files.length) return;
|
|
// DataTransfer is the only way to put a dropped file into a file input.
|
|
var dt = new DataTransfer();
|
|
dt.items.add(e.dataTransfer.files[0]);
|
|
fileInput.files = dt.files;
|
|
describeSelection();
|
|
});
|
|
fileInput.addEventListener('change', describeSelection);
|
|
|
|
// --- upload -------------------------------------------------------------
|
|
form.addEventListener('submit', function (e) {
|
|
var file = fileInput.files[0];
|
|
if (!file) return; // let the browser's own validation speak
|
|
|
|
if (maxSize && file.size > maxSize) {
|
|
e.preventDefault();
|
|
showError('That file is ' + formatSize(file.size) + '; your limit is ' +
|
|
formatSize(maxSize) + '.');
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
showError('');
|
|
submit.disabled = true;
|
|
progress.hidden = false;
|
|
|
|
// FormData follows markup order, not painted order, so the options and the
|
|
// token precede the file part exactly as the server requires. The drop
|
|
// zone only looks like it comes first; see the order rules in style.css.
|
|
var data = new FormData(form);
|
|
var started = Date.now();
|
|
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', form.action);
|
|
// Ask for the page a plain form post would have been given. What a
|
|
// finished upload looks like is the server's business, and it already
|
|
// renders it; this script only adds the progress bar on the way there.
|
|
xhr.setRequestHeader('Accept', 'text/html');
|
|
|
|
xhr.upload.onprogress = function (ev) {
|
|
if (!ev.lengthComputable) return;
|
|
var pct = ev.loaded / ev.total;
|
|
barFill.style.width = (pct * 100).toFixed(1) + '%';
|
|
var elapsed = (Date.now() - started) / 1000;
|
|
var rate = elapsed > 0 ? ev.loaded / elapsed : 0;
|
|
var eta = rate > 0 ? (ev.total - ev.loaded) / rate : 0;
|
|
progressText.textContent =
|
|
(pct * 100).toFixed(0) + '% - ' + formatSize(ev.loaded) + ' of ' +
|
|
formatSize(ev.total) + ' - ' + formatSize(rate) + '/s' +
|
|
(eta > 1 ? ', ' + formatTime(eta) + ' left' : '');
|
|
};
|
|
|
|
xhr.onload = function () {
|
|
// Whatever came back, the result page or an error page, is the page the
|
|
// browser should be showing now. Writing it into this document rather
|
|
// than navigating leaves the address on the form's own URL, so a reload
|
|
// offers a fresh form instead of posting the file a second time.
|
|
document.open();
|
|
document.write(xhr.responseText);
|
|
document.close();
|
|
};
|
|
xhr.onerror = function () {
|
|
submit.disabled = false;
|
|
progress.hidden = true;
|
|
showError('Upload failed: the connection was lost.');
|
|
};
|
|
|
|
xhr.send(data);
|
|
});
|
|
|
|
function formatTime(s) {
|
|
s = Math.round(s);
|
|
if (s < 60) return s + 's';
|
|
var m = Math.floor(s / 60);
|
|
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
|
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
|
|
}
|
|
})();
|