First bits of pure lustre-javascript for user login session
This commit is contained in:
parent
a1e4eb1dff
commit
e6851255dc
41 changed files with 8413 additions and 733 deletions
20
.gitignore
vendored
20
.gitignore
vendored
|
|
@ -1,19 +1 @@
|
|||
# ---> Erlang
|
||||
.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/
|
||||
|
||||
localbuild.sh
|
||||
|
|
|
|||
14
Dockerfile
14
Dockerfile
|
|
@ -1,23 +1,23 @@
|
|||
ARG GLEAM_VERSION=v1.12.0
|
||||
ARG GLEAM_VERSION=v1.15.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/
|
||||
COPY ./server/priv /quizterm/server/priv
|
||||
COPY ./server/src /quizterm/server/src
|
||||
COPY ./server/gleam.toml /quizterm/server/
|
||||
|
||||
RUN cd /quizterm && gleam deps download
|
||||
RUN cd /quizterm/server && gleam deps download
|
||||
|
||||
# Compile the server code
|
||||
RUN cd /quizterm \
|
||||
RUN cd /quizterm/server \
|
||||
&& 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
|
||||
COPY --from=builder /quizterm/server/build/erlang-shipment /app
|
||||
|
||||
# Set up the entrypoint
|
||||
WORKDIR /app
|
||||
|
|
|
|||
46
README.md
46
README.md
|
|
@ -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.
|
||||
|
||||
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.
|
||||
Endpoints explained
|
||||
|
||||

