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
14
server/gleam.toml
Normal file
14
server/gleam.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
name = "quizterm"
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies]
|
||||
gleam_erlang = ">= 1.0.0 and < 2.0.0"
|
||||
gleam_http = ">= 3.7.2 and < 5.0.0"
|
||||
gleam_json = ">= 2.3.0 and < 4.0.0"
|
||||
gleam_otp = ">= 1.1.0 and < 2.0.0"
|
||||
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
|
||||
mist = ">= 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
|
||||
}
|
||||
131
server/src/backend/sockethandler.gleam
Normal file
131
server/src/backend/sockethandler.gleam
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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, 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),
|
||||
id: String,
|
||||
actor: actor.Started(Subject(message.RoomControl(start_args))),
|
||||
) -> Response(ResponseData) {
|
||||
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) {
|
||||
Socket(
|
||||
component: lustre.Runtime(msg),
|
||||
self: Subject(server_component.ClientMessage(msg)),
|
||||
)
|
||||
}
|
||||
|
||||
type SocketMessage(msg) =
|
||||
server_component.ClientMessage(msg)
|
||||
|
||||
pub type SocketInit(msg) =
|
||||
#(Socket(msg), Option(Selector(SocketMessage(msg))))
|
||||
|
||||
fn init_socket(
|
||||
_,
|
||||
component: lustre.App(start_args, model, msg),
|
||||
start_args: start_args,
|
||||
) -> SocketInit(msg) {
|
||||
let assert Ok(component) =
|
||||
lustre.start_server_component(component, start_args)
|
||||
|
||||
let self = process.new_subject()
|
||||
let selector = process.new_selector() |> process.select(self)
|
||||
|
||||
server_component.register_subject(self)
|
||||
|> lustre.send(to: component)
|
||||
|
||||
#(Socket(component:, self:), Some(selector))
|
||||
}
|
||||
|
||||
fn loop_socket(
|
||||
state: Socket(msg),
|
||||
message: mist.WebsocketMessage(SocketMessage(msg)),
|
||||
connection: mist.WebsocketConnection,
|
||||
) -> mist.Next(Socket(msg), SocketMessage(msg)) {
|
||||
case message {
|
||||
mist.Text(json) -> {
|
||||
case json.parse(json, server_component.runtime_message_decoder()) {
|
||||
Ok(runtime_message) -> lustre.send(state.component, runtime_message)
|
||||
Error(_) -> Nil
|
||||
}
|
||||
|
||||
mist.continue(state)
|
||||
}
|
||||
|
||||
mist.Binary(_) -> {
|
||||
mist.continue(state)
|
||||
}
|
||||
|
||||
mist.Custom(client_message) -> {
|
||||
let json = server_component.client_message_to_json(client_message)
|
||||
let assert Ok(_) = mist.send_text_frame(connection, json.to_string(json))
|
||||
|
||||
mist.continue(state)
|
||||
}
|
||||
|
||||
mist.Closed | mist.Shutdown -> {
|
||||
server_component.deregister_subject(state.self)
|
||||
|> lustre.send(to: state.component)
|
||||
|
||||
mist.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_socket(state: Socket(msg)) -> Nil {
|
||||
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),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
124
server/src/web/components/control.gleam
Normal file
124
server/src/web/components/control.gleam
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// IMPORTS ---------------------------------------------------------------------
|
||||
import gleam/dynamic/decode
|
||||
import gleam/erlang/process.{type Subject}
|
||||
import gleam/otp/actor.{type Started}
|
||||
import gleam/pair
|
||||
import group_registry.{type GroupRegistry}
|
||||
import lustre
|
||||
import lustre/attribute
|
||||
import lustre/effect.{type Effect}
|
||||
import lustre/element.{type Element}
|
||||
import lustre/element/html
|
||||
import lustre/element/keyed
|
||||
import lustre/event
|
||||
import lustre/server_component
|
||||
import shared/message.{
|
||||
type NotifyClient, type NotifyServer, AnswerQuiz, RevealAnswer,
|
||||
}
|
||||
|
||||
pub fn component() -> lustre.App(message.ClientsServer, Model, Msg) {
|
||||
lustre.application(init, update, view)
|
||||
}
|
||||
|
||||
type State {
|
||||
Quiz
|
||||
Reveal
|
||||
}
|
||||
|
||||
pub opaque type Model {
|
||||
Model(
|
||||
state: State,
|
||||
registry: GroupRegistry(NotifyClient),
|
||||
handler: Started(Subject(NotifyServer)),
|
||||
)
|
||||
}
|
||||
|
||||
pub opaque type Msg {
|
||||
AnnounceQuiz
|
||||
AnnounceAnswer
|
||||
PurgePlayers
|
||||
SharedMessage(message: message.NotifyClient)
|
||||
}
|
||||
|
||||
fn init(handlers: message.ClientsServer) -> #(Model, Effect(Msg)) {
|
||||
let #(registry, handler) = handlers
|
||||
|
||||
let model = Model(state: Quiz, registry:, handler:)
|
||||
#(model, subscribe(pair.first(handlers), SharedMessage))
|
||||
}
|
||||
|
||||
fn subscribe(
|
||||
registry: GroupRegistry(topic),
|
||||
on_msg handle_msg: fn(topic) -> msg,
|
||||
) -> Effect(msg) {
|
||||
use _, _ <- server_component.select
|
||||
let subject = group_registry.join(registry, "quiz", process.self())
|
||||
|
||||
let selector =
|
||||
process.new_selector()
|
||||
|> process.select_map(subject, handle_msg)
|
||||
|
||||
selector
|
||||
}
|
||||
|
||||
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
||||
let handler = model.handler
|
||||
#(
|
||||
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) {
|
||||
html.div([attribute.class("terminal-section")], [
|
||||
html.div([attribute.class("participants-grid")], [
|
||||
element.fragment([
|
||||
keyed.div([attribute.class("participand-hidden")], [
|
||||
#("reveal", html.text("")),
|
||||
]),
|
||||
keyed.div([attribute.class("participand-hidden")], [
|
||||
#("reveal", html.text("")),
|
||||
]),
|
||||
keyed.div([attribute.class("control")], [
|
||||
#("reveal", html.text("")),
|
||||
]),
|
||||
case model.state {
|
||||
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_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], [
|
||||
html.text(text),
|
||||
])
|
||||
}
|
||||
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 @@
|
|||
|
||||
Loading…
Add table
Add a link
Reference in a new issue