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
// multipart POST still works; everything here is additive.
// Everything here is additive: with JavaScript off, the plain multipart POST
// still works.
(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.
// Wired before the form-specific code bails out, because the result page has
// copy buttons and no form. They ship hidden, so without this there is no
// dead button.
function wireCopy() {
var buttons = document.querySelectorAll('button.copy[data-copy]');
Array.prototype.forEach.call(buttons, function (button) {
@@ -20,12 +20,9 @@
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.
// Answers both ways the clipboard API fails us: absent over plain HTTP,
// where it is not a secure context, and present but refusing. Needs no
// permission. If it fails too, the text is selected, so say what to press.
function copySelection() {
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { /* ok stays false */ }
@@ -47,8 +44,6 @@
}
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();
@@ -67,8 +62,7 @@
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.
// Zero means unlimited.
var maxSize = parseInt(form.dataset.maxSize, 10) || 0;
function formatSize(n) {
@@ -82,7 +76,6 @@
errorBox.hidden = !msg;
}
// --- drag and drop ------------------------------------------------------
function describeSelection() {
var f = fileInput.files[0];
dropHint.textContent = f ? f.name + ' - ' + formatSize(f.size)
@@ -109,7 +102,6 @@
});
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
@@ -127,16 +119,15 @@
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.
// token precede the file part 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.
// Ask for the page a plain form post would get: the server owns what a
// finished upload looks like, and this only adds a progress bar.
xhr.setRequestHeader('Accept', 'text/html');
xhr.upload.onprogress = function (ev) {
@@ -153,10 +144,9 @@
};
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.
// Writing the response into this document rather than navigating leaves
// the address on the form's URL, so a reload offers a fresh form instead
// of posting the file again.
document.open();
document.write(xhr.responseText);
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 > .field { flex: 1 1 12rem; }
/* The file input has to be the last part in the submitted body, so it sits at
the bottom of the form's markup and is lifted back to the top here. Only the
painting order moves; the submission order, which the server depends on, is
the markup's. */
/* The file input must be the last part in the submitted body, so it sits last
in the markup and is lifted back to the top here. Only the painting order
moves; the server depends on the markup's. */
#upload { display: flex; flex-direction: column; }
#upload > .drop { order: -1; }
#upload > button { align-self: flex-start; }
@@ -184,17 +183,13 @@ button.link {
cursor: pointer;
}
/* Result card ------------------------------------------------------------ */
.result .links { margin: 1.25rem 0 0; }
.result .links .field { margin-bottom: 1rem; }
/* A read-only URL with its copy button alongside. */
.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 .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 dd { margin-bottom: .25rem; }
@@ -205,8 +200,6 @@ button.link {
.copyrow input { flex-basis: 100%; }
}
/* Administration --------------------------------------------------------- */
header { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; }
header nav {
margin-left: auto;
@@ -244,8 +237,6 @@ table.admin .nowrap { white-space: nowrap; }
table.admin form { margin: 0; }
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 .id {
display: block;
@@ -264,8 +255,8 @@ button.small { padding: .2rem .5rem; font-size: .75rem; }
overflow: hidden;
}
/* Narrow screens get one card per row rather than a horizontal scrollbar,
which is unusable for picking a row and pressing a button in it. */
/* One card per row beats a horizontal scrollbar, which is unusable for picking
a row and pressing a button in it. */
@media (max-width: 46rem) {
table.admin,
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; }
/* Disclosure panels: the one-off token on the upload form, "Remove this file"
on the info page. Closed, a panel is a single line and needs nothing; open,
it needs an edge, or its contents read as loose page furniture and there is
no telling where it ends. */
/* Disclosure panels. Open, a panel needs an edge, or there is no telling where
its contents end. */
details.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
@@ -309,20 +298,17 @@ details.panel[open] > summary {
border-bottom: 1px solid var(--line);
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[open] { padding-bottom: .75rem; }
details.panel > :last-child { margin-bottom: 0; }
details.panel .field { max-width: 24rem; }
/* A hint that introduces a control is an instruction for what follows, not a
caption for what came before, so it must not sit against it. */
/* A hint before a control explains what follows, so it must not sit against
it. */
.hint + form, .hint + .field { margin-top: .75rem; }
.limits .hint { margin-top: .75rem; }
.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 form { margin: 0; }
+4 -4
View File
@@ -6,10 +6,10 @@
<form id="upload" class="card" method="post" action="{{.Base}}upload"
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
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">
<label class="field">
@@ -32,10 +32,10 @@
</p>
<label class="field">
<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
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"
spellcheck="false" autocapitalize="off">
</label>
+2 -2
View File
@@ -40,8 +40,8 @@
<input type="hidden" name="from" value="info">
<label class="field">
<span>Delete token</span>
<!-- Plain text, like the upload form's: a delete token is pasted
once and a mask would only hide a mistake in it. -->
{{- /* Plain text, like the upload form's: a delete token is pasted
once and a mask would only hide a mistake in it. */}}
<input type="text" name="token" autocomplete="off" required
spellcheck="false" autocapitalize="off">
</label>
+3
View File
@@ -11,6 +11,9 @@
<header>
<a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a>
<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}}
<a href="{{.Base}}files">{{if .Admin}}Administration{{else}}My files{{end}}</a>
<span class="who">{{.User}}</span>