Preview a match by join code before joining
CI / Build and push docker image (push) Successful in 1m22s
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:
8 files changed
+580
-11
No files matched your search
+184
-7
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user