From a1e4eb1dff5e595b6255322e37feb283b27e030f Mon Sep 17 00:00:00 2001 From: Lett Osprey Date: Sun, 2 Nov 2025 08:58:21 +0100 Subject: [PATCH] Initial commit --- Dockerfile | 31 +++++ README.md | 25 +++- gleam.toml | 13 ++ layout.html | 74 ++++++++++ manifest.toml | 33 +++++ priv/layout.css | 69 +++++++++ screenshot.png | Bin 0 -> 41829 bytes src/backend/sockethandler.gleam | 91 ++++++++++++ src/backend/statehandler.gleam | 86 ++++++++++++ src/components/chat.gleam | 239 ++++++++++++++++++++++++++++++++ src/components/control.gleam | 112 +++++++++++++++ src/quizterm.gleam | 128 +++++++++++++++++ src/shared/message.gleam | 18 +++ 13 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 gleam.toml create mode 100644 layout.html create mode 100644 manifest.toml create mode 100644 priv/layout.css create mode 100644 screenshot.png create mode 100644 src/backend/sockethandler.gleam create mode 100644 src/backend/statehandler.gleam create mode 100644 src/components/chat.gleam create mode 100644 src/components/control.gleam create mode 100644 src/quizterm.gleam create mode 100644 src/shared/message.gleam diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..73c380f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +ARG GLEAM_VERSION=v1.12.0 +# Build stage - compile the application +FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine AS builder + +# Add project code +COPY ./priv /quizterm/priv +COPY ./src /quizterm/src +COPY ./gleam.toml /quizterm/ + +RUN cd /quizterm && gleam deps download + +# Compile the server code +RUN cd /quizterm \ + && gleam export erlang-shipment + +# Runtime stage - slim image with only what's needed to run +FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine + +# Copy the compiled server code from the builder stage +COPY --from=builder /quizterm/build/erlang-shipment /app + +# Set up the entrypoint +WORKDIR /app +RUN echo -e '#!/bin/sh\nexec ./entrypoint.sh "$@"' > ./start.sh \ + && chmod +x ./start.sh + +# Expose the port the server will run on +EXPOSE 1234 + +# Run the server +CMD ["./start.sh", "run"] diff --git a/README.md b/README.md index 216f9d7..74b3c3c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,24 @@ -# quizterm +### Welcome to QUIZTerm -Quizterminal - multi user quiz game. \ No newline at end of file +QUIZTerm is a simple online "quiz answering" game. It provides a way for contestants to provide answers to questions, +and reveal the answers for everyone at the same time. + +Cards showing who are playing, their answer status (have they answered or not?), and when revealed, what their answer +was, will show up on everyones screen. + +Not quite finished yet, it is at a point where it is "usable" enough. + +There are two endpoints to use: +| / |endpoint for "regular" players. +| /control | endpoint for person controlling the quiz. Same interface as for regular players, but with possiblity to control when to reveal answers and when to move on to next question. This gives the possiblity for the person asking the question to also provide answers, but the controls will work even if there is no player joined from this page. + +![Screenshot of the game](screenshot.png) + +Next steps are: +- Display questions. Currently, Quizterm only asks user to provide answer, the actual question needs to be asked +elsewhere. This is often not a problem, since questions are asked on site, or in streamed meetings. +- Make it a little harder to join a quiz. So far, a quiz is open, and anyone that knows the URL can easily join. +A good idea to deploy to a disposable URL. +- Bad handling of players with the same name. If a player register with a name that is already in used, two players +will "compete" about being this player. You need to make sure to register with a different name than those already in +use. As all "in use" names are displayed on your screen, this is somewhat doable. diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..e3286ee --- /dev/null +++ b/gleam.toml @@ -0,0 +1,13 @@ +name = "quizterm" +version = "0.5.0" + +[dependencies] +gleam_erlang = ">= 1.0.0 and < 2.0.0" +gleam_http = ">= 3.7.2 and < 5.0.0" +gleam_json = ">= 2.3.0 and < 4.0.0" +gleam_otp = ">= 1.1.0 and < 2.0.0" +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +mist = ">= 5.0.0 and < 6.0.0" +lustre = ">= 5.3.5 and < 6.0.0" +gleam_crypto = ">= 1.5.1 and < 2.0.0" +group_registry = ">= 1.0.0 and < 2.0.0" diff --git a/layout.html b/layout.html new file mode 100644 index 0000000..f462667 --- /dev/null +++ b/layout.html @@ -0,0 +1,74 @@ + + + + + + +
+

