Rebuild the web UI on Bootstrap 5 with the dark felt theme
CI / Build and push docker image (push) Failing after 39s
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:
+147
-38
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user