Files
tavolo/web/src/pages/lobby.rs
T
woggioni 46f10f145a
CI / Build and push docker image (push) Successful in 2m9s
Show the OIDC profile picture with an identicon fallback
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.
2026-10-15 00:14:17 +08:00

709 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(),
))
}
}
}
}
}
}
}
})
}
}
}