Web-based stream overlay manager

Make rooms

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

lesbian.skin 139643fb ec66afed

verified
+1
gleam.toml
··· 15 15 logging = ">= 1.3.0 and < 2.0.0" 16 16 gleam_http = ">= 4.3.0 and < 5.0.0" 17 17 gleam_json = ">= 3.0.2 and < 4.0.0" 18 + formal = ">= 3.0.0 and < 4.0.0" 18 19 19 20 [dev-dependencies] 20 21 gleeunit = ">= 1.0.0 and < 2.0.0"
+3
manifest.toml
··· 5 5 { name = "compresso", version = "0.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_stdlib", "gleam_yielder", "logging"], otp_app = "compresso", source = "hex", outer_checksum = "8BE29A1EDA42F70826ED148EAE40C46BB3FC18E78FE472663DB01DD4A38172D4" }, 6 6 { name = "ewe", version = "2.0.2", build_tools = ["gleam"], requirements = ["compresso", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "logging"], otp_app = "ewe", source = "hex", outer_checksum = "55FABC310F9C0EC67021F6CA973ED59A37481E39E6A850813D5B6F4CEA241A68" }, 7 7 { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 8 + { name = "formal", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "formal", source = "hex", outer_checksum = "686D0C4C9CB36397DBAC2EC8C6ED9BD0F81B2DF2E88F75A7DB75F56768DDD8FC" }, 8 9 { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 9 10 { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 10 11 { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 11 12 { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 12 13 { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 13 14 { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 15 + { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, 14 16 { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 15 17 { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, 16 18 { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, ··· 22 24 23 25 [requirements] 24 26 ewe = { version = ">= 2.0.1 and < 3.0.0" } 27 + formal = { version = ">= 3.0.0 and < 4.0.0" } 25 28 gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } 26 29 gleam_http = { version = ">= 4.3.0 and < 5.0.0" } 27 30 gleam_json = { version = ">= 3.0.2 and < 4.0.0" }
+34 -85
src/clover.gleam
··· 1 1 import clover/counter 2 2 import clover/data 3 + import clover/pages/index 4 + import clover/pages/room 3 5 import ewe 4 - import gleam/bytes_tree 6 + import formal/form 5 7 import gleam/erlang/application 6 8 import gleam/erlang/process 7 9 import gleam/http/request 8 10 import gleam/http/response 9 11 import gleam/int 10 - import gleam/json 12 + import gleam/list 11 13 import gleam/option 14 + import gleam/string 15 + import gleam/uri 12 16 import logging 13 17 import lustre 14 - import lustre/attribute as attr 15 - import lustre/element 16 - import lustre/element/html 17 - import lustre/server_component 18 18 19 19 pub fn main() { 20 20 logging.configure() ··· 45 45 use req <- log_req(req) 46 46 47 47 case request.path_segments(req) { 48 - [] -> serve_html() 48 + [] -> data.page(index.view()) 49 + ["new"] -> { 50 + case list.key_find(req.headers, "content-type") { 51 + Ok("application/x-www-form-urlencoded") 52 + | Ok("application/x-www-form-urlencoded; " <> _) -> { 53 + use body <- data.require_string_body(req) 54 + case uri.parse_query(body) { 55 + Error(_) -> data.bad_request("Invalid form encoding") 56 + Ok(pairs) -> { 57 + let pairs = sort_keys(pairs) 58 + let form = index.room_form() |> form.add_values(pairs) 59 + case form.run(form) { 60 + Ok(data) -> data.redirect("/room/" <> data.id) 61 + Error(_) -> data.redirect("/") 62 + } 63 + } 64 + } 65 + } 66 + _ -> data.redirect("/") 67 + } 68 + } 69 + ["room", id] -> data.page(room.view(id)) 70 + ["room", id, "html"] -> data.page(room.view_html(id)) 49 71 ["lustre", "runtime.mjs"] -> serve_runtime() 50 72 ["ws"] -> serve_counter(req, counter) 51 73 _ -> data.not_found() 52 74 } 53 75 } 54 76 55 - fn serve_html() -> response.Response(ewe.ResponseBody) { 56 - let html = 57 - html.html([attr.lang("en")], [ 58 - html.head([], [ 59 - html.meta([attr.charset("utf-8")]), 60 - html.meta([ 61 - attr.name("viewport"), 62 - attr.content("width=device-width, initial-scale=1"), 63 - ]), 64 - html.title([], "Clover"), 65 - html.script([attr.type_("module"), attr.src("/lustre/runtime.mjs")], ""), 66 - ]), 67 - html.body( 68 - [attr.styles([#("max-width", "32rem"), #("margin", "3rem auto")])], 69 - [server_component.element([server_component.route("/ws")], [])], 70 - ), 71 - ]) 72 - |> element.to_document_string_tree() 73 - |> bytes_tree.from_string_tree() 74 - 75 - response.new(200) 76 - |> data.html_response(html) 77 + fn sort_keys(pairs: List(#(String, t))) -> List(#(String, t)) { 78 + list.sort(pairs, fn(a, b) { string.compare(a.0, b.0) }) 77 79 } 78 80 79 81 fn serve_runtime() -> response.Response(ewe.ResponseBody) { ··· 96 98 req: request.Request(ewe.Connection), 97 99 counter: lustre.Runtime(counter.Msg), 98 100 ) -> response.Response(ewe.ResponseBody) { 99 - let on_init = fn(a, b) { init_counter_socket(a, b, counter) } 101 + let on_init = fn(a, b) { counter.init_socket(a, b, counter) } 100 102 101 103 req 102 104 |> ewe.upgrade_websocket( 103 105 on_init:, 104 - handler: loop_counter_socket, 105 - on_close: close_counter_socket, 106 + handler: counter.loop_socket, 107 + on_close: counter.close_socket, 106 108 ) 107 109 } 108 - 109 - type CounterSocket { 110 - CounterSocket( 111 - component: lustre.Runtime(counter.Msg), 112 - self: process.Subject(server_component.ClientMessage(counter.Msg)), 113 - ) 114 - } 115 - 116 - fn init_counter_socket( 117 - _: ewe.WebsocketConnection, 118 - _: process.Selector(_), 119 - counter counter: lustre.Runtime(counter.Msg), 120 - ) -> #(CounterSocket, process.Selector(_)) { 121 - let self = process.new_subject() 122 - let selector = 123 - process.new_selector() 124 - |> process.select(self) 125 - 126 - server_component.register_subject(self) 127 - |> lustre.send(to: counter) 128 - 129 - #(CounterSocket(component: counter, self:), selector) 130 - } 131 - 132 - fn loop_counter_socket( 133 - conn: ewe.WebsocketConnection, 134 - value: CounterSocket, 135 - msg: ewe.WebsocketMessage(server_component.ClientMessage(counter.Msg)), 136 - ) -> ewe.WebsocketNext( 137 - CounterSocket, 138 - server_component.ClientMessage(counter.Msg), 139 - ) { 140 - case msg { 141 - ewe.Text(json) -> { 142 - case json.parse(json, server_component.runtime_message_decoder()) { 143 - Ok(runtime_msg) -> lustre.send(value.component, runtime_msg) 144 - Error(_) -> Nil 145 - } 146 - ewe.websocket_continue(value) 147 - } 148 - ewe.Binary(_) -> ewe.websocket_continue(value) 149 - ewe.User(client_msg) -> { 150 - let json = server_component.client_message_to_json(client_msg) 151 - let assert Ok(_) = ewe.send_text_frame(conn, json.to_string(json)) 152 - ewe.websocket_continue(value) 153 - } 154 - } 155 - } 156 - 157 - fn close_counter_socket(_: ewe.WebsocketConnection, value: CounterSocket) -> Nil { 158 - server_component.deregister_subject(value.self) 159 - |> lustre.send(to: value.component) 160 - }
+56
src/clover/counter.gleam
··· 1 + import ewe 2 + import gleam/erlang/process 1 3 import gleam/int 4 + import gleam/json 2 5 import lustre 3 6 import lustre/attribute as attr 4 7 import lustre/element 5 8 import lustre/element/html 6 9 import lustre/event 10 + import lustre/server_component 7 11 8 12 pub fn component() -> lustre.App(_, Model, Msg) { 9 13 lustre.simple(init, update, view) ··· 44 48 fn view_button(string: String, msg: Msg) -> element.Element(Msg) { 45 49 html.button([event.on_click(msg)], [html.text(string)]) 46 50 } 51 + 52 + // Server Component stuff 53 + 54 + pub type Socket { 55 + Socket( 56 + component: lustre.Runtime(Msg), 57 + self: process.Subject(server_component.ClientMessage(Msg)), 58 + ) 59 + } 60 + 61 + pub fn init_socket( 62 + _: ewe.WebsocketConnection, 63 + _: process.Selector(_), 64 + counter counter: lustre.Runtime(Msg), 65 + ) -> #(Socket, process.Selector(_)) { 66 + let self = process.new_subject() 67 + let selector = 68 + process.new_selector() 69 + |> process.select(self) 70 + 71 + server_component.register_subject(self) 72 + |> lustre.send(to: counter) 73 + 74 + #(Socket(component: counter, self:), selector) 75 + } 76 + 77 + pub fn loop_socket( 78 + conn: ewe.WebsocketConnection, 79 + value: Socket, 80 + msg: ewe.WebsocketMessage(server_component.ClientMessage(Msg)), 81 + ) -> ewe.WebsocketNext(Socket, server_component.ClientMessage(Msg)) { 82 + case msg { 83 + ewe.Text(json) -> { 84 + case json.parse(json, server_component.runtime_message_decoder()) { 85 + Ok(runtime_msg) -> lustre.send(value.component, runtime_msg) 86 + Error(_) -> Nil 87 + } 88 + ewe.websocket_continue(value) 89 + } 90 + ewe.Binary(_) -> ewe.websocket_continue(value) 91 + ewe.User(client_msg) -> { 92 + let json = server_component.client_message_to_json(client_msg) 93 + let assert Ok(_) = ewe.send_text_frame(conn, json.to_string(json)) 94 + ewe.websocket_continue(value) 95 + } 96 + } 97 + } 98 + 99 + pub fn close_socket(_: ewe.WebsocketConnection, value: Socket) -> Nil { 100 + server_component.deregister_subject(value.self) 101 + |> lustre.send(to: value.component) 102 + }
+55 -2
src/clover/data.gleam
··· 1 1 import ewe 2 + import gleam/bit_array 2 3 import gleam/bytes_tree 4 + import gleam/http/request 3 5 import gleam/http/response 6 + import lustre/attribute as attr 7 + import lustre/element 8 + import lustre/element/html 4 9 5 - pub fn text_response( 10 + pub fn text( 6 11 resp: response.Response(_), 7 12 string: String, 8 13 ) -> response.Response(ewe.ResponseBody) { ··· 11 16 |> response.set_body(ewe.TextData(string)) 12 17 } 13 18 14 - pub fn html_response( 19 + pub fn html( 15 20 resp: response.Response(_), 16 21 bytes: bytes_tree.BytesTree, 17 22 ) -> response.Response(ewe.ResponseBody) { ··· 24 29 response.new(404) 25 30 |> response.set_body(ewe.Empty) 26 31 } 32 + 33 + pub fn redirect(to url: String) -> response.Response(ewe.ResponseBody) { 34 + response.new(303) 35 + |> response.set_header("location", url) 36 + |> response.set_header("content-type", "text/plain; charset=utf-8") 37 + |> response.set_body(ewe.TextData("You are being redirected: " <> url)) 38 + } 39 + 40 + pub fn page(body: element.Element(a)) -> response.Response(ewe.ResponseBody) { 41 + let r = 42 + html.html([attr.lang("en")], [ 43 + html.head([], [ 44 + html.meta([attr.charset("utf-8")]), 45 + html.meta([ 46 + attr.name("viewport"), 47 + attr.content("width=device-width, initial-scale=1"), 48 + ]), 49 + html.title([], "Clover"), 50 + html.script([attr.type_("module"), attr.src("/lustre/runtime.mjs")], ""), 51 + ]), 52 + body, 53 + ]) 54 + |> element.to_document_string_tree() 55 + |> bytes_tree.from_string_tree() 56 + 57 + response.new(200) 58 + |> html(r) 59 + } 60 + 61 + pub fn bad_request(detail: String) -> response.Response(ewe.ResponseBody) { 62 + let body = case detail { 63 + "" -> "Bad request" 64 + _ -> "Bad request: " <> detail 65 + } 66 + response.new(400) 67 + |> response.set_header("content-type", "text/plain") 68 + |> response.set_body(ewe.TextData(body)) 69 + } 70 + 71 + pub fn require_string_body( 72 + req: request.Request(ewe.Connection), 73 + next: fn(String) -> response.Response(ewe.ResponseBody), 74 + ) -> response.Response(ewe.ResponseBody) { 75 + case bit_array.to_string(req.body.buffer.data) { 76 + Ok(body) -> next(body) 77 + Error(_) -> bad_request("Invalid UTF-8") 78 + } 79 + }
+48
src/clover/pages/index.gleam
··· 1 + import formal/form 2 + import gleam/list 3 + import lustre/attribute as attr 4 + import lustre/element/html 5 + 6 + pub type RoomInput { 7 + RoomInput(id: String) 8 + } 9 + 10 + pub fn room_form() -> form.Form(RoomInput) { 11 + form.new({ 12 + use id <- form.field("id", form.parse_string) 13 + form.success(RoomInput(id:)) 14 + }) 15 + } 16 + 17 + pub fn view() { 18 + let form = room_form() 19 + html.body([attr.styles([#("max-width", "32rem"), #("margin", "3rem auto")])], [ 20 + html.h1([], [html.text("Clover")]), 21 + html.form([attr.method("POST"), attr.action("/new")], [ 22 + field_input(form:, label: "Room ID", type_: "text", name: "id"), 23 + html.div([], [html.input([attr.type_("submit")])]), 24 + ]), 25 + ]) 26 + } 27 + 28 + fn field_input( 29 + form form: form.Form(RoomInput), 30 + label label: String, 31 + type_ type_: String, 32 + name name: String, 33 + ) { 34 + let errors = form.field_error_messages(form, name) 35 + html.label([], [ 36 + html.text(label), 37 + html.input([ 38 + attr.type_(type_), 39 + attr.name(name), 40 + attr.value(form.field_value(form, name)), 41 + case errors { 42 + [] -> attr.none() 43 + _ -> attr.aria_invalid("true") 44 + }, 45 + ]), 46 + ..list.map(errors, fn(msg) { html.small([], [html.text(msg)]) }) 47 + ]) 48 + }
+17
src/clover/pages/room.gleam
··· 1 + import lustre/attribute as attr 2 + import lustre/element/html 3 + import lustre/server_component 4 + 5 + pub fn view(id: String) { 6 + html.body([attr.styles([#("max-width", "32rem"), #("margin", "3rem auto")])], [ 7 + html.a([attr.href("/")], [html.text("< Home")]), 8 + html.h1([], [html.text("Room ID: "), html.text(id)]), 9 + server_component.element([server_component.route("/ws")], []), 10 + ]) 11 + } 12 + 13 + pub fn view_html(_id: String) { 14 + html.body([attr.styles([#("max-width", "32rem"), #("margin", "3rem auto")])], [ 15 + server_component.element([server_component.route("/ws")], []), 16 + ]) 17 + }