Rebuild the web UI on Bootstrap 5 with the dark felt theme
CI / Build and push docker image (push) Failing after 39s

The lobby was hand-rolled CSS and looked plain. Vendor Bootstrap 5.3
(MIT) under web/vendor so builds stay offline, load it in dark mode and
bridge the felt/gold palette onto Bootstrap's CSS variables in a new
theme.css; style.css now only owns the game table, cards and scoring
summary.

Rebuild the lobby, waiting room and shared chrome with Bootstrap
components: a shared navbar, creation form as cards with schema-driven
controls, roster as a list group with seat/team badges, a seats progress
bar and a copy-to-clipboard button on the join code. History,
leaderboard, connection/error states and the 404 page use cards, tables
and buttons too; the play table itself is unchanged.

The copy feedback timer is cancelled when the waiting room is rebuilt by
another player joining, fixing a disposed-signal panic that froze the
page mid-match.
This commit is contained in:
2026-09-21 23:40:44 +08:00
parent ed8a004ec8
commit 12fa3f5aae
15 changed files with 774 additions and 486 deletions
+1
View File
@@ -1,3 +1,4 @@
pub mod card;
pub mod nav;
pub mod summary;
pub mod toast;
+38
View File
@@ -0,0 +1,38 @@
//! Shared Bootstrap navbar used by the lobby, history and leaderboard pages.
use sycamore::prelude::*;
fn nav_link(href: &'static str, label: &'static str, active: &str) -> View {
let class = if href == active {
"nav-link active"
} else {
"nav-link"
};
let aria = if href == active { "page" } else { "" };
view! {
li(class="nav-item") {
a(class=class, href=href, aria-current=aria) { (label) }
}
}
}
/// Renders the app navbar. `active` is the current route (`/`, `/history`,
/// `/leaderboard`); `whoami` builds the right-aligned slot for the
/// signed-in user and the log-out action.
pub fn app_nav(active: &'static str, whoami: impl Fn() -> View + 'static) -> View {
view! {
nav(class="navbar navbar-expand flex-wrap") {
div(class="container-fluid") {
a(class="navbar-brand", href="/") {
span(class="text-warning") { "♠" }
" tavolo"
}
ul(class="navbar-nav me-auto") {
(nav_link("/", "Lobby", active))
(nav_link("/history", "My matches", active))
(nav_link("/leaderboard", "Leaderboard", active))
}
(move || whoami())
}
}
}
}
+3 -3
View File
@@ -246,7 +246,7 @@ pub fn hand_summary_modal(
// whole modal (which would swap DOM nodes under the user's cursor).
let deadline_ms = js_sys::Date::parse(deadline);
view! {
p(class="hint") {
p(class="text-secondary small") {
"Auto-continuing in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
@@ -263,11 +263,11 @@ pub fn hand_summary_modal(
let action = if already_acked {
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
view! {
button(class="button primary", disabled=true) { (waiting_text) }
button(class="btn btn-warning", disabled=true) { (waiting_text) }
}
} else {
view! {
button(class="button primary", on:click=move |_| {
button(class="btn btn-warning", on:click=move |_| {
if let Some(s) = socket.get_clone() {
s.ack();
}
+1 -1
View File
@@ -15,6 +15,6 @@ pub fn toast(error: Signal<Option<String>>) -> View {
}
});
view! {
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
(move || error.get_clone().map(|e| view! { div(class="app-toast") { (e) } }))
}
}
+9 -3
View File
@@ -42,9 +42,15 @@ fn main() {
AppRoutes::History => view! { HistoryPage() },
AppRoutes::Leaderboard => view! { LeaderboardPage() },
AppRoutes::NotFound => view! {
div(class="panel") {
h1 { "Page not found" }
a(href="/") { "Back to lobby" }
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h4 mb-3") { "Page not found" }
a(class="btn btn-warning", href="/") {
"Back to lobby"
}
}
}
}
},
})
+147 -38
View File
@@ -1,5 +1,6 @@
//! Live game page: table view over the websocket.
use std::cell::Cell;
use std::cell::RefCell;
use std::rc::Rc;
use sycamore::prelude::*;
@@ -190,22 +191,35 @@ fn conn_banner(
}
if fatal {
view! {
div(class="conn-banner") {
"Connection closed by the server. "
a(href="/") { "Back to lobby" }
div(
class="alert alert-warning d-flex align-items-center justify-content-center \
gap-3 mx-3 mt-3 mb-0"
) {
"Connection closed by the server."
a(class="alert-link", href="/") { "Back to lobby" }
}
}
} else if gave_up {
view! {
div(class="conn-banner") {
div(
class="alert alert-warning d-flex align-items-center justify-content-center \
gap-3 mx-3 mt-3 mb-0"
) {
"Connection lost."
button(class="button", on:click=move |_| reconnect()) { "Retry now" }
a(href="/") { "Back to lobby" }
button(
class="btn btn-sm btn-warning",
on:click=move |_| reconnect(),
) {
"Retry now"
}
a(class="alert-link", href="/") { "Back to lobby" }
}
}
} else {
view! {
div(class="conn-banner") { "Connection lost — reconnecting…" }
div(class="alert alert-warning text-center mx-3 mt-3 mb-0") {
"Connection lost — reconnecting…"
}
}
}
}
@@ -296,22 +310,33 @@ pub fn GamePage(id: String) -> View {
None => {
if fatal.get() {
view! {
div(class="panel status-panel") {
p { "Connection closed." }
p { a(href="/") { "Back to lobby" } }
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
p { "Connection closed." }
a(class="btn btn-warning", href="/") { "Back to lobby" }
}
}
}
}
} else if gave_up.get() {
let reconnect = reconnect.clone();
view! {
div(class="panel status-panel") {
p { "Connection lost." }
p {
button(class="button primary", on:click=move |_| reconnect()) {
"Retry now"
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
p { "Connection lost." }
button(
class="btn btn-warning me-2",
on:click=move |_| reconnect(),
) {
"Retry now"
}
a(class="btn btn-outline-light", href="/") {
"Back to lobby"
}
}
}
p { a(href="/") { "Back to lobby" } }
}
}
} else {
@@ -321,14 +346,23 @@ pub fn GamePage(id: String) -> View {
"Connecting to the game…"
};
view! {
div(class="panel status-panel") {
p { (status) }
p { a(href="/") { "Back to lobby" } }
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
div(class="spinner-border text-warning mb-3", role="status") {
span(class="visually-hidden") { "Loading…" }
}
p { (status) }
a(class="btn btn-outline-light", href="/") {
"Back to lobby"
}
}
}
}
}
}
}
Some(g) if g.phase == "lobby" => lobby_view(g),
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
// The live table is scopone-specific; other games get a
// fallback until they ship their own play view.
Some(g) if g.game_type == "scopone_scientifico" => {
@@ -353,11 +387,38 @@ pub fn GamePage(id: String) -> View {
/// Lobby view while waiting for the remaining players. The seat count
/// comes from the payload (`seats_open`), so games with other player
/// counts render correctly.
fn lobby_view(game: GameView) -> View {
#[component(inline_props)]
fn LobbyView(game: GameView) -> View {
let seats_open = game
.seats_open
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
let join_code = game.join_code.clone();
let copied = create_signal(false);
// Held so the reset timer is cancelled if the waiting room is rebuilt
// (e.g. another player joins) while the "Copied!" feedback is showing:
// firing it afterwards would set a disposed signal and panic.
let reset_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
Rc::new(RefCell::new(None));
on_cleanup({
let reset_timer = reset_timer.clone();
move || {
reset_timer.borrow_mut().take();
}
});
let copy = {
let join_code = join_code.clone();
move |_| {
if let Some(win) = web_sys::window() {
let _ = win.navigator().clipboard().write_text(&join_code);
}
copied.set(true);
*reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(1500, move || {
copied.set(false);
}));
}
};
let players = game
.players
.iter()
@@ -366,21 +427,61 @@ fn lobby_view(game: GameView) -> View {
let seat = p.seat;
let team = p.team.clone();
view! {
li {
li(class="list-group-item d-flex justify-content-between align-items-center") {
strong { (name) }
span { " (seat " (seat) ", team " (team) ")" }
span {
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
span(class="badge text-bg-info") { "Team " (team) }
}
}
}
})
.collect::<Vec<_>>();
let open_seats = (0..seats_open)
.map(|_| view! {
li(class="list-group-item seat-open") { "Waiting for a player…" }
})
.collect::<Vec<_>>();
let seated = game.players.len();
let total = seated + seats_open;
let percent = if total == 0 {
0
} else {
seated * 100 / total
};
let percent_style = format!("width: {percent}%");
view! {
div(class="panel status-panel") {
h2 { "Waiting for players" }
p { "Share this join code:" }
p(class="join-code") { (join_code) }
ul(class="roster") { (players) }
p { (seats_open) " seat(s) still open" }
p { a(href="/") { "Back to lobby" } }
div(class="container page-shell py-4") {
div(class="card status-card") {
div(class="card-body p-4") {
h1(class="h4 mb-3") { "Waiting for players" }
p(class="text-secondary") { "Share this join code with your friends:" }
p(class="code-display") { (join_code) }
div(class="d-grid mb-4") {
button(class="btn btn-outline-light", on:click=copy) {
(move || if copied.get() { "Copied!" } else { "Copy code" })
}
}
ul(class="list-group list-group-flush text-start mb-3") {
(players)
(open_seats)
}
div(
class="progress mb-2",
role="progressbar",
aria-valuenow=percent.to_string(),
aria-valuemin="0",
aria-valuemax="100",
) {
div(class="progress-fill", style=percent_style) {}
}
p(class="text-secondary small") {
(seated) " of " (total) " players seated"
}
a(class="btn btn-warning", href="/") { "Back to lobby" }
}
}
}
}
}
@@ -389,10 +490,16 @@ fn lobby_view(game: GameView) -> View {
fn unsupported_game_view(game: GameView) -> View {
let game_type = game.game_type.clone();
view! {
div(class="panel status-panel") {
h2 { "Unsupported game" }
p { "Live play for “" (game_type) "” isn't supported in the web client yet." }
p { a(href="/") { "Back to lobby" } }
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h4 mb-3") { "Unsupported game" }
p(class="text-secondary") {
"Live play for “" (game_type) "” isn't supported in the web client yet."
}
a(class="btn btn-warning", href="/") { "Back to lobby" }
}
}
}
}
}
@@ -484,7 +591,7 @@ fn table_view(
})
.collect::<Vec<_>>();
let hint = my_turn.then(|| view! {
p(class="hint") {
p(class="text-secondary small text-center") {
"Click a card to play it. If it can capture in several ways you "
"will be asked to choose."
}
@@ -554,7 +661,9 @@ fn capture_picker(
div(class="picker") {
h3 { (title) }
div(class="capture-options") { (option_views) }
button(class="button", on:click=move |_| capture_choice.set(None)) { "Cancel" }
button(class="btn btn-outline-light", on:click=move |_| capture_choice.set(None)) {
"Cancel"
}
}
}
}
@@ -592,8 +701,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
(final_summary)
p(class="final-score") { (line) }
div(class="gameover-actions") {
a(class="button primary", href="/") { "Back to lobby" }
a(class="button", href="/history") { "My matches" }
a(class="btn btn-warning", href="/") { "Back to lobby" }
a(class="btn btn-outline-light", href="/history") { "My matches" }
}
}
}
+107 -93
View File
@@ -109,104 +109,118 @@ pub fn HistoryPage() -> View {
});
view! {
div(class="page") {
nav(class="top-nav") {
a(href="/") { "← Lobby" }
a(href="/leaderboard") { "Leaderboard" }
}
h1 { "My matches" }
(toast(error))
(move || match page.get_clone() {
None => view! { p(class="status") { "Loading…" } },
Some(_) if rows.get_clone().is_empty() => view! {
p(class="status") { "No matches played yet." }
},
Some(_) => {
let names = game_types.get_clone();
let table_rows = rows
.get_clone()
.into_iter()
.map(|m| {
let finished = m
.finished_at
.replace('T', " ")
.chars()
.take(16)
.collect::<String>();
let game = game_name(&names, &m.game_type);
let mut cells: Vec<View> = team_columns(&m)
.iter()
.map(|t| team_names(&m, t))
.map(|c| view! { td { (c) } })
.collect();
let score = score_text(&m);
let winner = winner_text(&m);
let outcome = if m.you_won { "Won" } else { "Lost" };
let elo_delta = m
.your_elo_delta
.map(|d| {
if d >= 0 {
format!("+{d}")
} else {
d.to_string()
}
})
.unwrap_or_else(|| "—".to_string());
let cls = if m.you_won { "won" } else { "lost" };
let mut row_cells = vec![
view! { td { (finished) } },
view! { td { (game) } },
];
row_cells.append(&mut cells);
row_cells.push(view! { td { (score) } });
row_cells.push(view! { td { (winner) } });
row_cells.push(view! { td(class=cls) { (outcome) } });
row_cells.push(view! { td(class=cls) { (elo_delta) } });
view! { tr { (row_cells) } }
})
.collect::<Vec<_>>();
// Team columns are per-row (matches may mix games), so
// the header shows the union across the loaded rows.
let mut header_teams = BTreeSet::new();
for m in rows.get_clone().iter() {
for t in team_columns(m) {
header_teams.insert(t);
div {
(crate::components::nav::app_nav("/history", || view! {}))
div(class="container page-shell py-4") {
h1(class="h3 mb-4") { "My matches" }
(toast(error))
(move || match page.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…" }
}
}
}
let mut header_teams: Vec<Option<String>> =
header_teams.into_iter().collect();
header_teams.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let header_cells: Vec<View> = header_teams
.iter()
.map(team_header)
.map(|h| view! { th { (h) } })
.collect();
view! {
table(class="matches") {
thead {
tr {
th { "Finished" }
th { "Game" }
(header_cells)
th { "Score" }
th { "Winner" }
th { "You" }
th { "Elo" }
},
Some(_) if rows.get_clone().is_empty() => view! {
p(class="text-secondary text-center p-5") { "No matches played yet." }
},
Some(_) => {
let names = game_types.get_clone();
let table_rows = rows
.get_clone()
.into_iter()
.map(|m| {
let finished = m
.finished_at
.replace('T', " ")
.chars()
.take(16)
.collect::<String>();
let game = game_name(&names, &m.game_type);
let mut cells: Vec<View> = team_columns(&m)
.iter()
.map(|t| team_names(&m, t))
.map(|c| view! { td { (c) } })
.collect();
let score = score_text(&m);
let winner = winner_text(&m);
let outcome = if m.you_won { "Won" } else { "Lost" };
let elo_delta = m
.your_elo_delta
.map(|d| {
if d >= 0 {
format!("+{d}")
} else {
d.to_string()
}
})
.unwrap_or_else(|| "—".to_string());
let cls = if m.you_won {
"text-success fw-semibold"
} else {
"text-secondary"
};
let mut row_cells = vec![
view! { td { (finished) } },
view! { td { (game) } },
];
row_cells.append(&mut cells);
row_cells.push(view! { td { (score) } });
row_cells.push(view! { td { (winner) } });
row_cells.push(view! { td(class=cls) { (outcome) } });
row_cells.push(view! { td(class=cls) { (elo_delta) } });
view! { tr { (row_cells) } }
})
.collect::<Vec<_>>();
// Team columns are per-row (matches may mix games), so
// the header shows the union across the loaded rows.
let mut header_teams = BTreeSet::new();
for m in rows.get_clone().iter() {
for t in team_columns(m) {
header_teams.insert(t);
}
}
let mut header_teams: Vec<Option<String>> =
header_teams.into_iter().collect();
header_teams.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let header_cells: Vec<View> = header_teams
.iter()
.map(team_header)
.map(|h| view! { th { (h) } })
.collect();
view! {
div(class="table-responsive") {
table(class="table table-striped table-hover align-middle") {
thead {
tr {
th { "Finished" }
th { "Game" }
(header_cells)
th { "Score" }
th { "Winner" }
th { "You" }
th { "Elo" }
}
}
tbody { (table_rows) }
}
}
tbody { (table_rows) }
(cursor.get_clone().map(|c| view! {
button(
class="btn btn-outline-light",
on:click=move |_| load2(Some(c.clone())),
) {
"Load more"
}
}))
}
(cursor.get_clone().map(|c| view! {
button(class="button", on:click=move |_| load2(Some(c.clone()))) {
"Load more"
}
}))
}
}
})
})
}
}
}
}
+53 -41
View File
@@ -19,51 +19,63 @@ pub fn LeaderboardPage() -> View {
});
view! {
div(class="page") {
nav(class="top-nav") {
a(href="/") { "← Lobby" }
a(href="/history") { "My matches" }
}
h1 { "Leaderboard" }
(toast(error))
(move || match page.get_clone() {
None => view! { p(class="status") { "Loading…" } },
Some(p) => {
let rows = p
.results
.iter()
.cloned()
.enumerate()
.map(|(i, e)| {
view! {
tr {
td { (i + 1) }
td { (e.display_name.clone()) }
td { (e.elo) }
td { (e.wins) }
td { (e.matches) }
td { (fmt_points(e.points)) }
div {
(crate::components::nav::app_nav("/leaderboard", || view! {}))
div(class="container page-shell py-4") {
h1(class="h3 mb-4") { "Leaderboard" }
(toast(error))
(move || match page.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(p) if p.results.is_empty() => view! {
p(class="text-secondary text-center p-5") {
"No ratings yet — finish a match to appear here."
}
},
Some(p) => {
let rows = p
.results
.iter()
.cloned()
.enumerate()
.map(|(i, e)| {
view! {
tr {
td { (i + 1) }
td { (e.display_name.clone()) }
td { (e.elo) }
td { (e.wins) }
td { (e.matches) }
td { (fmt_points(e.points)) }
}
}
})
.collect::<Vec<_>>();
view! {
div(class="table-responsive") {
table(class="table table-striped table-hover align-middle") {
thead {
tr {
th { "#" }
th { "Player" }
th { "Elo" }
th { "Wins" }
th { "Matches" }
th { "Points" }
}
}
tbody { (rows) }
}
}
})
.collect::<Vec<_>>();
view! {
table(class="matches") {
thead {
tr {
th { "#" }
th { "Player" }
th { "Elo" }
th { "Wins" }
th { "Matches" }
th { "Points" }
}
}
tbody { (rows) }
}
}
}
})
})
}
}
}
}
+202 -113
View File
@@ -10,6 +10,7 @@ use sycamore::prelude::*;
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};
@@ -66,9 +67,14 @@ fn OptionInput(props: OptionInputProps) -> View {
});
});
view! {
label(class="check", r#for=for_attr) {
input(id=id_attr, r#type="checkbox", bind:checked=flag)
" " (title)
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) }
}
}
}
@@ -96,17 +102,19 @@ fn OptionInput(props: OptionInputProps) -> View {
let id_attr = key.clone();
let for_attr = key;
view! {
label(r#for=for_attr) { (title) }
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(),
)
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(),
)
}
}
}
}
@@ -134,14 +142,17 @@ fn OptionInput(props: OptionInputProps) -> View {
let id_attr = key.clone();
let for_attr = key;
view! {
label(r#for=for_attr) { (title) }
input(
id=id_attr,
r#type="number",
min=min_attr,
max=max_attr,
bind:value=value,
)
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 => {
@@ -161,8 +172,10 @@ fn OptionInput(props: OptionInputProps) -> View {
let id_attr = key.clone();
let for_attr = key;
view! {
label(r#for=for_attr) { (title) }
input(id=id_attr, r#type="text", bind:value=value)
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)
}
}
}
}
@@ -253,105 +266,181 @@ pub fn LobbyPage() -> View {
};
view! {
div(class="lobby") {
h1 { "tavolo" }
div {
(toast(error))
(move || match user.get_clone() {
None => view! { p(class="status") { "Loading…" } },
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! {
div(class="panel login-panel") {
p { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: this must be a full page navigation
// to the backend's OIDC login endpoint.
a(class="button primary", href="/auth/login", rel="external") { "Log in" }
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h3 mb-3") {
span(class="text-warning") { "♠" }
" tavolo"
}
p(class="text-secondary") { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: this must be a full page navigation
// to the backend's OIDC login endpoint.
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
}
}
}
},
Some(Some(me)) => view! {
div(class="lobby-grid") {
nav(class="top-nav") {
span(class="whoami") {
"Signed in as " strong { (me.name.clone()) }
(move || ratings
.get_clone()
.into_iter()
.find(|r| r.game_type == selected_game.get_clone())
.map(|r| view! {
span(class="rating") { " · Elo " (r.rating) }
}))
}
a(href="/history") { "My matches" }
a(href="/leaderboard") { "Leaderboard" }
a(href="/auth/logout", rel="external") { "Log out" }
}
div(class="panel") {
h2 { "New match" }
label(r#for="game-type") { "Game" }
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
Some(Some(_me)) => {
view! {
(crate::components::nav::app_nav("/", move || {
let name = user
.get_clone()
.into_iter()
.find(|g| g.id == selected_game.get_clone())
.map(|g| {
let players_note = match (g.min_players, g.max_players) {
(0, 0) => None,
(min, max) if min == max => {
Some(format!("{min} players"))
}
(min, max) => {
Some(format!("{min}–{max} players"))
}
};
let description: View = if g.description.is_empty() {
view! {}
} else {
let text = g.description.clone();
view! {
p(class="hint") { (text) }
}
};
let note: View = match players_note {
Some(note) => view! {
p(class="hint") { (note) }
},
None => view! {},
};
view! {
(description)
(note)
}
}))
Keyed(
list=fields,
view=move |f| view! {
OptionInput(field=f.clone(), options=options)
},
key=|f| f.key.clone(),
)
button(class="button primary", on:click=on_create) {
"Create match"
.flatten()
.map(|u| u.name)
.unwrap_or_default();
view! {
span(class="navbar-text me-3") {
"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="panel") {
h2 { "Join with a code" }
div(class="join-form") {
input(
r#type="text",
placeholder="6-letter code",
maxlength="6",
bind:value=code,
)
button(class="button primary", on:click=on_join) { "Join" }
}))
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 scientifico with friends."
}
}
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) }
}
}))
Keyed(
list=fields,
view=move |f| view! {
OptionInput(field=f.clone(), options=options)
},
key=|f| f.key.clone(),
)
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,
)
button(
class="btn btn-warning btn-lg",
on:click=on_join,
) {
"Join"
}
}
}
}
}
}
}
}
},
}
})
}
}