diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css
index f241273..a129c91 100644
--- a/clients/web/assets/style.css
+++ b/clients/web/assets/style.css
@@ -378,7 +378,7 @@ a:hover { text-decoration: underline; }
/* ── Game overlay (full-screen, covers portal during play) ───────── */
.game-overlay {
position: fixed;
- inset: 54px 0 0 0;
+ inset: 0;
background: #8a7050;
background-image:
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);
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;
+}
diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json
index 2d7ca43..4e41d9a 100644
--- a/clients/web/locales/en.json
+++ b/clients/web/locales/en.json
@@ -121,5 +121,12 @@
"scan_qr": "or scan the QR code",
"join_code_label": "Join by 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"
}
diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json
index 3ff78aa..1d2a8d0 100644
--- a/clients/web/locales/fr.json
+++ b/clients/web/locales/fr.json
@@ -121,5 +121,12 @@
"scan_qr": "ou scannez le QR code",
"join_code_label": "Rejoindre par code",
"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"
}
diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs
index 5aa0f80..de8d55f 100644
--- a/clients/web/src/app.rs
+++ b/clients/web/src/app.rs
@@ -3,7 +3,7 @@ use futures::{FutureExt, StreamExt};
use gloo_storage::{LocalStorage, Storage};
use leptos::prelude::*;
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::path;
use serde::{Deserialize, Serialize};
@@ -19,14 +19,9 @@ use crate::game::session::{
use crate::game::trictrac::backend::TrictracBackend;
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
use crate::i18n::*;
-use crate::nav::SiteNav;
use crate::portal::{
- account::AccountPage,
- forgot_password::ForgotPasswordPage,
- game_detail::GameDetailPage,
- lobby::LobbyPage,
- profile::ProfilePage,
- reset_password::ResetPasswordPage,
+ account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage,
+ lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage,
verify_email::VerifyEmailPage,
};
use trictrac_store::CheckerMove;
@@ -154,11 +149,19 @@ pub fn App() -> impl IntoView {
let auth_email_verified: RwSignal = RwSignal::new(false);
provide_context(auth_username);
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 = RwSignal::new(false);
+ provide_context(auth_loaded);
+ // Nickname chosen by an anonymous player; used instead of "Anonymous".
+ let anon_nickname: RwSignal> = RwSignal::new(None);
+ provide_context(anon_nickname);
spawn_local(async move {
if let Ok(me) = api::get_me().await {
auth_username.set(Some(me.username));
auth_email_verified.set(me.email_verified);
}
+ auth_loaded.set(true);
});
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::();
@@ -242,6 +245,7 @@ pub fn App() -> impl IntoView {
if remote_config.is_none() {
let player_name = auth_username
.get_untracked()
+ .or_else(|| anon_nickname.get_untracked())
.unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string()));
loop {
let restart =
@@ -287,7 +291,11 @@ pub fn App() -> impl IntoView {
let reconnect_token = session.reconnect_token;
let my_name = auth_username
.get_untracked()
+ .or_else(|| anon_nickname.get_untracked())
.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 result_submitted = false;
@@ -367,8 +375,7 @@ pub fn App() -> impl IntoView {
view! {
-
-
+
"Page not found."
}>
@@ -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::>>().unwrap_or_else(|| RwSignal::new(None));
+ let screen = use_context::>().expect("Screen context not found");
+ let cmd_tx = use_context::>()
+ .expect("cmd_tx not found in context");
+
+ let sidebar_open = RwSignal::new(false);
+
+ let cmd_tx_newgame = cmd_tx.clone();
+
+ view! {
+ // ── Hamburger button (☰ → ✕ animation) ───────────────────────────────
+
+
+
+
+
+
+ // ── Left sidebar ──────────────────────────────────────────────────────
+
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs
index 2a1d761..d55284f 100644
--- a/clients/web/src/game/components/game_screen.rs
+++ b/clients/web/src/game/components/game_screen.rs
@@ -19,8 +19,6 @@ use super::scoring::ScoringPanel;
pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n();
- let auth_username =
- use_context::>>().unwrap_or_else(|| RwSignal::new(None));
let vs = state.view_state.clone();
let player_id = state.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 ─────────────────────────────────────────────────────────
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_replay = cmd_tx.clone();
// 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_holes_end = opp_score.holes;
- let share_open = RwSignal::new(false);
- let share_url = if !is_bot_game {
- room_url(&room_id)
- } else {
- String::new()
- };
- let share_svg = if !is_bot_game {
- qr_svg(&share_url)
- } else {
- String::new()
- };
+ let sidebar_copied = RwSignal::new(false);
+ let share_url = if !is_bot_game { room_url(&room_id) } else { String::new() };
+ let share_svg = if !is_bot_game { qr_svg(&share_url) } else { String::new() };
view! {
+ // ── Game container ────────────────────────────────────────────────────
- // ── Top bar ──────────────────────────────────────────────────────
-
-
{move || if is_bot_game {
- t_string!(i18n, vs_bot_label).to_owned()
- } else {
- t_string!(i18n, room_label, id = room_id.as_str())
- }}
-
- {move || (!is_bot_game).then(|| view! {
-
- {move || if share_open.get() { "✕ " } else { "" }}
- {t!(i18n, share_btn)}
-
- })}
-
- {move || auth_username.get().map(|u| view! {
-
"▶ " {u}
- })}
-
-
{t!(i18n, quit)}
-
-
- // ── Share popover ─────────────────────────────────────────────────
- {move || share_open.get().then(|| view! {
-
-
{t!(i18n, share_link)}
-
-
{ share_url.clone() }
+ // ── Share popover (while waiting for opponent) ───────────────────
+ {(!is_bot_game && stage == SerStage::PreGame).then(|| {
+ let url_label = share_url.clone();
+ let url_copy = share_url.clone();
+ let svg = share_svg.clone();
+ view! {
+
+
{t!(i18n, share_link)}
+
+ {url_label}
+
+ {move || if sidebar_copied.get() {
+ t_string!(i18n, link_copied)
+ } else {
+ t_string!(i18n, copy_link)
+ }}
+
+
+
{t!(i18n, scan_qr)}
+
-
- {t!(i18n, scan_qr)}
-
-
-
+ }
})}
// ── Merged scoreboard + scoring panels (above board) ─────────────
diff --git a/clients/web/src/game/trictrac/backend.rs b/clients/web/src/game/trictrac/backend.rs
index 3581057..67a2970 100644
--- a/clients/web/src/game/trictrac/backend.rs
+++ b/clients/web/src/game/trictrac/backend.rs
@@ -202,6 +202,16 @@ impl BackEndArchitecture
for TrictracBackend
}
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.
if self.ceremony_started {
if matches!(action, PlayerAction::PreGameRoll) {
@@ -262,6 +272,7 @@ impl BackEndArchitecture for TrictracBackend
}
}
PlayerAction::PreGameRoll => {} // ignored outside ceremony
+ PlayerAction::SetName(_) => {} // handled at the top of inform_rpc
}
self.broadcast_state();
diff --git a/clients/web/src/game/trictrac/types.rs b/clients/web/src/game/trictrac/types.rs
index 3c0dfe2..45ac48c 100644
--- a/clients/web/src/game/trictrac/types.rs
+++ b/clients/web/src/game/trictrac/types.rs
@@ -16,6 +16,8 @@ pub enum PlayerAction {
Mark,
/// Roll a single die during the pre-game ceremony to decide who goes first.
PreGameRoll,
+ /// Declare the player's display name; sent once immediately after connecting.
+ SetName(String),
}
// ── Incremental state update broadcast to all clients ────────────────────────
diff --git a/clients/web/src/main.rs b/clients/web/src/main.rs
index 5d5fbb4..f4bc597 100644
--- a/clients/web/src/main.rs
+++ b/clients/web/src/main.rs
@@ -3,7 +3,6 @@ leptos_i18n::load_locales!();
mod api;
mod app;
mod game;
-mod nav;
mod portal;
use app::App;
diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs
index 7a1d71e..9902039 100644
--- a/clients/web/src/portal/lobby.rs
+++ b/clients/web/src/portal/lobby.rs
@@ -1,11 +1,12 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
+use leptos_router::components::A;
use leptos_router::hooks::use_query_map;
use crate::app::{NetCommand, Screen};
use crate::i18n::*;
-// ── Room code generation ──────────────────────────────────────────────────────
+// ── Room/nickname generation ──────────────────────────────────────────────────
fn generate_room_code() -> String {
use rand::Rng;
@@ -16,6 +17,23 @@ fn generate_room_code() -> String {
.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 ─────────────────────────────────────────────────────
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)
}
-// ── 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)]
enum LobbyView {
@@ -70,24 +95,38 @@ enum LobbyView {
Waiting { code: String },
}
+// ── LobbyPage ─────────────────────────────────────────────────────────────────
+
#[component]
pub fn LobbyPage() -> impl IntoView {
- let screen = use_context::>().expect("Screen context not found");
- let cmd_tx = use_context::>()
- .expect("UnboundedSender not found in context");
+ let screen = use_context::>().expect("Screen context");
+ let cmd_tx = use_context::>().expect("NetCommand sender");
+ let auth_username = use_context::>>().expect("auth_username context");
+ let auth_loaded = use_context::>().expect("auth_loaded context");
+ let anon_nickname = use_context::>>().expect("anon_nickname context");
let query = use_query_map();
let view_state: RwSignal = RwSignal::new(LobbyView::Idle);
+ // Non-None while the nickname-chooser modal is open.
+ let pending_action: RwSignal> = RwSignal::new(None);
- // Auto-join when the URL contains ?room=CODE
- let cmd_tx_query = cmd_tx.clone();
+ // ── Auto-join when URL has ?room=CODE ──────────────────────────────────
+ // 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 |_| {
- if let Some(code) = query.read().get("room") {
- if !code.is_empty() {
- cmd_tx_query
- .unbounded_send(NetCommand::JoinRoom { room: code })
- .ok();
- }
+ if join_processed.get_value() || !auth_loaded.get() {
+ return;
+ }
+ 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,
};
- let cmd_tx_idle = cmd_tx;
+ let cmd_idle = cmd_tx.clone();
+ let cmd_modal = cmd_tx;
view! {
@@ -114,54 +154,73 @@ pub fn LobbyPage() -> impl IntoView {
{move || error().map(|err| view! {
{err}
})}
{move || match view_state.get() {
- LobbyView::Idle => {
- // Create fresh closures each render so they are FnMut-compatible
- let cmd_tx_create = cmd_tx_idle.clone();
- let cmd_tx_bot = cmd_tx_idle.clone();
- let on_create = move |_: leptos::ev::MouseEvent| {
- let code = generate_room_code();
- cmd_tx_create
- .unbounded_send(NetCommand::CreateRoom { room: code.clone() })
- .ok();
- view_state.set(LobbyView::Waiting { code });
- };
- view! {
-
- }.into_any()
- }
+ LobbyView::Idle => view! {
+
+ }.into_any(),
LobbyView::Waiting { code } => view! {
}.into_any(),
}}
+
+ // Fixed-position modal overlay; rendered here but escapes layout.
+ {move || pending_action.get().map(|action| view! {
+
+ })}
}
}
-// ── Idle card: Create + vs Bot + hidden join-by-code ─────────────────────────
+// ── IdleCard: Create + vs Bot + hidden join-by-code ──────────────────────────
#[component]
fn IdleCard(
- on_create: impl Fn(leptos::ev::MouseEvent) + 'static,
- cmd_tx_bot: UnboundedSender,
+ cmd_tx: UnboundedSender,
+ auth_username: RwSignal>,
+ view_state: RwSignal,
+ pending_action: RwSignal>,
) -> impl IntoView {
let i18n = use_i18n();
let join_open = RwSignal::new(false);
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! {
-
- {t!(i18n, create_room)}
-
{t!(i18n, play_vs_bot)}
+
+ {t!(i18n, create_room)}
+
// Hidden "join by code" fallback
@@ -175,8 +234,7 @@ fn IdleCard(
{t!(i18n, join_code_label)}
{move || {
- // Clone the sender on each reactive run to keep the outer closure FnMut
- let cmd = cmd_tx_join.clone();
+ let cmd = cmd_join.clone();
join_open.get().then(|| view! {
{t!(i18n, join_room)}
@@ -205,7 +267,68 @@ fn IdleCard(
}
}
-// ── Waiting card: URL + copy + QR ────────────────────────────────────────────
+// ── NicknameModal ─────────────────────────────────────────────────────────────
+
+#[component]
+fn NicknameModal(
+ pending: PendingLobbyAction,
+ cmd_tx: UnboundedSender
,
+ view_state: RwSignal,
+ pending_action: RwSignal>,
+ anon_nickname: RwSignal >,
+) -> 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! {
+
+ }
+}
+
+// ── WaitingCard: URL + copy + QR ─────────────────────────────────────────────
#[component]
fn WaitingCard(code: String) -> impl IntoView {
@@ -221,12 +344,9 @@ fn WaitingCard(code: String) -> impl IntoView {
{
let url = url.clone();
wasm_bindgen_futures::spawn_local(async move {
- if let Some(clipboard) = web_sys::window()
- .map(|w| w.navigator().clipboard())
- {
- let _ = wasm_bindgen_futures::JsFuture::from(
- clipboard.write_text(&url)
- ).await;
+ if let Some(clipboard) = web_sys::window().map(|w| w.navigator().clipboard()) {
+ let _ =
+ wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&url)).await;
copied.set(true);
gloo_timers::future::TimeoutFuture::new(2000).await;
copied.set(false);