More massive workstuff for completion :P

This commit is contained in:
Lett Osprey 2026-04-10 19:36:28 +02:00
parent 7a8acf27a7
commit 3385118b14
20 changed files with 325 additions and 354 deletions

View file

@ -1,3 +1,4 @@
sh docker-up echo "Tests not updated after rewrite, commented out until this is fixed"
sh api-test #sh test-files/docker-up
sh docker-down #sh test-files/api-test
#sh test-files/docker-down

View file

@ -1,27 +1,30 @@
import gleam/dynamic/decode import gleam/dynamic/decode
import gleam/json import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some} import gleam/option.{type Option, None, Some}
import gleam/result import gleam/result
import gleam/string import gleam/string
import lustre import lustre
import lustre/effect.{type Effect} import lustre/effect.{type Effect}
import model.{ import model.{
type Model, type Msg, AwaitPlayers, Empty, EnterPin, Initialize, KeyPin, Model, type Model, type Msg, type Room, Empty, EnterPin, Initialize, KeyPin, Model,
PickPlayer, Players, SelectedGamestyle, SelectedPlayer, SelectedRoom, Room, SelectedGamestyle, SelectedRoom,
} }
import plinth/browser/document import plinth/browser/document
import plinth/browser/element as plinth_element import plinth/browser/element as plinth_element
import rsvp
import shared.{type Room}
import view.{view} import view.{view}
pub fn main() { pub fn main() {
let room_decoder = {
use name <- decode.field("name", decode.string)
use id <- decode.field("id", decode.string)
use pin <- decode.field("key", decode.string)
decode.success(Room(id:, name:, pin:))
}
let initial_items = let initial_items =
document.query_selector("#model") document.query_selector("#model")
|> result.map(plinth_element.inner_text) |> result.map(plinth_element.inner_text)
|> result.try(fn(json) { |> result.try(fn(json) {
json.parse(json, shared.grocery_list_decoder()) json.parse(json, decode.list(room_decoder))
|> result.replace_error(Nil) |> result.replace_error(Nil)
}) })
|> result.unwrap([]) |> result.unwrap([])
@ -47,14 +50,6 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
effect.none(), effect.none(),
) )
KeyPin(pin) -> { KeyPin(pin) -> {
//let _decode_answer = {
// use id <- decode.field("id", decode.string)
// use text <- decode.field("text", decode.string)
// decode.success(id)
// }
// let decode_list = {
// decode.list(decode.string)
// }
case model.state { case model.state {
EnterPin(room, _) -> #( EnterPin(room, _) -> #(
Model(..model, state: case string.length(pin) < 4 { Model(..model, state: case string.length(pin) < 4 {
@ -72,97 +67,21 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
} }
SelectedGamestyle(style) -> { SelectedGamestyle(style) -> {
case model.state { case model.state {
model.SelectGamestyle(room:, pin:) -> #( model.SelectGamestyle(room:, pin:) -> {
Model(..model, state: case style { #(
"Single Game" -> Model(..model, state: case style {
PickPlayer(room:, pin:, players: [ "Single Game" -> model.JoinSingle(room:, pin:)
"Player a", _ -> model.JoinLive(room:, pin:)
"Player b", }),
"Player c", effect.none(),
"Player d", )
"Player e", }
"Player f",
])
_ -> model.JoinLive(room:, pin:)
}),
effect.none(),
)
_ -> _ ->
init(#( init(#(
model.rooms, model.rooms,
Some("(fail: selectgamestyle) Invalid state, starting over"), Some("(fail: selectgamestyle) Invalid state, starting over"),
)) ))
} }
// Model(..model, state: AwaitPlayers(room:, pin:)),
// rsvp.post(
// "http://localhost:1234/api/room/players",
// json.object([#("id", json.string("1234"))]),
// rsvp.expect_json(decode_answer, Players),
// ),
// )
} }
Players(Ok(players)) -> {
echo "got players"
case model.state {
AwaitPlayers(room:, pin:) -> #(
Model(..model, state: PickPlayer(room:, pin:, players: [players])),
effect.none(),
)
_ ->
init(#(
model.rooms,
Some("(fail: awaitplayers) Invalid state, starting over"),
))
}
}
Players(Error(x)) ->
init(#(
model.rooms,
Some("Error fetching players " <> decode_rsvp_error(x)),
))
SelectedPlayer(player) ->
case model.state {
PickPlayer(room:, pin:, players: _) -> #(
Model(..model, state: model.JoinSingle(room:, pin:, player:)),
effect.none(),
)
_ ->
init(#(
model.rooms,
Some("(fail: pickplayer) Invalid state, starting over"),
))
}
}
}
fn decode_rsvp_error(rsvp_error: rsvp.Error) {
case rsvp_error {
rsvp.BadBody -> "Bad body"
rsvp.BadUrl(_x) -> "Bad url"
rsvp.HttpError(_x) -> "Http error"
rsvp.JsonError(x) -> "Json error" <> decode_decode_error(x)
rsvp.NetworkError -> "Network error"
rsvp.UnhandledResponse(_) -> "Unhandled response"
}
}
fn decode_decode_error(json_error: json.DecodeError) -> String {
case json_error {
json.UnableToDecode(x) ->
"[unable to decode"
<> string.concat(
list.map(x, fn(x) { "(" <> decode_ddecode_error(x) <> ")" }),
)
<> "]"
json.UnexpectedSequence(s) -> "[unexpected sequence " <> s <> "]"
json.UnexpectedByte(x) -> "[unexpected byte " <> x <> "]"
json.UnexpectedEndOfInput -> "[unexpected end of input]"
}
}
fn decode_ddecode_error(decode_error: decode.DecodeError) -> String {
case decode_error {
decode.DecodeError(expected:, found:, path:) ->
"(e: " <> expected <> ", f: " <> found <> ", p: " <> string.concat(path)
} }
} }

