feat(client_web): slide dice jans right panel
This commit is contained in:
parent
4550b1d66a
commit
72c5e16ea3
4 changed files with 197 additions and 55 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1397,6 +1397,7 @@ dependencies = [
|
|||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"gloo-storage",
|
||||
"gloo-timers",
|
||||
"leptos",
|
||||
"leptos_i18n",
|
||||
"rand 0.9.2",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ gloo-storage = "0.3"
|
|||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
|
||||
# Must be a direct dependency (not just transitive) for the feature to take effect.
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
|
|
|
|||
|
|
@ -445,15 +445,19 @@ body {
|
|||
position: relative;
|
||||
}
|
||||
|
||||
/* The side panel is anchored to the board's RIGHT edge. Scoring panel
|
||||
wrappers inside it initially overlap the board; they slide to a peek
|
||||
strip after a few seconds, and reveal fully on hover. */
|
||||
.side-panel {
|
||||
position: absolute;
|
||||
left: calc(100% + 1rem);
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
width: 200px;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.15rem;
|
||||
pointer-events: none; /* pass board clicks through the empty area */
|
||||
}
|
||||
|
||||
.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
|
@ -637,22 +641,55 @@ body {
|
|||
.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; }
|
||||
|
||||
/* ── Scoring notification panel (§6b) ───────────────────────────────── */
|
||||
@keyframes score-panel-in {
|
||||
from { transform: translateX(18px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
|
||||
/* ── Wrapper: handles slide-in → peek → reveal lifecycle ──────────────
|
||||
The wrapper starts off-screen right (translateX(100%)), slides in on
|
||||
mount via animation, then Leptos adds .peeked after 3.4s to slide it
|
||||
back to a 28px peek strip. First hover adds .revealed for permanent
|
||||
visibility. pointer-events: auto overrides the parent's none. */
|
||||
@keyframes scoring-panel-enter {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.scoring-panel-wrapper {
|
||||
/* width: 290px; */
|
||||
pointer-events: auto;
|
||||
animation: scoring-panel-enter 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
filter: drop-shadow(-4px 0 14px rgba(0,0,0,0.38));
|
||||
}
|
||||
|
||||
/* Peeked: slide right by the full panel width so the board is 100% clear.
|
||||
The panel's left portion stays visible in whatever free space exists to
|
||||
the right of the board (depends on viewport width). */
|
||||
.scoring-panel-wrapper.peeked {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* Click on the visible left strip → .revealed slides it back over the board.
|
||||
A second click removes .revealed and returns to the peeked position. */
|
||||
.scoring-panel-wrapper.revealed {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Pointer cursor on the peeked (clickable) strip */
|
||||
.scoring-panel-wrapper.peeked:not(.revealed) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Inner panel card ─────────────────────────────────────────────────── */
|
||||
.scoring-panel {
|
||||
background: var(--ui-parchment);
|
||||
border-radius: 5px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.84rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
border-left: 3px solid var(--ui-green-accent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
animation: score-panel-in 0.22s ease-out;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scoring-total {
|
||||
|
|
@ -660,15 +697,17 @@ body {
|
|||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: #1a5c1a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scoring-jan-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 1px 2px;
|
||||
padding: 2px 3px;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.scoring-jan-row:hover { background: rgba(0,0,0,0.05); }
|
||||
|
||||
|
|
@ -688,6 +727,23 @@ body {
|
|||
|
||||
.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; }
|
||||
|
||||
/* ── Large-screen layout: panel in free space, no peek needed ─────────
|
||||
Threshold: board (832) + body-padding (48) + panel-gap (16) + panel (290)
|
||||
+ symmetric left margin = 1492 px.
|
||||
At this width the panel fits entirely to the right of the board. */
|
||||
@media (min-width: 1492px) {
|
||||
.side-panel {
|
||||
right: auto;
|
||||
left: calc(100% + 1rem); /* outside board, no overlap */
|
||||
}
|
||||
/* Already fully visible in free space — peeked/revealed are no-ops. */
|
||||
.scoring-panel-wrapper.peeked,
|
||||
.scoring-panel-wrapper.revealed {
|
||||
transform: none;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Board wrapper ──────────────────────────────────────────────────── */
|
||||
.board-wrapper {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
use futures::channel::mpsc::UnboundedSender;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use leptos::prelude::*;
|
||||
use trictrac_store::CheckerMove;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::app::NetCommand;
|
||||
use crate::i18n::*;
|
||||
|
|
@ -8,6 +12,10 @@ use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
|
|||
|
||||
use super::score_panel::jan_label;
|
||||
|
||||
/// One row in the scoring panel. Sets the hovered-moves context on enter
|
||||
/// (so board shows arrows for that jan's moves), but does NOT clear on
|
||||
/// leave — clearing is handled by the outer wrapper's mouseleave so that
|
||||
/// arrows persist while the pointer moves between rows.
|
||||
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||
|
|
@ -25,11 +33,6 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
|||
h.set(moves_hover.clone());
|
||||
}
|
||||
}
|
||||
on:mouseleave=move |_| {
|
||||
if let Some(h) = hovered {
|
||||
h.set(vec![]);
|
||||
}
|
||||
}
|
||||
>
|
||||
<span class="jan-label">{move || jan_label(&jan)}</span>
|
||||
<span class="jan-tag">{move || if is_double {
|
||||
|
|
@ -58,11 +61,88 @@ pub fn ScoringPanel(
|
|||
let holes_total = event.holes_total;
|
||||
let bredouille = event.bredouille;
|
||||
let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
|
||||
let panel_class = if is_opponent { "scoring-panel scoring-panel-opp" } else { "scoring-panel" };
|
||||
let panel_class = if is_opponent {
|
||||
"scoring-panel scoring-panel-opp"
|
||||
} else {
|
||||
"scoring-panel"
|
||||
};
|
||||
|
||||
// ── Lifecycle signals ──────────────────────────────────────────────────
|
||||
// peeked: added after 3.4 s (slide to peek strip)
|
||||
// revealed: added on first hover of the peek strip (stay open permanently)
|
||||
let peeked = RwSignal::new(false);
|
||||
let revealed = RwSignal::new(false);
|
||||
|
||||
// ── Collect all moves from all jans for automatic arrow display ────────
|
||||
let all_moves: Vec<(CheckerMove, CheckerMove)> = event
|
||||
.jans
|
||||
.iter()
|
||||
.flat_map(|e| e.moves.iter().cloned())
|
||||
.collect();
|
||||
let all_moves_click = all_moves.clone();
|
||||
let all_moves_enter = all_moves.clone();
|
||||
|
||||
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||
|
||||
// On mount: show all this event's moves as board arrows immediately.
|
||||
// After 3.4 s (0.45 s slide-in + 3 s display + guard), slide to peek
|
||||
// and clear arrows. A cancellation flag prevents stale tasks from
|
||||
// interfering when the component is replaced by a new scored event.
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(all_moves.clone());
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let is_alive = RwSignal::new(true);
|
||||
on_cleanup(move || is_alive.set(false));
|
||||
|
||||
spawn_local(async move {
|
||||
TimeoutFuture::new(3_400).await;
|
||||
if !is_alive.get_untracked() {
|
||||
return;
|
||||
}
|
||||
hm.set(vec![]);
|
||||
peeked.set(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
||||
|
||||
view! {
|
||||
// ── Outer wrapper: owns the slide / peek / reveal animation ───────
|
||||
// pointer-events are on by default (parent .side-panel sets none,
|
||||
// and .scoring-panel-wrapper overrides back to auto in CSS).
|
||||
<div
|
||||
class="scoring-panel-wrapper"
|
||||
class:peeked=move || peeked.get()
|
||||
class:revealed=move || revealed.get()
|
||||
// Click toggles revealed↔peeked when the panel is in its peeked state.
|
||||
on:click=move |_| {
|
||||
if peeked.get_untracked() {
|
||||
revealed.update(|r| *r = !*r);
|
||||
}
|
||||
// Show arrows when clicking to open, clear when clicking to close.
|
||||
if let Some(hm) = hovered_ctx {
|
||||
if !revealed.get_untracked() {
|
||||
hm.set(all_moves_click.clone());
|
||||
} else {
|
||||
hm.set(vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
on:mouseenter=move |_| {
|
||||
// Show all event moves as arrows while the cursor is inside.
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(all_moves_enter.clone());
|
||||
}
|
||||
}
|
||||
on:mouseleave=move |_| {
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(vec![]);
|
||||
}
|
||||
}
|
||||
>
|
||||
<div class=panel_class>
|
||||
<div class="scoring-total">
|
||||
{move || if is_opponent {
|
||||
|
|
@ -90,12 +170,15 @@ pub fn ScoringPanel(
|
|||
let dismissed = RwSignal::new(false);
|
||||
view! {
|
||||
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
||||
<button class="btn btn-secondary" on:click=move |_| {
|
||||
// stop_propagation so these buttons don't also toggle the panel
|
||||
<button class="btn btn-secondary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
dismissed.set(true);
|
||||
}>
|
||||
{t!(i18n, hold)}
|
||||
</button>
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
<button class="btn btn-primary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||
}>
|
||||
{t!(i18n, go)}
|
||||
|
|
@ -104,5 +187,6 @@ pub fn ScoringPanel(
|
|||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue