Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eea293471
|
||
|
|
4ba5ae1434
|
||
|
|
0f9fb9b6bb
|
||
|
|
322ca27bf4
|
||
|
|
0674e06565
|
@@ -56,9 +56,10 @@ Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
|
||||
player does not move — disconnected or fallen asleep — the server plays a
|
||||
random legal card for them (choosing among the legal captures when the
|
||||
rules require a capture), so one absent player cannot stall the table.
|
||||
Briscola chiamata arms the same countdown for its auction bids and for
|
||||
the auction winner's called-card choice (auto-bid or auto-pass, then a
|
||||
random suit of the won rank).
|
||||
Briscola chiamata arms the same countdown for its auction turns and for
|
||||
the auction winner's called-card choice: an absent bidder passes, the
|
||||
last player standing with no bid is made to bid the ace, and a timed-out
|
||||
call names a random suit of the won rank.
|
||||
|
||||
## Watching
|
||||
|
||||
|
||||
+7
-6
@@ -375,9 +375,9 @@ timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||
queue (see above), not from timers tied to client connections, so the
|
||||
match keeps progressing even with every player disconnected. Briscola
|
||||
chiamata uses the same timeout for its auction bids (auto-bid or
|
||||
auto-pass) and for the called-card choice (a random suit of the won
|
||||
rank).
|
||||
chiamata uses the same timeout for its auction turns (an absent bidder
|
||||
passes; the last player standing with no bid is made to bid the ace) and
|
||||
for the called-card choice (a random suit of the won rank).
|
||||
|
||||
### Hand-end summary
|
||||
|
||||
@@ -460,9 +460,10 @@ player must dismiss. A play attempted in this phase is rejected with an
|
||||
replayed with the next dealer. A match is a single deal, so the caller
|
||||
and partner (or the lone caller) against the other players map onto
|
||||
the platform's two-team result, Elo and history.
|
||||
- Auction bids, the called-card choice and the plays all have their own
|
||||
`TURN_TIMEOUT_SECONDS` deadlines (an absent player is auto-bid,
|
||||
auto-called or auto-played for).
|
||||
- Auction turns, the called-card choice and the plays all have their own
|
||||
`TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
|
||||
player standing with no bid is made to bid the ace, an absent winner's
|
||||
call names a random suit of the won rank, and plays are auto-played.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -41,9 +41,11 @@ Three variants share one rules core:
|
||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||
application composition root. Briscola chiamata uses the turn timeout
|
||||
for its auction bids, the called-card choice and the plays; it never
|
||||
enters the between-deals acknowledgement phase (a single deal ends the
|
||||
match), so the hand-ack timeout is accepted but unused.
|
||||
for its auction turns, the called-card choice and the plays: an absent
|
||||
bidder passes, the last player standing with no bid is made to bid the
|
||||
ace, and an absent winner's call names a random suit of the won rank. It
|
||||
never enters the between-deals acknowledgement phase (a single deal ends
|
||||
the match), so the hand-ack timeout is accepted but unused.
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -797,12 +797,13 @@ def _end_deal(state: ChiamataState) -> None:
|
||||
# -- server-side auto actions ---------------------------------------------------
|
||||
|
||||
|
||||
def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Act for the bidder on turn: bid a random legal rank, else pass.
|
||||
def auto_bid(state: ChiamataState) -> None:
|
||||
"""Act for the bidder on turn: always pass, unless forced to bid.
|
||||
|
||||
The only player who cannot afford to pass — the last one standing
|
||||
before any bid — always has every rank available, so a bid is always
|
||||
possible there.
|
||||
An absent player never outbids anyone: they leave the auction. Only
|
||||
the last player standing before any bid cannot pass — they are made
|
||||
to bid the ace, whose suit :func:`auto_call` picks at random if the
|
||||
call itself times out too.
|
||||
"""
|
||||
if state.phase != PHASE_AUCTION:
|
||||
raise GameNotStarted("the auction is not open")
|
||||
@@ -810,10 +811,8 @@ def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
if auction is None: # pragma: no cover - guarded by phase
|
||||
raise GameNotStarted("the auction is not open")
|
||||
player = _player_at(state, auction.turn)
|
||||
ranks = legal_bid_ranks(state)
|
||||
if ranks:
|
||||
chooser = rng or _rng
|
||||
bid_rank(state, player.sub, chooser.choice(ranks))
|
||||
if len(auction.active) == 1 and auction.current is None:
|
||||
bid_rank(state, player.sub, 1)
|
||||
return
|
||||
pass_auction(state, player.sub)
|
||||
|
||||
|
||||
@@ -485,10 +485,18 @@ class BriscolaChiamataEngine(GameEngine):
|
||||
state: ChiamataState = session.state
|
||||
if kind == "auction":
|
||||
seat = state.turn
|
||||
auction = state.auction
|
||||
passed_before = len(auction.passed) if auction is not None else 0
|
||||
chiamata.auto_bid(state)
|
||||
action = (
|
||||
"auto-passed"
|
||||
if auction is not None and len(auction.passed) > passed_before
|
||||
else "forced to bid the ace"
|
||||
)
|
||||
log.info(
|
||||
"auto-bid for seat %s (auction timeout, deal %d)",
|
||||
"seat %s %s (auction timeout, deal %d)",
|
||||
seat,
|
||||
action,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "call":
|
||||
|
||||
@@ -240,18 +240,43 @@ class AuctionTest(unittest.TestCase):
|
||||
self.assertEqual(63, view["legal_bids"]["points_min"])
|
||||
self.assertEqual(120, view["legal_bids"]["points_max"])
|
||||
|
||||
def test_auto_bid_bids_or_passes(self) -> None:
|
||||
def test_auto_bid_always_passes_when_legal(self) -> None:
|
||||
state = started_state()
|
||||
rules.auto_bid(state, random.Random(1))
|
||||
assert state.auction is not None
|
||||
self.assertIsNotNone(state.auction.current)
|
||||
# The auction keeps moving and eventually concludes.
|
||||
for _ in range(50):
|
||||
if state.phase != PHASE_AUCTION:
|
||||
break
|
||||
rules.auto_bid(state, random.Random(2))
|
||||
rules.auto_bid(state)
|
||||
# Seat 1 passes: nobody has bid and the turn moves on.
|
||||
self.assertEqual([1], state.auction.passed)
|
||||
self.assertIsNone(state.auction.current)
|
||||
self.assertEqual(2, state.auction.turn)
|
||||
# An absent bidder still passes when there is a standing bid.
|
||||
rules.bid_rank(state, "p2", 10)
|
||||
rules.auto_bid(state)
|
||||
self.assertEqual([1, 3], state.auction.passed)
|
||||
assert state.auction.current is not None
|
||||
self.assertEqual(10, state.auction.current.rank)
|
||||
|
||||
def test_auto_bid_passes_at_the_point_stage(self) -> None:
|
||||
state = started_state()
|
||||
rules.bid_rank(state, "p1", 2)
|
||||
rules.bid_points(state, "p2", 62)
|
||||
rules.auto_bid(state) # seat 3
|
||||
assert state.auction is not None
|
||||
self.assertIn(3, state.auction.passed)
|
||||
assert state.auction.current is not None
|
||||
self.assertEqual(62, state.auction.current.points)
|
||||
|
||||
def test_auto_bid_forced_to_call_the_ace(self) -> None:
|
||||
state = started_state()
|
||||
for seat in (1, 2, 3, 4):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
# Seat 0 is the last one standing and nobody has bid yet: the
|
||||
# forced auto-bid calls the ace.
|
||||
rules.auto_bid(state)
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertIsNotNone(state.caller)
|
||||
self.assertEqual(0, state.caller)
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
self.assertEqual(1, state.auction.current.rank)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
|
||||
class CallTest(unittest.TestCase):
|
||||
@@ -543,7 +568,7 @@ class ScoringTest(unittest.TestCase):
|
||||
moves = 0
|
||||
while state.phase != PHASE_FINISHED and moves < 5000:
|
||||
if state.phase == PHASE_AUCTION:
|
||||
rules.auto_bid(state, rng)
|
||||
rules.auto_bid(state)
|
||||
elif state.phase == PHASE_CALLING:
|
||||
rules.auto_call(state, rng)
|
||||
else:
|
||||
|
||||
@@ -241,7 +241,7 @@ class ChiamataTimeoutTest(unittest.TestCase):
|
||||
return session.id
|
||||
|
||||
@async_test
|
||||
async def test_auction_timeout_auto_bids(self) -> None:
|
||||
async def test_auction_timeout_auto_passes(self) -> None:
|
||||
game_id = await self._seed(
|
||||
"chiamata-auction-timeout-1", "CH0001", lambda state: None
|
||||
)
|
||||
@@ -263,15 +263,62 @@ class ChiamataTimeoutTest(unittest.TestCase):
|
||||
break
|
||||
if (
|
||||
update.get("type") == "state"
|
||||
and update["game"]["auction"]["current"] is not None
|
||||
and 1 in update["game"]["auction"]["passed"]
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(update)
|
||||
assert update is not None
|
||||
# The absent seat 1 was auto-bid for.
|
||||
current = update["game"]["auction"]["current"]
|
||||
self.assertEqual(1, current["seat"])
|
||||
self.assertIn(current["rank"], (1, 3, 10, 9, 8, 7, 6, 5, 4, 2))
|
||||
# The absent seat 1 passed: nobody bid and the turn
|
||||
# moved to seat 2.
|
||||
auction = update["game"]["auction"]
|
||||
self.assertIn(1, auction["passed"])
|
||||
self.assertIsNone(auction["current"])
|
||||
self.assertEqual(2, auction["turn"])
|
||||
|
||||
@async_test
|
||||
async def test_forced_last_bidder_auto_bids_the_ace(self) -> None:
|
||||
def seed(state) -> None:
|
||||
state.phase = PHASE_AUCTION
|
||||
state.auction = Auction(
|
||||
turn=0, active=[0], passed=[1, 2, 3, 4], current=None
|
||||
)
|
||||
state.turn = 0
|
||||
state.auction_deadline = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||
).isoformat()
|
||||
state.call_deadline = None
|
||||
|
||||
game_id = await self._seed("chiamata-forced-ace-1", "CH0003", seed)
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||
|
||||
# The auction times out: seat 0 is forced to bid the
|
||||
# ace; its call then times out too and a random ace
|
||||
# suit is named.
|
||||
played = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
played = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
played.get("type") == "state"
|
||||
and played["game"]["phase"] == PHASE_PLAYING
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(played)
|
||||
assert played is not None
|
||||
self.assertEqual(PHASE_PLAYING, played["game"]["phase"])
|
||||
self.assertEqual(0, played["game"]["caller"])
|
||||
card = played["game"]["called_card"]
|
||||
self.assertIsNotNone(card)
|
||||
self.assertTrue(card.startswith("01"))
|
||||
|
||||
@async_test
|
||||
async def test_call_and_turn_timeouts(self) -> None:
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
futures = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage"] }
|
||||
js-sys = "0.3"
|
||||
gloo-timers = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<!-- Seamless felt grain: fractal noise mapped to semi-transparent black
|
||||
speckles, tiled as a CSS background over the green table gradient.
|
||||
stitchTiles + a filter region equal to the tile keep the seams
|
||||
invisible. -->
|
||||
<filter id="felt" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" result="noise"/>
|
||||
<feColorMatrix in="noise" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.3 0.3 0 -0.3"/>
|
||||
</filter>
|
||||
<rect width="240" height="240" filter="url(#felt)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
+32
-25
@@ -1,13 +1,32 @@
|
||||
//! REST client for the tavolo backend. Same-origin requests carry the
|
||||
//! session cookie automatically.
|
||||
use crate::auth;
|
||||
use crate::model::*;
|
||||
use gloo_net::http::Request;
|
||||
use gloo_net::http::{Request, Response};
|
||||
|
||||
fn server_error(status: u16) -> String {
|
||||
format!("server returned {status}")
|
||||
}
|
||||
|
||||
/// Map a failed response to an error. A `401` additionally starts the
|
||||
/// login flow: the session is gone (or the caller was never logged in),
|
||||
/// and the user is returned to this page after logging in.
|
||||
fn check(resp: &Response) -> Result<(), String> {
|
||||
if resp.status() == 401 {
|
||||
auth::redirect_to_login();
|
||||
return Err("login required".to_string());
|
||||
}
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the current user; `None` when unauthenticated (401).
|
||||
///
|
||||
/// Deliberately bypasses [`check`]: an unauthenticated answer is how the
|
||||
/// app renders its logged-out state, so it must not trigger the login
|
||||
/// redirect.
|
||||
pub async fn me() -> Result<Option<User>, String> {
|
||||
let resp = Request::get("/api/me")
|
||||
.send()
|
||||
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(page.providers)
|
||||
}
|
||||
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(page.results)
|
||||
}
|
||||
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 401 {
|
||||
auth::redirect_to_login();
|
||||
return Err("login required".to_string());
|
||||
}
|
||||
if !resp.ok() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let message = body
|
||||
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
||||
if resp.status() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map(Some).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -135,9 +150,7 @@ pub async fn my_games() -> Result<Vec<MyGame>, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(page.results)
|
||||
}
|
||||
@@ -148,9 +161,7 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||
None => "/api/me/matches?limit=10".to_string(),
|
||||
};
|
||||
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -160,9 +171,7 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -173,8 +182,6 @@ pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Client-side login redirect handling.
|
||||
//!
|
||||
//! The OIDC callback always returns the browser to the configured
|
||||
//! post-login redirect (usually `/`), and the backend's login flow has no
|
||||
//! per-request `next` support. So when the API answers `401`, the page the
|
||||
//! user is on is saved in `sessionStorage` and restored once a session
|
||||
//! exists again.
|
||||
use sycamore_router::navigate_replace;
|
||||
|
||||
/// `sessionStorage` key holding the page to return to after login.
|
||||
const RETURN_TO_KEY: &str = "tavolo.login.return_to";
|
||||
|
||||
fn session_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.session_storage().ok().flatten()
|
||||
}
|
||||
|
||||
/// The current page (path plus query), the URL to come back to after
|
||||
/// login.
|
||||
pub fn current_path() -> String {
|
||||
let Some(location) = web_sys::window().map(|w| w.location()) else {
|
||||
return String::new();
|
||||
};
|
||||
format!(
|
||||
"{}{}",
|
||||
location.pathname().unwrap_or_default(),
|
||||
location.search().unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a path is a page worth returning to: not the login flow
|
||||
/// itself, and not an absolute/protocol-relative URL.
|
||||
fn is_restorable(path: &str) -> bool {
|
||||
path.starts_with('/')
|
||||
&& !path.starts_with("//")
|
||||
&& !path.starts_with("/auth/")
|
||||
&& !path.starts_with("/api/")
|
||||
&& path != "/login"
|
||||
}
|
||||
|
||||
/// Handle an expired session: remember the current page and go to the
|
||||
/// login page.
|
||||
///
|
||||
/// The navigation is deferred to a timer so the caller can finish handling
|
||||
/// the failed response (e.g. set an error) before its page is unmounted.
|
||||
pub fn redirect_to_login() {
|
||||
let path = current_path();
|
||||
if is_restorable(&path) {
|
||||
if let Some(storage) = session_storage() {
|
||||
let _ = storage.set_item(RETURN_TO_KEY, &path);
|
||||
}
|
||||
}
|
||||
gloo_timers::callback::Timeout::new(0, move || {
|
||||
// Several in-flight requests may fail at once; only the first
|
||||
// takes us to the login page.
|
||||
if current_path() != "/login" {
|
||||
navigate_replace("/login");
|
||||
}
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
|
||||
/// The page saved by [`redirect_to_login`], if any.
|
||||
pub fn saved_return_to() -> Option<String> {
|
||||
let storage = session_storage()?;
|
||||
let path = storage.get_item(RETURN_TO_KEY).ok().flatten()?;
|
||||
is_restorable(&path).then_some(path)
|
||||
}
|
||||
|
||||
/// Drop the saved page, e.g. after it has been restored.
|
||||
pub fn clear_return_to() {
|
||||
if let Some(storage) = session_storage() {
|
||||
let _ = storage.remove_item(RETURN_TO_KEY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Login card, shared by the lobby's logged-out state and the `/login`
|
||||
//! page.
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::model::AuthProvider;
|
||||
|
||||
/// Human-readable label for a provider name ("google" -> "Google").
|
||||
fn provider_label(name: &str) -> String {
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Card offering one login button per configured provider.
|
||||
///
|
||||
/// `providers` is filled by the caller from `GET /api/auth/providers`;
|
||||
/// while it is empty (or the fetch failed) the card falls back to the
|
||||
/// generic `/auth/login` route, which redirects to the first configured
|
||||
/// provider.
|
||||
pub fn login_card(providers: Signal<Vec<AuthProvider>>) -> View {
|
||||
view! {
|
||||
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: these must be full page navigations
|
||||
// to the backend's OIDC login endpoints.
|
||||
(move || {
|
||||
let providers = providers.get_clone();
|
||||
if providers.is_empty() {
|
||||
// Providers could not be fetched: the generic
|
||||
// route redirects to the first configured
|
||||
// provider.
|
||||
view! {
|
||||
a(
|
||||
class="btn btn-warning btn-lg w-100",
|
||||
href="/auth/login",
|
||||
rel="external",
|
||||
) {
|
||||
"Log in"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
div(class="d-grid gap-2") {
|
||||
(providers.iter().cloned().map(|p| view! {
|
||||
a(
|
||||
class="btn btn-warning btn-lg w-100",
|
||||
href=p.login_url,
|
||||
rel="external",
|
||||
) {
|
||||
"Log in with " (provider_label(&p.name))
|
||||
}
|
||||
}).collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod briscola;
|
||||
pub mod card;
|
||||
pub mod copy;
|
||||
pub mod login;
|
||||
pub mod nav;
|
||||
pub mod summary;
|
||||
pub mod toast;
|
||||
|
||||
+33
-1
@@ -1,22 +1,27 @@
|
||||
//! Application entry point.
|
||||
mod api;
|
||||
mod auth;
|
||||
mod components;
|
||||
mod model;
|
||||
mod pages;
|
||||
mod ws;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
use sycamore_router::{HistoryIntegration, Route, Router};
|
||||
use sycamore_router::{navigate, HistoryIntegration, Route, Router};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use pages::game::GamePage;
|
||||
use pages::history::HistoryPage;
|
||||
use pages::leaderboard::LeaderboardPage;
|
||||
use pages::lobby::LobbyPage;
|
||||
use pages::login::LoginPage;
|
||||
|
||||
#[derive(Route, Clone)]
|
||||
enum AppRoutes {
|
||||
#[to("/")]
|
||||
Lobby,
|
||||
#[to("/login")]
|
||||
Login,
|
||||
#[to("/game/<id>")]
|
||||
Game { id: String },
|
||||
#[to("/history")]
|
||||
@@ -27,6 +32,31 @@ enum AppRoutes {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// After the OIDC callback lands the SPA back on the configured
|
||||
/// post-login redirect, forward the user to the page they were on before
|
||||
/// being sent to the login page (see [`auth`]) — but only once a session
|
||||
/// exists, so a cancelled login never bounces into a stale page.
|
||||
fn restore_page_after_login() {
|
||||
let Some(path) = auth::saved_return_to() else {
|
||||
return;
|
||||
};
|
||||
spawn_local(async move {
|
||||
match api::me().await {
|
||||
Ok(Some(_)) => {
|
||||
auth::clear_return_to();
|
||||
if auth::current_path() != path {
|
||||
navigate(&path);
|
||||
}
|
||||
}
|
||||
// Not logged in: keep the target for the next successful
|
||||
// login (a reload of the login page must not lose it).
|
||||
Ok(None) => {}
|
||||
// The probe failed (offline?): keep the target, too.
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
sycamore::render(|| {
|
||||
@@ -38,6 +68,7 @@ fn main() {
|
||||
div(class="app") {
|
||||
(match route.get_clone() {
|
||||
AppRoutes::Lobby => view! { LobbyPage() },
|
||||
AppRoutes::Login => view! { LoginPage() },
|
||||
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
||||
AppRoutes::History => view! { HistoryPage() },
|
||||
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
||||
@@ -60,4 +91,5 @@ fn main() {
|
||||
)
|
||||
}
|
||||
});
|
||||
restore_page_after_login();
|
||||
}
|
||||
|
||||
@@ -464,10 +464,25 @@ pub fn chiamata_table(
|
||||
}
|
||||
|
||||
/// Overlay for the auction winner: name the called card.
|
||||
///
|
||||
/// The winning bid fixes the rank, so the choice is which of the four
|
||||
/// suits to call: its holder becomes the secret partner, unless the
|
||||
/// caller holds it too — then that option means playing alone.
|
||||
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
|
||||
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
|
||||
return view! {};
|
||||
};
|
||||
let rank = cards
|
||||
.first()
|
||||
.and_then(|code| code.get(..2))
|
||||
.and_then(|rank| rank.parse::<i32>().ok())
|
||||
.map(rank_name)
|
||||
.unwrap_or_default();
|
||||
let title = if rank.is_empty() {
|
||||
"Name the called card".to_string()
|
||||
} else {
|
||||
format!("You won with the {rank} — name the card")
|
||||
};
|
||||
let own_hand = game
|
||||
.players
|
||||
.iter()
|
||||
@@ -479,33 +494,35 @@ pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
|
||||
.map(|code| {
|
||||
let held = own_hand.contains(&code);
|
||||
let label = card_label(&code);
|
||||
let img = card_img(code.clone(), "hand-card");
|
||||
let solo_note = held.then(|| view! {
|
||||
span(class="solo-note") { "plays alone" }
|
||||
});
|
||||
let img = card_img(code.clone(), "call-card");
|
||||
let note = if held {
|
||||
view! { span(class="call-note solo") { "Plays alone" } }
|
||||
} else {
|
||||
view! { span(class="call-note partner") { "Partner" } }
|
||||
};
|
||||
let click_code = code.clone();
|
||||
view! {
|
||||
button(class="capture-option call-option", on:click=move |_| {
|
||||
button(class="call-option", on:click=move |_| {
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.call_card(&click_code);
|
||||
}
|
||||
}) {
|
||||
(img)
|
||||
span(class="call-label") { (label) }
|
||||
(solo_note)
|
||||
(note)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class="overlay") {
|
||||
div(class="picker") {
|
||||
h3 { "Name the called card" }
|
||||
div(class="picker call-picker") {
|
||||
h3 { (title) }
|
||||
p(class="text-secondary small") {
|
||||
"Its holder becomes your secret partner; name one of your "
|
||||
"own cards to play alone."
|
||||
}
|
||||
div(class="capture-options") { (options) }
|
||||
div(class="call-options") { (options) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+168
-47
@@ -12,29 +12,50 @@ use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
||||
|
||||
/// Distinct team labels in seat order; players without a team render in
|
||||
/// a shared "Players" column.
|
||||
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
|
||||
let mut teams = BTreeSet::new();
|
||||
/// The match's teams, each as a `(label, names)` pair, in a readable
|
||||
/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori),
|
||||
/// then other team labels alphabetically (A, B, ...), with team-less
|
||||
/// players last.
|
||||
fn team_groups(m: &MatchSummary) -> Vec<(String, String)> {
|
||||
let mut labels: BTreeSet<Option<String>> = BTreeSet::new();
|
||||
for p in &m.players {
|
||||
teams.insert(p.team.clone());
|
||||
labels.insert(p.team.clone());
|
||||
}
|
||||
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
|
||||
// Seated teams first (in first-seat order), then the team-less column.
|
||||
ordered.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
let mut ordered: Vec<Option<String>> = labels.into_iter().collect();
|
||||
ordered.sort_by_key(team_sort_key);
|
||||
ordered
|
||||
.into_iter()
|
||||
.map(|label| (team_header(&label), team_names(m, &label)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sort key for team labels: chiamata roles in play order, other labels
|
||||
/// alphabetically, team-less players last.
|
||||
fn team_sort_key(label: &Option<String>) -> (u8, String) {
|
||||
match label.as_deref() {
|
||||
Some("Chiamante") => (0, String::new()),
|
||||
Some("Socio") => (1, String::new()),
|
||||
Some("Difensori") => (2, String::new()),
|
||||
Some(other) => (0, other.to_string()),
|
||||
None => (9, String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
||||
m.players
|
||||
let names = m
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| &p.team == team)
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ")
|
||||
.collect::<Vec<_>>();
|
||||
match names.len() {
|
||||
0 => String::new(),
|
||||
1 => names[0].clone(),
|
||||
_ => {
|
||||
let (last, rest) = names.split_last().expect("non-empty");
|
||||
format!("{} & {}", rest.join(", "), last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn team_header(team: &Option<String>) -> String {
|
||||
@@ -105,18 +126,29 @@ pub fn HistoryPage() -> View {
|
||||
spawn_local(async move {
|
||||
match api::my_matches(next.as_deref()).await {
|
||||
Ok(p) => {
|
||||
// The page can unmount (e.g. the login redirect) while
|
||||
// the request is in flight.
|
||||
if !page.is_alive() {
|
||||
return;
|
||||
}
|
||||
cursor.set(p.next_cursor.clone());
|
||||
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
||||
page.set(Some(p));
|
||||
}
|
||||
Err(e) => error.set(Some(e)),
|
||||
Err(e) => {
|
||||
if error.is_alive() {
|
||||
error.set(Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(types) = api::game_types().await {
|
||||
game_types.set(types);
|
||||
if game_types.is_alive() {
|
||||
game_types.set(types);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -124,11 +156,18 @@ pub fn HistoryPage() -> View {
|
||||
spawn_local(async move {
|
||||
match api::my_matches(None).await {
|
||||
Ok(p) => {
|
||||
if !page.is_alive() {
|
||||
return;
|
||||
}
|
||||
cursor.set(p.next_cursor.clone());
|
||||
rows.set(p.results.clone());
|
||||
page.set(Some(p));
|
||||
}
|
||||
Err(e) => error.set(Some(e)),
|
||||
Err(e) => {
|
||||
if error.is_alive() {
|
||||
error.set(Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -157,10 +196,18 @@ pub fn HistoryPage() -> View {
|
||||
.map(|m| {
|
||||
let finished = local_finished(&m.finished_at);
|
||||
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) } })
|
||||
let teams: Vec<View> = team_groups(&m)
|
||||
.into_iter()
|
||||
.map(|(label, names)| {
|
||||
view! {
|
||||
div(class="match-team") {
|
||||
span(class="text-secondary") {
|
||||
(label) ": "
|
||||
}
|
||||
(names)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let score = score_text(&m);
|
||||
let winner = winner_text(&m);
|
||||
@@ -180,37 +227,18 @@ pub fn HistoryPage() -> View {
|
||||
} else {
|
||||
"text-secondary"
|
||||
};
|
||||
let mut row_cells = vec![
|
||||
let row_cells = vec![
|
||||
view! { td { (finished) } },
|
||||
view! { td { (game) } },
|
||||
view! { td { (teams) } },
|
||||
view! { td { (score) } },
|
||||
view! { td { (winner) } },
|
||||
view! { td(class=cls) { (outcome) } },
|
||||
view! { td(class=cls) { (elo_delta) } },
|
||||
];
|
||||
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") {
|
||||
@@ -218,7 +246,7 @@ pub fn HistoryPage() -> View {
|
||||
tr {
|
||||
th { "Finished" }
|
||||
th { "Game" }
|
||||
(header_cells)
|
||||
th { "Teams" }
|
||||
th { "Score" }
|
||||
th { "Winner" }
|
||||
th { "You" }
|
||||
@@ -243,3 +271,96 @@ pub fn HistoryPage() -> View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn match_summary(players: serde_json::Value) -> MatchSummary {
|
||||
serde_json::from_value(json!({
|
||||
"id": "m1",
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"finished_at": "2026-01-01T01:00:00+00:00",
|
||||
"players": players,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_groups_order_chiamata_roles() {
|
||||
let m = match_summary(json!([
|
||||
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||
"team": "Difensori", "won": false},
|
||||
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||
"team": "Chiamante", "won": true},
|
||||
{"user_sub": "c", "display_name": "Carol", "seat": 2,
|
||||
"team": "Difensori", "won": false},
|
||||
{"user_sub": "d", "display_name": "Dave", "seat": 3,
|
||||
"team": "Socio", "won": true},
|
||||
{"user_sub": "e", "display_name": "Erin", "seat": 4,
|
||||
"team": "Difensori", "won": false},
|
||||
]));
|
||||
assert_eq!(
|
||||
vec![
|
||||
("Chiamante".to_string(), "Bob".to_string()),
|
||||
("Socio".to_string(), "Dave".to_string()),
|
||||
(
|
||||
"Difensori".to_string(),
|
||||
"Alice, Carol & Erin".to_string()
|
||||
),
|
||||
],
|
||||
team_groups(&m)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_groups_keep_a_and_b_in_order() {
|
||||
let m = match_summary(json!([
|
||||
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||
"team": "A", "won": true},
|
||||
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||
"team": "B", "won": false},
|
||||
]));
|
||||
assert_eq!(
|
||||
vec![
|
||||
("Team A".to_string(), "Alice".to_string()),
|
||||
("Team B".to_string(), "Bob".to_string()),
|
||||
],
|
||||
team_groups(&m)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_groups_put_teamless_players_last() {
|
||||
let m = match_summary(json!([
|
||||
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||
"team": null, "won": false},
|
||||
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||
"team": "A", "won": true},
|
||||
]));
|
||||
assert_eq!(
|
||||
vec![
|
||||
("Team A".to_string(), "Bob".to_string()),
|
||||
("Players".to_string(), "Alice".to_string()),
|
||||
],
|
||||
team_groups(&m)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_names_join_three_names_readably() {
|
||||
let m = match_summary(json!([
|
||||
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||
"team": "A", "won": true},
|
||||
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||
"team": "A", "won": true},
|
||||
{"user_sub": "c", "display_name": "Carol", "seat": 2,
|
||||
"team": "A", "won": true},
|
||||
]));
|
||||
assert_eq!(
|
||||
"Alice, Bob & Carol",
|
||||
team_names(&m, &Some("A".to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-62
@@ -13,21 +13,13 @@ use sycamore_router::navigate;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::card::card_back;
|
||||
use crate::components::login::login_card;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{
|
||||
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||||
MyGame, OptionField, OptionKind, PlayerRating, User,
|
||||
};
|
||||
|
||||
/// Human-readable label for a provider name ("google" -> "Google").
|
||||
fn provider_label(name: &str) -> String {
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_string(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
@@ -386,18 +378,30 @@ pub fn LobbyPage() -> View {
|
||||
if me.is_some() {
|
||||
spawn_local(async move {
|
||||
if let Ok(p) = api::my_ratings().await {
|
||||
ratings.set(p.results);
|
||||
if ratings.is_alive() {
|
||||
ratings.set(p.results);
|
||||
}
|
||||
}
|
||||
if let Ok(games) = api::my_games().await {
|
||||
my_games.set(games);
|
||||
if my_games.is_alive() {
|
||||
my_games.set(games);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
user.set(Some(me));
|
||||
// The page may have been unmounted (e.g. by the login
|
||||
// redirect triggered while this request was in flight).
|
||||
if user.is_alive() {
|
||||
user.set(Some(me));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(e));
|
||||
user.set(Some(None));
|
||||
if error.is_alive() {
|
||||
error.set(Some(e));
|
||||
}
|
||||
if user.is_alive() {
|
||||
user.set(Some(None));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -405,6 +409,9 @@ pub fn LobbyPage() -> View {
|
||||
spawn_local(async move {
|
||||
match api::game_types().await {
|
||||
Ok(types) if !types.is_empty() => {
|
||||
if !game_types.is_alive() {
|
||||
return;
|
||||
}
|
||||
selected_game.set(types[0].id.clone());
|
||||
game_types.set(types);
|
||||
}
|
||||
@@ -414,7 +421,9 @@ pub fn LobbyPage() -> View {
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(list) = api::auth_providers().await {
|
||||
auth_providers.set(list);
|
||||
if auth_providers.is_alive() {
|
||||
auth_providers.set(list);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -470,53 +479,7 @@ pub fn LobbyPage() -> View {
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => view! {
|
||||
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: these must be full page navigations
|
||||
// to the backend's OIDC login endpoints.
|
||||
(move || {
|
||||
let providers = auth_providers.get_clone();
|
||||
if providers.is_empty() {
|
||||
// Providers could not be fetched: the
|
||||
// generic route redirects to the first
|
||||
// configured provider.
|
||||
view! {
|
||||
a(
|
||||
class="btn btn-warning btn-lg w-100",
|
||||
href="/auth/login",
|
||||
rel="external",
|
||||
) {
|
||||
"Log in"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
div(class="d-grid gap-2") {
|
||||
(providers.iter().cloned().map(|p| view! {
|
||||
a(
|
||||
class="btn btn-warning btn-lg w-100",
|
||||
href=p.login_url,
|
||||
rel="external",
|
||||
) {
|
||||
"Log in with " (provider_label(&p.name))
|
||||
}
|
||||
}).collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => view! { (login_card(auth_providers)) },
|
||||
Some(Some(_me)) => {
|
||||
view! {
|
||||
(crate::components::nav::app_nav("/", move || {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Login page, shown after the API answered `401` (see [`crate::auth`]):
|
||||
//! the session expired, or the visitor tried to open a page that needs an
|
||||
//! account.
|
||||
//!
|
||||
//! The backend's OIDC callback lands the SPA back on the configured
|
||||
//! post-login redirect; [`crate::auth`] then returns the user to the page
|
||||
//! they were on before this one.
|
||||
use sycamore::prelude::*;
|
||||
use sycamore_router::navigate_replace;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::api;
|
||||
use crate::auth;
|
||||
use crate::components::login::login_card;
|
||||
use crate::model::AuthProvider;
|
||||
|
||||
#[component]
|
||||
pub fn LoginPage() -> View {
|
||||
let providers = create_signal(Vec::<AuthProvider>::new());
|
||||
|
||||
spawn_local(async move {
|
||||
// Already signed in: no business here — return to where the user
|
||||
// was headed, or the lobby.
|
||||
if matches!(api::me().await, Ok(Some(_))) {
|
||||
let target = auth::saved_return_to().unwrap_or_else(|| "/".to_string());
|
||||
auth::clear_return_to();
|
||||
navigate_replace(&target);
|
||||
return;
|
||||
}
|
||||
if let Ok(list) = api::auth_providers().await {
|
||||
// The page can unmount while the fetch is in flight.
|
||||
if providers.is_alive() {
|
||||
providers.set(list);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
(login_card(providers))
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ pub mod game;
|
||||
pub mod history;
|
||||
pub mod leaderboard;
|
||||
pub mod lobby;
|
||||
pub mod login;
|
||||
|
||||
+64
-11
@@ -292,8 +292,12 @@ body {
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
/* Tall dialogs (the chiamata call picker, the scoring summaries) can
|
||||
* exceed short windows: allow scrolling instead of clipping them. */
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@@ -302,8 +306,11 @@ body {
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem 2rem;
|
||||
text-align: center;
|
||||
min-width: min(320px, calc(100vw - 1.5rem));
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
/* Centers the dialog when it fits and keeps it reachable (top
|
||||
* aligned, scrollable via the overlay) when it does not. */
|
||||
margin: auto;
|
||||
min-width: min(320px, calc(100vw - 3rem));
|
||||
max-width: calc(100vw - 3rem);
|
||||
}
|
||||
|
||||
.capture-options {
|
||||
@@ -784,26 +791,64 @@ body {
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
/* The auction winner's call picker: one card per suit. */
|
||||
.call-option {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* The auction winner's call picker: the four cards of the won rank,
|
||||
* laid out in a compact grid so the dialog stays short. */
|
||||
.call-picker {
|
||||
max-width: min(34rem, calc(100vw - 3rem));
|
||||
}
|
||||
|
||||
.call-option .playing-card {
|
||||
.call-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.call-options {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.call-option {
|
||||
background: var(--panel-light);
|
||||
border: 1px solid #4c6a54;
|
||||
border-radius: 10px;
|
||||
padding: 0.4rem 0.3rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.call-option:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Smaller than a hand card: the picker must fit short laptop windows. */
|
||||
.playing-card.call-card {
|
||||
height: 84px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.call-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
margin-top: 0.25rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.solo-note {
|
||||
.call-note {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.call-note.partner {
|
||||
color: var(--team-a);
|
||||
}
|
||||
|
||||
.call-note.solo {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -826,3 +871,11 @@ body {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- match history ---------- */
|
||||
|
||||
/* One line per team inside the "Teams" cell of the history table. */
|
||||
.match-team {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
+21
-5
@@ -40,11 +40,27 @@
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: var(--felt-dark);
|
||||
background-image: radial-gradient(
|
||||
ellipse at 50% -10%,
|
||||
var(--felt) 0%,
|
||||
var(--felt-dark) 65%
|
||||
);
|
||||
/* Casino-table felt: a fine grain texture tiled over the green
|
||||
* gradient, lit from above and darkened towards the edges. The first
|
||||
* layer is the topmost one. */
|
||||
background-image:
|
||||
radial-gradient(
|
||||
ellipse at 50% 45%,
|
||||
transparent 55%,
|
||||
rgba(0, 0, 0, 0.38) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 50% -10%,
|
||||
rgba(255, 255, 255, 0.07),
|
||||
transparent 60%
|
||||
),
|
||||
url("/assets/felt.svg"),
|
||||
radial-gradient(
|
||||
ellipse at 50% -10%,
|
||||
var(--felt) 0%,
|
||||
var(--felt-dark) 65%
|
||||
);
|
||||
background-repeat: no-repeat, no-repeat, repeat, no-repeat;
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user