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/route import client/session import gleam/bool import gleam/http/response import gleam/option import gleam/uri import lustre import lustre/attribute as attr import lustre/effect.{type Effect} import lustre/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", InitOpts(uri)) Nil } /// Data passed to our application during startup pub type InitOpts { InitOpts( /// Uri of the page when it first loaded. uri: uri.Uri, ) } pub type Msg { /// Handle internal links navigation UserNavigatedTo(to: route.Route) /// User sent a request to the Server during startup to verify if the current /// token is still valid. UserRestoredSession(result: Result(session.Session, rsvp.Error)) /// Server removed the token from the Client, ending their session. ServerRemovedToken(result: Result(response.Response(String), rsvp.Error)) // PAGES --- LoginMsg(msg: login.Msg) HomeMsg(msg: home.Msg) DashboardMsg(msg: dashboard.Msg) // UI Elements SidebarMsg(msg: sidebar.Msg) NavbarMsg(msg: navbar.Msg) NotFoundMsg(msg: not_found.Msg) } // INIT ------------------------------------------------------------------------ /// Build initial application Model pub fn init(opts: InitOpts) -> #(Model, Effect(Msg)) { let route = route.parse(opts.uri) let 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 UserNavigatedTo(route.parse(uri)) } // 󱡯 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 restore_session = UserRestoredSession |> rsvp.expect_json(session.decoder(), _) |> rsvp.get("/api/whoami", _) // Batch all scheduled effects and build the initial application `Model` let effect = effect.batch([restore_session, init_modem]) #(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, element_view: element.Element(a), f: fn(a) -> Msg, ) -> element.Element(Msg) { html.section([attr.data("sidebar-layout", "")], [ navbar.view(model.session, model.lang) |> element.map(NavbarMsg), sidebar.view(model.session) |> element.map(SidebarMsg), html.main([attr.class("p-0")], [element_view |> element.map(f)]), ]) } /// Render page HTML pub fn view(model: Model) -> element.Element(Msg) { case model { // HOME PAGE --------------------------------------------------------------- Model(route: route.Home, page: page.Home, ..) -> layout(model, home.view(model.session, model.lang), HomeMsg) // LOGIN PAGE -------------------------------------------------------------- Model(route: route.Login, page: page.Login(page), ..) -> layout(model, login.view(page, model.lang), LoginMsg) // DASHBOARD PAGE ---------------------------------------------------------- Model(route: route.Dashboard, page: page.Dashboard(page), ..) -> layout(model, dashboard.view(model.session, page), DashboardMsg) _ -> layout(model, not_found.view(), NotFoundMsg) } } // UPDATE ---------------------------------------------------------------------- /// Update current application `Model` pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case model, msg { //  NAVIGATION model, UserNavigatedTo(route) -> handle_navigation(model, route) //  LANGUAGE SELECTION model, NavbarMsg(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(Ok(session)) -> #( Model(..model, session:, route: on_success, page: page.init(on_success)), modem.push(route.path(on_success), option.None, option.None), ) // 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(Error(_)) -> { let session = session.Guest let route = on_failure let model = Model(..model, route:, page: page.init(route), session:) #(model, modem.push(route.path(route), option.None, option.None)) } // User ended their Session and token has been removed. // Redirect user to the Home page. model, ServerRemovedToken(Ok(_)) -> { let session = session.Guest let route = route.path(route.Home) #(Model(..model, session:), modem.push(route, option.None, option.None)) } // PAGES ------------------------------------------------------------------- // // LOGIN Model(route: route.Login, page: page.Login(page), ..), LoginMsg(page_msg) -> handle_login_step(model, page, page_msg) // FALLBACK // _, _ -> #(model, effect.none()) } } fn handle_navigation( model: Model, route: route.Route, ) -> #(Model, Effect(Msg)) { // Do nothing 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 = page.init(route) #(Model(..model, route:, page:), effect.none()) } fn handle_login_step( model: Model, page: login.Model, msg: login.Msg, ) -> #(Model, Effect(Msg)) { case login.update(page, msg) { // Clear any remaining error message and continue execution as normal. login.Continue(page, effect) -> #( Model(..model, page: page.Login(page)), effect.map(effect, LoginMsg), ) //  Server successfully authenticated the Client, we can now store // the returned Session token in our application Model and access // protected routes. login.ServerAuthenticatedUser(session) -> #( Model(..model, session:), route.Dashboard |> route.path |> modem.push(option.None, option.None), ) //  Server failed to authenticate the Client, display a error message // on the Login page and continue execution. login.ServerFailedToAuthenticate(reason) -> { let message = case reason { rsvp.HttpError(resp) -> resp.body rsvp.NetworkError -> case model.lang { lang.BrazillianPortuguese | lang.Portuguese -> "Conexão não disponível" _ -> "Connection not available" } _ -> case model.lang { lang.BrazillianPortuguese | lang.Portuguese -> "Ocorreu um erro ao enviar credenciais" _ -> "An error occurred when sending credentials" } } let page = login.Model(..page, loading: False, message:) #(Model(..model, page: page.Login(page)), effect.none()) } } }