From f3020b7cb068c9c5c380449bc281388c0d1afd21 Mon Sep 17 00:00:00 2001 From: Lett Osprey Date: Mon, 13 Apr 2026 12:17:50 +0200 Subject: [PATCH] single game fix --- client/src/view.gleam | 5 +- server/priv/static/layout.css | 74 +++++++++--- server/src/web/components/answerlist.gleam | 63 ++++------ server/src/web/components/card.gleam | 47 +++++--- server/src/web/components/shared.gleam | 128 ++++++++++++--------- shared/src/components.gleam | 6 +- 6 files changed, 195 insertions(+), 128 deletions(-) diff --git a/client/src/view.gleam b/client/src/view.gleam index c0cf678..75c8ab8 100644 --- a/client/src/view.gleam +++ b/client/src/view.gleam @@ -51,7 +51,7 @@ fn layout(header: String, ohno: option.Option(String), body: List(Element(Msg))) fn view_room_list(items: List(Room)) -> Element(Msg) { layout("Select room to play in", None, case items { - [] -> [html.text("No items in your list yet.")] + [] -> [html.text("No rooms exist, nowhere to play! (ohno!)")] _ -> { list.index_map(items, fn(item, index) { room_cell(index, item, SelectedRoom) @@ -93,7 +93,6 @@ fn view_live_or_single() -> Element(Msg) { ]) } - fn input_cell( header: String, password: Bool, @@ -101,7 +100,7 @@ fn input_cell( ) -> Element(Msg) { html.div([class("participant-login")], [ html.div([class("participant-name")], [ - html.text("► " <> header), + html.p([], [html.text("► " <> header)]), html.div([], [ html.input([ attribute.type_(case password { diff --git a/server/priv/static/layout.css b/server/priv/static/layout.css index 4b04b8f..24b3583 100644 --- a/server/priv/static/layout.css +++ b/server/priv/static/layout.css @@ -26,9 +26,9 @@ body { .terminal-glow { position: relative; text-shadow: - 0 0 5px rgba(0, 255, 0, 0.8), - 0 0 10px rgba(0, 255, 0, 0.5), - 0 0 20px rgba(0, 255, 0, 0.3); + 0 0 5px rgba(0, 255, 0, 0.8), + 0 0 10px rgba(0, 255, 0, 0.5), + 0 0 20px rgba(0, 255, 0, 0.3); animation: flicker 0.15s infinite alternate; } @@ -40,11 +40,11 @@ body { width: 100%; height: 100%; background: repeating-linear-gradient( - 0deg, - rgba(0, 0, 0, 0.15), - rgba(0, 0, 0, 0.15) 1px, - transparent 1px, - transparent 2px + 0deg, + rgba(0, 0, 0, 0.15), + rgba(0, 0, 0, 0.15) 1px, + transparent 1px, + transparent 2px ); pointer-events: none; z-index: 1000; @@ -261,24 +261,64 @@ body { } } -input { - background-color: #000000; +.terminal-input-wrap { + display: flex; + align-items: center; + gap: 0.4ch; +} + +.terminal-input-wrap::before { + content: "$>"; color: #00ff00; - width: 20cap; + font-weight: bold; + text-shadow: 0 0 5px rgba(0, 255, 0, 0.8); + flex-shrink: 0; +} + +input { + background: transparent; + color: #00ff00; + font-family: "Courier New", "Courier", monospace; + font-size: inherit; + border: none; + border-bottom: 2px solid #00aa00; + outline: none; + caret-color: #00ff00; + width: 20ch; + padding: 0 0 2px 0; + text-shadow: 0 0 5px rgba(0, 255, 0, 0.6); +} + + +button { + background: transparent; + color: #00ff00; + font-family: "Courier New", "Courier", monospace; + font-size: inherit; + border: none; + border-bottom: 2px solid #00aa00; + outline: none; + caret-color: #00ff00; + width: 20ch; + padding: 0 0 2px 0; + text-shadow: 0 0 5px rgba(0, 255, 0, 0.6); } input:focus { - background-color: #000000; - width: 20cap; - border-color: #00ff00; - outline: none; + border-bottom-color: #00ff00; + box-shadow: 0 2px 8px rgba(0, 255, 0, 0.3); +} +button:hover { + border-bottom-color: #00ff00; + box-shadow: 0 2px 8px rgba(0, 255, 0, 0.3); } -button { + +/*button { background-color: #000000; color: #00ff00; border: 3px solid #73ad21; -} +}*/ button:focus { border: 3px solid #534d01; diff --git a/server/src/web/components/answerlist.gleam b/server/src/web/components/answerlist.gleam index a7f81ff..ebd317d 100644 --- a/server/src/web/components/answerlist.gleam +++ b/server/src/web/components/answerlist.gleam @@ -1,4 +1,4 @@ -import components.{click_cell, click_cell_pair} +import components.{click_cell_pair} import gleam/bit_array import gleam/crypto import gleam/dynamic/decode @@ -146,7 +146,7 @@ fn view(model: Model) -> Element(Msg) { html.div([class("terminal-status")], [ html.span([class("status-blink")], [html.text("●")]), html.text(" SYSTEM READY"), - html.span([class("ml-8")], [ + html.div([class("ml-8")], [ case model.state { Initial -> html.text("STATUS: Please select player") ReceiveName(_) -> html.text("STATUS: Please enter your name") @@ -163,19 +163,18 @@ fn view(model: Model) -> Element(Msg) { html.text("[ACTIVE TRANSMISSIONS]"), ]), ]), - html.div([class("participants-grid")], [ case model.state { Initial -> case model.players { - [] -> input_new_player() - _ -> view_players(model.players) + [] -> shared.input_new_player(ReceiveName) + _ -> shared.view_players(model.players, PickedPlayer) } PickQuestion -> view_questions(model.answers) - ReceiveName(_) -> input_new_player() + ReceiveName(_) -> shared.input_new_player(ReceiveName) AcceptPlayer(Some(player)) -> { let #(_, player_name) = player - shared.confirm_cell( + shared.confirm_cells( Some("Join as this player: " <> player_name <> "?"), player, AcceptPlayer, @@ -189,26 +188,6 @@ fn view(model: Model) -> Element(Msg) { ]) } -fn view_players(players: List(#(String, String))) { - html.div([], [ - html.div( - [], - list.append( - list.index_map(players, fn(item, index) { - click_cell_pair(Some(int.to_string(index)), Some(item), PickedPlayer) - }), - [click_cell_pair(Some("ENTER NEW PLAYER"), None, PickedPlayer)], - ), - ), - ]) -} - -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")], [ @@ -220,17 +199,25 @@ fn input_new_answer(question: #(String, String)) { } fn view_questions(answers: List(#(String, #(String, String)))) { - html.div( - [class("singles-grid")], - list.map(answers, fn(content) { - let #(number, #(question, answer)) = content - click_cell_pair( - Some(number <> " " <> answer), - Some(#(number, question)), - PickedQuestion, - ) - }), - ) + html.div([], [ + html.div( + [class("singles-grid")], + list.map(answers, fn(content) { + let #(number, #(question, answer)) = content + click_cell_pair( + Some(number <> " " <> answer), + Some(#(number, question)), + True, + PickedQuestion, + ) + }), + ), + html.div([], [ + html.text( + "[Your answers are saved automatically, when you are done answering, simply close the window]", + ), + ]), + ]) } fn content_cell(answer: #(Int, #(String, String))) -> Element(Msg) { diff --git a/server/src/web/components/card.gleam b/server/src/web/components/card.gleam index db71267..4c0d10e 100644 --- a/server/src/web/components/card.gleam +++ b/server/src/web/components/card.gleam @@ -12,7 +12,7 @@ import lustre/element/html import lustre/server_component import shared/message.{type NotifyClient, type NotifyServer, type User, User} import web/components/shared.{ - step_prompt, view_input, view_named_input, view_yes_no, + input_new_player, step_prompt, view_named_input, view_players, } pub fn component() -> lustre.App(message.ClientsServer, Model, Msg) { @@ -29,6 +29,7 @@ type State { pub opaque type Model { Model( state: State, + players: List(#(String, String)), lobby: #(String, List(User)), registry: GroupRegistry(NotifyClient), handler: Started(Subject(NotifyServer)), @@ -38,7 +39,14 @@ pub opaque type Model { fn init(handlers: message.ClientsServer) -> #(Model, Effect(Msg)) { let #(registry, handler) = handlers - let model = Model(AskName, #("", []), registry, handler) + let model = + Model( + AskName, + actor.call(handler.data, 1000, message.FetchPlayers), + #("", []), + registry, + handler, + ) #(model, subscribe(registry, SharedMessage)) } @@ -58,8 +66,8 @@ fn subscribe( pub opaque type Msg { SharedMessage(message: NotifyClient) - ReceiveName(message: String) - AcceptName(accept: Option(String)) + ReceiveName(Option(String)) + AcceptName(Option(#(String, String))) GiveAnswer(name: String, answer: String) } @@ -67,9 +75,13 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { let handler = model.handler case msg { - ReceiveName(name) -> #(Model(..model, state: NameOk(name)), effect.none()) + ReceiveName(Some(name)) -> #( + Model(..model, state: NameOk(name)), + effect.none(), + ) AcceptName(Some(name)) -> { - actor.send(handler.data, message.GiveName(name:)) + let #(_, name) = name + actor.send(handler.data, message.GiveName(name)) #(Model(..model, state: WaitForQuiz(name)), effect.none()) } AcceptName(None) -> #(Model(..model, state: AskName), effect.none()) @@ -81,13 +93,15 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { handle_server_message(model, shared_msg), effect.none(), ) + _ -> #(model, effect.none()) } } fn handle_server_message(model: Model, notify_client) { case notify_client { message.Lobby(question, lobby) -> Model(..model, lobby: #(question, lobby)) - message.Exit -> Model(AskName, #("", []), model.registry, model.handler) + message.Exit -> + Model(AskName, model.players, #("", []), model.registry, model.handler) message.Answer -> case model.state { // We are currently waiting for next quiz question, ok to switch to answer mode @@ -117,20 +131,21 @@ fn handle_server_message(model: Model, notify_client) { fn view(model: Model) -> Element(Msg) { let #(question, lobby) = model.lobby - element.fragment([ html.div([attribute.class("terminal-prompt")], [ case model.state { AskName -> - step_prompt( - "Hello stranger. To join the quiz, I need to know your name", - fn() { view_input(ReceiveName) }, - ) - NameOk(name) -> - step_prompt( - "Your name is " <> name <> "? Are you absolutely sure???", - fn() { view_yes_no(name, AcceptName) }, + case model.players { + [] -> input_new_player(ReceiveName) + _ -> view_players(model.players, AcceptName) + } + NameOk(name) -> { + shared.confirm_cells( + Some("Join as this player: " <> name <> "?"), + #("", name), + AcceptName, ) + } Answer(name) -> step_prompt( "The Quiz Lead will now ask the question, and you may answer.", diff --git a/server/src/web/components/shared.gleam b/server/src/web/components/shared.gleam index e5d3cd9..d6a3206 100644 --- a/server/src/web/components/shared.gleam +++ b/server/src/web/components/shared.gleam @@ -1,4 +1,7 @@ +import components.{click_cell_pair} import gleam/dynamic/decode +import gleam/int +import gleam/list import gleam/option.{type Option, None, Some} import lustre/attribute import lustre/element.{type Element} @@ -49,35 +52,6 @@ pub fn view_input(on_submit handle_keydown: fn(String) -> msg) -> Element(msg) { ) } -pub fn view_yes_no( - accepted: String, - on_submit handle_button: fn(Option(String)) -> msg, -) -> Element(msg) { - html.div([], [ - html.button([event.on_click(handle_button(Some(accepted)))], [ - html.text(" "), - ]), - html.text(" - "), - html.button([event.on_click(handle_button(None))], [html.text(" ")]), - ]) -} - -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"]) -} - fn prompt_input(key, on_keydown) { keyed.div([], [ #(key <> "header", html.text("$>")), @@ -103,35 +77,83 @@ 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.")] -// _ -> { -// list.append( -// list.index_map(items, fn(item, index) { -// click_cell(index, item, SelectedPlayer) -// }), -// [input_cell("[#NEW PLAYER]", False, KeyPin)], -// ) -// } -// }) -// -pub fn confirm_cell( +pub fn confirm_cells( 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.div([], [ + html.div([attribute.class("participant-box")], [ + html.div([attribute.class("participant-name")], [ + case title { + Some(title) -> html.div([], [html.text(title)]) + _ -> element.none() + }, ]), - html.text(" - "), - html.button([event.on_click(handle_button(None))], [html.text(" ")]), + ]), + click_cell_pair(Some("Yes"), Some(accepted), False, handle_button), + click_cell_pair(Some("No"), None, False, handle_button), + ]) +} + +pub fn view_players( + players: List(#(String, String)), + handler: fn(Option(#(String, String))) -> msg, +) { + html.div([], [ + html.div( + [], + list.append( + list.index_map(players, fn(item, index) { + click_cell_pair(Some(int.to_string(index)), Some(item), True, handler) + }), + [click_cell_pair(Some("ENTER NEW PLAYER"), None, True, handler)], + ), + ), + ]) +} + +pub fn input_new_player(handler: fn(Option(String)) -> msg) { + html.div([attribute.class("participant-box")], [ + input_cell("Enter player name:", handler), + ]) +} + +pub 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/shared/src/components.gleam b/shared/src/components.gleam index 1c6c79a..b96c9ad 100644 --- a/shared/src/components.gleam +++ b/shared/src/components.gleam @@ -27,6 +27,7 @@ pub fn click_cell( pub fn click_cell_pair( tag: Option(String), pair: Option(#(String, String)), + display_value: Bool, on_click: fn(Option(#(String, String))) -> msg, ) -> Element(msg) { let value = case pair { @@ -47,7 +48,10 @@ pub fn click_cell_pair( }, ), ]), - html.text(value), + case display_value { + True -> html.text(value) + False -> element.none() + }, ]), ]) }