Initial commit

This commit is contained in:
Lett Osprey 2025-11-02 08:58:21 +01:00
parent c7ff93a1f7
commit a1e4eb1dff
13 changed files with 917 additions and 2 deletions

31
Dockerfile Normal file
View file

@ -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"]

View file

@ -1,3 +1,24 @@
# quizterm
### Welcome to QUIZTerm
Quizterminal - multi user quiz game.
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.

13
gleam.toml Normal file
View file

@ -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"

74
layout.html Normal file
View file

@ -0,0 +1,74 @@
<!doctype html>
<html>
<head>
<style>
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 div {
border: 3px solid #73ad21;
margin: 5px;
width: 95%;
}
#fname {
background-color: #000000;
color: #00FF00;
width: 20cap;
}
#fname:focus {
background-color: #000000;
width: 20cap;
border-color: #00FF00;
outline: none;
}
</style>
</head>
<body onload="setFocusToTextBox()" onfocus="setFocusToTextBox()">
<div class="center">
<h1>QUIZTerminal</h1>
<p>What is your name? <span id="demo"></span><input onblur="setFocusToTextBox()" type="text" id="fname""></p>
<p></p>
</div>
<div class="under">
<div class="card">
TESTsfdfdsk jhfkj dshfkjs skjfdshjs TESTsfdfdsk jhfkj
dshfkjs skjfdshjsTESTsfdfdsk jhfkj dshfkjs skjfdshjs
</div>
<div class="card">TEST</div>
<div class="card">TEST</div>
<div class="card">
TESTsfdfdsk jhfkj dshfkjs skjfdshjs TESTsfdfdsk jhfkj
dshfkjs skjfdshjsTESTsfdfdsk jhfkj dshfkjs skjfdshjs
</div>
<div class="card">TEST</div>
<div class="card">
TESTsfdfdsk jhfkj dshfkjs skjfdshjs TESTsfdfdsk jhfkj
dshfkjs skjfdshjsTESTsfdfdsk jhfkj dshfkjs skjfdshjs
</div>
<div class="card">TEST</div>
<div class="card">TEST</div>
</div>
<script>
function setFocusToTextBox() {
document.getElementById("fname").focus()
}
</script>
</body>
</html>

33
manifest.toml Normal file
View file

@ -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" }

69
priv/layout.css Normal file
View file

@ -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;
}

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

@ -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)
}

View file

@ -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)
}

239
src/components/chat.gleam Normal file
View file

@ -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)]),
])
}

View file

@ -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),
])
}

128
src/quizterm.gleam Normal file
View file

@ -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()))
}
}

18
src/shared/message.gleam Normal file
View file

@ -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))
}