diff --git a/Cargo.lock b/Cargo.lock index 8ce19af..e557059 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6347,6 +6347,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + [[package]] name = "quick-error" version = "2.0.1" @@ -8714,6 +8720,7 @@ dependencies = [ "leptos", "leptos_i18n", "leptos_router", + "qrcodegen", "rand 0.9.3", "serde", "serde_json", diff --git a/clients/web/Cargo.toml b/clients/web/Cargo.toml index 04857a6..71af23b 100644 --- a/clients/web/Cargo.toml +++ b/clients/web/Cargo.toml @@ -18,6 +18,7 @@ serde_json = "1" futures = "0.3" rand = "0.9" gloo-storage = "0.3" +qrcodegen = "1.8" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2" @@ -38,4 +39,7 @@ web-sys = { version = "0.3", features = [ "OscillatorType", "BaseAudioContext", "HtmlAudioElement", + "Clipboard", + "Navigator", + "Location", ] } diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index b2d89a4..3de4a9f 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -287,10 +287,74 @@ a:hover { text-decoration: underline; } .portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; } .portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; } +/* ── Share URL row (lobby waiting card + game top bar) ──────────── */ +.share-url-row { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(0,0,0,0.18); + border: 1px solid rgba(200,164,72,0.25); + border-radius: 5px; + padding: 0.4rem 0.6rem; +} +.share-url-text { + flex: 1; + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(242,232,208,0.75); + word-break: break-all; + user-select: all; +} +.share-copy-btn { + flex-shrink: 0; + font-family: var(--font-ui); + font-size: 0.72rem; + padding: 0.2rem 0.6rem; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 3px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} +.share-copy-btn:hover { background: rgba(200,164,72,0.22); } + +/* ── QR code container ───────────────────────────────────────────── */ +.qr-container { + width: 160px; + height: 160px; + margin: 0 auto; + border-radius: 4px; + overflow: hidden; +} +.qr-container svg { width: 100%; height: 100%; display: block; } + +/* ── Share popover (in-game top bar) ─────────────────────────────── */ +.share-popover { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(200,164,72,0.2); + border-radius: 6px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.5rem; +} +.share-popover .qr-container { width: 120px; height: 120px; } +.share-popover-label { + font-size: 0.75rem; + color: rgba(242,232,208,0.6); + text-align: center; + margin: 0; +} + /* ── Game overlay (full-screen, covers portal during play) ───────── */ .game-overlay { position: fixed; - inset: 0; + inset: 54px 0 0 0; background: #8a7050; background-image: radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 5d5005d..8ff3548 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -58,6 +58,7 @@ "hint_move": "Click a highlighted field to move a checker", "hint_hold_or_go": "Hold to keep points — Go to reset the setting", "hint_continue": "Click Continue when ready", + "anonymous_name": "Anonymous", "login_failed": "Invalid username or password.", "sign_in": "Sign in", "sign_out": "Sign out", @@ -93,5 +94,12 @@ "anonymous_player": "anonymous", "started_label": "Started", "ended_label": "Ended", - "room_detail_title": "Room" + "room_detail_title": "Room", + "share_link": "Share this link to invite an opponent", + "copy_link": "Copy link", + "link_copied": "Copied!", + "scan_qr": "or scan the QR code", + "join_code_label": "Join by code", + "join_code_placeholder": "Room code", + "share_btn": "Share" } diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 8cef7df..2346395 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -58,6 +58,7 @@ "hint_move": "Cliquez un champ surligné pour déplacer", "hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir", "hint_continue": "Cliquez Continuer quand vous êtes prêt", + "anonymous_name": "Anonyme", "login_failed": "Identifiant ou mot de passe incorrect.", "sign_in": "Se connecter", "sign_out": "Se déconnecter", @@ -93,5 +94,12 @@ "anonymous_player": "anonyme", "started_label": "Début", "ended_label": "Fin", - "room_detail_title": "Salle" + "room_detail_title": "Salle", + "share_link": "Partagez ce lien pour inviter un adversaire", + "copy_link": "Copier le lien", + "link_copied": "Copié !", + "scan_qr": "ou scannez le QR code", + "join_code_label": "Rejoindre par code", + "join_code_placeholder": "Code de salle", + "share_btn": "Partager" } diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 8d604b6..b9ce4aa 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -4,6 +4,7 @@ use gloo_storage::{LocalStorage, Storage}; use leptos::prelude::*; use leptos::task::spawn_local; use leptos_router::components::{Route, Router, Routes}; +use leptos_router::hooks::use_location; use leptos_router::path; use serde::{Deserialize, Serialize}; @@ -11,15 +12,15 @@ use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, Ses use backbone_lib::traits::ViewStateUpdate; use crate::api; +use crate::i18n::*; use crate::game::components::{ConnectingScreen, GameScreen}; use crate::game::session::{ - compute_last_moves, push_or_show, run_local_bot_game, + compute_last_moves, patch_player_name, push_or_show, run_local_bot_game, }; use crate::game::trictrac::backend::TrictracBackend; use crate::game::trictrac::types::{ GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState, }; -use crate::i18n::I18nContextProvider; use crate::nav::SiteNav; use crate::portal::{account::AccountPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage}; use trictrac_store::CheckerMove; @@ -128,6 +129,7 @@ async fn submit_game_result(room_code: String, game_state: ViewState) { #[component] pub fn App() -> impl IntoView { + let i18n = use_i18n(); let stored = load_session(); let initial_screen = if stored.is_some() { Screen::Connecting @@ -225,8 +227,10 @@ pub fn App() -> impl IntoView { }; if remote_config.is_none() { + let player_name = auth_username.get_untracked() + .unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string()); loop { - let restart = run_local_bot_game(screen, &mut cmd_rx, pending).await; + let restart = run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()).await; if !restart { break; } @@ -266,7 +270,9 @@ pub fn App() -> impl IntoView { let is_host = session.is_host; let player_id = session.player_id; let reconnect_token = session.reconnect_token; - let mut vs = ViewState::default_with_names("Blancs", "Noirs"); + let my_name = auth_username.get_untracked() + .unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string()); + let mut vs = ViewState::default_with_names("", ""); let mut result_submitted = false; loop { @@ -290,6 +296,7 @@ pub fn App() -> impl IntoView { ViewStateUpdate::Full(state) => vs = state, ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta), } + patch_player_name(&mut vs, player_id, &my_name); if is_host && !result_submitted && vs.stage == SerStage::Ended { result_submitted = true; @@ -343,42 +350,52 @@ pub fn App() -> impl IntoView { }); view! { - - - // Nav: hidden while game overlay is active - + + - // Portal pages — always mounted for router stability -
- "Page not found."

}> - - - - -
-
+
+ "Page not found."

}> + + + + +
+
- // Game overlay: fixed, covers portal during play - {move || { - let q = pending.get(); - let front = q.front().cloned(); - if let Some(state) = front { - return view! { -
- }.into_any(); - } - match screen.get() { - Screen::Playing(state) => view! { -
- }.into_any(), - Screen::Connecting => view! { -
- }.into_any(), - _ => view! { }.into_any(), - } - }} -
-
+ + + } +} + +/// Renders the full-screen game overlay, but only when the current route is "/". +/// This lets the user navigate to profile/account pages while a game is running. +#[component] +fn GameOverlay( + pending: RwSignal>, + screen: RwSignal, +) -> impl IntoView { + let location = use_location(); + + move || { + if location.pathname.get() != "/" { + return view! { }.into_any(); + } + let q = pending.get(); + let front = q.front().cloned(); + if let Some(state) = front { + return view! { +
+ }.into_any(); + } + match screen.get() { + Screen::Playing(state) => view! { +
+ }.into_any(), + Screen::Connecting => view! { +
+ }.into_any(), + _ => view! { }.into_any(), + } } } diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 3806a41..7c811d3 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -8,6 +8,7 @@ use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, use super::die::Die; use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::i18n::*; +use crate::portal::lobby::{qr_svg, room_url}; use crate::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage}; use super::board::Board; @@ -223,6 +224,10 @@ 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() }; + view! {
// ── Top bar ────────────────────────────────────────────────────── @@ -232,6 +237,18 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { } else { t_string!(i18n, room_label, id = room_id.as_str()) }} + + {move || (!is_bot_game).then(|| view! { + + })} +
- {move || auth_username.get().map(|u| view! { - "▶ " {u} - })} + {move || auth_username.get().map(|u| view! { + "▶ " {u} + })} impl IntoView { }>{t!(i18n, quit)}
+ // ── Share popover ───────────────────────────────────────────────── + {move || share_open.get().then(|| view! { +