fabrica

hanna/fabrica

feat(web): drag-and-drop release asset uploader

11e96da · hanna committed on 2026-07-26

Replace the bare file input on the release edit page with a dashed dropzone that
accepts a file by drop or click, then shows an editable name, size, and a remove
button before uploading. The edited name overrides the file's own (basename only,
so it can't smuggle a path). Progressive enhancement: the plain file input still
works without JavaScript.

Also strengthen the release Delete button's margin reset so it beats the
card-form rule and lines up with Edit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
3 files changed · +94 −4UnifiedSplit
assets/base.css +42 −3
@@ -2250,8 +2250,9 @@ pre.code {
22502250 flex: none;
22512251 }
22522252 /* The Delete button sits in a card form, so it would otherwise inherit the
2253- stacked-form top margin and float below the Edit link. */
2254-.release-actions form button[type="submit"] {
2253+ stacked-form top margin and float below the Edit link. Specificity here must
2254+ beat `.card form button[type="submit"]:not(.inline-btn)`. */
2255+.card .release-actions form button[type="submit"]:not(.inline-btn) {
22552256 margin-top: 0;
22562257 }
22572258 /* Asset management sits below the edit form; give it room to breathe. */
@@ -2299,11 +2300,49 @@ pre.code {
22992300 font-size: 0.85em;
23002301 }
23012302 .asset-upload {
2303+ margin-top: 0.75rem;
2304+}
2305+/* A dashed drop target that also opens the file picker on click (the file input
2306+ is visually hidden inside the label but stays keyboard- and click-reachable). */
2307+.dropzone {
2308+ display: flex;
2309+ align-items: center;
2310+ justify-content: center;
2311+ gap: 0.5rem;
2312+ padding: 1.5rem;
2313+ border: 1px dashed var(--fb-border);
2314+ border-radius: var(--fb-radius);
2315+ color: var(--fb-fg-muted);
2316+ text-align: center;
2317+ cursor: pointer;
2318+}
2319+.dropzone:hover,
2320+.dropzone.drag {
2321+ border-color: var(--fb-accent);
2322+ color: var(--fb-fg);
2323+}
2324+.dropzone-input {
2325+ position: absolute;
2326+ width: 1px;
2327+ height: 1px;
2328+ padding: 0;
2329+ opacity: 0;
2330+ pointer-events: none;
2331+}
2332+/* The pending (not-yet-uploaded) file: editable name, size, remove. */
2333+.asset-pending {
23022334 display: flex;
23032335 align-items: center;
23042336 gap: 0.6rem;
2337+ margin-bottom: 0.75rem;
2338+}
2339+.asset-pending-name {
2340+ flex: 1;
2341+ min-width: 0;
2342+ font-family: var(--fb-font-mono);
2343+}
2344+.asset-upload .btn {
23052345 margin-top: 0.75rem;
2306- flex-wrap: wrap;
23072346 }
23082347 .release-form-actions {
23092348 display: flex;
assets/fabrica.js +27 −0
@@ -169,4 +169,31 @@
169169 });
170170 }
171171 });
172+
173+ // ---- Release asset dropzone (drag-drop / click, editable name + size) ----
174+ document.querySelectorAll(".asset-upload").forEach(function (form) {
175+ var input = form.querySelector("input[type=file]");
176+ var zone = form.querySelector(".dropzone");
177+ var pending = form.querySelector(".asset-pending");
178+ if (!input || !zone || !pending) return;
179+ function show() {
180+ var f = input.files[0];
181+ pending.hidden = !f;
182+ if (!f) return;
183+ pending.querySelector("input[type=text]").value = f.name;
184+ pending.querySelector(".asset-size").textContent =
185+ "(" + (f.size < 1048576 ? (f.size / 1024).toFixed(1) + " KB" : (f.size / 1048576).toFixed(2) + " MB") + ")";
186+ }
187+ input.addEventListener("change", show);
188+ pending.querySelector(".asset-remove").addEventListener("click", function () {
189+ input.value = ""; pending.hidden = true;
190+ });
191+ ["dragover", "dragenter", "dragleave", "drop"].forEach(function (ev) {
192+ zone.addEventListener(ev, function (e) {
193+ e.preventDefault();
194+ zone.classList.toggle("drag", ev === "dragover" || ev === "dragenter");
195+ if (ev === "drop" && e.dataTransfer.files.length) { input.files = e.dataTransfer.files; show(); }
196+ });
197+ });
198+ });
172199 })();
crates/web/src/releases.rs +25 −1
@@ -366,7 +366,18 @@ fn assets_editor(release_id: &str, csrf: &str, assets: &[store::ReleaseAsset]) -
366366 }
367367 form method="post" action=(format!("/release/{release_id}/asset")) enctype="multipart/form-data" class="asset-upload" {
368368 input type="hidden" name="_csrf" value=(csrf);
369- input type="file" name="asset" required;
369+ // Populated by JS when a file is picked (editable name, size,
370+ // remove); stays hidden without JS.
371+ div class="asset-pending" hidden {
372+ input type="text" name="name" class="asset-pending-name" aria-label="Asset name";
373+ span class="asset-size muted" {}
374+ button type="button" class="asset-remove icon-btn" aria-label="Remove file" title="Remove file" { (icon(Icon::X)) }
375+ }
376+ label class="dropzone" {
377+ input type="file" name="asset" class="dropzone-input" required;
378+ (icon(Icon::Download))
379+ span { "Attach a file by dropping it here or selecting it." }
380+ }
370381 button class="btn inline-btn" type="submit" { "Upload asset" }
371382 }
372383 }
@@ -551,10 +562,12 @@ pub async fn asset_upload(
551562 mut multipart: Multipart,
552563 ) -> Response {
553564 let mut csrf: Option<String> = None;
565+ let mut override_name: Option<String> = None;
554566 let mut saved: Option<(String, String, i64, String)> = None; // (tmp_name, name, size, ctype)
555567 while let Ok(Some(mut field)) = multipart.next_field().await {
556568 match field.name() {
557569 Some("_csrf") => csrf = field.text().await.ok(),
570+ Some("name") => override_name = field.text().await.ok(),
558571 Some("asset") => {
559572 let name = field
560573 .file_name()
@@ -587,6 +600,17 @@ pub async fn asset_upload(
587600 if crate::session::verify_csrf(&jar, &HeaderMap::new(), csrf.as_deref()).is_err() {
588601 return AppError::Forbidden.into_response();
589602 }
603+ // An edited name from the form overrides the uploaded file's own name; keep
604+ // only the basename so it can't smuggle a path.
605+ if let Some(name) = &mut saved
606+ && let Some(ov) = override_name
607+ .as_deref()
608+ .map(str::trim)
609+ .and_then(|s| s.rsplit(['/', '\\']).next())
610+ .filter(|s| !s.is_empty())
611+ {
612+ name.1 = ov.to_string();
613+ }
590614 let result = async {
591615 let (release, _repo, _user) = writer_release(&state, viewer.as_ref(), &id).await?;
592616 if let Some((tmp_name, name, size, ctype)) = saved {