Initial commit
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// Progressive enhancement for the upload form. With JavaScript off the plain
|
||||
// multipart POST still works; everything here is additive.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var base = document.body.dataset.base || '/';
|
||||
var form = document.getElementById('upload');
|
||||
if (!form) return;
|
||||
|
||||
var fileInput = document.getElementById('file');
|
||||
var tokenInput = document.getElementById('token');
|
||||
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');
|
||||
|
||||
// Limits as rendered for an anonymous caller; refreshed when a token is typed.
|
||||
var limits = { max_size: null, allow_vanity: false };
|
||||
|
||||
function formatSize(n) {
|
||||
if (n === null || n === undefined) return 'unlimited';
|
||||
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;
|
||||
}
|
||||
|
||||
// --- token memory -------------------------------------------------------
|
||||
// Kept in localStorage only so the field survives a reload; it is never sent
|
||||
// anywhere but this origin's upload endpoint.
|
||||
try {
|
||||
var saved = localStorage.getItem('send.token');
|
||||
if (saved && tokenInput) tokenInput.value = saved;
|
||||
} catch (e) { /* private mode; not important */ }
|
||||
|
||||
function refreshLimits() {
|
||||
var token = tokenInput ? tokenInput.value.trim() : '';
|
||||
try {
|
||||
if (token) localStorage.setItem('send.token', token);
|
||||
else localStorage.removeItem('send.token');
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', base + 'api/limits');
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token);
|
||||
xhr.onload = function () {
|
||||
if (xhr.status !== 200) return;
|
||||
var l;
|
||||
try { l = JSON.parse(xhr.responseText); } catch (e) { return; }
|
||||
limits = l;
|
||||
set('limit-size', l.max_size === null ? 'unlimited' : formatSize(l.max_size));
|
||||
set('limit-expiry', l.max_expiry || 'never');
|
||||
set('limit-default', l.default_expiry || 'never');
|
||||
set('limit-vanity', l.allow_vanity ? 'allowed' : 'requires a token');
|
||||
var vanity = document.getElementById('vanity');
|
||||
if (vanity) vanity.disabled = !l.allow_vanity;
|
||||
var expiry = document.getElementById('expiry');
|
||||
if (expiry) expiry.placeholder = l.default_expiry || 'never';
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function set(id, text) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
if (tokenInput) {
|
||||
var debounce;
|
||||
tokenInput.addEventListener('input', function () {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(refreshLimits, 400);
|
||||
});
|
||||
}
|
||||
refreshLimits();
|
||||
|
||||
// --- 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 (limits.max_size && file.size > limits.max_size) {
|
||||
e.preventDefault();
|
||||
showError('That file is ' + formatSize(file.size) + '; the limit is ' +
|
||||
formatSize(limits.max_size) + '.');
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
showError('');
|
||||
submit.disabled = true;
|
||||
progress.hidden = false;
|
||||
|
||||
// FormData follows DOM order, so the token, expiry and vanity fields all
|
||||
// precede the file part — which is exactly what the server requires.
|
||||
var data = new FormData(form);
|
||||
var started = Date.now();
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', form.action);
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
|
||||
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 () {
|
||||
submit.disabled = false;
|
||||
var body;
|
||||
try { body = JSON.parse(xhr.responseText); } catch (err) { body = null; }
|
||||
if (xhr.status >= 200 && xhr.status < 300 && body) {
|
||||
showResult(body);
|
||||
} else {
|
||||
progress.hidden = true;
|
||||
showError(body && body.error ? body.error : 'Upload failed (' + xhr.status + ').');
|
||||
}
|
||||
};
|
||||
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';
|
||||
}
|
||||
|
||||
function showResult(r) {
|
||||
var card = document.createElement('section');
|
||||
card.className = 'card result';
|
||||
|
||||
var h = document.createElement('h1');
|
||||
h.textContent = 'Uploaded';
|
||||
card.appendChild(h);
|
||||
|
||||
var field = document.createElement('label');
|
||||
field.className = 'field';
|
||||
var span = document.createElement('span');
|
||||
span.textContent = 'Download link';
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.readOnly = true;
|
||||
input.value = r.url;
|
||||
field.appendChild(span);
|
||||
field.appendChild(input);
|
||||
card.appendChild(field);
|
||||
|
||||
var copy = document.createElement('button');
|
||||
copy.type = 'button';
|
||||
copy.textContent = 'Copy link';
|
||||
copy.addEventListener('click', function () {
|
||||
input.select();
|
||||
var done = function () { copy.textContent = 'Copied'; };
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(r.url).then(done, function () {});
|
||||
else { document.execCommand('copy'); done(); }
|
||||
});
|
||||
card.appendChild(copy);
|
||||
|
||||
var dl = document.createElement('dl');
|
||||
addRow(dl, 'Expires', r.expires || 'never');
|
||||
addRow(dl, 'SHA-256', r.sha256, 'mono wrap');
|
||||
card.appendChild(dl);
|
||||
|
||||
var warn = document.createElement('div');
|
||||
warn.className = 'warn';
|
||||
var wh = document.createElement('h2');
|
||||
wh.textContent = 'Delete token';
|
||||
var wp = document.createElement('p');
|
||||
wp.textContent = 'Shown once. Keep it if you want to remove the file early.';
|
||||
var wt = document.createElement('p');
|
||||
wt.className = 'mono wrap';
|
||||
wt.textContent = r.delete_token;
|
||||
warn.appendChild(wh);
|
||||
warn.appendChild(wp);
|
||||
warn.appendChild(wt);
|
||||
card.appendChild(warn);
|
||||
|
||||
form.parentNode.insertBefore(card, form);
|
||||
form.hidden = true;
|
||||
}
|
||||
|
||||
function addRow(dl, label, value, cls) {
|
||||
var dt = document.createElement('dt');
|
||||
dt.textContent = label;
|
||||
var dd = document.createElement('dd');
|
||||
dd.textContent = value;
|
||||
if (cls) dd.className = cls;
|
||||
dl.appendChild(dt);
|
||||
dl.appendChild(dd);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,144 @@
|
||||
/* Hand-written, no build step, no framework. */
|
||||
|
||||
:root {
|
||||
--bg: #f6f6f4;
|
||||
--fg: #1b1b18;
|
||||
--muted: #6b6b63;
|
||||
--card: #ffffff;
|
||||
--line: #dcdcd5;
|
||||
--accent: #2f6f4f;
|
||||
--accent-fg: #ffffff;
|
||||
--danger: #9b2c2c;
|
||||
--warn-bg: #fdf6e3;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #16161a;
|
||||
--fg: #e8e8e3;
|
||||
--muted: #9a9a92;
|
||||
--card: #1f1f25;
|
||||
--line: #33333c;
|
||||
--accent: #5fae86;
|
||||
--accent-fg: #10241a;
|
||||
--danger: #e07070;
|
||||
--warn-bg: #2a2418;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0 1rem 3rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
header, main, footer { max-width: 42rem; margin: 0 auto; }
|
||||
header { padding: 1.5rem 0; }
|
||||
footer { padding-top: 2rem; color: var(--muted); font-size: .875rem; }
|
||||
|
||||
.brand {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.375rem; margin: 0 0 .75rem; }
|
||||
h2 { font-size: 1rem; margin: 0 0 .5rem; }
|
||||
a { color: var(--accent); }
|
||||
|
||||
.card, .limits, .cli {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.field { display: block; margin-bottom: 1rem; }
|
||||
.field > span { display: block; font-size: .8125rem; color: var(--muted); margin-bottom: .25rem; }
|
||||
.field em { font-style: normal; opacity: .7; }
|
||||
|
||||
input[type=text], input[type=password] {
|
||||
width: 100%;
|
||||
padding: .5rem .625rem;
|
||||
font: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.row > .field { flex: 1 1 12rem; }
|
||||
|
||||
.drop {
|
||||
border: 2px dashed var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.drop.over { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||
.drop input[type=file] { max-width: 100%; }
|
||||
|
||||
button, .button {
|
||||
display: inline-block;
|
||||
padding: .5rem 1rem;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
button:disabled { opacity: .55; cursor: default; }
|
||||
button.danger { background: var(--danger); color: #fff; }
|
||||
|
||||
.progress .bar {
|
||||
height: .5rem;
|
||||
background: var(--line);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: .375rem;
|
||||
}
|
||||
.progress .fill { height: 100%; width: 0; background: var(--accent); transition: width .1s linear; }
|
||||
|
||||
.hint { color: var(--muted); font-size: .875rem; margin: .25rem 0 0; }
|
||||
#error:not([hidden]) { color: var(--danger); }
|
||||
|
||||
dl { display: grid; grid-template-columns: auto 1fr; gap: .25rem 1rem; margin: 0; }
|
||||
dt { color: var(--muted); font-size: .875rem; }
|
||||
dd { margin: 0; }
|
||||
|
||||
.mono, pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8125rem; }
|
||||
.wrap { overflow-wrap: anywhere; }
|
||||
.filename { overflow-wrap: anywhere; }
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: .75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.warn {
|
||||
margin: 1.25rem 0;
|
||||
padding: 1rem;
|
||||
background: var(--warn-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.warn p { margin: .25rem 0 .75rem; font-size: .875rem; }
|
||||
|
||||
.result .field { margin-top: 1rem; }
|
||||
Reference in New Issue
Block a user