feat: generate room name, link & QR code

This commit is contained in:
Henri Bourcereau 2026-04-25 22:23:52 +02:00
parent c46d26ae02
commit 04369ea28e
7 changed files with 350 additions and 53 deletions

7
Cargo.lock generated
View file

@ -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",

View file

@ -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",
] }

View file

@ -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;

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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! {
<div class="game-container">
// ── Top bar ──────────────────────────────────────────────────────
@ -232,6 +237,18 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
} else {
t_string!(i18n, room_label, id = room_id.as_str())
}}</span>
{move || (!is_bot_game).then(|| view! {
<button
class="quit-link"
style="border:none;background:transparent;cursor:pointer"
on:click=move |_| share_open.update(|v| *v = !*v)
>
{move || if share_open.get() { "" } else { "" }}
{t!(i18n, share_btn)}
</button>
})}
<div class="lang-switcher">
<button
class:lang-active=move || i18n.get_locale() == Locale::en
@ -243,9 +260,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
>"FR"</button>
</div>
{move || auth_username.get().map(|u| view! {
<span class="playing-as">"" <strong>{u}</strong></span>
})}
{move || auth_username.get().map(|u| view! {
<span class="playing-as">"" <strong>{u}</strong></span>
})}
<a class="quit-link" href="#" on:click=move |e| {
e.prevent_default();
@ -253,6 +270,20 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}>{t!(i18n, quit)}</a>
</div>
// ── Share popover ─────────────────────────────────────────────────
{move || share_open.get().then(|| view! {
<div class="share-popover">
<p class="share-popover-label">{t!(i18n, share_link)}</p>
<div class="share-url-row">
<span class="share-url-text">{ share_url.clone() }</span>
</div>
<p class="share-popover-label" style="margin-top:0.75rem">
{t!(i18n, scan_qr)}
</p>
<div class="qr-container" inner_html=share_svg.clone() />
</div>
})}
// ── Opponent score (above board) ─────────────────────────────────
<PlayerScorePanel score=opp_score is_you=false />

View file

@ -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!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {t} {t}\" shape-rendering=\"crispEdges\">",
t = total,
);
svg.push_str("<rect width=\"100%\" height=\"100%\" fill=\"#f2e8d0\"/>");
for y in 0..size {
for x in 0..size {
if qr.get_module(x, y) {
svg.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"1\" height=\"1\" fill=\"#2a1508\"/>",
x + border,
y + border,
));
}
}
}
svg.push_str("</svg>");
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::<RwSignal<Screen>>().expect("Screen context not found");
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> 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<LobbyView> = 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! {
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
<div class="login-card">
@ -34,55 +109,157 @@ pub fn LobbyPage() -> impl IntoView {
<p class="login-subtitle">
<em>"Une interprétation numérique"</em>
</p>
<div class="login-ornament">""</div>
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
<input
class="login-input"
type="text"
placeholder=move || t_string!(i18n, room_name_placeholder)
prop:value=move || room_name.get()
on:input=move |ev| set_room_name.set(event_target_value(&ev))
/>
<div class="login-actions">
<button
class="login-btn login-btn-primary"
disabled=move || room_name.get().is_empty()
on:click=move |_| {
{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: room_name.get() })
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
.ok();
}
>
{t!(i18n, create_room)}
</button>
<button
class="login-btn login-btn-secondary"
disabled=move || room_name.get().is_empty()
on:click=move |_| {
cmd_tx_join
.unbounded_send(NetCommand::JoinRoom { room: room_name.get() })
.ok();
}
>
{t!(i18n, join_room)}
</button>
<button
class="login-btn login-btn-bot"
on:click=move |_| {
cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok();
}
>
{t!(i18n, play_vs_bot)}
</button>
</div>
view_state.set(LobbyView::Waiting { code });
};
view! {
<IdleCard on_create=on_create cmd_tx_bot=cmd_tx_bot />
}.into_any()
}
LobbyView::Waiting { code } => view! {
<WaitingCard code=code />
}.into_any(),
}}
</div>
</div>
</div>
}
}
// ── Idle card: Create + vs Bot + hidden join-by-code ─────────────────────────
#[component]
fn IdleCard(
on_create: impl Fn(leptos::ev::MouseEvent) + 'static,
cmd_tx_bot: UnboundedSender<NetCommand>,
) -> 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! {
<div class="login-actions">
<button class="login-btn login-btn-primary" on:click=on_create>
{t!(i18n, create_room)}
</button>
<button
class="login-btn login-btn-bot"
on:click=move |_| { cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
>
{t!(i18n, play_vs_bot)}
</button>
</div>
// Hidden "join by code" fallback
<div style="margin-top:1.25rem;text-align:center">
<button
class="portal-page-btn"
style="font-size:0.75rem;opacity:0.7"
on:click=move |_| join_open.update(|v| *v = !*v)
>
{move || if join_open.get() { "" } else { "" }}
{t!(i18n, join_code_label)}
</button>
{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! {
<div style="margin-top:0.75rem;display:flex;gap:0.5rem">
<input
class="login-input"
style="flex:1;margin:0"
type="text"
placeholder=move || t_string!(i18n, join_code_placeholder)
prop:value=move || join_code.get()
on:input=move |ev| join_code.set(event_target_value(&ev))
/>
<button
class="login-btn login-btn-secondary"
style="margin:0;padding:0 1rem"
disabled=move || join_code.get().is_empty()
on:click=move |_| {
cmd.unbounded_send(NetCommand::JoinRoom { room: join_code.get() })
.ok();
}
>
{t!(i18n, join_room)}
</button>
</div>
})
}}
</div>
}
}
// ── 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! {
<p style="font-size:0.85rem;color:rgba(242,232,208,0.75);margin-bottom:1rem;text-align:center">
{t!(i18n, waiting_for_opponent)}
</p>
<p style="font-size:0.8rem;color:rgba(242,232,208,0.6);margin-bottom:0.5rem;text-align:center">
{t!(i18n, share_link)}
</p>
<div class="share-url-row">
<span class="share-url-text">{ url.clone() }</span>
<button class="share-copy-btn" on:click=on_copy>
{move || if copied.get() {
t_string!(i18n, link_copied)
} else {
t_string!(i18n, copy_link)
}}
</button>
</div>
<p style="font-size:0.75rem;color:rgba(242,232,208,0.45);margin:1rem 0 0.5rem;text-align:center">
{t!(i18n, scan_qr)}
</p>
<div class="qr-container" inner_html=svg />
}
}