feat: replace navigation bar by collapsable sidebar & hamburger button

This commit is contained in:
Henri Bourcereau 2026-05-04 20:32:30 +02:00
parent e0698986f1
commit 236c6df826
6 changed files with 274 additions and 75 deletions

View file

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

View file

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

View file

@ -3,7 +3,6 @@ leptos_i18n::load_locales!();
mod api;
mod app;
mod game;
mod nav;
mod portal;
use app::App;