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",
|
"futures",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"gloo-storage",
|
"gloo-storage",
|
||||||
|
"gloo-timers",
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_i18n",
|
"leptos_i18n",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ gloo-storage = "0.3"
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
wasm-bindgen-futures = "0.4"
|
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.
|
# 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.
|
# Must be a direct dependency (not just transitive) for the feature to take effect.
|
||||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
|
|
|
||||||
|
|
@ -445,15 +445,19 @@ body {
|
||||||
position: relative;
|
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 {
|
.side-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: calc(100% + 1rem);
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: 0.5rem;
|
||||||
width: 200px;
|
|
||||||
padding-top: 0.15rem;
|
padding-top: 0.15rem;
|
||||||
|
pointer-events: none; /* pass board clicks through the empty area */
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; }
|
.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; }
|
.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; }
|
||||||
|
|
||||||
/* ── Scoring notification panel (§6b) ───────────────────────────────── */
|
/* ── Scoring notification panel (§6b) ───────────────────────────────── */
|
||||||
@keyframes score-panel-in {
|
|
||||||
from { transform: translateX(18px); opacity: 0; }
|
/* ── Wrapper: handles slide-in → peek → reveal lifecycle ──────────────
|
||||||
to { transform: translateX(0); opacity: 1; }
|
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 {
|
.scoring-panel {
|
||||||
background: var(--ui-parchment);
|
background: var(--ui-parchment);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.4rem 0.7rem;
|
padding: 0.45rem 0.85rem;
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||||
border-left: 3px solid var(--ui-green-accent);
|
border-left: 3px solid var(--ui-green-accent);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3px;
|
gap: 4px;
|
||||||
animation: score-panel-in 0.22s ease-out;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoring-total {
|
.scoring-total {
|
||||||
|
|
@ -660,15 +697,17 @@ body {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #1a5c1a;
|
color: #1a5c1a;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoring-jan-row {
|
.scoring-jan-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 1px 2px;
|
padding: 2px 3px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.scoring-jan-row:hover { background: rgba(0,0,0,0.05); }
|
.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; }
|
.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 ──────────────────────────────────────────────────── */
|
||||||
.board-wrapper {
|
.board-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
use futures::channel::mpsc::UnboundedSender;
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use gloo_timers::future::TimeoutFuture;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use trictrac_store::CheckerMove;
|
use trictrac_store::CheckerMove;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
use crate::app::NetCommand;
|
use crate::app::NetCommand;
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
@ -8,6 +12,10 @@ use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
|
||||||
|
|
||||||
use super::score_panel::jan_label;
|
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 {
|
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
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());
|
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-label">{move || jan_label(&jan)}</span>
|
||||||
<span class="jan-tag">{move || if is_double {
|
<span class="jan-tag">{move || if is_double {
|
||||||
|
|
@ -58,51 +61,132 @@ pub fn ScoringPanel(
|
||||||
let holes_total = event.holes_total;
|
let holes_total = event.holes_total;
|
||||||
let bredouille = event.bredouille;
|
let bredouille = event.bredouille;
|
||||||
let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
|
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();
|
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class=panel_class>
|
// ── Outer wrapper: owns the slide / peek / reveal animation ───────
|
||||||
<div class="scoring-total">
|
// pointer-events are on by default (parent .side-panel sets none,
|
||||||
{move || if is_opponent {
|
// and .scoring-panel-wrapper overrides back to auto in CSS).
|
||||||
t_string!(i18n, opp_scored_pts, n = points_earned)
|
<div
|
||||||
} else {
|
class="scoring-panel-wrapper"
|
||||||
t_string!(i18n, scored_pts, n = points_earned)
|
class:peeked=move || peeked.get()
|
||||||
}}
|
class:revealed=move || revealed.get()
|
||||||
</div>
|
// Click toggles revealed↔peeked when the panel is in its peeked state.
|
||||||
{jan_rows}
|
on:click=move |_| {
|
||||||
{(holes_gained > 0).then(|| view! {
|
if peeked.get_untracked() {
|
||||||
<div class="scoring-hole">
|
revealed.update(|r| *r = !*r);
|
||||||
<span>{move || if is_opponent {
|
|
||||||
t_string!(i18n, opp_hole_made, holes = holes_total)
|
|
||||||
} else {
|
|
||||||
t_string!(i18n, hole_made, holes = holes_total)
|
|
||||||
}}</span>
|
|
||||||
{bredouille.then(|| view! {
|
|
||||||
<span class="bredouille-badge">
|
|
||||||
{move || t_string!(i18n, bredouille_applied)}
|
|
||||||
</span>
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
})}
|
|
||||||
{show_hold_go.then(|| {
|
|
||||||
let dismissed = RwSignal::new(false);
|
|
||||||
view! {
|
|
||||||
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
|
||||||
<button class="btn btn-secondary" on:click=move |_| {
|
|
||||||
dismissed.set(true);
|
|
||||||
}>
|
|
||||||
{t!(i18n, hold)}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary" on:click=move |_| {
|
|
||||||
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
|
||||||
}>
|
|
||||||
{t!(i18n, go)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
})}
|
// 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 {
|
||||||
|
t_string!(i18n, opp_scored_pts, n = points_earned)
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, scored_pts, n = points_earned)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
{jan_rows}
|
||||||
|
{(holes_gained > 0).then(|| view! {
|
||||||
|
<div class="scoring-hole">
|
||||||
|
<span>{move || if is_opponent {
|
||||||
|
t_string!(i18n, opp_hole_made, holes = holes_total)
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, hole_made, holes = holes_total)
|
||||||
|
}}</span>
|
||||||
|
{bredouille.then(|| view! {
|
||||||
|
<span class="bredouille-badge">
|
||||||
|
{move || t_string!(i18n, bredouille_applied)}
|
||||||
|
</span>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
{show_hold_go.then(|| {
|
||||||
|
let dismissed = RwSignal::new(false);
|
||||||
|
view! {
|
||||||
|
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
||||||
|
// 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 |ev: leptos::web_sys::MouseEvent| {
|
||||||
|
ev.stop_propagation();
|
||||||
|
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||||
|
}>
|
||||||
|
{t!(i18n, go)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue