Initial commit
This commit is contained in:
parent
c7ff93a1f7
commit
a1e4eb1dff
13 changed files with 917 additions and 2 deletions
31
Dockerfile
Normal file
31
Dockerfile
Normal 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"]
|
||||||
25
README.md
25
README.md
|
|
@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
13
gleam.toml
Normal 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
74
layout.html
Normal 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
33
manifest.toml
Normal 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
69
priv/layout.css
Normal 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
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
91
src/backend/sockethandler.gleam
Normal file
91
src/backend/sockethandler.gleam
Normal 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)
|
||||||
|
}
|
||||||
86
src/backend/statehandler.gleam
Normal file
86
src/backend/statehandler.gleam
Normal 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
239
src/components/chat.gleam
Normal 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)]),
|
||||||
|
])
|
||||||
|
}
|
||||||
112
src/components/control.gleam
Normal file
112
src/components/control.gleam
Normal 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
128
src/quizterm.gleam
Normal 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
18
src/shared/message.gleam
Normal 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))
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue