Add upload to navbar

This commit is contained in:
2026-09-13 12:12:45 +02:00
parent cb34bae784
commit 000f9d6984
5 changed files with 34 additions and 55 deletions
+16 -26
View File
@@ -1,11 +1,11 @@
// Progressive enhancement for the upload form. With JavaScript off the plain // Everything here is additive: with JavaScript off, the plain multipart POST
// multipart POST still works; everything here is additive. // still works.
(function () { (function () {
'use strict'; 'use strict';
// Copy buttons appear on the server-rendered result page too, which has no // Wired before the form-specific code bails out, because the result page has
// upload form, so they are wired before anything form-specific bails out. // copy buttons and no form. They ship hidden, so without this there is no
// They ship hidden so that without this script there is no dead button. // dead button.
function wireCopy() { function wireCopy() {
var buttons = document.querySelectorAll('button.copy[data-copy]'); var buttons = document.querySelectorAll('button.copy[data-copy]');
Array.prototype.forEach.call(buttons, function (button) { Array.prototype.forEach.call(buttons, function (button) {
@@ -20,12 +20,9 @@
timer = setTimeout(function () { button.textContent = label; }, 1500); timer = setTimeout(function () { button.textContent = label; }, 1500);
} }
// Copying the selection needs neither a permission nor a secure context, // Answers both ways the clipboard API fails us: absent over plain HTTP,
// so it answers for both of the ways the clipboard API can let us down: // where it is not a secure context, and present but refusing. Needs no
// missing entirely (this service is reached over plain HTTP as well as // permission. If it fails too, the text is selected, so say what to press.
// 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() { function copySelection() {
var ok = false; var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { /* ok stays false */ } try { ok = document.execCommand('copy'); } catch (e) { /* ok stays false */ }
@@ -47,8 +44,6 @@
} }
wireCopy(); 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) { Array.prototype.forEach.call(document.querySelectorAll('[data-confirm]'), function (button) {
button.addEventListener('click', function (e) { button.addEventListener('click', function (e) {
if (!window.confirm(button.dataset.confirm)) e.preventDefault(); if (!window.confirm(button.dataset.confirm)) e.preventDefault();
@@ -67,8 +62,7 @@
var submit = document.getElementById('submit'); var submit = document.getElementById('submit');
var errorBox = document.getElementById('error'); var errorBox = document.getElementById('error');
// The session's limits are rendered by the server, so this script never has // Zero means unlimited.
// to ask who the visitor is. Zero means unlimited.
var maxSize = parseInt(form.dataset.maxSize, 10) || 0; var maxSize = parseInt(form.dataset.maxSize, 10) || 0;
function formatSize(n) { function formatSize(n) {
@@ -82,7 +76,6 @@
errorBox.hidden = !msg; errorBox.hidden = !msg;
} }
// --- drag and drop ------------------------------------------------------
function describeSelection() { function describeSelection() {
var f = fileInput.files[0]; var f = fileInput.files[0];
dropHint.textContent = f ? f.name + ' - ' + formatSize(f.size) dropHint.textContent = f ? f.name + ' - ' + formatSize(f.size)
@@ -109,7 +102,6 @@
}); });
fileInput.addEventListener('change', describeSelection); fileInput.addEventListener('change', describeSelection);
// --- upload -------------------------------------------------------------
form.addEventListener('submit', function (e) { form.addEventListener('submit', function (e) {
var file = fileInput.files[0]; var file = fileInput.files[0];
if (!file) return; // let the browser's own validation speak if (!file) return; // let the browser's own validation speak
@@ -127,16 +119,15 @@
progress.hidden = false; progress.hidden = false;
// FormData follows markup order, not painted order, so the options and the // FormData follows markup order, not painted order, so the options and the
// token precede the file part exactly as the server requires. The drop // token precede the file part as the server requires. The drop zone only
// zone only looks like it comes first; see the order rules in style.css. // looks like it comes first; see the order rules in style.css.
var data = new FormData(form); var data = new FormData(form);
var started = Date.now(); var started = Date.now();
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open('POST', form.action); xhr.open('POST', form.action);
// Ask for the page a plain form post would have been given. What a // Ask for the page a plain form post would get: the server owns what a
// finished upload looks like is the server's business, and it already // finished upload looks like, and this only adds a progress bar.
// renders it; this script only adds the progress bar on the way there.
xhr.setRequestHeader('Accept', 'text/html'); xhr.setRequestHeader('Accept', 'text/html');
xhr.upload.onprogress = function (ev) { xhr.upload.onprogress = function (ev) {
@@ -153,10 +144,9 @@
}; };
xhr.onload = function () { xhr.onload = function () {
// Whatever came back, the result page or an error page, is the page the // Writing the response into this document rather than navigating leaves
// browser should be showing now. Writing it into this document rather // the address on the form's URL, so a reload offers a fresh form instead
// than navigating leaves the address on the form's own URL, so a reload // of posting the file again.
// offers a fresh form instead of posting the file a second time.
document.open(); document.open();
document.write(xhr.responseText); document.write(xhr.responseText);
document.close(); document.close();
+9 -23
View File
@@ -83,10 +83,9 @@ input[type=text], input[type=password] {
.row { display: flex; gap: 1rem; flex-wrap: wrap; } .row { display: flex; gap: 1rem; flex-wrap: wrap; }
.row > .field { flex: 1 1 12rem; } .row > .field { flex: 1 1 12rem; }
/* The file input has to be the last part in the submitted body, so it sits at /* The file input must be the last part in the submitted body, so it sits last
the bottom of the form's markup and is lifted back to the top here. Only the in the markup and is lifted back to the top here. Only the painting order
painting order moves; the submission order, which the server depends on, is moves; the server depends on the markup's. */
the markup's. */
#upload { display: flex; flex-direction: column; } #upload { display: flex; flex-direction: column; }
#upload > .drop { order: -1; } #upload > .drop { order: -1; }
#upload > button { align-self: flex-start; } #upload > button { align-self: flex-start; }
@@ -184,17 +183,13 @@ button.link {
cursor: pointer; cursor: pointer;
} }
/* Result card ------------------------------------------------------------ */
.result .links { margin: 1.25rem 0 0; } .result .links { margin: 1.25rem 0 0; }
.result .links .field { margin-bottom: 1rem; } .result .links .field { margin-bottom: 1rem; }
/* A read-only URL with its copy button alongside. */
.copyrow { display: flex; gap: .5rem; align-items: stretch; } .copyrow { display: flex; gap: .5rem; align-items: stretch; }
.copyrow input { flex: 1 1 auto; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8125rem; } .copyrow input { flex: 1 1 auto; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8125rem; }
.copyrow .copy { flex: 0 0 auto; } .copyrow .copy { flex: 0 0 auto; }
/* Keep the detail list clear of the copy buttons above it. */
.result dl { margin-top: 1.75rem; padding-top: 1.25rem; border-top: 1px solid var(--line); } .result dl { margin-top: 1.75rem; padding-top: 1.25rem; border-top: 1px solid var(--line); }
.result dl dd { margin-bottom: .25rem; } .result dl dd { margin-bottom: .25rem; }
@@ -205,8 +200,6 @@ button.link {
.copyrow input { flex-basis: 100%; } .copyrow input { flex-basis: 100%; }
} }
/* Administration --------------------------------------------------------- */
header { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; } header { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; }
header nav { header nav {
margin-left: auto; margin-left: auto;
@@ -244,8 +237,6 @@ table.admin .nowrap { white-space: nowrap; }
table.admin form { margin: 0; } table.admin form { margin: 0; }
table.admin .actions-cell { text-align: right; width: 1%; } table.admin .actions-cell { text-align: right; width: 1%; }
/* The filename carries the link and the identifier sits under it, which saves
a whole column of UUID. */
table.admin .filecell a { overflow-wrap: anywhere; font-weight: 500; } table.admin .filecell a { overflow-wrap: anywhere; font-weight: 500; }
table.admin .filecell .id { table.admin .filecell .id {
display: block; display: block;
@@ -264,8 +255,8 @@ button.small { padding: .2rem .5rem; font-size: .75rem; }
overflow: hidden; overflow: hidden;
} }
/* Narrow screens get one card per row rather than a horizontal scrollbar, /* One card per row beats a horizontal scrollbar, which is unusable for picking
which is unusable for picking a row and pressing a button in it. */ a row and pressing a button in it. */
@media (max-width: 46rem) { @media (max-width: 46rem) {
table.admin, table.admin,
table.admin tbody, table.admin tbody,
@@ -289,10 +280,8 @@ button.small { padding: .2rem .5rem; font-size: .75rem; }
.cli code, .hint code { background: var(--bg); padding: .1rem .3rem; border-radius: 4px; } .cli code, .hint code { background: var(--bg); padding: .1rem .3rem; border-radius: 4px; }
/* Disclosure panels: the one-off token on the upload form, "Remove this file" /* Disclosure panels. Open, a panel needs an edge, or there is no telling where
on the info page. Closed, a panel is a single line and needs nothing; open, its contents end. */
it needs an edge, or its contents read as loose page furniture and there is
no telling where it ends. */
details.panel { details.panel {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
@@ -309,20 +298,17 @@ details.panel[open] > summary {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
margin-bottom: .75rem; margin-bottom: .75rem;
} }
/* The summary carries the panel's padding itself; everything else is inset to
match, and the panel closes the gap under the last of them. */
details.panel > :not(summary) { padding-left: .75rem; padding-right: .75rem; } details.panel > :not(summary) { padding-left: .75rem; padding-right: .75rem; }
details.panel[open] { padding-bottom: .75rem; } details.panel[open] { padding-bottom: .75rem; }
details.panel > :last-child { margin-bottom: 0; } details.panel > :last-child { margin-bottom: 0; }
details.panel .field { max-width: 24rem; } details.panel .field { max-width: 24rem; }
/* A hint that introduces a control is an instruction for what follows, not a /* A hint before a control explains what follows, so it must not sit against
caption for what came before, so it must not sit against it. */ it. */
.hint + form, .hint + .field { margin-top: .75rem; } .hint + form, .hint + .field { margin-top: .75rem; }
.limits .hint { margin-top: .75rem; } .limits .hint { margin-top: .75rem; }
.limits dl, .card dl { row-gap: .375rem; } .limits dl, .card dl { row-gap: .375rem; }
/* Actions that mix a link-button with a form-button. */
.actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } .actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.actions form { margin: 0; } .actions form { margin: 0; }
+4 -4
View File
@@ -6,10 +6,10 @@
<form id="upload" class="card" method="post" action="{{.Base}}upload" <form id="upload" class="card" method="post" action="{{.Base}}upload"
enctype="multipart/form-data" data-max-size="{{.MaxSizeBytes}}"> enctype="multipart/form-data" data-max-size="{{.MaxSizeBytes}}">
<!-- Field order is load-bearing: the server streams this body rather than {{- /* Field order is load-bearing: the server streams this body rather than
spooling it, so every option and the token have to arrive before the spooling it, so every option and the token have to arrive before the
file part does. The drop zone is lifted back to the top of the card with file part does. The drop zone is lifted back to the top of the card with
CSS order, which changes what is seen without changing what is sent. --> CSS order, which changes what is seen without changing what is sent. */}}
<div class="row"> <div class="row">
<label class="field"> <label class="field">
@@ -32,10 +32,10 @@
</p> </p>
<label class="field"> <label class="field">
<span>Token</span> <span>Token</span>
<!-- Plain text on purpose: this is pasted once, behind a collapsed {{- /* Plain text on purpose: this is pasted once, behind a collapsed
panel, and a masked field only hides the typo. Keeping it out of panel, and a masked field only hides the typo. Keeping it out of
the password family also stops the browser treating this form as a the password family also stops the browser treating this form as a
login and hunting for a username to fill in above. --> login and hunting for a username to fill in above. */}}
<input type="text" name="token" autocomplete="off" <input type="text" name="token" autocomplete="off"
spellcheck="false" autocapitalize="off"> spellcheck="false" autocapitalize="off">
</label> </label>
+2 -2
View File
@@ -40,8 +40,8 @@
<input type="hidden" name="from" value="info"> <input type="hidden" name="from" value="info">
<label class="field"> <label class="field">
<span>Delete token</span> <span>Delete token</span>
<!-- Plain text, like the upload form's: a delete token is pasted {{- /* Plain text, like the upload form's: a delete token is pasted
once and a mask would only hide a mistake in it. --> once and a mask would only hide a mistake in it. */}}
<input type="text" name="token" autocomplete="off" required <input type="text" name="token" autocomplete="off" required
spellcheck="false" autocapitalize="off"> spellcheck="false" autocapitalize="off">
</label> </label>
+3
View File
@@ -11,6 +11,9 @@
<header> <header>
<a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a> <a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a>
<nav> <nav>
{{- /* Uploading is what the site is for, so it is a named destination and
not only a click on the wordmark. */}}
<a href="{{.Base}}">Upload</a>
{{if .User}} {{if .User}}
<a href="{{.Base}}files">{{if .Admin}}Administration{{else}}My files{{end}}</a> <a href="{{.Base}}files">{{if .Admin}}Administration{{else}}My files{{end}}</a>
<span class="who">{{.User}}</span> <span class="who">{{.User}}</span>