๐Ÿ“๐Ÿ–ผ๏ธ๐Ÿน A small thing where I can upload a file and get a link back. https://media.strawmelonjuice.com/
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Time for an eyesight break

+413 -58
+2 -1
.gitignore
··· 39 39 .vscode 40 40 41 41 # Data 42 - data 42 + data 43 + prelude.ts
+1
dashboard/gleam.toml
··· 24 24 gleam_http = ">= 3.7.2 and < 5.0.0" 25 25 gleam_json = ">= 2.3.0 and < 4.0.0" 26 26 plinth = ">= 0.7.2 and < 1.0.0" 27 + gleam_javascript = ">= 1.0.0 and < 2.0.0" 27 28 28 29 [dev-dependencies] 29 30 gleeunit = ">= 1.0.0 and < 2.0.0"
+1
dashboard/manifest.toml
··· 19 19 20 20 [requirements] 21 21 gleam_http = { version = ">= 3.7.2 and < 5.0.0" } 22 + gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" } 22 23 gleam_json = { version = ">= 2.3.0 and < 4.0.0" } 23 24 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 24 25 gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+41
dashboard/src/ffi.ts
··· 1 + import {Error as ResultError, Ok as ResultOk} from "./prelude.ts"; 2 + 3 + export async function get_uploaded_file() { 4 + const fileInput = document.getElementById("file-upload-selector") as HTMLInputElement; 5 + let file = fileInput.files?.[0] ?? null; 6 + if (!file) { 7 + return new ResultError(null); 8 + } 9 + const bytes = await file?.bytes() ?? null; 10 + if (bytes) { 11 + const b64 = await to_base64(file); 12 + const s = document.getElementById("file-base64") as HTMLInputElement; 13 + s.value = b64; 14 + s.dispatchEvent(new Event('change')); 15 + } else { 16 + return new ResultError(null); 17 + } 18 + } 19 + 20 + export function get_uploaded_file_name() { 21 + const fileInput = document.getElementById("file-upload-selector") as HTMLInputElement; 22 + let file = fileInput.files?.[0]; 23 + if (!file) { 24 + return []; 25 + } 26 + return [file.name]; 27 + } 28 + 29 + function to_base64(file: File): Promise<string> { 30 + return new Promise((resolve, reject) => { 31 + const reader = new FileReader(); 32 + reader.onload = () => { 33 + const result = reader.result as string; 34 + // Remove the data URL prefix if present 35 + const base64 = result.split(',')[1] ?? result; 36 + resolve(base64); 37 + }; 38 + reader.onerror = (error) => reject(error); 39 + reader.readAsDataURL(file); 40 + }); 41 + }
+363 -52
dashboard/src/strawmediajuice_fe.gleam
··· 6 6 import gleam/http/request 7 7 import gleam/http/response 8 8 import gleam/int 9 + import gleam/javascript/array 10 + import gleam/javascript/promise 9 11 import gleam/json 10 12 import gleam/list 11 - import gleam/option 13 + import gleam/option.{Some} 12 14 import gleam/order 13 15 import gleam/string 14 16 import lustre 15 - import lustre/attribute 17 + import lustre/attribute.{attribute} 16 18 import lustre/effect.{type Effect} 17 19 import lustre/element.{type Element} 18 20 import lustre/element/html ··· 40 42 password: option.Option(String), 41 43 files: List(FileInfo), 42 44 error_message: option.Option(String), 45 + successfully_uploaded_dialog: option.Option(String), 43 46 is_loading: Bool, 47 + selected_file: option.Option(String), 48 + selected_filename: option.Option(String), 44 49 ) 45 50 } 46 51 ··· 66 71 password: option.None, 67 72 files: [], 68 73 error_message: option.None, 74 + successfully_uploaded_dialog: option.None, 69 75 is_loading: False, 76 + selected_file: option.None, 77 + selected_filename: option.None, 70 78 ), 71 79 eff, 72 80 ) ··· 94 102 UserSubmittedLoginForm(List(#(String, String))) 95 103 UserClickedLogout 96 104 UserClickedDeleteFile(String) 105 + FileSelected 106 + // filename, content 107 + UserSubmittedUploadForm(List(#(String, String))) 97 108 ApiLoginSucceeded(#(String, String)) 98 109 ApiLoginFailed 99 110 ApiFilesLoaded(List(FileInfo)) 100 111 ApiFilesError 101 112 ApiFileDeleted(String) 102 113 ApiFileDeleteFailed 114 + ApiFileUploaded(String) 115 + ApiFileUploadFailed 116 + UploadableFileContent(String) 103 117 } 104 118 105 119 fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 106 - case msg |> echo as "Update Msg received: " { 120 + case msg { 107 121 UserSubmittedLoginForm(form_data) -> { 108 122 let username = 109 123 option.from_result( ··· 116 130 ) 117 131 |> option.map(fn(field) { field.1 }) 118 132 case username, password { 119 - option.Some(u), option.Some(p) if u != "" && p != "" -> { 133 + Some(u), Some(p) if u != "" && p != "" -> { 120 134 #( 121 135 Model(..model, error_message: option.None, is_loading: True), 122 136 authenticate(u, p), ··· 128 142 username: option.None, 129 143 password: option.None, 130 144 files: model.files, 131 - error_message: option.Some( 132 - "Please enter both username and password", 133 - ), 145 + error_message: Some("Please enter both username and password"), 146 + successfully_uploaded_dialog: option.None, 134 147 is_loading: False, 148 + selected_file: model.selected_file, 149 + selected_filename: model.selected_filename, 135 150 ), 136 151 effect.none(), 137 152 ) ··· 145 160 let _ = storage.set_item(storage, "password", password) 146 161 #( 147 162 Model( 148 - username: option.Some(username), 149 - password: option.Some(password), 163 + username: Some(username), 164 + password: Some(password), 150 165 files: [], 151 166 error_message: option.None, 152 167 is_loading: True, 168 + selected_file: option.None, 169 + selected_filename: option.None, 170 + successfully_uploaded_dialog: option.None, 153 171 ), 154 172 fetch_files(username, password), 155 173 ) ··· 161 179 username: model.username, 162 180 password: model.password, 163 181 files: model.files, 164 - error_message: option.Some("Invalid username or password"), 182 + error_message: Some("Invalid username or password"), 165 183 is_loading: False, 184 + successfully_uploaded_dialog: option.None, 185 + selected_file: model.selected_file, 186 + selected_filename: model.selected_filename, 166 187 ), 167 188 effect.none(), 168 189 ) 169 190 } 170 191 171 192 ApiFilesLoaded(files) -> { 172 - #( 173 - Model( 174 - username: model.username, 175 - password: model.password, 176 - files: files, 177 - error_message: model.error_message, 178 - is_loading: False, 179 - ), 180 - effect.none(), 181 - ) 193 + #(Model(..model, files: files, is_loading: False), effect.none()) 182 194 } 183 195 184 196 ApiFilesError -> { 185 197 #( 186 198 Model( 187 - username: model.username, 188 - password: model.password, 189 - files: model.files, 190 - error_message: option.Some("Failed to load files"), 199 + ..model, 200 + error_message: Some("Failed to load files"), 191 201 is_loading: False, 192 202 ), 193 203 effect.none(), ··· 197 207 UserClickedLogout -> { 198 208 let assert Ok(storage) = storage.local() 199 209 let _ = storage.clear(storage) 200 - #( 201 - Model( 202 - username: option.None, 203 - password: option.None, 204 - files: [], 205 - error_message: option.None, 206 - is_loading: False, 207 - ), 208 - effect.none(), 209 - ) 210 + init(1) 210 211 } 211 212 212 213 UserClickedDeleteFile(file_id) -> { 213 214 case model.username, model.password { 214 - option.Some(username), option.Some(password) -> { 215 + Some(username), Some(password) -> { 215 216 #( 216 217 Model(..model, is_loading: True), 217 218 delete_file(username, password, file_id), ··· 223 224 224 225 ApiFileDeleted(file_id) -> { 225 226 case model.username, model.password { 226 - option.Some(username), option.Some(password) -> { 227 + Some(username), Some(password) -> { 227 228 let new_files = list.filter(model.files, fn(f) { f.id != file_id }) 228 229 #(Model(..model, files: new_files), fetch_files(username, password)) 229 230 } ··· 235 236 #( 236 237 Model( 237 238 ..model, 238 - error_message: option.Some("Failed to delete file"), 239 + error_message: Some("Failed to delete file"), 240 + is_loading: False, 241 + ), 242 + effect.none(), 243 + ) 244 + } 245 + 246 + FileSelected -> { 247 + console.log("File selected event triggered") 248 + let filename = get_uploaded_file() 249 + case filename { 250 + Ok(filename) -> { 251 + #(Model(..model, selected_filename: Some(filename)), effect.none()) 252 + } 253 + Error(_) -> { 254 + #(model, effect.none()) 255 + } 256 + } 257 + } 258 + 259 + UploadableFileContent(contents) -> { 260 + #(Model(..model, selected_file: Some(contents)), effect.none()) 261 + } 262 + 263 + UserSubmittedUploadForm(form_data) -> { 264 + case model.username, model.password, model.selected_file { 265 + Some(username), Some(password), Some(file_content) -> { 266 + let filename = 267 + option.from_result( 268 + list.find(form_data, fn(field) { field.0 == "filename" }), 269 + ) 270 + |> option.map(fn(field) { field.1 }) 271 + |> option.or(model.selected_filename) 272 + // Use selected filename as fallback 273 + case filename { 274 + Some(fname) if fname != "" -> { 275 + #( 276 + Model(..model, is_loading: True, error_message: option.None), 277 + upload_file(username, password, fname, file_content), 278 + ) 279 + } 280 + _ -> { 281 + #( 282 + Model(..model, error_message: Some("Please enter a filename")), 283 + effect.none(), 284 + ) 285 + } 286 + } 287 + } 288 + _, _, option.None -> { 289 + #( 290 + Model(..model, error_message: Some("Please select a file")), 291 + effect.none(), 292 + ) 293 + } 294 + _, _, _ -> { 295 + #( 296 + Model(..model, error_message: Some("Authentication required")), 297 + effect.none(), 298 + ) 299 + } 300 + } 301 + } 302 + 303 + ApiFileUploaded(file_id) -> { 304 + case model.username, model.password { 305 + Some(username), Some(password) -> { 306 + #( 307 + Model( 308 + ..model, 309 + selected_file: option.None, 310 + selected_filename: option.None, 311 + is_loading: False, 312 + error_message: option.None, 313 + successfully_uploaded_dialog: Some(file_id), 314 + ), 315 + fetch_files(username, password), 316 + ) 317 + } 318 + _, _ -> #(model, effect.none()) 319 + } 320 + } 321 + 322 + ApiFileUploadFailed -> { 323 + #( 324 + Model( 325 + ..model, 326 + error_message: Some("Failed to upload file"), 239 327 is_loading: False, 240 328 ), 241 329 effect.none(), ··· 310 398 ) -> Effect(Msg) { 311 399 let handler = fn(result) { 312 400 case result { 313 - Ok(True) -> ApiFileDeleted(file_id) 314 - _ -> ApiFileDeleteFailed 401 + Ok(..) -> ApiFileDeleted(file_id) 402 + Error(..) -> ApiFileDeleteFailed 315 403 } 316 404 } 317 405 let body = ··· 321 409 #("fileId", json.string(file_id)), 322 410 ]) 323 411 412 + rsvp.post("/api/files/delete", body, rsvp.expect_ok_response(handler)) 413 + } 414 + 415 + fn upload_file( 416 + username: String, 417 + password: String, 418 + filename: String, 419 + content: String, 420 + ) -> Effect(Msg) { 421 + let handler = fn(result: Result(UploadResponse, rsvp.Error)) { 422 + case result { 423 + Ok(response) -> ApiFileUploaded(response.id) 424 + Error(_) -> ApiFileUploadFailed 425 + } 426 + } 427 + let body = 428 + json.object([ 429 + #("username", json.string(username)), 430 + #("password", json.string(password)), 431 + #("filename", json.string(filename)), 432 + #("content", json.string(content)), 433 + ]) 434 + 324 435 rsvp.post( 325 - "/api/files/delete", 436 + "/api/upload", 326 437 body, 327 - rsvp.expect_json(success_decoder(), handler), 438 + rsvp.expect_json(upload_response_decoder(), handler), 328 439 ) 329 440 } 330 441 331 - fn success_decoder() -> Decoder(Bool) { 332 - use success <- decode.field("success", decode.bool) 333 - decode.success(success) 442 + fn upload_response_decoder() -> Decoder(UploadResponse) { 443 + use id <- decode.field("id", decode.string) 444 + decode.success(UploadResponse(id: id)) 445 + } 446 + 447 + type UploadResponse { 448 + UploadResponse(id: String) 334 449 } 335 450 336 451 fn file_list_decoder() -> decode.Decoder(List(FileInfo)) { 337 452 decode.list(file_info_decoder()) 338 453 } 339 - 340 - // VIEW ------------------------------------------------------------------------ 341 454 342 455 fn file_size_display(size: Float) -> String { 343 456 case float.compare(size, 0.1) { ··· 436 549 ), 437 550 ]), 438 551 case model.error_message { 439 - option.Some(error) -> 552 + Some(error) -> 440 553 html.div([attribute.class("alert alert-error mt-4")], [ 441 554 html.svg( 442 555 [ ··· 471 584 ) 472 585 } 473 586 474 - fn view_file_list(model: Model) -> Element(Msg) { 587 + fn view_home(model: Model) -> Element(Msg) { 475 588 html.div([attribute.class("p-6 bg-base-200 min-h-screen")], [ 476 589 html.div([attribute.class("max-w-7xl mx-auto")], [ 590 + // Upload form 591 + html.div([attribute.class("mb-8")], [ 592 + html.div( 593 + [attribute.class("card bg-base-100 shadow-xl max-w-md mx-auto")], 594 + [ 595 + html.div([attribute.class("card-body")], [ 596 + html.h3( 597 + [attribute.class("card-title text-primary justify-center mb-4")], 598 + [ 599 + html.svg( 600 + [ 601 + attribute.class("w-5 h-5 mr-2"), 602 + attribute.attribute("fill", "none"), 603 + attribute.attribute("stroke", "currentColor"), 604 + attribute.attribute("viewBox", "0 0 24 24"), 605 + ], 606 + [ 607 + svg.path([ 608 + attribute.attribute("stroke-linecap", "round"), 609 + attribute.attribute("stroke-linejoin", "round"), 610 + attribute.attribute("stroke-width", "2"), 611 + attribute.attribute( 612 + "d", 613 + "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12", 614 + ), 615 + ]), 616 + ], 617 + ), 618 + html.text("Upload New File"), 619 + ], 620 + ), 621 + html.form( 622 + [ 623 + event.on_submit(UserSubmittedUploadForm), 624 + attribute.class("space-y-4"), 625 + ], 626 + [ 627 + html.div([attribute.class("form-control")], [ 628 + html.label([attribute.class("label")], [ 629 + html.span([attribute.class("label-text")], [ 630 + html.text("Select File"), 631 + ]), 632 + ]), 633 + html.input([ 634 + attribute.id("file-upload-selector"), 635 + attribute.type_("file"), 636 + attribute.class( 637 + "file-input file-input-bordered file-input-primary w-full", 638 + ), 639 + attribute.attribute("accept", "*/*"), 640 + event.on("change", decode.success(FileSelected)), 641 + ]), 642 + ]), 643 + html.div([attribute.class("form-control")], [ 644 + html.label([attribute.class("label")], [ 645 + html.span([attribute.class("label-text")], [ 646 + html.text("Filename"), 647 + ]), 648 + ]), 649 + html.input([ 650 + attribute.type_("text"), 651 + attribute.name("filename"), 652 + attribute.placeholder("Enter filename"), 653 + attribute.class( 654 + "input input-bordered input-primary w-full", 655 + ), 656 + case model.selected_filename { 657 + Some(filename) -> attribute.value(filename) 658 + option.None -> attribute.value("") 659 + }, 660 + ]), 661 + ]), 662 + // base64 field 663 + html.div([attribute.class("form-control hidden")], [ 664 + html.label([attribute.class("label")], [ 665 + html.span([attribute.class("label-text")], [ 666 + html.text("Base 64 of your file"), 667 + ]), 668 + ]), 669 + html.input([ 670 + attribute.id("file-base64"), 671 + attribute.type_("text"), 672 + attribute.name("base64"), 673 + event.on_change(UploadableFileContent), 674 + attribute.class( 675 + "input input-bordered input-primary w-full disabled", 676 + ), 677 + ]), 678 + ]), 679 + // end 680 + html.div([attribute.class("form-control mt-6")], [ 681 + html.button( 682 + [ 683 + attribute.type_("submit"), 684 + attribute.class("btn btn-primary w-full"), 685 + attribute.disabled( 686 + model.is_loading 687 + || option.is_none(model.selected_file), 688 + ), 689 + ], 690 + [ 691 + case model.is_loading { 692 + True -> html.text("Uploading...") 693 + False -> html.text("Upload File") 694 + }, 695 + ], 696 + ), 697 + ]), 698 + ], 699 + ), 700 + ]), 701 + ], 702 + ), 703 + ]), 704 + 477 705 html.div([attribute.class("mb-8")], [ 478 706 html.h2([attribute.class("text-3xl font-bold text-primary mb-2")], [ 479 707 html.text("Your Files"), ··· 691 919 } 692 920 }, 693 921 case model.error_message { 694 - option.Some(error) -> 922 + Some(error) -> 695 923 html.div([attribute.class("alert alert-error mt-6")], [ 696 924 html.svg( 697 925 [ ··· 731 959 ]), 732 960 html.div([attribute.class("navbar-end")], [ 733 961 case model.username { 734 - option.Some(username) -> 962 + Some(username) -> 735 963 html.div([attribute.class("flex items-center gap-4")], [ 736 964 html.div([attribute.class("flex items-center gap-2")], [ 737 965 html.div([attribute.class("avatar placeholder")], [ ··· 785 1013 ], 786 1014 ), 787 1015 ]) 788 - option.None -> html.text("") 1016 + option.None -> element.none() 789 1017 }, 790 1018 ]), 791 1019 ], 792 1020 ), 1021 + case model.successfully_uploaded_dialog { 1022 + Some(file_id) -> 1023 + html.div( 1024 + [ 1025 + attribute.class("alert alert-vertical sm:alert-horizontal"), 1026 + attribute.role("alert"), 1027 + ], 1028 + [ 1029 + svg.svg( 1030 + [ 1031 + attribute.class("stroke-info h-6 w-6 shrink-0"), 1032 + attribute("viewBox", "0 0 24 24"), 1033 + attribute("fill", "none"), 1034 + attribute("xmlns", "http://www.w3.org/2000/svg"), 1035 + ], 1036 + [ 1037 + svg.path([ 1038 + attribute( 1039 + "d", 1040 + "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", 1041 + ), 1042 + attribute("stroke-width", "2"), 1043 + attribute("stroke-linejoin", "round"), 1044 + attribute("stroke-linecap", "round"), 1045 + ]), 1046 + ], 1047 + ), 1048 + html.div([], [ 1049 + html.h3([attribute.class("font-bold")], [ 1050 + html.text("Successfully uploaded file!"), 1051 + ]), 1052 + html.div([attribute.class("text-xs")], [ 1053 + html.text("It is now available at: "), 1054 + html.a( 1055 + [ 1056 + attribute.href(window.origin() <> "/file/" <> file_id), 1057 + attribute.class("link link-success"), 1058 + ], 1059 + [html.text(window.origin() <> "/file/" <> file_id)], 1060 + ), 1061 + ]), 1062 + ]), 1063 + html.button( 1064 + [ 1065 + attribute.class("btn btn-sm btn-success"), 1066 + attribute( 1067 + "onclick", 1068 + "javscript:navigator.clipboard.writeText('" 1069 + <> window.origin() 1070 + <> "/file/" 1071 + <> file_id 1072 + <> "')", 1073 + ), 1074 + ], 1075 + [html.text("Copy link to clipboard")], 1076 + ), 1077 + ], 1078 + ) 1079 + option.None -> element.none() 1080 + }, 793 1081 case model.username { 794 - option.Some(_) -> view_file_list(model) 1082 + Some(_) -> view_home(model) 795 1083 option.None -> view_login_form(model) 796 1084 }, 797 1085 ]) 798 1086 } 1087 + 1088 + /// Gets uploaded file 1089 + fn get_uploaded_file() -> Result(String, Nil) { 1090 + let filename = case 1091 + get_uploaded_file__name_ffi() 1092 + |> array.to_list 1093 + { 1094 + [] -> Error(Nil) 1095 + [name] -> Ok(name) 1096 + _ -> panic 1097 + } 1098 + 1099 + let _ = get_uploaded_file_ffi() 1100 + filename 1101 + } 1102 + 1103 + /// Gets uploaded file content as base64 1104 + @external(javascript, "./ffi", "get_uploaded_file") 1105 + fn get_uploaded_file_ffi() -> promise.Promise(Result(String, Nil)) 1106 + 1107 + /// Gets uploaded file name 1108 + @external(javascript, "./ffi", "get_uploaded_file_name") 1109 + fn get_uploaded_file__name_ffi() -> array.Array(String)
+1 -1
index.ts
··· 454 454 if (fileResult.owner !== "server") { 455 455 // Don't update quota for system files 456 456 db.exec( 457 - `UPDATE users SET used_megabytes = GREATEST(0, used_megabytes - ?1) WHERE username = ?2`, 457 + `UPDATE users SET used_megabytes = CASE WHEN used_megabytes - ?1 < 0 THEN 0 ELSE used_megabytes - ?1 END WHERE username = ?2`, 458 458 [fileSizeMB, fileResult.owner], 459 459 ); 460 460 }
+2 -2
mise.toml
··· 28 28 29 29 [tasks.dev] 30 30 depends = ["build-dashboard"] 31 - run = "bun ./index.ts" 31 + run = ["gleam export typescript-prelude > ./dashboard/src/prelude.ts", "bun ./index.ts"] 32 32 dir = "{{ config_root }}" 33 33 34 34 [tasks.format] 35 - run = ["prettier --write ..","gleam format"] 35 + run = ["prettier --write ..", "gleam format"] 36 36 dir = "{{ config_root }}/dashboard"
+2 -2
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 3 // Environment setup & latest features 4 - "lib": ["ESNext"], 4 + "lib": ["ESNext", "DOM"], 5 5 "target": "ESNext", 6 6 "module": "Preserve", 7 7 "moduleDetection": "force", 8 - "jsx": "react-jsx", 8 + // "jsx": "react-jsx", 9 9 "allowJs": true, 10 10 11 11 // Bundler mode