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:
+202
-113
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user