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:
@@ -0,0 +1,45 @@
|
||||
//! Clipboard copy button, shared by the join-code and watch-link actions.
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
|
||||
/// A button that copies `text` to the clipboard and shows "Copied!" for a
|
||||
/// moment. `label` is the idle caption.
|
||||
#[component(inline_props)]
|
||||
pub fn CopyButton(text: String, label: String) -> View {
|
||||
let copied = create_signal(false);
|
||||
// Held so the reset timer is cancelled if the button 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 on_click = {
|
||||
let text = text.clone();
|
||||
move |_| {
|
||||
if let Some(win) = web_sys::window() {
|
||||
let _ = win.navigator().clipboard().write_text(&text);
|
||||
}
|
||||
copied.set(true);
|
||||
*reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
|
||||
1500,
|
||||
move || {
|
||||
copied.set(false);
|
||||
},
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
button(class="btn btn-outline-light", on:click=on_click) {
|
||||
(move || if copied.get() { "Copied!".to_string() } else { label.clone() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod card;
|
||||
pub mod copy;
|
||||
pub mod nav;
|
||||
pub mod summary;
|
||||
pub mod toast;
|
||||
|
||||
@@ -260,7 +260,14 @@ pub fn hand_summary_modal(
|
||||
let totals_view = totals(&game);
|
||||
let title = format!("Hand {} — results", summary.hand);
|
||||
|
||||
let action = if already_acked {
|
||||
let action = if game.spectator {
|
||||
// Spectators cannot acknowledge; they just watch the summary.
|
||||
view! {
|
||||
p(class="text-secondary small mb-0") {
|
||||
"Spectating — the next hand is dealt when every player is ready."
|
||||
}
|
||||
}
|
||||
} else if already_acked {
|
||||
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
||||
view! {
|
||||
button(class="btn btn-warning", disabled=true) { (waiting_text) }
|
||||
|
||||
Reference in New Issue
Block a user