import client/language as lang import client/page import client/page/dashboard import client/page/home import client/page/login import client/page/navbar import client/page/not_found import client/page/sidebar import client/page/signup import client/route import client/session import client/session/token import gleam/bool import gleam/http/response import gleam/option import gleam/uri import lustre import lustre/attribute.{class} as attr import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html import modem import rsvp pub type Model { Model( /// Current user session: session.Session, /// Current route route: route.Route, /// Current Page model page: page.Page, /// Selected language lang: lang.Language, ) } pub fn main() -> Nil { let assert Ok(uri) = modem.initial_uri() let app = lustre.application(init:, update:, view:) let assert Ok(_runtime) = lustre.start(app, "#app", Init(uri)) Nil } /// Data passed to our application during startup pub type Init { Init( /// Uri of the page when it first loaded. uri: uri.Uri, ) } pub type Message { /// Handle internal links navigation UserNavigatedTo(route: route.Route) /// User sent a request to the Server during startup to verify if the current /// token is still valid. UserRestoredSession(result: Result(token.Token, rsvp.Error(String))) /// Server removed the token from the Client, ending their session. ServerRemovedToken( result: Result(response.Response(String), rsvp.Error(String)), ) // PAGES --- LoginMessage(message: login.Message) HomeMessage(message: home.Message) DashboardMessage(message: dashboard.Message) // UI Elements SidebarMessage(message: sidebar.Message) NavbarMessage(message: navbar.Message) NotFoundMessage(message: not_found.Message) SignupMessage(message: signup.Message) } // INIT ------------------------------------------------------------------------ /// Build initial application Model /// /// ## Example /// /// ```gleam /// let assert Ok(uri) = modem.initial_uri() /// /// lustre.application(init:, update:, view:) /// |> lustre.start("#app", Init(uri)) /// ``` pub fn init(opts: Init) -> #(Model, Effect(Message)) { let route = route.parse(opts.uri) let #(page, init_page) = page.init(route) let is_protected = route.is_protected(route) let session = init_session(route:, is_protected:) let init_modem = { use uri <- modem.init() let route = route.parse(uri) UserNavigatedTo(route:) } // 󱡯 Send a request to the Server to verify if the current // token is still valid. // // Allow access to the route if it succeeds and // redirect User to the Login page in case of failure. let init_session = UserRestoredSession |> rsvp.expect_json(token.decoder(), _) |> rsvp.get("/api/whoami", _) // Batch all scheduled effects and build the initial application `Model` let effect = effect.batch([init_session, init_modem, init_page]) #(Model(session:, route:, page:, lang: lang.English), effect) } fn init_session( route route: route.Route, is_protected is_protected: Bool, ) -> session.Session { case route { route.Login | route.Home -> session.Pending(on_success: route.Dashboard, on_failure: route) route if is_protected -> session.Pending(on_success: route, on_failure: route.Login) _ -> session.Guest } } // VIEW ------------------------------------------------------------------------ fn layout( model model: Model, map view: Element(page_message), with fun: fn(page_message) -> Message, ) -> Element(Message) { let style = class("p-0") html.section([attr.data("sidebar-layout", "")], [ navbar.view(model.session, model.lang) |> element.map(NavbarMessage), sidebar.view(model.session) |> element.map(SidebarMessage), html.main([style], [view |> element.map(fun)]), ]) } /// Render page HTML pub fn view(model: Model) -> Element(Message) { case model { // HOME PAGE --------------------------------------------------------------- Model(route: route.Home, page: page.Home(..), ..) -> layout(model, home.view(model.session, model.lang), HomeMessage) // LOGIN PAGE -------------------------------------------------------------- Model(route: route.Login, page: page.Login(page), ..) -> layout(model, login.view(page, model.lang), LoginMessage) // DASHBOARD PAGE ---------------------------------------------------------- Model(route: route.Dashboard, page: page.Dashboard(page), ..) -> layout(model, dashboard.view(model.session, page), DashboardMessage) Model(route: route.Signup, page: page.Signup(page), ..) -> layout(model, signup.view(page, model.lang), SignupMessage) _ -> layout(model, not_found.view(), NotFoundMessage) } } // UPDATE ---------------------------------------------------------------------- /// Update current application `Model` pub fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { case model, message { //  NAVIGATION model, UserNavigatedTo(route:) -> handle_navigation(model, route) //  LANGUAGE SELECTION model, NavbarMessage(message: navbar.UserSelectedLanguage(lang:)) -> #( Model(..model, lang:), effect.none(), ) // SESSION MANAGEMENT ----------------------------------------------------- // // If the Server successfully authenticated the User, // initialize its Session, and redirect them to the correct route. Model(session: session.Pending(on_success:, ..), ..), UserRestoredSession(result: Ok(token)) -> { let session = session.Authenticated(token) let route = on_success let #(page, init_page) = page.init(route) let effect = effect.batch([init_page, redirect(to: route)]) #(Model(..model, session:, route:, page:), effect) } // If it fails, start the Session as a Guest and redirect // the User accordingly, usually to the Login Page. Model(session: session.Pending(on_failure:, ..), ..), UserRestoredSession(result: Error(..)) -> { let session = session.Guest let route = on_failure let #(page, init_page) = page.init(route) let effect = effect.batch([init_page, redirect(to: route)]) #(Model(..model, route:, page:, session:), effect) } // User ended their Session and token has been removed. // Redirect user to the Home page. model, ServerRemovedToken(result: Ok(..)) -> #( Model(..model, session: session.Guest), redirect(to: route.Home), ) // PAGES ------------------------------------------------------------------- // // LOGIN Model(route: route.Login, page: page.Login(page), ..), LoginMessage(page_message) -> handle_login_step(model, page, page_message) Model(route: route.Signup, page: page.Signup(page), ..), SignupMessage(page_message) -> handle_signup_message(model, page, page_message) // FALLBACK // _, _ -> #(model, effect.none()) } } fn handle_signup_message( model: Model, page: signup.Model, page_message: signup.Message, ) -> #(Model, Effect(Message)) { let #(page_model, page_effect) = signup.update(page, page_message, model.lang) let page = page.Signup(page_model) let effect = effect.map(page_effect, SignupMessage) #(Model(..model, page: page), effect) } fn handle_navigation( model: Model, route: route.Route, ) -> #(Model, Effect(Message)) { // Do nothing if the route doesnt change use <- bool.guard(model.route == route, #(model, effect.none())) let protected = route.is_protected(route) let route = case model.session, route { // If the route require the User to be authenticated, // redirect them to the Login page. session.Guest, _ | session.Pending(..), _ if protected -> route.Login // If the User is *already* authenticated but navigating to // the Login page, redirect them to Dashboard instead. session.Authenticated(..), route.Login -> route.Dashboard _, _ -> route } let #(page, effect) = page.init(route) #(Model(..model, route:, page:), effect) } fn handle_login_step( model: Model, page: login.Model, message: login.Message, ) -> #(Model, Effect(Message)) { case login.update(page, message, model.lang) { // Clear any remaining error message and continue execution as normal. login.Continue(page, effect) -> #( Model(..model, page: page.Login(page)), effect.map(effect, LoginMessage), ) login.ServerAuthenticatedUser(session:) -> #( Model(..model, session:), redirect(to: route.Dashboard), ) } } fn redirect(to route: route.Route) -> Effect(Message) { route.path(route) |> modem.push(option.None, option.None) }