Let anyone with the game URL watch as a read-only spectator
CI / Build and push docker image (push) Successful in 3m0s
CI / Build and push docker image (push) Successful in 3m0s
Anonymous users and logged-in non-players can now open /game/<id> and
follow the match: they see the table, the score and each player's
captured cards and scope, but never a hand and never the join code.
Spectator actions are rejected with a forbidden error.
Backend (tavolo-platform / tavolo-scopone):
- /ws/games/{id} accepts anonymous and non-seated connections as
spectators; only unknown games are refused. The state payload carries
spectator: true and withholds join_code unless the viewer is seated.
- GET /api/games/{id} is public and returns the same personalized
(player) or read-only (spectator) view.
- GameEngine.view_for/game_over_view take Optional[str] (None = spectator)
and are documented to hide privileged information for non-seated
viewers.
Frontend (Sycamore):
- Parse the spectator flag and render a spectator table: card backs plus
captured/scope for every seat, no clickable hand, "Spectating" badge.
- A pre-start spectator sees a "game not started yet" waiting room with
the watch link and no join code.
- "Copy watch link" buttons in the waiting room and the game HUD.
- The hand-end summary carries no acknowledge button for spectators and
the game-over overlay hides "My matches".
Tests and docs updated.
This commit is contained in:
+73
-48
@@ -1,6 +1,5 @@
|
||||
//! Live game page: table view over the websocket.
|
||||
use std::cell::Cell;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
@@ -8,6 +7,7 @@ use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::components::copy::CopyButton;
|
||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{card_label, hand_sort_key, GameView, MoveView, PlayerView, Scores, ServerMessage};
|
||||
@@ -19,6 +19,14 @@ fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<V
|
||||
}
|
||||
}
|
||||
|
||||
/// The absolute, shareable URL of this game's page, for the clipboard.
|
||||
fn watch_link(id: &str) -> String {
|
||||
let origin = web_sys::window()
|
||||
.and_then(|w| w.location().origin().ok())
|
||||
.unwrap_or_default();
|
||||
format!("{origin}/game/{id}")
|
||||
}
|
||||
|
||||
/// Panel for one player seat (name, team, hidden card count, stats).
|
||||
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else {
|
||||
@@ -154,11 +162,6 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
let alive = alive.clone();
|
||||
spawn_local(async move {
|
||||
match api::probe_game(&id).await {
|
||||
api::GameEntry::Unauthenticated => {
|
||||
ctx.fatal.set(true);
|
||||
ctx.error
|
||||
.set(Some("Session expired — please log in again.".to_string()));
|
||||
}
|
||||
api::GameEntry::Unavailable => {
|
||||
ctx.fatal.set(true);
|
||||
ctx.error
|
||||
@@ -407,38 +410,16 @@ pub fn GamePage(id: String) -> View {
|
||||
|
||||
/// Lobby view while waiting for the remaining players. The seat count
|
||||
/// comes from the payload (`seats_open`), so games with other player
|
||||
/// counts render correctly.
|
||||
/// counts render correctly. Spectators (no seat, no join code) get a
|
||||
/// "not started yet" note and the watch link instead.
|
||||
#[component(inline_props)]
|
||||
fn LobbyView(game: GameView) -> View {
|
||||
let seats_open = game
|
||||
.seats_open
|
||||
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
||||
let spectator = game.spectator;
|
||||
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 watch_link = watch_link(&game.id);
|
||||
|
||||
let players = game
|
||||
.players
|
||||
@@ -472,18 +453,39 @@ fn LobbyView(game: GameView) -> View {
|
||||
};
|
||||
let percent_style = format!("width: {percent}%");
|
||||
|
||||
// The server withholds the join code from spectators; they get a
|
||||
// "not started yet" note and can share the watch link instead.
|
||||
let share = if spectator {
|
||||
view! {
|
||||
p(class="text-secondary") {
|
||||
"This game hasn't started yet — waiting for the players to take their seats."
|
||||
}
|
||||
div(class="d-grid mb-4") {
|
||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Separate clones: each interpolation/prop captures its own copy.
|
||||
let display_code = join_code.clone();
|
||||
let copy_code = join_code.clone();
|
||||
view! {
|
||||
p(class="text-secondary") { "Share this join code with your friends:" }
|
||||
p(class="code-display") { (display_code) }
|
||||
div(class="d-grid gap-2 mb-4") {
|
||||
CopyButton(text=copy_code, label="Copy code".to_string())
|
||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
div(class="container page-shell py-4") {
|
||||
div(class="card status-card") {
|
||||
div(class="card-body p-4") {
|
||||
h1(class="h4 mb-3") { "Waiting for players" }
|
||||
p(class="text-secondary") { "Share this join code with your friends:" }
|
||||
p(class="code-display") { (join_code) }
|
||||
div(class="d-grid mb-4") {
|
||||
button(class="btn btn-outline-light", on:click=copy) {
|
||||
(move || if copied.get() { "Copied!" } else { "Copy code" })
|
||||
}
|
||||
h1(class="h4 mb-3") {
|
||||
(if spectator { "Game not started yet" } else { "Waiting for players" })
|
||||
}
|
||||
(share)
|
||||
ul(class="list-group list-group-flush text-start mb-3") {
|
||||
(players)
|
||||
(open_seats)
|
||||
@@ -536,7 +538,8 @@ fn table_view(
|
||||
selected: Signal<Option<String>>,
|
||||
now: Signal<f64>,
|
||||
) -> View {
|
||||
// Own seat: the only player entry carrying a hand.
|
||||
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||
// hand, so they watch from seat 0's perspective.
|
||||
let viewer_seat = game
|
||||
.players
|
||||
.iter()
|
||||
@@ -622,18 +625,41 @@ fn table_view(
|
||||
}
|
||||
});
|
||||
|
||||
// Spectators have no hand: render the pivot seat as a regular panel
|
||||
// so its captured count and scope stay visible where the hand would be.
|
||||
let bottom = if game.spectator {
|
||||
seat_panel(game.clone(), viewer_seat, "bottom")
|
||||
} else {
|
||||
view! {
|
||||
div(class="seat-bottom") {
|
||||
div(class="seat-stats own-stats") {
|
||||
(my_captured) " captured · " (my_scope) " scope"
|
||||
}
|
||||
div(class="hand") { (hand_cards) }
|
||||
(hint)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let spectator_badge = game.spectator.then(|| view! {
|
||||
span(class="spectator-badge") { "Spectating" }
|
||||
});
|
||||
let watch_link = watch_link(&game.id);
|
||||
|
||||
view! {
|
||||
div(class="table-wrap") {
|
||||
div(class="hud") {
|
||||
a(href="/") { "← Lobby" }
|
||||
span { "Hand " (hand_number) }
|
||||
(rule_badge)
|
||||
(spectator_badge)
|
||||
span(class="hud-scores") {
|
||||
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
||||
(target_score) ")"
|
||||
}
|
||||
span(class=turn_cls) { (turn_note) }
|
||||
(countdown)
|
||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||
}
|
||||
div(class="table-grid") {
|
||||
(top)
|
||||
@@ -646,13 +672,7 @@ fn table_view(
|
||||
}
|
||||
}
|
||||
(right)
|
||||
div(class="seat-bottom") {
|
||||
div(class="seat-stats own-stats") {
|
||||
(my_captured) " captured · " (my_scope) " scope"
|
||||
}
|
||||
div(class="hand") { (hand_cards) }
|
||||
(hint)
|
||||
}
|
||||
(bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,6 +733,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
Some((scores, winner)) => {
|
||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
|
||||
// Spectators have no match history to look at.
|
||||
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
||||
// Explain the final hand's scoring before the result.
|
||||
let final_summary = game
|
||||
.and_then(|g| g.last_hand)
|
||||
@@ -720,6 +742,9 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
let rows = summary_rows(s);
|
||||
view! { (rows) }
|
||||
});
|
||||
let my_matches = (!spectator).then(|| view! {
|
||||
a(class="btn btn-outline-light", href="/history") { "My matches" }
|
||||
});
|
||||
view! {
|
||||
div(class="overlay") {
|
||||
div(class="picker summary-panel") {
|
||||
@@ -728,7 +753,7 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
p(class="final-score") { (line) }
|
||||
div(class="gameover-actions") {
|
||||
a(class="btn btn-warning", href="/") { "Back to lobby" }
|
||||
a(class="btn btn-outline-light", href="/history") { "My matches" }
|
||||
(my_matches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user