diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 84af598..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%), @@ -1836,3 +1836,153 @@ a:hover { text-decoration: underline; } } .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 5110ae6..4e41d9a 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -127,5 +127,6 @@ "nickname_modal_play": "Play", "nickname_modal_or": "or", "nickname_modal_sign_in": "Sign in", - "nickname_modal_register": "Create account" + "nickname_modal_register": "Create account", + "new_game": "New game" } diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index c1acee7..1d2a8d0 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -127,5 +127,6 @@ "nickname_modal_play": "Jouer", "nickname_modal_or": "ou", "nickname_modal_sign_in": "Se connecter", - "nickname_modal_register": "Créer un compte" + "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 6383e3d..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; @@ -380,8 +375,7 @@ pub fn App() -> impl IntoView { view! { - - +
"Page not found."

}> @@ -434,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 011f962..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,62 +243,23 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let opp_name_end = opp_score.name.clone(); let opp_holes_end = opp_score.holes; - // Open by default for the room creator while waiting for an opponent. - // When the opponent joins the stage becomes PreGameRoll, so the next - // re-mount will initialise this to false — auto-closing the popover. - let share_open = RwSignal::new(!is_bot_game && player_id == 0 && stage == SerStage::PreGame); - let share_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() - }; + 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(|| { - let url = share_url.clone(); - let url_copy = share_url.clone(); + // ── Share popover (while waiting for opponent) ─────────────────── + {(!is_bot_game && stage == SerStage::PreGame).then(|| { + let url_label = share_url.clone(); + let url_copy = share_url.clone(); + let svg = share_svg.clone(); view! {