Compare commits
3 commits
d24f850882
...
236c6df826
| Author | SHA1 | Date | |
|---|---|---|---|
| 236c6df826 | |||
| e0698986f1 | |||
| 2c3281cc34 |
9 changed files with 566 additions and 116 deletions
|
|
@ -378,7 +378,7 @@ a:hover { text-decoration: underline; }
|
||||||
/* ── Game overlay (full-screen, covers portal during play) ───────── */
|
/* ── Game overlay (full-screen, covers portal during play) ───────── */
|
||||||
.game-overlay {
|
.game-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 54px 0 0 0;
|
inset: 0;
|
||||||
background: #8a7050;
|
background: #8a7050;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%),
|
radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%),
|
||||||
|
|
@ -1776,3 +1776,213 @@ a:hover { text-decoration: underline; }
|
||||||
color: var(--ui-red-accent);
|
color: var(--ui-red-accent);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Nickname modal (anonymous player name chooser) ─────────────────── */
|
||||||
|
.nickname-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-modal {
|
||||||
|
background: var(--ui-parchment);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem 2rem 1.75rem;
|
||||||
|
width: min(360px, 90vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 60px rgba(0,0,0,0.55),
|
||||||
|
0 0 0 1px rgba(200,164,72,0.35),
|
||||||
|
0 0 0 5px rgba(42,21,8,0.9),
|
||||||
|
0 0 0 6px rgba(200,164,72,0.2);
|
||||||
|
animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-modal-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ui-ink);
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-modal-hint {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(42,26,8,0.6);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-modal-alt {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(42,26,8,0.55);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid rgba(138,106,40,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-modal-alt a {
|
||||||
|
color: var(--ui-gold-dark);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-modal-alt a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */
|
||||||
|
.game-hamburger {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
z-index: 251;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: var(--board-rail);
|
||||||
|
border: 1px solid rgba(200,164,72,0.35);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.game-hamburger:hover {
|
||||||
|
background: #3d1f0a;
|
||||||
|
border-color: rgba(200,164,72,0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-bar {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ui-parchment);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
/* Top bar rotates down to form \ */
|
||||||
|
.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); }
|
||||||
|
/* Middle bar fades out */
|
||||||
|
.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); }
|
||||||
|
/* Bottom bar rotates up to form / */
|
||||||
|
.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); }
|
||||||
|
|
||||||
|
/* ── Game sidebar ────────────────────────────────────────────────────── */
|
||||||
|
.game-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 280px;
|
||||||
|
z-index: 250;
|
||||||
|
background: var(--board-rail);
|
||||||
|
border-right: 1px solid rgba(200,164,72,0.25);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.game-sidebar-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(200,164,72,0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-sidebar-brand {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ui-gold);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-left: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-sidebar-close {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(200,164,72,0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--ui-parchment);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.65;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.game-sidebar-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.game-sidebar-section {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(200,164,72,0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-sidebar-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(242,232,208,0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-sidebar-link {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ui-parchment);
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
|
||||||
|
.game-sidebar-btn {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid rgba(200,164,72,0.35);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(200,164,72,0.1);
|
||||||
|
color: var(--ui-parchment);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); }
|
||||||
|
|
||||||
|
.game-sidebar-btn-newgame {
|
||||||
|
background: rgba(58,107,42,0.25);
|
||||||
|
border-color: rgba(58,107,42,0.55);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); }
|
||||||
|
|
||||||
|
.game-sidebar-qr {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
max-width: 200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,5 +121,12 @@
|
||||||
"scan_qr": "or scan the QR code",
|
"scan_qr": "or scan the QR code",
|
||||||
"join_code_label": "Join by code",
|
"join_code_label": "Join by code",
|
||||||
"join_code_placeholder": "Room code",
|
"join_code_placeholder": "Room code",
|
||||||
"share_btn": "Share"
|
"share_btn": "Share",
|
||||||
|
"nickname_modal_title": "Choose your nickname",
|
||||||
|
"nickname_modal_hint": "You will play as:",
|
||||||
|
"nickname_modal_play": "Play",
|
||||||
|
"nickname_modal_or": "or",
|
||||||
|
"nickname_modal_sign_in": "Sign in",
|
||||||
|
"nickname_modal_register": "Create account",
|
||||||
|
"new_game": "New game"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,5 +121,12 @@
|
||||||
"scan_qr": "ou scannez le QR code",
|
"scan_qr": "ou scannez le QR code",
|
||||||
"join_code_label": "Rejoindre par code",
|
"join_code_label": "Rejoindre par code",
|
||||||
"join_code_placeholder": "Code de salle",
|
"join_code_placeholder": "Code de salle",
|
||||||
"share_btn": "Partager"
|
"share_btn": "Partager",
|
||||||
|
"nickname_modal_title": "Choisissez votre pseudo",
|
||||||
|
"nickname_modal_hint": "Vous jouerez sous le nom de :",
|
||||||
|
"nickname_modal_play": "Jouer",
|
||||||
|
"nickname_modal_or": "ou",
|
||||||
|
"nickname_modal_sign_in": "Se connecter",
|
||||||
|
"nickname_modal_register": "Créer un compte",
|
||||||
|
"new_game": "Nouvelle partie"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use futures::{FutureExt, StreamExt};
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_router::components::{Route, Router, Routes};
|
use leptos_router::components::{Route, Router, Routes, A};
|
||||||
use leptos_router::hooks::use_location;
|
use leptos_router::hooks::use_location;
|
||||||
use leptos_router::path;
|
use leptos_router::path;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -19,14 +19,9 @@ use crate::game::session::{
|
||||||
use crate::game::trictrac::backend::TrictracBackend;
|
use crate::game::trictrac::backend::TrictracBackend;
|
||||||
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
use crate::nav::SiteNav;
|
|
||||||
use crate::portal::{
|
use crate::portal::{
|
||||||
account::AccountPage,
|
account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage,
|
||||||
forgot_password::ForgotPasswordPage,
|
lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage,
|
||||||
game_detail::GameDetailPage,
|
|
||||||
lobby::LobbyPage,
|
|
||||||
profile::ProfilePage,
|
|
||||||
reset_password::ResetPasswordPage,
|
|
||||||
verify_email::VerifyEmailPage,
|
verify_email::VerifyEmailPage,
|
||||||
};
|
};
|
||||||
use trictrac_store::CheckerMove;
|
use trictrac_store::CheckerMove;
|
||||||
|
|
@ -154,11 +149,19 @@ pub fn App() -> impl IntoView {
|
||||||
let auth_email_verified: RwSignal<bool> = RwSignal::new(false);
|
let auth_email_verified: RwSignal<bool> = RwSignal::new(false);
|
||||||
provide_context(auth_username);
|
provide_context(auth_username);
|
||||||
provide_context(auth_email_verified);
|
provide_context(auth_email_verified);
|
||||||
|
// Set to true once get_me resolves (success or failure) so lobby can
|
||||||
|
// decide immediately whether to show the nickname modal.
|
||||||
|
let auth_loaded: RwSignal<bool> = RwSignal::new(false);
|
||||||
|
provide_context(auth_loaded);
|
||||||
|
// Nickname chosen by an anonymous player; used instead of "Anonymous".
|
||||||
|
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
|
||||||
|
provide_context(anon_nickname);
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(me) = api::get_me().await {
|
if let Ok(me) = api::get_me().await {
|
||||||
auth_username.set(Some(me.username));
|
auth_username.set(Some(me.username));
|
||||||
auth_email_verified.set(me.email_verified);
|
auth_email_verified.set(me.email_verified);
|
||||||
}
|
}
|
||||||
|
auth_loaded.set(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
|
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
|
||||||
|
|
@ -242,6 +245,7 @@ pub fn App() -> impl IntoView {
|
||||||
if remote_config.is_none() {
|
if remote_config.is_none() {
|
||||||
let player_name = auth_username
|
let player_name = auth_username
|
||||||
.get_untracked()
|
.get_untracked()
|
||||||
|
.or_else(|| anon_nickname.get_untracked())
|
||||||
.unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string()));
|
.unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string()));
|
||||||
loop {
|
loop {
|
||||||
let restart =
|
let restart =
|
||||||
|
|
@ -287,7 +291,11 @@ pub fn App() -> impl IntoView {
|
||||||
let reconnect_token = session.reconnect_token;
|
let reconnect_token = session.reconnect_token;
|
||||||
let my_name = auth_username
|
let my_name = auth_username
|
||||||
.get_untracked()
|
.get_untracked()
|
||||||
|
.or_else(|| anon_nickname.get_untracked())
|
||||||
.unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string());
|
.unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string());
|
||||||
|
// Announce our name to the host backend so it can broadcast it to
|
||||||
|
// the opponent. Done once immediately after connecting.
|
||||||
|
session.send_action(PlayerAction::SetName(my_name.clone()));
|
||||||
let mut vs = ViewState::default_with_names("", "");
|
let mut vs = ViewState::default_with_names("", "");
|
||||||
let mut result_submitted = false;
|
let mut result_submitted = false;
|
||||||
|
|
||||||
|
|
@ -367,8 +375,7 @@ pub fn App() -> impl IntoView {
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Router>
|
<Router>
|
||||||
<SiteNav />
|
<SiteHamburger />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
||||||
<Route path=path!("/") view=LobbyPage />
|
<Route path=path!("/") view=LobbyPage />
|
||||||
|
|
@ -421,6 +428,105 @@ fn GameOverlay(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persistent hamburger button + left sidebar — visible on every page.
|
||||||
|
#[component]
|
||||||
|
fn SiteHamburger() -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().unwrap_or_else(|| RwSignal::new(None));
|
||||||
|
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
||||||
|
let cmd_tx = use_context::<futures::channel::mpsc::UnboundedSender<NetCommand>>()
|
||||||
|
.expect("cmd_tx not found in context");
|
||||||
|
|
||||||
|
let sidebar_open = RwSignal::new(false);
|
||||||
|
|
||||||
|
let cmd_tx_newgame = cmd_tx.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
// ── Hamburger button (☰ → ✕ animation) ───────────────────────────────
|
||||||
|
<button
|
||||||
|
class="game-hamburger"
|
||||||
|
class:game-hamburger-open=move || sidebar_open.get()
|
||||||
|
on:click=move |_| sidebar_open.update(|v| *v = !*v)
|
||||||
|
aria-label="Menu"
|
||||||
|
>
|
||||||
|
<span class="hb-bar hb-top"></span>
|
||||||
|
<span class="hb-bar hb-mid"></span>
|
||||||
|
<span class="hb-bar hb-bot"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// ── Left sidebar ──────────────────────────────────────────────────────
|
||||||
|
<div class="game-sidebar" class:game-sidebar-open=move || sidebar_open.get()>
|
||||||
|
|
||||||
|
<div class="game-sidebar-header">
|
||||||
|
<span class="game-sidebar-brand">"Trictrac"</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Language switcher
|
||||||
|
<div class="game-sidebar-section">
|
||||||
|
<span class="game-sidebar-label">"Language"</span>
|
||||||
|
<div class="lang-switcher">
|
||||||
|
<button
|
||||||
|
class:lang-active=move || i18n.get_locale() == Locale::en
|
||||||
|
on:click=move |_| i18n.set_locale(Locale::en)
|
||||||
|
>"EN"</button>
|
||||||
|
<button
|
||||||
|
class:lang-active=move || i18n.get_locale() == Locale::fr
|
||||||
|
on:click=move |_| i18n.set_locale(Locale::fr)
|
||||||
|
>"FR"</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
<div class="game-sidebar-section">
|
||||||
|
{move || match auth_username.get() {
|
||||||
|
Some(u) => {
|
||||||
|
let href = format!("/profile/{u}");
|
||||||
|
view! {
|
||||||
|
<A href=href attr:class="game-sidebar-link"
|
||||||
|
on:click=move |_| sidebar_open.set(false)>
|
||||||
|
{u}
|
||||||
|
</A>
|
||||||
|
<button class="game-sidebar-btn" on:click=move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
let _ = api::post_logout().await;
|
||||||
|
auth_username.set(None);
|
||||||
|
});
|
||||||
|
}>{t!(i18n, sign_out)}</button>
|
||||||
|
}.into_any()
|
||||||
|
},
|
||||||
|
None => view! {
|
||||||
|
<A href="/account" attr:class="game-sidebar-link"
|
||||||
|
on:click=move |_| sidebar_open.set(false)>
|
||||||
|
{t!(i18n, sign_in)}
|
||||||
|
</A>
|
||||||
|
}.into_any(),
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// New game — only shown while a game is in progress
|
||||||
|
{move || {
|
||||||
|
if matches!(screen.get(), Screen::Playing(_) | Screen::Connecting) {
|
||||||
|
let tx = cmd_tx_newgame.clone();
|
||||||
|
Some(view! {
|
||||||
|
<div class="game-sidebar-section">
|
||||||
|
<button class="game-sidebar-btn game-sidebar-btn-newgame"
|
||||||
|
on:click=move |_| {
|
||||||
|
tx.unbounded_send(NetCommand::Disconnect).ok();
|
||||||
|
sidebar_open.set(false);
|
||||||
|
}>
|
||||||
|
{t!(i18n, new_game)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ use super::scoring::ScoringPanel;
|
||||||
pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
|
|
||||||
let auth_username =
|
|
||||||
use_context::<RwSignal<Option<String>>>().unwrap_or_else(|| RwSignal::new(None));
|
|
||||||
let vs = state.view_state.clone();
|
let vs = state.view_state.clone();
|
||||||
let player_id = state.player_id;
|
let player_id = state.player_id;
|
||||||
let is_my_turn = vs.active_mp_player == Some(player_id);
|
let is_my_turn = vs.active_mp_player == Some(player_id);
|
||||||
|
|
@ -100,7 +98,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
|
|
||||||
// ── Button senders ─────────────────────────────────────────────────────────
|
// ── Button senders ─────────────────────────────────────────────────────────
|
||||||
let cmd_tx_go = cmd_tx.clone();
|
let cmd_tx_go = cmd_tx.clone();
|
||||||
let cmd_tx_quit = cmd_tx.clone();
|
|
||||||
let cmd_tx_end_quit = cmd_tx.clone();
|
let cmd_tx_end_quit = cmd_tx.clone();
|
||||||
let cmd_tx_end_replay = cmd_tx.clone();
|
let cmd_tx_end_replay = cmd_tx.clone();
|
||||||
// Only show the fallback Go button when there is no ScoringPanel showing it.
|
// Only show the fallback Go button when there is no ScoringPanel showing it.
|
||||||
|
|
@ -246,61 +243,52 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
let opp_name_end = opp_score.name.clone();
|
let opp_name_end = opp_score.name.clone();
|
||||||
let opp_holes_end = opp_score.holes;
|
let opp_holes_end = opp_score.holes;
|
||||||
|
|
||||||
let share_open = RwSignal::new(false);
|
let sidebar_copied = RwSignal::new(false);
|
||||||
let share_url = if !is_bot_game {
|
let share_url = if !is_bot_game { room_url(&room_id) } else { String::new() };
|
||||||
room_url(&room_id)
|
let share_svg = if !is_bot_game { qr_svg(&share_url) } else { String::new() };
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
let share_svg = if !is_bot_game {
|
|
||||||
qr_svg(&share_url)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
// ── Game container ────────────────────────────────────────────────────
|
||||||
<div class="game-container">
|
<div class="game-container">
|
||||||
// ── Top bar ──────────────────────────────────────────────────────
|
// ── Share popover (while waiting for opponent) ───────────────────
|
||||||
<div class="top-bar">
|
{(!is_bot_game && stage == SerStage::PreGame).then(|| {
|
||||||
<span>{move || if is_bot_game {
|
let url_label = share_url.clone();
|
||||||
t_string!(i18n, vs_bot_label).to_owned()
|
let url_copy = share_url.clone();
|
||||||
} else {
|
let svg = share_svg.clone();
|
||||||
t_string!(i18n, room_label, id = room_id.as_str())
|
view! {
|
||||||
}}</span>
|
|
||||||
|
|
||||||
{move || (!is_bot_game).then(|| view! {
|
|
||||||
<button
|
|
||||||
class="quit-link"
|
|
||||||
style="border:none;background:transparent;cursor:pointer"
|
|
||||||
on:click=move |_| share_open.update(|v| *v = !*v)
|
|
||||||
>
|
|
||||||
{move || if share_open.get() { "✕ " } else { "" }}
|
|
||||||
{t!(i18n, share_btn)}
|
|
||||||
</button>
|
|
||||||
})}
|
|
||||||
|
|
||||||
{move || auth_username.get().map(|u| view! {
|
|
||||||
<span class="playing-as">"▶ " <strong>{u}</strong></span>
|
|
||||||
})}
|
|
||||||
|
|
||||||
<a class="quit-link" href="#" on:click=move |e| {
|
|
||||||
e.prevent_default();
|
|
||||||
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();
|
|
||||||
}>{t!(i18n, quit)}</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// ── Share popover ─────────────────────────────────────────────────
|
|
||||||
{move || share_open.get().then(|| view! {
|
|
||||||
<div class="share-popover">
|
<div class="share-popover">
|
||||||
<p class="share-popover-label">{t!(i18n, share_link)}</p>
|
<p class="share-popover-label">{t!(i18n, share_link)}</p>
|
||||||
<div class="share-url-row">
|
<div class="share-url-row">
|
||||||
<span class="share-url-text">{ share_url.clone() }</span>
|
<span class="share-url-text">{url_label}</span>
|
||||||
|
<button class="share-copy-btn" on:click=move |_| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let u = url_copy.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Some(cb) = web_sys::window()
|
||||||
|
.map(|w| w.navigator().clipboard())
|
||||||
|
{
|
||||||
|
let _ = wasm_bindgen_futures::JsFuture::from(
|
||||||
|
cb.write_text(&u),
|
||||||
|
).await;
|
||||||
|
sidebar_copied.set(true);
|
||||||
|
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||||
|
sidebar_copied.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{move || if sidebar_copied.get() {
|
||||||
|
t_string!(i18n, link_copied)
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, copy_link)
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="share-popover-label" style="margin-top:0.75rem">
|
<p class="share-popover-label">{t!(i18n, scan_qr)}</p>
|
||||||
{t!(i18n, scan_qr)}
|
<div class="qr-container" inner_html=svg />
|
||||||
</p>
|
|
||||||
<div class="qr-container" inner_html=share_svg.clone() />
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
})}
|
})}
|
||||||
|
|
||||||
// ── Merged scoreboard + scoring panels (above board) ─────────────
|
// ── Merged scoreboard + scoring panels (above board) ─────────────
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,16 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) {
|
fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) {
|
||||||
|
// SetName is always accepted regardless of game stage or whose turn it is.
|
||||||
|
if let PlayerAction::SetName(name) = action {
|
||||||
|
let store_id = if mp_player == 0 { HOST_PLAYER_ID } else { GUEST_PLAYER_ID };
|
||||||
|
if let Some(p) = self.game.players.get_mut(&store_id) {
|
||||||
|
p.name = name;
|
||||||
|
}
|
||||||
|
self.broadcast_state();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// During the first-player ceremony only PreGameRoll actions are accepted.
|
// During the first-player ceremony only PreGameRoll actions are accepted.
|
||||||
if self.ceremony_started {
|
if self.ceremony_started {
|
||||||
if matches!(action, PlayerAction::PreGameRoll) {
|
if matches!(action, PlayerAction::PreGameRoll) {
|
||||||
|
|
@ -262,6 +272,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerAction::PreGameRoll => {} // ignored outside ceremony
|
PlayerAction::PreGameRoll => {} // ignored outside ceremony
|
||||||
|
PlayerAction::SetName(_) => {} // handled at the top of inform_rpc
|
||||||
}
|
}
|
||||||
|
|
||||||
self.broadcast_state();
|
self.broadcast_state();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ pub enum PlayerAction {
|
||||||
Mark,
|
Mark,
|
||||||
/// Roll a single die during the pre-game ceremony to decide who goes first.
|
/// Roll a single die during the pre-game ceremony to decide who goes first.
|
||||||
PreGameRoll,
|
PreGameRoll,
|
||||||
|
/// Declare the player's display name; sent once immediately after connecting.
|
||||||
|
SetName(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Incremental state update broadcast to all clients ────────────────────────
|
// ── Incremental state update broadcast to all clients ────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ leptos_i18n::load_locales!();
|
||||||
mod api;
|
mod api;
|
||||||
mod app;
|
mod app;
|
||||||
mod game;
|
mod game;
|
||||||
mod nav;
|
|
||||||
mod portal;
|
mod portal;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
use futures::channel::mpsc::UnboundedSender;
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::components::A;
|
||||||
use leptos_router::hooks::use_query_map;
|
use leptos_router::hooks::use_query_map;
|
||||||
|
|
||||||
use crate::app::{NetCommand, Screen};
|
use crate::app::{NetCommand, Screen};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
// ── Room code generation ──────────────────────────────────────────────────────
|
// ── Room/nickname generation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fn generate_room_code() -> String {
|
fn generate_room_code() -> String {
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
|
@ -16,6 +17,23 @@ fn generate_room_code() -> String {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generate_nickname() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
const ADJ: &[&str] = &[
|
||||||
|
"swift", "brave", "noble", "fierce", "clever", "bold", "cunning",
|
||||||
|
"agile", "sharp", "golden", "iron", "silver", "quick", "daring", "wild",
|
||||||
|
];
|
||||||
|
const NOUN: &[&str] = &[
|
||||||
|
"fox", "hawk", "wolf", "lion", "bear", "rook", "knight",
|
||||||
|
"duke", "earl", "lance", "blade", "crown", "dame", "ace", "star",
|
||||||
|
];
|
||||||
|
let adj = ADJ[rng.random_range(0..ADJ.len())];
|
||||||
|
let noun = NOUN[rng.random_range(0..NOUN.len())];
|
||||||
|
let num: u8 = rng.random_range(10..=99);
|
||||||
|
format!("{adj}-{noun}-{num}")
|
||||||
|
}
|
||||||
|
|
||||||
// ── QR code SVG rendering ─────────────────────────────────────────────────────
|
// ── QR code SVG rendering ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(crate) fn qr_svg(text: &str) -> String {
|
pub(crate) fn qr_svg(text: &str) -> String {
|
||||||
|
|
@ -62,7 +80,14 @@ pub(crate) fn room_url(code: &str) -> String {
|
||||||
format!("http://localhost:9091/?room={}", code)
|
format!("http://localhost:9091/?room={}", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lobby component ───────────────────────────────────────────────────────────
|
// ── Lobby state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Action to execute once the anonymous player has chosen their nickname.
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum PendingLobbyAction {
|
||||||
|
Create { code: String },
|
||||||
|
Join { code: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum LobbyView {
|
enum LobbyView {
|
||||||
|
|
@ -70,24 +95,38 @@ enum LobbyView {
|
||||||
Waiting { code: String },
|
Waiting { code: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LobbyPage ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LobbyPage() -> impl IntoView {
|
pub fn LobbyPage() -> impl IntoView {
|
||||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
let screen = use_context::<RwSignal<Screen>>().expect("Screen context");
|
||||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
let cmd_tx = use_context::<UnboundedSender<NetCommand>>().expect("NetCommand sender");
|
||||||
.expect("UnboundedSender<NetCommand> not found in context");
|
let auth_username = use_context::<RwSignal<Option<String>>>().expect("auth_username context");
|
||||||
|
let auth_loaded = use_context::<RwSignal<bool>>().expect("auth_loaded context");
|
||||||
|
let anon_nickname = use_context::<RwSignal<Option<String>>>().expect("anon_nickname context");
|
||||||
let query = use_query_map();
|
let query = use_query_map();
|
||||||
|
|
||||||
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
|
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
|
||||||
|
// Non-None while the nickname-chooser modal is open.
|
||||||
|
let pending_action: RwSignal<Option<PendingLobbyAction>> = RwSignal::new(None);
|
||||||
|
|
||||||
// Auto-join when the URL contains ?room=CODE
|
// ── Auto-join when URL has ?room=CODE ──────────────────────────────────
|
||||||
let cmd_tx_query = cmd_tx.clone();
|
// Wait for auth to resolve so we join directly when already logged in,
|
||||||
|
// or show the nickname modal when anonymous.
|
||||||
|
let join_processed = StoredValue::new(false);
|
||||||
|
let cmd_tx_q = cmd_tx.clone();
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if let Some(code) = query.read().get("room") {
|
if join_processed.get_value() || !auth_loaded.get() {
|
||||||
if !code.is_empty() {
|
return;
|
||||||
cmd_tx_query
|
|
||||||
.unbounded_send(NetCommand::JoinRoom { room: code })
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
let Some(code) = query.read().get("room").filter(|s| !s.is_empty()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
join_processed.set_value(true);
|
||||||
|
if auth_username.get_untracked().is_some() {
|
||||||
|
cmd_tx_q.unbounded_send(NetCommand::JoinRoom { room: code }).ok();
|
||||||
|
} else {
|
||||||
|
pending_action.set(Some(PendingLobbyAction::Join { code }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -96,7 +135,8 @@ pub fn LobbyPage() -> impl IntoView {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let cmd_tx_idle = cmd_tx;
|
let cmd_idle = cmd_tx.clone();
|
||||||
|
let cmd_modal = cmd_tx;
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
|
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
|
||||||
|
|
@ -114,54 +154,73 @@ pub fn LobbyPage() -> impl IntoView {
|
||||||
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
|
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
|
||||||
|
|
||||||
{move || match view_state.get() {
|
{move || match view_state.get() {
|
||||||
LobbyView::Idle => {
|
LobbyView::Idle => view! {
|
||||||
// Create fresh closures each render so they are FnMut-compatible
|
<IdleCard
|
||||||
let cmd_tx_create = cmd_tx_idle.clone();
|
cmd_tx=cmd_idle.clone()
|
||||||
let cmd_tx_bot = cmd_tx_idle.clone();
|
auth_username=auth_username
|
||||||
let on_create = move |_: leptos::ev::MouseEvent| {
|
view_state=view_state
|
||||||
let code = generate_room_code();
|
pending_action=pending_action
|
||||||
cmd_tx_create
|
/>
|
||||||
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
|
}.into_any(),
|
||||||
.ok();
|
|
||||||
view_state.set(LobbyView::Waiting { code });
|
|
||||||
};
|
|
||||||
view! {
|
|
||||||
<IdleCard on_create=on_create cmd_tx_bot=cmd_tx_bot />
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
LobbyView::Waiting { code } => view! {
|
LobbyView::Waiting { code } => view! {
|
||||||
<WaitingCard code=code />
|
<WaitingCard code=code />
|
||||||
}.into_any(),
|
}.into_any(),
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// Fixed-position modal overlay; rendered here but escapes layout.
|
||||||
|
{move || pending_action.get().map(|action| view! {
|
||||||
|
<NicknameModal
|
||||||
|
pending=action
|
||||||
|
cmd_tx=cmd_modal.clone()
|
||||||
|
view_state=view_state
|
||||||
|
pending_action=pending_action
|
||||||
|
anon_nickname=anon_nickname
|
||||||
|
/>
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Idle card: Create + vs Bot + hidden join-by-code ─────────────────────────
|
// ── IdleCard: Create + vs Bot + hidden join-by-code ──────────────────────────
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn IdleCard(
|
fn IdleCard(
|
||||||
on_create: impl Fn(leptos::ev::MouseEvent) + 'static,
|
cmd_tx: UnboundedSender<NetCommand>,
|
||||||
cmd_tx_bot: UnboundedSender<NetCommand>,
|
auth_username: RwSignal<Option<String>>,
|
||||||
|
view_state: RwSignal<LobbyView>,
|
||||||
|
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
let join_open = RwSignal::new(false);
|
let join_open = RwSignal::new(false);
|
||||||
let join_code = RwSignal::new(String::new());
|
let join_code = RwSignal::new(String::new());
|
||||||
let cmd_tx_join = cmd_tx_bot.clone();
|
|
||||||
|
let cmd_bot = cmd_tx.clone();
|
||||||
|
let cmd_create = cmd_tx.clone();
|
||||||
|
let cmd_join = cmd_tx;
|
||||||
|
|
||||||
|
let on_create = move |_: leptos::ev::MouseEvent| {
|
||||||
|
let code = generate_room_code();
|
||||||
|
if auth_username.get_untracked().is_some() {
|
||||||
|
cmd_create.unbounded_send(NetCommand::CreateRoom { room: code.clone() }).ok();
|
||||||
|
view_state.set(LobbyView::Waiting { code });
|
||||||
|
} else {
|
||||||
|
pending_action.set(Some(PendingLobbyAction::Create { code }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="login-actions">
|
<div class="login-actions">
|
||||||
<button class="login-btn login-btn-primary" on:click=on_create>
|
|
||||||
{t!(i18n, create_room)}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="login-btn login-btn-bot"
|
class="login-btn login-btn-bot"
|
||||||
on:click=move |_| { cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
|
on:click=move |_| { cmd_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
|
||||||
>
|
>
|
||||||
{t!(i18n, play_vs_bot)}
|
{t!(i18n, play_vs_bot)}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="login-btn login-btn-primary" on:click=on_create>
|
||||||
|
{t!(i18n, create_room)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Hidden "join by code" fallback
|
// Hidden "join by code" fallback
|
||||||
|
|
@ -175,8 +234,7 @@ fn IdleCard(
|
||||||
{t!(i18n, join_code_label)}
|
{t!(i18n, join_code_label)}
|
||||||
</button>
|
</button>
|
||||||
{move || {
|
{move || {
|
||||||
// Clone the sender on each reactive run to keep the outer closure FnMut
|
let cmd = cmd_join.clone();
|
||||||
let cmd = cmd_tx_join.clone();
|
|
||||||
join_open.get().then(|| view! {
|
join_open.get().then(|| view! {
|
||||||
<div style="margin-top:0.75rem;display:flex;gap:0.5rem">
|
<div style="margin-top:0.75rem;display:flex;gap:0.5rem">
|
||||||
<input
|
<input
|
||||||
|
|
@ -192,8 +250,12 @@ fn IdleCard(
|
||||||
style="margin:0;padding:0 1rem"
|
style="margin:0;padding:0 1rem"
|
||||||
disabled=move || join_code.get().is_empty()
|
disabled=move || join_code.get().is_empty()
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
cmd.unbounded_send(NetCommand::JoinRoom { room: join_code.get() })
|
let code = join_code.get();
|
||||||
.ok();
|
if auth_username.get_untracked().is_some() {
|
||||||
|
cmd.unbounded_send(NetCommand::JoinRoom { room: code }).ok();
|
||||||
|
} else {
|
||||||
|
pending_action.set(Some(PendingLobbyAction::Join { code }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t!(i18n, join_room)}
|
{t!(i18n, join_room)}
|
||||||
|
|
@ -205,7 +267,68 @@ fn IdleCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Waiting card: URL + copy + QR ────────────────────────────────────────────
|
// ── NicknameModal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn NicknameModal(
|
||||||
|
pending: PendingLobbyAction,
|
||||||
|
cmd_tx: UnboundedSender<NetCommand>,
|
||||||
|
view_state: RwSignal<LobbyView>,
|
||||||
|
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
||||||
|
anon_nickname: RwSignal<Option<String>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
// Pre-fill with a random nickname; the player can edit it.
|
||||||
|
let nick = RwSignal::new(generate_nickname());
|
||||||
|
|
||||||
|
let on_play = move |_: leptos::ev::MouseEvent| {
|
||||||
|
let chosen = nick.get().trim().to_string();
|
||||||
|
let chosen = if chosen.is_empty() { generate_nickname() } else { chosen };
|
||||||
|
anon_nickname.set(Some(chosen));
|
||||||
|
match &pending {
|
||||||
|
PendingLobbyAction::Create { code } => {
|
||||||
|
cmd_tx.unbounded_send(NetCommand::CreateRoom { room: code.clone() }).ok();
|
||||||
|
view_state.set(LobbyView::Waiting { code: code.clone() });
|
||||||
|
}
|
||||||
|
PendingLobbyAction::Join { code } => {
|
||||||
|
cmd_tx.unbounded_send(NetCommand::JoinRoom { room: code.clone() }).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending_action.set(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="nickname-backdrop">
|
||||||
|
<div class="nickname-modal">
|
||||||
|
<h2 class="nickname-modal-title">{t!(i18n, nickname_modal_title)}</h2>
|
||||||
|
<p class="nickname-modal-hint">{t!(i18n, nickname_modal_hint)}</p>
|
||||||
|
<input
|
||||||
|
class="login-input"
|
||||||
|
type="text"
|
||||||
|
style="margin:0"
|
||||||
|
prop:value=move || nick.get()
|
||||||
|
on:input=move |ev| nick.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="login-btn login-btn-primary"
|
||||||
|
disabled=move || nick.get().trim().is_empty()
|
||||||
|
on:click=on_play
|
||||||
|
>
|
||||||
|
{t!(i18n, nickname_modal_play)}
|
||||||
|
</button>
|
||||||
|
<p class="nickname-modal-alt">
|
||||||
|
{t!(i18n, nickname_modal_or)}
|
||||||
|
" "
|
||||||
|
<A href="/account">{t!(i18n, nickname_modal_sign_in)}</A>
|
||||||
|
" · "
|
||||||
|
<A href="/account">{t!(i18n, nickname_modal_register)}</A>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WaitingCard: URL + copy + QR ─────────────────────────────────────────────
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn WaitingCard(code: String) -> impl IntoView {
|
fn WaitingCard(code: String) -> impl IntoView {
|
||||||
|
|
@ -221,12 +344,9 @@ fn WaitingCard(code: String) -> impl IntoView {
|
||||||
{
|
{
|
||||||
let url = url.clone();
|
let url = url.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
if let Some(clipboard) = web_sys::window()
|
if let Some(clipboard) = web_sys::window().map(|w| w.navigator().clipboard()) {
|
||||||
.map(|w| w.navigator().clipboard())
|
let _ =
|
||||||
{
|
wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&url)).await;
|
||||||
let _ = wasm_bindgen_futures::JsFuture::from(
|
|
||||||
clipboard.write_text(&url)
|
|
||||||
).await;
|
|
||||||
copied.set(true);
|
copied.set(true);
|
||||||
gloo_timers::future::TimeoutFuture::new(2000).await;
|
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||||
copied.set(false);
|
copied.set(false);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue