Files
tavolo/web/src/pages/history.rs
T
woggioni 96a95d74b6 Add Sycamore/WASM frontend and restructure into server/ + web/
Repo is now a monorepo:

- server/: the kaya backend, unchanged in behaviour, plus:
  - GET /api/me for SPA session detection
  - last_move recorded on every play and broadcast in the game state, so
    clients can show who played which card the moment they play it
  - legal_moves per hand card for the player on turn (rules stay
    server-side)
  - static catch-all route serving the compiled SPA with index.html
    fallback; Tortoise context now bound only for /api/* requests
  - configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
  lobby (create match / join by code), live game page over websocket with
  card images (CC0 woodcut napoletane deck), capture picker, move banner,
  game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
  app image serves the SPA; compose builds from the repo root with
  overridable ports/OIDC env

Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
2026-09-16 13:20:05 +08:00

111 lines
4.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Match history page.
use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*;
use crate::api;
use crate::model::MatchesPage;
#[component]
pub fn HistoryPage() -> View {
let page = create_signal(Option::<MatchesPage>::None);
let error = create_signal(Option::<String>::None);
let cursor = create_signal(Option::<String>::None);
// Accumulated rows across "load more" clicks.
let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
let load = move |next: Option<String>| {
spawn_local(async move {
match api::my_matches(next.as_deref()).await {
Ok(p) => {
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)),
}
});
};
let load2 = load;
spawn_local(async move {
match api::my_matches(None).await {
Ok(p) => {
cursor.set(p.next_cursor.clone());
rows.set(p.results.clone());
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
}
});
view! {
div(class="page") {
nav(class="top-nav") {
a(href="/") { "← Lobby" }
a(href="/leaderboard") { "Leaderboard" }
}
h1 { "My matches" }
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
(move || match page.get_clone() {
None => view! { p(class="status") { "Loading…" } },
Some(_) if rows.get_clone().is_empty() => view! {
p(class="status") { "No matches played yet." }
},
Some(_) => {
let table_rows = rows
.get_clone()
.into_iter()
.map(|m| {
let team_a: String = m
.players
.iter()
.filter(|p| p.team == "A")
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ");
let team_b: String = m
.players
.iter()
.filter(|p| p.team == "B")
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ");
let outcome = if m.you_won { "Won" } else { "Lost" };
view! {
tr {
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
td { (team_a) }
td { (team_b) }
td { (m.team_a_score) " " (m.team_b_score) }
td { "Team " (m.winner_team) }
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
}
}
})
.collect::<Vec<_>>();
view! {
table(class="matches") {
thead {
tr {
th { "Finished" }
th { "Team A" }
th { "Team B" }
th { "Score" }
th { "Winner" }
th { "You" }
}
}
tbody { (table_rows) }
}
(cursor.get_clone().map(|c| view! {
button(class="button", on:click=move |_| load2(Some(c.clone()))) {
"Load more"
}
}))
}
}
})
}
}
}