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:
@@ -10,7 +10,10 @@ scientifico** — the four-player, fixed-partnership Italian card game:
|
|||||||
and the `tavolo-app` composition root wiring them together.
|
and the `tavolo-app` composition root wiring them together.
|
||||||
See [server/README.md](server/README.md).
|
See [server/README.md](server/README.md).
|
||||||
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
||||||
WebAssembly, built with [Trunk](https://trunkrs.dev). Card images are the
|
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
||||||
|
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
|
||||||
|
`web/vendor/` so builds work offline) plus the custom felt theme in
|
||||||
|
`web/theme.css`. Card images are the
|
||||||
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||||||
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ serde_json = "1"
|
|||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
web-sys = { version = "0.3", features = ["Window", "Location", "console"] }
|
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console"] }
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
gloo-timers = "0.3"
|
gloo-timers = "0.3"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
|||||||
+4
-1
@@ -1,10 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-bs-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Tavolo</title>
|
<title>Tavolo</title>
|
||||||
<link data-trunk rel="rust" href="Cargo.toml">
|
<link data-trunk rel="rust" href="Cargo.toml">
|
||||||
|
<!-- Bootstrap 5.3.3 (MIT), vendored so builds work offline. -->
|
||||||
|
<link data-trunk rel="css" href="vendor/bootstrap.min.css">
|
||||||
|
<link data-trunk rel="css" href="theme.css">
|
||||||
<link data-trunk rel="css" href="style.css">
|
<link data-trunk rel="css" href="style.css">
|
||||||
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
|
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
|
||||||
<link data-trunk rel="copy-dir" href="assets">
|
<link data-trunk rel="copy-dir" href="assets">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod card;
|
pub mod card;
|
||||||
|
pub mod nav;
|
||||||
pub mod summary;
|
pub mod summary;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -246,7 +246,7 @@ pub fn hand_summary_modal(
|
|||||||
// whole modal (which would swap DOM nodes under the user's cursor).
|
// whole modal (which would swap DOM nodes under the user's cursor).
|
||||||
let deadline_ms = js_sys::Date::parse(deadline);
|
let deadline_ms = js_sys::Date::parse(deadline);
|
||||||
view! {
|
view! {
|
||||||
p(class="hint") {
|
p(class="text-secondary small") {
|
||||||
"Auto-continuing in "
|
"Auto-continuing in "
|
||||||
(move || {
|
(move || {
|
||||||
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
((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 action = if already_acked {
|
||||||
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
||||||
view! {
|
view! {
|
||||||
button(class="button primary", disabled=true) { (waiting_text) }
|
button(class="btn btn-warning", disabled=true) { (waiting_text) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
view! {
|
view! {
|
||||||
button(class="button primary", on:click=move |_| {
|
button(class="btn btn-warning", on:click=move |_| {
|
||||||
if let Some(s) = socket.get_clone() {
|
if let Some(s) = socket.get_clone() {
|
||||||
s.ack();
|
s.ack();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ pub fn toast(error: Signal<Option<String>>) -> View {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
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
@@ -42,9 +42,15 @@ fn main() {
|
|||||||
AppRoutes::History => view! { HistoryPage() },
|
AppRoutes::History => view! { HistoryPage() },
|
||||||
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
||||||
AppRoutes::NotFound => view! {
|
AppRoutes::NotFound => view! {
|
||||||
div(class="panel") {
|
div(class="status-card") {
|
||||||
h1 { "Page not found" }
|
div(class="card") {
|
||||||
a(href="/") { "Back to lobby" }
|
div(class="card-body p-4") {
|
||||||
|
h1(class="h4 mb-3") { "Page not found" }
|
||||||
|
a(class="btn btn-warning", href="/") {
|
||||||
|
"Back to lobby"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+143
-34
@@ -1,5 +1,6 @@
|
|||||||
//! Live game page: table view over the websocket.
|
//! Live game page: table view over the websocket.
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
@@ -190,22 +191,35 @@ fn conn_banner(
|
|||||||
}
|
}
|
||||||
if fatal {
|
if fatal {
|
||||||
view! {
|
view! {
|
||||||
div(class="conn-banner") {
|
div(
|
||||||
"Connection closed by the server. "
|
class="alert alert-warning d-flex align-items-center justify-content-center \
|
||||||
a(href="/") { "Back to lobby" }
|
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 {
|
} else if gave_up {
|
||||||
view! {
|
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."
|
"Connection lost."
|
||||||
button(class="button", on:click=move |_| reconnect()) { "Retry now" }
|
button(
|
||||||
a(href="/") { "Back to lobby" }
|
class="btn btn-sm btn-warning",
|
||||||
|
on:click=move |_| reconnect(),
|
||||||
|
) {
|
||||||
|
"Retry now"
|
||||||
|
}
|
||||||
|
a(class="alert-link", href="/") { "Back to lobby" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
view! {
|
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 => {
|
None => {
|
||||||
if fatal.get() {
|
if fatal.get() {
|
||||||
view! {
|
view! {
|
||||||
div(class="panel status-panel") {
|
div(class="status-card") {
|
||||||
|
div(class="card") {
|
||||||
|
div(class="card-body p-4") {
|
||||||
p { "Connection closed." }
|
p { "Connection closed." }
|
||||||
p { a(href="/") { "Back to lobby" } }
|
a(class="btn btn-warning", href="/") { "Back to lobby" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if gave_up.get() {
|
} else if gave_up.get() {
|
||||||
let reconnect = reconnect.clone();
|
let reconnect = reconnect.clone();
|
||||||
view! {
|
view! {
|
||||||
div(class="panel status-panel") {
|
div(class="status-card") {
|
||||||
|
div(class="card") {
|
||||||
|
div(class="card-body p-4") {
|
||||||
p { "Connection lost." }
|
p { "Connection lost." }
|
||||||
p {
|
button(
|
||||||
button(class="button primary", on:click=move |_| reconnect()) {
|
class="btn btn-warning me-2",
|
||||||
|
on:click=move |_| reconnect(),
|
||||||
|
) {
|
||||||
"Retry now"
|
"Retry now"
|
||||||
}
|
}
|
||||||
|
a(class="btn btn-outline-light", href="/") {
|
||||||
|
"Back to lobby"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p { a(href="/") { "Back to lobby" } }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -321,14 +346,23 @@ pub fn GamePage(id: String) -> View {
|
|||||||
"Connecting to the game…"
|
"Connecting to the game…"
|
||||||
};
|
};
|
||||||
view! {
|
view! {
|
||||||
div(class="panel status-panel") {
|
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) }
|
p { (status) }
|
||||||
p { a(href="/") { "Back to lobby" } }
|
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
|
// The live table is scopone-specific; other games get a
|
||||||
// fallback until they ship their own play view.
|
// fallback until they ship their own play view.
|
||||||
Some(g) if g.game_type == "scopone_scientifico" => {
|
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
|
/// Lobby view while waiting for the remaining players. The seat count
|
||||||
/// comes from the payload (`seats_open`), so games with other player
|
/// comes from the payload (`seats_open`), so games with other player
|
||||||
/// counts render correctly.
|
/// counts render correctly.
|
||||||
fn lobby_view(game: GameView) -> View {
|
#[component(inline_props)]
|
||||||
|
fn LobbyView(game: GameView) -> View {
|
||||||
let seats_open = game
|
let seats_open = game
|
||||||
.seats_open
|
.seats_open
|
||||||
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
||||||
let join_code = game.join_code.clone();
|
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
|
let players = game
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
@@ -366,21 +427,61 @@ fn lobby_view(game: GameView) -> View {
|
|||||||
let seat = p.seat;
|
let seat = p.seat;
|
||||||
let team = p.team.clone();
|
let team = p.team.clone();
|
||||||
view! {
|
view! {
|
||||||
li {
|
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
||||||
strong { (name) }
|
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<_>>();
|
.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! {
|
view! {
|
||||||
div(class="panel status-panel") {
|
div(class="container page-shell py-4") {
|
||||||
h2 { "Waiting for players" }
|
div(class="card status-card") {
|
||||||
p { "Share this join code:" }
|
div(class="card-body p-4") {
|
||||||
p(class="join-code") { (join_code) }
|
h1(class="h4 mb-3") { "Waiting for players" }
|
||||||
ul(class="roster") { (players) }
|
p(class="text-secondary") { "Share this join code with your friends:" }
|
||||||
p { (seats_open) " seat(s) still open" }
|
p(class="code-display") { (join_code) }
|
||||||
p { a(href="/") { "Back to lobby" } }
|
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 {
|
fn unsupported_game_view(game: GameView) -> View {
|
||||||
let game_type = game.game_type.clone();
|
let game_type = game.game_type.clone();
|
||||||
view! {
|
view! {
|
||||||
div(class="panel status-panel") {
|
div(class="status-card") {
|
||||||
h2 { "Unsupported game" }
|
div(class="card") {
|
||||||
p { "Live play for “" (game_type) "” isn't supported in the web client yet." }
|
div(class="card-body p-4") {
|
||||||
p { a(href="/") { "Back to lobby" } }
|
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<_>>();
|
.collect::<Vec<_>>();
|
||||||
let hint = my_turn.then(|| view! {
|
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 "
|
"Click a card to play it. If it can capture in several ways you "
|
||||||
"will be asked to choose."
|
"will be asked to choose."
|
||||||
}
|
}
|
||||||
@@ -554,7 +661,9 @@ fn capture_picker(
|
|||||||
div(class="picker") {
|
div(class="picker") {
|
||||||
h3 { (title) }
|
h3 { (title) }
|
||||||
div(class="capture-options") { (option_views) }
|
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)
|
(final_summary)
|
||||||
p(class="final-score") { (line) }
|
p(class="final-score") { (line) }
|
||||||
div(class="gameover-actions") {
|
div(class="gameover-actions") {
|
||||||
a(class="button primary", href="/") { "Back to lobby" }
|
a(class="btn btn-warning", href="/") { "Back to lobby" }
|
||||||
a(class="button", href="/history") { "My matches" }
|
a(class="btn btn-outline-light", href="/history") { "My matches" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-11
@@ -109,17 +109,21 @@ pub fn HistoryPage() -> View {
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
div(class="page") {
|
div {
|
||||||
nav(class="top-nav") {
|
(crate::components::nav::app_nav("/history", || view! {}))
|
||||||
a(href="/") { "← Lobby" }
|
div(class="container page-shell py-4") {
|
||||||
a(href="/leaderboard") { "Leaderboard" }
|
h1(class="h3 mb-4") { "My matches" }
|
||||||
}
|
|
||||||
h1 { "My matches" }
|
|
||||||
(toast(error))
|
(toast(error))
|
||||||
(move || match page.get_clone() {
|
(move || match page.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(_) if rows.get_clone().is_empty() => view! {
|
Some(_) if rows.get_clone().is_empty() => view! {
|
||||||
p(class="status") { "No matches played yet." }
|
p(class="text-secondary text-center p-5") { "No matches played yet." }
|
||||||
},
|
},
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
let names = game_types.get_clone();
|
let names = game_types.get_clone();
|
||||||
@@ -152,7 +156,11 @@ pub fn HistoryPage() -> View {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "—".to_string());
|
.unwrap_or_else(|| "—".to_string());
|
||||||
let cls = if m.you_won { "won" } else { "lost" };
|
let cls = if m.you_won {
|
||||||
|
"text-success fw-semibold"
|
||||||
|
} else {
|
||||||
|
"text-secondary"
|
||||||
|
};
|
||||||
let mut row_cells = vec![
|
let mut row_cells = vec![
|
||||||
view! { td { (finished) } },
|
view! { td { (finished) } },
|
||||||
view! { td { (game) } },
|
view! { td { (game) } },
|
||||||
@@ -185,7 +193,8 @@ pub fn HistoryPage() -> View {
|
|||||||
.map(|h| view! { th { (h) } })
|
.map(|h| view! { th { (h) } })
|
||||||
.collect();
|
.collect();
|
||||||
view! {
|
view! {
|
||||||
table(class="matches") {
|
div(class="table-responsive") {
|
||||||
|
table(class="table table-striped table-hover align-middle") {
|
||||||
thead {
|
thead {
|
||||||
tr {
|
tr {
|
||||||
th { "Finished" }
|
th { "Finished" }
|
||||||
@@ -199,8 +208,12 @@ pub fn HistoryPage() -> View {
|
|||||||
}
|
}
|
||||||
tbody { (table_rows) }
|
tbody { (table_rows) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
(cursor.get_clone().map(|c| view! {
|
(cursor.get_clone().map(|c| view! {
|
||||||
button(class="button", on:click=move |_| load2(Some(c.clone()))) {
|
button(
|
||||||
|
class="btn btn-outline-light",
|
||||||
|
on:click=move |_| load2(Some(c.clone())),
|
||||||
|
) {
|
||||||
"Load more"
|
"Load more"
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -209,4 +222,5 @@ pub fn HistoryPage() -> View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,24 @@ pub fn LeaderboardPage() -> View {
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
div(class="page") {
|
div {
|
||||||
nav(class="top-nav") {
|
(crate::components::nav::app_nav("/leaderboard", || view! {}))
|
||||||
a(href="/") { "← Lobby" }
|
div(class="container page-shell py-4") {
|
||||||
a(href="/history") { "My matches" }
|
h1(class="h3 mb-4") { "Leaderboard" }
|
||||||
}
|
|
||||||
h1 { "Leaderboard" }
|
|
||||||
(toast(error))
|
(toast(error))
|
||||||
(move || match page.get_clone() {
|
(move || match page.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(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) => {
|
Some(p) => {
|
||||||
let rows = p
|
let rows = p
|
||||||
.results
|
.results
|
||||||
@@ -48,7 +57,8 @@ pub fn LeaderboardPage() -> View {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
view! {
|
view! {
|
||||||
table(class="matches") {
|
div(class="table-responsive") {
|
||||||
|
table(class="table table-striped table-hover align-middle") {
|
||||||
thead {
|
thead {
|
||||||
tr {
|
tr {
|
||||||
th { "#" }
|
th { "#" }
|
||||||
@@ -63,7 +73,9 @@ pub fn LeaderboardPage() -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-50
@@ -10,6 +10,7 @@ use sycamore::prelude::*;
|
|||||||
use sycamore_router::navigate;
|
use sycamore_router::navigate;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
use crate::components::card::card_back;
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{GameTypeInfo, OptionField, OptionKind, PlayerRating, User};
|
use crate::model::{GameTypeInfo, OptionField, OptionKind, PlayerRating, User};
|
||||||
|
|
||||||
@@ -66,9 +67,14 @@ fn OptionInput(props: OptionInputProps) -> View {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
view! {
|
view! {
|
||||||
label(class="check", r#for=for_attr) {
|
div(class="form-check mb-3") {
|
||||||
input(id=id_attr, r#type="checkbox", bind:checked=flag)
|
input(
|
||||||
" " (title)
|
class="form-check-input",
|
||||||
|
id=id_attr,
|
||||||
|
r#type="checkbox",
|
||||||
|
bind:checked=flag,
|
||||||
|
)
|
||||||
|
label(class="form-check-label", r#for=for_attr) { (title) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,8 +102,9 @@ fn OptionInput(props: OptionInputProps) -> View {
|
|||||||
let id_attr = key.clone();
|
let id_attr = key.clone();
|
||||||
let for_attr = key;
|
let for_attr = key;
|
||||||
view! {
|
view! {
|
||||||
label(r#for=for_attr) { (title) }
|
div(class="mb-3") {
|
||||||
select(id=id_attr, bind:value=value) {
|
label(class="form-label", r#for=for_attr) { (title) }
|
||||||
|
select(class="form-select", id=id_attr, bind:value=value) {
|
||||||
Keyed(
|
Keyed(
|
||||||
list=items,
|
list=items,
|
||||||
view=|v| {
|
view=|v| {
|
||||||
@@ -110,6 +117,7 @@ fn OptionInput(props: OptionInputProps) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
OptionKind::Integer { min, max } => {
|
OptionKind::Integer { min, max } => {
|
||||||
let initial = options
|
let initial = options
|
||||||
.get_clone()
|
.get_clone()
|
||||||
@@ -134,8 +142,10 @@ fn OptionInput(props: OptionInputProps) -> View {
|
|||||||
let id_attr = key.clone();
|
let id_attr = key.clone();
|
||||||
let for_attr = key;
|
let for_attr = key;
|
||||||
view! {
|
view! {
|
||||||
label(r#for=for_attr) { (title) }
|
div(class="mb-3") {
|
||||||
|
label(class="form-label", r#for=for_attr) { (title) }
|
||||||
input(
|
input(
|
||||||
|
class="form-control",
|
||||||
id=id_attr,
|
id=id_attr,
|
||||||
r#type="number",
|
r#type="number",
|
||||||
min=min_attr,
|
min=min_attr,
|
||||||
@@ -144,6 +154,7 @@ fn OptionInput(props: OptionInputProps) -> View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
OptionKind::Text => {
|
OptionKind::Text => {
|
||||||
let initial = options
|
let initial = options
|
||||||
.get_clone()
|
.get_clone()
|
||||||
@@ -161,8 +172,10 @@ fn OptionInput(props: OptionInputProps) -> View {
|
|||||||
let id_attr = key.clone();
|
let id_attr = key.clone();
|
||||||
let for_attr = key;
|
let for_attr = key;
|
||||||
view! {
|
view! {
|
||||||
label(r#for=for_attr) { (title) }
|
div(class="mb-3") {
|
||||||
input(id=id_attr, r#type="text", bind:value=value)
|
label(class="form-label", r#for=for_attr) { (title) }
|
||||||
|
input(class="form-control", id=id_attr, r#type="text", bind:value=value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,78 +266,128 @@ pub fn LobbyPage() -> View {
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
div(class="lobby") {
|
div {
|
||||||
h1 { "tavolo" }
|
|
||||||
(toast(error))
|
(toast(error))
|
||||||
(move || match user.get_clone() {
|
(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! {
|
Some(None) => view! {
|
||||||
div(class="panel login-panel") {
|
div(class="status-card") {
|
||||||
p { "Log in with your account to play." }
|
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
|
// rel="external" opts out of the SPA router's click
|
||||||
// interception: this must be a full page navigation
|
// interception: this must be a full page navigation
|
||||||
// to the backend's OIDC login endpoint.
|
// to the backend's OIDC login endpoint.
|
||||||
a(class="button primary", href="/auth/login", rel="external") { "Log in" }
|
a(
|
||||||
|
class="btn btn-warning btn-lg w-100",
|
||||||
|
href="/auth/login",
|
||||||
|
rel="external",
|
||||||
|
) {
|
||||||
|
"Log in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(Some(me)) => view! {
|
Some(Some(_me)) => {
|
||||||
div(class="lobby-grid") {
|
view! {
|
||||||
nav(class="top-nav") {
|
(crate::components::nav::app_nav("/", move || {
|
||||||
span(class="whoami") {
|
let name = user
|
||||||
"Signed in as " strong { (me.name.clone()) }
|
.get_clone()
|
||||||
|
.flatten()
|
||||||
|
.map(|u| u.name)
|
||||||
|
.unwrap_or_default();
|
||||||
|
view! {
|
||||||
|
span(class="navbar-text me-3") {
|
||||||
|
"Signed in as " strong { (name) }
|
||||||
(move || ratings
|
(move || ratings
|
||||||
.get_clone()
|
.get_clone()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|r| r.game_type == selected_game.get_clone())
|
.find(|r| r.game_type == selected_game.get_clone())
|
||||||
.map(|r| view! {
|
.map(|r| view! {
|
||||||
span(class="rating") { " · Elo " (r.rating) }
|
span(class="badge text-bg-warning ms-2") {
|
||||||
|
"Elo " (r.rating)
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
a(href="/history") { "My matches" }
|
a(
|
||||||
a(href="/leaderboard") { "Leaderboard" }
|
class="btn btn-sm btn-outline-light",
|
||||||
a(href="/auth/logout", rel="external") { "Log out" }
|
href="/auth/logout",
|
||||||
|
rel="external",
|
||||||
|
) {
|
||||||
|
"Log out"
|
||||||
}
|
}
|
||||||
div(class="panel") {
|
}
|
||||||
h2 { "New match" }
|
}))
|
||||||
label(r#for="game-type") { "Game" }
|
div(class="container page-shell py-4") {
|
||||||
select(id="game-type", bind:value=selected_game) {
|
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(
|
Keyed(
|
||||||
list=game_types,
|
list=game_types,
|
||||||
view=|g| view! { option(value=g.id.clone()) { (g.name) } },
|
view=|g| view! {
|
||||||
|
option(value=g.id.clone()) { (g.name) }
|
||||||
|
},
|
||||||
key=|g| g.id.clone(),
|
key=|g| g.id.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
(move || game_types
|
(move || game_types
|
||||||
.get_clone()
|
.get_clone()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|g| g.id == selected_game.get_clone())
|
.find(|g| g.id == selected_game.get_clone())
|
||||||
.map(|g| {
|
.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() {
|
let description: View = if g.description.is_empty() {
|
||||||
view! {}
|
view! {}
|
||||||
} else {
|
} else {
|
||||||
let text = g.description.clone();
|
let text = g.description.clone();
|
||||||
view! {
|
view! {
|
||||||
p(class="hint") { (text) }
|
p(class="text-secondary small") { (text) }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let note: View = match players_note {
|
let players_note: View = match (g.min_players, g.max_players) {
|
||||||
Some(note) => view! {
|
(0, 0) => view! {},
|
||||||
p(class="hint") { (note) }
|
(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"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None => view! {},
|
|
||||||
};
|
};
|
||||||
view! {
|
view! {
|
||||||
(description)
|
(description)
|
||||||
(note)
|
div(class="mb-3") { (players_note) }
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
Keyed(
|
Keyed(
|
||||||
@@ -334,24 +397,50 @@ pub fn LobbyPage() -> View {
|
|||||||
},
|
},
|
||||||
key=|f| f.key.clone(),
|
key=|f| f.key.clone(),
|
||||||
)
|
)
|
||||||
button(class="button primary", on:click=on_create) {
|
button(
|
||||||
|
class="btn btn-warning btn-lg w-100 mt-auto",
|
||||||
|
on:click=on_create,
|
||||||
|
) {
|
||||||
"Create match"
|
"Create match"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div(class="panel") {
|
}
|
||||||
h2 { "Join with a code" }
|
}
|
||||||
div(class="join-form") {
|
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(
|
input(
|
||||||
|
class="form-control form-control-lg code-input",
|
||||||
r#type="text",
|
r#type="text",
|
||||||
placeholder="6-letter code",
|
placeholder="CODE",
|
||||||
maxlength="6",
|
maxlength="6",
|
||||||
bind:value=code,
|
bind:value=code,
|
||||||
)
|
)
|
||||||
button(class="button primary", on:click=on_join) { "Join" }
|
button(
|
||||||
|
class="btn btn-warning btn-lg",
|
||||||
|
on:click=on_join,
|
||||||
|
) {
|
||||||
|
"Join"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-191
@@ -1,17 +1,7 @@
|
|||||||
/* Scopone scientifico frontend — dark table-felt theme. */
|
/* Scopone scientifico frontend — game table, cards and scoring summary.
|
||||||
|
*
|
||||||
:root {
|
* The felt palette and the Bootstrap theme overrides live in theme.css,
|
||||||
--felt: #1d5c38;
|
* which is loaded before this file. */
|
||||||
--felt-dark: #14341f;
|
|
||||||
--panel: #223026;
|
|
||||||
--panel-light: #2e4234;
|
|
||||||
--text: #f1f0e8;
|
|
||||||
--muted: #a9b7ab;
|
|
||||||
--accent: #e8c547;
|
|
||||||
--danger: #d9534f;
|
|
||||||
--team-a: #7db4e8;
|
|
||||||
--team-b: #e8967d;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -20,8 +10,6 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Segoe UI", system-ui, sans-serif;
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
background: var(--felt-dark);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
@@ -32,65 +20,7 @@ body {
|
|||||||
|
|
||||||
/* ---------- shared chrome ---------- */
|
/* ---------- shared chrome ---------- */
|
||||||
|
|
||||||
.top-nav {
|
.app-toast {
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-nav a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.whoami {
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.whoami .rating {
|
|
||||||
color: var(--muted, #888);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
background: var(--panel);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
margin: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--panel-light);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid #4c6a54;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.55rem 1.1rem;
|
|
||||||
margin: 0.25rem 0.25rem 0.25rem 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.primary {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: #1d1d1d;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
filter: brightness(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
color: var(--muted);
|
|
||||||
text-align: center;
|
|
||||||
margin: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -103,96 +33,6 @@ body {
|
|||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- lobby / pages ---------- */
|
|
||||||
|
|
||||||
.lobby h1,
|
|
||||||
.page h1 {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lobby-grid,
|
|
||||||
.page {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-panel {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-form input {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3em;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #4c6a54;
|
|
||||||
background: var(--panel-light);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-code {
|
|
||||||
font-size: 2.4rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.35em;
|
|
||||||
color: var(--accent);
|
|
||||||
text-align: center;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-panel {
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 3rem auto;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.matches {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.matches th,
|
|
||||||
table.matches td {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #3c5543;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.matches td.won {
|
|
||||||
color: #8bc34a;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.matches td.lost {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- game table ---------- */
|
/* ---------- game table ---------- */
|
||||||
|
|
||||||
.hud {
|
.hud {
|
||||||
@@ -396,11 +236,6 @@ table.matches td.lost {
|
|||||||
transform: translateY(-10px);
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- move banner ---------- */
|
/* ---------- move banner ---------- */
|
||||||
|
|
||||||
.move-banner {
|
.move-banner {
|
||||||
@@ -422,27 +257,6 @@ table.matches td.lost {
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- connection banner ---------- */
|
|
||||||
|
|
||||||
.conn-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
background: rgba(232, 197, 71, 0.15);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--accent);
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
margin: 0.5rem auto 0;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conn-banner a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- overlays ---------- */
|
/* ---------- overlays ---------- */
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
/* Dark felt theme bridged onto Bootstrap 5.3.
|
||||||
|
*
|
||||||
|
* Loaded after bootstrap.min.css and before style.css: variables defined
|
||||||
|
* here retheme Bootstrap components, while style.css keeps owning the
|
||||||
|
* game table, cards and scoring summary. */
|
||||||
|
|
||||||
|
:root,
|
||||||
|
[data-bs-theme="dark"] {
|
||||||
|
--felt: #1d5c38;
|
||||||
|
--felt-dark: #14341f;
|
||||||
|
--panel: #223026;
|
||||||
|
--panel-light: #2e4234;
|
||||||
|
--text: #f1f0e8;
|
||||||
|
--muted: #a9b7ab;
|
||||||
|
--accent: #e8c547;
|
||||||
|
--danger: #d9534f;
|
||||||
|
--team-a: #7db4e8;
|
||||||
|
--team-b: #e8967d;
|
||||||
|
|
||||||
|
--bs-body-bg: var(--felt-dark);
|
||||||
|
--bs-body-color: var(--text);
|
||||||
|
--bs-body-color-rgb: 241, 240, 232;
|
||||||
|
--bs-body-bg-rgb: 20, 52, 31;
|
||||||
|
--bs-emphasis-color: #fff;
|
||||||
|
--bs-secondary-color: var(--muted);
|
||||||
|
--bs-secondary-bg: var(--panel);
|
||||||
|
--bs-tertiary-bg: var(--panel);
|
||||||
|
--bs-border-color: #3c5543;
|
||||||
|
--bs-border-color-translucent: rgba(241, 240, 232, 0.12);
|
||||||
|
--bs-link-color: var(--accent);
|
||||||
|
--bs-link-color-rgb: 232, 197, 71;
|
||||||
|
--bs-link-hover-color: #f3d97a;
|
||||||
|
--bs-link-hover-color-rgb: 243, 217, 122;
|
||||||
|
--bs-primary: var(--accent);
|
||||||
|
--bs-primary-rgb: 232, 197, 71;
|
||||||
|
--bs-warning: var(--accent);
|
||||||
|
--bs-warning-rgb: 232, 197, 71;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--felt-dark);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
ellipse at 50% -10%,
|
||||||
|
var(--felt) 0%,
|
||||||
|
var(--felt-dark) 65%
|
||||||
|
);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- navbar ---------- */
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
--bs-navbar-padding-y: 0.75rem;
|
||||||
|
--bs-navbar-color: var(--muted);
|
||||||
|
--bs-navbar-hover-color: var(--text);
|
||||||
|
--bs-navbar-active-color: var(--accent);
|
||||||
|
--bs-navbar-brand-color: var(--text);
|
||||||
|
--bs-navbar-brand-hover-color: var(--accent);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.35);
|
||||||
|
background: rgb(15, 39, 23);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link.active {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .navbar-text {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- cards ---------- */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
--bs-card-bg: var(--panel);
|
||||||
|
--bs-card-cap-bg: var(--panel-light);
|
||||||
|
--bs-card-border-color: rgba(0, 0, 0, 0.35);
|
||||||
|
--bs-card-border-radius: 12px;
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- buttons ---------- */
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
--bs-btn-color: #1d1d1d;
|
||||||
|
--bs-btn-hover-color: #1d1d1d;
|
||||||
|
--bs-btn-active-color: #1d1d1d;
|
||||||
|
--bs-btn-disabled-color: #1d1d1d;
|
||||||
|
--bs-btn-bg: var(--accent);
|
||||||
|
--bs-btn-border-color: var(--accent);
|
||||||
|
--bs-btn-hover-bg: #f0d266;
|
||||||
|
--bs-btn-hover-border-color: #f0d266;
|
||||||
|
--bs-btn-active-bg: #d4b23c;
|
||||||
|
--bs-btn-active-border-color: #d4b23c;
|
||||||
|
--bs-btn-disabled-bg: var(--accent);
|
||||||
|
--bs-btn-disabled-border-color: var(--accent);
|
||||||
|
--bs-btn-focus-shadow-rgb: 232, 197, 71;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- forms ---------- */
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
background-color: var(--panel-light);
|
||||||
|
border-color: #4c6a54;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
background-color: var(--panel-light);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(232, 197, 71, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input {
|
||||||
|
background-color: var(--panel-light);
|
||||||
|
border-color: #4c6a54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input:checked {
|
||||||
|
background-color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- lobby ---------- */
|
||||||
|
|
||||||
|
.lobby-hero {
|
||||||
|
padding: 2rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.35em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-display {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1.2;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-open {
|
||||||
|
opacity: 0.45;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
--bs-list-group-bg: var(--panel-light);
|
||||||
|
--bs-list-group-border-color: rgba(0, 0, 0, 0.35);
|
||||||
|
--bs-list-group-border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- page shells ---------- */
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
max-width: 60rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
max-width: 30rem;
|
||||||
|
margin: 4rem auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
Vendored
+6
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user