|
||||
| 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
|
||||
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.
|
||||
|
||||
| Ingame example | Idle player |
|
||||
|--------------------------|--------------------------|
|
||||
|  |  |
|
||||
|
||||
### Building and running
|
||||
|
||||
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
5
api-test/api-test.sh
Normal 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
2
api-test/docker-down.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
docker stop do_integration_test
|
||||
docker rm do_integration_test
|
||||
4
api-test/docker-up.sh
Normal file
4
api-test/docker-up.sh
Normal 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
3
api-test/run.sh
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
sh docker-up
|
||||
sh api-test
|
||||
sh docker-down
|
||||
8
api-test/test.json
Normal file
8
api-test/test.json
Normal 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
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
17
client/dist/index.html
vendored
Normal 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
15
client/gleam.toml
Normal 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
57
client/manifest.toml
Normal 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
83
client/src/client.gleam
Normal 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
19
client/src/shared.gleam
Normal 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
BIN
game1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
game2.png
Normal file
BIN
game2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
74
layout.html
74
layout.html
|
|
@ -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>
|
||||
|
|
@ -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" }
|
||||
|
|
@ -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;
|
||||
}
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
|
|
@ -1,5 +1,5 @@
|
|||
name = "quizterm"
|
||||
version = "0.5.0"
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies]
|
||||
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_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"
|
||||
mist = ">= 6.0.0 and < 7.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"
|
||||
wisp = ">= 2.2.1 and < 3.0.0"
|
||||
39
server/manifest.toml
Normal file
39
server/manifest.toml
Normal 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" }
|
||||
276
server/priv/static/layout.css
Normal file
276
server/priv/static/layout.css
Normal 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;
|
||||
}
|
||||
31
server/priv/static/root.html
Normal file
31
server/priv/static/root.html
Normal 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>
|
||||
262
server/src/backend/playerhandler.gleam
Normal file
262
server/src/backend/playerhandler.gleam
Normal 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)
|
||||
}
|
||||
68
server/src/backend/roomhandler.gleam
Normal file
68
server/src/backend/roomhandler.gleam
Normal 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
|
||||
}
|
||||
|
|
@ -1,23 +1,63 @@
|
|||
import gleam/bytes_tree
|
||||
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 gleam/option.{type Option, None, Some}
|
||||
import gleam/otp/actor
|
||||
import lustre
|
||||
import lustre/server_component
|
||||
import mist.{type Connection, type ResponseData}
|
||||
import shared/message
|
||||
|
||||
pub fn serve(
|
||||
request: Request(Connection),
|
||||
component: lustre.App(start_args, model, msg),
|
||||
start_args: start_args,
|
||||
id: String,
|
||||
actor: actor.Started(Subject(message.RoomControl(start_args))),
|
||||
) -> Response(ResponseData) {
|
||||
mist.websocket(
|
||||
request:,
|
||||
on_init: init_socket(_, component, start_args),
|
||||
handler: loop_socket,
|
||||
on_close: close_socket,
|
||||
)
|
||||
let start_args = actor.call(actor.data, 1000, message.FetchRoom(id, _))
|
||||
case start_args {
|
||||
Some(start_args) ->
|
||||
mist.websocket(
|
||||
request:,
|
||||
on_init: init_socket(_, component, 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -30,7 +70,7 @@ type Socket(msg) {
|
|||
type SocketMessage(msg) =
|
||||
server_component.ClientMessage(msg)
|
||||
|
||||
type SocketInit(msg) =
|
||||
pub type SocketInit(msg) =
|
||||
#(Socket(msg), Option(Selector(SocketMessage(msg))))
|
||||
|
||||
fn init_socket(
|
||||
|
|
@ -86,6 +126,6 @@ fn loop_socket(
|
|||
}
|
||||
|
||||
fn close_socket(state: Socket(msg)) -> Nil {
|
||||
server_component.deregister_subject(state.self)
|
||||
lustre.shutdown()
|
||||
|> lustre.send(to: state.component)
|
||||
}
|
||||
75
server/src/backend/statehandler.gleam
Normal file
75
server/src/backend/statehandler.gleam
Normal 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
103
server/src/quizterm.gleam
Normal 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()))
|
||||
}
|
||||
}
|
||||
50
server/src/shared/message.gleam
Normal file
50
server/src/shared/message.gleam
Normal 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)
|
||||
}
|
||||
182
server/src/web/components/answerlist.gleam
Normal file
182
server/src/web/components/answerlist.gleam
Normal 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),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
275
server/src/web/components/card.gleam
Normal file
275
server/src/web/components/card.gleam
Normal 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),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
|
@ -16,11 +16,7 @@ import shared/message.{
|
|||
type NotifyClient, type NotifyServer, AnswerQuiz, RevealAnswer,
|
||||
}
|
||||
|
||||
pub fn component() -> lustre.App(
|
||||
#(GroupRegistry(NotifyClient), Started(Subject(NotifyServer))),
|
||||
Model,
|
||||
Msg,
|
||||
) {
|
||||
pub fn component() -> lustre.App(message.ClientsServer, Model, Msg) {
|
||||
lustre.application(init, update, view)
|
||||
}
|
||||
|
||||
|
|
@ -40,13 +36,11 @@ pub opaque type Model {
|
|||
pub opaque type Msg {
|
||||
AnnounceQuiz
|
||||
AnnounceAnswer
|
||||
End
|
||||
PurgePlayers
|
||||
SharedMessage(message: message.NotifyClient)
|
||||
}
|
||||
|
||||
fn init(
|
||||
handlers: #(GroupRegistry(NotifyClient), Started(Subject(NotifyServer))),
|
||||
) -> #(Model, Effect(Msg)) {
|
||||
fn init(handlers: message.ClientsServer) -> #(Model, Effect(Msg)) {
|
||||
let #(registry, handler) = handlers
|
||||
|
||||
let model = Model(state: Quiz, registry:, handler:)
|
||||
|
|
@ -68,42 +62,60 @@ fn subscribe(
|
|||
}
|
||||
|
||||
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())
|
||||
}
|
||||
#(
|
||||
case msg {
|
||||
PurgePlayers -> {
|
||||
// Temp removed button to issue this action.
|
||||
actor.send(handler.data, message.PurgePlayers)
|
||||
model
|
||||
}
|
||||
AnnounceQuiz -> {
|
||||
actor.send(handler.data, AnswerQuiz)
|
||||
Model(..model, state: Quiz)
|
||||
}
|
||||
AnnounceAnswer -> {
|
||||
actor.send(handler.data, RevealAnswer)
|
||||
Model(..model, state: Reveal)
|
||||
}
|
||||
SharedMessage(message.Await) -> Model(..model, state: Reveal)
|
||||
SharedMessage(_) -> model
|
||||
},
|
||||
effect.none(),
|
||||
)
|
||||
}
|
||||
|
||||
fn view(model: Model) -> Element(Msg) {
|
||||
case model.state {
|
||||
Quiz -> {
|
||||
html.div([attribute.class("terminal-section")], [
|
||||
html.div([attribute.class("participants-grid")], [
|
||||
element.fragment([
|
||||
keyed.div([attribute.class("control")], [
|
||||
#("reveal", view_input("Reveal answers", AnnounceAnswer)),
|
||||
keyed.div([attribute.class("participand-hidden")], [
|
||||
#("reveal", html.text("")),
|
||||
]),
|
||||
])
|
||||
}
|
||||
Reveal -> {
|
||||
element.fragment([
|
||||
keyed.div([attribute.class("control")], [
|
||||
#("next", view_input("Ask for next answer", AnnounceQuiz)),
|
||||
keyed.div([attribute.class("participand-hidden")], [
|
||||
#("reveal", html.text("")),
|
||||
]),
|
||||
])
|
||||
}
|
||||
}
|
||||
keyed.div([attribute.class("control")], [
|
||||
#("reveal", html.text("")),
|
||||
]),
|
||||
case model.state {
|
||||
Quiz -> {
|
||||
keyed.div([attribute.class("control")], [
|
||||
#("reveal", view_button("Reveal answers", AnnounceAnswer)),
|
||||
])
|
||||
}
|
||||
Reveal -> {
|
||||
keyed.div([attribute.class("control")], [
|
||||
#("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) })
|
||||
|
||||
html.button([attribute.class("controlbutton"), on_keydown], [
|
||||
104
server/src/web/components/shared.gleam
Normal file
104
server/src/web/components/shared.gleam
Normal 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(),
|
||||
]),
|
||||
])
|
||||
}
|
||||
123
server/src/web/handlers/serve.gleam
Normal file
123
server/src/web/handlers/serve.gleam
Normal 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
124
server/src/web/router.gleam
Normal 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
1
server/test/live.gleam
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)]),
|
||||
])
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue