Image sharing backed by ATProto
atproto images gleam

implement title + description

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

lesbian.skin 8bceace4 041a18e7

verified
+167 -43
+1 -1
README.md
··· 11 11 12 12 ## Roadmap 13 13 - [x] MVP: In-app viewing 14 - - [ ] MVP: Image titles/descriptions 14 + - [x] MVP: Image titles/descriptions 15 15 - [x] MVP: Formal Lexicon definition(s) 16 16 - [ ] MVP: Embeds 17 17 - [ ] MVP: Styling
+4 -4
lexicons/image.json
··· 12 12 "properties": { 13 13 "title": { 14 14 "type": "string" 15 - } 15 + }, 16 16 "description": { 17 17 "type": "string" 18 - } 18 + }, 19 19 "content": { 20 - "type": "blob" 20 + "type": "blob", 21 21 "accept": ["image/*"] 22 - } 22 + }, 23 23 "createdAt": { 24 24 "type": "string", 25 25 "format": "datetime"
+39
src/plonk/forms.gleam
··· 1 1 import formal/form 2 2 import gleam/dynamic/decode 3 + import gleam/list 3 4 import lustre/attribute 4 5 import lustre/event 5 6 import plonk/file ··· 9 10 FormDataFile(file.File) 10 11 } 11 12 13 + pub type FormDataError { 14 + IdNotFound 15 + IdCollision(String) 16 + IncorrectFormDataType 17 + } 18 + 19 + pub fn file( 20 + formdata: List(#(String, FormDataType)), 21 + id: String, 22 + f: fn(file.File) -> Result(a, FormDataError), 23 + ) -> Result(a, FormDataError) { 24 + let matches = list.key_filter(formdata, id) 25 + case matches { 26 + [FormDataFile(file)] -> f(file) 27 + [_] -> Error(IncorrectFormDataType) 28 + [] -> Error(IdNotFound) 29 + _ -> Error(IdCollision(id)) 30 + } 31 + } 32 + 33 + pub fn string( 34 + formdata: List(#(String, FormDataType)), 35 + id: String, 36 + f: fn(String) -> Result(a, FormDataError), 37 + ) -> Result(a, FormDataError) { 38 + let matches = list.key_filter(formdata, id) 39 + case matches { 40 + [FormDataString(string)] -> f(string) 41 + [_] -> Error(IncorrectFormDataType) 42 + [] -> Error(IdNotFound) 43 + _ -> Error(IdCollision(id)) 44 + } 45 + } 46 + 12 47 pub fn on_submit_with_files( 13 48 msg: fn(List(#(String, FormDataType))) -> msg, 14 49 ) -> attribute.Attribute(msg) { ··· 37 72 } 38 73 39 74 decode.list(k_v_decoder) 75 + } 76 + 77 + pub type Upload { 78 + Upload(file: file.File, title: String, description: String) 40 79 } 41 80 42 81 pub type Login {
+8 -1
src/plonk/pages/image_view.gleam
··· 126 126 False -> 127 127 case model.file { 128 128 option.Some(image) -> { 129 - html.img([attribute.src(image)]) 129 + // if model.file exists, model.image must exist 130 + let assert option.Some(record) = model.image 131 + html.div([], [ 132 + html.h2([], [html.text(record.title)]), 133 + html.p([], [html.text(record.created_at)]), 134 + html.p([], [html.text(record.description)]), 135 + html.img([attribute.src(image), attribute.alt(record.description)]), 136 + ]) 130 137 } 131 138 option.None -> html.h2([], [html.text("404 Not Found")]) 132 139 }
+66 -34
src/plonk/pages/upload.gleam
··· 11 11 import plonk/record/image 12 12 13 13 pub type Model { 14 - Model(upload_is_busy: Bool, files: List(file.File)) 14 + Model(upload_is_busy: Bool, files: List(forms.Upload)) 15 15 } 16 16 17 17 pub type Msg { 18 18 UserSubmittedUploadForm(formdata: List(#(String, forms.FormDataType))) 19 - ClientUploadedBlob(result: Result(atp.BlobOutputSchema, Nil)) 19 + ClientUploadedBlob( 20 + title: String, 21 + description: String, 22 + result: Result(atp.BlobOutputSchema, Nil), 23 + ) 20 24 ClientCreatedRecord(result: Result(image.Record, String)) 21 25 } 22 26 ··· 31 35 ) -> #(Model, effect.Effect(Msg)) { 32 36 case msg { 33 37 UserSubmittedUploadForm(formdata:) -> { 34 - let files = 35 - list.map(formdata, fn(data) { 36 - case data { 37 - #(_, forms.FormDataFile(file)) -> Ok(file) 38 - #(_, forms.FormDataString(_)) -> Error(Nil) 39 - } 40 - }) 41 - |> result.values 38 + let form = { 39 + use file <- forms.file(formdata, "file_upload") 40 + use title <- forms.string(formdata, "file_title") 41 + use description <- forms.string(formdata, "file_description") 42 + Ok(forms.Upload(file:, title:, description:)) 43 + } 42 44 43 - #( 44 - Model(upload_is_busy: True, files:), 45 - effect.batch( 46 - list.map(files, fn(file) { 47 - effect.from(fn(dispatch) { 48 - atp.upload_file_blob(agent, file, Nil) 49 - |> promise.map(ClientUploadedBlob) 50 - |> promise.tap(dispatch) 51 - Nil 52 - }) 45 + case form { 46 + Ok(file) -> #( 47 + Model(upload_is_busy: True, files: [file]), 48 + effect.from(fn(dispatch) { 49 + atp.upload_file_blob(agent, file.file, Nil) 50 + |> promise.map(ClientUploadedBlob( 51 + title: file.title, 52 + description: file.description, 53 + result: _, 54 + )) 55 + |> promise.tap(dispatch) 56 + Nil 53 57 }), 54 - ), 55 - ) 58 + ) 59 + Error(_) -> #(model, effect.none()) 60 + } 56 61 } 57 - ClientUploadedBlob(result: Error(_)) -> #( 62 + ClientUploadedBlob(result: Error(_), title: _, description: _) -> #( 58 63 Model(..model, upload_is_busy: False), 59 64 effect.none(), 60 65 ) ··· 62 67 Model(..model, upload_is_busy: False), 63 68 effect.none(), 64 69 ) 65 - ClientUploadedBlob(result: Ok(output_schema)) -> #( 70 + ClientUploadedBlob(result: Ok(output_schema), title:, description:) -> #( 66 71 model, 67 72 effect.from(fn(dispatch) { 68 - image.create_record(agent, "", "", atp.get_blob_ref(output_schema:)) 73 + image.create_record( 74 + agent, 75 + title, 76 + description, 77 + atp.get_blob_ref(output_schema:), 78 + ) 69 79 |> promise.map(ClientCreatedRecord) 70 80 |> promise.tap(dispatch) 71 81 Nil ··· 74 84 // TODO: Redirect to view page 75 85 // alternative: show list of links to file(s) uploaded 76 86 // - allows for multi-upload better 77 - ClientCreatedRecord(result: Ok(_)) -> #( 78 - Model(..model, upload_is_busy: False), 79 - effect.none(), 80 - ) 87 + ClientCreatedRecord(result: Ok(record)) -> { 88 + echo record 89 + #(Model(..model, upload_is_busy: False), effect.none()) 90 + } 81 91 } 82 92 } 83 93 ··· 85 95 html.article([], [ 86 96 html.h2([], [html.text("Upload Image")]), 87 97 html.form([forms.on_submit_with_files(UserSubmittedUploadForm)], [ 88 - html.input([ 89 - attribute.type_("file"), 90 - attribute.name("file_upload"), 91 - attribute.accept(["image/*"]), 92 - attribute.required(True), 98 + html.label([], [ 99 + html.text("Choose a file:"), 100 + html.input([ 101 + attribute.type_("file"), 102 + attribute.id("file-upload"), 103 + attribute.name("file_upload"), 104 + attribute.accept(["image/*"]), 105 + attribute.required(True), 106 + ]), 107 + ]), 108 + html.label([], [ 109 + html.text("Title:"), 110 + html.input([ 111 + attribute.type_("text"), 112 + attribute.name("file_title"), 113 + attribute.required(True), 114 + ]), 115 + ]), 116 + html.label([], [ 117 + html.text("Description:"), 118 + html.textarea( 119 + [ 120 + attribute.name("file_description"), 121 + attribute.required(True), 122 + ], 123 + "", 124 + ), 93 125 ]), 94 126 html.button([attribute.aria_busy(model.upload_is_busy)], [ 95 127 html.text("Upload"),
+25
src/plonk/record/image.ffi.mjs
··· 29 29 if (!res.success) { 30 30 return Result$Error(JSON.stringify(res)); 31 31 } 32 + console.log(res); 32 33 return Result$Ok(res.data); 33 34 } catch (e) { 34 35 return Result$Error(`${e}`); ··· 105 106 export function getGrCid(output) { 106 107 return output.cid; 107 108 } 109 + 110 + /** 111 + * @param {ComAtprotoRepoGetRecord.OutputSchema} output 112 + * @returns {String} cid 113 + */ 114 + export function getTitle(output) { 115 + return output.value.title; 116 + } 117 + 118 + /** 119 + * @param {ComAtprotoRepoGetRecord.OutputSchema} output 120 + * @returns {String} cid 121 + */ 122 + export function getDescription(output) { 123 + return output.value.description; 124 + } 125 + 126 + /** 127 + * @param {ComAtprotoRepoGetRecord.OutputSchema} output 128 + * @returns {String} cid 129 + */ 130 + export function getCreatedAt(output) { 131 + return output.value.createdAt; 132 + }
+24 -3
src/plonk/record/image.gleam
··· 3 3 import plonk/atp 4 4 5 5 pub type Record { 6 - Record(uri: String, cid: String, blob_cid: String) 6 + Record( 7 + uri: String, 8 + cid: String, 9 + blob_cid: String, 10 + title: String, 11 + description: String, 12 + created_at: String, 13 + ) 7 14 } 8 15 9 16 type GetRecordOutputSchema ··· 21 28 let uri = get_gr_uri(output_schema) 22 29 let cid = get_gr_cid(output_schema) 23 30 let blob_cid = get_blob_cid(output_schema) 24 - Record(uri:, cid:, blob_cid:) 31 + let title = get_title(output_schema) 32 + let description = get_description(output_schema) 33 + let created_at = get_created_at(output_schema) 34 + Record(uri:, cid:, blob_cid:, title:, description:, created_at:) 25 35 }) 26 36 }) 27 37 } ··· 39 49 @external(javascript, "./image.ffi.mjs", "getGrCid") 40 50 fn get_gr_cid(output: GetRecordOutputSchema) -> String 41 51 52 + @external(javascript, "./image.ffi.mjs", "getTitle") 53 + fn get_title(output: GetRecordOutputSchema) -> String 54 + 55 + @external(javascript, "./image.ffi.mjs", "getDescription") 56 + fn get_description(output: GetRecordOutputSchema) -> String 57 + 58 + @external(javascript, "./image.ffi.mjs", "getCreatedAt") 59 + fn get_created_at(output: GetRecordOutputSchema) -> String 60 + 42 61 pub fn create_record( 43 62 agent: atp.Agent, 44 63 title: String, ··· 50 69 result.map(r, fn(output_schema) { 51 70 let uri = get_cr_uri(output_schema) 52 71 let cid = get_cr_cid(output_schema) 53 - Record(uri:, cid:, blob_cid: "") 72 + // TODO: should probably try and populate blob_cid and created_at 73 + // properly but it's fine for now 74 + Record(uri:, cid:, blob_cid: "", title:, description:, created_at: "") 54 75 }) 55 76 }) 56 77 }