import client/page import client/page/dashboard import client/page/landing import client/page/login import client/page/not_found import client/page/profile import client/page/signup import client/ui/header import client/ui/sidebar import glaze/oat/sidebar as oat_sidebar import gleam/bool import gleam/http/response import gleam/option import lustre import lustre/attribute.{class} import lustre/effect import lustre/element import lustre/element/html import modem import rsvp import shared/route import shared/session pub fn main() -> Nil { let app = lustre.application(init:, update:, view:) let assert Ok(_runtime) = lustre.start(app, "#app", Nil) Nil } // MODEL ----------------------------------------------------------------------- pub type Model { Model( /// Current user session: session.Session, /// Current route route: route.Route, /// Current Page model page: page.Page, ) } pub type Msg { // Navigation UserNavigatedTo(route.Route) // Components HeaderMsg(header.Msg) SidebarMsg(sidebar.Msg) // Pages LoginMsg(login.Msg) SignupMsg(signup.Msg) ProfileMsg(profile.Msg) DashboardMsg(dashboard.Msg) LandingMsg(landing.Msg) NotFoundMsg(not_found.Msg) // Session UserRestoredSession(Result(session.Session, rsvp.Error)) ServerRemovedToken(Result(response.Response(String), rsvp.Error)) } fn init(_props: Nil) -> #(Model, effect.Effect(Msg)) { let assert Ok(uri) = modem.initial_uri() let route = route.parse(uri) let page = page.init(route) let is_protected = route.is_protected(route) let session = init_session(route, is_protected) let router_effect = fn(uri) { UserNavigatedTo(route.parse(uri)) } |> modem.init let session_effect = UserRestoredSession |> rsvp.expect_json(session.decoder(), _) |> rsvp.get("/api/whoami", _) let effect = effect.batch([session_effect, router_effect]) #(Model(session:, route:, page:), effect) } fn init_session(route: route.Route, is_protected: Bool) -> session.Session { case route { route.Login | route.Landing -> session.Pending(on_success: route.Dashboard, on_error: route) route if is_protected -> session.Pending(on_success: route, on_error: route.Login) _ -> session.None } } fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { case model, msg { // UI ---------------------------------------------------------------------- // // HEADER model, HeaderMsg(msg) -> handle_header_msg(model, msg) // SIDEBAR model, SidebarMsg(msg) -> handle_sidebar_msg(model, msg) // PAGES ------------------------------------------------------------------- // // LOGIN Model(route: route.Login, page: page.Login(page_model), ..), LoginMsg(page_msg) -> handle_login_msg(model, page_model, page_msg) // SIGNUP Model(route: route.Signup, page: page.Signup(page_model), ..), SignupMsg(page_msg) -> handle_signup_msg(model, page_model, page_msg) // PROFILE Model(route: route.Profile, page: page.Profile(page_model), ..), ProfileMsg(page_msg) -> handle_profile_msg(model, page_model, page_msg) // LANDING Model(route: route.Landing, page: page.Landing(_), ..), LandingMsg(_) -> #( model, effect.none(), ) // NAVIGATION -------------------------------------------------------------- // model, UserNavigatedTo(route) -> handle_navigation(model, route) // SESSION MANAGEMENT ------------------------------------------------------- // Model(session: session.Pending(on_success:, on_error: _), ..), UserRestoredSession(Ok(session)) -> #( Model(session:, route: on_success, page: page.init(on_success)), modem.push(route.to_path(on_success), option.None, option.None), ) Model(session: session.Pending(on_success: _, on_error:), ..), UserRestoredSession(Error(_)) -> #( Model(..model, route: on_error, page: page.init(on_error)), modem.push(route.to_path(on_error), option.None, option.None), ) model, ServerRemovedToken(Ok(_)) -> { let session = session.None let model = Model(..model, session:) let redirect_to = route.to_path(route.Landing) #(model, modem.push(redirect_to, option.None, option.None)) } // FALLBACK ---------------------------------------------------------------- // model, _ -> #(model, effect.none()) } } fn handle_profile_msg( model: Model, _page_model: profile.Model, _page_msg: profile.Msg, ) -> #(Model, effect.Effect(Msg)) { #(model, effect.none()) } // VIEW ------------------------------------------------------------------------ fn layout( model: Model, element_view: element.Element(a), f: fn(a) -> Msg, ) -> element.Element(Msg) { let attributes = [class("text-white")] oat_sidebar.sidebar_always(html.main, attributes, [ header.view(model.session) |> element.map(HeaderMsg), sidebar.view(model.session) |> element.map(SidebarMsg), element_view |> element.map(f), ]) } fn view(model: Model) -> element.Element(Msg) { case model { // LANDING PAGE ------------------------------------------------------------ Model(session:, route: route.Landing, page: page.Landing(page_model)) -> layout(model, landing.view(session, page_model), LandingMsg) // LOGIN PAGE -------------------------------------------------------------- Model(session:, route: route.Login, page: page.Login(page_model)) -> layout(model, login.view(session, page_model), LoginMsg) // DASHBOARD PAGE ---------------------------------------------------------- Model(session:, route: route.Dashboard, page: page.Dashboard(page_model)) -> layout(model, dashboard.view(session, page_model), DashboardMsg) // SIGNUP PAGE ------------------------------------------------------------- Model(session:, route: route.Signup, page: page.Signup(page_model)) -> layout(model, signup.view(session, page_model), SignupMsg) Model(session:, route: route.Profile, page: page.Profile(page_model)) -> layout(model, profile.view(session, page_model), ProfileMsg) // FALLBACK ---------------------------------------------------------------- model -> layout(model, not_found.view(), NotFoundMsg) } } // MESSAGE HANDLERS ------------------------------------------------------------ fn handle_navigation( model: Model, route: route.Route, ) -> #(Model, effect.Effect(Msg)) { use <- bool.guard(model.route == route, #(model, effect.none())) let is_protected = route.is_protected(route) let route = case model.session, route { session.None, _ | session.Pending(_, _), _ if is_protected -> route.Login session.Authenticated(_), route.Login -> route.Dashboard _, route -> route } let model = Model(..model, route:, page: page.init(route)) #(model, effect.none()) } fn handle_header_msg( model: Model, _msg: header.Msg, ) -> #(Model, effect.Effect(Msg)) { #(model, effect.none()) } fn handle_sidebar_msg( model: Model, msg: sidebar.Msg, ) -> #(Model, effect.Effect(Msg)) { case model, msg { Model(session: session.Authenticated(_), ..), sidebar.UserClickedLogout -> { let message = ServerRemovedToken let effect = rsvp.get("/api/logout", rsvp.expect_ok_response(message)) #(model, effect) } _, _ -> #(model, effect.none()) } } fn handle_login_msg( model: Model, page_model: login.Model, msg: login.Msg, ) -> #(Model, effect.Effect(Msg)) { case login.update(page_model, msg) { login.Continue(page_model, effect) -> #( Model(..model, page: page.Login(page_model)), effect.map(effect, LoginMsg), ) login.ServerAuthenticatedUser(session) -> #( Model(..model, session:), route.Dashboard |> route.to_path |> modem.push(option.None, option.None), ) login.ServerFailedToAuthenticate(reason) -> { let message = case reason { rsvp.HttpError(resp) -> resp.body rsvp.NetworkError -> "Connection unnavailable" _ -> "" } let page = login.Model(..page_model, loading: False, message:) |> page.Login #(Model(..model, page:), effect.none()) } } } fn handle_signup_msg( model: Model, page_model: signup.Model, page_msg: signup.Msg, ) -> #(Model, effect.Effect(Msg)) { let #(page_model, effect) = signup.update(page_model, page_msg) let page = page.Signup(page_model) let effect = effect.map(effect, SignupMsg) #(Model(..model, page:), effect) }