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 {
2250 flex: none;2250 flex: none;
2251}2251}
2252/* The Delete button sits in a card form, so it would otherwise inherit the2252/* 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. */2253 stacked-form top margin and float below the Edit link. Specificity here must
2254.release-actions form button[type="submit"] {2254 beat `.card form button[type="submit"]:not(.inline-btn)`. */
2255.card .release-actions form button[type="submit"]:not(.inline-btn) {
2255 margin-top: 0;2256 margin-top: 0;
2256}2257}
2257/* Asset management sits below the edit form; give it room to breathe. */2258/* Asset management sits below the edit form; give it room to breathe. */
@@ -2299,11 +2300,49 @@ pre.code {
2299 font-size: 0.85em;2300 font-size: 0.85em;
2300}2301}
2301.asset-upload {2302.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 {
2302 display: flex;2334 display: flex;
2303 align-items: center;2335 align-items: center;
2304 gap: 0.6rem;2336 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 {
2305 margin-top: 0.75rem;2345 margin-top: 0.75rem;
2306 flex-wrap: wrap;
2307}2346}
2308.release-form-actions {2347.release-form-actions {
2309 display: flex;2348 display: flex;
assets/fabrica.js +27 −0
@@ -169,4 +169,31 @@
169 });169 });
170 }170 }
171 });171 });
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 });
172})();199})();
crates/web/src/releases.rs +25 −1
@@ -366,7 +366,18 @@ fn assets_editor(release_id: &str, csrf: &str, assets: &[store::ReleaseAsset]) -
366 }366 }
367 form method="post" action=(format!("/release/{release_id}/asset")) enctype="multipart/form-data" class="asset-upload" {367 form method="post" action=(format!("/release/{release_id}/asset")) enctype="multipart/form-data" class="asset-upload" {
368 input type="hidden" name="_csrf" value=(csrf);368 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 }
370 button class="btn inline-btn" type="submit" { "Upload asset" }381 button class="btn inline-btn" type="submit" { "Upload asset" }
371 }382 }
372 }383 }
@@ -551,10 +562,12 @@ pub async fn asset_upload(
551 mut multipart: Multipart,562 mut multipart: Multipart,
552) -> Response {563) -> Response {
553 let mut csrf: Option<String> = None;564 let mut csrf: Option<String> = None;
565 let mut override_name: Option<String> = None;
554 let mut saved: Option<(String, String, i64, String)> = None; // (tmp_name, name, size, ctype)566 let mut saved: Option<(String, String, i64, String)> = None; // (tmp_name, name, size, ctype)
555 while let Ok(Some(mut field)) = multipart.next_field().await {567 while let Ok(Some(mut field)) = multipart.next_field().await {
556 match field.name() {568 match field.name() {
557 Some("_csrf") => csrf = field.text().await.ok(),569 Some("_csrf") => csrf = field.text().await.ok(),
570 Some("name") => override_name = field.text().await.ok(),
558 Some("asset") => {571 Some("asset") => {
559 let name = field572 let name = field
560 .file_name()573 .file_name()
@@ -587,6 +600,17 @@ pub async fn asset_upload(
587 if crate::session::verify_csrf(&jar, &HeaderMap::new(), csrf.as_deref()).is_err() {600 if crate::session::verify_csrf(&jar, &HeaderMap::new(), csrf.as_deref()).is_err() {
588 return AppError::Forbidden.into_response();601 return AppError::Forbidden.into_response();
589 }602 }
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 }
590 let result = async {614 let result = async {
591 let (release, _repo, _user) = writer_release(&state, viewer.as_ref(), &id).await?;615 let (release, _repo, _user) = writer_release(&state, viewer.as_ref(), &id).await?;
592 if let Some((tmp_name, name, size, ctype)) = saved {616 if let Some((tmp_name, name, size, ctype)) = saved {