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/nickname generation ────────────────────────────────────────────────── fn generate_room_code() -> String { use rand::Rng; let mut rng = rand::rng(); const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; (0..6) .map(|_| CHARS[rng.random_range(0..CHARS.len())] as char) .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 { use qrcodegen::{QrCode, QrCodeEcc}; let qr = match QrCode::encode_text(text, QrCodeEcc::Medium) { Ok(q) => q, Err(_) => return String::new(), }; let size = qr.size(); let border = 2; let total = size + 2 * border; let mut svg = format!( "", t = total, ); svg.push_str(""); for y in 0..size { for x in 0..size { if qr.get_module(x, y) { svg.push_str(&format!( "", x + border, y + border, )); } } } svg.push_str(""); svg } // ── Share URL helper ────────────────────────────────────────────────────────── #[cfg(target_arch = "wasm32")] pub(crate) fn room_url(code: &str) -> String { let origin = web_sys::window() .and_then(|w| w.location().origin().ok()) .unwrap_or_default(); format!("{}/?room={}", origin, code) } #[cfg(not(target_arch = "wasm32"))] pub(crate) fn room_url(code: &str) -> String { format!("http://localhost:9091/?room={}", code) } // ── 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 { Idle, Waiting { code: String }, } // ── LobbyPage ───────────────────────────────────────────────────────────────── #[component] pub fn LobbyPage() -> impl IntoView { 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 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 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 })); } }); let error = move || match screen.get() { Screen::Login { error } => error, _ => None, }; let cmd_idle = cmd_tx.clone(); let cmd_modal = cmd_tx; view! { "Trictrac" "Une interprétation numérique" "✦" {move || error().map(|err| view! { {err} })} {move || match view_state.get() { 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! { })} } } // ── IdleCard: Create + vs Bot + hidden join-by-code ────────────────────────── #[component] fn IdleCard( 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_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, play_vs_bot)} {t!(i18n, create_room)} // Hidden "join by code" fallback {move || if join_open.get() { "▲ " } else { "▼ " }} {t!(i18n, join_code_label)} {move || { let cmd = cmd_join.clone(); join_open.get().then(|| view! { {t!(i18n, join_room)} }) }} } } // ── 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! { {t!(i18n, nickname_modal_title)} {t!(i18n, nickname_modal_hint)} {t!(i18n, nickname_modal_play)} {t!(i18n, nickname_modal_or)} " " {t!(i18n, nickname_modal_sign_in)} " · " {t!(i18n, nickname_modal_register)} } } // ── WaitingCard: URL + copy + QR ───────────────────────────────────────────── #[component] fn WaitingCard(code: String) -> impl IntoView { let i18n = use_i18n(); let url = room_url(&code); let svg = qr_svg(&url); let copied = RwSignal::new(false); let on_copy = { let url = url.clone(); move |_| { #[cfg(target_arch = "wasm32")] { 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; copied.set(true); gloo_timers::future::TimeoutFuture::new(2000).await; copied.set(false); } }); } } }; view! { {t!(i18n, waiting_for_opponent)} {t!(i18n, share_link)} { url.clone() } {move || if copied.get() { t_string!(i18n, link_copied) } else { t_string!(i18n, copy_link) }} {t!(i18n, scan_qr)} } }
"Une interprétation numérique"
{err}
{t!(i18n, nickname_modal_hint)}
{t!(i18n, nickname_modal_or)} " " {t!(i18n, nickname_modal_sign_in)} " · " {t!(i18n, nickname_modal_register)}
{t!(i18n, waiting_for_opponent)}
{t!(i18n, share_link)}
{t!(i18n, scan_qr)}