Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
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 the | 2252 | /* 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 = field | 572 | 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 { |