diff --git a/server/src/backend/playerhandler.gleam b/server/src/backend/playerhandler.gleam index 8ab815b..a115198 100644 --- a/server/src/backend/playerhandler.gleam +++ b/server/src/backend/playerhandler.gleam @@ -1,3 +1,5 @@ +import gleam/bit_array +import gleam/crypto import gleam/erlang/process.{type Subject} import gleam/int import gleam/list @@ -15,7 +17,7 @@ type State { State( question_number: Int, // id, (name (question#, answer_attempt) - slow_answers: List(#(String, #(String, List(#(Int, String))))), + slow_answers: List(#(String, #(String, List(#(String, String))))), // int in #pair: ping counted since response back. name_answers: List(#(String, #(Int, AnswerStatus))), hide_answers: Bool, @@ -92,22 +94,25 @@ pub fn initialize( fetch_players(state.slow_answers, subject) state } - message.AddPlayer(name:, subject:) -> { - let _ = add_player(name, subject) - state - } + message.AddPlayer(name) -> + State(..state, slow_answers: add_player(name, state.slow_answers)) } |> actor.continue() }) |> actor.start } -fn add_player(_name: String, _subject: Subject(Result(String, String))) { - #(200, "ok", "ok") +fn add_player(name: String, players: List(#(String, #(String, List(#(_, _)))))) { + let id = + bit_array.base64_encode(crypto.hash(crypto.Sha256, <>), True) + case list.key_find(players, id) { + Error(_) -> [#(id, #(name, [])), ..players] + Ok(_) -> players + } } fn fetch_players( - players: List(#(String, #(String, List(#(Int, String))))), + players: List(#(String, #(String, List(#(_, String))))), subject: Subject(List(#(String, String))), ) { actor.send( @@ -246,7 +251,7 @@ fn combine_lists(state: State) { let #(_, #(name, answers)) = name_answers list.filter_map(answers, fn(number_answer) { let #(answer_number, answer) = number_answer - case state.question_number == answer_number { + case int.to_string(state.question_number) == answer_number { True -> { Ok( User(name, 0, case state.hide_answers { diff --git a/server/src/backend/sockethandler.gleam b/server/src/backend/sockethandler.gleam index a6db770..58db8e7 100644 --- a/server/src/backend/sockethandler.gleam +++ b/server/src/backend/sockethandler.gleam @@ -35,7 +35,7 @@ pub fn serve( pub fn serve_slow( request: Request(Connection), - component: lustre.App(#(List(#(Int, String)), message.ClientsServer), model, msg), + component: lustre.App(#(List(#(String, String)), message.ClientsServer), model, msg), id: String, roomhandler: actor.Started(Subject(message.RoomControl)), statehandler: actor.Started(Subject(message.StateControl)), diff --git a/server/src/backend/statehandler.gleam b/server/src/backend/statehandler.gleam index 12b3cb7..6e15dc3 100644 --- a/server/src/backend/statehandler.gleam +++ b/server/src/backend/statehandler.gleam @@ -1,3 +1,4 @@ +import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/otp/actor @@ -63,7 +64,7 @@ pub fn initialize() { subject, list.map(state.questions, fn(x) { let #(i, #(q, _)) = x - #(i, q) + #(int.to_string(i), q) }), ) state diff --git a/server/src/shared/message.gleam b/server/src/shared/message.gleam index 9035c35..dfe84d4 100644 --- a/server/src/shared/message.gleam +++ b/server/src/shared/message.gleam @@ -14,9 +14,9 @@ pub type NotifyServer { PurgePlayers GiveName(name: String) GiveAnswer(name: String, answer: Option(String)) - GiveSingleAnswer(id: String, question: Int, answer: String) + GiveSingleAnswer(id: String, question: String, answer: String) FetchPlayers(subject: Subject(List(#(String, String)))) - AddPlayer(name: String, subject: Subject(Result(String, String))) + AddPlayer(String) } pub type StateControl { @@ -24,7 +24,7 @@ pub type StateControl { SetAnswer(id: Int, answer: String) SetInfo(url: String) FetchQuestion(id: Int, subject: Subject(Option(String))) - FetchQuestions(subject: Subject(List(#(Int, String)))) + FetchQuestions(subject: Subject(List(#(String, String)))) } pub type Room { diff --git a/server/src/web/components/answerlist.gleam b/server/src/web/components/answerlist.gleam index 1094af2..a7f81ff 100644 --- a/server/src/web/components/answerlist.gleam +++ b/server/src/web/components/answerlist.gleam @@ -1,4 +1,7 @@ -import components.{click_cell} +import components.{click_cell, click_cell_pair} +import gleam/bit_array +import gleam/crypto +import gleam/dynamic/decode import gleam/erlang/process.{type Subject} import gleam/int import gleam/list @@ -9,13 +12,14 @@ import lustre/attribute.{class} import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html +import lustre/element/keyed +import lustre/event +import lustre/server_component import shared/message.{type NotifyClient, type NotifyServer} -import web/components/shared.{ - step_prompt, view_input, view_named_input, view_named_keyed_input, view_yes_no, -} +import web/components/shared pub fn component() -> lustre.App( - #(List(#(Int, String)), message.ClientsServer), + #(List(#(String, String)), message.ClientsServer), Model, Msg, ) { @@ -26,14 +30,14 @@ pub opaque type Model { Model( state: Msg, players: List(#(String, String)), - player_id: Option(String), - answers: List(#(Int, #(String, String))), + player: Option(#(String, String)), + answers: List(#(String, #(String, String))), handler: Started(Subject(NotifyServer)), ) } fn init( - start_args: #(List(#(Int, String)), message.ClientsServer), + start_args: #(List(#(String, String)), message.ClientsServer), ) -> #(Model, Effect(Msg)) { let #(answers, handlers) = start_args let #(_registry, handler) = handlers @@ -42,11 +46,7 @@ fn init( // "question number" -> #("question text", "users answer" array // with blank user answers. let initial_array = - list.filter(answers, fn(x) { - let #(i, _) = x - i <= 14 && i >= 0 - }) - |> list.map(fn(x) { + list.map(answers, fn(x) { let #(a, b) = x #(a, #(b, "")) }) @@ -65,68 +65,78 @@ fn init( pub opaque type Msg { Initial - PickedName(name: String) + PickedPlayer(player: Option(#(String, String))) SharedMessage(message: NotifyClient) - ReceiveName(message: String) - AcceptName(accept: Option(String)) + ReceiveName(name: Option(String)) + AcceptPlayer(accept: Option(#(String, String))) PickQuestion - PickedQuestion(question: String) - GiveAnswer(question: String, answer: String) + PickedQuestion(question: Option(#(String, String))) + GiveAnswer(question: #(String, String), answer: Option(String)) } fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { Initial -> #(Model(..model, state: msg), effect.none()) - PickedName(player_id) -> #( - Model(..model, player_id: Some(player_id), state: PickQuestion), + PickedPlayer(player) -> #( + case player { + Some(player) -> Model(..model, state: AcceptPlayer(Some(player))) + None -> Model(..model, state: ReceiveName(None)) + }, effect.none(), ) + SharedMessage(_) -> #(model, effect.none()) - AcceptName(None) -> #( - Model(Initial, model.players, None, [], model.handler), - effect.none(), - ) - AcceptName(Some(player_id)) -> { + ReceiveName(Some(name)) -> { + let id = + bit_array.base64_encode(crypto.hash(crypto.Sha256, <>), True) + #(Model(..model, state: AcceptPlayer(Some(#(id, name)))), effect.none()) + } + AcceptPlayer(Some(player)) -> { + let #(_, player_name) = player + actor.send(model.handler.data, message.AddPlayer(player_name)) #( - Model(..model, player_id: Some(player_id), state: PickQuestion), + Model(..model, player: Some(player), state: PickQuestion), effect.none(), ) } - PickQuestion -> #(model, effect.none()) - PickedQuestion(question) -> { - #(Model(..model, state: GiveAnswer(question, "")), effect.none()) + PickedQuestion(Some(question)) -> { + #( + Model(..model, state: GiveAnswer(question:, answer: None)), + effect.none(), + ) } - GiveAnswer(question, answer) -> { - let assert Some(player_id) = model.player_id - case int.parse(question) { - Ok(question) -> { - actor.send( - model.handler.data, - message.GiveSingleAnswer(id: player_id, question:, answer:), - ) - let new_value = case list.key_find(model.answers, question) { - Ok(pair) -> { - let #(a, _) = pair - #(a, answer) - } - Error(_) -> #("", answer) - } - #( - Model( - ..model, - state: PickQuestion, - answers: list.key_set(model.answers, question, new_value), - ), - effect.none(), - ) - } - _ -> { - echo "bad index" - #(model, effect.none()) + GiveAnswer(question, Some(answer)) -> { + let #(question, _) = question + let assert Some(#(player_id, _)) = model.player + actor.send( + model.handler.data, + message.GiveSingleAnswer(id: player_id, question:, answer:), + ) + let new_value = case list.key_find(model.answers, question) { + Ok(pair) -> { + let #(a, _) = pair + #(a, answer) } + Error(_) -> #("", answer) } + #( + Model( + ..model, + state: PickQuestion, + answers: list.key_set(model.answers, question, new_value), + ), + effect.none(), + ) } - ReceiveName(_) -> #(Model(..model, state: msg), effect.none()) + // Invalid states and "I want to start over" states| + GiveAnswer(_, None) + | AcceptPlayer(None) + | ReceiveName(None) + | PickedQuestion(None) + | PickQuestion -> #( + Model(Initial, model.players, None, model.answers, model.handler), + effect.none(), + ) } } @@ -139,9 +149,10 @@ fn view(model: Model) -> Element(Msg) { html.span([class("ml-8")], [ case model.state { Initial -> html.text("STATUS: Please select player") - ReceiveName(_) -> html.text("STATUS: Please validate your name") + ReceiveName(_) -> html.text("STATUS: Please enter your name") PickQuestion -> html.text("STATUS: Pick question to answer") GiveAnswer(_, _) -> html.text("STATUS: Give your answer") + AcceptPlayer(_) -> html.text("STATUS: Validate player") _ -> html.text("STATUS: Waiting for next question") }, ]), @@ -152,11 +163,29 @@ fn view(model: Model) -> Element(Msg) { html.text("[ACTIVE TRANSMISSIONS]"), ]), ]), - case model.state { - Initial -> view_players(model.players) - PickQuestion -> view_questions(model.answers) - _ -> content_cell(#(10, #("Answer", "Answer question"))) - }, + + html.div([class("participants-grid")], [ + case model.state { + Initial -> + case model.players { + [] -> input_new_player() + _ -> view_players(model.players) + } + PickQuestion -> view_questions(model.answers) + ReceiveName(_) -> input_new_player() + AcceptPlayer(Some(player)) -> { + let #(_, player_name) = player + shared.confirm_cell( + Some("Join as this player: " <> player_name <> "?"), + player, + AcceptPlayer, + ) + } + GiveAnswer(answer, None) -> input_new_answer(answer) + + _ -> content_cell(#(10, #("Answer", "Answer question"))) + }, + ]), ]) } @@ -166,18 +195,41 @@ fn view_players(players: List(#(String, String))) { [], list.append( list.index_map(players, fn(item, index) { - click_cell(Some(int.to_string(index)), item, PickedName) + click_cell_pair(Some(int.to_string(index)), Some(item), PickedPlayer) }), - [click_cell(Some("NEW"), #("new", "New Player!"), PickedName)], + [click_cell_pair(Some("ENTER NEW PLAYER"), None, PickedPlayer)], ), ), ]) } -fn view_questions(answers: List(#(Int, #(String, String)))) { +fn input_new_player() { + html.div([class("participant-box")], [ + input_cell("Enter player name:", ReceiveName), + ]) +} + +fn input_new_answer(question: #(String, String)) { + let #(question_id, question_text) = question + html.div([class("participant-box")], [ + input_cell("Answer [" <> question_id <> "] " <> question_text, GiveAnswer( + question, + _, + )), + ]) +} + +fn view_questions(answers: List(#(String, #(String, String)))) { html.div( - [attribute.class("singles-grid")], - list.map(answers, fn(content) { content_cell(content) }), + [class("singles-grid")], + list.map(answers, fn(content) { + let #(number, #(question, answer)) = content + click_cell_pair( + Some(number <> " " <> answer), + Some(#(number, question)), + PickedQuestion, + ) + }), ) } @@ -197,3 +249,42 @@ fn content_cell(answer: #(Int, #(String, String))) -> Element(Msg) { ], ) } + +fn input_cell( + text: String, + on_submit handle_keydown: fn(Option(String)) -> msg, +) -> Element(msg) { + html.div([attribute.class("singles-grid")], [ + html.div([], [html.text(text)]), + keyed.div([], [ + #("inputheader", html.text("$>")), + #( + "input", + html.input([ + attribute.type_("text"), + key_down( + fn(a: String) { decode.success(handle_keydown(Some(a))) }, + fn() { decode.failure(handle_keydown(None), "") }, + ), + attribute.autofocus(True), + ]), + ), + ]), + ]) +} + +fn key_down( + success: fn(String) -> decode.Decoder(msg), + fail: fn() -> decode.Decoder(msg), +) { + event.on("keydown", { + use key <- decode.field("key", decode.string) + use value <- decode.subfield(["target", "value"], decode.string) + + case key { + "Enter" if value != "" -> success(value) + _ -> fail() + } + }) + |> server_component.include(["key", "target.value"]) +} diff --git a/server/src/web/components/shared.gleam b/server/src/web/components/shared.gleam index df7be00..e5d3cd9 100644 --- a/server/src/web/components/shared.gleam +++ b/server/src/web/components/shared.gleam @@ -102,6 +102,7 @@ pub fn step_prompt(text: String, fetch: fn() -> Element(a)) { ]), ]) } + //fn xiew_player_list(items: List(String)) -> Element(Msg) { // layout("Select or enter your player", None, case items { // [] -> [html.text("No items in your list yet.")] @@ -115,3 +116,22 @@ pub fn step_prompt(text: String, fetch: fn() -> Element(a)) { // } // }) // +pub fn confirm_cell( + title: Option(String), + accepted: #(String, String), + on_submit handle_button: fn(Option(#(String, String))) -> msg, +) -> Element(msg) { + html.div([attribute.class("participant-login")], [ + html.div([attribute.class("participant-name")], [ + case title { + Some(title) -> html.div([], [html.text(title)]) + None -> element.none() + }, + html.button([event.on_click(handle_button(Some(accepted)))], [ + html.text(" "), + ]), + html.text(" - "), + html.button([event.on_click(handle_button(None))], [html.text(" ")]), + ]), + ]) +} diff --git a/server/src/web/router.gleam b/server/src/web/router.gleam index e78b2cc..fee332c 100644 --- a/server/src/web/router.gleam +++ b/server/src/web/router.gleam @@ -4,9 +4,7 @@ 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 RoomControl, type StateControl} import web/handlers/serve.{html_404} @@ -21,7 +19,6 @@ pub fn handle_request( case wisp.path_segments(req) { [] | ["index.html"] -> serve.main_html(fetch_rooms(room_handler)) ["api", "room"] -> handle_room(room_handler, req) - ["api", "room", id, ..path] -> handle_players(room_handler, req, id, path) ["api", ..path] -> handle_admin_api(state_handler, req, path) _ -> html_404() } @@ -37,29 +34,6 @@ fn handle_room(room_handler: Started(Subject(RoomControl)), req: Request) { |> serve.create_json_response } -fn handle_players( - room_handler: Started(Subject(RoomControl)), - req: Request, - id: String, - path: List(String), -) { - use json <- wisp.require_json(req) - - case actor.call(room_handler.data, 1000, message.FetchRoom(id, _)) { - option.Some(#(_, player_handler)) -> - case req.method, path { - http.Post, ["player"] -> add_player(player_handler, json) - _, _ -> #(404, "bad api path", "Resource not found") - } - option.None -> #( - 404, - "Room not found, or key invalid", - "resource not found", - ) - } - |> serve.create_json_response -} - fn handle_admin_api( actor: Started(Subject(StateControl)), req: Request, @@ -117,37 +91,6 @@ fn decode_info( } } -fn add_player( - player_handler: Started(Subject(message.NotifyServer)), - json_string: decode.Dynamic, -) { - let decode_player = { - use name <- decode.field("name", decode.string) - decode.success(name) - } - case decode.run(json_string, decode_player) { - Ok(player) -> { - case actor.call(player_handler.data, 1000, message.AddPlayer(player, _)) { - Ok(id) -> #( - 200, - "Added player with name [" <> player <> "] given id [" <> id <> "]", - json.to_string(json.object([#("id", json.string(id))])), - ) - Error(msg) -> #( - 400, - "Unable to add player [" <> msg <> "]", - "player not added", - ) - } - } - Error(_msg) -> #( - 400, - "Could not parse player [decoding error]", - "bad request", - ) - } -} - fn add_room(room_handler: Started(Subject(RoomControl)), json) { let decode_room = { use id <- decode.field("id", decode.string) diff --git a/shared/src/components.gleam b/shared/src/components.gleam index da520fa..1c6c79a 100644 --- a/shared/src/components.gleam +++ b/shared/src/components.gleam @@ -1,4 +1,3 @@ -import gleam/int import gleam/option.{type Option, None, Some} import lustre/attribute.{class} import lustre/element.{type Element} @@ -7,10 +6,10 @@ import lustre/event pub fn click_cell( tag: Option(String), - id_value_pair: #(String, String), - on_click: fn(String) -> msg, + id: Option(String), + value: String, + on_click: fn(Option(String)) -> msg, ) -> Element(msg) { - let #(id, value) = id_value_pair html.div([class("participant-login"), event.on_click(on_click(id))], [ html.div([class("participant-name")], [ html.text( @@ -24,3 +23,31 @@ pub fn click_cell( ]), ]) } + +pub fn click_cell_pair( + tag: Option(String), + pair: Option(#(String, String)), + on_click: fn(Option(#(String, String))) -> msg, +) -> Element(msg) { + let value = case pair { + Some(pair) -> { + let #(_, value) = pair + value + } + None -> "" + } + html.div([class("participant-login"), event.on_click(on_click(pair))], [ + html.div([class("participant-name")], [ + html.div([], [ + html.text( + "► " + <> case tag { + Some(text) -> "[#" <> text <> "] " + None -> "" + }, + ), + ]), + html.text(value), + ]), + ]) +}