diff --git a/client/src/client.gleam b/client/src/client.gleam index 2402959..0f79040 100644 --- a/client/src/client.gleam +++ b/client/src/client.gleam @@ -1,3 +1,4 @@ +import gleam/dynamic/decode import gleam/int import gleam/json import gleam/list @@ -12,6 +13,7 @@ import lustre/element/html import lustre/event import plinth/browser/document import plinth/browser/element as plinth_element +import rsvp import shared.{type Room} pub fn main() { @@ -25,97 +27,164 @@ pub fn main() { |> result.unwrap([]) let app = lustre.application(init, update, view) - let assert Ok(_) = lustre.start(app, "#app", initial_items) + let assert Ok(_) = lustre.start(app, "#app", #(initial_items, None)) Nil } type Model { - Model( - rooms: List(Room), - name: Option(String), - pin: Option(String), - typed: String, - ) + Model(rooms: List(Room), state: State, ohno: Option(String)) } -fn init(items: List(Room)) -> #(Model, Effect(State)) { - let model = Model(rooms: items, name: None, pin: None, typed: "") +fn init(initial: #(List(Room), Option(String))) -> #(Model, Effect(Msg)) { + let #(rooms, ohno) = initial + let model = Model(rooms:, state: Empty, ohno:) #(model, effect.none()) } type State { + Empty + EnterPin(room: String, pin: String) + AwaitPlayers(room: String, pin: String) + PickPlayer(room: String, pin: String, players: List(String)) +} + +type Msg { + Initialize SelectRoom(String) - Initial - KeyIn(String) + SelectPlayer(String) + KeyPin(String) + Players(Result(String, rsvp.Error)) } -fn update(model: Model, msg: State) -> #(Model, Effect(msg)) { +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { - Initial -> - case model.name { - _ -> #(model, effect.none()) + Initialize -> init(#(model.rooms, None)) + SelectRoom(room) -> #( + Model(..model, state: EnterPin(room:, pin: "")), + effect.none(), + ) + KeyPin(pin) -> + case model.state { + EnterPin(room, _) -> { + let decode_answer = { + use id <- decode.field("id", decode.string) + // use text <- decode.field("text", decode.string) + decode.success(id) + } + // let decode_list = { + // decode.list(decode.string) + // } + case string.length(pin) < 4 { + False -> { + #( + Model( + ..model, + state: PickPlayer(room:, pin:, players: ["Player a", "Player b", "Player c", "Player d", "Player e", "Player f"]), + ), + effect.none(), + ) + // Model(..model, state: AwaitPlayers(room:, pin:)), + // rsvp.post( + // "http://localhost:1234/api/room/players", + // json.object([#("id", json.string("1234"))]), + // rsvp.expect_json(decode_answer, Players), + // ), + // ) + } + True -> #( + Model(..model, state: EnterPin(room:, pin:)), + effect.none(), + ) + } + } + _ -> + init(#(model.rooms, Some("(EnterPin) Invalid state, starting over"))) + // Invalid model state, start over } - SelectRoom(text) -> { - #(Model(..model, name: Some(text)), effect.none()) - } - KeyIn(newpin) -> { - case string.length(newpin) < 4 { - False -> #(Model(..model, pin: Some(newpin)), effect.none()) - True -> #(Model(..model, typed: newpin), effect.none()) + + Players(Ok(players)) -> { + echo "got players" + case model.state { + AwaitPlayers(room:, pin:) -> #( + Model(..model, state: PickPlayer(room:, pin:, players: [players])), + effect.none(), + ) + _ -> init(#(model.rooms, Some("Invalid state, expected AwaitPlayers"))) + // invalid state, start over } } + Players(Error(x)) -> + init(#( + model.rooms, + Some("Error fetching players " <> decode_rsvp_error(x)), + )) + SelectPlayer(x) -> init(#(model.rooms, Some("Players " <> x))) } } -fn view(model: Model) -> Element(State) { - case model.name, model.pin { - None, _ -> - html.div([], [ - html.div([class("terminal-header")], [ - html.div([class("terminal-status")], [ - html.span([class("status-blink")], [html.text("●")]), - html.text(" SYSTEM READY"), - html.span([class("ml-8")], [ - html.text("<< Please Log On to use QuizTerm. >>"), - ]), - ]), - ]), - view_room_list(model.rooms), - ]) - Some(_), None -> { - html.div([], [ - html.div([class("terminal-header")], [ - html.div([class("terminal-status")], [ - html.span([class("status-blink")], [html.text("●")]), - html.text(" SYSTEM READY"), - html.span([class("ml-8")], [ - html.text("<< Please Log On to use QuizTerm. >>"), - ]), - ]), - ]), - pin(), - ]) - } - Some(_), Some(_) -> { - html.div([], [ - html.div([class("terminal-header")], [ - html.div([class("terminal-status")], [ - html.span([class("status-blink")], [html.text("●")]), - html.text(" SYSTEM READY"), - html.span([class("ml-8")], [ - html.text("<< Please Log On to use QuizTerm. >>"), - ]), - ]), - ]), - html.text("FETCHING USERS FOR ROOM"), - ]) - } +fn decode_rsvp_error(rsvp_error: rsvp.Error) { + case rsvp_error { + rsvp.BadBody -> "Bad body" + rsvp.BadUrl(x) -> "Bad url" + rsvp.HttpError(x) -> "Http error" + rsvp.JsonError(x) -> "Json error" <> decode_decode_error(x) + rsvp.NetworkError -> "Network error" + rsvp.UnhandledResponse(_) -> "Unhandled response" } } -fn pin() -> Element(State) { +fn decode_decode_error(json_error: json.DecodeError) -> String { + case json_error { + json.UnableToDecode(x) -> + "[unable to decode" + <> string.concat( + list.map(x, fn(x) { "(" <> decode_ddecode_error(x) <> ")" }), + ) + <> "]" + json.UnexpectedSequence(s) -> "[unexpected sequence " <> s <> "]" + json.UnexpectedByte(x) -> "[unexpected byte " <> x <> "]" + json.UnexpectedEndOfInput -> "[unexpected end of input]" + } +} + +fn decode_ddecode_error(decode_error: decode.DecodeError) -> String { + case decode_error { + decode.DecodeError(expected:, found:, path:) -> + "(e: " <> expected <> ", f: " <> found <> ", p: " <> string.concat(path) + } +} + +fn view(model: Model) -> Element(Msg) { + html.div([], [ + html.div([class("terminal-header")], [ + html.div([class("terminal-status")], [ + html.span([class("status-blink")], [html.text("●")]), + html.div([], [ + html.text(" SYSTEM READY"), + ]), + html.div([], [ + case model.ohno { + None -> element.none() + Some(x) -> html.h3([], [html.text("Fail: " <> x)]) + }, + ]), + html.span([class("ml-8")], [ + html.text("<< Please Log On to use QuizTerm. >>"), + ]), + ]), + ]), + case model.state { + Empty -> view_room_list(model.rooms) + EnterPin(_, _) -> pin() + AwaitPlayers(_, _) -> html.text("FETCHING USERS FOR ROOM") + PickPlayer(_, _, players) -> view_player_list(players) + }, + ]) +} + +fn pin() -> Element(Msg) { html.div([attribute.class("terminal-section")], [ html.div([attribute.class("terminal-label mb-4")], [ html.text("Select room to play in"), @@ -124,14 +193,14 @@ fn pin() -> Element(State) { html.text("Enter PIN code for room"), html.input([ attribute.type_("password"), - event.on_input(KeyIn), + event.on_input(KeyPin), attribute.autofocus(True), ]), ]), ]) } -fn view_room_list(items: List(Room)) -> Element(State) { +fn view_room_list(items: List(Room)) -> Element(Msg) { html.div([attribute.class("terminal-section")], [ html.div([attribute.class("terminal-label mb-4")], [ html.text("Select room to play in"), @@ -140,14 +209,42 @@ fn view_room_list(items: List(Room)) -> Element(State) { [] -> [html.text("No items in your list yet.")] _ -> { list.index_map(items, fn(item, index) { - content_cell(index, item, SelectRoom) + room_cell(index, item, SelectRoom) }) } }), ]) } -fn content_cell( +fn view_player_list(items: List(String)) -> Element(Msg) { + html.div([attribute.class("terminal-section")], [ + html.div([attribute.class("terminal-label mb-4")], [ + html.text("Select room to play in"), + ]), + html.div([attribute.class("participants-grid")], case items { + [] -> [html.text("No items in your list yet.")] + _ -> { + list.index_map(items, fn(item, index) { + player_cell(index, item, SelectPlayer) + }) + } + }), + ]) +} + +fn player_cell( + number: Int, + player: String, + on_click: fn(String) -> msg, +) -> Element(msg) { + html.div([class("participant-login"), event.on_click(on_click(player))], [ + html.div([class("participant-name")], [ + html.text("► " <> "[#" <> int.to_string(number) <> "] " <> player), + ]), + ]) +} + +fn room_cell( number: Int, room: Room, on_click: fn(String) -> msg, diff --git a/server/src/backend/roomhandler.gleam b/server/src/backend/roomhandler.gleam index 04fd45b..7bbe140 100644 --- a/server/src/backend/roomhandler.gleam +++ b/server/src/backend/roomhandler.gleam @@ -28,8 +28,8 @@ pub fn initialize(state_handler: Started(Subject(StateControl))) { state.rooms |> list.key_find(id) { Error(_) -> { - // Prevent overflowing server with rooms, set max 200 - case list.length(state.rooms) < 200 { + // Prevent overflowing server with rooms, set max 50 + case list.length(state.rooms) < 50 { True -> { // Room not found (not really an error case), create it. let name = process.new_name("quiz-registry" <> id) diff --git a/server/src/web/router.gleam b/server/src/web/router.gleam index 330092b..0641526 100644 --- a/server/src/web/router.gleam +++ b/server/src/web/router.gleam @@ -4,7 +4,9 @@ import gleam/dynamic/decode import gleam/erlang/process.{type Subject} import gleam/http import gleam/int +import gleam/json import gleam/list +import gleam/option import gleam/otp/actor.{type Started} import shared/message.{type ClientsServer, type RoomControl, type StateControl} import web/handlers/serve.{board, main_html, room, slow, status_head} @@ -17,7 +19,8 @@ pub fn handle_request( ) -> Response { use req <- middleware(req) case wisp.path_segments(req) { - ["api", ..path] -> handle_api(state_handler, req, path) + ["api", "room", ..path] -> handle_room_api(room_handler, req, path) + ["api", ..path] -> handle_admin_api(state_handler, req, path) _ -> handle_html(room_handler, req) } } @@ -38,7 +41,21 @@ fn handle_html( |> main_html } -fn handle_api( +fn handle_room_api( + room_handler: Started(Subject(RoomControl(ClientsServer))), + req: Request, + path: List(String), +) { + use json <- wisp.require_json(req) + + case req.method, path { + http.Post, ["players"] -> fetch_players(room_handler, json) + _, _ -> #(404, "bad api path", "Resource not found") + } + |> serve.create_json_response +} + +fn handle_admin_api( actor: Started(Subject(StateControl)), req: Request, path: List(String), @@ -58,15 +75,15 @@ fn handle_api( decode_index_to_text(actor, json, message.SetQuestion) http.Post, ["answers"] -> decode_index_to_text(actor, json, message.SetAnswer) - _, _ -> #(404, "bad api apth","Resource not found") + _, _ -> #(404, "bad api path", "Resource not found") } False -> { - #(401, "invalid api key","unauthorized") + #(401, "invalid api key", "unauthorized") } } } Error(_) -> { - #(401, "missing api key","unauthorized") + #(401, "missing api key", "unauthorized") } } |> serve.create_json_response @@ -85,7 +102,35 @@ fn decode_info( actor.send(actor.data, info) #(200, "Updated info", "Updated info") } - Error(_) -> #(400, "Unable to update info","bad request") + Error(_) -> #(400, "Unable to update info", "bad request") + } +} + +fn fetch_players( + room_handler: Started(Subject(RoomControl(ClientsServer))), + json_string: decode.Dynamic, +) { + let decode_uri = { + use id <- decode.field("id", decode.string) + //use key <- decode.field("key", decode.string) + decode.success(message.FetchRoom(id, _)) + } + case decode.run(json_string, decode_uri) { + Ok(room) -> { + case actor.call(room_handler.data, 1000, room) { + option.Some(#(_, player_handler)) -> #( + 200, + json.to_string(json.object([#("id", json.string("10"))])), + json.to_string(json.object([#("id", json.string("10"))])), + ) + option.None -> #( + 404, + "Room not found, or key invalid", + "resource not found", + ) + } + } + Error(fault) -> #(400, "Unable to fetch players", "bad request") } } @@ -105,9 +150,13 @@ fn decode_index_to_text( list.each(answers, fn(answer_question) { actor.send(actor.data, answer_question) }) - #(200, "imported " <> int.to_string(list.length(answers)) <> " items.","imported " <> int.to_string(list.length(answers)) <> " items.") + #( + 200, + "imported " <> int.to_string(list.length(answers)) <> " items.", + "imported " <> int.to_string(list.length(answers)) <> " items.", + ) } - Error(_) -> #(400, "Failed to import","bad request") + Error(_) -> #(400, "Failed to import", "bad request") } }