View file

@ -1,6 +1,5 @@
import gleam/option.{type Option} import gleam/option.{type Option}
import rsvp.{type Error} import rsvp.{type Error}
import shared.{type Room}
pub type Model { pub type Model {
Model(rooms: List(Room), state: State, ohno: Option(String)) Model(rooms: List(Room), state: State, ohno: Option(String))
@ -10,17 +9,17 @@ pub type State {
Empty Empty
EnterPin(room: String, pin: String) EnterPin(room: String, pin: String)
SelectGamestyle(room: String, pin: String) SelectGamestyle(room: String, pin: String)
AwaitPlayers(room: String, pin: String)
PickPlayer(room: String, pin: String, players: List(String))
JoinLive(room: String, pin: String) JoinLive(room: String, pin: String)
JoinSingle(room: String, pin: String, player: String) JoinSingle(room: String, pin: String)
} }
pub type Msg { pub type Msg {
Initialize Initialize
SelectedRoom(String) SelectedRoom(String)
SelectedPlayer(String)
SelectedGamestyle(String) SelectedGamestyle(String)
KeyPin(String) KeyPin(String)
Players(Result(String, Error)) }
pub type Room {
Room(id: String, name: String, pin: String)
} }

View file

@ -1,16 +0,0 @@
import gleam/dynamic/decode
pub type Room {
Room(id: String, name: String, pin: String)
}
pub fn grocery_list_decoder() -> decode.Decoder(List(Room)) {
let room_decoder = {
use name <- decode.field("name", decode.string)
use id <- decode.field("id", decode.string)
use pin <- decode.field("key", decode.string)
decode.success(Room(id:, name:, pin: ))
}
decode.list(room_decoder)
}

View file

@ -7,20 +7,17 @@ import lustre/element/html
import lustre/event import lustre/event
import lustre/server_component import lustre/server_component
import model.{ import model.{
type Model, type Msg, AwaitPlayers, Empty, EnterPin, JoinLive, JoinSingle, type Model, type Msg, type Room, Empty, EnterPin, JoinLive, JoinSingle, KeyPin,
KeyPin, PickPlayer, SelectGamestyle, SelectedPlayer, SelectedRoom, SelectGamestyle, SelectedRoom,
} }
import shared.{type Room}
pub fn view(model: Model) -> Element(Msg) { pub fn view(model: Model) -> Element(Msg) {
case model.state { case model.state {
Empty -> view_room_list(model.rooms) Empty -> view_room_list(model.rooms)
EnterPin(_, _) -> view_enter_pin() EnterPin(_, _) -> view_enter_pin()
SelectGamestyle(_, _) -> view_live_or_single() SelectGamestyle(_, _) -> view_live_or_single()
AwaitPlayers(_, _) -> html.text("FETCHING USERS FOR ROOM")
PickPlayer(_, _, players) -> view_player_list(players)
JoinLive(room:, pin:) -> view_join_live(room, pin) JoinLive(room:, pin:) -> view_join_live(room, pin)
JoinSingle(room:, pin:, player:) -> view_join_single(room, pin, player) JoinSingle(room:, pin:) -> view_join_single(room, pin)
} }
} }
@ -65,38 +62,28 @@ fn view_room_list(items: List(Room)) -> Element(Msg) {
fn view_enter_pin() -> Element(Msg) { fn view_enter_pin() -> Element(Msg) {
layout("Enter PIN code for room", None, [ layout("Enter PIN code for room", None, [
html.input([ input_cell("[#ENTER PIN]", True, KeyPin),
attribute.type_("password"),
event.on_input(KeyPin),
attribute.autofocus(True),
]),
]) ])
} }
fn view_join_live(room: String, pin: String) -> Element(Msg) { fn view_join_live(room: String, pin: String) -> Element(Msg) {
html.div([attribute.class("terminal-section")], [ element.fragment([
html.div([attribute.class("terminal-label mb-4")], [ server_component.element(
server_component.element( [server_component.route("/socket/live/" <> room)],
[server_component.route("/socket/live/" <> room)], [],
[], ),
), server_component.element(
server_component.element( [server_component.route("/socket/control/" <> room)],
[server_component.route("/socket/control/" <> room)], [],
[], ),
),
]),
]) ])
} }
fn view_join_single(room: String, pin: String, player: String) -> Element(Msg) { fn view_join_single(room: String, pin: String) -> Element(Msg) {
html.div([attribute.class("terminal-section")], [ server_component.element(
html.div([attribute.class("terminal-label mb-4")], [ [server_component.route("/socket/single/" <> room)],
server_component.element( [],
[server_component.route("/socket/single/" <> room)], )
[],
),
]),
])
} }
fn view_live_or_single() -> Element(Msg) { fn view_live_or_single() -> Element(Msg) {
@ -106,15 +93,27 @@ fn view_live_or_single() -> Element(Msg) {
]) ])
} }
fn view_player_list(items: List(String)) -> Element(Msg) {
layout("Select or enter your player", None, case items { fn input_cell(
[] -> [html.text("No items in your list yet.")] header: String,
_ -> { password: Bool,
list.index_map(items, fn(item, index) { on_input: fn(String) -> Msg,
click_cell(index, item, SelectedPlayer) ) -> Element(Msg) {
}) html.div([class("participant-login")], [
} html.div([class("participant-name")], [
}) html.text("" <> header),
html.div([], [
html.input([
attribute.type_(case password {
True -> "password"
False -> "text"
}),
event.on_input(on_input),
attribute.autofocus(True),
]),
]),
]),
])
} }
fn click_cell( fn click_cell(

View file

@ -115,6 +115,14 @@ body {
margin: 0 auto; margin: 0 auto;
} }
.singles-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(590px, 1fr));
gap: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.participant-box { .participant-box {
border: 2px solid #00ff00; border: 2px solid #00ff00;
padding: 1rem; padding: 1rem;

View file

@ -1,48 +0,0 @@
<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">
[
{
"id": "1234",
"name": "Team A",
"key": "1234"
},
{
"id": "1235",
"name": "Team B",
"key": "1235"
},
{
"id": "1236",
"name": "Team C",
"key": "1236"
},
{
"id": "1237",
"name": "Team D",
"key": "1237"
}
]
</script>
<link href="/static/layout.css" rel="stylesheet" type="text/css">
</head>
<body>
<div class="terminal-screen">
<div class="terminal-glow">
<div class="scanlines"></div>
<div class="terminal-header"><pre class="terminal-title">
╔═══════════════════════════════════════╗
║ Q U I Z T E R M I N A L ║
╚═══════════════════════════════════════╝
</pre>
</div>
<div id="app"></div>
</div>
</div>
</body>
</html>

View file

@ -14,8 +14,8 @@ import shared/message.{
type State { type State {
State( State(
question_number: Int, question_number: Int,
// int in #pair: answer number // id, (name (question#, answer_attempt)
slow_answers: List(#(String, List(#(Int, String)))), slow_answers: List(#(String, #(String, List(#(Int, String))))),
// int in #pair: ping counted since response back. // int in #pair: ping counted since response back.
name_answers: List(#(String, #(Int, AnswerStatus))), name_answers: List(#(String, #(Int, AnswerStatus))),
hide_answers: Bool, hide_answers: Bool,
@ -66,19 +66,19 @@ pub fn initialize(
GiveAnswer(name, answer) -> give_answer(state, registry, name, answer) GiveAnswer(name, answer) -> give_answer(state, registry, name, answer)
// A player has answered a question in "single" game. Register the answer. // A player has answered a question in "single" game. Register the answer.
GiveSingleAnswer(name, question, answer) -> { GiveSingleAnswer(id, question, answer) -> {
State( State(
..state, ..state,
slow_answers: case list.key_find(state.slow_answers, name) { slow_answers: case list.key_find(state.slow_answers, id) {
Ok(l) -> { Ok(value) -> {
list.key_set( let #(name, list) = value
state.slow_answers, list.key_set(state.slow_answers, id, #(
name, name,
list.key_set(l, question, answer), list.key_set(list, question, answer),
) ))
} }
Error(_) -> { Error(_) -> {
list.key_set(state.slow_answers, name, [#(question, answer)]) state.slow_answers
} }
}, },
) )
@ -88,12 +88,37 @@ pub fn initialize(
// Switch from "Wait for next question" to "Answer next question" mode // Switch from "Wait for next question" to "Answer next question" mode
AnswerQuiz -> answer_quiz(state, registry) AnswerQuiz -> answer_quiz(state, registry)
message.FetchPlayers(subject:) -> {
fetch_players(state.slow_answers, subject)
state
}
message.AddPlayer(name:, subject:) -> {
let _ = add_player(name, subject)
state
}
} }
|> actor.continue() |> actor.continue()
}) })
|> actor.start |> actor.start
} }
fn add_player(_name: String, _subject: Subject(Result(String, String))) {
#(200, "ok", "ok")
}
fn fetch_players(
players: List(#(String, #(String, List(#(Int, String))))),
subject: Subject(List(#(String, String))),
) {
actor.send(
subject,
list.map(players, fn(player) {
let #(id, #(name, _)) = player
#(id, name)
}),
)
}
// Reschedule a new ping request, and ask clients to ping us back // Reschedule a new ping request, and ask clients to ping us back
fn ping(state, registry, sender) { fn ping(state, registry, sender) {
broadcast(registry, message.Ping) broadcast(registry, message.Ping)
@ -218,7 +243,7 @@ fn combine_lists(state: State) {
// Second list require a bit more work Iterate over each payers answers, // Second list require a bit more work Iterate over each payers answers,
// creating user objects where question number match current question number. // creating user objects where question number match current question number.
list.flat_map(state.slow_answers, fn(name_answers) { list.flat_map(state.slow_answers, fn(name_answers) {
let #(name, answers) = name_answers let #(_, #(name, answers)) = name_answers
list.filter_map(answers, fn(number_answer) { list.filter_map(answers, fn(number_answer) {
let #(answer_number, answer) = number_answer let #(answer_number, answer) = number_answer
case state.question_number == answer_number { case state.question_number == answer_number {

View file

@ -4,25 +4,29 @@ import gleam/list
import gleam/option.{Some} import gleam/option.{Some}
import gleam/otp/actor.{type Started} import gleam/otp/actor.{type Started}
import group_registry import group_registry
import shared/message.{type ClientsServer, type RoomControl, type StateControl} import shared/message.{
type Room, type RoomControl, type StateControl, CreateRoom, FetchRoom,
FetchRooms, PingTime, Room, RoomInfo,
}
// Room handler, actor to hold the rooms for the different teams playing. // Room handler, actor to hold the rooms for the different teams playing.
// //
// Reacts to: // Reacts to:
// CreateRoom(id) - create room with given ID. // CreateRoom(id, name, pin_enc) - create room with given ID, name and encoded pin
// //
// Responds to: // Responds to:
// FetchRoom(id, <subject>) - Fetch room with the given id. // FetchRoom(id, <subject>) - Fetch room with the given id.
// FetchRooms(<subject>) - Fetch list of rooms.
type Room { type Rooms {
Room(questions: List(#(Int, String)), rooms: List(#(String, ClientsServer))) Rooms(rooms: List(#(String, Room)))
} }
pub fn initialize(state_handler: Started(Subject(StateControl))) { pub fn initialize(state_handler: Started(Subject(StateControl))) {
actor.new(Room([], [])) actor.new(Rooms([]))
|> actor.on_message(fn(state: Room, message: RoomControl(ClientsServer)) { |> actor.on_message(fn(state: Rooms, message: RoomControl) {
case message { case message {
message.CreateRoom(id:) -> { CreateRoom(id:, room: RoomInfo(name, pin_enc)) -> {
case case
// Does room already exist? // Does room already exist?
state.rooms |> list.key_find(id) state.rooms |> list.key_find(id)
@ -32,17 +36,18 @@ pub fn initialize(state_handler: Started(Subject(StateControl))) {
case list.length(state.rooms) < 50 { case list.length(state.rooms) < 50 {
True -> { True -> {
// Room not found (not really an error case), create it. // 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, ..)) = let assert Ok(actor.Started(data: registry, ..)) =
group_registry.start(name) group_registry.start(process.new_name("quiz-registry" <> id))
let assert Ok(actor) = let assert Ok(actor) =
player_handler.initialize(state_handler, registry) player_handler.initialize(state_handler, registry)
process.send_after( process.send_after(actor.data, 1000, PingTime(actor.data))
actor.data, Rooms(rooms: [
1000, #(
message.PingTime(actor.data), id,
) Room(pin_enc: pin_enc, name:, actors: #(registry, actor)),
Room(..state, rooms: [#(id, #(registry, actor)), ..state.rooms]) ),
..state.rooms
])
} }
False -> state False -> state
} }
@ -51,16 +56,26 @@ pub fn initialize(state_handler: Started(Subject(StateControl))) {
Ok(_) -> state Ok(_) -> state
} }
} }
message.FetchRoom(id:, subject:) -> { FetchRoom(id:, subject:) -> {
case case
// Find the room, if it exists // Find the room, if it exists
state.rooms |> list.key_find(id) state.rooms |> list.key_find(id)
{ {
Ok(room) -> actor.send(subject, Some(room)) Ok(Room(_, _, actors)) -> actor.send(subject, Some(actors))
Error(_) -> actor.send(subject, option.None) _ -> actor.send(subject, option.None)
} }
state state
} }
FetchRooms(subject:) -> {
// Transform from Room to RoomInfo and ship back
state.rooms
|> list.map(fn(id_room) {
let #(id, Room(name, pin_enc, _)) = id_room
#(id, message.RoomInfo(name:, pin_enc:))
})
|> actor.send(subject, _)
state
}
} }
|> actor.continue() |> actor.continue()
}) })

View file

@ -12,9 +12,9 @@ import shared/message
pub fn serve( pub fn serve(
request: Request(Connection), request: Request(Connection),
component: lustre.App(start_args, model, msg), component: lustre.App(message.ClientsServer, model, msg),
id: String, id: String,
actor: actor.Started(Subject(message.RoomControl(start_args))), actor: actor.Started(Subject(message.RoomControl)),
) -> Response(ResponseData) { ) -> Response(ResponseData) {
let start_args = actor.call(actor.data, 1000, message.FetchRoom(id, _)) let start_args = actor.call(actor.data, 1000, message.FetchRoom(id, _))
case start_args { case start_args {
@ -35,9 +35,9 @@ pub fn serve(
pub fn serve_slow( pub fn serve_slow(
request: Request(Connection), request: Request(Connection),
component: lustre.App(#(List(#(Int, String)), start_args), model, msg), component: lustre.App(#(List(#(Int, String)), message.ClientsServer), model, msg),
id: String, id: String,
roomhandler: actor.Started(Subject(message.RoomControl(start_args))), roomhandler: actor.Started(Subject(message.RoomControl)),
statehandler: actor.Started(Subject(message.StateControl)), statehandler: actor.Started(Subject(message.StateControl)),
) -> Response(ResponseData) { ) -> Response(ResponseData) {
let start_args_opt = let start_args_opt =

View file

@ -35,7 +35,6 @@ pub fn main() {
_ -> _ ->
case request.path_segments(req) { case request.path_segments(req) {
["lustre", "runtime.mjs"] -> serve_runtime() ["lustre", "runtime.mjs"] -> serve_runtime()
[] | ["index.html"] -> serve_static("root.html")
["client.js"] -> serve_static("client.js") ["client.js"] -> serve_static("client.js")
["static", file] -> serve_static(file) ["static", file] -> serve_static(file)
["socket", "live", id] -> ["socket", "live", id] ->

View file

@ -14,7 +14,9 @@ pub type NotifyServer {
PurgePlayers PurgePlayers
GiveName(name: String) GiveName(name: String)
GiveAnswer(name: String, answer: Option(String)) GiveAnswer(name: String, answer: Option(String))
GiveSingleAnswer(name: String, question: Int, answer: String) GiveSingleAnswer(id: String, question: Int, answer: String)
FetchPlayers(subject: Subject(List(#(String, String))))
AddPlayer(name: String, subject: Subject(Result(String, String)))
} }
pub type StateControl { pub type StateControl {
@ -25,9 +27,18 @@ pub type StateControl {
FetchQuestions(subject: Subject(List(#(Int, String)))) FetchQuestions(subject: Subject(List(#(Int, String))))
} }
pub type RoomControl(msg) { pub type Room {
CreateRoom(id: String) Room(name: String, pin_enc: String, actors: ClientsServer)
FetchRoom(id: String, subject: Subject(Option(msg))) }
pub type RoomInfo {
RoomInfo(name: String, pin_enc: String)
}
pub type RoomControl {
CreateRoom(id: String, room: RoomInfo)
FetchRoom(id: String, subject: Subject(Option(ClientsServer)))
FetchRooms(subject: Subject(List(#(String, RoomInfo))))
} }
pub type AnswerStatus { pub type AnswerStatus {

View file

@ -78,10 +78,10 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
effect.none(), effect.none(),
) )
} }
GiveAnswer(name, question, answer) -> { GiveAnswer(id, question, answer) -> {
actor.send( actor.send(
model.handler.data, model.handler.data,
message.GiveSingleAnswer(name:, question:, answer:), message.GiveSingleAnswer(id:, question:, answer:),
) )
let new_value = case list.key_find(model.answers, question) { let new_value = case list.key_find(model.answers, question) {
Ok(pair) -> { Ok(pair) -> {
@ -93,7 +93,7 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
#( #(
Model( Model(
..model, ..model,
state: GiveQuestion(name, ""), state: GiveQuestion(id, ""),
answers: list.key_set(model.answers, question, new_value), answers: list.key_set(model.answers, question, new_value),
), ),
effect.none(), effect.none(),
@ -160,7 +160,7 @@ fn terminal_section(
html.div([attribute.class("terminal-label mb-4")], [ html.div([attribute.class("terminal-label mb-4")], [
html.text(header), html.text(header),
]), ]),
html.div([attribute.class("participants-grid")], list.map(answers, extract)), html.div([attribute.class("singles-grid")], list.map(answers, extract)),
]) ])
} }

View file

@ -102,3 +102,16 @@ pub fn step_prompt(text: String, fetch: fn() -> Element(a)) {
]), ]),
]) ])
} }
//fn xiew_player_list(items: List(String)) -> Element(Msg) {
// layout("Select or enter your player", None, case items {
// [] -> [html.text("No items in your list yet.")]
// _ -> {
// list.append(
// list.index_map(items, fn(item, index) {
// click_cell(index, item, SelectedPlayer)
// }),
// [input_cell("[#NEW PLAYER]", False, KeyPin)],
// )
// }
// })
//

View file

@ -1,18 +1,77 @@
import gleam/erlang/process.{type Subject} import shared/message
import gleam/json
import gleam/int import gleam/int
import gleam/option.{None, Some} import gleam/json
import gleam/otp/actor.{type Started}
import lustre/attribute.{class} import lustre/attribute.{class}
import lustre/element import lustre/element
import lustre/element/html.{body, div, head, html, link, meta, script, title} 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} import wisp.{type Response}
pub fn main_html(content: fn() -> element.Element(a)) -> Response { pub fn main_html(rooms: List(#(String, message.RoomInfo))) -> 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")],
"",
),
script([attribute.type_("module"), attribute.src("/client.js")], ""),
script(
[
attribute.id("model"),
attribute.type_("application/json"),
attribute.src("/client.js"),
],
TODO: CREATE TEXT STRING FROM ROOM LIST!
"
[
{
\"id\": \"1234\",
\"name\": \"Team A\",
\"key\": \"1234\"
}
]
",
),
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
",
),
]),
]),
html.div([attribute.id("app")], []),
]),
]),
]),
])
|> element.to_document_string
|> wisp.html_response(200)
}
// Todo: join with main_html
pub fn html_404() -> Response {
html([], [ html([], [
head([], [ head([], [
meta([attribute.charset("utf-8")]), meta([attribute.charset("utf-8")]),
@ -42,70 +101,23 @@ pub fn main_html(content: fn() -> element.Element(a)) -> Response {
html.text( html.text(
" "
Q U I Z T E R M I N A L 4 0 4
ohno!
", ",
), ),
]), ]),
]), ]),
// Insert content
content(),
]), ]),
]), ]),
]), ]),
]) ])
|> element.to_document_string |> element.to_document_string
|> wisp.html_response(200) |> wisp.html_response(400)
}
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)) { pub fn create_json_response(response: #(Int, String, String)) {
let #(code, message, output) = response let #(code, message, output) = response
wisp.log_info("[api][" <>int.to_string(code)<>"][" <> message<> "]") wisp.log_info("[api][" <> int.to_string(code) <> "][" <> message <> "]")
json.object([#("response", json.string(output))]) json.object([#("response", json.string(output))])
|> json.to_string |> json.to_string
|> wisp.json_response(200) |> wisp.json_response(200)

View file

@ -8,49 +8,54 @@ import gleam/json
import gleam/list import gleam/list
import gleam/option import gleam/option
import gleam/otp/actor.{type Started} import gleam/otp/actor.{type Started}
import shared/message.{type ClientsServer, type RoomControl, type StateControl} import shared/message.{type RoomControl, type StateControl}
import web/handlers/serve.{board, main_html, room, slow, status_head} import web/handlers/serve.{html_404}
import wisp.{type Request, type Response} import wisp.{type Request, type Response}
pub fn handle_request( pub fn handle_request(
room_handler: Started(Subject(RoomControl(ClientsServer))), room_handler: Started(Subject(RoomControl)),
state_handler: Started(Subject(StateControl)), state_handler: Started(Subject(StateControl)),
req: Request, req: Request,
) -> Response { ) -> Response {
use req <- middleware(req) use req <- middleware(req)
case wisp.path_segments(req) { case wisp.path_segments(req) {
["api", "room", ..path] -> handle_room_api(room_handler, req, path) [] | ["index.html"] -> serve.main_html(fetch_rooms(room_handler))
["api", "room"] -> handle_room(room_handler, req)
["api", "room", id, ..path] -> handle_players(room_handler, req, id, path)
["api", ..path] -> handle_admin_api(state_handler, req, path) ["api", ..path] -> handle_admin_api(state_handler, req, path)
_ -> handle_html(room_handler, req) _ -> html_404()
} }
} }
fn handle_html( fn handle_room(room_handler: Started(Subject(RoomControl)), req: Request) {
actor: Started(Subject(RoomControl(ClientsServer))), use json <- wisp.require_json(req)
req: Request,
) -> Response { case req.method {
case wisp.path_segments(req) { http.Post -> add_room(room_handler, json)
["slow", id] -> slow(actor, id) _ -> #(404, "bad api path", "Resource not found")
["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 |> serve.create_json_response
} }
fn handle_room_api( fn handle_players(
room_handler: Started(Subject(RoomControl(ClientsServer))), room_handler: Started(Subject(RoomControl)),
req: Request, req: Request,
id: String,
path: List(String), path: List(String),
) { ) {
use json <- wisp.require_json(req) use json <- wisp.require_json(req)
case req.method, path { case actor.call(room_handler.data, 1000, message.FetchRoom(id, _)) {
http.Post, ["players"] -> fetch_players(room_handler, json) option.Some(#(_, player_handler)) ->
_, _ -> #(404, "bad api path", "Resource not found") case req.method, path {
http.Post, ["player"] -> add_player(player_handler, json)
_, _ -> #(404, "bad api path", "Resource not found")
}
option.None -> #(
404,
"Room not found, or key invalid",
"resource not found",
)
} }
|> serve.create_json_response |> serve.create_json_response
} }
@ -89,6 +94,12 @@ fn handle_admin_api(
|> serve.create_json_response |> serve.create_json_response
} }
fn fetch_rooms(
room_handler: Started(Subject(RoomControl)),
) -> List(#(String, message.RoomInfo)) {
actor.call(room_handler.data, 1000, message.FetchRooms)
}
fn decode_info( fn decode_info(
actor: Started(Subject(StateControl)), actor: Started(Subject(StateControl)),
json_string: decode.Dynamic, json_string: decode.Dynamic,
@ -106,31 +117,54 @@ fn decode_info(
} }
} }
fn fetch_players( fn add_player(
room_handler: Started(Subject(RoomControl(ClientsServer))), player_handler: Started(Subject(message.NotifyServer)),
json_string: decode.Dynamic, json_string: decode.Dynamic,
) { ) {
let decode_uri = { let decode_player = {
use id <- decode.field("id", decode.string) use name <- decode.field("name", decode.string)
//use key <- decode.field("key", decode.string) decode.success(name)
decode.success(message.FetchRoom(id, _))
} }
case decode.run(json_string, decode_uri) { case decode.run(json_string, decode_player) {
Ok(room) -> { Ok(player) -> {
case actor.call(room_handler.data, 1000, room) { case actor.call(player_handler.data, 1000, message.AddPlayer(player, _)) {
option.Some(#(_, player_handler)) -> #( Ok(id) -> #(
200, 200,
json.to_string(json.object([#("id", json.string("10"))])), "Added player with name [" <> player <> "] given id [" <> id <> "]",
json.to_string(json.object([#("id", json.string("10"))])), json.to_string(json.object([#("id", json.string(id))])),
) )
option.None -> #( Error(msg) -> #(
404, 400,
"Room not found, or key invalid", "Unable to add player [" <> msg <> "]",
"resource not found", "player not added",
) )
} }
} }
Error(fault) -> #(400, "Unable to fetch players", "bad request") Error(_msg) -> #(
400,
"Could not parse player [decoding error]",
"bad request",
)
}
}
fn add_room(room_handler: Started(Subject(RoomControl)), json) {
let decode_room = {
use id <- decode.field("id", decode.string)
use pin_enc <- decode.field("pin", decode.string)
use name <- decode.field("name", decode.string)
decode.success(message.CreateRoom(
id:,
room: message.RoomInfo(name, pin_enc),
))
}
case decode.run(json, decode_room) {
Ok(player) -> {
actor.send(room_handler.data, player)
#(200, "added room", "added room")
}
Error(_msg) -> #(400, "unable to add room", "bad request")
} }
} }