Signed by hanna SSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +42 −3 assets/fabrica.js +27 −0 crates/web/src/releases.rs +25 −1 assets/base.css +42 −3 Expand 2249 hidden lines 2250 2250 flex : none ; 2251 2251 } 2252 2252 2253 - 2254 - . release-actions form button [ type = "submit" ] { 2253 + 2254 + 2255 + . card . release-actions form button [ type = "submit" ] : not ( . inline-btn ) { 2255 2256 margin-top : 0 ; 2256 2257 } 2257 2258 Expand 41 hidden lines 2299 2300 font-size : 0.85em ; 2300 2301 } 2301 2302 . asset-upload { 2303 + margin-top : 0.75rem ; 2304 + } 2305 + 2306 + 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 + 2333 + . asset-pending { 2302 2334 display : flex ; 2303 2335 align-items : center ; 2304 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 2345 margin-top : 0.75rem ; 2306 - flex-wrap : wrap ; 2307 2346 } 2308 2347 . release-form-actions { 2309 2348 display : flex ;
assets/fabrica.js +27 −0 Expand 168 hidden lines 169 169 } ) ; 170 170 } 171 171 } ) ; 172 + 173 + 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 Expand 365 hidden lines 366 366 } 367 367 form method="post" action=( format ! ( "/release/{release_id}/asset" ) ) enctype="multipart/form-data" class="asset-upload" { 368 368 input type ="hidden" name="_csrf" value=( csrf) ; 369 - input type ="file" name="asset" required; 369 + 370 + 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 381 button class="btn inline-btn" type ="submit" { "Upload asset" } 371 382 } 372 383 } Expand 178 hidden lines 551 562 mut multipart : Multipart , 552 563 ) -> Response { 553 564 let mut csrf: Option < String > = None ; 565 + let mut override_name: Option < String > = None ; 554 566 let mut saved: Option < ( String , String , i64 , String ) > = None ; 555 567 while let Ok ( Some ( mut field) ) = multipart. next_field ( ) . await { 556 568 match field. name ( ) { 557 569 Some ( "_csrf" ) => csrf = field. text ( ) . await . ok ( ) , 570 + Some ( "name" ) => override_name = field. text ( ) . await . ok ( ) , 558 571 Some ( "asset" ) => { 559 572 let name = field 560 573 . file_name ( ) Expand 26 hidden lines 587 600 if crate :: session:: verify_csrf ( & jar, & HeaderMap :: new ( ) , csrf. as_deref ( ) ) . is_err ( ) { 588 601 return AppError :: Forbidden . into_response ( ) ; 589 602 } 603 + 604 + 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 614 let result = async { 591 615 let ( release, _repo, _user) = writer_release ( & state, viewer. as_ref ( ) , & id) . await ?; 592 616 if let Some ( ( tmp_name, name, size, ctype) ) = saved {