Preview a match by join code before joining
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.
This commit is contained in:
2026-09-24 09:03:31 +08:00
parent cc0b31ce6b
commit a3566f2083
8 changed files with 580 additions and 11 deletions
+1
View File
@@ -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 |
@@ -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:
@@ -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 "
@@ -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()
+49
View File
@@ -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)
+17
View File
@@ -70,6 +70,23 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
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<Option<JoinPreview>, 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<GameView, String> {
let resp = Request::get(&format!("/api/games/{game_id}"))
+225 -2
View File
@@ -337,6 +337,83 @@ pub struct GameTypesPage {
pub results: Vec<GameTypeInfo>,
}
/// 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<PreviewPlayer>,
#[serde(default)]
pub seats_open: Option<usize>,
#[serde(default)]
pub phase: String,
#[serde(flatten)]
pub details: HashMap<String, serde_json::Value>,
}
/// 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<String>,
}
/// 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<bool>,
}
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<PreviewOption> {
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<String> {
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<String> {
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<i64> {
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!({
+184 -7
View File
@@ -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<GameTypeInfo> {
@@ -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<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.
@@ -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::<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) => {
@@ -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(),
))
}
}
}