First bits of pure lustre-javascript for user login session

This commit is contained in:
Lett Osprey 2026-03-29 13:53:16 +02:00
parent a1e4eb1dff
commit e6851255dc
41 changed files with 8413 additions and 733 deletions

20
.gitignore vendored
View file

@ -1,19 +1 @@
# ---> Erlang localbuild.sh
.eunit
*.o
*.beam
*.plt
erl_crash.dump
.concrete/DEV_MODE
# rebar 2.x
.rebar
rel/example_project
ebin/*.beam
deps
# rebar 3
.rebar3
_build/
_checkouts/

View file

@ -1,23 +1,23 @@
ARG GLEAM_VERSION=v1.12.0 ARG GLEAM_VERSION=v1.15.0
# Build stage - compile the application # Build stage - compile the application
FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine AS builder FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine AS builder
# Add project code # Add project code
COPY ./priv /quizterm/priv COPY ./server/priv /quizterm/server/priv
COPY ./src /quizterm/src COPY ./server/src /quizterm/server/src
COPY ./gleam.toml /quizterm/ COPY ./server/gleam.toml /quizterm/server/
RUN cd /quizterm && gleam deps download RUN cd /quizterm/server && gleam deps download
# Compile the server code # Compile the server code
RUN cd /quizterm \ RUN cd /quizterm/server \
&& gleam export erlang-shipment && gleam export erlang-shipment
# Runtime stage - slim image with only what's needed to run # Runtime stage - slim image with only what's needed to run
FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine
# Copy the compiled server code from the builder stage # Copy the compiled server code from the builder stage
COPY --from=builder /quizterm/build/erlang-shipment /app COPY --from=builder /quizterm/server/build/erlang-shipment /app
# Set up the entrypoint # Set up the entrypoint
WORKDIR /app WORKDIR /app

View file

@ -8,17 +8,39 @@ was, will show up on everyones screen.
Not quite finished yet, it is at a point where it is "usable" enough. Not quite finished yet, it is at a point where it is "usable" enough.
There are two endpoints to use: Endpoints explained
| / |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) | Endpoint | Usage |
|--------------------------|--------------------------------------------------------------|
| /room/<room_id> | Create room with given room_id (max 200 rooms) |
| /board/<room_id> | Join a game with the given room_id |
| /board/<room_id>/control | Join a game with the given room_id with more control options |
Next steps are:
- Display questions. Currently, Quizterm only asks user to provide answer, the actual question needs to be asked | Ingame example | Idle player |
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. | ![Screenshot](game1.png) | ![Screenshot](game2.png) |
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 ### Building and running
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. Docker, or a compatible container manager, like podman, is required to build and run
quizterm. The alternative is to install Gleam and Erlang/BEAM and run it dockerless.
Unless you plan to do Gleam development, using Docker will save a lot of hassle.
To compile project and build docker image, write:
```
docker build . -t quizterm:1
```
quizterm can be whatever name you want to give the container, 1 can be
changed to whatever you want the version of the container to be.
Start server on port 4321:
```
docker run -p 4321:1234 quizterm:1
```
Port 1234 is the port used internally in the docker container, while 4321
is the port exposed outside the container. The latter can be set to whatever
port you want to use.
Open web browser and access http://localhost:4321

5
api-test/api-test.sh Normal file
View file

@ -0,0 +1,5 @@
cat test.json | curl --json @- http://localhost:1234/api/info
echo ""
echo ""

2
api-test/docker-down.sh Normal file
View file

@ -0,0 +1,2 @@
docker stop do_integration_test
docker rm do_integration_test

4
api-test/docker-up.sh Normal file
View file

@ -0,0 +1,4 @@
docker build . -t quizterm:1
docker run --name do_integration_test -d -p 4321:1234 quizterm:1
sleep 2

3
api-test/run.sh Normal file
View file

@ -0,0 +1,3 @@
sh docker-up
sh api-test
sh docker-down

8
api-test/test.json Normal file
View file

@ -0,0 +1,8 @@
{
"answers":
[
{"question" : 14, "answer": "what is the meaning of the question that is number what is the meaning of the question that is numberwhat is the meaning of the question that is numberwhat is the meaning of the question that is number"}
]
}

6344
client/dist/client.js vendored Normal file

File diff suppressed because it is too large Load diff

17
client/dist/index.html vendored Normal file
View file

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport">
<title>
client
</title>
<script src="/client.js" type="module"></script>
</head>
<body><div id="app"></div></body>
</html>

15
client/gleam.toml Normal file
View file

@ -0,0 +1,15 @@
name = "client"
version = "1.0.0"
target = "javascript"
[dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
lustre = ">= 5.6.0 and < 6.0.0"
rsvp = ">= 1.2.0 and < 2.0.0"
gleam_json = ">= 3.1.0 and < 4.0.0"
gleam_http = ">= 4.3.0 and < 5.0.0"
plinth = ">= 0.10.2 and < 1.0.0"
[dev_dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
lustre_dev_tools = ">= 2.3.5 and < 3.0.0"

57
client/manifest.toml Normal file
View file

@ -0,0 +1,57 @@
# This file was generated by Gleam
# You typically do not need to edit this file
packages = [
{ name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
{ name = "booklet", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "booklet", source = "hex", outer_checksum = "08E0FDB78DC4D8A5D3C80295B021505C7D2A2E7B6C6D5EAB7286C36F4A53C851" },
{ name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },
{ name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" },
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
{ name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" },
{ name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" },
{ 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_fetch", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "A8FEB5FC4F9C4C72A71BA0D7AC249CF3AE4E98A4123607A5077D8C0B8ECC5A40" },
{ name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
{ name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" },
{ name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" },
{ name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
{ name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" },
{ name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" },
{ name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
{ name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" },
{ name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" },
{ 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 = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" },
{ name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
{ name = "lustre", version = "5.6.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "EE558CD4DB9F09FCC16417ADF0183A3C2DAC3E4B21ED3AC0CAE859792AB810CA" },
{ name = "lustre_dev_tools", version = "2.3.5", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "0B376F4057AB6E0FB5D48DFED9943D48571968D132EBD67EAED0A21EA29027FE" },
{ name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
{ name = "mist", version = "5.0.4", 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 = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" },
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
{ name = "plinth", version = "0.10.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "3FE77CED3F19D70918EE32CE8BFB12BE1C28CA004D997F874C2D8DAD2DB73D87" },
{ name = "polly", version = "3.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_erlang", "gleam_otp", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "51FB565D81FF6212FDF3306D44419601F2A7C4EDD1F00FC9DA5C376A00AED4FE" },
{ name = "rsvp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "40F9E0E662FF258E10C7041A9591261FE802D56625FB444B91510969644F7722" },
{ name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" },
{ name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" },
{ name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" },
{ name = "tom", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "234A842F3D087D35737483F5DFB6DE9839E3366EF0CAF8726D2D094210227670" },
{ name = "wisp", version = "2.2.1", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "6E37308F4F8C45B5D3F2A2DC71BDE4DA6AD2F7978D26A7C8D6790CE275C8614E" },
]
[requirements]
gleam_http = { version = ">= 4.3.0 and < 5.0.0" }
gleam_json = { version = ">= 3.1.0 and < 4.0.0" }
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
lustre = { version = ">= 5.6.0 and < 6.0.0" }
lustre_dev_tools = { version = ">= 2.3.5 and < 3.0.0" }
plinth = { version = ">= 0.10.2 and < 1.0.0" }
rsvp = { version = ">= 1.2.0 and < 2.0.0" }

83
client/src/client.gleam Normal file
View file

@ -0,0 +1,83 @@
import gleam/json
import gleam/list
import gleam/result
import lustre
import lustre/attribute
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import plinth/browser/document
import plinth/browser/element as plinth_element
import shared
pub fn main() {
let initial_items =
document.query_selector("#model")
|> result.map(plinth_element.inner_text)
|> result.try(fn(json) {
json.parse(json, shared.grocery_list_decoder())
|> result.replace_error(Nil)
})
|> result.unwrap([])
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", initial_items)
Nil
}
type Model {
Model(rooms: List(String), name: String)
}
fn init(items: List(String)) -> #(Model, Effect(Msg)) {
let model = Model(rooms: items, name: "")
#(model, effect.none())
}
type Msg {
UserTypedNewItem(String)
UserAddedItem
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
UserAddedItem -> {
case model.name {
"" -> #(model, effect.none())
name -> {
let updated_items = [name, ..model.rooms]
#(Model(rooms: updated_items, name: ""), effect.none())
}
}
}
UserTypedNewItem(text) -> #(Model(..model, name: text), effect.none())
}
}
fn view(model: Model) -> Element(Msg) {
let styles = [
#("max-width", "30ch"),
#("margin", "0 auto"),
#("display", "flex"),
#("flex-direction", "column"),
#("gap", "1em"),
]
html.div([attribute.styles(styles)], [
html.h1([], [html.text("Select your QuizRoom")]),
view_room_list(model.rooms),
])
}
fn view_room_list(items: List(String)) -> Element(Msg) {
case items {
[] -> html.p([], [html.text("No items in your list yet.")])
_ -> {
html.ul([], list.map(items, fn(item) { html.li([], [html.text(item)]) }))
}
}
}

19
client/src/shared.gleam Normal file
View file

@ -0,0 +1,19 @@
import gleam/dynamic/decode
import gleam/json
pub type GroceryItem {
GroceryItem(name: String, quantity: Int)
}
pub fn grocery_list_decoder() -> decode.Decoder(List(String)) {
decode.list(decode.string)
}
fn grocery_item_to_json(grocery_item: GroceryItem) -> json.Json {
let GroceryItem(name:, quantity:) = grocery_item
json.object([#("name", json.string(name)), #("quantity", json.int(quantity))])
}
pub fn grocery_list_to_json(items: List(GroceryItem)) -> json.Json {
json.array(items, grocery_item_to_json)
}

BIN
game1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
game2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View file

@ -1,74 +0,0 @@
<!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>

View file

@ -1,33 +0,0 @@
# 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" }

View file

@ -1,69 +0,0 @@
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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View file

@ -1,5 +1,5 @@
name = "quizterm" name = "quizterm"
version = "0.5.0" version = "1.0.0"
[dependencies] [dependencies]
gleam_erlang = ">= 1.0.0 and < 2.0.0" gleam_erlang = ">= 1.0.0 and < 2.0.0"
@ -7,7 +7,8 @@ gleam_http = ">= 3.7.2 and < 5.0.0"
gleam_json = ">= 2.3.0 and < 4.0.0" gleam_json = ">= 2.3.0 and < 4.0.0"
gleam_otp = ">= 1.1.0 and < 2.0.0" gleam_otp = ">= 1.1.0 and < 2.0.0"
gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0"
mist = ">= 5.0.0 and < 6.0.0" mist = ">= 6.0.0 and < 7.0.0"
lustre = ">= 5.3.5 and < 6.0.0" lustre = ">= 5.3.5 and < 6.0.0"
gleam_crypto = ">= 1.5.1 and < 2.0.0" gleam_crypto = ">= 1.5.1 and < 2.0.0"
group_registry = ">= 1.0.0 and < 2.0.0" group_registry = ">= 1.0.0 and < 2.0.0"
wisp = ">= 2.2.1 and < 3.0.0"

39
server/manifest.toml Normal file
View file

@ -0,0 +1,39 @@
# This file was generated by Gleam
# You typically do not need to edit this file
packages = [
{ name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },
{ name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" },
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
{ 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.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
{ name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
{ name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" },
{ name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" },
{ 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.6.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "EE558CD4DB9F09FCC16417ADF0183A3C2DAC3E4B21ED3AC0CAE859792AB810CA" },
{ name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
{ name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "6B03DEEA38A02F276333CB27B53B16D3D45BD741B89599085A601BAF635F2006" },
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
{ name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" },
{ name = "wisp", version = "2.2.2", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" },
]
[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 = ">= 6.0.0 and < 7.0.0" }
wisp = { version = ">= 2.2.1 and < 3.0.0" }

View file

@ -0,0 +1,276 @@
/* Reset and Base Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: #000;
color: #00ff00;
font-family: "Courier New", "Courier", monospace;
overflow-x: hidden;
height: 100vh;
}
/* Terminal Screen Container */
.terminal-screen {
min-height: 100vh;
background: #000;
padding: 2rem;
padding-bottom: 5rem; /* Space for prompt */
position: relative;
}
/* CRT Glow Effect */
.terminal-glow {
position: relative;
text-shadow:
0 0 5px rgba(0, 255, 0, 0.8),
0 0 10px rgba(0, 255, 0, 0.5),
0 0 20px rgba(0, 255, 0, 0.3);
animation: flicker 0.15s infinite alternate;
}
/* Scanlines Effect */
.scanlines {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 1000;
opacity: 0.3;
}
/* Terminal Header */
.terminal-header {
text-align: center;
margin-bottom: 3rem;
position: relative;
z-index: 1;
}
.terminal-title {
font-size: 1.25rem;
line-height: 1.6;
letter-spacing: 0.05em;
color: #00ff00;
margin-bottom: 1rem;
white-space: pre-wrap;
display: inline-block;
text-align: left;
}
.terminal-status {
font-size: 0.875rem;
margin-top: 1rem;
color: #00aa00;
}
.status-blink {
animation: blink 1s infinite;
color: #00ff00;
}
.mb-4 {
margin-bottom: 1rem;
}
/* Terminal Sections */
.terminal-section {
margin-bottom: 2rem;
position: relative;
z-index: 1;
}
.terminal-box {
border: 2px solid #00ff00;
padding: 1rem;
text-align: center;
max-width: 500px;
margin: 0 auto;
}
.terminal-label {
color: #00ff00;
font-weight: bold;
display: inline-block;
}
/* Participants Grid */
.participants-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.participant-box {
border: 2px solid #00ff00;
padding: 1rem;
transition: all 0.2s;
}
.participant-hidden {
border: 0px dashed #005500;
padding: 1rem;
color: #000000;
transition: all 0.2s;
}
.participant-disconnect {
border: 1px dashed #005500;
padding: 1rem;
transition: all 0.2s;
}
.participant-box:hover {
background: rgba(0, 255, 0, 0.05);
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
}
.participant-name {
color: #00ff00;
font-weight: bold;
margin-bottom: 0.5rem;
font-size: 1rem;
}
.participant-answer {
color: #00aa00;
font-size: 0.875rem;
}
.dim {
color: #006600;
}
/* Terminal Prompt */
.terminal-prompt {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #000;
border-top: 2px solid #00ff00;
padding: 1rem 2rem;
z-index: 100;
}
.prompt-line {
font-size: 1.125rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.prompt-symbol {
color: #00ff00;
font-weight: bold;
}
.prompt-text {
color: #00ff00;
}
/* Blinking Cursor */
.cursor-blink {
animation: blink 1s infinite;
color: #00ff00;
font-weight: bold;
}
/* Animations */
@keyframes blink {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
@keyframes flicker {
0% {
opacity: 0.98;
}
100% {
opacity: 1;
}
}
/* Responsive */
@media (max-width: 768px) {
.terminal-screen {
padding: 1rem;
padding-bottom: 4rem;
}
.terminal-title {
font-size: 0.875rem;
}
.participants-grid {
grid-template-columns: 1fr;
}
.terminal-prompt {
padding: 0.75rem 1rem;
}
.prompt-line {
font-size: 0.875rem;
}
.ml-8 {
margin-left: 0;
display: block;
margin-top: 0.5rem;
}
}
input {
background-color: #000000;
color: #00ff00;
width: 20cap;
}
input:focus {
background-color: #000000;
width: 20cap;
border-color: #00ff00;
outline: none;
}
button {
background-color: #000000;
color: #00ff00;
border: 3px solid #73ad21;
}
button:focus {
border: 3px solid #534d01;
}
.controlbutton {
background-color: #000000;
color: #00ff00;
border: 3px solid #73ad21;
width: 100%;
}
.controlbutton:focus {
border: 3px solid #534d01;
}

View file

@ -0,0 +1,31 @@
<html>
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>QUIZTERMINAL v1.0</title>
<script src="/lustre/runtime.mjs" type="module"></script>
<script src="/client.js" type="module"></script>
<script id="model" type="application/json">
[
"a",
"b",
"c"
]
</script>
<link href="/static/layout.css" rel="stylesheet" type="text/css">
</head>
<body>
<div class="terminal-screen">
<div class="terminal-glow">
<div class="scanlines"></div>
<div class="terminal-header"><pre class="terminal-title">
╔═══════════════════════════════════════╗
║ Q U I Z T E R M I N A L ║
╚═══════════════════════════════════════╝
</pre>
</div>
<div id="app"></div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,262 @@
import gleam/erlang/process.{type Subject}
import gleam/int
import gleam/list
import gleam/option.{None, Some}
import gleam/otp/actor
import group_registry.{type GroupRegistry}
import shared/message.{
type AnswerStatus, type NotifyClient, type StateControl, Answer, AnswerQuiz,
Await, GiveAnswer, GiveName, GiveSingleAnswer, GivenAnswer, HasAnswered,
IDontKnow, Lobby, NotAnswered, PingTime, Pong, PurgePlayers, RevealAnswer,
User,
}
type State {
State(
question_number: Int,
// int in #pair: answer number
slow_answers: List(#(String, List(#(Int, String)))),
// int in #pair: ping counted since response back.
name_answers: List(#(String, #(Int, AnswerStatus))),
hide_answers: Bool,
question: option.Option(String),
state_handler: actor.Started(Subject(StateControl)),
)
}
pub fn initialize(
state_handler: actor.Started(Subject(StateControl)),
registry: GroupRegistry(NotifyClient),
) {
actor.new(State(1, [], [], True, None, state_handler))
|> actor.on_message(fn(state: State, message) {
let question = case state.question {
None -> {
case
actor.call(state_handler.data, 1000, message.FetchQuestion(
state.question_number,
_,
))
{
Some(question) -> question
None -> "(no question text found)"
}
}
Some(question) -> question
}
let state = State(..state, question: Some(question))
case message {
// Ask all the clients to let us know they are still there by sending a Pong with their name. Schedule
// a new ping as well. Count unacced pings per client
PingTime(sender) -> ping(state, registry, sender)
// A client has responded to the ping with a pong. Reset the unacced ping count
Pong(name) -> pong(state, name)
// (Controller) client asks to remove all players from the board
PurgePlayers -> purge_players(state, registry)
// A new player has signed up, put their name in the registry
GiveName(name) -> give_name(state, registry, name)
// A player has answered a question, put it in their state. If every player has answered, signal
// to reveal answers (live game)
GiveAnswer(name, answer) -> give_answer(state, registry, name, answer)
// A player has answered a question in "single" game. Register the answer.
GiveSingleAnswer(name, question, answer) -> {
State(
..state,
slow_answers: case list.key_find(state.slow_answers, name) {
Ok(l) -> {
list.key_set(
state.slow_answers,
name,
list.key_set(l, question, answer),
)
}
Error(_) -> {
list.key_set(state.slow_answers, name, [#(question, answer)])
}
},
)
}
// Reveal all answers given by players, setting the game in a "wait for next question" mode
RevealAnswer -> revel_answers(state, registry)
// Switch from "Wait for next question" to "Answer next question" mode
AnswerQuiz -> answer_quiz(state, registry)
}
|> actor.continue()
})
|> actor.start
}
// Reschedule a new ping request, and ask clients to ping us back
fn ping(state, registry, sender) {
broadcast(registry, message.Ping)
process.send_after(sender, 500, message.PingTime(sender))
State(
..state,
// Increase ping count with one,
// filter away users with more than 4 missed pings first.
name_answers: list.map(
list.filter(state.name_answers, fn(user) {
let #(_, #(count, _)) = user
count < 8
}),
fn(user) {
let #(name, #(count, stat)) = user
#(name, #(count + 1, stat))
},
),
)
|> broadcast_lobby(registry)
}
fn give_answer(state, registry, name, answer) {
let state =
State(
..state,
name_answers: list.key_set(
state.name_answers,
name,
#(0, case answer {
Some("?") -> IDontKnow
Some(answer) -> GivenAnswer(answer)
None -> IDontKnow
}),
),
)
// Check if everyone has answered, if so, reveal answer.
case
list.filter(state.name_answers, fn(x) {
case x {
#(_, #(_, message.NotAnswered)) -> True
_ -> False
}
})
|> list.length
{
0 -> {
broadcast(registry, Await)
State(..state, hide_answers: False)
}
_ -> state
}
|> broadcast_lobby(registry)
}
fn give_name(state: State, registry, name) {
// Let the new client (and everyone else) know the current question state
case state.hide_answers {
True -> broadcast(registry, Answer)
False -> broadcast(registry, Await)
}
// Add the new user to lobby, and broadcast lobby
State(
..state,
name_answers: list.key_set(state.name_answers, name, #(0, NotAnswered)),
)
|> broadcast_lobby(registry)
}
fn answer_quiz(state, registry) {
// Tell the clients to switch to "answer quiz" mode
broadcast(registry, Answer)
State(
..state,
name_answers: list.map(state.name_answers, fn(user) {
let #(name, #(count, _)) = user
#(name, #(count, NotAnswered))
}),
question: None,
question_number: state.question_number + 1,
hide_answers: True,
)
|> broadcast_lobby(registry)
}
fn purge_players(state: State, registry) {
broadcast(registry, message.Exit)
State(1, [], [], True, None, state.state_handler)
|> broadcast_lobby(registry)
}
fn revel_answers(state, registry) {
// Tell the clients to switch to "view answers" mode
broadcast(registry, Await)
State(..state, hide_answers: False)
|> broadcast_lobby(registry)
}
fn pong(state: State, name) {
// Reset ping count
case list.key_find(state.name_answers, name) {
Ok(#(_, answer)) ->
State(
..state,
name_answers: list.key_set(state.name_answers, name, #(0, answer)),
)
Error(_) -> state
}
}
// Combine the active player answers with the answers given by the "single" player.
fn combine_lists(state: State) {
list.append(
list.map(state.name_answers, fn(name_answer) {
let #(name, #(ping_time, answer)) = name_answer
User(name, ping_time, case answer, state.hide_answers {
GivenAnswer(_), True -> HasAnswered
GivenAnswer(answer), False -> GivenAnswer(answer)
other, _ -> other
})
}),
// Second list require a bit more work Iterate over each payers answers,
// creating user objects where question number match current question number.
list.flat_map(state.slow_answers, fn(name_answers) {
let #(name, answers) = name_answers
list.filter_map(answers, fn(number_answer) {
let #(answer_number, answer) = number_answer
case state.question_number == answer_number {
True -> {
Ok(
User(name, 0, case state.hide_answers {
True -> HasAnswered
False -> GivenAnswer(answer)
}),
)
}
False -> Error("ignore")
}
})
}),
)
}
fn broadcast_lobby(state: State, registry: GroupRegistry(NotifyClient)) {
broadcast(
registry,
Lobby(
"Question "
<> int.to_string(state.question_number)
<> ": "
<> case state.question {
Some(question) -> question
None -> "(question not found)"
},
combine_lists(state),
),
)
state
}
fn broadcast(registry: GroupRegistry(msg), msg) -> Nil {
use member <- list.each(group_registry.members(registry, "quiz"))
process.send(member, msg)
}

View file

@ -0,0 +1,68 @@
import backend/playerhandler as player_handler
import gleam/erlang/process.{type Subject}
import gleam/list
import gleam/option.{Some}
import gleam/otp/actor.{type Started}
import group_registry
import shared/message.{type ClientsServer, type RoomControl, type StateControl}
// Room handler, actor to hold the rooms for the different teams playing.
//
// Reacts to:
// CreateRoom(id) - create room with given ID.
//
// Responds to:
// FetchRoom(id, <subject>) - Fetch room with the given id.
type Room {
Room(questions: List(#(Int, String)), rooms: List(#(String, ClientsServer)))
}
pub fn initialize(state_handler: Started(Subject(StateControl))) {
actor.new(Room([], []))
|> actor.on_message(fn(state: Room, message: RoomControl(ClientsServer)) {
case message {
message.CreateRoom(id:) -> {
case
// Does room already exist?
state.rooms |> list.key_find(id)
{
Error(_) -> {
// Prevent overflowing server with rooms, set max 200
case list.length(state.rooms) < 200 {
True -> {
// Room not found (not really an error case), create it.
let name = process.new_name("quiz-registry" <> id)
let assert Ok(actor.Started(data: registry, ..)) =
group_registry.start(name)
let assert Ok(actor) =
player_handler.initialize(state_handler, registry)
process.send_after(
actor.data,
1000,
message.PingTime(actor.data),
)
Room(..state, rooms: [#(id, #(registry, actor)), ..state.rooms])
}
False -> state
}
}
// Room exists, do nothing.
Ok(_) -> state
}
}
message.FetchRoom(id:, subject:) -> {
case
// Find the room, if it exists
state.rooms |> list.key_find(id)
{
Ok(room) -> actor.send(subject, Some(room))
Error(_) -> actor.send(subject, option.None)
}
state
}
}
|> actor.continue()
})
|> actor.start
}

View file

@ -1,23 +1,63 @@
import gleam/bytes_tree
import gleam/erlang/process.{type Selector, type Subject} import gleam/erlang/process.{type Selector, type Subject}
import gleam/http/request.{type Request} import gleam/http/request.{type Request}
import gleam/http/response.{type Response} import gleam/http/response.{type Response}
import gleam/json import gleam/json
import gleam/option.{type Option, Some} import gleam/option.{type Option, None, Some}
import gleam/otp/actor
import lustre import lustre
import lustre/server_component import lustre/server_component
import mist.{type Connection, type ResponseData} import mist.{type Connection, type ResponseData}
import shared/message
pub fn serve( pub fn serve(
request: Request(Connection), request: Request(Connection),
component: lustre.App(start_args, model, msg), component: lustre.App(start_args, model, msg),
start_args: start_args, id: String,
actor: actor.Started(Subject(message.RoomControl(start_args))),
) -> Response(ResponseData) { ) -> Response(ResponseData) {
let start_args = actor.call(actor.data, 1000, message.FetchRoom(id, _))
case start_args {
Some(start_args) ->
mist.websocket( mist.websocket(
request:, request:,
on_init: init_socket(_, component, start_args), on_init: init_socket(_, component, start_args),
handler: loop_socket, handler: loop_socket,
on_close: close_socket, on_close: close_socket,
) )
None ->
response.new(404)
|> response.set_body(
bytes_tree.from_string("Requested resource not found") |> mist.Bytes,
)
}
}
pub fn serve_slow(
request: Request(Connection),
component: lustre.App(#(List(#(Int, String)), start_args), model, msg),
id: String,
roomhandler: actor.Started(Subject(message.RoomControl(start_args))),
statehandler: actor.Started(Subject(message.StateControl)),
) -> Response(ResponseData) {
let start_args_opt =
actor.call(roomhandler.data, 1000, message.FetchRoom(id, _))
let answer_list = actor.call(statehandler.data, 1000, message.FetchQuestions)
case start_args_opt {
Some(start_args) ->
mist.websocket(
request:,
on_init: init_socket(_, component, #(answer_list, start_args)),
handler: loop_socket,
on_close: close_socket,
)
None ->
response.new(404)
|> response.set_body(
bytes_tree.from_string("Requested resource not found") |> mist.Bytes,
)
}
} }
type Socket(msg) { type Socket(msg) {
@ -30,7 +70,7 @@ type Socket(msg) {
type SocketMessage(msg) = type SocketMessage(msg) =
server_component.ClientMessage(msg) server_component.ClientMessage(msg)
type SocketInit(msg) = pub type SocketInit(msg) =
#(Socket(msg), Option(Selector(SocketMessage(msg)))) #(Socket(msg), Option(Selector(SocketMessage(msg))))
fn init_socket( fn init_socket(
@ -86,6 +126,6 @@ fn loop_socket(
} }
fn close_socket(state: Socket(msg)) -> Nil { fn close_socket(state: Socket(msg)) -> Nil {
server_component.deregister_subject(state.self) lustre.shutdown()
|> lustre.send(to: state.component) |> lustre.send(to: state.component)
} }

View file

@ -0,0 +1,75 @@
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/otp/actor
import shared/message.{type StateControl, SetQuestion}
type State {
State(uri: Option(String), questions: List(#(Int, #(String, String))))
}
pub fn initialize() {
actor.new(State(None, []))
|> actor.on_message(fn(state: State, message: StateControl) {
case message {
SetQuestion(id:, question:) if id >= 0 && id <= 14 -> {
case list.key_find(state.questions, id) {
Ok(#(_, answer)) ->
State(
..state,
questions: list.key_set(state.questions, id, #(question, answer)),
)
Error(_) ->
State(
..state,
questions: list.key_set(state.questions, id, #(
question,
"not provided",
)),
)
}
}
message.SetAnswer(id:, answer:) if id >= 0 && id <= 14 ->
case list.key_find(state.questions, id) {
Ok(#(question, _)) ->
State(
..state,
questions: list.key_set(state.questions, id, #(question, answer)),
)
Error(_) ->
State(
..state,
questions: list.key_set(state.questions, id, #(
"not provided",
answer,
)),
)
}
// Ignore requests for questions/answers not between 1 and 14.
message.SetQuestion(_, _) | message.SetAnswer(_, _) -> state
message.FetchQuestion(id:, subject:) -> {
case
// Find the room, if it exists
list.key_find(state.questions, id)
{
Ok(#(question, _)) -> actor.send(subject, Some(question))
Error(_) -> actor.send(subject, option.None)
}
state
}
message.SetInfo(uri) -> State(..state, uri: Some(uri))
message.FetchQuestions(subject) -> {
actor.send(
subject,
list.map(state.questions, fn(x) {
let #(i, #(q, _)) = x
#(i, q)
}),
)
state
}
}
|> actor.continue()
})
|> actor.start
}

103
server/src/quizterm.gleam Normal file
View file

@ -0,0 +1,103 @@
import backend/roomhandler
import backend/sockethandler
import backend/statehandler
import gleam/bytes_tree
import gleam/erlang/application
import gleam/erlang/process
import gleam/http/request
import gleam/http/response.{type Response}
import gleam/list
import gleam/option.{None}
import gleam/result
import gleam/string
import mist.{type ResponseData, File}
import web/components/answerlist
import web/components/card
import web/components/control
import web/router
import wisp
import wisp/wisp_mist
pub fn main() {
wisp.configure_logger()
let assert Ok(state_handler) = statehandler.initialize()
let assert Ok(room_handler) = roomhandler.initialize(state_handler)
let assert Ok(_) =
fn(req) {
case request.path_segments(req) {
["lustre", "runtime.mjs"] -> serve_runtime()
[] | ["index.html"]-> serve_static("root.html")
["client.js"] -> serve_static("client.js")
["static", file] -> serve_static(file)
["socket", "card", id] ->
sockethandler.serve(req, card.component(), id, room_handler)
["socket", "control", id] ->
sockethandler.serve(req, control.component(), id, room_handler)
["socket", "slow", id] ->
sockethandler.serve_slow(
req,
answerlist.component(),
id,
room_handler,
state_handler,
)
_ ->
wisp_mist.handler(
router.handle_request(room_handler, state_handler, _),
"very_secret",
)(req)
}
}
|> mist.new
|> mist.bind("0.0.0.0")
|> mist.port(1234)
|> mist.start
process.sleep_forever()
}
fn serve_static(filename: String) {
let assert Ok(priv) = application.priv_directory("quizterm")
let surname = string.split(filename, ".") |> list.last
let path = priv <> "/static/" <> filename
let data =
mist.send_file(path, offset: 0, limit: None)
|> result.map(fn(file) {
echo "SUCCESS " <> filename
response.new(200)
|> response.set_header("Content-Type", case surname {
Ok("css") -> "text/css"
Ok("js") -> "application/javascript"
Ok(_) | Error(_) -> "text/html"
})
|> response.set_body(file)
})
|> result.lazy_unwrap(fn() {
echo "FAIL " <> filename
response.new(404)
|> response.set_body(
bytes_tree.from_string("Requested resource not found") |> mist.Bytes,
)
})
echo "Attempting to serve file " <> filename <> " was "
data
}
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()))
}
}

View file

@ -0,0 +1,50 @@
import gleam/erlang/process.{type Subject}
import gleam/option.{type Option}
import gleam/otp/actor.{type Started}
import group_registry.{type GroupRegistry}
pub type ClientsServer =
#(GroupRegistry(NotifyClient), Started(Subject(NotifyServer)))
pub type NotifyServer {
PingTime(Subject(NotifyServer))
Pong(name: String)
AnswerQuiz
RevealAnswer
PurgePlayers
GiveName(name: String)
GiveAnswer(name: String, answer: Option(String))
GiveSingleAnswer(name: String, question: Int, answer: String)
}
pub type StateControl {
SetQuestion(id: Int, question: String)
SetAnswer(id: Int, answer: String)
SetInfo(url: String)
FetchQuestion(id: Int, subject: Subject(Option(String)))
FetchQuestions(subject: Subject(List(#(Int, String))))
}
pub type RoomControl(msg) {
CreateRoom(id: String)
FetchRoom(id: String, subject: Subject(Option(msg)))
}
pub type AnswerStatus {
NotAnswered
HasAnswered
IDontKnow
GivenAnswer(answer: String)
}
pub type NotifyClient {
Ping
Lobby(question: String, names: List(User))
Answer
Await
Exit
}
pub type User {
User(name: String, ping_time: Int, answer: AnswerStatus)
}

View file

@ -0,0 +1,182 @@
import gleam/erlang/process.{type Subject}
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/otp/actor.{type Started}
import lustre
import lustre/attribute.{class}
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import shared/message.{type NotifyClient, type NotifyServer}
import web/components/shared.{
step_prompt, view_input, view_named_input, view_named_keyed_input, view_yes_no,
}
pub fn component() -> lustre.App(
#(List(#(Int, String)), message.ClientsServer),
Model,
Msg,
) {
lustre.application(init, update, view)
}
pub opaque type Model {
Model(
state: Msg,
answers: List(#(Int, #(String, String))),
handler: Started(Subject(NotifyServer)),
)
}
fn init(
start_args: #(List(#(Int, String)), message.ClientsServer),
) -> #(Model, Effect(Msg)) {
let #(answers, handlers) = start_args
let #(_registry, handler) = handlers
// Convert a "question number -> question text" array to
// "question number" -> #("question text", "users answer" array
// with blank user answers.
let initial_array =
list.filter(answers, fn(x) {
let #(i, _) = x
i <= 14 && i >= 0
})
|> list.map(fn(x) {
let #(a, b) = x
#(a, #(b, ""))
})
#(Model(Initial, initial_array, handler), effect.none())
}
pub opaque type Msg {
Initial
SharedMessage(message: NotifyClient)
ReceiveName(message: String)
AcceptName(accept: Option(String))
GiveQuestion(name: String, question: String)
GiveAnswer(name: String, question: Int, answer: String)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
Initial | SharedMessage(_) -> #(model, effect.none())
AcceptName(None) -> #(Model(Initial, [], model.handler), effect.none())
AcceptName(Some(name)) -> {
#(Model(..model, state: GiveQuestion(name, "")), effect.none())
}
GiveQuestion(name, question) ->
case int.parse(question) {
Ok(question) if question >= 1 && question <= 14 -> #(
Model(..model, state: GiveAnswer(name:, question:, answer: "")),
effect.none(),
)
_ -> #(
Model(..model, state: GiveQuestion(name:, question: "")),
effect.none(),
)
}
GiveAnswer(name, question, answer) -> {
actor.send(
model.handler.data,
message.GiveSingleAnswer(name:, question:, answer:),
)
let new_value = case list.key_find(model.answers, question) {
Ok(pair) -> {
let #(a, _) = pair
#(a, answer)
}
Error(_) -> #("", answer)
}
#(
Model(
..model,
state: GiveQuestion(name, ""),
answers: list.key_set(model.answers, question, new_value),
),
effect.none(),
)
}
ReceiveName(_) -> #(Model(..model, state: msg), effect.none())
}
}
fn view(model: Model) -> Element(Msg) {
element.fragment([
html.div([attribute.class("terminal-prompt")], [
case model.state {
Initial ->
step_prompt(
"Hello stranger. To join the quiz, I need to know your name",
fn() { view_input(ReceiveName) },
)
ReceiveName(name) ->
step_prompt(
"Your name is " <> name <> "? Are you absolutely sure???",
fn() { view_yes_no(name, AcceptName) },
)
GiveQuestion(name, _) ->
step_prompt(
"Enter the number of the question you want to answer",
fn() { view_named_input(name, GiveQuestion) },
)
GiveAnswer(name, question, _) ->
step_prompt(
"Enter the answer to question number " <> int.to_string(question),
fn() { view_named_keyed_input(question, name, GiveAnswer) },
)
_ -> html.h3([], [html.text("Waiting for next question")])
},
]),
html.div([class("terminal-header")], [
html.div([class("terminal-status")], [
html.span([class("status-blink")], [html.text("")]),
html.text(" SYSTEM READY"),
html.span([class("ml-8")], [
case model.state {
Initial -> html.text("STATUS: Please input your name")
ReceiveName(_) -> html.text("STATUS: Please validate your name")
GiveQuestion(_, _) -> html.text("STATUS: Pick question to answer")
GiveAnswer(_, _, _) -> html.text("STATUS: Give your answer")
_ -> html.text("STATUS: Waiting for next question")
},
]),
]),
]),
terminal_section(model.answers, "[ACTIVE TRANSMISSIONS]", fn(answer) {
content_cell(answer)
}),
])
}
fn terminal_section(
answers: List(#(Int, #(String, String))),
header: String,
extract: fn(#(Int, #(String, String))) -> Element(Msg),
) {
html.div([attribute.class("terminal-section")], [
html.div([attribute.class("terminal-label mb-4")], [
html.text(header),
]),
html.div([attribute.class("participants-grid")], list.map(answers, extract)),
])
}
fn content_cell(answer: #(Int, #(String, String))) -> Element(Msg) {
let #(question, #(question_text, answer)) = answer
html.div(
[
class("participant-box"),
],
[
html.div([class("participant-name")], [
html.text("" <> int.to_string(question) <> " " <> question_text),
]),
html.div([class("participant-answer")], [
html.text(answer),
]),
],
)
}

View file

@ -0,0 +1,275 @@
import gleam/erlang/process.{type Subject}
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/otp/actor.{type Started}
import group_registry.{type GroupRegistry}
import lustre
import lustre/attribute.{class}
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/server_component
import shared/message.{type NotifyClient, type NotifyServer, type User, User}
import web/components/shared.{
step_prompt, view_input, view_named_input, view_yes_no,
}
pub fn component() -> lustre.App(message.ClientsServer, Model, Msg) {
lustre.application(init, update, view)
}
type State {
AskName
NameOk(String)
WaitForQuiz(String)
Answer(String)
}
pub opaque type Model {
Model(
state: State,
lobby: #(String, List(User)),
registry: GroupRegistry(NotifyClient),
handler: Started(Subject(NotifyServer)),
)
}
fn init(handlers: message.ClientsServer) -> #(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(Some(name)) -> {
actor.send(handler.data, message.GiveName(name:))
#(Model(..model, state: WaitForQuiz(name)), effect.none())
}
AcceptName(None) -> #(Model(..model, state: AskName), effect.none())
GiveAnswer(name, answer) -> {
actor.send(handler.data, message.GiveAnswer(name, Some(answer)))
#(Model(..model, state: WaitForQuiz(name)), effect.none())
}
SharedMessage(shared_msg) -> #(
handle_server_message(model, shared_msg),
effect.none(),
)
}
}
fn handle_server_message(model: Model, notify_client) {
case notify_client {
message.Lobby(question, lobby) -> Model(..model, lobby: #(question, lobby))
message.Exit -> Model(AskName, #("", []), model.registry, model.handler)
message.Answer ->
case model.state {
// We are currently waiting for next quiz question, ok to switch to answer mode
WaitForQuiz(name) -> Model(..model, state: Answer(name))
// We are not in a state to react, ignore switch to answer mode.
_ -> model
}
message.Await ->
case model.state {
Answer(name) -> Model(..model, state: WaitForQuiz(name))
_ -> model
}
message.Ping -> {
let has_name = case model.state {
Answer(name) -> Some(name)
WaitForQuiz(name) -> Some(name)
_ -> None
}
case has_name {
Some(name) -> actor.send(model.handler.data, message.Pong(name))
_ -> Nil
}
model
}
}
}
fn view(model: Model) -> Element(Msg) {
let #(question, lobby) = model.lobby
element.fragment([
html.div([attribute.class("terminal-prompt")], [
case model.state {
AskName ->
step_prompt(
"Hello stranger. To join the quiz, I need to know your name",
fn() { view_input(ReceiveName) },
)
NameOk(name) ->
step_prompt(
"Your name is " <> name <> "? Are you absolutely sure???",
fn() { view_yes_no(name, AcceptName) },
)
Answer(name) ->
step_prompt(
"The Quiz Lead will now ask the question, and you may answer.",
fn() { view_named_input(name, GiveAnswer) },
)
_ -> html.h3([], [html.text("Waiting for next question")])
},
]),
html.div([class("terminal-header")], [
html.div([class("terminal-status")], [
html.span([class("status-blink")], [html.text("")]),
html.text(" SYSTEM READY"),
html.span([class("ml-8")], [
case model.state {
AskName -> html.text("STATUS: Please input your name")
NameOk(_) -> html.text("STATUS: Please validate your name")
Answer(_) ->
html.div([], [
html.div([], [html.text("STATUS: Answer the following:")]),
html.div([], [html.text(question)]),
])
_ -> html.text("STATUS: Waiting for next question")
},
]),
]),
]),
html.div([class("terminal-section")], case lobby {
[] -> []
lobby -> {
let answered =
list.filter(lobby, fn(x) {
case x.answer {
message.IDontKnow | message.HasAnswered | message.GivenAnswer(_) ->
True
_ -> False
}
})
|> list.length
|> int.to_string
let size = lobby |> list.length |> int.to_string
[
html.div([attribute.class("terminal-box")], [
html.span([attribute.class("terminal-label")], [
html.text("[PROGRESS] "),
]),
html.text("Answered: "),
case answered == size {
True -> html.text("Everyone!")
False -> html.text(answered <> "/" <> size)
},
]),
]
}
}),
terminal_section(
lobby,
"[ACTIVE TRANSMISSIONS]",
fn(x) {
case x.answer {
message.GivenAnswer(_) | message.HasAnswered -> True
_ -> False
}
},
fn(user) {
let User(name, ping_time, answer) = user
case answer {
message.GivenAnswer(answer) -> answer
message.HasAnswered -> "Answer Given"
_ -> "Odd State..."
}
|> content_cell(name, ping_time, _)
},
),
terminal_section(
lobby,
"[P A S S]",
fn(x) {
case x.answer {
message.IDontKnow -> True
_ -> False
}
},
fn(user) {
let User(name, ping_time, _) = user
content_cell(name, ping_time, "P.A.S.S :(")
},
),
terminal_section(
lobby,
"[AWAITING RESPONSE]",
fn(x) {
case x.answer {
message.NotAnswered -> True
_ -> False
}
},
fn(user) {
case user {
User(name, ping_time, _) ->
content_cell(name, ping_time, "Not Answered")
}
},
),
])
}
fn terminal_section(
lobby: List(User),
header: String,
filter: fn(User) -> Bool,
extract: fn(User) -> Element(Msg),
) {
html.div([attribute.class("terminal-section")], [
html.div([attribute.class("terminal-label mb-4")], [
html.text(header),
]),
html.div(
[attribute.class("participants-grid")],
list.filter(lobby, filter)
|> list.map(extract),
),
])
}
fn content_cell(header: String, ping_time: Int, content: String) -> Element(Msg) {
html.div(
[
class(case ping_time > 1 {
True -> "participant-disconnect"
False -> "participant-box"
}),
],
[
html.div([class("participant-name")], [
html.text("" <> header),
]),
html.div([class("participant-answer")], [
html.text(content),
]),
],
)
}

View file

@ -16,11 +16,7 @@ import shared/message.{
type NotifyClient, type NotifyServer, AnswerQuiz, RevealAnswer, type NotifyClient, type NotifyServer, AnswerQuiz, RevealAnswer,
} }
pub fn component() -> lustre.App( pub fn component() -> lustre.App(message.ClientsServer, Model, Msg) {
#(GroupRegistry(NotifyClient), Started(Subject(NotifyServer))),
Model,
Msg,
) {
lustre.application(init, update, view) lustre.application(init, update, view)
} }
@ -40,13 +36,11 @@ pub opaque type Model {
pub opaque type Msg { pub opaque type Msg {
AnnounceQuiz AnnounceQuiz
AnnounceAnswer AnnounceAnswer
End PurgePlayers
SharedMessage(message: message.NotifyClient) SharedMessage(message: message.NotifyClient)
} }
fn init( fn init(handlers: message.ClientsServer) -> #(Model, Effect(Msg)) {
handlers: #(GroupRegistry(NotifyClient), Started(Subject(NotifyServer))),
) -> #(Model, Effect(Msg)) {
let #(registry, handler) = handlers let #(registry, handler) = handlers
let model = Model(state: Quiz, registry:, handler:) let model = Model(state: Quiz, registry:, handler:)
@ -68,42 +62,60 @@ fn subscribe(
} }
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
let registry = model.registry
let handler = model.handler let handler = model.handler
#(
case msg { case msg {
PurgePlayers -> {
// Temp removed button to issue this action.
actor.send(handler.data, message.PurgePlayers)
model
}
AnnounceQuiz -> { AnnounceQuiz -> {
actor.send(handler.data, AnswerQuiz) actor.send(handler.data, AnswerQuiz)
#(Model(Quiz, registry:, handler:), effect.none()) Model(..model, state: Quiz)
} }
AnnounceAnswer -> { AnnounceAnswer -> {
actor.send(handler.data, RevealAnswer) actor.send(handler.data, RevealAnswer)
#(Model(Reveal, registry:, handler:), effect.none()) Model(..model, state: Reveal)
}
End -> #(model, effect.none())
SharedMessage(_) -> #(model, effect.none())
} }
SharedMessage(message.Await) -> Model(..model, state: Reveal)
SharedMessage(_) -> model
},
effect.none(),
)
} }
fn view(model: Model) -> Element(Msg) { fn view(model: Model) -> Element(Msg) {
html.div([attribute.class("terminal-section")], [
html.div([attribute.class("participants-grid")], [
element.fragment([
keyed.div([attribute.class("participand-hidden")], [
#("reveal", html.text("")),
]),
keyed.div([attribute.class("participand-hidden")], [
#("reveal", html.text("")),
]),
keyed.div([attribute.class("control")], [
#("reveal", html.text("")),
]),
case model.state { case model.state {
Quiz -> { Quiz -> {
element.fragment([
keyed.div([attribute.class("control")], [ keyed.div([attribute.class("control")], [
#("reveal", view_input("Reveal answers", AnnounceAnswer)), #("reveal", view_button("Reveal answers", AnnounceAnswer)),
]),
]) ])
} }
Reveal -> { Reveal -> {
element.fragment([
keyed.div([attribute.class("control")], [ keyed.div([attribute.class("control")], [
#("next", view_input("Ask for next answer", AnnounceQuiz)), #("next", view_button("Ask next question", AnnounceQuiz)),
])
}
},
]),
]), ]),
]) ])
} }
}
}
fn view_input(text: String, on_submit handle_keydown: msg) -> Element(msg) { fn view_button(text: String, on_submit handle_keydown: msg) -> Element(msg) {
let on_keydown = event.on("click", { decode.success(handle_keydown) }) let on_keydown = event.on("click", { decode.success(handle_keydown) })
html.button([attribute.class("controlbutton"), on_keydown], [ html.button([attribute.class("controlbutton"), on_keydown], [

View file

@ -0,0 +1,104 @@
import gleam/dynamic/decode
import gleam/option.{type Option, None, Some}
import lustre/attribute
import lustre/element.{type Element}
import lustre/element/html
import lustre/element/keyed
import lustre/event
import lustre/server_component
// Components use "keyed.div" rather than "html.div" for input fields
// The value of the fields are uncontrolled, so this is needed to
// get a new state between each input, or else the value transfers between
// input fields.
//
// see: https://hexdocs.pm/lustre/lustre/element/keyed.html
pub fn view_named_input(
name: String,
on_submit handle_keydown: fn(String, String) -> msg,
) -> Element(msg) {
prompt_input(
"nameinput",
key_down(fn(a: String) { decode.success(handle_keydown(name, a)) }, fn() {
decode.failure(handle_keydown(name, ""), "")
}),
)
}
pub fn view_named_keyed_input(
question: Int,
name: String,
on_submit handle_keydown: fn(String, Int, String) -> msg,
) -> Element(msg) {
prompt_input(
"keyput",
key_down(
fn(a: String) { decode.success(handle_keydown(name, question, a)) },
fn() { decode.failure(handle_keydown(name, question, ""), "") },
),
)
}
pub fn view_input(on_submit handle_keydown: fn(String) -> msg) -> Element(msg) {
prompt_input(
"input",
key_down(fn(a: String) { decode.success(handle_keydown(a)) }, fn() {
decode.failure(handle_keydown(""), "")
}),
)
}
pub fn view_yes_no(
accepted: String,
on_submit handle_button: fn(Option(String)) -> msg,
) -> Element(msg) {
html.div([], [
html.button([event.on_click(handle_button(Some(accepted)))], [
html.text(" <Yes> "),
]),
html.text(" - "),
html.button([event.on_click(handle_button(None))], [html.text(" <No> ")]),
])
}
fn key_down(
success: fn(String) -> decode.Decoder(msg),
fail: fn() -> decode.Decoder(msg),
) {
event.on("keydown", {
use key <- decode.field("key", decode.string)
use value <- decode.subfield(["target", "value"], decode.string)
case key {
"Enter" if value != "" -> success(value)
_ -> fail()
}
})
|> server_component.include(["key", "target.value"])
}
fn prompt_input(key, on_keydown) {
keyed.div([], [
#(key <> "header", html.text("$>")),
#(
key,
html.input([
attribute.type_("text"),
on_keydown,
attribute.autofocus(True),
]),
),
])
}
pub fn step_prompt(text: String, fetch: fn() -> Element(a)) {
html.div([attribute.class("prompt-line")], [
html.div([attribute.class("prompt-text")], [
html.div([], [
html.text(text),
]),
fetch(),
]),
])
}

View file

@ -0,0 +1,123 @@
import gleam/erlang/process.{type Subject}
import gleam/json
import gleam/int
import gleam/option.{None, Some}
import gleam/otp/actor.{type Started}
import lustre/attribute.{class}
import lustre/element
import lustre/element/html.{body, div, head, html, link, meta, script, title}
import lustre/server_component
import shared/message.{
type ClientsServer, type RoomControl, CreateRoom, FetchRoom,
}
import wisp.{type Response}
pub fn main_html(content: fn() -> element.Element(a)) -> Response {
html([], [
head([], [
meta([attribute.charset("utf-8")]),
meta([
attribute.name("viewport"),
attribute.content("width=device-width, initial-scale=1.0"),
]),
title([], "QUIZTERMINAL v1.0"),
script(
[attribute.type_("module"), attribute.src("/lustre/runtime.mjs")],
"",
),
link([
attribute.rel("stylesheet"),
attribute.type_("text/css"),
attribute.href("/static/layout.css"),
]),
]),
body([], [
div([class("terminal-screen")], [
div([class("terminal-glow")], [
div([class("scanlines")], []),
// title
div([class("terminal-header")], [
html.pre([class("terminal-title")], [
html.text(
"
Q U I Z T E R M I N A L
",
),
]),
]),
// Insert content
content(),
]),
]),
]),
])
|> element.to_document_string
|> wisp.html_response(200)
}
pub fn room(actor: Started(Subject(RoomControl(ClientsServer))), id: String) {
process.send(actor.data, CreateRoom(id))
status_head("Created room with id " <> id)
}
pub fn slow(
actor: Started(Subject(RoomControl(ClientsServer))),
id: String,
) -> fn() -> element.Element(a) {
let start_args = actor.call(actor.data, 1000, FetchRoom(id, _))
case start_args {
Some(_) -> fn() {
div([], [
server_component.element(
[server_component.route("/socket/slow/" <> id)],
[],
),
])
}
None -> status_head("Could not find that room...")
}
}
pub fn board(
actor: Started(Subject(RoomControl(ClientsServer))),
id: String,
) -> fn() -> element.Element(a) {
let start_args = actor.call(actor.data, 1000, FetchRoom(id, _))
case start_args {
Some(_) -> fn() {
div([], [
server_component.element(
[server_component.route("/socket/card/" <> id)],
[],
),
server_component.element(
[server_component.route("/socket/control/" <> id)],
[],
),
])
}
None -> status_head("Could not find that room...")
}
}
pub fn create_json_response(response: #(Int, String, String)) {
let #(code, message, output) = response
wisp.log_info("[api][" <>int.to_string(code)<>"][" <> message<> "]")
json.object([#("response", json.string(output))])
|> json.to_string
|> wisp.json_response(200)
}
pub fn status_head(output: String) {
fn() -> element.Element(a) {
html.div([class("terminal-header")], [
html.div([class("terminal-status")], [
html.span([class("status-blink")], [html.text("")]),
html.h2([class("ml-8")], [html.text(output)]),
]),
])
}
}

124
server/src/web/router.gleam Normal file
View file

@ -0,0 +1,124 @@
import gleam/bit_array
import gleam/crypto
import gleam/dynamic/decode
import gleam/erlang/process.{type Subject}
import gleam/http
import gleam/int
import gleam/list
import gleam/otp/actor.{type Started}
import shared/message.{type ClientsServer, type RoomControl, type StateControl}
import web/handlers/serve.{board, main_html, room, slow, status_head}
import wisp.{type Request, type Response}
pub fn handle_request(
room_handler: Started(Subject(RoomControl(ClientsServer))),
state_handler: Started(Subject(StateControl)),
req: Request,
) -> Response {
use req <- middleware(req)
case wisp.path_segments(req) {
["api", ..path] -> handle_api(state_handler, req, path)
_ -> handle_html(room_handler, req)
}
}
fn handle_html(
actor: Started(Subject(RoomControl(ClientsServer))),
req: Request,
) -> Response {
case wisp.path_segments(req) {
["slow", id] -> slow(actor, id)
["board", id] -> board(actor, id)
["room", id] -> room(actor, id)
_ -> {
wisp.log_info("No match for request")
status_head("Nothing to see here")
}
}
|> main_html
}
fn handle_api(
actor: Started(Subject(StateControl)),
req: Request,
path: List(String),
) {
use json <- wisp.require_json(req)
case list.key_find(req.headers, "x-api-key") {
Ok(key) -> {
case
bit_array.base64_encode(crypto.hash(crypto.Sha256, <<key:utf8>>), True)
== "1nIr1fQzs0K9UZAeUcG/67n12iRiviIS6gO5WXyI2+0="
{
True ->
case req.method, path {
http.Post, ["info"] -> decode_info(actor, json)
http.Post, ["questions"] ->
decode_index_to_text(actor, json, message.SetQuestion)
http.Post, ["answers"] ->
decode_index_to_text(actor, json, message.SetAnswer)
_, _ -> #(404, "bad api apth","Resource not found")
}
False -> {
#(401, "invalid api key","unauthorized")
}
}
}
Error(_) -> {
#(401, "missing api key","unauthorized")
}
}
|> serve.create_json_response
}
fn decode_info(
actor: Started(Subject(StateControl)),
json_string: decode.Dynamic,
) {
let decode_uri = {
use uri <- decode.field("teaserImage", decode.string)
decode.success(message.SetInfo(uri))
}
case decode.run(json_string, decode_uri) {
Ok(info) -> {
actor.send(actor.data, info)
#(200, "Updated info", "Updated info")
}
Error(_) -> #(400, "Unable to update info","bad request")
}
}
fn decode_index_to_text(
actor: Started(Subject(StateControl)),
json_string: decode.Dynamic,
message: fn(Int, String) -> StateControl,
) {
let decode_answer = {
use index <- decode.field("index", decode.int)
use text <- decode.field("text", decode.string)
decode.success(message(index, text))
}
case decode.run(json_string, decode.list(decode_answer)) {
Ok(answers) -> {
list.each(answers, fn(answer_question) {
actor.send(actor.data, answer_question)
})
#(200, "imported " <> int.to_string(list.length(answers)) <> " items.","imported " <> int.to_string(list.length(answers)) <> " items.")
}
Error(_) -> #(400, "Failed to import","bad request")
}
}
pub fn middleware(
req: wisp.Request,
handle_request: fn(wisp.Request) -> wisp.Response,
) -> wisp.Response {
let req = wisp.method_override(req)
use <- wisp.log_request(req)
use <- wisp.rescue_crashes
use req <- wisp.handle_head(req)
use req <- wisp.csrf_known_header_protection(req)
handle_request(req)
}

1
server/test/live.gleam Normal file
View file

@ -0,0 +1 @@

View file

@ -1,86 +0,0 @@
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)
}

View file

@ -1,239 +0,0 @@
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

@ -1,128 +0,0 @@
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()))
}
}

View file

@ -1,18 +0,0 @@
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))
}