diff --git a/server/README.md b/server/README.md index c428e38..d754088 100644 --- a/server/README.md +++ b/server/README.md @@ -191,6 +191,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, | `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` | | `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back | +| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) | | `GET` | `/api/me/ratings` | The caller's Elo rating per game type | diff --git a/server/packages/tavolo-platform/src/tavolo/platform/engine.py b/server/packages/tavolo-platform/src/tavolo/platform/engine.py index b422461..3fee068 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/engine.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/engine.py @@ -201,7 +201,13 @@ class GameEngine(ABC): @abstractmethod def lobby_view(self, session: GameSession) -> Dict[str, Any]: - """Game-specific fields of the lobby payload (options echo, phase).""" + """Game-specific fields of the lobby payload (options echo, phase). + + Echo the creation options under the same keys as + :attr:`options_schema`, plus ``phase``, so clients can render a + generic pre-join preview (``GET /api/games/preview``) from the + schema without game-specific code. + """ @abstractmethod def in_lobby(self, session: GameSession) -> bool: diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py index 3a14a52..7a237ed 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py @@ -24,7 +24,14 @@ from kaya.openapi import operation from ..auth import display_name, require_auth from ..engine import GameSession, Seat from ..errors import GameError -from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json +from ..http import ( + JsonRequestError, + extract_query_params, + read_json, + read_json_optional, + send_error, + send_json, +) if TYPE_CHECKING: from ..mixin import Platform @@ -79,6 +86,18 @@ def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[ } +def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]: + """The join-code preview: the lobby payload plus the game's name and + description, so a client can render a pre-join summary without a + second lookup.""" + engine = platform.registry.require(session.game_type) + return { + **_lobby_payload(platform, session), + "game_name": engine.name, + "game_description": engine.description, + } + + def register(app: KayaApp, platform: "Platform") -> None: @app.GET("/api/game-types") @operation(summary="List available games", @@ -262,6 +281,33 @@ def register(app: KayaApp, platform: "Platform") -> None: log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat) await send_json(ctx, 200, _view_payload(platform, session, user.sub)) + @app.GET("/api/games/preview") + @operation(summary="Preview a game by join code", + description="Looks up a game by its join code without " + "joining, for the lobby's pre-join summary: game " + "name and description, seated players, open " + "seats and the game-specific options echoed by " + "its engine.", + tags=["games"], + responses={ + 200: {"description": "The lobby preview"}, + 400: {"description": "Missing code"}, + 401: {"description": "Authentication required"}, + 404: {"description": "Unknown join code"}, + }) + @require_auth(platform.oidc) + async def preview_game(ctx: HttpContext) -> None: + values = extract_query_params(ctx.query_string).get("code") + code = values[0].strip() if values else "" + if not code: + await send_error(ctx, 400, "code is required") + return + session = await platform.game_store.find_by_code(code) + if session is None: + await send_error(ctx, 404, "unknown join code") + return + await send_json(ctx, 200, _preview_payload(platform, session)) + @app.GET("/api/games/${game_id}") @operation(summary="Get a game snapshot", description="Only seated players may read a game; the view is " diff --git a/server/packages/tavolo-platform/tests/test_routes.py b/server/packages/tavolo-platform/tests/test_routes.py index 6108d20..ad4855a 100644 --- a/server/packages/tavolo-platform/tests/test_routes.py +++ b/server/packages/tavolo-platform/tests/test_routes.py @@ -69,6 +69,56 @@ class GamesRouteTest(unittest.TestCase): self.assertEqual(0, state["plays"]) self.assertTrue(state["viewer_seated"]) + @async_test + async def test_preview_requires_auth(self) -> None: + app, _, _ = make_platform() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + response = await client.get("/api/games/preview?code=ABC123") + self.assertEqual(401, response.status_code) + + @async_test + async def test_preview_lobby_and_started_game(self) -> None: + app, platform, _ = make_platform() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user(platform.oidc, "alice"): + created = await client.post( + "/api/games", json={"options": {"target": 5}} + ) + code = created.json()["join_code"] + + with oidc_user(platform.oidc, "mallory"): + missing = await client.get("/api/games/preview") + unknown = await client.get("/api/games/preview?code=ZZZZZZ") + self.assertEqual(400, missing.status_code) + self.assertEqual(404, unknown.status_code) + + with oidc_user(platform.oidc, "mallory"): + preview = await client.get(f"/api/games/preview?code={code}") + self.assertEqual(200, preview.status_code) + body = preview.json() + self.assertEqual("dummy", body["game_type"]) + self.assertEqual("Dummy game", body["game_name"]) + self.assertTrue(body["game_description"]) + self.assertEqual("lobby", body["phase"]) + self.assertEqual(5, body["target"]) + self.assertEqual(1, body["seats_open"]) + self.assertEqual("alice", body["players"][0]["sub"]) + + # Join codes are case-insensitive. + with oidc_user(platform.oidc, "mallory"): + lower = await client.get(f"/api/games/preview?code={code.lower()}") + self.assertEqual(200, lower.status_code) + + with oidc_user(platform.oidc, "bob"): + await client.post("/api/games/join", json={"code": code}) + with oidc_user(platform.oidc, "mallory"): + started = await client.get(f"/api/games/preview?code={code}") + self.assertEqual(200, started.status_code) + self.assertEqual("playing", started.json()["phase"]) + self.assertEqual(0, started.json()["seats_open"]) + @async_test async def test_create_rejects_bad_options(self) -> None: app, platform, _ = make_platform() diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index ffecacc..9253b7d 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -116,6 +116,55 @@ class GamesRouteTest(unittest.TestCase): ) self.assertEqual(400, invalid.status_code) + @async_test + async def test_preview_join_code(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + created = await client.post( + "/api/games", + json={ + "options": { + "target_score": 16, + "napola": False, + "asso_pigliatutto": True, + } + }, + ) + code = created.json()["join_code"] + + response = await client.get(f"/api/games/preview?code={code}") + self.assertEqual(401, response.status_code) + + with oidc_user("bob"): + missing = await client.get("/api/games/preview") + unknown = await client.get("/api/games/preview?code=ZZZZZZ") + self.assertEqual(400, missing.status_code) + self.assertEqual(404, unknown.status_code) + + with oidc_user("bob"): + preview = await client.get(f"/api/games/preview?code={code}") + self.assertEqual(200, preview.status_code) + body = preview.json() + self.assertEqual("scopone_scientifico", body["game_type"]) + self.assertEqual("Scopone scientifico", body["game_name"]) + self.assertTrue(body["game_description"]) + self.assertEqual("lobby", body["phase"]) + self.assertEqual(16, body["target_score"]) + self.assertFalse(body["napola"]) + self.assertTrue(body["asso_pigliatutto"]) + self.assertEqual(3, body["seats_open"]) + self.assertEqual(["alice"], [p["sub"] for p in body["players"]]) + + for player in ("bob", "carol", "dave"): + with oidc_user(player): + await client.post("/api/games/join", json={"code": code}) + with oidc_user("bob"): + started = await client.get(f"/api/games/preview?code={code}") + self.assertEqual(200, started.status_code) + self.assertEqual("playing", started.json()["phase"]) + self.assertEqual(0, started.json()["seats_open"]) + @async_test async def test_join_errors(self) -> None: transport = ASGITransport(app=app) diff --git a/web/src/api.rs b/web/src/api.rs index fd47ad7..1c61413 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -70,6 +70,23 @@ pub async fn join_game(code: &str) -> Result { resp.json().await.map_err(|e| e.to_string()) } +/// Look up a game by join code without joining, for the pre-join summary. +/// `Ok(None)` means the code names no live game (404); other failures are +/// `Err`. +pub async fn join_preview(code: &str) -> Result, String> { + let resp = Request::get(&format!("/api/games/preview?code={code}")) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 404 { + return Ok(None); + } + if !resp.ok() { + return Err(server_error(resp.status())); + } + resp.json().await.map(Some).map_err(|e| e.to_string()) +} + #[allow(dead_code)] pub async fn game_state(game_id: &str) -> Result { let resp = Request::get(&format!("/api/games/{game_id}")) diff --git a/web/src/model.rs b/web/src/model.rs index 4a2adfa..3af51aa 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -337,6 +337,83 @@ pub struct GameTypesPage { pub results: Vec, } +/// A join-code lookup result (GET /api/games/preview): the game's lobby +/// payload plus its display name, for the pre-join summary. The options +/// echoed by the engine land in `details` under their `options_schema` +/// keys. +#[derive(Debug, Clone, Deserialize)] +pub struct JoinPreview { + #[serde(default)] + pub game_type: String, + #[serde(default)] + pub game_name: String, + #[serde(default)] + pub game_description: String, + #[serde(default)] + pub players: Vec, + #[serde(default)] + pub seats_open: Option, + #[serde(default)] + pub phase: String, + #[serde(flatten)] + pub details: HashMap, +} + +/// One seated player in a join preview. +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct PreviewPlayer { + pub name: String, + #[serde(default)] + pub seat: usize, + #[serde(default)] + pub team: Option, +} + +/// One option to show in the join preview, rendered from the game's +/// schema and the engine-echoed value. +#[derive(Debug, Clone, PartialEq)] +pub struct PreviewOption { + pub label: String, + pub value: String, + /// Present for booleans, for on/off styling. + pub on: Option, +} + +impl JoinPreview { + /// Whether the lobby is still waiting for players with a free seat. + pub fn joinable(&self) -> bool { + self.phase == "lobby" && self.seats_open.map(|n| n > 0).unwrap_or(true) + } + + /// The open-seat note for the preview, e.g. `" · 2 seats open"` (empty + /// when the engine does not report a seat count). + pub fn seats_text(&self) -> String { + match self.seats_open { + Some(1) => " · 1 seat open".to_string(), + Some(n) => format!(" · {n} seats open"), + None => String::new(), + } + } + + /// The options to display: every schema field the engine echoed, in + /// schema order. Fields the engine does not echo are skipped. + pub fn options(&self, fields: &[OptionField]) -> Vec { + fields + .iter() + .filter_map(|field| { + let raw = self.details.get(&field.key)?; + let value = field.format_value(raw)?; + let on = match &field.kind { + OptionKind::Boolean => raw.as_bool(), + _ => None, + }; + Some(PreviewOption { label: field.label.clone(), value, on }) + }) + .collect() + } +} + /// One creation option rendered from a game's `options_schema`. #[derive(Debug, Clone, PartialEq)] pub enum OptionKind { @@ -350,14 +427,52 @@ pub enum OptionKind { pub struct OptionField { pub key: String, pub title: String, + /// Compact label for badges: the schema `title` when present, else the + /// key with underscores turned into spaces. + pub label: String, pub kind: OptionKind, pub default: serde_json::Value, } +impl OptionField { + /// Human-readable rendering of one engine-echoed option value. + pub fn format_value(&self, value: &serde_json::Value) -> Option { + match &self.kind { + OptionKind::Boolean => value + .as_bool() + .map(|b| if b { "On" } else { "Off" }.to_string()), + OptionKind::Integer { .. } => value + .as_i64() + .map(|n| n.to_string()) + .or_else(|| value.as_f64().map(fmt_points)), + OptionKind::Text | OptionKind::Enum { .. } => { + value.as_str().map(str::to_string) + } + } + } +} + fn schema_string(schema: &serde_json::Value, key: &str) -> Option { schema.get(key).and_then(|v| v.as_str()).map(str::to_string) } +/// Turn a schema key into a readable label: `asso_pigliatutto` -> +/// "Asso pigliatutto". +fn humanize_key(key: &str) -> String { + let spaced = key.replace('_', " "); + let spaced = spaced.trim(); + if spaced.is_empty() { + return key.to_string(); + } + let mut chars = spaced.chars(); + let mut out = String::new(); + if let Some(first) = chars.next() { + out.extend(first.to_uppercase()); + out.push_str(chars.as_str()); + } + out +} + fn schema_i64(schema: &serde_json::Value, key: &str) -> Option { schema.get(key).and_then(|v| { v.as_i64().or_else(|| { @@ -378,9 +493,12 @@ impl GameTypeInfo { properties .iter() .filter_map(|(key, schema)| { - let title = schema_string(schema, "title") + let explicit_title = schema_string(schema, "title"); + let title = explicit_title + .clone() .or_else(|| schema_string(schema, "description")) .unwrap_or_else(|| key.clone()); + let label = explicit_title.unwrap_or_else(|| humanize_key(key)); let default = schema.get("default").cloned().unwrap_or(serde_json::Value::Null); let kind = match schema.get("type").and_then(|v| v.as_str()) { Some("integer") => OptionKind::Integer { @@ -403,7 +521,7 @@ impl GameTypeInfo { Some("string") => OptionKind::Text, _ => return None, }; - Some(OptionField { key: key.clone(), title, kind, default }) + Some(OptionField { key: key.clone(), title, label, kind, default }) }) .collect() } @@ -714,6 +832,111 @@ mod tests { assert!(!legacy.pigliatutto); } + #[test] + fn join_preview_parses_scopone_payload() { + let p: JoinPreview = serde_json::from_value(json!({ + "id": "11111111-2222-3333-4444-555555555555", + "join_code": "ABC123", + "game_type": "scopone_scientifico", + "game_name": "Scopone scientifico", + "game_description": "Four players in fixed partnerships.", + "players": [ + {"sub": "alice", "name": "Alice", "seat": 0, "team": "A"}, + ], + "seats_open": 3, + "phase": "lobby", + "target_score": 16, + "napola": false, + "asso_pigliatutto": true, + })) + .unwrap(); + assert_eq!("Scopone scientifico", p.game_name); + assert_eq!("lobby", p.phase); + assert!(p.joinable()); + assert_eq!(Some(3), p.seats_open); + assert_eq!("Alice", p.players[0].name); + assert_eq!(Some("A".to_string()), p.players[0].team); + // Options render from the schema, in schema order; keys the + // engine did not echo are skipped. + let opts = p.options(&scopone_game_type().option_fields()); + assert_eq!(3, opts.len()); + let by_label: HashMap<_, _> = + opts.into_iter().map(|o| (o.label.clone(), o)).collect(); + assert_eq!("Target score", by_label["Target score"].label); + assert_eq!("16", by_label["Target score"].value); + assert_eq!(None, by_label["Target score"].on); + assert_eq!("Napola", by_label["Napola"].label); + assert_eq!("Off", by_label["Napola"].value); + assert_eq!(Some(false), by_label["Napola"].on); + assert_eq!("Asso pigliatutto", by_label["Asso pigliatutto"].label); + assert_eq!("On", by_label["Asso pigliatutto"].value); + assert_eq!(Some(true), by_label["Asso pigliatutto"].on); + } + + #[test] + fn join_preview_joinable_states() { + let started: JoinPreview = serde_json::from_value(json!({ + "game_type": "x", "phase": "playing", "seats_open": 0, + })) + .unwrap(); + assert!(!started.joinable()); + let full: JoinPreview = serde_json::from_value(json!({ + "game_type": "x", "phase": "lobby", "seats_open": 0, + })) + .unwrap(); + assert!(!full.joinable()); + let open: JoinPreview = serde_json::from_value(json!({ + "game_type": "x", "phase": "lobby", "seats_open": 1, + })) + .unwrap(); + assert!(open.joinable()); + assert!(open.options(&scopone_game_type().option_fields()).is_empty()); + // Engines that don't report seats_open are assumed joinable. + let unknown: JoinPreview = serde_json::from_value(json!({ + "game_type": "x", "phase": "lobby", + })) + .unwrap(); + assert!(unknown.joinable()); + assert_eq!("", unknown.seats_text()); + assert_eq!(" · 1 seat open", open.seats_text()); + assert_eq!(" · 0 seats open", full.seats_text()); + let two: JoinPreview = serde_json::from_value(json!({ + "game_type": "x", "phase": "lobby", "seats_open": 2, + })) + .unwrap(); + assert_eq!(" · 2 seats open", two.seats_text()); + } + + #[test] + fn option_field_formats_values() { + let g: GameTypeInfo = serde_json::from_value(json!({ + "id": "x", + "name": "x", + "options_schema": { + "type": "object", + "properties": { + "flag": {"type": "boolean", "title": "Flag", "default": false}, + "count": {"type": "integer", "default": 2}, + "mode": {"type": "string", "enum": ["a", "b"], "default": "a"}, + "note": {"type": "string"}, + }, + }, + })) + .unwrap(); + let fields: HashMap<_, _> = + g.option_fields().into_iter().map(|f| (f.key.clone(), f)).collect(); + assert_eq!(Some("On".to_string()), fields["flag"].format_value(&json!(true))); + assert_eq!(Some("Off".to_string()), fields["flag"].format_value(&json!(false))); + assert_eq!(None, fields["flag"].format_value(&json!("yes"))); + assert_eq!(Some("16".to_string()), fields["count"].format_value(&json!(16))); + assert_eq!(Some("2.5".to_string()), fields["count"].format_value(&json!(2.5))); + assert_eq!(Some("b".to_string()), fields["mode"].format_value(&json!("b"))); + assert_eq!(Some("hi".to_string()), fields["note"].format_value(&json!("hi"))); + // Badges use the schema title, or the humanized key without one. + assert_eq!("Flag", fields["flag"].label); + assert_eq!("Count", fields["count"].label); + } + #[test] fn game_type_without_schema_has_no_fields() { let g: GameTypeInfo = serde_json::from_value(json!({ diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index 5b914b7..f591310 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -3,7 +3,9 @@ //! 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::*; @@ -12,7 +14,7 @@ use sycamore_router::navigate; use crate::api; use crate::components::card::card_back; use crate::components::toast::toast; -use crate::model::{GameTypeInfo, OptionField, OptionKind, PlayerRating, User}; +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 { @@ -181,6 +183,102 @@ fn OptionInput(props: OptionInputProps) -> View { } } +/// 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 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::>(); + 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. @@ -211,6 +309,68 @@ pub fn LobbyPage() -> View { } }); + // 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) => { @@ -265,6 +425,22 @@ pub fn LobbyPage() -> View { }); }; + // 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)) @@ -427,13 +603,14 @@ pub fn LobbyPage() -> View { maxlength="6", bind:value=code, ) - button( - class="btn btn-warning btn-lg", - on:click=on_join, - ) { - "Join" - } + (join_button) } + (move || join_preview_panel( + preview.get_clone(), + game_types.get_clone(), + checking_code.get(), + preview_not_found.get(), + )) } } }