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 0000000..1ad1b81 Binary files /dev/null and b/screenshot.png differ 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)) +}