For now? I'm experimenting on an old concept.
1

Configure Feed

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

Routing and things that are best done client-sided, now happen on the client side. In an SPA.

MLC Bloeiman (Jul 9, 2026, 9:49 PM +0200) cd333279 82c22265

+839
+16
lumina/web/initialiser/gleam.toml
··· 1 + name = "lumina_spa" 2 + version = "1.0.0" 3 + target = "javascript" 4 + licences = ["EUPL-1.2"] 5 + 6 + [dependencies] 7 + gleam_stdlib = ">= 1.0.0 and < 2.0.0" 8 + lustre = ">= 5.7.0 and < 6.0.0" 9 + humanise = ">= 1.1.0 and < 2.0.0" 10 + gleam_json = ">= 3.1.0 and < 4.0.0" 11 + rsvp = ">= 2.0.0 and < 3.0.0" 12 + modem = ">= 2.1.3 and < 3.0.0" 13 + gleam_javascript = ">= 1.0.0 and < 2.0.0" 14 + 15 + [dev_dependencies] 16 + gleeunit = ">= 1.0.0 and < 2.0.0"
+35
lumina/web/initialiser/manifest.toml
··· 1 + # Do not manually edit this file, it is managed by Gleam. 2 + # 3 + # This file locks the dependency versions used, to make your build 4 + # deterministic and to prevent unexpected versions from being included 5 + # in your application. 6 + # 7 + # You should check this file into your source control repository. 8 + 9 + packages = [ 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_fetch", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "284CE88E37436699545F9F65D413E1DFB6C1EA3FE3824B6EA2018D0ECF088FFC" }, 12 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 13 + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 14 + { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, 15 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 16 + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 17 + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 18 + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, 19 + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, 20 + { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, 21 + { name = "humanise", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "humanise", source = "hex", outer_checksum = "5B78C52863B673F7D903117BE9262628210946406E04B6AB09554D5C2A7F15F0" }, 22 + { name = "lustre", version = "5.7.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "38C6FCBD7B7BACE994D5BF643202BB69B02576D44250B4C5DCE738387C0E8F87" }, 23 + { name = "modem", version = "2.1.3", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "F03560273C4E857025E7628BCE1EBA8B7E9A1A81A73CA998FC29FD1CED188968" }, 24 + { name = "rsvp", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "A38F2DB9657E84A584278DB50FD903693FF370C22C191753B773546FEB2465F0" }, 25 + ] 26 + 27 + [requirements] 28 + gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" } 29 + gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 30 + gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 31 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 32 + humanise = { version = ">= 1.1.0 and < 2.0.0" } 33 + lustre = { version = ">= 5.7.0 and < 6.0.0" } 34 + modem = { version = ">= 2.1.3 and < 3.0.0" } 35 + rsvp = { version = ">= 2.0.0 and < 3.0.0" }
+730
lumina/web/initialiser/src/lumina_spa.gleam
··· 1 + //// **Lumina >** 2 + //// # Web client 3 + //// 4 + //// Initialising and routing SPA for Lumina on the web. 5 + 6 + // Imports ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 7 + 8 + import gleam/bit_array 9 + import gleam/bool 10 + import gleam/dynamic/decode 11 + import gleam/list 12 + import gleam/option.{type Option, None, Some} 13 + import gleam/result 14 + import gleam/string 15 + import gleam/uri.{type Uri} 16 + import lumina_spa/log 17 + import lustre 18 + import lustre/attribute.{type Attribute} 19 + import lustre/effect.{type Effect} 20 + import lustre/element.{type Element} 21 + import lustre/element/html 22 + import lustre/event 23 + import lustre/server_component 24 + import modem 25 + import rsvp 26 + 27 + // Main ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 28 + 29 + pub fn main() { 30 + log.log("Hello from lumina_spa!") 31 + 32 + let app = lustre.application(init, update, view) 33 + let assert Ok(_) = lustre.start(app, "body", Nil) 34 + 35 + Nil 36 + } 37 + 38 + // Model ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 39 + 40 + type Model { 41 + Model(route: Route, user_authenticated: Option(MyUser)) 42 + } 43 + 44 + pub type MyUser { 45 + MyUser( 46 + /// User ID (uuid) 47 + uid: String, 48 + /// Username 49 + username: String, 50 + /// Email 51 + email: String, 52 + /// Avatar as uri string, either a full URL or a base64-encoded 'data:'-string 53 + avatar: String, 54 + /// Notifications 55 + notifs: List( 56 + // This should be something more specific than a string, but for now it's not. 57 + String, 58 + ), 59 + ) 60 + } 61 + 62 + /// Available route for Lumina! 63 + type Route { 64 + Index 65 + Login 66 + Register 67 + Timeline(id: String) 68 + Post(id: String) 69 + About 70 + NotFound(uri: Uri) 71 + External(location: String) 72 + } 73 + 74 + /// Used to turn a uri into a Route 75 + fn parse_route(uri: Uri) -> Route { 76 + case uri.path_segments(uri.path |> string.remove_prefix("/app")) { 77 + [] | [""] -> Index 78 + ["login"] -> Login 79 + ["signup"] -> Register 80 + ["post", post_id] -> Post(id: post_id) 81 + ["browse", tl_id] -> Timeline(id: tl_id) 82 + ["browse"] -> Timeline("global") 83 + ["about"] -> About 84 + ["out", location] -> { 85 + bit_array.base64_url_decode(location) 86 + |> result.map(bit_array.to_string) 87 + |> result.flatten 88 + |> result.map(External) 89 + |> result.unwrap(NotFound(uri:)) 90 + } 91 + _ -> NotFound(uri:) 92 + } 93 + } 94 + 95 + /// Used to turn a Route into an attribute.href Attribute 96 + fn href(route: Route) -> Attribute(Message) { 97 + let url = 98 + "/app" 99 + <> case route { 100 + Index -> "/" 101 + About -> "/about" 102 + Login -> "/login" 103 + Post(post_id) -> "/post/" <> post_id 104 + NotFound(_) -> "/404" 105 + Register -> "/signup" 106 + Timeline(id:) if id == "global" -> "/browse/" 107 + Timeline(id:) -> "/browse/" <> id 108 + External(location:) -> 109 + "/out/" 110 + <> bit_array.from_string(location) |> bit_array.base64_url_encode(True) 111 + } 112 + 113 + attribute.href(url) 114 + } 115 + 116 + fn init(_) -> #(Model, Effect(Message)) { 117 + let route = case modem.initial_uri() { 118 + Ok(uri) -> parse_route(uri) 119 + Error(_) -> Index 120 + } 121 + 122 + let model = Model(route:, user_authenticated: None) 123 + 124 + let effect = 125 + effect.batch([ 126 + modem.init(fn(uri) { UserNavigatedTo(parse_route(uri)) }), 127 + 128 + case get_session_revive_key() { 129 + Ok(key) -> try_session_revive(key) 130 + _ -> { 131 + log.info("No past sessions to revive!") 132 + check_auth_status() 133 + } 134 + }, 135 + ]) 136 + 137 + #(model, effect) 138 + } 139 + 140 + fn try_session_revive(key: String) -> Effect(Message) { 141 + // TODO: Implement this 142 + // For now: 143 + effect.none() 144 + } 145 + 146 + @external(javascript, "./lumina_spa_ffi", "with_timeout") 147 + fn set_timeout(_delay: Int, _cb: fn() -> a) -> Nil { 148 + Nil 149 + } 150 + 151 + fn wait_message(message: Message, delay: Int) { 152 + use dispatch <- effect.from() 153 + use <- set_timeout(delay) 154 + dispatch(message) 155 + } 156 + 157 + fn check_auth_status() { 158 + let url = "/api/3.1/session/auth-status/" 159 + let decoder = { 160 + use authenticated <- decode.field("authenticated", decode.bool) 161 + case authenticated { 162 + False -> decode.success(None) 163 + True -> todo 164 + } 165 + } 166 + let handler = 167 + rsvp.expect_json( 168 + decoder, 169 + fn(returned: Result(Option(MyUser), rsvp.Error(String))) -> Message { 170 + let returned = result.unwrap(returned, None) 171 + AuthenticationCheckResponse(returned) 172 + }, 173 + ) 174 + 175 + rsvp.get(url, handler) 176 + } 177 + 178 + @external(javascript, "./lumina_spa_ffi", "getSessionRevivekey") 179 + fn get_session_revive_key() -> Result(String, Nil) 180 + 181 + // Update ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 182 + 183 + type Message { 184 + UserNavigatedTo(route: Route) 185 + AuthenticationCheckResponse(Option(MyUser)) 186 + RetryAuthenticationCheck 187 + UserNavigatedBack(by: Int) 188 + } 189 + 190 + fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { 191 + case message { 192 + UserNavigatedBack(by:) -> #(model, modem.back(by)) 193 + 194 + UserNavigatedTo(route:) -> #(Model(..model, route:), effect.none()) 195 + 196 + AuthenticationCheckResponse(None) -> #( 197 + model, 198 + wait_message(RetryAuthenticationCheck, 1200), 199 + ) 200 + RetryAuthenticationCheck -> #(model, check_auth_status()) 201 + 202 + AuthenticationCheckResponse(_) -> todo 203 + } 204 + } 205 + 206 + // View ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 207 + 208 + fn view(model: Model) -> Element(Message) { 209 + let #(sidebar_content, main_page_content) = case model.route { 210 + Index -> view_index(model) 211 + Login -> #(view_default_sidebar(model), [ 212 + server_component.element([server_component.route("/ws/web/login")], []), 213 + ]) 214 + 215 + Post(post_id) -> #(view_default_sidebar(model), [ 216 + html.div([], [ 217 + server_component.element( 218 + [server_component.route("/ws/web/postrender/" <> post_id)], 219 + [], 220 + ), 221 + ]), 222 + ]) 223 + 224 + About -> view_about(model) 225 + NotFound(_) -> view_not_found() 226 + Register -> #(view_default_sidebar(model), [ 227 + server_component.element([server_component.route("/ws/web/register")], []), 228 + ]) 229 + Timeline(id:) -> #( 230 + [ 231 + server_component.element( 232 + [server_component.route("/ws/web/timeline/" <> id <> "/aside")], 233 + [], 234 + ), 235 + ], 236 + [ 237 + server_component.element( 238 + [server_component.route("/ws/web/timeline/" <> id <> "/main")], 239 + [], 240 + ), 241 + ], 242 + ) 243 + External(location:) -> view_external(location) 244 + } 245 + 246 + html.div([attribute.data("sidebar-layout", "")], [ 247 + html.nav( 248 + [ 249 + attribute.data("topnav", ""), 250 + attribute.styles([ 251 + #("background-color", "var(--accent)"), 252 + #("color", "var(--accent-foreground)"), 253 + #("justify-content", "space-between"), 254 + ]), 255 + ], 256 + [ 257 + html.button( 258 + [ 259 + // attribute.class("outline"), 260 + attribute.attribute("aria-label", "Toggle menu"), 261 + attribute.data("sidebar-toggle", ""), 262 + attribute.data("variant", "primary"), 263 + attribute.styles([ 264 + #("background-color", "var(--primary)"), 265 + #("width", "40px"), 266 + #("height", "40px"), 267 + ]), 268 + ], 269 + [html.text("☰")], 270 + ), 271 + html.span( 272 + [ 273 + attribute.class("justify-center flex"), 274 + attribute.styles([ 275 + #("font-family", "var(--font-logo)"), 276 + #("font-size", "var(--text-2)"), 277 + ]), 278 + ], 279 + [ 280 + html.a( 281 + [ 282 + attribute.class("logo"), 283 + href(Index), 284 + ], 285 + [ 286 + html.img([ 287 + attribute.styles([ 288 + #("margin-inline-end", "var(--text-3)"), 289 + ]), 290 + attribute.style("height", "40px"), 291 + attribute.src("/favicon.ico"), 292 + attribute.alt("icon"), 293 + ]), 294 + ], 295 + ), 296 + html.text("Lumina"), 297 + ], 298 + ), 299 + html.nav( 300 + [ 301 + attribute.class("hstack flex align-right"), 302 + attribute.styles([ 303 + #("font-family", "var(--font-menuitems)"), 304 + #("justify-content", "flex-end"), 305 + ]), 306 + ], 307 + [ 308 + view_header_button( 309 + current: model.route, 310 + show_on: list.append(if_unauthenticated([Index, About], model), [ 311 + Register, 312 + Login, 313 + ]), 314 + to: Login, 315 + label: "Login", 316 + ), 317 + view_header_button( 318 + current: model.route, 319 + show_on: list.append(if_unauthenticated([Index, About], model), [ 320 + Register, 321 + Login, 322 + ]), 323 + to: Register, 324 + label: "Sign up", 325 + ), 326 + case model.user_authenticated { 327 + Some(user) -> 328 + element.element("ot-dropdown", [], [ 329 + html.button( 330 + [ 331 + attribute.attribute("popovertarget", "self-menu"), 332 + attribute.class("unstyled"), 333 + attribute.styles([#("padding", "0")]), 334 + ], 335 + [ 336 + html.figure( 337 + [ 338 + attribute.attribute("aria-label", "Oat"), 339 + attribute.attribute("data-variant", "avatar"), 340 + ], 341 + [ 342 + html.abbr([attribute.attribute("title", "Jane Doe")], [ 343 + html.text("OT"), 344 + ]), 345 + ], 346 + ), 347 + ], 348 + ), 349 + html.menu( 350 + [ 351 + attribute.id("self-menu"), 352 + attribute.attribute("popover", ""), 353 + ], 354 + [ 355 + html.button( 356 + [attribute.class("ghost"), attribute.role("menuitem")], 357 + [html.text("Profile")], 358 + ), 359 + html.button( 360 + [attribute.class("ghost"), attribute.role("menuitem")], 361 + [html.text("Help")], 362 + ), 363 + html.a( 364 + [ 365 + attribute.role("menuitem"), 366 + attribute.href("#"), 367 + ], 368 + [html.text("Link")], 369 + ), 370 + html.hr([]), 371 + html.button( 372 + [attribute.class("ghost"), attribute.role("menuitem")], 373 + [html.text("Logout")], 374 + ), 375 + ], 376 + ), 377 + ]) 378 + None -> element.none() 379 + }, 380 + ], 381 + ), 382 + ], 383 + ), 384 + html.aside( 385 + [ 386 + attribute.data("sidebar", ""), 387 + ], 388 + sidebar_content, 389 + ), 390 + html.main( 391 + [ 392 + attribute.class("p-4"), 393 + attribute.styles([ 394 + #("background-color", "var(--muted)"), 395 + #("color", "var(--muted-foreground)"), 396 + ]), 397 + ], 398 + main_page_content, 399 + ), 400 + ]) 401 + } 402 + 403 + // Page views ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 404 + fn view_index( 405 + model: Model, 406 + ) -> #(List(Element(Message)), List(Element(Message))) { 407 + #( 408 + [ 409 + html.header([], [element.text("Hai")]), 410 + html.nav( 411 + [ 412 + attribute.styles([ 413 + #("font-family", "var(--font-menuitems)"), 414 + ]), 415 + ], 416 + [ 417 + html.text("on-page navigation"), 418 + ], 419 + ), 420 + html.footer([], [ 421 + html.text("Actions"), 422 + html.nav( 423 + [ 424 + attribute.styles([ 425 + #("font-family", "var(--font-menuitems)"), 426 + ]), 427 + ], 428 + [ 429 + view_sidebar_button( 430 + current: model.route, 431 + show_on: [], 432 + to: About, 433 + label: "About Lumina", 434 + ), 435 + ], 436 + ), 437 + ]), 438 + ], 439 + [ 440 + html.div([attribute.class("align-center justify-center p-4")], [ 441 + html.h1([], [ 442 + html.text("Welcome to Lumina!"), 443 + ]), 444 + leading("It's ... running?"), 445 + paragraph("There is not much going on at the moment still yet though!"), 446 + link( 447 + External("https://tangled.org/strawmelonjuice.com/Lumina"), 448 + "Visit Lumina's source code", 449 + ), 450 + ]), 451 + ], 452 + ) 453 + } 454 + 455 + fn view_default_sidebar(model: Model) -> List(Element(Message)) { 456 + [ 457 + html.header([], [element.text("Hai")]), 458 + html.nav( 459 + [ 460 + attribute.styles([ 461 + #("font-family", "var(--font-menuitems)"), 462 + ]), 463 + ], 464 + [ 465 + html.text("on-page navigation"), 466 + ], 467 + ), 468 + html.footer([], [ 469 + html.text("Actions"), 470 + html.nav( 471 + [ 472 + attribute.styles([ 473 + #("font-family", "var(--font-menuitems)"), 474 + ]), 475 + ], 476 + [ 477 + view_sidebar_button( 478 + current: model.route, 479 + show_on: [], 480 + to: About, 481 + label: "About Lumina", 482 + ), 483 + ], 484 + ), 485 + ]), 486 + ] 487 + } 488 + 489 + fn view_about( 490 + model: Model, 491 + ) -> #(List(Element(Message)), List(Element(Message))) { 492 + #( 493 + [ 494 + html.header([], [element.text("Go...")]), 495 + html.nav( 496 + [ 497 + attribute.styles([ 498 + #("font-family", "var(--font-menuitems)"), 499 + ]), 500 + ], 501 + [ 502 + view_menu_link( 503 + current: model.route, 504 + show_on: [], 505 + to: Index, 506 + label: "Back home", 507 + ), 508 + view_menu_link( 509 + current: model.route, 510 + show_on: [], 511 + to: External("https://tangled.org/strawmelonjuice.com/Lumina"), 512 + label: "View source code", 513 + ), 514 + ], 515 + ), 516 + html.footer([], []), 517 + ], 518 + [ 519 + html.h1([], [html.text("About Lumina")]), 520 + leading("Lumina/Peonies "), 521 + ], 522 + ) 523 + } 524 + 525 + fn view_external( 526 + location: String, 527 + ) -> #(List(Element(Message)), List(Element(Message))) { 528 + #([], [ 529 + html.div( 530 + [ 531 + attribute.class("items-center justify-center flex"), 532 + attribute.styles([ 533 + #("position", "fixed"), 534 + #("top", "0"), 535 + #("left", "0"), 536 + #("height", "100dVH"), 537 + #("width", "100dVW"), 538 + ]), 539 + ], 540 + [ 541 + html.article( 542 + [ 543 + attribute.class("card"), 544 + ], 545 + [ 546 + html.header([], [ 547 + html.h3([], [html.text("Leaving Lumina")]), 548 + leading("You are about to go to an external site."), 549 + ]), 550 + html.p([], [ 551 + element.text("Do you trust "), 552 + html.code( 553 + [ 554 + attribute.styles([ 555 + #("user-select", "text"), 556 + #("-webkit-user-select", "text"), 557 + ]), 558 + ], 559 + [element.text(location)], 560 + ), 561 + element.text("?"), 562 + ]), 563 + html.footer([attribute.class("hstack")], [ 564 + html.button( 565 + [ 566 + attribute.class("outline"), 567 + event.on_click(UserNavigatedBack(by: 1)), 568 + ], 569 + [html.text("Cancel")], 570 + ), 571 + html.a([attribute.href(location)], [ 572 + html.button( 573 + [ 574 + attribute.data("variant", "danger"), 575 + attribute.class("outline"), 576 + ], 577 + [ 578 + html.text("Yes, take me there!"), 579 + ], 580 + ), 581 + ]), 582 + ]), 583 + ], 584 + ), 585 + ], 586 + ), 587 + ]) 588 + } 589 + 590 + fn view_not_found() -> #(List(Element(Message)), List(Element(Message))) { 591 + #( 592 + [ 593 + html.nav( 594 + [ 595 + attribute.styles([ 596 + #("font-family", "var(--font-menuitems)"), 597 + ]), 598 + ], 599 + [ 600 + html.li([], [ 601 + html.a([attribute.href("/app/")], [html.text("Back home")]), 602 + ]), 603 + ], 604 + ), 605 + ], 606 + [ 607 + html.h1([], [html.text("Not found")]), 608 + paragraph( 609 + "You glimpse into the void and see -- nothing? 610 + Well that was somewhat expected.", 611 + ), 612 + ], 613 + ) 614 + } 615 + 616 + // View helpers 617 + 618 + fn leading(text: String) -> Element(Message) { 619 + html.p( 620 + [ 621 + attribute.class("text-light"), 622 + attribute.styles([ 623 + #("padding-top", "0"), 624 + #("font-weight", "var(--font-bold)"), 625 + #("margin-block-start", "0"), 626 + #("margin-bottom", "var(--space-8)"), 627 + ]), 628 + ], 629 + [html.text(text)], 630 + ) 631 + } 632 + 633 + fn paragraph(text: String) -> Element(Message) { 634 + html.p([attribute.class("mt-3")], [html.text(text)]) 635 + } 636 + 637 + fn link(target: Route, title: String) -> Element(Message) { 638 + html.a( 639 + [ 640 + href(target), 641 + ], 642 + [html.text(title)], 643 + ) 644 + } 645 + 646 + fn if_unauthenticated(on: List(Route), model: Model) -> List(Route) { 647 + case model.user_authenticated { 648 + None -> on 649 + Some(..) -> [] 650 + } 651 + } 652 + 653 + fn view_header_button( 654 + to target: Route, 655 + current current: Route, 656 + show_on show_on: List(Route), 657 + label text: String, 658 + ) -> Element(Message) { 659 + use <- bool.guard( 660 + !bool.or(list.contains(show_on, current), list.is_empty(show_on)), 661 + element.none(), 662 + ) 663 + let is_active = case current, target { 664 + Post(_), Login -> True 665 + _, _ -> current == target 666 + } 667 + html.a( 668 + case is_active { 669 + True -> [attribute.aria_current("page"), href(target)] 670 + False -> [href(target)] 671 + }, 672 + [html.button([attribute.class("small")], [html.text(text)])], 673 + ) 674 + } 675 + 676 + fn view_sidebar_button( 677 + to target: Route, 678 + current current: Route, 679 + show_on show_on: List(Route), 680 + label text: String, 681 + ) -> Element(Message) { 682 + use <- bool.guard( 683 + !bool.or(list.contains(show_on, current), list.is_empty(show_on)), 684 + element.none(), 685 + ) 686 + let is_active = case current, target { 687 + Post(_), Login -> True 688 + _, _ -> current == target 689 + } 690 + html.a( 691 + case is_active { 692 + True -> [attribute.aria_current("page"), href(target)] 693 + False -> [href(target)] 694 + }, 695 + [ 696 + html.button( 697 + [ 698 + attribute.class("outline small"), 699 + attribute.styles([#("width", "100%")]), 700 + ], 701 + [html.text(text)], 702 + ), 703 + ], 704 + ) 705 + } 706 + 707 + fn view_menu_link( 708 + to target: Route, 709 + current current: Route, 710 + show_on show_on: List(Route), 711 + label text: String, 712 + ) -> Element(Message) { 713 + use <- bool.guard( 714 + !bool.or(list.contains(show_on, current), list.is_empty(show_on)), 715 + element.none(), 716 + ) 717 + let is_active = case current, target { 718 + Post(_), Login -> True 719 + _, _ -> current == target 720 + } 721 + html.li([], [ 722 + html.a( 723 + case is_active { 724 + True -> [attribute.aria_current("page"), href(target)] 725 + False -> [href(target)] 726 + }, 727 + [html.text(text)], 728 + ), 729 + ]) 730 + }
+15
lumina/web/initialiser/src/lumina_spa/log.gleam
··· 1 + @external(javascript, "../lumina_spa_ffi", "logLog") 2 + @internal 3 + pub fn log(_: a) -> Nil 4 + 5 + @external(javascript, "../lumina_spa_ffi", "logInfo") 6 + @internal 7 + pub fn info(_: a) -> Nil 8 + 9 + @external(javascript, "../lumina_spa_ffi", "logWarn") 10 + @internal 11 + pub fn warn(_: a) -> Nil 12 + 13 + @external(javascript, "../lumina_spa_ffi", "logError") 14 + @internal 15 + pub fn error(_: a) -> Nil
+30
lumina/web/initialiser/src/lumina_spa_ffi.mjs
··· 1 + import { Result$Ok, Result$Error } from "./gleam"; 2 + 3 + // localStorage helpers 4 + export function getSessionRevivekey() { 5 + let val = window.localStorage.getItem("sessionrevive") 6 + if (val !== null) { 7 + return Result$Ok(val); 8 + } else { 9 + return Result$Error(null); 10 + } 11 + } 12 + 13 + // Timer helper 14 + export function with_timeout(delay, cb) { 15 + return window.setTimeout(cb, delay); 16 + } 17 + 18 + // Console log helpers 19 + export function logLog(any) { 20 + console.log("[lumina_spa] ", any) 21 + } 22 + export function logInfo(any) { 23 + console.info("[lumina_spa] ", any) 24 + } 25 + export function logWarn(any) { 26 + console.warn("[lumina_spa] ", any) 27 + } 28 + export function logError(any) { 29 + console.warn("[lumina_spa] ", any) 30 + }
+13
lumina/web/initialiser/test/lumina_spa_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 + }