First bits of pure lustre-javascript for user login session

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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