From 88221303ef09fb05cfafa9529719e4b15a53a0d7 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Fri, 22 May 2026 22:15:10 +0200 Subject: [PATCH 01/10] chore: bump version to 0.2.12 --- Cargo.toml | 2 +- flake.nix | 4 ++-- justfile | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9db1a73..d722f4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.2.11" +version = "0.2.12" [workspace] resolver = "2" diff --git a/flake.nix b/flake.nix index cde292d..62b1eac 100644 --- a/flake.nix +++ b/flake.nix @@ -56,7 +56,7 @@ frontendCargoDeps = rustPlatform.fetchCargoVendor { src = ./.; name = "trictrac-frontend-vendor"; - hash = "sha256-lapxu9R6s2ASGdpnU0dht4jxewQjbrXlAkpFf/GsK/Q="; + hash = "sha256-neJh0ZQGa5LNY8vBu3kYkM+ARkXOW/EHx8sPBOsWsgE="; }; in final.stdenv.mkDerivation { @@ -103,7 +103,7 @@ trictrac = with final; rustPlatform.buildRustPackage { pname = "trictrac"; - version = "0.2.11"; # trictrac-version + version = "0.2.12"; # trictrac-version src = ./.; nativeBuildInputs = [ pkg-config ]; diff --git a/justfile b/justfile index fe4e52e..2c3871a 100644 --- a/justfile +++ b/justfile @@ -8,9 +8,9 @@ bump version: sed -i '/^\[workspace\.package\]/,/^\[/{s/^version = ".*"/version = "{{version}}"/}' Cargo.toml sed -i 's/version = "[0-9.]*"; # trictrac-version/version = "{{version}}"; # trictrac-version/' flake.nix - git flow release start {{version}} git add Cargo.toml flake.nix git commit -m "chore: bump version to {{version}}" + git flow release start {{version}} @echo "Done. Finish with: git flow release finish {{version}}" doc: From 2f40a0a507f9912ff6ebc3d70a01129d039388dc Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sun, 24 May 2026 16:01:42 +0200 Subject: [PATCH 02/10] fix(web client): websocket url detection on invite screen --- clients/web/locales/fr.json | 4 +-- clients/web/src/app.rs | 25 ++++++++++++++---- clients/web/src/portal/lobby.rs | 47 --------------------------------- devenv.lock | 11 +++++--- justfile | 4 +++ 5 files changed, 32 insertions(+), 59 deletions(-) diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 569d66b..d429838 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -130,14 +130,12 @@ "copy_link": "Copier le lien", "link_copied": "Copié !", "scan_qr": "ou scannez le QR code", - "join_code_label": "Rejoindre avec un code", - "join_code_placeholder": "Code de la salle", "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_sign_in": "connectez-vous", "nickname_modal_register": "Créer un compte", "new_game": "Nouvelle partie", "language": "Langue" diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 5c38d33..6cfaa54 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -29,7 +29,22 @@ use trictrac_store::CheckerMove; use std::collections::VecDeque; -const RELAY_URL: &str = "ws://localhost:8080/ws"; +fn relay_url() -> String { + #[cfg(debug_assertions)] + { + "ws://localhost:8080/ws".to_string() + } + #[cfg(not(debug_assertions))] + { + let location = web_sys::window() + .and_then(|w| Some(w.location())) + .unwrap(); + let protocol = location.protocol().unwrap_or_default(); + let host = location.host().unwrap_or_default(); + let ws_protocol = if protocol == "https:" { "wss" } else { "ws" }; + format!("{ws_protocol}://{host}/ws") + } +} const GAME_ID: &str = "trictrac"; const STORAGE_KEY: &str = "trictrac_session"; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -205,7 +220,7 @@ pub fn App() -> impl IntoView { Some(NetCommand::CreateRoom { room }) => { break Some(( RoomConfig { - relay_url: RELAY_URL.to_string(), + relay_url: relay_url(), game_id: GAME_ID.to_string(), room_id: room, rule_variation: 0, @@ -219,7 +234,7 @@ pub fn App() -> impl IntoView { Some(NetCommand::JoinRoom { room }) => { break Some(( RoomConfig { - relay_url: RELAY_URL.to_string(), + relay_url: relay_url(), game_id: GAME_ID.to_string(), room_id: room, rule_variation: 0, @@ -304,7 +319,7 @@ pub fn App() -> impl IntoView { if !session.is_host { save_session(&StoredSession { - relay_url: RELAY_URL.to_string(), + relay_url: relay_url(), game_id: GAME_ID.to_string(), room_id: room_id_for_storage.clone(), token: session.reconnect_token, @@ -358,7 +373,7 @@ pub fn App() -> impl IntoView { if is_host { save_session(&StoredSession { - relay_url: RELAY_URL.to_string(), + relay_url: relay_url(), game_id: GAME_ID.to_string(), room_id: room_id_for_storage.clone(), token: reconnect_token, diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs index c3dbf24..686dac7 100644 --- a/clients/web/src/portal/lobby.rs +++ b/clients/web/src/portal/lobby.rs @@ -195,12 +195,9 @@ fn IdleCard( pending_action: RwSignal>, ) -> impl IntoView { let i18n = use_i18n(); - let join_open = RwSignal::new(false); - let join_code = RwSignal::new(String::new()); let cmd_bot = cmd_tx.clone(); let cmd_create = cmd_tx.clone(); - let cmd_join = cmd_tx; let on_create = move |_: leptos::ev::MouseEvent| { let code = generate_room_code(); @@ -232,48 +229,6 @@ fn IdleCard( {t!(i18n, create_room)} - - // Hidden "join by code" fallback -
- - {move || { - let cmd = cmd_join.clone(); - join_open.get().then(|| view! { -
- - -
- }) - }} -
} } @@ -338,8 +293,6 @@ fn NicknameModal( {t!(i18n, nickname_modal_or)} " " {t!(i18n, nickname_modal_sign_in)} - " · " - {t!(i18n, nickname_modal_register)}

diff --git a/devenv.lock b/devenv.lock index 991fcf7..e6e8ef6 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,10 +3,11 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1776863933, + "lastModified": 1779486363, + "narHash": "sha256-6rNmvwTngbmz/j3DGsEYkyakrHHY8N5wgRD9k35HyuM=", "owner": "cachix", "repo": "devenv", - "rev": "863b4204725efaeeb73811e376f928232b720646", + "rev": "90692720b2ad7a7811204155900bf6bea3a3b420", "type": "github" }, "original": { @@ -18,10 +19,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1776734388, + "lastModified": 1779102034, + "narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac", + "rev": "687f05a9184cad4eaf905c48b63649e3a86f5433", "type": "github" }, "original": { @@ -34,6 +36,7 @@ "nixpkgs-cmake3": { "locked": { "lastModified": 1758213207, + "narHash": "sha256-rqoqF0LEi+6ZT59tr+hTQlxVwrzQsET01U4uUdmqRtM=", "owner": "NixOS", "repo": "nixpkgs", "rev": "f4b140d5b253f5e2a1ff4e5506edbf8267724bde", diff --git a/justfile b/justfile index 1ac30e4..bc78103 100644 --- a/justfile +++ b/justfile @@ -55,6 +55,10 @@ build-relay: cp target/release/relay-server deploy cp -u server/relay-server/GameConfig.json deploy/ +# generate web stats report from the current nginx logs +stats: + ssh -t raspberry sudo goaccess /var/log/nginx/trictrac_access.log --log-format=COMBINED -o html > var/stats/report.html + # start a trictrac container with nixos-container # `boot.enableContainers = true` must be set on local nixos system local: From 91981e6872d77a405580cf12d01564d6ddb34b09 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sun, 24 May 2026 16:22:50 +0200 Subject: [PATCH 03/10] fix(web client): sidebar shows login link for anonymous with a nickname --- Cargo.lock | 10 +-- README.md | 116 ++----------------------- clients/web/src/app.rs | 10 ++- clients/web/src/portal/account.rs | 13 +-- clients/web/src/portal/lobby.rs | 4 +- clients/web/src/portal/verify_email.rs | 5 +- 6 files changed, 32 insertions(+), 126 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de6765c..8992cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,7 +189,7 @@ dependencies = [ [[package]] name = "backbone-lib" -version = "0.2.11" +version = "0.2.12" dependencies = [ "bytes", "ewebsock", @@ -2649,7 +2649,7 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.11" +version = "0.2.12" dependencies = [ "serde", ] @@ -2883,7 +2883,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relay-server" -version = "0.2.11" +version = "0.2.12" dependencies = [ "argon2", "axum", @@ -3893,7 +3893,7 @@ dependencies = [ [[package]] name = "trictrac-store" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "base64 0.21.7", @@ -3906,7 +3906,7 @@ dependencies = [ [[package]] name = "trictrac-web" -version = "0.2.11" +version = "0.2.12" dependencies = [ "backbone-lib", "futures", diff --git a/README.md b/README.md index ca4c0de..f9485c7 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation. -The project is still on its early stages. - ## Usage Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands. @@ -17,118 +15,18 @@ just run-relay # listens on :8080 just dev ``` -Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name. - -Playing with the cli against the 'random' bot: `cargo run --bin=client_cli -- --bot random` - -## Roadmap - -- [x] rules -- [x] command line interface -- [x] basic bot (random play) -- [ ] web client (in progress) -- [ ] network game (in progress) -- [ ] AI bot +Open a browser window at `http://127.0.0.1:9091`. You can play against a very basic bot, or invite an other player to connect at the same address. ## Code structure - game rules and game state are implemented in the _store/_ folder. +- a server for the network game is implemented in _server/relay-server_, which uses _server/protocol_ +- the web client is in _clients/web_, it connects to the server using the _clients/backbone-lib_ library - the command-line application is implemented in _clients/cli/_; it allows you to play against a bot, or to have two bots play against each other -- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. +- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. This is a work in progress, they are not performant at all. -### _store_ package +## Inspirations -The game state is defined by the `GameState` struct in _store/src/game.rs_. The `to_string_id()` method allows this state to be encoded compactly in a string (without the played moves history). For a more readable textual representation, the `fmt::Display` trait is implemented. +The multiplayer game architecture, implemented in packages _clients/backbone-lib_, _clients/web/game_, _server/protocol_, _server/relay-server_ is a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/). -### _clients/cli_ package - -`clients/cli/src/game_runner.rs` contains the logic to make two bots play against each other. - -### _bot_ package - -- `bot/src/strategy/default.rs` contains the code for a basic bot strategy: it determines the list of valid moves (using the `get_possible_moves_sequences` method of `store::MoveRules`) and simply executes the first move in the list. -- `bot/src/strategy/dqnburn.rs` is another bot strategy that uses a reinforcement learning trained model with the DQN algorithm via the burn library (). -- `bot/scripts/trains.sh` allows you to train agents using different algorithms (DQN, PPO, SAC). - -### multiplayer game - -Packages "clients/backbone-lib", "clients/web/game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/). - -The system consists of: - -- A **relay server** (Axum/Tokio) that routes messages between players and manages rooms, without knowing anything about game rules. -- A **backbone library** that handles WebSocket connection, handshake, and message routing, exposing an async API to the game frontend. -- Game-specific **backend logic** implementing the `BackEndArchitecture` trait, which runs only on the hosting client. -- A **Leptos frontend** that connects to a session and reacts to state updates. - -There is no dedicated game server. One of the players acts as the host: their browser runs the game backend locally. The relay server only forwards messages — it never touches game state. - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Host Client │ -│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │ -│ │ Leptos UI │◄──►│ GameSession │◄──►│ Backend │ │ -│ └─────────────┘ └────────┬─────────┘ └────────────┘ │ -└───────────────────────────── │ ────────────────────────────┘ - │ WebSocket - ┌──────▼──────┐ - │ Relay Server│ - └──────┬──────┘ - │ WebSocket -┌───────────────────────────────│────────────────────────────┐ -│ ┌─────────────┐ ┌─────────▼────────┐ │ -│ │ Leptos UI │◄──►│ GameSession │ (no backend) │ -│ └─────────────┘ └──────────────────┘ │ -│ Remote Client │ -└─────────────────────────────────────────────────────────────┘ -``` - -#### Data flow - -- **Actions** (e.g. "place stone at B3") flow from the UI to the host backend via `GameSession::send_action()`. -- **State updates** flow back as `ViewStateUpdate::Full` (full snapshot, on join or reset) or `ViewStateUpdate::Incremental` (delta, for animations). -- **Timers** are managed by the host's background task (wall-clock, no polling required from the game). - -#### backbone-lib session API - -The key design choice: `backbone-lib` owns a background async task per session. The Leptos app never drives a loop — it just awaits on events. - -#### Workspace - -**server/protocol** - -Shared message-type constants and the `JoinRequest` struct used during the WebSocket handshake. - -**server/relay-server** - -Listens on port 8080. Loads `GameConfig.json` on startup to know which games exist and their player limits: - -```json -[{ "name": "trictrac", "max_players": 10 }] -``` - -Games can be added at runtime via the `/reload` endpoint. `/enlist` lists active rooms. A watchdog cleans up inactive rooms every 20 minutes. - -For production, put it behind a reverse proxy with SSL (the browser requires `wss://` on HTTPS pages). Example Caddy config: - -``` -your-domain.com { - handle_path /api/* { - reverse_proxy localhost:8080 - } - file_server -} -``` - -**clients/backbone-lib** - -Modules: - -| Module | Purpose | -| ---------- | ---------------------------------------------------------------------------------------------------------- | -| `session` | `GameSession`, `connect()`, `SessionEvent`, `RoomConfig` | -| `host` | Background async task for the hosting client (drives `BackEndArchitecture`, manages timers) | -| `client` | Background async task for non-hosting clients | -| `protocol` | Wire encoding/decoding helpers (postcard + message-type bytes) | -| `platform` | `spawn_task` / `sleep_ms` abstractions (WASM: `spawn_local` + gloo-timers; native: thread + thread::sleep) | -| `traits` | `BackEndArchitecture`, `BackendCommand`, `ViewStateUpdate`, `SerializationCap` | +The web client UX/UI is inspired by https://playtiao.com. diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 6cfaa54..2749d9c 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -29,6 +29,12 @@ use trictrac_store::CheckerMove; use std::collections::VecDeque; +/// Newtype wrappers so context lookup can distinguish signals of the same inner type. +#[derive(Clone, Copy)] +pub(crate) struct AnonNickname(pub RwSignal>); +#[derive(Clone, Copy)] +pub(crate) struct AuthEmailVerified(pub RwSignal); + fn relay_url() -> String { #[cfg(debug_assertions)] { @@ -170,14 +176,14 @@ pub fn App() -> impl IntoView { let auth_username: RwSignal> = RwSignal::new(None); let auth_email_verified: RwSignal = RwSignal::new(false); provide_context(auth_username); - provide_context(auth_email_verified); + provide_context(AuthEmailVerified(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); + provide_context(AnonNickname(anon_nickname)); spawn_local(async move { if let Ok(me) = api::get_me().await { auth_username.set(Some(me.username)); diff --git a/clients/web/src/portal/account.rs b/clients/web/src/portal/account.rs index 842338d..c986f38 100644 --- a/clients/web/src/portal/account.rs +++ b/clients/web/src/portal/account.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use leptos_router::hooks::use_navigate; use crate::api; +use crate::app::AuthEmailVerified; use crate::i18n::*; #[component] @@ -9,8 +10,8 @@ pub fn AccountPage() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let navigate = use_navigate(); // Only redirect to profile when the email is actually verified. @@ -107,8 +108,8 @@ fn LoginForm() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let navigate = use_navigate(); let login = RwSignal::new(String::new()); @@ -177,8 +178,8 @@ fn RegisterForm() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let username = RwSignal::new(String::new()); let email = RwSignal::new(String::new()); diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs index 686dac7..3dcde7e 100644 --- a/clients/web/src/portal/lobby.rs +++ b/clients/web/src/portal/lobby.rs @@ -3,7 +3,7 @@ use leptos::prelude::*; use leptos_router::components::A; use leptos_router::hooks::use_query_map; -use crate::app::{NetCommand, Screen}; +use crate::app::{AnonNickname, NetCommand, Screen}; use crate::i18n::*; // ── Room/nickname generation ────────────────────────────────────────────────── @@ -103,7 +103,7 @@ pub fn LobbyPage() -> impl IntoView { let cmd_tx = use_context::>().expect("NetCommand sender"); let auth_username = use_context::>>().expect("auth_username context"); let auth_loaded = use_context::>().expect("auth_loaded context"); - let anon_nickname = use_context::>>().expect("anon_nickname context"); + let anon_nickname = use_context::().expect("anon_nickname context").0; let query = use_query_map(); let view_state: RwSignal = RwSignal::new(LobbyView::Idle); diff --git a/clients/web/src/portal/verify_email.rs b/clients/web/src/portal/verify_email.rs index 0ce0cae..03736e2 100644 --- a/clients/web/src/portal/verify_email.rs +++ b/clients/web/src/portal/verify_email.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use leptos_router::hooks::use_query_map; use crate::api; +use crate::app::AuthEmailVerified; use crate::i18n::*; #[derive(Clone, PartialEq)] @@ -16,8 +17,8 @@ pub fn VerifyEmailPage() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let query = use_query_map(); let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default()); From 29a9c9b0a9e3bd8546e1be62ae053966c668574e Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sun, 24 May 2026 20:49:17 +0200 Subject: [PATCH 04/10] fix(relay-server): send email verification at login --- server/relay-server/src/http.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/relay-server/src/http.rs b/server/relay-server/src/http.rs index 9c6071f..c8701fc 100644 --- a/server/relay-server/src/http.rs +++ b/server/relay-server/src/http.rs @@ -245,6 +245,7 @@ async fn register( async fn login( mut auth_session: AuthSession, + State(state): State>, Json(body): Json, ) -> Result { let creds = Credentials { @@ -260,6 +261,18 @@ async fn login( auth_session.login(&user).await.map_err(|_| AppError::Internal)?; + if !user.email_verified { + let _ = db::delete_email_tokens(&state.db, user.id, "verify").await; + let token = generate_token(); + let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY; + if db::create_email_token(&state.db, user.id, &token, "verify", expires_at) + .await + .is_ok() + { + state.mailer.send_verification(&user.email, &token).await; + } + } + Ok(Json(MeResponse { id: user.id, username: user.username, From 58f5722551268ebe61a90c8911082aa89d5d0f45 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sun, 24 May 2026 21:33:47 +0200 Subject: [PATCH 05/10] feat(web client): sidebar links style --- clients/web/assets/style.css | 5 ++-- clients/web/src/app.rs | 53 ++++++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 09b21e9..dcc1b7b 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -2045,6 +2045,7 @@ a:hover { text-decoration: underline; } text-decoration: none; opacity: 0.8; transition: opacity 0.15s; + cursor: pointer; } .game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } @@ -2078,13 +2079,13 @@ a:hover { text-decoration: underline; } } /* Push the version wrapper to the bottom of the sidebar flex column */ -.game-sidebar > div:has(.site-nav-version) { +.sidebar-footer { margin-top: auto; - padding: 0.75rem 1rem; border-top: 1px solid rgba(200,164,72,0.12); } .site-nav-version { + margin: 2em 0 1em; display: block; text-align: center; font-family: var(--font-ui); diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 2749d9c..9288be3 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -42,9 +42,7 @@ fn relay_url() -> String { } #[cfg(not(debug_assertions))] { - let location = web_sys::window() - .and_then(|w| Some(w.location())) - .unwrap(); + let location = web_sys::window().and_then(|w| Some(w.location())).unwrap(); let protocol = location.protocol().unwrap_or_default(); let host = location.host().unwrap_or_default(); let ws_protocol = if protocol == "https:" { "wss" } else { "ws" }; @@ -565,47 +563,60 @@ fn SiteHamburger() -> impl IntoView { // Auth -
- - - - {move || match auth_username.get() { Some(u) => { let href = format!("/profile/{u}"); view! { + }.into_any() }, None => view! { + }.into_any(), }} -
+
"v" {VERSION}
+ // ── Replay snapshot modal ───────────────────────────────────────────── From 6fd3499d7b10f7cca4bbe0740a0ab3018f1e2f8b Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Mon, 25 May 2026 16:14:25 +0200 Subject: [PATCH 06/10] feat(web client): content pages --- Cargo.lock | 39 +++++++++++- clients/web/Cargo.toml | 1 + clients/web/assets/style.css | 88 ++++++++++++++++++++++++++ clients/web/pages/about/en.md | 13 ++++ clients/web/pages/about/fr.md | 13 ++++ clients/web/pages/readme.txt | 1 + clients/web/src/api.rs | 18 ++++++ clients/web/src/app.rs | 7 +- clients/web/src/portal/content_page.rs | 51 +++++++++++++++ clients/web/src/portal/mod.rs | 1 + justfile | 6 +- module.nix | 13 +++- server/relay-server/src/http.rs | 64 +++++++++++++++++++ server/relay-server/src/lobby.rs | 5 +- server/relay-server/src/main.rs | 3 +- 15 files changed, 312 insertions(+), 11 deletions(-) create mode 100644 clients/web/pages/about/en.md create mode 100644 clients/web/pages/about/fr.md create mode 100644 clients/web/pages/readme.txt create mode 100644 clients/web/src/portal/content_page.rs diff --git a/Cargo.lock b/Cargo.lock index 8992cbe..c1257d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -926,6 +926,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2654,6 +2663,25 @@ dependencies = [ "serde", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "qrcodegen" version = "1.8.0" @@ -3918,6 +3946,7 @@ dependencies = [ "leptos", "leptos_i18n", "leptos_router", + "pulldown-cmark", "qrcodegen", "rand 0.9.4", "serde", @@ -4034,6 +4063,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4358,7 +4393,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/clients/web/Cargo.toml b/clients/web/Cargo.toml index 1edb9eb..4b82427 100644 --- a/clients/web/Cargo.toml +++ b/clients/web/Cargo.toml @@ -19,6 +19,7 @@ futures = "0.3" rand = "0.9" gloo-storage = "0.3" qrcodegen = "1.8" +pulldown-cmark = "0.13" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2.118" diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index dcc1b7b..85455c3 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -2093,3 +2093,91 @@ a:hover { text-decoration: underline; } letter-spacing: 0.06em; color: rgba(200,164,72,0.4); } + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} diff --git a/clients/web/pages/about/en.md b/clients/web/pages/about/en.md new file mode 100644 index 0000000..81590e7 --- /dev/null +++ b/clients/web/pages/about/en.md @@ -0,0 +1,13 @@ +# About Trictrac + +Trictrac was one of the most popular French board games from the 16th to the 19th centuries. It is played with the same basic equipment and moves as modern backgammon (more or less), but is much more complex. The goal of the game is not to move out all your pieces before your opponent, but to reach a certain number of points by navigating through various game situations. + +## This Project + +This application allows you to play trictrac against a friend online or locally against a bot. The game engine is written in Rust and compiled into WebAssembly, and runs entirely in your browser. + +The source code is available at https://github.com/mmai/trictrac + +## Contact + +For any questions, bug reports, or feedback, please open an issue on the project repository. diff --git a/clients/web/pages/about/fr.md b/clients/web/pages/about/fr.md new file mode 100644 index 0000000..6a09579 --- /dev/null +++ b/clients/web/pages/about/fr.md @@ -0,0 +1,13 @@ +# À propos du Trictrac + +Le trictrac fut un des jeux de société français les plus populaires du XVIe au XIXe siècle. Il se joue avec le même matériel et mouvements de base que le backgammon moderne (à peu de choses près), mais est beaucoup plus complexe. Le but du jeu n'est pas de sortir toutes ses pièces avant l'adversaire, mais d'atteindre un certain nombre de points en réalisant travers de multiples situations de jeu. + +## Ce projet + +Cette application vous permet de jouer au trictrac contre un ami en ligne ou localement contre un bot. Le moteur de jeu est écrit en Rust et compilé en WebAssembly, et s'exécute entièrement dans votre navigateur. + +Le code source est disponible sur https://github.com/mmai/trictrac + +## Contact + +Pour toute question, rapport de bogue ou retour d'expérience, veuillez ouvrir une issue sur le dépôt du projet. diff --git a/clients/web/pages/readme.txt b/clients/web/pages/readme.txt new file mode 100644 index 0000000..ea3df35 --- /dev/null +++ b/clients/web/pages/readme.txt @@ -0,0 +1 @@ +Sync this folder to the PAGES_DIR directory of the server running `relay-server`. diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs index d826165..c3ae3c4 100644 --- a/clients/web/src/api.rs +++ b/clients/web/src/api.rs @@ -64,6 +64,12 @@ pub struct GameDetail { pub participants: Vec, } +#[derive(Clone, Debug, Deserialize)] +pub struct PageContent { + pub title: String, + pub content: String, +} + // ── Request bodies ──────────────────────────────────────────────────────────── #[derive(Serialize)] @@ -242,6 +248,18 @@ pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(), } } +pub async fn get_page(slug: &str, lang: &str) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/pages/{slug}?lang={lang}"))) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + // ── Utilities ───────────────────────────────────────────────────────────────── /// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`. diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 9288be3..c3ae904 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -21,9 +21,9 @@ use crate::game::trictrac::backend::TrictracBackend; use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState}; use crate::i18n::*; use crate::portal::{ - account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage, - lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage, - verify_email::VerifyEmailPage, + account::AccountPage, content_page::ContentPage, forgot_password::ForgotPasswordPage, + game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage, + reset_password::ResetPasswordPage, verify_email::VerifyEmailPage, }; use trictrac_store::CheckerMove; @@ -432,6 +432,7 @@ pub fn App() -> impl IntoView { + diff --git a/clients/web/src/portal/content_page.rs b/clients/web/src/portal/content_page.rs new file mode 100644 index 0000000..f44e3c0 --- /dev/null +++ b/clients/web/src/portal/content_page.rs @@ -0,0 +1,51 @@ +use leptos::prelude::*; +use leptos_router::hooks::use_params_map; +use pulldown_cmark::{Options, Parser, html}; + +use crate::api; +use crate::i18n::*; + +#[component] +pub fn ContentPage() -> impl IntoView { + let params = use_params_map(); + let slug = move || params.read().get("slug").unwrap_or_default(); + let i18n = use_i18n(); + + let page = LocalResource::new(move || { + let s = slug(); + let lang = match i18n.get_locale() { + Locale::en => "en", + Locale::fr => "fr", + }; + async move { api::get_page(&s, lang).await } + }); + + view! { +
+ {move || match page.get().map(|sw| sw.take()) { + None => view! { +

{t!(i18n, loading)}

+ }.into_any(), + Some(Err(_)) => view! { +

"Page not found."

+ }.into_any(), + Some(Ok(p)) => { + let html = md_to_html(&p.content); + view! { +
+ }.into_any() + } + }} +
+ } +} + +fn md_to_html(md: &str) -> String { + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_TABLES); + opts.insert(Options::ENABLE_STRIKETHROUGH); + let parser = Parser::new_ext(md, opts); + let mut output = String::new(); + html::push_html(&mut output, parser); + output +} diff --git a/clients/web/src/portal/mod.rs b/clients/web/src/portal/mod.rs index a270b5f..54a84d1 100644 --- a/clients/web/src/portal/mod.rs +++ b/clients/web/src/portal/mod.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod content_page; pub mod forgot_password; pub mod game_detail; pub mod lobby; diff --git a/justfile b/justfile index bc78103..baadfba 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,10 @@ bump version: git flow release start {{version}} @echo "Done. Finish with: git flow release finish {{version}}" +# Sync pages content to production server +pages-deploy: + rsync -av clients/web/pages/ raspberry:/var/lib/trictrac/pages/ + doc: cargo doc --no-deps shell: @@ -47,7 +51,7 @@ build: [working-directory: 'deploy'] run-relay: - ./relay-server + PAGES_DIR=../clients/web/pages ./relay-server build-relay: CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release diff --git a/module.nix b/module.nix index 53f77c6..28bec85 100644 --- a/module.nix +++ b/module.nix @@ -29,6 +29,12 @@ in description = "Web server protocol."; }; + pages_dir = mkOption { + type = types.str; + default = "/var/lib/trictrac/pages"; + description = "Directory containing content pages."; + }; + hostname = mkOption { type = types.str; default = "trictrac.localhost"; @@ -132,9 +138,9 @@ in # Explicit listen so this vhost isn't shadowed by a default_server # created by other virtual hosts with forceSSL = true. listen = [ - { addr = "0.0.0.0"; port = listenPort; ssl = withSSL; } - { addr = "[::]"; port = listenPort; ssl = withSSL; } - ]; + { addr = "0.0.0.0"; port = listenPort; ssl = withSSL; } + { addr = "[::]"; port = listenPort; ssl = withSSL; } + ]; locations."/" = { extraConfig = proxyConfig; proxyPass = "http://trictrac-api/"; @@ -195,6 +201,7 @@ in environment = { DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}"; APP_URL = "${cfg.protocol}://${cfg.hostname}"; + PAGES_DIR = cfg.pages_dir; SMTP_HOST = cfg.smtp.host; SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port else if cfg.smtp.tls then 465 else 1025); diff --git a/server/relay-server/src/http.rs b/server/relay-server/src/http.rs index c8701fc..b7dfedd 100644 --- a/server/relay-server/src/http.rs +++ b/server/relay-server/src/http.rs @@ -52,6 +52,7 @@ pub fn router() -> Router> { .route("/users/{username}/games", get(user_games)) .route("/games/result", post(game_result)) .route("/games/{id}", get(game_detail)) + .route("/pages/{slug}", get(get_page)) } // ── Token generation ────────────────────────────────────────────────────────── @@ -535,3 +536,66 @@ async fn game_result( Ok(Json(GameResultResponse { game_record_id })) } + +// ── Static content pages ────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct LangQuery { + #[serde(default = "default_lang")] + lang: String, +} + +fn default_lang() -> String { + "en".to_string() +} + +#[derive(Serialize)] +struct PageResponse { + title: String, + content: String, +} + +async fn get_page( + Path(slug): Path, + Query(query): Query, + State(state): State>, +) -> Result { + // Reject slugs with path-traversal characters or unusual lengths. + if slug.is_empty() + || slug.len() > 64 + || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(AppError::NotFound); + } + // Normalise lang to a safe identifier. + let lang = if !query.lang.is_empty() + && query.lang.len() <= 5 + && query.lang.chars().all(|c| c.is_ascii_alphabetic()) + { + query.lang.to_ascii_lowercase() + } else { + "en".to_string() + }; + + let base = std::path::Path::new(&state.pages_dir); + let primary = base.join(&slug).join(format!("{lang}.md")); + + let content = match tokio::fs::read_to_string(&primary).await { + Ok(c) => c, + Err(_) if lang != "en" => { + let fallback = base.join(&slug).join("en.md"); + tokio::fs::read_to_string(&fallback) + .await + .map_err(|_| AppError::NotFound)? + } + Err(_) => return Err(AppError::NotFound), + }; + + let title = content + .lines() + .find(|l| l.starts_with("# ")) + .map(|l| l[2..].trim().to_string()) + .unwrap_or_default(); + + Ok(Json(PageResponse { title, content })) +} diff --git a/server/relay-server/src/lobby.rs b/server/relay-server/src/lobby.rs index db1c4f8..db8f57c 100644 --- a/server/relay-server/src/lobby.rs +++ b/server/relay-server/src/lobby.rs @@ -63,15 +63,18 @@ pub struct AppState { pub db: Pool, /// SMTP mailer for email verification and password reset. pub mailer: Mailer, + /// Directory containing static content pages as `{slug}/{lang}.md` files. + pub pages_dir: String, } impl AppState { - pub fn new(db: Pool, mailer: Mailer) -> Self { + pub fn new(db: Pool, mailer: Mailer, pages_dir: String) -> Self { Self { rooms: Mutex::new(HashMap::new()), configs: RwLock::new(HashMap::new()), db, mailer, + pages_dir, } } } diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs index 32baf70..b416811 100644 --- a/server/relay-server/src/main.rs +++ b/server/relay-server/src/main.rs @@ -66,7 +66,8 @@ async fn main() { let auth_backend = AuthBackend::new(pool.clone()); let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build(); - let app_state = Arc::new(AppState::new(pool, mailer)); + let pages_dir = std::env::var("PAGES_DIR").unwrap_or_else(|_| "pages".to_string()); + let app_state = Arc::new(AppState::new(pool, mailer, pages_dir)); let watchdog_state = app_state.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min From 20b8353cfbcb2859fb168ea010bcd7086b8043b8 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Mon, 25 May 2026 17:12:23 +0200 Subject: [PATCH 07/10] feat(server): user account deletion --- clients/web/assets/style.css | 56 +++++++++++++++ clients/web/locales/en.json | 9 ++- clients/web/locales/fr.json | 9 ++- clients/web/src/api.rs | 13 ++++ clients/web/src/app.rs | 28 ++++++++ clients/web/src/portal/profile.rs | 109 +++++++++++++++++++++++++++++- server/relay-server/src/db.rs | 19 ++++++ server/relay-server/src/http.rs | 13 +++- server/relay-server/src/main.rs | 2 +- 9 files changed, 252 insertions(+), 6 deletions(-) diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 85455c3..8d6d009 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -305,6 +305,62 @@ 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; } +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + .portal-link { color: var(--ui-gold); text-decoration: none; diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 03ba37c..978d902 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -140,5 +140,12 @@ "nickname_modal_sign_in": "Sign in", "nickname_modal_register": "Create account", "new_game": "New game", - "language": "Language" + "language": "Language", + "delete_account_title": "Danger zone", + "delete_account_btn": "Delete my account", + "delete_account_warning": "This action is irreversible. Your account will be permanently deleted.", + "delete_account_confirm_label": "Type your username to confirm:", + "delete_account_confirm_btn": "Delete permanently", + "delete_account_mismatch": "Username does not match.", + "account_deleted": "Your account has been permanently deleted." } diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index d429838..f41446b 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -138,5 +138,12 @@ "nickname_modal_sign_in": "connectez-vous", "nickname_modal_register": "Créer un compte", "new_game": "Nouvelle partie", - "language": "Langue" + "language": "Langue", + "delete_account_title": "Zone de danger", + "delete_account_btn": "Supprimer mon compte", + "delete_account_warning": "Cette action est irréversible. Votre compte sera définitivement supprimé.", + "delete_account_confirm_label": "Tapez votre nom d'utilisateur pour confirmer :", + "delete_account_confirm_btn": "Supprimer définitivement", + "delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.", + "account_deleted": "Votre compte a été définitivement supprimé." } diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs index c3ae3c4..2452b67 100644 --- a/clients/web/src/api.rs +++ b/clients/web/src/api.rs @@ -147,6 +147,19 @@ pub async fn post_logout() -> Result<(), String> { } } +pub async fn delete_account() -> Result<(), String> { + let resp = gloo_net::http::Request::delete(&url("/auth/account")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 204 { + Ok(()) + } else { + Err(format!("status {}", resp.status())) + } +} + pub async fn get_user_profile(username: &str) -> Result { let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}"))) .credentials(web_sys::RequestCredentials::Include) diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index c3ae904..6e2186f 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -34,6 +34,9 @@ use std::collections::VecDeque; pub(crate) struct AnonNickname(pub RwSignal>); #[derive(Clone, Copy)] pub(crate) struct AuthEmailVerified(pub RwSignal); +/// One-shot message shown as a top banner and auto-dismissed after a few seconds. +#[derive(Clone, Copy)] +pub(crate) struct FlashMessage(pub RwSignal>); fn relay_url() -> String { #[cfg(debug_assertions)] @@ -182,6 +185,8 @@ pub fn App() -> impl IntoView { // Nickname chosen by an anonymous player; used instead of "Anonymous". let anon_nickname: RwSignal> = RwSignal::new(None); provide_context(AnonNickname(anon_nickname)); + let flash: RwSignal> = RwSignal::new(None); + provide_context(FlashMessage(flash)); spawn_local(async move { if let Ok(me) = api::get_me().await { auth_username.set(Some(me.username)); @@ -423,6 +428,7 @@ pub fn App() -> impl IntoView { view! { +
"Page not found."

}> @@ -441,6 +447,28 @@ pub fn App() -> impl IntoView { } } +/// Fixed top banner that shows a flash message and auto-dismisses after 5 seconds. +#[component] +fn FlashBanner() -> impl IntoView { + let flash = use_context::().expect("FlashMessage context not found").0; + + Effect::new(move |_| { + if flash.get().is_some() { + spawn_local(async move { + gloo_timers::future::TimeoutFuture::new(5_000).await; + flash.set(None); + }); + } + }); + + move || flash.get().map(|msg| view! { +
+ { msg } + +
+ }) +} + /// Renders the full-screen game overlay, but only when the current route is "/". /// This lets the user navigate to profile/account pages while a game is running. #[component] diff --git a/clients/web/src/portal/profile.rs b/clients/web/src/portal/profile.rs index c727bbd..ac11bd6 100644 --- a/clients/web/src/portal/profile.rs +++ b/clients/web/src/portal/profile.rs @@ -1,7 +1,8 @@ use leptos::prelude::*; -use leptos_router::{components::A, hooks::use_params_map}; +use leptos_router::{components::A, hooks::use_navigate, hooks::use_params_map}; use crate::api::{self, GameSummary, UserProfile}; +use crate::app::{AuthEmailVerified, FlashMessage}; use crate::i18n::*; #[component] @@ -30,6 +31,8 @@ pub fn ProfilePage() -> impl IntoView { #[component] fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView { let i18n = use_i18n(); + let auth_username = + use_context::>>().expect("auth_username context not found"); let page = RwSignal::new(0i64); let games = LocalResource::new(move || { let u = username.clone(); @@ -46,7 +49,9 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView { time_style: None, }; let joined = api::format_ts(profile.created_at, locale_tag, &date_format); - // let joined = api::format_ts(profile.created_at, locale_tag, &api::DateFormatOptions::date_only()); + + let profile_username = profile.username.clone(); + let is_own_profile = move || auth_username.get().as_deref() == Some(&profile_username); view! {
@@ -83,6 +88,106 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView { } }}
+ + {move || if is_own_profile() { + let uname = profile.username.clone(); + view! { }.into_any() + } else { + view! { }.into_any() + }} + } +} + +#[component] +fn DeleteAccountSection(username: String) -> impl IntoView { + let i18n = use_i18n(); + let auth_username = + use_context::>>().expect("auth_username context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; + let flash = use_context::().expect("FlashMessage context not found").0; + let navigate = use_navigate(); + + let confirming = RwSignal::new(false); + let confirm_input = RwSignal::new(String::new()); + let error = RwSignal::new(String::new()); + let pending = RwSignal::new(false); + + view! { +
+

