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 || if share_open.get() { "✕ " } else { "" }}
+ {t!(i18n, share_btn)}
+
+ })}
+
impl IntoView {
>"FR"
- {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! {
+
+
{t!(i18n, share_link)}
+
+ { share_url.clone() }
+
+
+ {t!(i18n, scan_qr)}
+
+
+
+ })}
+
// ── 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! {
@@ -34,55 +109,157 @@ pub fn LobbyPage() -> impl IntoView {
"Une interprétation numérique"
-
"✦"
{move || error().map(|err| view! {
{err}
})}
-
-
-
- {
+ // 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: room_name.get() })
+ .unbounded_send(NetCommand::CreateRoom { room: code.clone() })
.ok();
- }
- >
- {t!(i18n, create_room)}
-
-
-
- {t!(i18n, join_room)}
-
-
-
- {t!(i18n, play_vs_bot)}
-
-
+ view_state.set(LobbyView::Waiting { code });
+ };
+ view! {
+
+ }.into_any()
+ }
+ LobbyView::Waiting { code } => view! {
+
+ }.into_any(),
+ }}
}
}
+
+// ── 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! {
+
+
+ {t!(i18n, create_room)}
+
+
+ {t!(i18n, play_vs_bot)}
+
+
+
+ // Hidden "join by code" fallback
+
+
+ {move || if join_open.get() { "▲ " } else { "▼ " }}
+ {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();
+ join_open.get().then(|| view! {
+
+
+
+ {t!(i18n, join_room)}
+
+
+ })
+ }}
+
+ }
+}
+
+// ── 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)}
+
+
+
+ { url.clone() }
+
+ {move || if copied.get() {
+ t_string!(i18n, link_copied)
+ } else {
+ t_string!(i18n, copy_link)
+ }}
+
+
+
+
+ {t!(i18n, scan_qr)}
+
+
+
+ }
+}