feat: replace navigation bar by collapsable sidebar & hamburger button
This commit is contained in:
parent
e0698986f1
commit
236c6df826
6 changed files with 274 additions and 75 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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! {
|
||||
<Router>
|
||||
<SiteNav />
|
||||
|
||||
<SiteHamburger />
|
||||
<main>
|
||||
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
||||
<Route path=path!("/") view=LobbyPage />
|
||||
|
|
@ -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::<RwSignal<Option<String>>>().unwrap_or_else(|| RwSignal::new(None));
|
||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
||||
let cmd_tx = use_context::<futures::channel::mpsc::UnboundedSender<NetCommand>>()
|
||||
.expect("cmd_tx not found in context");
|
||||
|
||||
let sidebar_open = RwSignal::new(false);
|
||||
|
||||
let cmd_tx_newgame = cmd_tx.clone();
|
||||
|
||||
view! {
|
||||
// ── Hamburger button (☰ → ✕ animation) ───────────────────────────────
|
||||
<button
|
||||
class="game-hamburger"
|
||||
class:game-hamburger-open=move || sidebar_open.get()
|
||||
on:click=move |_| sidebar_open.update(|v| *v = !*v)
|
||||
aria-label="Menu"
|
||||
>
|
||||
<span class="hb-bar hb-top"></span>
|
||||
<span class="hb-bar hb-mid"></span>
|
||||
<span class="hb-bar hb-bot"></span>
|
||||
</button>
|
||||
|
||||
// ── Left sidebar ──────────────────────────────────────────────────────
|
||||
<div class="game-sidebar" class:game-sidebar-open=move || sidebar_open.get()>
|
||||
|
||||
<div class="game-sidebar-header">
|
||||
<span class="game-sidebar-brand">"Trictrac"</span>
|
||||
</div>
|
||||
|
||||
// Language switcher
|
||||
<div class="game-sidebar-section">
|
||||
<span class="game-sidebar-label">"Language"</span>
|
||||
<div class="lang-switcher">
|
||||
<button
|
||||
class:lang-active=move || i18n.get_locale() == Locale::en
|
||||
on:click=move |_| i18n.set_locale(Locale::en)
|
||||
>"EN"</button>
|
||||
<button
|
||||
class:lang-active=move || i18n.get_locale() == Locale::fr
|
||||
on:click=move |_| i18n.set_locale(Locale::fr)
|
||||
>"FR"</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Auth
|
||||
<div class="game-sidebar-section">
|
||||
{move || match auth_username.get() {
|
||||
Some(u) => {
|
||||
let href = format!("/profile/{u}");
|
||||
view! {
|
||||
<A href=href attr:class="game-sidebar-link"
|
||||
on:click=move |_| sidebar_open.set(false)>
|
||||
{u}
|
||||
</A>
|
||||
<button class="game-sidebar-btn" on:click=move |_| {
|
||||
spawn_local(async move {
|
||||
let _ = api::post_logout().await;
|
||||
auth_username.set(None);
|
||||
});
|
||||
}>{t!(i18n, sign_out)}</button>
|
||||
}.into_any()
|
||||
},
|
||||
None => view! {
|
||||
<A href="/account" attr:class="game-sidebar-link"
|
||||
on:click=move |_| sidebar_open.set(false)>
|
||||
{t!(i18n, sign_in)}
|
||||
</A>
|
||||
}.into_any(),
|
||||
}}
|
||||
</div>
|
||||
|
||||
// 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! {
|
||||
<div class="game-sidebar-section">
|
||||
<button class="game-sidebar-btn game-sidebar-btn-newgame"
|
||||
on:click=move |_| {
|
||||
tx.unbounded_send(NetCommand::Disconnect).ok();
|
||||
sidebar_open.set(false);
|
||||
}>
|
||||
{t!(i18n, new_game)}
|
||||
</button>
|
||||
</div>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ use super::scoring::ScoringPanel;
|
|||
pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().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 ────────────────────────────────────────────────────
|
||||
<div class="game-container">
|
||||
// ── Top bar ──────────────────────────────────────────────────────
|
||||
<div class="top-bar">
|
||||
<span>{move || if is_bot_game {
|
||||
t_string!(i18n, vs_bot_label).to_owned()
|
||||
} 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>
|
||||
})}
|
||||
|
||||
{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();
|
||||
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();
|
||||
}>{t!(i18n, quit)}</a>
|
||||
</div>
|
||||
|
||||
// ── 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! {
|
||||
<div class="share-popover">
|
||||
<p class="share-popover-label">{t!(i18n, share_link)}</p>
|
||||
<div class="share-url-row">
|
||||
<span class="share-url-text">{url}</span>
|
||||
<span class="share-url-text">{url_label}</span>
|
||||
<button class="share-copy-btn" on:click=move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
|
@ -312,26 +270,23 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
{
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(
|
||||
cb.write_text(&u),
|
||||
)
|
||||
.await;
|
||||
share_copied.set(true);
|
||||
).await;
|
||||
sidebar_copied.set(true);
|
||||
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||
share_copied.set(false);
|
||||
sidebar_copied.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}>
|
||||
{move || if share_copied.get() {
|
||||
{move || if sidebar_copied.get() {
|
||||
t_string!(i18n, link_copied)
|
||||
} else {
|
||||
t_string!(i18n, copy_link)
|
||||
}}
|
||||
</button>
|
||||
</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() />
|
||||
<p class="share-popover-label">{t!(i18n, scan_qr)}</p>
|
||||
<div class="qr-container" inner_html=svg />
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ leptos_i18n::load_locales!();
|
|||
mod api;
|
||||
mod app;
|
||||
mod game;
|
||||
mod nav;
|
||||
mod portal;
|
||||
|
||||
use app::App;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue