From 5663b6190683cf7026555f22ffb3f79d6ae018dd Mon Sep 17 00:00:00 2001 From: lettosprey Date: Mon, 30 Mar 2026 23:49:20 +0200 Subject: [PATCH] Project restructure for better login Restructure project into client / server to support a "pure javascript" gleam project to work before the having enough info to launch the server components. --- .gitignore | 7 +- Dockerfile | 6 ++ client/src/client.gleam | 149 ++++++++++++++++++++++++++-------- client/src/shared.gleam | 23 +++--- server/priv/static/layout.css | 12 +++ server/priv/static/root.html | 23 +++++- server/src/quizterm.gleam | 54 ++++++------ 7 files changed, 198 insertions(+), 76 deletions(-) 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