From 04369ea28e9a1ac6185710fc409fb50aa05f14fb Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 25 Apr 2026 22:23:52 +0200 Subject: [PATCH] feat: generate room name, link & QR code --- Cargo.lock | 7 + clients/web/Cargo.toml | 4 + clients/web/assets/style.css | 64 ++++ clients/web/locales/en.json | 9 +- clients/web/locales/fr.json | 9 +- .../web/src/game/components/game_screen.rs | 37 ++- clients/web/src/portal/lobby.rs | 273 +++++++++++++++--- 7 files changed, 350 insertions(+), 53 deletions(-) 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 8c7630e..3de4a9f 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -287,6 +287,70 @@ 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; diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 5cbb36e..8ff3548 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -94,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 da12cc8..2346395 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -94,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/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! { +
+ + + +
+
+ })} + // ── Opponent score (above board) ───────────────────────────────── diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs index ef66281..6b2af0b 100644 --- a/clients/web/src/portal/lobby.rs +++ b/clients/web/src/portal/lobby.rs @@ -1,28 +1,103 @@ use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; +use leptos_router::hooks::use_query_map; use crate::app::{NetCommand, Screen}; use crate::i18n::*; +// ── Room code 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() +} + +// ── 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 component ─────────────────────────────────────────────────────────── + +#[derive(Clone)] +enum LobbyView { + Idle, + Waiting { code: String }, +} + #[component] pub fn LobbyPage() -> impl IntoView { - let i18n = use_i18n(); - let (room_name, set_room_name) = signal(String::new()); - let screen = use_context::>().expect("Screen context not found"); let cmd_tx = use_context::>() .expect("UnboundedSender not found in context"); + let query = use_query_map(); - let cmd_tx_create = cmd_tx.clone(); - let cmd_tx_join = cmd_tx.clone(); - let cmd_tx_bot = cmd_tx; + let view_state: RwSignal = RwSignal::new(LobbyView::Idle); + + // Auto-join when the URL contains ?room=CODE + let cmd_tx_query = 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(); + } + } + }); - // Extract connection error from screen state. let error = move || match screen.get() { Screen::Login { error } => error, _ => None, }; + let cmd_tx_idle = cmd_tx; + view! {
} } + +// ── Idle card: Create + vs Bot + hidden join-by-code ───────────────────────── + +#[component] +fn IdleCard( + on_create: impl Fn(leptos::ev::MouseEvent) + 'static, + cmd_tx_bot: UnboundedSender, +) -> 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(); + + view! { + + + // Hidden "join by code" fallback +
+ + {move || { + // Clone the sender on each reactive run to keep the outer closure FnMut + let cmd = cmd_tx_join.clone(); + join_open.get().then(|| view! { +
+ + +
+ }) + }} +
+ } +} + +// ── Waiting card: 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)} +

+ + + +

+ {t!(i18n, scan_qr)} +

+ +
+ } +}