this repo has no description
0
fork

Configure Feed

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

...What am I getting myself into now


Signed-off-by: MLC Bloeiman <mar@strawmelonjuice.com>

+612
+4
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump 1 5 data
+15
README.md
··· 1 + # strawmelonservices 2 + 3 + ## Gleam management application 4 + 5 + The Gleam bits in this repo are a manager around the services. 6 + 7 + ### `strawmelonservices/serverone` 8 + 9 + ... is the main part of this manager, and is supposed to run on `marpi-5-1`, the current primary server in my tailscale. 10 + 11 + It contains a dashboard, showing podman containers and their statuses, as well as allowing to start the backup procedure, during which `strawmelonservices/ollies_bkup` is notified on the backup server, allowing it to pull the backup archives over websocket. The previously used rsync is replaced by this, as SSH packet handshaking seems to be a little resource heavy on the arm singleboard. The music library is still rsync'ed. 12 + 13 + ### `strawmelonservices/ollies_bkup` 14 + 15 + Runs a websocket client which is stand by until notice, and allows the backup server to pull the backup instead of the main server pushing it.
+61
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1776169885, 6 + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixos-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs", 22 + "utils": "utils" 23 + } 24 + }, 25 + "systems": { 26 + "locked": { 27 + "lastModified": 1681028828, 28 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 29 + "owner": "nix-systems", 30 + "repo": "default", 31 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 32 + "type": "github" 33 + }, 34 + "original": { 35 + "owner": "nix-systems", 36 + "repo": "default", 37 + "type": "github" 38 + } 39 + }, 40 + "utils": { 41 + "inputs": { 42 + "systems": "systems" 43 + }, 44 + "locked": { 45 + "lastModified": 1731533236, 46 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 47 + "owner": "numtide", 48 + "repo": "flake-utils", 49 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 50 + "type": "github" 51 + }, 52 + "original": { 53 + "owner": "numtide", 54 + "repo": "flake-utils", 55 + "type": "github" 56 + } 57 + } 58 + }, 59 + "root": "root", 60 + "version": 7 61 + }
+42
flake.nix
··· 1 + { 2 + inputs = { 3 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 + utils.url = "github:numtide/flake-utils"; 5 + }; 6 + 7 + outputs = 8 + { 9 + self, 10 + nixpkgs, 11 + utils, 12 + }: 13 + utils.lib.eachDefaultSystem ( 14 + system: 15 + let 16 + pkgs = import nixpkgs { inherit system; }; 17 + in 18 + { 19 + devShells.default = pkgs.mkShell { 20 + buildInputs = with pkgs; [ 21 + gleam 22 + erlang_28 23 + rebar3 24 + ]; 25 + 26 + shellHook = '' 27 + HOSTNAME=$(hostname) 28 + 29 + if [ "$HOSTNAME" = "marpi5-1" ]; then 30 + echo "🚀 Preparing for takeoff..." 31 + gleam run -m strawmelonservices/serverone 32 + elif [ "$HOSTNAME" = "mar-backups" ]; then 33 + echo "💾 Starting backup service..." 34 + gleam run -m strawmelonservices/ollies_bkup 35 + else 36 + echo "👋" 37 + fi 38 + ''; 39 + }; 40 + } 41 + ); 42 + }
+27
gleam.toml
··· 1 + name = "strawmelonservices" 2 + version = "1.0.0" 3 + 4 + # Fill out these fields if you intend to generate HTML documentation or publish 5 + # your project to the Hex package manager. 6 + # 7 + # description = "" 8 + # licences = ["Apache-2.0"] 9 + # repository = { type = "github", user = "", repo = "" } 10 + # links = [{ title = "Website", href = "" }] 11 + # 12 + # For a full reference of all the available options, you can have a look at 13 + # https://gleam.run/writing-gleam/gleam-toml/. 14 + 15 + [dependencies] 16 + gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 + ewe = ">= 3.0.7 and < 4.0.0" 18 + collie = ">= 1.0.0 and < 2.0.0" 19 + lustre = ">= 5.6.0 and < 6.0.0" 20 + group_registry = ">= 1.0.0 and < 2.0.0" 21 + gleam_erlang = ">= 1.3.0 and < 2.0.0" 22 + gleam_http = ">= 4.3.0 and < 5.0.0" 23 + gleam_otp = ">= 1.2.0 and < 2.0.0" 24 + gleam_json = ">= 3.1.0 and < 4.0.0" 25 + 26 + [dev_dependencies] 27 + gleeunit = ">= 1.0.0 and < 2.0.0"
+35
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "collie", version = "1.0.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "logging", "websocks"], otp_app = "collie", source = "hex", outer_checksum = "5796F28859B0CF0AF7E7B409775C291995B432B48BA2E2CDCA0D4B018B8162B5" }, 6 + { 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" }, 7 + { name = "ewe", version = "3.0.7", build_tools = ["gleam"], requirements = ["compresso", "exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "logging", "websocks"], otp_app = "ewe", source = "hex", outer_checksum = "5679A3763B79376C0846B23E42C60091441524FF6A6B5DD022ACB2BCB3F35BEC" }, 8 + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 9 + { name = "gleam_crypto", version = "1.5.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "43A25BCE17834475AEA7FFC9408B6D0FC1D709A682BCAE5FEFC6D4192CF47718" }, 10 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 11 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 12 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 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" }, 14 + { name = "gleam_stdlib", version = "0.71.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "702F3BC2A14793906880B1078B19A6165F87323AEE8D0C4A34085846336FCAAE" }, 15 + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 16 + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 17 + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, 18 + { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, 19 + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 20 + { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, 21 + { name = "lustre", version = "5.6.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "EE558CD4DB9F09FCC16417ADF0183A3C2DAC3E4B21ED3AC0CAE859792AB810CA" }, 22 + { name = "websocks", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_stdlib"], otp_app = "websocks", source = "hex", outer_checksum = "C70340E5B6C3390383ADA17029DCA6F8903863A7AD8CD8E1520EDCC4FE70D6FD" }, 23 + ] 24 + 25 + [requirements] 26 + collie = { version = ">= 1.0.0 and < 2.0.0" } 27 + ewe = { version = ">= 3.0.7 and < 4.0.0" } 28 + gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } 29 + gleam_http = { version = ">= 4.3.0 and < 5.0.0" } 30 + gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 31 + gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } 32 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 33 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 34 + group_registry = { version = ">= 1.0.0 and < 2.0.0" } 35 + lustre = { version = ">= 5.6.0 and < 6.0.0" }
+5
src/strawmelonservices.gleam
··· 1 + import gleam/io 2 + 3 + pub fn main() -> Nil { 4 + io.println("Hello from strawmelonservices!") 5 + }
+147
src/strawmelonservices/serverone.gleam
··· 1 + import ewe.{type Connection, type Response} 2 + import gleam/bytes_tree 3 + import gleam/erlang/application 4 + import gleam/erlang/process.{type Subject} 5 + import gleam/http/request.{type Request} 6 + import gleam/http/response 7 + import gleam/json 8 + import gleam/option.{None} 9 + import gleam/otp/actor 10 + import group_registry.{type GroupRegistry} 11 + import lustre 12 + import lustre/attribute 13 + import lustre/element 14 + import lustre/element/html.{html} 15 + import lustre/server_component 16 + import strawmelonservices/serverone/dashboard 17 + 18 + pub fn main() { 19 + let name = process.new_name("dashboard-registry") 20 + let assert Ok(actor.Started(data: registry, ..)) = group_registry.start(name) 21 + 22 + let handler = fn(request: Request(Connection)) -> Response { 23 + case request.path_segments(request) { 24 + [] -> serve_html() 25 + ["lustre", "runtime.mjs"] -> serve_runtime() 26 + ["ws"] -> serve_dashboard(request, registry) 27 + _ -> response.set_body(response.new(404), ewe.BytesData(bytes_tree.new())) 28 + } 29 + } 30 + 31 + let assert Ok(_) = 32 + ewe.new(handler) 33 + |> ewe.bind("0.0.0.0") 34 + |> ewe.listening(port: 8080) 35 + |> ewe.start 36 + 37 + process.sleep_forever() 38 + } 39 + 40 + fn serve_html() -> Response { 41 + let html = 42 + html([attribute.lang("en")], [ 43 + html.head([], [ 44 + html.meta([attribute.charset("utf-8")]), 45 + html.meta([ 46 + attribute.name("viewport"), 47 + attribute.content("width=device-width, initial-scale=1"), 48 + ]), 49 + html.title([], "Lustre + Ewe Dashboard"), 50 + html.script( 51 + [attribute.type_("module"), attribute.src("/lustre/runtime.mjs")], 52 + "", 53 + ), 54 + ]), 55 + html.body([attribute.style("height", "100dvh")], [ 56 + server_component.element([server_component.route("/ws")], []), 57 + ]), 58 + ]) 59 + |> element.to_document_string_tree 60 + |> bytes_tree.from_string_tree 61 + 62 + response.new(200) 63 + |> response.set_body(ewe.BytesData(html)) 64 + |> response.set_header("content-type", "text/html") 65 + } 66 + 67 + fn serve_runtime() -> Response { 68 + let assert Ok(lustre_priv) = application.priv_directory("lustre") 69 + let file_path = lustre_priv <> "/static/lustre-server-component.min.mjs" 70 + 71 + // Ewe's send_file werkt vrijwel identiek aan mist 72 + case ewe.file(file_path, offset: None, limit: None) { 73 + Ok(file) -> 74 + response.new(200) 75 + |> response.prepend_header("content-type", "application/javascript") 76 + |> response.set_body(file) 77 + 78 + Error(_) -> 79 + response.new(404) 80 + |> response.set_body(ewe.BytesData(bytes_tree.new())) 81 + } 82 + } 83 + 84 + type DashboardSocket { 85 + DashboardSocket( 86 + component: lustre.Runtime(dashboard.Message), 87 + self: Subject(DashboardSocketMessage), 88 + ) 89 + } 90 + 91 + type DashboardSocketMessage = 92 + server_component.ClientMessage(dashboard.Message) 93 + 94 + fn serve_dashboard( 95 + request: Request(Connection), 96 + registry: GroupRegistry(dashboard.SharedMessage), 97 + ) -> Response { 98 + ewe.upgrade_websocket( 99 + request, 100 + on_init: fn(_conn, selector) { 101 + let dashboard = dashboard.component() 102 + let assert Ok(component) = 103 + lustre.start_server_component(dashboard, registry) 104 + 105 + let self = process.new_subject() 106 + // Belangrijk: we voegen onze 'self' subject toe aan de selector die ewe ons geeft 107 + let selector = process.select(selector, self) 108 + 109 + server_component.register_subject(self) 110 + |> lustre.send(to: component) 111 + 112 + #(DashboardSocket(component:, self:), selector) 113 + }, 114 + handler: loop_dashboard_socket, 115 + on_close: fn(_conn, state) { 116 + lustre.shutdown() 117 + |> lustre.send(to: state.component) 118 + }, 119 + ) 120 + } 121 + 122 + fn loop_dashboard_socket( 123 + connection: ewe.WebsocketConnection, 124 + state: DashboardSocket, 125 + message: ewe.WebsocketMessage(DashboardSocketMessage), 126 + ) -> ewe.WebsocketNext(DashboardSocket, DashboardSocketMessage) { 127 + case message { 128 + ewe.Text(json) -> { 129 + // In Gleam/ewe heet dit vaak json.decode of json.parse afhankelijk van de lib versie 130 + case json.parse(json, server_component.runtime_message_decoder()) { 131 + Ok(runtime_message) -> lustre.send(state.component, runtime_message) 132 + Error(_) -> Nil 133 + } 134 + ewe.websocket_continue(state) 135 + } 136 + 137 + ewe.Binary(_) -> { 138 + ewe.websocket_continue(state) 139 + } 140 + 141 + ewe.User(client_message) -> { 142 + let json = server_component.client_message_to_json(client_message) 143 + let _ = ewe.send_text_frame(connection, json.to_string(json)) 144 + ewe.websocket_continue(state) 145 + } 146 + } 147 + }
+263
src/strawmelonservices/serverone/dashboard.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import gleam/dict.{type Dict} 4 + import gleam/dynamic/decode 5 + import gleam/erlang/process 6 + import gleam/int 7 + import gleam/list 8 + import group_registry.{type GroupRegistry} 9 + import lustre.{type App} 10 + import lustre/attribute.{attribute} 11 + import lustre/effect.{type Effect} 12 + import lustre/element.{type Element} 13 + import lustre/element/html 14 + import lustre/element/svg 15 + import lustre/event 16 + import lustre/server_component 17 + 18 + // MAIN ------------------------------------------------------------------------ 19 + 20 + pub fn component() -> App(GroupRegistry(SharedMessage), Model, Message) { 21 + lustre.application(init, update, view) 22 + } 23 + 24 + // MODEL ----------------------------------------------------------------------- 25 + 26 + pub type Color { 27 + Blue 28 + Red 29 + Green 30 + Yellow 31 + } 32 + 33 + fn to_string(color: Color) -> String { 34 + case color { 35 + Red -> "red" 36 + Blue -> "blue" 37 + Green -> "green" 38 + Yellow -> "yellow" 39 + } 40 + } 41 + 42 + pub type Model { 43 + Model( 44 + drawn_points: Dict(#(Int, Int), Color), 45 + selected_color: Color, 46 + registry: GroupRegistry(SharedMessage), 47 + ) 48 + } 49 + 50 + fn init(registry: GroupRegistry(SharedMessage)) -> #(Model, Effect(Message)) { 51 + let model = Model(drawn_points: dict.new(), selected_color: Red, registry:) 52 + 53 + #(model, subscribe(registry, AppReceivedSharedMessage)) 54 + } 55 + 56 + fn subscribe( 57 + registry: GroupRegistry(topic), 58 + on_message handle_message: fn(topic) -> message, 59 + ) -> Effect(message) { 60 + // Using the special `select` effect lets us return a `Selector` so that we 61 + // can receive messages from processes outside of our server component runtime. 62 + use _, _ <- server_component.select 63 + 64 + // Joining a topic in the registry returns a `Subject` we need to select on 65 + // in order to subscribe to messages sent to that topic. 66 + let subject = group_registry.join(registry, "dashboard", process.self()) 67 + 68 + // We need to teach the server component runtime to listen for messages on 69 + // this subject by returning a `Selector` that matches our apps `message` type. 70 + let selector = 71 + process.new_selector() 72 + |> process.select_map(subject, handle_message) 73 + 74 + selector 75 + } 76 + 77 + // UPDATE ---------------------------------------------------------------------- 78 + 79 + /// We have 2 kinds of messages: 80 + /// 81 + /// - Messages that originate from this instance of the server-component 82 + /// - Messages that we receive from and send to the glubsub topic. 83 + /// 84 + /// `SharedMessage` contains messages of the latter type. 85 + pub opaque type SharedMessage { 86 + // Received or sent when any client wants to draw on the dashboard. 87 + ClientDrewCircle(x: Int, y: Int, color: Color) 88 + /// Received or sent when any client wants to clear the screen. 89 + ClientClearedScreen 90 + } 91 + 92 + /// `Message` is the usual message type in a Lustre app and contains all messages 93 + /// that a single instance of our server component can receive. 94 + pub opaque type Message { 95 + /// We received some SharedMessage from the glubsub topic. 96 + AppReceivedSharedMessage(message: SharedMessage) 97 + /// The user wants to change their selected color; 98 + /// This color can be different for every single client. 99 + UserChangedColor(color: Color) 100 + /// We have a way to get notified if any client wants to modify the dashboard 101 + /// through the shared topic, but we still need a way for the user to tell us 102 + /// that they want to in the first place! 103 + UserDrewCircle(x: Int, y: Int, color: Color) 104 + /// 105 + UserClearedScreen 106 + } 107 + 108 + fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { 109 + case message { 110 + AppReceivedSharedMessage(ClientDrewCircle(x:, y:, color:)) -> { 111 + // If any client wants to draw on the screen, we do that update locally 112 + // to reflect that change. 113 + // 114 + // Note: This means that we duplicate this state in every single client! 115 + let new_points = 116 + model.drawn_points 117 + |> dict.insert(#(x, y), color) 118 + 119 + #(Model(..model, drawn_points: new_points), effect.none()) 120 + } 121 + 122 + AppReceivedSharedMessage(ClientClearedScreen) -> { 123 + let model = Model(..model, drawn_points: dict.new()) 124 + 125 + #(model, effect.none()) 126 + } 127 + 128 + UserChangedColor(color:) -> { 129 + #(Model(..model, selected_color: color), effect.none()) 130 + } 131 + 132 + UserDrewCircle(x:, y:, color:) -> { 133 + // If a user wants to draw, instead of doing that directly we broadcast 134 + // that intent as a `SharedMessage` over our topic. 135 + // Later, we will receive that same message ourselves again, at which point 136 + // we will update our dashboard. 137 + #(model, broadcast(model.registry, ClientDrewCircle(x:, y:, color:))) 138 + } 139 + 140 + UserClearedScreen -> { 141 + #(model, broadcast(model.registry, ClientClearedScreen)) 142 + } 143 + } 144 + } 145 + 146 + fn broadcast(registry: GroupRegistry(message), message: message) -> Effect(any) { 147 + use _ <- effect.from 148 + use member <- list.each(group_registry.members(registry, "dashboard")) 149 + 150 + process.send(member, message) 151 + } 152 + 153 + // VIEW ------------------------------------------------------------------------ 154 + 155 + fn view(model: Model) -> Element(Message) { 156 + let on_mouse_move = 157 + event.on("mousemove", { 158 + use button <- decode.field("buttons", decode.int) 159 + use client_x <- decode.field("clientX", decode.int) 160 + use client_y <- decode.field("clientY", decode.int) 161 + 162 + case button { 163 + 1 -> 164 + decode.success(UserDrewCircle( 165 + x: client_x, 166 + y: client_y, 167 + color: model.selected_color, 168 + )) 169 + _ -> decode.failure(UserDrewCircle(x: 0, y: 0, color: Red), "Message") 170 + } 171 + }) 172 + |> server_component.include(["buttons", "clientX", "clientY"]) 173 + |> event.throttle(5) 174 + 175 + element.fragment([ 176 + html.style([], { 177 + " 178 + #controls { 179 + align-items: center; 180 + background-color: white; 181 + border-radius: 2rem; 182 + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 183 + display: flex; 184 + gap: 1rem; 185 + left: 1rem; 186 + padding: 1rem 1.5rem; 187 + position: fixed; 188 + bottom: 1rem; 189 + z-index: 1; 190 + width: max-content; 191 + 192 + .colour { 193 + border: none; 194 + width: 3rem; 195 + height: 3rem; 196 + border-radius: 50%; 197 + display: inline-block; 198 + } 199 + 200 + .colour.selected { 201 + filter: darken(0.2); 202 + } 203 + } 204 + 205 + svg { 206 + background-color: oklch(98.4% 0.003 247.858); 207 + position: fixed; 208 + top: 0; 209 + left: 0; 210 + width: 100vw; 211 + height: 100vh; 212 + } 213 + " 214 + }), 215 + html.div([attribute.id("controls")], [ 216 + html.button( 217 + [ 218 + attribute.class("colour"), 219 + attribute.style("background-color", "red"), 220 + event.on_click(UserChangedColor(Red)), 221 + ], 222 + [], 223 + ), 224 + html.button( 225 + [ 226 + attribute.class("colour selected"), 227 + attribute.style("background-color", "green"), 228 + event.on_click(UserChangedColor(Green)), 229 + ], 230 + [], 231 + ), 232 + html.button( 233 + [ 234 + attribute.class("colour"), 235 + attribute.style("background-color", "blue"), 236 + event.on_click(UserChangedColor(Blue)), 237 + ], 238 + [], 239 + ), 240 + html.button( 241 + [ 242 + attribute.class("colour"), 243 + attribute.style("background-color", "yellow"), 244 + event.on_click(UserChangedColor(Yellow)), 245 + ], 246 + [], 247 + ), 248 + html.button([event.on_click(UserClearedScreen)], [html.text("Clear")]), 249 + ]), 250 + html.svg([on_mouse_move], { 251 + use points, #(x, y), color <- dict.fold(model.drawn_points, []) 252 + let point = 253 + svg.circle([ 254 + attribute("cx", int.to_string(x)), 255 + attribute("cy", int.to_string(y)), 256 + attribute("r", "5"), 257 + attribute("fill", to_string(color)), 258 + ]) 259 + 260 + [point, ..points] 261 + }), 262 + ]) 263 + }
+13
test/strawmelonservices_test.gleam
··· 1 + import gleeunit 2 + 3 + pub fn main() -> Nil { 4 + gleeunit.main() 5 + } 6 + 7 + // gleeunit test functions end in `_test` 8 + pub fn hello_world_test() { 9 + let name = "Joe" 10 + let greeting = "Hello, " <> name <> "!" 11 + 12 + assert greeting == "Hello, Joe!" 13 + }