{t!(i18n, delete_account_title)}

+ {move || if !confirming.get() { + view! { +
+

+ {t!(i18n, delete_account_warning)} +

+ +
+ }.into_any() + } else { + // Define submit fresh each reactive call so the closure is FnMut-compatible. + let expected = username.clone(); + let nav = navigate.clone(); + let submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + if pending.get() { return; } + error.set(String::new()); + + if confirm_input.get() != expected { + error.set(t_string!(i18n, delete_account_mismatch).to_string()); + return; + } + + pending.set(true); + let nav = nav.clone(); + wasm_bindgen_futures::spawn_local(async move { + match api::delete_account().await { + Ok(()) => { + auth_username.set(None); + auth_email_verified.set(false); + flash.set(Some(t_string!(i18n, account_deleted).to_string())); + nav("/", Default::default()); + } + Err(e) => { + error.set(e); + pending.set(false); + } + } + }); + }; + view! { +
+

+ {t!(i18n, delete_account_warning)} +

+ + + {move || if !error.get().is_empty() { + view! {

{ error.get() }

}.into_any() + } else { + view! { }.into_any() + }} +
+ + +
+ + }.into_any() + }} +
} } diff --git a/server/relay-server/src/db.rs b/server/relay-server/src/db.rs index 83b9f25..0b9c878 100644 --- a/server/relay-server/src/db.rs +++ b/server/relay-server/src/db.rs @@ -188,6 +188,25 @@ pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Resu Ok(()) } +/// Permanently deletes a user and their auth data. +/// Game history rows are kept but de-associated (user_id set to NULL). +pub async fn delete_user(pool: &Pool, user_id: i64) -> Result<(), DbError> { + let client = pool.get().await?; + client + .execute( + "UPDATE game_participants SET user_id = NULL WHERE user_id = $1", + &[&user_id], + ) + .await?; + client + .execute("DELETE FROM email_tokens WHERE user_id = $1", &[&user_id]) + .await?; + client + .execute("DELETE FROM users WHERE id = $1", &[&user_id]) + .await?; + Ok(()) +} + // ── Email tokens ────────────────────────────────────────────────────────────── pub async fn create_email_token( diff --git a/server/relay-server/src/http.rs b/server/relay-server/src/http.rs index b7dfedd..0104c76 100644 --- a/server/relay-server/src/http.rs +++ b/server/relay-server/src/http.rs @@ -19,7 +19,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, - routing::{get, post}, + routing::{delete, get, post}, }; use axum_login::AuthSession; use rand::distributions::Alphanumeric; @@ -48,6 +48,7 @@ pub fn router() -> Router> { .route("/auth/resend-verification", post(resend_verification)) .route("/auth/forgot-password", post(forgot_password)) .route("/auth/reset-password", post(reset_password)) + .route("/auth/account", delete(delete_account)) .route("/users/{username}", get(user_profile)) .route("/users/{username}/games", get(user_games)) .route("/games/result", post(game_result)) @@ -286,6 +287,16 @@ async fn logout(mut auth_session: AuthSession) -> Result, + State(state): State>, +) -> Result { + let user = auth_session.user.clone().ok_or(AppError::Unauthorized)?; + auth_session.logout().await.map_err(|_| AppError::Internal)?; + db::delete_user(&state.db, user.id).await?; + Ok(StatusCode::NO_CONTENT) +} + async fn me(auth_session: AuthSession) -> Result { match auth_session.user { Some(user) => Ok(Json(MeResponse { diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs index b416811..367ef98 100644 --- a/server/relay-server/src/main.rs +++ b/server/relay-server/src/main.rs @@ -87,7 +87,7 @@ async fn main() { .allow_origin(AllowOrigin::list([ "http://localhost:9091".parse().unwrap(), // unified web dev server ])) - .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) .allow_headers([ HeaderName::from_static("content-type"), HeaderName::from_static("cookie"), From 9e2ff3a9f1895342652751d587f94fd590c26c64 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Mon, 25 May 2026 20:52:11 +0200 Subject: [PATCH 08/10] feat(web client): about & legal links --- clients/web/assets/style.css | 14 ++++++++++++++ clients/web/locales/en.json | 4 +++- clients/web/locales/fr.json | 4 +++- clients/web/pages/about/en.md | 15 +++++++-------- clients/web/pages/about/fr.md | 15 +++++++-------- clients/web/pages/legal/en.md | 26 ++++++++++++++++++++++++++ clients/web/pages/legal/fr.md | 28 ++++++++++++++++++++++++++++ clients/web/src/app.rs | 27 ++++++++++++++++++++------- 8 files changed, 108 insertions(+), 25 deletions(-) create mode 100644 clients/web/pages/legal/en.md create mode 100644 clients/web/pages/legal/fr.md diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 8d6d009..1d4cc77 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -2140,6 +2140,20 @@ a:hover { text-decoration: underline; } border-top: 1px solid rgba(200,164,72,0.12); } +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + .site-nav-version { margin: 2em 0 1em; display: block; diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 978d902..1e5fbc2 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -147,5 +147,7 @@ "delete_account_confirm_label": "Type your username to confirm:", "delete_account_confirm_btn": "Delete permanently", "delete_account_mismatch": "Username does not match.", - "account_deleted": "Your account has been permanently deleted." + "account_deleted": "Your account has been permanently deleted.", + "about": "About", + "legal": "Legal notices" } diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index f41446b..28ae43c 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -145,5 +145,7 @@ "delete_account_confirm_label": "Tapez votre nom d'utilisateur pour confirmer :", "delete_account_confirm_btn": "Supprimer définitivement", "delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.", - "account_deleted": "Votre compte a été définitivement supprimé." + "account_deleted": "Votre compte a été définitivement supprimé.", + "about": "À propos", + "legal": "Mentions légales" } diff --git a/clients/web/pages/about/en.md b/clients/web/pages/about/en.md index 81590e7..27a382e 100644 --- a/clients/web/pages/about/en.md +++ b/clients/web/pages/about/en.md @@ -1,13 +1,12 @@ -# About Trictrac +# About -Trictrac was one of the most popular French board games from the 16th to the 19th centuries. It is played with the same basic equipment and moves as modern backgammon (more or less), but is much more complex. The goal of the game is not to move out all your pieces before your opponent, but to reach a certain number of points by navigating through various game situations. +This application allows you to play [trictrac](https://en.wikipedia.org/wiki/Trictrac) against a friend online or locally against a bot. -## This Project +The source code is available at [github.com/mmai/trictrac](https://github.com/mmai/trictrac) +The application is self-hosted and runs on a simple Raspberry Pi. -This application allows you to play trictrac against a friend online or locally against a bot. The game engine is written in Rust and compiled into WebAssembly, and runs entirely in your browser. +## Contact & bug Report -The source code is available at https://github.com/mmai/trictrac +For any questions, bug reports, or feedback, you can contact me at rhumbs@rhumbs.fr. -## Contact - -For any questions, bug reports, or feedback, please open an issue on the project repository. +If you encounter an issue during gameplay, you can copy the context of a game by clicking _Take snapshot_ then paste the resulting code into your message, specifying the expected behavior and the incorrect behavior you observed. diff --git a/clients/web/pages/about/fr.md b/clients/web/pages/about/fr.md index 6a09579..1c3ec74 100644 --- a/clients/web/pages/about/fr.md +++ b/clients/web/pages/about/fr.md @@ -1,13 +1,12 @@ -# À propos du Trictrac +# À propos -Le trictrac fut un des jeux de société français les plus populaires du XVIe au XIXe siècle. Il se joue avec le même matériel et mouvements de base que le backgammon moderne (à peu de choses près), mais est beaucoup plus complexe. Le but du jeu n'est pas de sortir toutes ses pièces avant l'adversaire, mais d'atteindre un certain nombre de points en réalisant travers de multiples situations de jeu. +Cette application vous permet de jouer au [trictrac](https://fr.wikipedia.org/wiki/Trictrac) contre un ami en ligne ou localement contre un bot. -## Ce projet +Le code source est disponible sur [github.com/mmai/trictrac](https://github.com/mmai/trictrac). +L'application est auto hébergée et tourne sur un simple Raspberry Pi. -Cette application vous permet de jouer au trictrac contre un ami en ligne ou localement contre un bot. Le moteur de jeu est écrit en Rust et compilé en WebAssembly, et s'exécute entièrement dans votre navigateur. +## Contact et rapport de bogue -Le code source est disponible sur https://github.com/mmai/trictrac +Pour toute question, rapport de bogue ou retour d'expérience, vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr. -## Contact - -Pour toute question, rapport de bogue ou retour d'expérience, veuillez ouvrir une issue sur le dépôt du projet. +Si vous constatez une anomalie en cours de jeu, vous pouvez copier le contexte d'une partie en cliquant sur _Prendre un instantané_, puis coller le code obtenu dans le message, en précisant le comportement auquel vous vous attendiez, et le comportement erroné constaté. diff --git a/clients/web/pages/legal/en.md b/clients/web/pages/legal/en.md new file mode 100644 index 0000000..ff72761 --- /dev/null +++ b/clients/web/pages/legal/en.md @@ -0,0 +1,26 @@ +# Legal Notices + +## Data and Privacy + +This site does not use third-party analytics or advertising trackers. + +If you create an account, your username, email address, and argon2-hashed password are stored in a database on our server. Your email address is used only to send you account-related messages (email verification, password reset). It is never shared with third parties. + +Game records (room codes, move history, outcomes) may be stored to display game history on your profile page. + +## Cookies and Sessions + +A session cookie is stored in your browser when you sign in. It is used solely to keep you authenticated and expires after 30 days of inactivity. + +## Contact + +The website is created by + +Henri Bourcereau\ +7 rue Lugeol\ +33000 Bordeaux\ +France + +It is hosted at the same address. + +You can contact me at rhumbs@rhumbs.fr diff --git a/clients/web/pages/legal/fr.md b/clients/web/pages/legal/fr.md new file mode 100644 index 0000000..43f85d5 --- /dev/null +++ b/clients/web/pages/legal/fr.md @@ -0,0 +1,28 @@ +# Mentions légales + +## Données et vie privée + +Ce site n'utilise pas d'outils d'analyse tiers ni de traceurs publicitaires. + +Si vous créez un compte, votre nom d'utilisateur, votre adresse e-mail et votre mot de passe haché (argon2) sont stockés dans une base de données sur notre serveur. Votre adresse e-mail est utilisée uniquement pour vous envoyer des messages liés à votre compte (vérification d'e-mail, réinitialisation de mot de passe). Elle n'est jamais partagée avec des tiers. + +Les enregistrements de parties (codes de salle, historique des coups, résultats) peuvent être conservés afin d'afficher l'historique des parties sur votre page de profil. + +Vous pouvez supprimer votre compte et la totalité des données associées depuis votre page de profil. + +## Cookies et sessions + +Un cookie de session est stocké dans votre navigateur lorsque vous vous connectez. Il sert uniquement à maintenir votre authentification et expire après 30 jours d'inactivité. + +## Contact + +Le site est réalisé par + +Henri Bourcereau\ +7 rue Lugeol\ +33000 Bordeaux\ +France + +Il est hébergé à la même adresse. + +Vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 6e2186f..ba90a54 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -450,7 +450,9 @@ pub fn App() -> impl IntoView { /// Fixed top banner that shows a flash message and auto-dismisses after 5 seconds. #[component] fn FlashBanner() -> impl IntoView { - let flash = use_context::().expect("FlashMessage context not found").0; + let flash = use_context::() + .expect("FlashMessage context not found") + .0; Effect::new(move |_| { if flash.get().is_some() { @@ -461,12 +463,16 @@ fn FlashBanner() -> impl IntoView { } }); - move || flash.get().map(|msg| view! { -
- { msg } - -
- }) + move || { + flash.get().map(|msg| { + view! { +
+ { msg } + +
+ } + }) + } } /// Renders the full-screen game overlay, but only when the current route is "/". @@ -688,6 +694,13 @@ fn SiteHamburger() -> impl IntoView { sidebar_open.set(false); }>{t!(i18n, replay_snapshot)}
+
"v" {VERSION}
From 50f5c43a21c8417d051bf9390e78b293c680ddce Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Mon, 25 May 2026 21:01:01 +0200 Subject: [PATCH 09/10] chore(nix): update web client front package hash --- container/flake.lock | 6 +++--- flake.nix | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/container/flake.lock b/container/flake.lock index 073ffc3..d81bd13 100644 --- a/container/flake.lock +++ b/container/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1778430510, - "narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=", + "lastModified": 1779467186, + "narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575", + "rev": "b77b3de8775677f84492abe84635f87b0e153f0f", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 62b1eac..7cdabb7 100644 --- a/flake.nix +++ b/flake.nix @@ -56,7 +56,7 @@ frontendCargoDeps = rustPlatform.fetchCargoVendor { src = ./.; name = "trictrac-frontend-vendor"; - hash = "sha256-neJh0ZQGa5LNY8vBu3kYkM+ARkXOW/EHx8sPBOsWsgE="; + hash = "sha256-E3MJbEehbXni7B8fQPS8fSri4f2b0A33r2djiK81E2Y="; }; in final.stdenv.mkDerivation { From 68a85353977fc5a0e3223172843bd3692412d78f Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Mon, 25 May 2026 21:01:17 +0200 Subject: [PATCH 10/10] chore: bump version to 0.2.13 --- Cargo.toml | 2 +- flake.nix | 2 +- justfile | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d722f4f..a468cd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.2.12" +version = "0.2.13" [workspace] resolver = "2" diff --git a/flake.nix b/flake.nix index 7cdabb7..93b33d8 100644 --- a/flake.nix +++ b/flake.nix @@ -103,7 +103,7 @@ trictrac = with final; rustPlatform.buildRustPackage { pname = "trictrac"; - version = "0.2.12"; # trictrac-version + version = "0.2.13"; # trictrac-version src = ./.; nativeBuildInputs = [ pkg-config ]; diff --git a/justfile b/justfile index baadfba..3ae77d1 100644 --- a/justfile +++ b/justfile @@ -11,7 +11,6 @@ bump version: sed -i 's/version = "[0-9.]*"; # trictrac-version/version = "{{version}}"; # trictrac-version/' flake.nix git add Cargo.toml flake.nix git commit -m "chore: bump version to {{version}}" - git flow release start {{version}} @echo "Done. Finish with: git flow release finish {{version}}" # Sync pages content to production server