diff --git a/.gitignore b/.gitignore
index 095eb04..877ce67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,6 @@
-localbuild.sh
+**/.*
+/localbuild.sh
+/client/build
+/server/build
+/server/priv/static/client.js
+/server/priv/static/index.html
diff --git a/Dockerfile b/Dockerfile
index a72311e..4486743 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,9 +6,15 @@ FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine AS builder
COPY ./server/priv /quizterm/server/priv
COPY ./server/src /quizterm/server/src
COPY ./server/gleam.toml /quizterm/server/
+COPY ./client/src /quizterm/client/src
+COPY ./client/gleam.toml /quizterm/client/
RUN cd /quizterm/server && gleam deps download
+# Compile client code and move generated javascript to server project
+RUN cd /quizterm/client \
+ && gleam run -m lustre/dev build --outdir=/quizterm/server/priv/static
+
# Compile the server code
RUN cd /quizterm/server \
&& gleam export erlang-shipment
diff --git a/client/src/client.gleam b/client/src/client.gleam
index 17d8591..2402959 100644
--- a/client/src/client.gleam
+++ b/client/src/client.gleam
@@ -1,14 +1,18 @@
+import gleam/int
import gleam/json
import gleam/list
+import gleam/option.{type Option, None, Some}
import gleam/result
+import gleam/string
import lustre
-import lustre/attribute
+import lustre/attribute.{class}
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
+import lustre/event
import plinth/browser/document
import plinth/browser/element as plinth_element
-import shared
+import shared.{type Room}
pub fn main() {
let initial_items =
@@ -27,57 +31,130 @@ pub fn main() {
}
type Model {
- Model(rooms: List(String), name: String)
+ Model(
+ rooms: List(Room),
+ name: Option(String),
+ pin: Option(String),
+ typed: String,
+ )
}
-fn init(items: List(String)) -> #(Model, Effect(Msg)) {
- let model = Model(rooms: items, name: "")
+fn init(items: List(Room)) -> #(Model, Effect(State)) {
+ let model = Model(rooms: items, name: None, pin: None, typed: "")
#(model, effect.none())
}
-type Msg {
- UserTypedNewItem(String)
- UserAddedItem
+type State {
+ SelectRoom(String)
+ Initial
+ KeyIn(String)
}
-fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
+fn update(model: Model, msg: State) -> #(Model, Effect(msg)) {
case msg {
- UserAddedItem -> {
+ Initial ->
case model.name {
- "" -> #(model, effect.none())
- name -> {
- let updated_items = [name, ..model.rooms]
-
- #(Model(rooms: updated_items, name: ""), effect.none())
- }
+ _ -> #(model, effect.none())
+ }
+ SelectRoom(text) -> {
+ #(Model(..model, name: Some(text)), effect.none())
+ }
+ KeyIn(newpin) -> {
+ case string.length(newpin) < 4 {
+ False -> #(Model(..model, pin: Some(newpin)), effect.none())
+ True -> #(Model(..model, typed: newpin), effect.none())
}
}
-
- UserTypedNewItem(text) -> #(Model(..model, name: text), effect.none())
}
}
-fn view(model: Model) -> Element(Msg) {
- let styles = [
- #("max-width", "30ch"),
- #("margin", "0 auto"),
- #("display", "flex"),
- #("flex-direction", "column"),
- #("gap", "1em"),
- ]
+fn view(model: Model) -> Element(State) {
+ case model.name, model.pin {
+ None, _ ->
+ html.div([], [
+ 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")], [
+ html.text("<< Please Log On to use QuizTerm. >>"),
+ ]),
+ ]),
+ ]),
+ view_room_list(model.rooms),
+ ])
+ Some(_), None -> {
+ html.div([], [
+ 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")], [
+ html.text("<< Please Log On to use QuizTerm. >>"),
+ ]),
+ ]),
+ ]),
+ pin(),
+ ])
+ }
+ Some(_), Some(_) -> {
+ html.div([], [
+ 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")], [
+ html.text("<< Please Log On to use QuizTerm. >>"),
+ ]),
+ ]),
+ ]),
+ html.text("FETCHING USERS FOR ROOM"),
+ ])
+ }
+ }
+}
- html.div([attribute.styles(styles)], [
- html.h1([], [html.text("Select your QuizRoom")]),
- view_room_list(model.rooms),
+fn pin() -> Element(State) {
+ html.div([attribute.class("terminal-section")], [
+ html.div([attribute.class("terminal-label mb-4")], [
+ html.text("Select room to play in"),
+ ]),
+ html.div([attribute.class("participants-grid")], [
+ html.text("Enter PIN code for room"),
+ html.input([
+ attribute.type_("password"),
+ event.on_input(KeyIn),
+ attribute.autofocus(True),
+ ]),
+ ]),
])
}
-fn view_room_list(items: List(String)) -> Element(Msg) {
- case items {
- [] -> html.p([], [html.text("No items in your list yet.")])
- _ -> {
- html.ul([], list.map(items, fn(item) { html.li([], [html.text(item)]) }))
- }
- }
+fn view_room_list(items: List(Room)) -> Element(State) {
+ html.div([attribute.class("terminal-section")], [
+ html.div([attribute.class("terminal-label mb-4")], [
+ html.text("Select room to play in"),
+ ]),
+ html.div([attribute.class("participants-grid")], case items {
+ [] -> [html.text("No items in your list yet.")]
+ _ -> {
+ list.index_map(items, fn(item, index) {
+ content_cell(index, item, SelectRoom)
+ })
+ }
+ }),
+ ])
+}
+
+fn content_cell(
+ number: Int,
+ room: Room,
+ on_click: fn(String) -> msg,
+) -> Element(msg) {
+ html.div([class("participant-login"), event.on_click(on_click(room.name))], [
+ html.div([class("participant-name")], [
+ html.text("► " <> "[#" <> int.to_string(number) <> "] Team " <> room.name),
+ ]),
+ ])
}
diff --git a/client/src/shared.gleam b/client/src/shared.gleam
index f0bae3c..7f27196 100644
--- a/client/src/shared.gleam
+++ b/client/src/shared.gleam
@@ -1,19 +1,16 @@
import gleam/dynamic/decode
-import gleam/json
-pub type GroceryItem {
- GroceryItem(name: String, quantity: Int)
+pub type Room {
+ Room(id: String, name: String, pin: String)
}
-pub fn grocery_list_decoder() -> decode.Decoder(List(String)) {
- decode.list(decode.string)
-}
-fn grocery_item_to_json(grocery_item: GroceryItem) -> json.Json {
- let GroceryItem(name:, quantity:) = grocery_item
- json.object([#("name", json.string(name)), #("quantity", json.int(quantity))])
+pub fn grocery_list_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)
}
-
-pub fn grocery_list_to_json(items: List(GroceryItem)) -> json.Json {
- json.array(items, grocery_item_to_json)
-}
\ No newline at end of file
diff --git a/server/priv/static/layout.css b/server/priv/static/layout.css
index 94434d9..1baa22e 100644
--- a/server/priv/static/layout.css
+++ b/server/priv/static/layout.css
@@ -121,6 +121,12 @@ body {
transition: all 0.2s;
}
+.participant-login {
+ border: 2px dashed #005500;
+ padding: 1rem;
+ transition: all 0.2s;
+}
+
.participant-hidden {
border: 0px dashed #005500;
padding: 1rem;
@@ -139,6 +145,12 @@ body {
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
}
+.participant-login:hover {
+ border: 2px solid #00ff00;
+ 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;
diff --git a/server/priv/static/root.html b/server/priv/static/root.html
index dd37914..993a821 100644
--- a/server/priv/static/root.html
+++ b/server/priv/static/root.html
@@ -7,9 +7,26 @@
diff --git a/server/src/quizterm.gleam b/server/src/quizterm.gleam
index 7030a77..653a210 100644
--- a/server/src/quizterm.gleam
+++ b/server/src/quizterm.gleam
@@ -4,13 +4,14 @@ import backend/statehandler
import gleam/bytes_tree
import gleam/erlang/application
import gleam/erlang/process
+import gleam/http
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 mist.{type ResponseData}
import web/components/answerlist
import web/components/card
import web/components/control
@@ -25,29 +26,36 @@ pub fn main() {
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,
- )
+ fn(req: request.Request(mist.Connection)) {
+ case req.method {
+ // Filter out Head requests,
+ http.Head ->
+ response.new(200)
+ |> response.set_body(mist.Bytes(bytes_tree.new()))
_ ->
- wisp_mist.handler(
- router.handle_request(room_handler, state_handler, _),
- "very_secret",
- )(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