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