//! 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>, } #[component] fn OptionInput(props: OptionInputProps) -> View { let field = props.field; let options: Signal> = 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::() .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, game_types: Vec, 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::>(); 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::>(); 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::>::None); // Configured login providers, for the logged-out card's buttons. let auth_providers = create_signal(Vec::::new()); // The player's Elo ratings, one row per game type played. let ratings = create_signal(Vec::::new()); // Live games the player is seated in, for the "Continue" list. let my_games = create_signal(Vec::::new()); let error = create_signal(Option::::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::::new()); let fields = create_signal(Vec::::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::::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>> = 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 = 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::>(); 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::>() }) 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(), )) } } } } } } } }) } } }