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:
woggioni committed 2026-09-21 23:40:44 +08:00
1 parent ed8a004ec8
commit 12fa3f5aae
15 files changed
+774 -486

No files matched your search

+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"
}
}))
}
}
})
})
}
}
}
}