This repository has no description
lustre
gleam
1import client/page
2import client/page/home
3import client/page/landing
4import client/page/login
5import client/page/not_found
6import client/page/profile
7import client/page/register_crew
8import client/page/signup
9import client/ui/header
10import client/ui/sidebar
11import glaze/oat/sidebar as oat_sidebar
12import gleam/bool
13import gleam/http/response
14import gleam/option
15import lustre
16import lustre/effect.{type Effect}
17import lustre/element
18import lustre/element/html
19import modem
20import rsvp
21import shared/route
22import shared/session
23
24pub fn main() -> Nil {
25 let app = lustre.application(init:, update:, view:)
26 let assert Ok(_runtime) = lustre.start(app, "#app", Nil)
27
28 Nil
29}
30
31// MODEL -----------------------------------------------------------------------
32
33pub type Model {
34 Model(
35 /// Current user
36 session: session.Session,
37 /// Current route
38 route: route.Route,
39 /// Current Page model
40 page: page.Page,
41 )
42}
43
44pub type Msg {
45 // Navigation
46 UserNavigatedTo(route.Route)
47
48 // Components
49 HeaderMsg(header.Msg)
50 SidebarMsg(sidebar.Msg)
51
52 // Pages
53 LoginMsg(login.Msg)
54 SignupMsg(signup.Msg)
55 ProfileMsg(profile.Msg)
56 DashboardMsg(home.Msg)
57 LandingMsg(landing.Msg)
58 NotFoundMsg(not_found.Msg)
59 RegisterCrewMsg(register_crew.Msg)
60
61 // Session
62 UserRestoredSession(Result(session.Session, rsvp.Error))
63 ServerRemovedToken(Result(response.Response(String), rsvp.Error))
64}
65
66fn init(_props: Nil) -> #(Model, Effect(Msg)) {
67 let assert Ok(uri) = modem.initial_uri()
68 let route = route.parse(uri)
69 let page = page.init(route)
70 let is_protected = route.is_protected(route)
71 let session = init_session(route, is_protected)
72
73 let router_effect =
74 modem.init(fn(uri) { route.parse(uri) |> UserNavigatedTo })
75
76 let session_effect =
77 UserRestoredSession
78 |> rsvp.expect_json(session.decoder(), _)
79 |> rsvp.get("/api/whoami", _)
80
81 let effect = effect.batch([session_effect, router_effect])
82 #(Model(session:, route:, page:), effect)
83}
84
85fn init_session(route: route.Route, is_protected: Bool) -> session.Session {
86 case route {
87 route.Login | route.Landing ->
88 session.Pending(on_success: route.Home, on_error: route)
89
90 route if is_protected ->
91 session.Pending(on_success: route, on_error: route.Login)
92
93 _ -> session.None
94 }
95}
96
97fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
98 case model, msg {
99 // NAVIGATION --------------------------------------------------------------
100 //
101 model, UserNavigatedTo(route) -> handle_navigation(model, route)
102
103 // SESSION MANAGEMENT -------------------------------------------------------
104 //
105 Model(session: session.Pending(on_success:, on_error: _), ..),
106 UserRestoredSession(Ok(session))
107 -> #(
108 Model(session:, route: on_success, page: page.init(on_success)),
109 modem.push(route.to_path(on_success), option.None, option.None),
110 )
111
112 Model(session: session.Pending(on_success: _, on_error:), ..),
113 UserRestoredSession(Error(_))
114 -> #(
115 Model(..model, route: on_error, page: page.init(on_error)),
116 modem.push(route.to_path(on_error), option.None, option.None),
117 )
118
119 model, ServerRemovedToken(Ok(_)) -> {
120 let session = session.None
121 let redirect_to = route.to_path(route.Landing)
122
123 let new = Model(..model, session:)
124 let effect = modem.push(redirect_to, option.None, option.None)
125
126 #(new, effect)
127 }
128
129 // UI ----------------------------------------------------------------------
130 //
131 // HEADER
132 model, HeaderMsg(_msg) -> #(model, effect.none())
133
134 // SIDEBAR
135 model, SidebarMsg(msg) -> handle_sidebar_msg(model, msg)
136
137 // PAGES -------------------------------------------------------------------
138 //
139 // LOGIN
140 Model(route: route.Login, page: page.Login(page_model), ..),
141 LoginMsg(page_msg)
142 -> handle_login_msg(model, page_model, page_msg)
143
144 // SIGNUP
145 Model(route: route.Signup, page: page.Signup(page_model), ..),
146 SignupMsg(page_msg)
147 ->
148 handle_msg(
149 model:,
150 page_model:,
151 page_model_fn: page.Signup,
152 page_msg:,
153 page_msg_map: SignupMsg,
154 update_fn: signup.update,
155 )
156
157 // REGISTER CREW
158 Model(route: route.RegisterCrew, page: page.RegisterCrew(page_model), ..),
159 RegisterCrewMsg(page_msg)
160 ->
161 handle_msg(
162 model:,
163 page_model:,
164 page_model_fn: page.RegisterCrew,
165 page_msg:,
166 page_msg_map: RegisterCrewMsg,
167 update_fn: register_crew.update,
168 )
169
170 // PROFILE
171 Model(route: route.Profile, page: page.Profile(_page_model), ..),
172 ProfileMsg(_page_msg)
173 -> #(model, effect.none())
174
175 // LANDING
176 Model(route: route.Landing, page: page.Landing(_), ..), LandingMsg(_) -> #(
177 model,
178 effect.none(),
179 )
180
181 // FALLBACK ----------------------------------------------------------------
182 //
183 _, _ -> #(model, effect.none())
184 }
185}
186
187// VIEW ------------------------------------------------------------------------
188
189fn layout(
190 model: Model,
191 element_view: element.Element(a),
192 f: fn(a) -> Msg,
193) -> element.Element(Msg) {
194 oat_sidebar.sidebar(html.main, [], [
195 header.view(model.session) |> element.map(HeaderMsg),
196 sidebar.view(model.session) |> element.map(SidebarMsg),
197 element_view |> element.map(f),
198 ])
199}
200
201fn view(model: Model) -> element.Element(Msg) {
202 case model {
203 // LANDING PAGE ------------------------------------------------------------
204 Model(session:, route: route.Landing, page: page.Landing(page_model)) ->
205 layout(model, landing.view(session, page_model), LandingMsg)
206
207 // LOGIN PAGE --------------------------------------------------------------
208 Model(session:, route: route.Login, page: page.Login(page_model)) ->
209 layout(model, login.view(session, page_model), LoginMsg)
210
211 // DASHBOARD PAGE ----------------------------------------------------------
212 Model(session:, route: route.Home, page: page.Dashboard(page_model)) ->
213 layout(model, home.view(session, page_model), DashboardMsg)
214
215 // SIGNUP PAGE -------------------------------------------------------------
216 Model(session:, route: route.Signup, page: page.Signup(page_model)) ->
217 layout(model, signup.view(session, page_model), SignupMsg)
218
219 Model(session:, route: route.Profile, page: page.Profile(page_model)) ->
220 layout(model, profile.view(session, page_model), ProfileMsg)
221
222 // REGISTER CREW
223 Model(
224 session:,
225 route: route.RegisterCrew,
226 page: page.RegisterCrew(page_model),
227 ) -> layout(model, register_crew.view(session, page_model), RegisterCrewMsg)
228
229 // FALLBACK ----------------------------------------------------------------
230 model -> layout(model, not_found.view(), NotFoundMsg)
231 }
232}
233
234// MESSAGE HANDLERS ------------------------------------------------------------
235
236fn handle_navigation(model: Model, route: route.Route) -> #(Model, Effect(Msg)) {
237 use <- bool.guard(model.route == route, #(model, effect.none()))
238 let is_protected = route.is_protected(route)
239
240 let route = case model.session, route {
241 session.None, _ | session.Pending(_, _), _ if is_protected -> route.Login
242 session.Authenticated(_), route.Login -> route.Home
243
244 _, _ -> route
245 }
246
247 let new = Model(..model, route:, page: page.init(route))
248 #(new, effect.none())
249}
250
251/// Default message handling.
252/// Updates the current page model.
253fn handle_msg(
254 model model: Model,
255 page_model page_model: a,
256 page_model_fn page_model_fn: fn(a) -> page.Page,
257 page_msg page_msg: b,
258 page_msg_map page_msg_map: fn(b) -> Msg,
259 update_fn update_fn: fn(a, b) -> #(a, Effect(b)),
260) -> #(Model, Effect(Msg)) {
261 let #(page_model, effect) = update_fn(page_model, page_msg)
262 let page = page_model_fn(page_model)
263
264 let new = Model(..model, page:)
265 let effect = effect.map(effect, page_msg_map)
266
267 #(new, effect)
268}
269
270fn handle_sidebar_msg(model: Model, msg: sidebar.Msg) -> #(Model, Effect(Msg)) {
271 case model, msg {
272 Model(session: session.Authenticated(_), ..), sidebar.UserClickedLogout -> #(
273 model,
274 ServerRemovedToken
275 |> rsvp.expect_ok_response
276 |> rsvp.get("/api/logout", _),
277 )
278
279 _, _ -> #(model, effect.none())
280 }
281}
282
283fn handle_login_msg(
284 model: Model,
285 page_model: login.Model,
286 msg: login.Msg,
287) -> #(Model, Effect(Msg)) {
288 case login.update(page_model, msg) {
289 login.Continue(page_model, effect) -> #(
290 Model(..model, page: page.Login(page_model)),
291 effect.map(effect, LoginMsg),
292 )
293
294 login.ServerAuthenticatedUser(session) -> #(
295 Model(..model, session:),
296 route.Home
297 |> route.to_path
298 |> modem.push(option.None, option.None),
299 )
300
301 login.ServerFailedToAuthenticate(reason) -> {
302 let message = case reason {
303 rsvp.HttpError(resp) -> resp.body
304 rsvp.NetworkError -> "Connection unnavailable"
305
306 _ -> ""
307 }
308
309 let page_model = login.Model(..page_model, loading: False, message:)
310 let new = Model(..model, page: page.Login(page_model))
311
312 #(new, effect.none())
313 }
314 }
315}