diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index f241273..a129c91 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -378,7 +378,7 @@ a:hover { text-decoration: underline; } /* ── Game overlay (full-screen, covers portal during play) ───────── */ .game-overlay { position: fixed; - inset: 54px 0 0 0; + inset: 0; background: #8a7050; background-image: radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), @@ -1776,3 +1776,213 @@ a:hover { text-decoration: underline; } color: var(--ui-red-accent); font-style: italic; } + +/* ── Nickname modal (anonymous player name chooser) ─────────────────── */ +.nickname-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.nickname-modal { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2rem 2rem 1.75rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.nickname-modal-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-ink); + text-align: center; + letter-spacing: 0.04em; +} + +.nickname-modal-hint { + font-family: var(--font-ui); + font-size: 0.8rem; + color: rgba(42,26,8,0.6); + text-align: center; + margin-bottom: -0.25rem; +} + +.nickname-modal-alt { + text-align: center; + font-size: 0.8rem; + color: rgba(42,26,8,0.55); + padding-top: 0.5rem; + border-top: 1px solid rgba(138,106,40,0.2); +} + +.nickname-modal-alt a { + color: var(--ui-gold-dark); + text-decoration: none; + font-weight: 500; +} + +.nickname-modal-alt a:hover { text-decoration: underline; } + +/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */ +.game-hamburger { + position: fixed; + top: 0.6rem; + left: 0.6rem; + z-index: 251; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + background: var(--board-rail); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.game-hamburger:hover { + background: #3d1f0a; + border-color: rgba(200,164,72,0.65); +} + +.hb-bar { + display: block; + width: 16px; + height: 2px; + background: var(--ui-parchment); + border-radius: 1px; + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s; + transform-origin: center; +} +/* Top bar rotates down to form \ */ +.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); } +/* Middle bar fades out */ +.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); } +/* Bottom bar rotates up to form / */ +.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); } + +/* ── Game sidebar ────────────────────────────────────────────────────── */ +.game-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 280px; + z-index: 250; + background: var(--board-rail); + border-right: 1px solid rgba(200,164,72,0.25); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); + overflow-y: auto; +} +.game-sidebar-open { + transform: translateX(0); +} + +.game-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.2); + flex-shrink: 0; +} + +.game-sidebar-brand { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.06em; + margin-left: 45px; +} + +.game-sidebar-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(200,164,72,0.25); + border-radius: 4px; + color: var(--ui-parchment); + font-size: 0.85rem; + cursor: pointer; + opacity: 0.65; + transition: opacity 0.15s; +} +.game-sidebar-close:hover { opacity: 1; } + +.game-sidebar-section { + padding: 0.9rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.game-sidebar-label { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(242,232,208,0.45); +} + +.game-sidebar-link { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s; +} +.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } + +.game-sidebar-btn { + font-family: var(--font-ui); + font-size: 0.82rem; + padding: 0.4rem 0.75rem; + border: 1px solid rgba(200,164,72,0.35); + border-radius: 4px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + text-align: left; + transition: background 0.15s; +} +.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); } + +.game-sidebar-btn-newgame { + background: rgba(58,107,42,0.25); + border-color: rgba(58,107,42,0.55); + font-weight: 500; +} +.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); } + +.game-sidebar-qr { + width: 100%; + height: auto; + aspect-ratio: 1; + max-width: 200px; + margin: 0 auto; +} diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 2d7ca43..4e41d9a 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -121,5 +121,12 @@ "scan_qr": "or scan the QR code", "join_code_label": "Join by code", "join_code_placeholder": "Room code", - "share_btn": "Share" + "share_btn": "Share", + "nickname_modal_title": "Choose your nickname", + "nickname_modal_hint": "You will play as:", + "nickname_modal_play": "Play", + "nickname_modal_or": "or", + "nickname_modal_sign_in": "Sign in", + "nickname_modal_register": "Create account", + "new_game": "New game" } diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 3ff78aa..1d2a8d0 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -121,5 +121,12 @@ "scan_qr": "ou scannez le QR code", "join_code_label": "Rejoindre par code", "join_code_placeholder": "Code de salle", - "share_btn": "Partager" + "share_btn": "Partager", + "nickname_modal_title": "Choisissez votre pseudo", + "nickname_modal_hint": "Vous jouerez sous le nom de :", + "nickname_modal_play": "Jouer", + "nickname_modal_or": "ou", + "nickname_modal_sign_in": "Se connecter", + "nickname_modal_register": "Créer un compte", + "new_game": "Nouvelle partie" } diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 5aa0f80..de8d55f 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -3,7 +3,7 @@ use futures::{FutureExt, StreamExt}; use gloo_storage::{LocalStorage, Storage}; use leptos::prelude::*; use leptos::task::spawn_local; -use leptos_router::components::{Route, Router, Routes}; +use leptos_router::components::{Route, Router, Routes, A}; use leptos_router::hooks::use_location; use leptos_router::path; use serde::{Deserialize, Serialize}; @@ -19,14 +19,9 @@ use crate::game::session::{ use crate::game::trictrac::backend::TrictracBackend; use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState}; use crate::i18n::*; -use crate::nav::SiteNav; use crate::portal::{ - account::AccountPage, - forgot_password::ForgotPasswordPage, - game_detail::GameDetailPage, - lobby::LobbyPage, - profile::ProfilePage, - reset_password::ResetPasswordPage, + account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage, + lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage, verify_email::VerifyEmailPage, }; use trictrac_store::CheckerMove; @@ -154,11 +149,19 @@ pub fn App() -> impl IntoView { let auth_email_verified: RwSignal = RwSignal::new(false); provide_context(auth_username); provide_context(auth_email_verified); + // Set to true once get_me resolves (success or failure) so lobby can + // decide immediately whether to show the nickname modal. + let auth_loaded: RwSignal = RwSignal::new(false); + provide_context(auth_loaded); + // Nickname chosen by an anonymous player; used instead of "Anonymous". + let anon_nickname: RwSignal> = RwSignal::new(None); + provide_context(anon_nickname); spawn_local(async move { if let Ok(me) = api::get_me().await { auth_username.set(Some(me.username)); auth_email_verified.set(me.email_verified); } + auth_loaded.set(true); }); let (cmd_tx, mut cmd_rx) = mpsc::unbounded::(); @@ -242,6 +245,7 @@ pub fn App() -> impl IntoView { if remote_config.is_none() { let player_name = auth_username .get_untracked() + .or_else(|| anon_nickname.get_untracked()) .unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string())); loop { let restart = @@ -287,7 +291,11 @@ pub fn App() -> impl IntoView { let reconnect_token = session.reconnect_token; let my_name = auth_username .get_untracked() + .or_else(|| anon_nickname.get_untracked()) .unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string()); + // Announce our name to the host backend so it can broadcast it to + // the opponent. Done once immediately after connecting. + session.send_action(PlayerAction::SetName(my_name.clone())); let mut vs = ViewState::default_with_names("", ""); let mut result_submitted = false; @@ -367,8 +375,7 @@ pub fn App() -> impl IntoView { view! { - - +
"Page not found."

}> @@ -421,6 +428,105 @@ fn GameOverlay( } } +/// Persistent hamburger button + left sidebar — visible on every page. +#[component] +fn SiteHamburger() -> impl IntoView { + let i18n = use_i18n(); + let auth_username = + use_context::>>().unwrap_or_else(|| RwSignal::new(None)); + let screen = use_context::>().expect("Screen context not found"); + let cmd_tx = use_context::>() + .expect("cmd_tx not found in context"); + + let sidebar_open = RwSignal::new(false); + + let cmd_tx_newgame = cmd_tx.clone(); + + view! { + // ── Hamburger button (☰ → ✕ animation) ─────────────────────────────── + + + // ── Left sidebar ────────────────────────────────────────────────────── +
+ +
+ "Trictrac" +
+ + // Language switcher +
+ "Language" +
+ + +
+
+ + // Auth +
+ {move || match auth_username.get() { + Some(u) => { + let href = format!("/profile/{u}"); + view! { + + {u} + + + }.into_any() + }, + None => view! { + + {t!(i18n, sign_in)} + + }.into_any(), + }} +
+ + // New game — only shown while a game is in progress + {move || { + if matches!(screen.get(), Screen::Playing(_) | Screen::Connecting) { + let tx = cmd_tx_newgame.clone(); + Some(view! { +
+ +
+ }) + } else { + None + } + }} +
+ } +} + #[cfg(test)] mod tests { use super::*; diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 2a1d761..d55284f 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -19,8 +19,6 @@ use super::scoring::ScoringPanel; pub fn GameScreen(state: GameUiState) -> impl IntoView { let i18n = use_i18n(); - let auth_username = - use_context::>>().unwrap_or_else(|| RwSignal::new(None)); let vs = state.view_state.clone(); let player_id = state.player_id; let is_my_turn = vs.active_mp_player == Some(player_id); @@ -100,7 +98,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Button senders ───────────────────────────────────────────────────────── let cmd_tx_go = cmd_tx.clone(); - let cmd_tx_quit = cmd_tx.clone(); let cmd_tx_end_quit = cmd_tx.clone(); let cmd_tx_end_replay = cmd_tx.clone(); // Only show the fallback Go button when there is no ScoringPanel showing it. @@ -246,61 +243,52 @@ 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() - }; + let sidebar_copied = 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! { + // ── Game container ────────────────────────────────────────────────────
- // ── Top bar ────────────────────────────────────────────────────── -
- {move || if is_bot_game { - t_string!(i18n, vs_bot_label).to_owned() - } 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} - })} - - {t!(i18n, quit)} -
- - // ── Share popover ───────────────────────────────────────────────── - {move || share_open.get().then(|| view! { -