Image sharing backed by ATProto
atproto images gleam

refactor: split into pages

Signed-off-by: Naomi Roberts <mia@naomieow.xyz>

lesbian.skin c843c1c7 05201e28

verified
+369 -165
+1
gleam.toml
··· 20 20 formal = ">= 3.0.0 and < 4.0.0" 21 21 gleam_javascript = ">= 1.0.0 and < 2.0.0" 22 22 gleam_http = ">= 4.3.0 and < 5.0.0" 23 + modem = ">= 2.1.2 and < 3.0.0" 23 24 24 25 [dev-dependencies] 25 26 gleeunit = ">= 1.0.0 and < 2.0.0"
+2
manifest.toml
··· 35 35 { name = "lustre_dev_tools", version = "2.3.1", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "2C8C646FF45087C31C2DE8088C2F6DB26E8CEE52B3A883F47F6B2C4F5A16C9C6" }, 36 36 { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 37 37 { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, 38 + { name = "modem", version = "2.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "3F9682EBCBF4D26045F1038A7507E8C7967E49D43F9CA6BA68EF0C971B195A7F" }, 38 39 { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 39 40 { name = "polly", version = "2.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "1BA4D0ACE9BCF52AEA6AD9DE020FD8220CCA399A379E50A1775FC5C1204FCF56" }, 40 41 { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, ··· 52 53 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 53 54 lustre = { version = ">= 5.4.0 and < 6.0.0" } 54 55 lustre_dev_tools = { version = ">= 2.3.1 and < 3.0.0" } 56 + modem = { version = ">= 2.1.2 and < 3.0.0" }
+127 -156
src/plonk.gleam
··· 1 1 import formal/form 2 - import gleam/dynamic/decode 3 2 import gleam/javascript/promise 4 - import gleam/list 5 3 import gleam/option.{None, Some} 6 4 import gleam/result 5 + import gleam/uri 7 6 import lustre 8 7 import lustre/attribute as attr 9 8 import lustre/effect 10 9 import lustre/element 11 10 import lustre/element/html 12 11 import lustre/event 12 + import modem 13 13 import plonk/atp 14 - import plonk/file 14 + import plonk/forms 15 + import plonk/pages/home 16 + import plonk/pages/image_view 17 + import plonk/pages/upload 15 18 16 19 type Msg { 17 20 OAuthClientCreated(atp.BrowserOAuthClient) 18 21 OAuthClientInitialised(atp.OACInit) 19 22 OAuthLogin(atp.OAuthSession) 20 23 OAuthGetSessionResponse(Result(atp.SessionResponse, String)) 21 - UserSubmittedLoginForm(result: Result(Login, form.Form(Login))) 24 + UserSubmittedLoginForm(result: Result(forms.Login, form.Form(forms.Login))) 22 25 UserClickedLogout 23 - UserSubmittedImageUploadForm(formdata: List(#(String, FormDataType))) 24 - FileBlobUploadAttempted(result: Result(atp.BlobOutputSchema, Nil)) 25 - ImageRecordCreationAttempted( 26 - result: Result(atp.CreateRecordOutputSchema, String), 27 - ) 26 + OnRouteChange(Route, effect.Effect(Msg)) 27 + UploadPageMsg(upload.Msg) 28 + HomePageMsg(home.Msg) 29 + ImageViewPageMsg(image_view.Msg) 30 + } 31 + 32 + type Route { 33 + HomePage(model: home.Model) 34 + UploadPage(model: upload.Model) 35 + ImageViewPage(model: image_view.Model) 28 36 } 29 37 30 38 type Model { ··· 36 44 show_spinner: Bool, 37 45 show_login: Bool, 38 46 login_busy: Bool, 39 - login_form: form.Form(Login), 47 + login_form: form.Form(forms.Login), 40 48 handle: option.Option(String), 41 - files: List(file.File), 49 + route: Route, 42 50 ) 43 51 } 44 52 ··· 50 58 51 59 fn init(_flags: Nil) -> #(Model, effect.Effect(Msg)) { 52 60 let client_id = atp.build_client_id() 61 + let #(route, page_load_effect) = 62 + modem.initial_uri() 63 + |> result.map(fn(uri) { uri.path_segments(uri.path) }) 64 + |> fn(path) { 65 + case path { 66 + Ok(["new"]) -> { 67 + let #(model, effect) = upload.init(Nil) 68 + #(UploadPage(model), effect.map(effect, UploadPageMsg)) 69 + } 70 + Ok([handle, cid]) -> { 71 + let #(model, effect) = image_view.init(handle:, cid:) 72 + #(ImageViewPage(model), effect.map(effect, ImageViewPageMsg)) 73 + } 74 + _ -> { 75 + let #(model, effect) = home.init(Nil) 76 + #(HomePage(model), effect.map(effect, HomePageMsg)) 77 + } 78 + } 79 + } 53 80 #( 54 81 Model( 55 82 client_id:, ··· 59 86 show_spinner: True, 60 87 show_login: False, 61 88 login_busy: False, 62 - login_form: login_form(), 89 + login_form: forms.login_form(), 63 90 handle: None, 64 - files: [], 91 + route:, 65 92 ), 66 - effect.from(fn(dispatch) { 67 - atp.get_oauth_client(client_id:) 68 - |> promise.map(OAuthClientCreated) 69 - |> promise.tap(dispatch) 70 - Nil 71 - }), 93 + effect.batch([ 94 + page_load_effect, 95 + modem.init(on_url_change), 96 + effect.from(fn(dispatch) { 97 + atp.get_oauth_client(client_id:) 98 + |> promise.map(OAuthClientCreated) 99 + |> promise.tap(dispatch) 100 + Nil 101 + }), 102 + ]), 72 103 ) 73 104 } 74 105 106 + fn on_url_change(uri: uri.Uri) -> Msg { 107 + case uri.path_segments(uri.path) { 108 + ["new"] -> { 109 + let #(model, effect) = upload.init(Nil) 110 + OnRouteChange(UploadPage(model), effect.map(effect, UploadPageMsg)) 111 + } 112 + [handle, cid] -> { 113 + let #(model, effect) = image_view.init(handle:, cid:) 114 + OnRouteChange(ImageViewPage(model), effect.map(effect, ImageViewPageMsg)) 115 + } 116 + _ -> { 117 + let #(model, effect) = home.init(Nil) 118 + OnRouteChange(HomePage(model), effect.map(effect, HomePageMsg)) 119 + } 120 + } 121 + } 122 + 75 123 fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 76 124 case msg { 77 125 OAuthClientCreated(oac) -> { ··· 130 178 } 131 179 None -> #(model, effect.none()) 132 180 } 133 - 134 181 OAuthLogin(session) -> #( 135 182 Model(..model, session: Some(session)), 136 183 effect.none(), ··· 157 204 Nil 158 205 }), 159 206 ) 160 - UserSubmittedImageUploadForm(formdata:) -> { 161 - let files = 162 - list.map(formdata, fn(a) { 163 - case a { 164 - #(_, FormDataFile(file)) -> Ok(file) 165 - #(_, FormDataString(_)) -> Error(Nil) 166 - } 167 - }) 168 - |> result.values 169 - 170 - // if user is submitting files(s) we can assume agent exists 171 - let assert Some(agent) = model.agent 172 - 173 - #( 174 - Model(..model, files:), 175 - effect.batch( 176 - list.map(files, fn(file) { 177 - effect.from(fn(dispatch) { 178 - atp.upload_file_blob(agent, file, Nil) 179 - |> promise.map(FileBlobUploadAttempted) 180 - |> promise.tap(dispatch) 181 - Nil 182 - }) 183 - }), 184 - ), 185 - ) 186 - } 187 - FileBlobUploadAttempted(result: Ok(output)) -> { 188 - // if user is submitting files(s) we can assume agent exists 189 - let assert Some(agent) = model.agent 190 - echo "yippee" 191 - echo output 192 - let ref = atp.get_blob_ref(output) 193 - #( 194 - model, 195 - effect.from(fn(dispatch) { 196 - atp.create_image_record(agent, ref, "") 197 - |> promise.map(ImageRecordCreationAttempted) 198 - |> promise.tap(dispatch) 199 - Nil 200 - }), 201 - ) 202 - } 203 - FileBlobUploadAttempted(result: Error(_)) -> { 204 - echo "fuck" 205 - #(model, effect.none()) 206 - } 207 - ImageRecordCreationAttempted(result: Error(_)) -> { 208 - echo "fuck x2" 209 - #(model, effect.none()) 210 - } 211 - ImageRecordCreationAttempted(result: Ok(_)) -> { 212 - echo "todo: redirect to page" 213 - #(model, effect.none()) 214 - } 207 + UploadPageMsg(upload_msg) -> 208 + case model.agent, model.route { 209 + Some(agent), UploadPage(upload_model) -> { 210 + let #(upload_model, upload_effect) = 211 + upload.update(upload_model, upload_msg, agent) 212 + #( 213 + Model(..model, route: UploadPage(upload_model)), 214 + effect.map(upload_effect, UploadPageMsg), 215 + ) 216 + } 217 + Some(_), _ -> #(model, modem.push("", option.None, option.None)) 218 + // TODO: invalidate session and refresh 219 + None, _ -> #(model, effect.none()) 220 + } 221 + OnRouteChange(route, effect) -> #(Model(..model, route:), effect) 222 + HomePageMsg(home_msg) -> 223 + case model.route { 224 + HomePage(home_model) -> { 225 + let #(home_model, home_effect) = home.update(home_model, home_msg) 226 + #( 227 + Model(..model, route: HomePage(home_model)), 228 + effect.map(home_effect, HomePageMsg), 229 + ) 230 + } 231 + _ -> #(model, effect.none()) 232 + } 233 + ImageViewPageMsg(image_view_msg) -> 234 + case model.route { 235 + ImageViewPage(image_view_model) -> { 236 + let #(image_view_model, image_view_effect) = 237 + image_view.update(image_view_model, image_view_msg) 238 + #( 239 + Model(..model, route: ImageViewPage(image_view_model)), 240 + effect.map(image_view_effect, ImageViewPageMsg), 241 + ) 242 + } 243 + _ -> #(model, effect.none()) 244 + } 215 245 } 216 246 } 217 247 ··· 223 253 True -> loading_spinner() 224 254 False -> element.none() 225 255 }, 256 + // TODO: move to own page + login button in header 226 257 case model.show_login { 227 258 True -> login_container(model.login_form, model.login_busy) 228 259 False -> element.none() 229 260 }, 230 - case model.handle { 231 - None -> element.none() 232 - Some(_) -> image_upload_container() 261 + case model.route { 262 + HomePage(home_model) -> 263 + home.view(home_model) |> element.map(HomePageMsg) 264 + ImageViewPage(image_view_model) -> 265 + image_view.view(image_view_model) |> element.map(ImageViewPageMsg) 266 + UploadPage(upload_model) -> 267 + upload.view(upload_model) |> element.map(UploadPageMsg) 233 268 }, 234 269 ]), 235 270 ]) 236 271 } 237 272 238 - fn header(show_logout: Bool) -> element.Element(Msg) { 273 + fn header(signed_in: Bool) -> element.Element(Msg) { 239 274 html.header([], [ 240 - html.hgroup([], [html.h1([], [html.text("OAuth Browser Example")])]), 275 + html.hgroup([], [ 276 + html.h1([], [html.a([attr.href("/")], [html.text("Plonk")])]), 277 + ]), 241 278 html.nav([], [ 242 279 html.ul([], [ 243 - case show_logout { 280 + case signed_in { 244 281 True -> 245 - html.li([attr.id("logout-nav"), event.on_click(UserClickedLogout)], [ 246 - html.a([attr.href("#")], [html.text("Logout")]), 282 + element.fragment([ 283 + html.li( 284 + [attr.id("logout-nav"), event.on_click(UserClickedLogout)], 285 + [ 286 + html.a([attr.href("#")], [html.text("Logout")]), 287 + ], 288 + ), 289 + html.li([], [html.a([attr.href("/new")], [html.text("+ New")])]), 247 290 ]) 248 291 False -> element.none() 249 292 }, ··· 270 313 ]) 271 314 } 272 315 273 - type Login { 274 - Login(username: String) 275 - } 276 - 277 - fn login_form() -> form.Form(Login) { 278 - form.new({ 279 - use username <- form.field("username", { 280 - form.parse_string 281 - |> form.check_not_empty 282 - }) 283 - Login(username:) 284 - |> form.success 285 - }) 286 - } 287 - 288 316 fn login_container( 289 - form: form.Form(Login), 317 + form: form.Form(forms.Login), 290 318 login_busy: Bool, 291 319 ) -> element.Element(Msg) { 292 320 let submitted = fn(fields) { ··· 335 363 html.p([attr.id("login-form-error"), attr.style("color", "#E37474")], []), 336 364 ]) 337 365 } 338 - 339 - pub type FormDataType { 340 - FormDataString(String) 341 - FormDataFile(file.File) 342 - } 343 - 344 - fn on_submit_with_files( 345 - msg: fn(List(#(String, FormDataType))) -> msg, 346 - ) -> attr.Attribute(msg) { 347 - event.on("submit", { 348 - use formdata <- decode.subfield(["detail", "formData"], formdata_decoder()) 349 - formdata 350 - |> msg 351 - |> decode.success() 352 - }) 353 - |> event.prevent_default 354 - } 355 - 356 - fn formdata_decoder() -> decode.Decoder(List(#(String, FormDataType))) { 357 - let k_v_decoder = { 358 - use key <- decode.field(0, decode.string) 359 - use value <- decode.field( 360 - 1, 361 - decode.one_of(decode.map(decode.string, FormDataString), [ 362 - decode.map( 363 - decode.new_primitive_decoder("File", file.dynamic_file), 364 - FormDataFile, 365 - ), 366 - ]), 367 - ) 368 - decode.success(#(key, value)) 369 - } 370 - 371 - decode.list(k_v_decoder) 372 - } 373 - 374 - fn image_upload_container() -> element.Element(Msg) { 375 - html.article([attr.id("image-upload-container")], [ 376 - html.h2([], [html.text("Upload Image")]), 377 - html.form( 378 - [ 379 - attr.id("image-upload-form"), 380 - on_submit_with_files(UserSubmittedImageUploadForm), 381 - ], 382 - [ 383 - html.input([ 384 - attr.type_("file"), 385 - attr.id("file-upload"), 386 - attr.name("image_file"), 387 - attr.accept(["image/*"]), 388 - attr.required(True), 389 - ]), 390 - html.button([attr.id("image-upload-button")], [html.text("Upload")]), 391 - ], 392 - ), 393 - ]) 394 - }
-6
src/plonk/file.ffi.mjs
··· 1 1 import { Result, Result$Ok, Result$Error } from "../gleam.mjs"; 2 2 3 - export function inspect(file) { 4 - console.log("inspecting"); 5 - console.log(file); 6 - return file; 7 - } 8 - 9 3 /** 10 4 * 11 5 * @returns {Result<File, File>} result
-3
src/plonk/file.gleam
··· 4 4 5 5 @external(javascript, "./file.ffi.mjs", "file_decoder") 6 6 pub fn dynamic_file(from _data: dynamic.Dynamic) -> Result(File, File) 7 - 8 - @external(javascript, "./file.ffi.mjs", "inspect") 9 - pub fn inspect(file: File) -> File
+55
src/plonk/forms.gleam
··· 1 + import formal/form 2 + import gleam/dynamic/decode 3 + import lustre/attribute 4 + import lustre/event 5 + import plonk/file 6 + 7 + pub type FormDataType { 8 + FormDataString(String) 9 + FormDataFile(file.File) 10 + } 11 + 12 + pub fn on_submit_with_files( 13 + msg: fn(List(#(String, FormDataType))) -> msg, 14 + ) -> attribute.Attribute(msg) { 15 + event.on("submit", { 16 + use formdata <- decode.subfield(["detail", "formData"], formdata_decoder()) 17 + formdata 18 + |> msg 19 + |> decode.success() 20 + }) 21 + |> event.prevent_default 22 + } 23 + 24 + pub fn formdata_decoder() -> decode.Decoder(List(#(String, FormDataType))) { 25 + let k_v_decoder = { 26 + use key <- decode.field(0, decode.string) 27 + use value <- decode.field( 28 + 1, 29 + decode.one_of(decode.map(decode.string, FormDataString), [ 30 + decode.map( 31 + decode.new_primitive_decoder("File", file.dynamic_file), 32 + FormDataFile, 33 + ), 34 + ]), 35 + ) 36 + decode.success(#(key, value)) 37 + } 38 + 39 + decode.list(k_v_decoder) 40 + } 41 + 42 + pub type Login { 43 + Login(username: String) 44 + } 45 + 46 + pub fn login_form() -> form.Form(Login) { 47 + form.new({ 48 + use username <- form.field("username", { 49 + form.parse_string 50 + |> form.check_not_empty 51 + }) 52 + Login(username:) 53 + |> form.success 54 + }) 55 + }
+55
src/plonk/pages/home.gleam
··· 1 + import gleam/list 2 + import gleam/option 3 + import lustre/effect 4 + import lustre/element 5 + import lustre/element/html 6 + import lustre/event 7 + import modem 8 + 9 + pub type Msg { 10 + UserClickedImage(handle: String, cid: String) 11 + UserClickedSeeAll 12 + } 13 + 14 + pub type Model { 15 + Model(recent_images: option.Option(List(#(String, String)))) 16 + } 17 + 18 + pub fn init(_flags: Nil) -> #(Model, effect.Effect(Msg)) { 19 + #(Model(recent_images: option.None), effect.none()) 20 + } 21 + 22 + pub fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 23 + case msg { 24 + UserClickedImage(handle:, cid:) -> #( 25 + model, 26 + modem.push(handle <> "/" <> cid, option.None, option.None), 27 + ) 28 + UserClickedSeeAll -> #(model, effect.none()) 29 + } 30 + } 31 + 32 + pub fn view(model: Model) -> element.Element(Msg) { 33 + html.article([], [ 34 + html.p([], [html.text("Lorem ipsum..")]), 35 + case model.recent_images { 36 + option.Some(images) -> 37 + html.div([], [ 38 + html.h2([], [html.text("Recent Files")]), 39 + html.button([event.on_click(UserClickedSeeAll)], [ 40 + html.text("See All"), 41 + ]), 42 + html.span( 43 + [], 44 + list.map(images, fn(image) { 45 + let #(handle, cid) = image 46 + html.button([event.on_click(UserClickedImage(handle:, cid:))], [ 47 + html.text(handle <> "/" <> cid), 48 + ]) 49 + }), 50 + ), 51 + ]) 52 + option.None -> element.none() 53 + }, 54 + ]) 55 + }
+31
src/plonk/pages/image_view.gleam
··· 1 + import lustre/effect 2 + import lustre/element 3 + import lustre/element/html 4 + 5 + pub type Msg { 6 + UserClickedDelete 7 + UserClickedEdit 8 + } 9 + 10 + pub type Model { 11 + Model(editing: Bool, owner: Bool, handle: String, cid: String) 12 + } 13 + 14 + pub fn init( 15 + handle handle: String, 16 + cid cid: String, 17 + ) -> #(Model, effect.Effect(Msg)) { 18 + // TODO: fire effect to check if owner 19 + #(Model(editing: False, owner: False, handle:, cid:), effect.none()) 20 + } 21 + 22 + pub fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 23 + case msg { 24 + UserClickedDelete -> #(model, effect.none()) 25 + UserClickedEdit -> #(model, effect.none()) 26 + } 27 + } 28 + 29 + pub fn view(model: Model) -> element.Element(Msg) { 30 + html.article([], [html.h2([], [html.text(model.handle <> "/" <> model.cid)])]) 31 + }
+98
src/plonk/pages/upload.gleam
··· 1 + import gleam/javascript/promise 2 + import gleam/list 3 + import gleam/result 4 + import lustre/attribute 5 + import lustre/effect 6 + import lustre/element 7 + import lustre/element/html 8 + import plonk/atp 9 + import plonk/file 10 + import plonk/forms 11 + 12 + pub type Model { 13 + Model(upload_is_busy: Bool, files: List(file.File)) 14 + } 15 + 16 + pub type Msg { 17 + UserSubmittedUploadForm(formdata: List(#(String, forms.FormDataType))) 18 + ClientUploadedBlob(result: Result(atp.BlobOutputSchema, Nil)) 19 + ClientCreatedRecord(result: Result(atp.CreateRecordOutputSchema, String)) 20 + } 21 + 22 + pub fn init(_flags: Nil) -> #(Model, effect.Effect(Msg)) { 23 + #(Model(upload_is_busy: False, files: []), effect.none()) 24 + } 25 + 26 + pub fn update( 27 + model: Model, 28 + msg: Msg, 29 + agent: atp.Agent, 30 + ) -> #(Model, effect.Effect(Msg)) { 31 + case msg { 32 + UserSubmittedUploadForm(formdata:) -> { 33 + let files = 34 + list.map(formdata, fn(data) { 35 + case data { 36 + #(_, forms.FormDataFile(file)) -> Ok(file) 37 + #(_, forms.FormDataString(_)) -> Error(Nil) 38 + } 39 + }) 40 + |> result.values 41 + 42 + #( 43 + Model(upload_is_busy: True, files:), 44 + effect.batch( 45 + list.map(files, fn(file) { 46 + effect.from(fn(dispatch) { 47 + atp.upload_file_blob(agent, file, Nil) 48 + |> promise.map(ClientUploadedBlob) 49 + |> promise.tap(dispatch) 50 + Nil 51 + }) 52 + }), 53 + ), 54 + ) 55 + } 56 + ClientUploadedBlob(result: Error(_)) -> #( 57 + Model(..model, upload_is_busy: False), 58 + effect.none(), 59 + ) 60 + ClientCreatedRecord(result: Error(_)) -> #( 61 + Model(..model, upload_is_busy: False), 62 + effect.none(), 63 + ) 64 + ClientUploadedBlob(result: Ok(output_schema)) -> #( 65 + model, 66 + effect.from(fn(dispatch) { 67 + atp.create_image_record(agent, atp.get_blob_ref(output_schema:), "") 68 + |> promise.map(ClientCreatedRecord) 69 + |> promise.tap(dispatch) 70 + Nil 71 + }), 72 + ) 73 + // TODO: Redirect to view page 74 + // alternative: show list of links to file(s) uploaded 75 + // - allows for multi-upload better 76 + ClientCreatedRecord(result: Ok(_)) -> #( 77 + Model(..model, upload_is_busy: False), 78 + effect.none(), 79 + ) 80 + } 81 + } 82 + 83 + pub fn view(model: Model) -> element.Element(Msg) { 84 + html.article([], [ 85 + html.h2([], [html.text("Upload Image")]), 86 + html.form([forms.on_submit_with_files(UserSubmittedUploadForm)], [ 87 + html.input([ 88 + attribute.type_("file"), 89 + attribute.name("file_upload"), 90 + attribute.accept(["image/*"]), 91 + attribute.required(True), 92 + ]), 93 + html.button([attribute.aria_busy(model.upload_is_busy)], [ 94 + html.text("Upload"), 95 + ]), 96 + ]), 97 + ]) 98 + }