QUIZTerminal

+

What is your name?

+

+
+
+
+ TESTsfdfdsk jhfkj dshfkjs skjfdshjs TESTsfdfdsk jhfkj + dshfkjs skjfdshjsTESTsfdfdsk jhfkj dshfkjs skjfdshjs +
+
TEST
+
TEST
+
+ TESTsfdfdsk jhfkj dshfkjs skjfdshjs TESTsfdfdsk jhfkj + dshfkjs skjfdshjsTESTsfdfdsk jhfkj dshfkjs skjfdshjs +
+
TEST
+
+ TESTsfdfdsk jhfkj dshfkjs skjfdshjs TESTsfdfdsk jhfkj + dshfkjs skjfdshjsTESTsfdfdsk jhfkj dshfkjs skjfdshjs +
+
TEST
+
TEST
+
+ + + diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..fda893a --- /dev/null +++ b/manifest.toml @@ -0,0 +1,33 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_http", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "DD0271B32C356FB684EC7E9F48B1E835D0480168848581F68983C0CC371405D4" }, + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_otp", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7987CBEBC8060B88F14575DEF546253F3116EBE2A5DA6FD82F38243FCE97C54B" }, + { name = "gleam_stdlib", version = "0.63.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "E1D5EC07638F606E48F0EA1556044DD805F2ACE9092A6F6AFBE4A0CC4DA21C2F" }, + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, + { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, + { name = "lustre", version = "5.3.5", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "5CBB5DD2849D8316A2101792FC35AEB58CE4B151451044A9C2A2A70A2F7FCEB8" }, + { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, +] + +[requirements] +gleam_crypto = { version = ">= 1.5.1 and < 2.0.0" } +gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } +gleam_http = { version = ">= 3.7.2 and < 5.0.0" } +gleam_json = { version = ">= 2.3.0 and < 4.0.0" } +gleam_otp = { version = ">= 1.1.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +group_registry = { version = ">= 1.0.0 and < 2.0.0" } +lustre = { version = ">= 5.3.5 and < 6.0.0" } +mist = { version = ">= 5.0.0 and < 6.0.0" } diff --git a/priv/layout.css b/priv/layout.css new file mode 100644 index 0000000..bfbd3d8 --- /dev/null +++ b/priv/layout.css @@ -0,0 +1,69 @@ +body { + background-color: #000000; + color: #73ad21; +} +.center { + margin: auto; + width: 50%; + border: 3px solid #73ad21; + padding: 10px; +} +.under { + margin: auto; + cursor: default; + margin-bottom: 8px; + display: grid; + width: 50%; + grid-template-columns: 33% 33% 33%; +} + +.under_cell { + text-align: center; + border: 3px solid #73ad21; + margin: 5px; + width: 95%; + min-height: 75px; +} + +.under_cell_nb { + text-align: center; + margin: 5px; + width: 95%; + min-height: 75px; +} + +.control { + margin: auto; + text-align: center; + cursor: default; + margin-bottom: 8px; + display: grid; + width: 50%; + grid-template-columns: 100% + border: 3px solid #73ad21; + padding: 10px; +} + +input { + background-color: #000000; + color: #00ff00; + width: 20cap; +} + +.controlbutton { + background-color: #000000; + color: #00FF00; + border: 3px solid #73ad21; + width: 100%; +} + +.controlbutton:focus { + border: 3px solid #534d01; +} + +input:focus { + background-color: #000000; + width: 20cap; + border-color: #00ff00; + outline: none; +} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..1ad1b8153afd54e876fd95d6964d04942fe7cdf0 GIT binary patch literal 41829 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 nQ+12wi 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 X=_5A~EiMGkNUoaMUM5`n^|${6fp$P5 literal 0 HcmV?d00001 diff --git a/src/backend/sockethandler.gleam b/src/backend/sockethandler.gleam new file mode 100644 index 0000000..5135a01 --- /dev/null +++ b/src/backend/sockethandler.gleam @@ -0,0 +1,91 @@ +import gleam/erlang/process.{type Selector, type Subject} +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import gleam/json +import gleam/option.{type Option, Some} +import lustre +import lustre/server_component +import mist.{type Connection, type ResponseData} + +pub fn serve( + request: Request(Connection), + component: lustre.App(start_args, model, msg), + start_args: start_args, +) -> Response(ResponseData) { + mist.websocket( + request:, + on_init: init_socket(_, component, start_args), + handler: loop_socket, + on_close: close_socket, + ) +} + +type Socket(msg) { + Socket( + component: lustre.Runtime(msg), + self: Subject(server_component.ClientMessage(msg)), + ) +} + +type SocketMessage(msg) = + server_component.ClientMessage(msg) + +type SocketInit(msg) = + #(Socket(msg), Option(Selector(SocketMessage(msg)))) + +fn init_socket( + _, + component: lustre.App(start_args, model, msg), + start_args: start_args, +) -> SocketInit(msg) { + let assert Ok(component) = + lustre.start_server_component(component, start_args) + + let self = process.new_subject() + let selector = process.new_selector() |> process.select(self) + + server_component.register_subject(self) + |> lustre.send(to: component) + + #(Socket(component:, self:), Some(selector)) +} + +fn loop_socket( + state: Socket(msg), + message: mist.WebsocketMessage(SocketMessage(msg)), + connection: mist.WebsocketConnection, +) -> mist.Next(Socket(msg), SocketMessage(msg)) { + case message { + mist.Text(json) -> { + case json.parse(json, server_component.runtime_message_decoder()) { + Ok(runtime_message) -> lustre.send(state.component, runtime_message) + Error(_) -> Nil + } + + mist.continue(state) + } + + mist.Binary(_) -> { + mist.continue(state) + } + + mist.Custom(client_message) -> { + let json = server_component.client_message_to_json(client_message) + let assert Ok(_) = mist.send_text_frame(connection, json.to_string(json)) + + mist.continue(state) + } + + mist.Closed | mist.Shutdown -> { + server_component.deregister_subject(state.self) + |> lustre.send(to: state.component) + + mist.stop() + } + } +} + +fn close_socket(state: Socket(msg)) -> Nil { + server_component.deregister_subject(state.self) + |> lustre.send(to: state.component) +} diff --git a/src/backend/statehandler.gleam b/src/backend/statehandler.gleam new file mode 100644 index 0000000..6edcd13 --- /dev/null +++ b/src/backend/statehandler.gleam @@ -0,0 +1,86 @@ +import gleam/erlang/process +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/otp/actor +import group_registry.{type GroupRegistry} +import shared/message.{ + type NotifyClient, Answer, AnswerQuiz, Await, GiveAnswer, GiveName, Lobby, + RevealAnswer, User, +} + +type State { + State(name_answers: List(#(String, Option(String))), hide_answers: Bool) +} + +pub fn initialize(registry: GroupRegistry(NotifyClient)) { + actor.new(State([], True)) + |> actor.on_message(fn(state: State, message) { + case message { + GiveName(name) -> { + // Let the new client know the current question state + case state.hide_answers { + True -> broadcast(registry, Answer) + False -> broadcast(registry, Await) + } + State(list.key_set(state.name_answers, name, None), state.hide_answers) + |> broadcast_lobby(registry) + } + GiveAnswer(name, answer) -> { + State( + list.key_set(state.name_answers, name, Some(answer)), + state.hide_answers, + ) + |> broadcast_lobby(registry) + } + AnswerQuiz -> { + broadcast(registry, Answer) + State( + list.map(state.name_answers, fn(user) { + let #(name, _) = user + #(name, None) + }), + True, + ) + |> broadcast_lobby(registry) + } + RevealAnswer -> { + broadcast(registry, Await) + State(state.name_answers, hide_answers: False) + |> broadcast_lobby(registry) + } + } + |> actor.continue() + }) + |> actor.start +} + +fn broadcast_lobby(state: State, registry: GroupRegistry(NotifyClient)) { + broadcast( + registry, + Lobby( + list.map(state.name_answers, fn(name_answer) { + let #(name, answer) = name_answer + User(name, case answer { + Some(answer) -> + Some(case state.hide_answers { + True -> "Answer" + False -> answer + }) + None -> + case state.hide_answers { + True -> None + False -> Some("No answer") + } + }) + }), + ), + ) + + state +} + +fn broadcast(registry: GroupRegistry(msg), msg) -> Nil { + use member <- list.each(group_registry.members(registry, "quiz")) + + process.send(member, msg) +} diff --git a/src/components/chat.gleam b/src/components/chat.gleam new file mode 100644 index 0000000..0a1ef34 --- /dev/null +++ b/src/components/chat.gleam @@ -0,0 +1,239 @@ +import gleam/dynamic/decode +import gleam/erlang/process.{type Subject} +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/otp/actor.{type Started} +import gleam/string +import group_registry.{type GroupRegistry} +import lustre +import lustre/attribute +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, type User, User} + +pub fn component() -> lustre.App( + #(GroupRegistry(NotifyClient), Started(Subject(NotifyServer))), + Model, + Msg, +) { + lustre.application(init, update, view) +} + +type State { + AskName + NameOk(String) + WaitForQuiz(String) + Answer(String) +} + +pub opaque type Model { + Model( + state: State, + lobby: List(User), + registry: GroupRegistry(NotifyClient), + handler: Started(Subject(NotifyServer)), + ) +} + +fn init( + handlers: #(GroupRegistry(NotifyClient), Started(Subject(NotifyServer))), +) -> #(Model, Effect(Msg)) { + let #(registry, handler) = handlers + + let model = Model(AskName, [], registry, handler) + #(model, subscribe(registry, SharedMessage)) +} + +fn subscribe( + registry: GroupRegistry(topic), + on_msg handle_msg: fn(topic) -> msg, +) -> Effect(msg) { + use _, _ <- server_component.select + let subject = group_registry.join(registry, "quiz", process.self()) + + let selector = + process.new_selector() + |> process.select_map(subject, handle_msg) + + selector +} + +pub opaque type Msg { + SharedMessage(message: NotifyClient) + ReceiveName(message: String) + AcceptName(accept: Option(String)) + GiveAnswer(name: String, answer: String) +} + +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + let handler = model.handler + + case msg { + ReceiveName(name) -> { + #(Model(..model, state: NameOk(name)), effect.none()) + } + AcceptName(name) -> { + case name { + Some(name) -> { + actor.send(handler.data, message.GiveName(name:)) + #(Model(..model, state: WaitForQuiz(name)), effect.none()) + } + _ -> #(Model(..model, state: AskName), effect.none()) + } + } + GiveAnswer(name, answer) -> { + actor.send(handler.data, message.GiveAnswer(name, answer)) + #(Model(..model, state: WaitForQuiz(name)), effect.none()) + } + + SharedMessage(message:) -> { + case message { + message.Lobby(lobby) -> #(Model(..model, lobby: lobby), effect.none()) + message.Answer -> + case model.state { + WaitForQuiz(name) -> #( + Model(..model, state: Answer(name)), + effect.none(), + ) + _ -> #(model, effect.none()) + } + message.Await -> + case model.state { + Answer(name) -> #( + Model(..model, state: WaitForQuiz(name)), + effect.none(), + ) + _ -> #(model, effect.none()) + } + } + } + } +} + +fn view(model: Model) -> Element(Msg) { + element.fragment([ + keyed.div([attribute.class("center")], [ + #("header", html.h1([], [html.text("QUIZTerminal")])), + case model.state { + AskName -> #( + "name", + view_input("Enter your name to join the quiz: ", ReceiveName), + ) + NameOk(name) -> { + #( + "accept", + view_accept( + "Are you ok with the name " <> name <> "? (y/n)", + name, + AcceptName, + ), + ) + } + Answer(name) -> { + #( + "answer", + view_named_input("Answer the question: ", name, GiveAnswer), + ) + } + _ -> { + #("history", view_ask_question("Waiting for next question")) + } + }, + ]), + html.div( + [attribute.class("under")], + list.map(model.lobby, fn(user) { + let User(name, answer) = user + let answer = case answer { + None -> "waiting..." + Some(answer) -> answer + } + html.div([attribute.class("under_cell")], [ + html.h3([], [ + html.text(name), + ]), + html.text(answer), + ]) + }), + ), + ]) +} + +fn view_ask_question(question: String) -> Element(msg) { + html.text(question) +} + +fn view_accept( + prompt: String, + accepted: String, + on_submit handle_keydown: fn(Option(String)) -> msg, +) -> Element(msg) { + let on_keydown = + event.on("keydown", { + use value <- decode.field("key", decode.string) + let result = case string.lowercase(value) { + "y" -> Some(accepted) + _ -> None + } + decode.success(handle_keydown(result)) + }) + |> server_component.include(["key"]) + + html.div([], [ + html.text(prompt), + html.input([ + attribute.class("input"), + on_keydown, + attribute.autofocus(True), + ]), + ]) +} + +fn view_input( + text: String, + on_submit handle_keydown: fn(String) -> msg, +) -> Element(msg) { + let on_keydown = + event.on("keydown", { + use key <- decode.field("key", decode.string) + use value <- decode.subfield(["target", "value"], decode.string) + + case key { + "Enter" if value != "" -> decode.success(handle_keydown(value)) + _ -> decode.failure(handle_keydown(""), "") + } + }) + |> server_component.include(["key", "target.value"]) + + html.div([], [ + html.text(text), + html.input([attribute.class("input"), on_keydown, attribute.autofocus(True)]), + ]) +} + +fn view_named_input( + text: String, + name: String, + on_submit handle_keydown: fn(String, String) -> msg, +) -> Element(msg) { + let on_keydown = + event.on("keydown", { + use key <- decode.field("key", decode.string) + use value <- decode.subfield(["target", "value"], decode.string) + + case key { + "Enter" if value != "" -> decode.success(handle_keydown(name, value)) + _ -> decode.failure(handle_keydown("", ""), "") + } + }) + |> server_component.include(["key", "target.value"]) + + html.div([], [ + html.text(text), + html.input([attribute.class("input"), on_keydown, attribute.autofocus(True)]), + ]) +} diff --git a/src/components/control.gleam b/src/components/control.gleam new file mode 100644 index 0000000..11ac002 --- /dev/null +++ b/src/components/control.gleam @@ -0,0 +1,112 @@ +// IMPORTS --------------------------------------------------------------------- +import gleam/dynamic/decode +import gleam/erlang/process.{type Subject} +import gleam/otp/actor.{type Started} +import gleam/pair +import group_registry.{type GroupRegistry} +import lustre +import lustre/attribute +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, AnswerQuiz, RevealAnswer, +} + +pub fn component() -> lustre.App( + #(GroupRegistry(NotifyClient), Started(Subject(NotifyServer))), + Model, + Msg, +) { + lustre.application(init, update, view) +} + +type State { + Quiz + Reveal +} + +pub opaque type Model { + Model( + state: State, + registry: GroupRegistry(NotifyClient), + handler: Started(Subject(NotifyServer)), + ) +} + +pub opaque type Msg { + AnnounceQuiz + AnnounceAnswer + End + SharedMessage(message: message.NotifyClient) +} + +fn init( + handlers: #(GroupRegistry(NotifyClient), Started(Subject(NotifyServer))), +) -> #(Model, Effect(Msg)) { + let #(registry, handler) = handlers + + let model = Model(state: Quiz, registry:, handler:) + #(model, subscribe(pair.first(handlers), SharedMessage)) +} + +fn subscribe( + registry: GroupRegistry(topic), + on_msg handle_msg: fn(topic) -> msg, +) -> Effect(msg) { + use _, _ <- server_component.select + let subject = group_registry.join(registry, "quiz", process.self()) + + let selector = + process.new_selector() + |> process.select_map(subject, handle_msg) + + selector +} + +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + let registry = model.registry + let handler = model.handler + case msg { + AnnounceQuiz -> { + actor.send(handler.data, AnswerQuiz) + #(Model(Quiz, registry:, handler:), effect.none()) + } + AnnounceAnswer -> { + actor.send(handler.data, RevealAnswer) + #(Model(Reveal, registry:, handler:), effect.none()) + } + End -> #(model, effect.none()) + SharedMessage(_) -> #(model, effect.none()) + } +} + +fn view(model: Model) -> Element(Msg) { + case model.state { + Quiz -> { + element.fragment([ + keyed.div([attribute.class("control")], [ + #("reveal", view_input("Reveal answers", AnnounceAnswer)), + ]), + ]) + } + Reveal -> { + element.fragment([ + keyed.div([attribute.class("control")], [ + #("next", view_input("Ask for next answer", AnnounceQuiz)), + ]), + ]) + } + } +} + +fn view_input(text: String, on_submit handle_keydown: msg) -> Element(msg) { + let on_keydown = event.on("click", { decode.success(handle_keydown) }) + + html.button([attribute.class("controlbutton"), on_keydown], [ + html.text(text), + ]) +} diff --git a/src/quizterm.gleam b/src/quizterm.gleam new file mode 100644 index 0000000..918f7fc --- /dev/null +++ b/src/quizterm.gleam @@ -0,0 +1,128 @@ +import backend/sockethandler +import backend/statehandler +import components/chat +import components/control +import gleam/bytes_tree +import gleam/erlang/application +import gleam/erlang/process +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import gleam/option.{None} +import gleam/otp/actor +import gleam/result +import group_registry +import lustre/attribute +import lustre/element +import lustre/element/html.{ + body, div, head, html, img, link, meta, script, title, +} +import lustre/server_component +import mist.{type Connection, type ResponseData} + +pub fn main() { + let name = process.new_name("quiz-registry") + let assert Ok(actor.Started(data: registry, ..)) = group_registry.start(name) + let assert Ok(actor) = statehandler.initialize(registry) + let assert Ok(_) = + fn(request: Request(Connection)) -> Response(ResponseData) { + case request.path_segments(request) { + [] -> serve_html(False) + ["control"] -> serve_html(True) + ["lustre", "runtime.mjs"] -> serve_runtime() + ["static", file] -> serve_static(file) + ["ws"] -> + sockethandler.serve(request, chat.component(), #(registry, actor)) + ["cws"] -> + sockethandler.serve(request, control.component(), #(registry, actor)) + _ -> response.set_body(response.new(404), mist.Bytes(bytes_tree.new())) + } + } + |> mist.new + |> mist.bind("localhost") + |> mist.port(1234) + |> mist.start + + process.sleep_forever() +} + +fn serve_html(control: Bool) -> Response(ResponseData) { + let html = + html([attribute.lang("en")], [ + head([], [ + link([ + attribute.rel("stylesheet"), + attribute.type_("text/css"), + attribute.href("/static/layout.css"), + ]), + meta([attribute.charset("utf-8")]), + meta([ + attribute.name("viewport"), + attribute.content("width=device-width, initial-scale=1"), + ]), + title([], "Quizterm"), + script( + [attribute.type_("module"), attribute.src("/lustre/runtime.mjs")], + "", + ), + ]), + body([], [ + case control { + False -> server_component.element([server_component.route("/ws")], []) + True -> + div([], [ + server_component.element([server_component.route("/ws")], []), + server_component.element([server_component.route("/cws")], []), + ]) + }, + div([attribute.class("under")], [ + div([attribute.class("under_cell_nb")], []), + div([attribute.class("under_cell_nb")], []), + div([attribute.class("under_cell_bn")], [ + img([ + attribute.src("https://gleam.run/images/lucy/lucydebugfail.svg"), + attribute.width(150), + ]), + ]), + ]), + ]), + ]) + |> element.to_document_string_tree + |> bytes_tree.from_string_tree + + response.new(200) + |> response.set_body(mist.Bytes(html)) + |> response.set_header("content-type", "text/html") +} + +fn serve_static(filename: String) { + let assert Ok(priv) = application.priv_directory("quizterm") + let path = priv <> "/" <> filename + mist.send_file(path, offset: 0, limit: None) + |> result.map(fn(file) { + response.new(200) + |> response.set_header("Content-Type", "text/css") + |> response.set_body(file) + }) + |> result.lazy_unwrap(fn() { + response.new(404) + |> response.set_body( + bytes_tree.from_string("Requested resource not found") |> mist.Bytes, + ) + }) +} + +fn serve_runtime() -> Response(ResponseData) { + let assert Ok(lustre_priv) = application.priv_directory("lustre") + let file_path = lustre_priv <> "/static/lustre-server-component.mjs" + + case mist.send_file(file_path, offset: 0, limit: None) { + Ok(file) -> + response.new(200) + |> response.prepend_header("content-type", "application/javascript") + |> response.set_body(file) + + Error(_) -> + response.new(404) + |> response.set_body(mist.Bytes(bytes_tree.new())) + } +} diff --git a/src/shared/message.gleam b/src/shared/message.gleam new file mode 100644 index 0000000..2968521 --- /dev/null +++ b/src/shared/message.gleam @@ -0,0 +1,18 @@ +import gleam/option.{type Option} + +pub type NotifyServer { + AnswerQuiz + RevealAnswer + GiveName(name: String) + GiveAnswer(name: String, answer: String) +} + +pub type NotifyClient { + Lobby(names: List(User)) + Answer + Await +} + +pub type User { + User(name: String, answer: Option(String)) +}