CI / Build and push docker image (push) Successful in 2m9s
GET /api/me now carries the profile picture URL when the provider supplies an absolute http(s) one; the SPA shows it in the lobby navbar and draws a deterministic identicon from the sub otherwise, falling back to the identicon when the image fails to load. The identicon is a 5x5 mirrored grid seeded by an inline FNV-1a hash so it stays stable across Rust versions and is unit-testable on the host. The dev mock IdP hands alice and bob a picture claim.
709 lines
31 KiB
Rust
709 lines
31 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::avatar::avatar;
|
||
use crate::components::card::card_back;
|
||
use crate::components::login::login_card;
|
||
use crate::components::toast::toast;
|
||
use crate::model::{
|
||
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||
MyGame, OptionField, OptionKind, PlayerRating, User,
|
||
};
|
||
|
||
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 show_teams = !is_individual_game(&p.game_type);
|
||
let players = p
|
||
.players
|
||
.iter()
|
||
.map(|player| {
|
||
let name = player.name.clone();
|
||
let team = if show_teams {
|
||
player.team.as_ref().map(|t| {
|
||
let label = t.clone();
|
||
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
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);
|
||
// Configured login providers, for the logged-out card's buttons.
|
||
let auth_providers = create_signal(Vec::<AuthProvider>::new());
|
||
// The player's Elo ratings, one row per game type played.
|
||
let ratings = create_signal(Vec::<PlayerRating>::new());
|
||
// Live games the player is seated in, for the "Continue" list.
|
||
let my_games = create_signal(Vec::<MyGame>::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 {
|
||
if ratings.is_alive() {
|
||
ratings.set(p.results);
|
||
}
|
||
}
|
||
if let Ok(games) = api::my_games().await {
|
||
if my_games.is_alive() {
|
||
my_games.set(games);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
// The page may have been unmounted (e.g. by the login
|
||
// redirect triggered while this request was in flight).
|
||
if user.is_alive() {
|
||
user.set(Some(me));
|
||
}
|
||
}
|
||
Err(e) => {
|
||
if error.is_alive() {
|
||
error.set(Some(e));
|
||
}
|
||
if user.is_alive() {
|
||
user.set(Some(None));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
spawn_local(async move {
|
||
match api::game_types().await {
|
||
Ok(types) if !types.is_empty() => {
|
||
if !game_types.is_alive() {
|
||
return;
|
||
}
|
||
selected_game.set(types[0].id.clone());
|
||
game_types.set(types);
|
||
}
|
||
_ => {} // keep the scopone fallback
|
||
}
|
||
});
|
||
|
||
spawn_local(async move {
|
||
if let Ok(list) = api::auth_providers().await {
|
||
if auth_providers.is_alive() {
|
||
auth_providers.set(list);
|
||
}
|
||
}
|
||
});
|
||
|
||
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! { (login_card(auth_providers)) },
|
||
Some(Some(_me)) => {
|
||
view! {
|
||
(crate::components::nav::app_nav("/", move || {
|
||
let me = user.get_clone().flatten();
|
||
let name = me
|
||
.as_ref()
|
||
.map(|u| u.name.clone())
|
||
.unwrap_or_default();
|
||
let avatar_view = me
|
||
.as_ref()
|
||
.map(|u| avatar(&u.sub, u.picture.as_deref()))
|
||
.unwrap_or_else(|| view! {});
|
||
view! {
|
||
span(class="navbar-text me-3 d-flex align-items-center gap-2") {
|
||
(avatar_view)
|
||
span() {
|
||
"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, scopa and briscola with friends."
|
||
}
|
||
}
|
||
// Ongoing games: a "Continue" link back into a
|
||
// match whose page was accidentally closed.
|
||
(move || {
|
||
let games = my_games.get_clone();
|
||
if games.is_empty() {
|
||
return view! {};
|
||
}
|
||
let types = game_types.get_clone();
|
||
let rows = games
|
||
.into_iter()
|
||
.map(|g| {
|
||
let name = if g.game_name.is_empty() {
|
||
types
|
||
.iter()
|
||
.find(|t| t.id == g.game_type)
|
||
.map(|t| t.name.clone())
|
||
.unwrap_or_else(|| g.game_type.clone())
|
||
} else {
|
||
g.game_name.clone()
|
||
};
|
||
let phase = g.phase_label();
|
||
let seats = g.seats_text();
|
||
let players = g.players_text();
|
||
let players_note = if players.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(" · {players}")
|
||
};
|
||
let href = format!("/game/{}", g.id);
|
||
view! {
|
||
li(
|
||
class="list-group-item d-flex flex-wrap \
|
||
justify-content-between \
|
||
align-items-center gap-2"
|
||
) {
|
||
div {
|
||
div(class="fw-semibold") { (name) }
|
||
div(class="text-secondary small") {
|
||
(phase) " · " (seats)
|
||
(players_note)
|
||
}
|
||
}
|
||
a(
|
||
class="btn btn-sm btn-warning",
|
||
href=href,
|
||
) {
|
||
"Continue"
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
view! {
|
||
div(class="card mb-4") {
|
||
div(class="card-header") { "Your games" }
|
||
div(class="card-body p-0") {
|
||
ul(class="list-group list-group-flush") {
|
||
(rows)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
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) }
|
||
}
|
||
}))
|
||
// Recreate the option inputs on every
|
||
// game switch: keyed rendering kept
|
||
// the previous game's field (value,
|
||
// min/max) for shared keys such as
|
||
// target_score.
|
||
(move || {
|
||
let _game = selected_game.get_clone();
|
||
fields
|
||
.get_clone()
|
||
.into_iter()
|
||
.map(|f| view! {
|
||
OptionInput(field=f, options=options)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
})
|
||
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(),
|
||
))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|