CI / Build and push docker image (push) Successful in 1m22s
GET /api/games/preview returns the lobby behind a code plus the game's name and engine-echoed options. The lobby debounces the lookup as a complete code is typed and shows the game, seated players, open seats and option badges rendered from the game's schema, disabling Join when the lobby is full or the match has already started.
625 lines
26 KiB
Rust
625 lines
26 KiB
Rust
//! Lobby page: login prompt, match creation and joining by code.
|
||
//!
|
||
//! Match creation is game-generic: the form is rendered from the selected
|
||
//! game's `options_schema` (see [`crate::model::GameTypeInfo`]), so new
|
||
//! games get a working creation UI without frontend changes.
|
||
use std::cell::{Cell, RefCell};
|
||
use std::collections::HashMap;
|
||
use std::rc::Rc;
|
||
|
||
use wasm_bindgen_futures::spawn_local;
|
||
use sycamore::prelude::*;
|
||
use sycamore_router::navigate;
|
||
|
||
use crate::api;
|
||
use crate::components::card::card_back;
|
||
use crate::components::toast::toast;
|
||
use crate::model::{GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User};
|
||
|
||
/// Used when the game-types fetch fails: match creation must still work.
|
||
fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||
vec![GameTypeInfo {
|
||
id: "scopone_scientifico".to_string(),
|
||
name: "Scopone scientifico".to_string(),
|
||
description: String::new(),
|
||
min_players: 4,
|
||
max_players: 4,
|
||
options_schema: serde_json::Value::Null,
|
||
}]
|
||
}
|
||
|
||
fn json_to_string(value: &serde_json::Value) -> String {
|
||
match value {
|
||
serde_json::Value::String(s) => s.clone(),
|
||
serde_json::Value::Number(n) => n.to_string(),
|
||
serde_json::Value::Bool(b) => b.to_string(),
|
||
_ => String::new(),
|
||
}
|
||
}
|
||
|
||
/// One creation option: renders the control matching the schema kind and
|
||
/// writes the parsed value back into the shared `options` map.
|
||
#[derive(Props)]
|
||
struct OptionInputProps {
|
||
field: OptionField,
|
||
options: Signal<HashMap<String, serde_json::Value>>,
|
||
}
|
||
|
||
#[component]
|
||
fn OptionInput(props: OptionInputProps) -> View {
|
||
let field = props.field;
|
||
let options: Signal<HashMap<String, serde_json::Value>> = props.options;
|
||
let key = field.key.clone();
|
||
let title = field.title.clone();
|
||
match field.kind.clone() {
|
||
OptionKind::Boolean => {
|
||
let initial = options
|
||
.get_clone()
|
||
.get(&key)
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or_else(|| field.default.as_bool().unwrap_or(false));
|
||
let flag = create_signal(initial);
|
||
let write_key = key.clone();
|
||
let id_attr = key.clone();
|
||
let for_attr = key;
|
||
create_effect(move || {
|
||
let value = flag.get_clone();
|
||
options.update(|m| {
|
||
m.insert(write_key.clone(), serde_json::Value::Bool(value));
|
||
});
|
||
});
|
||
view! {
|
||
div(class="form-check mb-3") {
|
||
input(
|
||
class="form-check-input",
|
||
id=id_attr,
|
||
r#type="checkbox",
|
||
bind:checked=flag,
|
||
)
|
||
label(class="form-check-label", r#for=for_attr) { (title) }
|
||
}
|
||
}
|
||
}
|
||
OptionKind::Enum { values } => {
|
||
let initial = options
|
||
.get_clone()
|
||
.get(&key)
|
||
.map(json_to_string)
|
||
.unwrap_or_else(|| json_to_string(&field.default));
|
||
let value = create_signal(initial);
|
||
let default = field.default.clone();
|
||
let write_key = key.clone();
|
||
create_effect(move || {
|
||
let text = value.get_clone();
|
||
let parsed = if text.is_empty() {
|
||
default.clone()
|
||
} else {
|
||
serde_json::Value::String(text)
|
||
};
|
||
options.update(|m| {
|
||
m.insert(write_key.clone(), parsed);
|
||
});
|
||
});
|
||
let items = create_signal(values);
|
||
let id_attr = key.clone();
|
||
let for_attr = key;
|
||
view! {
|
||
div(class="mb-3") {
|
||
label(class="form-label", r#for=for_attr) { (title) }
|
||
select(class="form-select", id=id_attr, bind:value=value) {
|
||
Keyed(
|
||
list=items,
|
||
view=|v| {
|
||
let val = v.clone();
|
||
let label = v;
|
||
view! { option(value=val) { (label) } }
|
||
},
|
||
key=|v| v.clone(),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
OptionKind::Integer { min, max } => {
|
||
let initial = options
|
||
.get_clone()
|
||
.get(&key)
|
||
.map(json_to_string)
|
||
.unwrap_or_else(|| json_to_string(&field.default));
|
||
let value = create_signal(initial);
|
||
let default = field.default.clone();
|
||
let write_key = key.clone();
|
||
create_effect(move || {
|
||
let text = value.get_clone();
|
||
let parsed = text
|
||
.parse::<i64>()
|
||
.map(serde_json::Value::from)
|
||
.unwrap_or_else(|_| default.clone());
|
||
options.update(|m| {
|
||
m.insert(write_key.clone(), parsed);
|
||
});
|
||
});
|
||
let min_attr = min.map(|m| m.to_string()).unwrap_or_default();
|
||
let max_attr = max.map(|m| m.to_string()).unwrap_or_default();
|
||
let id_attr = key.clone();
|
||
let for_attr = key;
|
||
view! {
|
||
div(class="mb-3") {
|
||
label(class="form-label", r#for=for_attr) { (title) }
|
||
input(
|
||
class="form-control",
|
||
id=id_attr,
|
||
r#type="number",
|
||
min=min_attr,
|
||
max=max_attr,
|
||
bind:value=value,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
OptionKind::Text => {
|
||
let initial = options
|
||
.get_clone()
|
||
.get(&key)
|
||
.map(json_to_string)
|
||
.unwrap_or_else(|| json_to_string(&field.default));
|
||
let value = create_signal(initial);
|
||
let write_key = key.clone();
|
||
create_effect(move || {
|
||
let text = value.get_clone();
|
||
options.update(|m| {
|
||
m.insert(write_key.clone(), serde_json::Value::String(text));
|
||
});
|
||
});
|
||
let id_attr = key.clone();
|
||
let for_attr = key;
|
||
view! {
|
||
div(class="mb-3") {
|
||
label(class="form-label", r#for=for_attr) { (title) }
|
||
input(class="form-control", id=id_attr, r#type="text", bind:value=value)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pre-join summary of the match behind the entered code: game name,
|
||
/// seated players and the engine-echoed options, rendered from the game's
|
||
/// `options_schema` so every game gets a preview without custom code.
|
||
fn join_preview_panel(
|
||
preview: Option<JoinPreview>,
|
||
game_types: Vec<GameTypeInfo>,
|
||
checking: bool,
|
||
not_found: bool,
|
||
) -> View {
|
||
let Some(p) = preview else {
|
||
if checking {
|
||
return view! {
|
||
p(class="text-secondary small mb-0") { "Checking code…" }
|
||
};
|
||
}
|
||
if not_found {
|
||
return view! {
|
||
p(class="text-secondary small mb-0") { "No match with this code." }
|
||
};
|
||
}
|
||
return view! {};
|
||
};
|
||
|
||
let game = game_types.into_iter().find(|g| g.id == p.game_type);
|
||
let name = if p.game_name.is_empty() {
|
||
game.as_ref()
|
||
.map(|g| g.name.clone())
|
||
.unwrap_or_else(|| p.game_type.clone())
|
||
} else {
|
||
p.game_name.clone()
|
||
};
|
||
let description = if p.game_description.is_empty() {
|
||
game.as_ref().map(|g| g.description.clone()).unwrap_or_default()
|
||
} else {
|
||
p.game_description.clone()
|
||
};
|
||
let description_view = (!description.is_empty()).then(|| view! {
|
||
p(class="text-secondary small mb-2") { (description) }
|
||
});
|
||
let option_badges = game
|
||
.as_ref()
|
||
.map(|g| p.options(&g.option_fields()))
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.map(|o| {
|
||
let cls = if o.on == Some(true) {
|
||
"badge text-bg-success me-1"
|
||
} else {
|
||
"badge text-bg-secondary me-1"
|
||
};
|
||
view! { span(class=cls) { (o.label) ": " (o.value) } }
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let seated = p.players.len();
|
||
let players = p
|
||
.players
|
||
.iter()
|
||
.map(|player| {
|
||
let name = player.name.clone();
|
||
let team = player.team.as_ref().map(|t| {
|
||
let label = t.clone();
|
||
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
|
||
});
|
||
view! {
|
||
li(
|
||
class="list-group-item d-flex justify-content-between \
|
||
align-items-center py-1 px-0"
|
||
) {
|
||
span { (name) }
|
||
(team)
|
||
}
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let seats_text = p.seats_text();
|
||
let status_view = match p.phase.as_str() {
|
||
"lobby" if p.seats_open == Some(0) => Some("Lobby full"),
|
||
"lobby" => None,
|
||
_ => Some("Match in progress"),
|
||
}
|
||
.map(|text| view! { p(class="text-warning small mb-0") { (text) } });
|
||
|
||
view! {
|
||
div(class="join-preview border-top mt-3 pt-3") {
|
||
h3(class="h6 mb-1") { (name) }
|
||
(description_view)
|
||
p(class="text-secondary small mb-2") {
|
||
(seated) " seated" (seats_text)
|
||
}
|
||
ul(class="list-group list-group-flush mb-2") { (players) }
|
||
div(class="d-flex flex-wrap gap-1") { (option_badges) }
|
||
(status_view)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[component]
|
||
pub fn LobbyPage() -> View {
|
||
// Outer None = still loading; Some(None) = logged out.
|
||
let user = create_signal(Option::<Option<User>>::None);
|
||
// The player's Elo ratings, one row per game type played.
|
||
let ratings = create_signal(Vec::<PlayerRating>::new());
|
||
let error = create_signal(Option::<String>::None);
|
||
let code = create_signal(String::new());
|
||
let game_types = create_signal(fallback_game_types());
|
||
let selected_game = create_signal("scopone_scientifico".to_string());
|
||
// Current creation-form values (option key -> JSON value), reset to
|
||
// the schema defaults whenever the selection changes.
|
||
let options = create_signal(HashMap::<String, serde_json::Value>::new());
|
||
let fields = create_signal(Vec::<OptionField>::new());
|
||
|
||
create_effect(move || {
|
||
let id = selected_game.get_clone();
|
||
let game = game_types.get_clone().into_iter().find(|g| g.id == id);
|
||
match game {
|
||
Some(g) => {
|
||
options.set(g.default_options().into_iter().collect());
|
||
fields.set(g.option_fields());
|
||
}
|
||
None => {
|
||
options.set(HashMap::new());
|
||
fields.set(Vec::new());
|
||
}
|
||
}
|
||
});
|
||
|
||
// Pre-join preview: once a complete code is typed, look it up
|
||
// (debounced) and show the match's details before joining.
|
||
let preview = create_signal(Option::<JoinPreview>::None);
|
||
let preview_not_found = create_signal(false);
|
||
let checking_code = create_signal(false);
|
||
let check_seq = Rc::new(Cell::new(0u64));
|
||
let check_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
|
||
Rc::new(RefCell::new(None));
|
||
let alive = Rc::new(Cell::new(true));
|
||
on_cleanup({
|
||
let check_timer = check_timer.clone();
|
||
let alive = alive.clone();
|
||
move || {
|
||
alive.set(false);
|
||
check_timer.borrow_mut().take();
|
||
}
|
||
});
|
||
create_effect({
|
||
let check_seq = check_seq.clone();
|
||
let check_timer = check_timer.clone();
|
||
let alive = alive.clone();
|
||
move || {
|
||
let value = code.get_clone().trim().to_uppercase();
|
||
// Any edit invalidates an in-flight lookup.
|
||
check_seq.set(check_seq.get() + 1);
|
||
check_timer.borrow_mut().take();
|
||
if value.len() != 6 {
|
||
preview.set(None);
|
||
preview_not_found.set(false);
|
||
checking_code.set(false);
|
||
return;
|
||
}
|
||
checking_code.set(true);
|
||
preview_not_found.set(false);
|
||
let seq = check_seq.get();
|
||
let seq_cell = check_seq.clone();
|
||
let alive_async = alive.clone();
|
||
*check_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
|
||
400,
|
||
move || {
|
||
let seq_cell = seq_cell.clone();
|
||
let alive = alive_async.clone();
|
||
spawn_local(async move {
|
||
let result = api::join_preview(&value).await;
|
||
if !alive.get() || seq_cell.get() != seq {
|
||
return;
|
||
}
|
||
match result {
|
||
Ok(Some(found)) => preview.set(Some(found)),
|
||
Ok(None) => {
|
||
preview.set(None);
|
||
preview_not_found.set(true);
|
||
}
|
||
Err(_) => preview.set(None),
|
||
}
|
||
checking_code.set(false);
|
||
});
|
||
},
|
||
));
|
||
}
|
||
});
|
||
|
||
spawn_local(async move {
|
||
match api::me().await {
|
||
Ok(me) => {
|
||
if me.is_some() {
|
||
spawn_local(async move {
|
||
if let Ok(p) = api::my_ratings().await {
|
||
ratings.set(p.results);
|
||
}
|
||
});
|
||
}
|
||
user.set(Some(me));
|
||
}
|
||
Err(e) => {
|
||
error.set(Some(e));
|
||
user.set(Some(None));
|
||
}
|
||
}
|
||
});
|
||
|
||
spawn_local(async move {
|
||
match api::game_types().await {
|
||
Ok(types) if !types.is_empty() => {
|
||
selected_game.set(types[0].id.clone());
|
||
game_types.set(types);
|
||
}
|
||
_ => {} // keep the scopone fallback
|
||
}
|
||
});
|
||
|
||
let on_create = move |_| {
|
||
let game_type = selected_game.get_clone();
|
||
let opts: serde_json::Map<String, serde_json::Value> =
|
||
options.get_clone().into_iter().collect();
|
||
spawn_local(async move {
|
||
match api::create_game(&game_type, serde_json::Value::Object(opts)).await {
|
||
Ok(game) => navigate(&format!("/game/{}", game.id)),
|
||
Err(e) => error.set(Some(e)),
|
||
}
|
||
});
|
||
};
|
||
|
||
let on_join = move |_| {
|
||
let value = code.get_clone().trim().to_uppercase();
|
||
if value.is_empty() {
|
||
return;
|
||
}
|
||
spawn_local(async move {
|
||
match api::join_game(&value).await {
|
||
Ok(game) => navigate(&format!("/game/{}", game.id)),
|
||
Err(e) => error.set(Some(e)),
|
||
}
|
||
});
|
||
};
|
||
|
||
// Join is disabled only when the previewed lobby is full or already
|
||
// started; without a preview it stays enabled (the server still
|
||
// validates).
|
||
let join_button = move || {
|
||
let disabled = preview.get_clone().map(|p| !p.joinable()).unwrap_or(false);
|
||
view! {
|
||
button(
|
||
class="btn btn-warning btn-lg",
|
||
disabled=disabled,
|
||
on:click=on_join,
|
||
) {
|
||
"Join"
|
||
}
|
||
}
|
||
};
|
||
|
||
view! {
|
||
div {
|
||
(toast(error))
|
||
(move || match user.get_clone() {
|
||
None => view! {
|
||
div(class="d-flex justify-content-center p-5") {
|
||
div(class="spinner-border text-warning", role="status") {
|
||
span(class="visually-hidden") { "Loading…" }
|
||
}
|
||
}
|
||
},
|
||
Some(None) => view! {
|
||
div(class="status-card") {
|
||
div(class="card") {
|
||
div(class="card-body p-4") {
|
||
h1(class="h3 mb-3") {
|
||
span(class="text-warning") { "♠" }
|
||
" tavolo"
|
||
}
|
||
p(class="text-secondary") { "Log in with your account to play." }
|
||
// rel="external" opts out of the SPA router's click
|
||
// interception: this must be a full page navigation
|
||
// to the backend's OIDC login endpoint.
|
||
a(
|
||
class="btn btn-warning btn-lg w-100",
|
||
href="/auth/login",
|
||
rel="external",
|
||
) {
|
||
"Log in"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
Some(Some(_me)) => {
|
||
view! {
|
||
(crate::components::nav::app_nav("/", move || {
|
||
let name = user
|
||
.get_clone()
|
||
.flatten()
|
||
.map(|u| u.name)
|
||
.unwrap_or_default();
|
||
view! {
|
||
span(class="navbar-text me-3") {
|
||
"Signed in as " strong { (name) }
|
||
(move || ratings
|
||
.get_clone()
|
||
.into_iter()
|
||
.find(|r| r.game_type == selected_game.get_clone())
|
||
.map(|r| view! {
|
||
span(class="badge text-bg-warning ms-2") {
|
||
"Elo " (r.rating)
|
||
}
|
||
}))
|
||
}
|
||
a(
|
||
class="btn btn-sm btn-outline-light",
|
||
href="/auth/logout",
|
||
rel="external",
|
||
) {
|
||
"Log out"
|
||
}
|
||
}
|
||
}))
|
||
div(class="container page-shell py-4") {
|
||
div(class="lobby-hero text-center") {
|
||
h1(class="display-5 fw-bold") { "tavolo" }
|
||
p(class="lead text-secondary mb-0") {
|
||
"Play scopone scientifico with friends."
|
||
}
|
||
}
|
||
div(class="row g-4") {
|
||
div(class="col-lg-7") {
|
||
div(class="card h-100") {
|
||
div(class="card-header") { "New match" }
|
||
div(class="card-body d-flex flex-column") {
|
||
div(class="mb-3") {
|
||
label(class="form-label", r#for="game-type") {
|
||
"Game"
|
||
}
|
||
select(
|
||
class="form-select",
|
||
id="game-type",
|
||
bind:value=selected_game,
|
||
) {
|
||
Keyed(
|
||
list=game_types,
|
||
view=|g| view! {
|
||
option(value=g.id.clone()) { (g.name) }
|
||
},
|
||
key=|g| g.id.clone(),
|
||
)
|
||
}
|
||
}
|
||
(move || game_types
|
||
.get_clone()
|
||
.into_iter()
|
||
.find(|g| g.id == selected_game.get_clone())
|
||
.map(|g| {
|
||
let description: View = if g.description.is_empty() {
|
||
view! {}
|
||
} else {
|
||
let text = g.description.clone();
|
||
view! {
|
||
p(class="text-secondary small") { (text) }
|
||
}
|
||
};
|
||
let players_note: View = match (g.min_players, g.max_players) {
|
||
(0, 0) => view! {},
|
||
(min, max) if min == max => view! {
|
||
span(class="badge text-bg-secondary") {
|
||
(min) " players"
|
||
}
|
||
},
|
||
(min, max) => view! {
|
||
span(class="badge text-bg-secondary") {
|
||
(min) "–" (max) " players"
|
||
}
|
||
},
|
||
};
|
||
view! {
|
||
(description)
|
||
div(class="mb-3") { (players_note) }
|
||
}
|
||
}))
|
||
Keyed(
|
||
list=fields,
|
||
view=move |f| view! {
|
||
OptionInput(field=f.clone(), options=options)
|
||
},
|
||
key=|f| f.key.clone(),
|
||
)
|
||
button(
|
||
class="btn btn-warning btn-lg w-100 mt-auto",
|
||
on:click=on_create,
|
||
) {
|
||
"Create match"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
div(class="col-lg-5") {
|
||
div(class="card h-100") {
|
||
div(class="card-header") { "Join with a code" }
|
||
div(class="card-body d-flex flex-column") {
|
||
p(class="text-secondary") {
|
||
"Got a 6-letter code from a friend? Enter it \
|
||
here to take a seat."
|
||
}
|
||
div(class="d-flex justify-content-center gap-3 my-4") {
|
||
(card_back("table-card"))
|
||
(card_back("table-card"))
|
||
(card_back("table-card"))
|
||
}
|
||
div(class="d-flex gap-2 mt-auto") {
|
||
input(
|
||
class="form-control form-control-lg code-input",
|
||
r#type="text",
|
||
placeholder="CODE",
|
||
maxlength="6",
|
||
bind:value=code,
|
||
)
|
||
(join_button)
|
||
}
|
||
(move || join_preview_panel(
|
||
preview.get_clone(),
|
||
game_types.get_clone(),
|
||
checking_code.get(),
|
||
preview_not_found.get(),
|
||
))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|