this repo has no description
lustre
frontent
oat-ui
gleam
1import client/language as lang
2import client/page
3import client/page/dashboard
4import client/page/home
5import client/page/login
6import client/page/navbar
7import client/page/not_found
8import client/page/sidebar
9import client/route
10import client/session
11import gleam/bool
12import gleam/http/response
13import gleam/option
14import gleam/uri
15import lustre
16import lustre/attribute as attr
17import lustre/effect.{type Effect}
18import lustre/element
19import lustre/element/html
20import modem
21import rsvp
22
23pub type Model {
24 Model(
25 /// Current user
26 session: session.Session,
27 /// Current route
28 route: route.Route,
29 /// Current Page model
30 page: page.Page,
31 /// Selected language
32 lang: lang.Language,
33 )
34}
35
36pub fn main() -> Nil {
37 let assert Ok(uri) = modem.initial_uri()
38
39 let app = lustre.application(init:, update:, view:)
40 let assert Ok(_runtime) = lustre.start(app, "#app", InitOpts(uri))
41
42 Nil
43}
44
45/// Data passed to our application during startup
46pub type InitOpts {
47 InitOpts(
48 /// Uri of the page when it first loaded.
49 uri: uri.Uri,
50 )
51}
52
53pub type Msg {
54 /// Handle internal links navigation
55 UserNavigatedTo(to: route.Route)
56 /// User sent a request to the Server during startup to verify if the current
57 /// token is still valid.
58 UserRestoredSession(result: Result(session.Session, rsvp.Error))
59 /// Server removed the token from the Client, ending their session.
60 ServerRemovedToken(result: Result(response.Response(String), rsvp.Error))
61
62 // PAGES ---
63 LoginMsg(msg: login.Msg)
64 HomeMsg(msg: home.Msg)
65 DashboardMsg(msg: dashboard.Msg)
66
67 // UI Elements
68 SidebarMsg(msg: sidebar.Msg)
69 NavbarMsg(msg: navbar.Msg)
70 NotFoundMsg(msg: not_found.Msg)
71}
72
73// INIT ------------------------------------------------------------------------
74
75/// Build initial application Model
76pub fn init(opts: InitOpts) -> #(Model, Effect(Msg)) {
77 let route = route.parse(opts.uri)
78 let page = page.init(route)
79 let is_protected = route.is_protected(route)
80 let session = init_session(route:, is_protected:)
81
82 let init_modem = {
83 use uri <- modem.init
84 UserNavigatedTo(route.parse(uri))
85 }
86
87 // Send a request to the Server to verify if the current
88 // token is still valid.
89 //
90 // Allow access to the route if it succeeds and
91 // redirect User to the Login page in case of failure.
92 let restore_session =
93 UserRestoredSession
94 |> rsvp.expect_json(session.decoder(), _)
95 |> rsvp.get("/api/whoami", _)
96
97 // Batch all scheduled effects and build the initial application `Model`
98 let effect = effect.batch([restore_session, init_modem])
99 #(Model(session:, route:, page:, lang: lang.BrazillianPortuguese), effect)
100}
101
102fn init_session(
103 route route: route.Route,
104 is_protected is_protected: Bool,
105) -> session.Session {
106 case route {
107 route.Login | route.Home ->
108 session.Pending(on_success: route.Home, on_error: route)
109
110 route if is_protected ->
111 session.Pending(on_success: route, on_error: route.Login)
112
113 _ -> session.Guest
114 }
115}
116
117// VIEW ------------------------------------------------------------------------
118
119fn layout(
120 model: Model,
121 element_view: element.Element(a),
122 f: fn(a) -> Msg,
123) -> element.Element(Msg) {
124 html.section([attr.data("sidebar-layout", "")], [
125 navbar.view(model.session, model.lang) |> element.map(NavbarMsg),
126 sidebar.view(model.session) |> element.map(SidebarMsg),
127 html.main([attr.class("p-0")], [element_view |> element.map(f)]),
128 ])
129}
130
131/// Render page HTML
132pub fn view(model: Model) -> element.Element(Msg) {
133 case model {
134 // HOME PAGE ---------------------------------------------------------------
135 Model(route: route.Home, page: page.Home, ..) ->
136 layout(model, home.view(model.session), HomeMsg)
137
138 // LOGIN PAGE --------------------------------------------------------------
139 Model(route: route.Login, page: page.Login(page), ..) ->
140 layout(model, login.view(page, model.lang), LoginMsg)
141
142 // DASHBOARD PAGE ----------------------------------------------------------
143 Model(route: route.Dashboard, page: page.Dashboard(page), ..) ->
144 layout(model, dashboard.view(model.session, page), DashboardMsg)
145
146 _ -> layout(model, not_found.view(), NotFoundMsg)
147 }
148}
149
150// UPDATE ----------------------------------------------------------------------
151
152/// Update current application `Model`
153pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
154 case model, msg {
155 // NAVIGATION
156 model, UserNavigatedTo(route) -> handle_navigation(model, route)
157
158 // LANGUAGE SELECTION
159 model, NavbarMsg(navbar.UserSelectedLanguage(lang)) -> #(
160 Model(..model, lang:),
161 effect.none(),
162 )
163
164 // SESSION MANAGEMENT -----------------------------------------------------
165 //
166 // If the Server successfully authenticated the User,
167 // initialize its Session, and redirect them to the correct route.
168 Model(session: session.Pending(on_success:, on_error: _), ..),
169 UserRestoredSession(Ok(session))
170 -> #(
171 Model(..model, session:, route: on_success, page: page.init(on_success)),
172 modem.push(route.to_path(on_success), option.None, option.None),
173 )
174
175 // If it fails, start the Session as a Guest and redirect
176 // the User accordingly, usually to the Login Page.
177 Model(session: session.Pending(on_success: _, on_error:), ..),
178 UserRestoredSession(Error(_))
179 -> #(
180 Model(
181 ..model,
182 route: on_error,
183 page: page.init(on_error),
184 session: session.Guest,
185 ),
186 modem.push(route.to_path(on_error), option.None, option.None),
187 )
188
189 // User ended their Session and token has been removed.
190 // Redirect user to the Home page.
191 model, ServerRemovedToken(Ok(_)) -> {
192 let session = session.Guest
193 let route = route.to_path(route.Home)
194
195 #(Model(..model, session:), modem.push(route, option.None, option.None))
196 }
197
198 // PAGES -------------------------------------------------------------------
199 //
200 // LOGIN
201 Model(route: route.Login, page: page.Login(page), ..), LoginMsg(page_msg) ->
202 handle_login_step(model, page, page_msg)
203
204 // FALLBACK
205 //
206 _, _ -> #(model, effect.none())
207 }
208}
209
210fn handle_navigation(
211 model: Model,
212 route: route.Route,
213) -> #(Model, Effect(Msg)) {
214 // Do nothing the route doesnt change
215 use <- bool.guard(model.route == route, #(model, effect.none()))
216 let protected = route.is_protected(route)
217
218 let route = case model.session, route {
219 // If the route require the User to be authenticated,
220 // redirect them to the Login page.
221 session.Guest, _ | session.Pending(_, _), _ if protected -> route.Login
222 // If the User is *already* authenticated but navigating to
223 // the Login page, redirect them to Dashboard instead.
224 session.Authenticated(_), route.Login -> route.Dashboard
225
226 _, _ -> route
227 }
228
229 let page = page.init(route)
230 #(Model(..model, route:, page:), effect.none())
231}
232
233fn handle_login_step(
234 model: Model,
235 page: login.Model,
236 msg: login.Msg,
237) -> #(Model, Effect(Msg)) {
238 case login.update(page, msg) {
239 // Clear any remaining error message and continue execution as normal.
240 login.Continue(page, effect) -> #(
241 Model(..model, page: page.Login(page)),
242 effect.map(effect, LoginMsg),
243 )
244
245 // Server successfully authenticated the Client, we can now store
246 // the returned Session token in our application Model and access
247 // protected routes.
248 login.ServerAuthenticatedUser(session) -> #(
249 Model(..model, session:),
250 route.Dashboard
251 |> route.to_path
252 |> modem.push(option.None, option.None),
253 )
254
255 // Server failed to authenticate the Client, display a error message
256 // on the Login page and continue execution.
257 login.ServerFailedToAuthenticate(reason) -> {
258 let message = case reason {
259 rsvp.HttpError(resp) -> resp.body
260
261 rsvp.NetworkError ->
262 case model.lang {
263 lang.BrazillianPortuguese | lang.Portuguese ->
264 "Conexão não disponível"
265 _ -> "Connection not available"
266 }
267
268 _ ->
269 case model.lang {
270 lang.BrazillianPortuguese | lang.Portuguese ->
271 "Ocorreu um erro ao enviar credenciais"
272 _ -> "An error occurred when sending credentials"
273 }
274 }
275
276 let page = login.Model(..page, loading: False, message:)
277 #(Model(..model, page: page.Login(page)), effect.none())
278 }
279 }
280}