Merge branch 'release/0.2.13'
This commit is contained in:
commit
849b31dbb1
29 changed files with 765 additions and 225 deletions
49
Cargo.lock
generated
49
Cargo.lock
generated
|
|
@ -189,7 +189,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backbone-lib"
|
name = "backbone-lib"
|
||||||
version = "0.2.11"
|
version = "0.2.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"ewebsock",
|
"ewebsock",
|
||||||
|
|
@ -738,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -926,6 +926,15 @@ dependencies = [
|
||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -2649,11 +2658,30 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "protocol"
|
name = "protocol"
|
||||||
version = "0.2.11"
|
version = "0.2.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "qrcodegen"
|
name = "qrcodegen"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
|
@ -2883,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relay-server"
|
name = "relay-server"
|
||||||
version = "0.2.11"
|
version = "0.2.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -3893,7 +3921,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trictrac-store"
|
name = "trictrac-store"
|
||||||
version = "0.2.11"
|
version = "0.2.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
|
|
@ -3906,7 +3934,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trictrac-web"
|
name = "trictrac-web"
|
||||||
version = "0.2.11"
|
version = "0.2.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backbone-lib",
|
"backbone-lib",
|
||||||
"futures",
|
"futures",
|
||||||
|
|
@ -3918,6 +3946,7 @@ dependencies = [
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_i18n",
|
"leptos_i18n",
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
|
"pulldown-cmark",
|
||||||
"qrcodegen",
|
"qrcodegen",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -4034,6 +4063,12 @@ version = "1.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|
@ -4358,7 +4393,7 @@ version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.12"
|
version = "0.2.13"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
|
||||||
116
README.md
116
README.md
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation.
|
This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation.
|
||||||
|
|
||||||
The project is still on its early stages.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands.
|
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
|
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.
|
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.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## Code structure
|
## Code structure
|
||||||
|
|
||||||
- game rules and game state are implemented in the _store/_ folder.
|
- 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 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
|
The web client UX/UI is inspired by https://playtiao.com.
|
||||||
|
|
||||||
`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 (<https://burn.dev/>).
|
|
||||||
- `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` |
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ futures = "0.3"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
gloo-storage = "0.3"
|
gloo-storage = "0.3"
|
||||||
qrcodegen = "1.8"
|
qrcodegen = "1.8"
|
||||||
|
pulldown-cmark = "0.13"
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
wasm-bindgen = "0.2.118"
|
wasm-bindgen = "0.2.118"
|
||||||
|
|
|
||||||
|
|
@ -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-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; }
|
.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 {
|
.portal-link {
|
||||||
color: var(--ui-gold);
|
color: var(--ui-gold);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -2045,6 +2101,7 @@ a:hover { text-decoration: underline; }
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; }
|
.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
|
||||||
|
|
@ -2078,13 +2135,27 @@ a:hover { text-decoration: underline; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Push the version wrapper to the bottom of the sidebar flex column */
|
/* Push the version wrapper to the bottom of the sidebar flex column */
|
||||||
.game-sidebar > div:has(.site-nav-version) {
|
.sidebar-footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-top: 1px solid rgba(200,164,72,0.12);
|
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 {
|
.site-nav-version {
|
||||||
|
margin: 2em 0 1em;
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
|
|
@ -2092,3 +2163,91 @@ a:hover { text-decoration: underline; }
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: rgba(200,164,72,0.4);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,5 +140,14 @@
|
||||||
"nickname_modal_sign_in": "Sign in",
|
"nickname_modal_sign_in": "Sign in",
|
||||||
"nickname_modal_register": "Create account",
|
"nickname_modal_register": "Create account",
|
||||||
"new_game": "New game",
|
"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.",
|
||||||
|
"about": "About",
|
||||||
|
"legal": "Legal notices"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,15 +130,22 @@
|
||||||
"copy_link": "Copier le lien",
|
"copy_link": "Copier le lien",
|
||||||
"link_copied": "Copié !",
|
"link_copied": "Copié !",
|
||||||
"scan_qr": "ou scannez le QR code",
|
"scan_qr": "ou scannez le QR code",
|
||||||
"join_code_label": "Rejoindre avec un code",
|
|
||||||
"join_code_placeholder": "Code de la salle",
|
|
||||||
"share_btn": "Partager",
|
"share_btn": "Partager",
|
||||||
"nickname_modal_title": "Choisissez votre pseudo",
|
"nickname_modal_title": "Choisissez votre pseudo",
|
||||||
"nickname_modal_hint": "Vous jouerez sous le nom de :",
|
"nickname_modal_hint": "Vous jouerez sous le nom de :",
|
||||||
"nickname_modal_play": "Jouer",
|
"nickname_modal_play": "Jouer",
|
||||||
"nickname_modal_or": "ou",
|
"nickname_modal_or": "ou",
|
||||||
"nickname_modal_sign_in": "Se connecter",
|
"nickname_modal_sign_in": "connectez-vous",
|
||||||
"nickname_modal_register": "Créer un compte",
|
"nickname_modal_register": "Créer un compte",
|
||||||
"new_game": "Nouvelle partie",
|
"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é.",
|
||||||
|
"about": "À propos",
|
||||||
|
"legal": "Mentions légales"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
clients/web/pages/about/en.md
Normal file
12
clients/web/pages/about/en.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# About
|
||||||
|
|
||||||
|
This application allows you to play [trictrac](https://en.wikipedia.org/wiki/Trictrac) against a friend online or locally against a bot.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Contact & bug Report
|
||||||
|
|
||||||
|
For any questions, bug reports, or feedback, you can contact me at rhumbs@rhumbs.fr.
|
||||||
|
|
||||||
|
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.
|
||||||
12
clients/web/pages/about/fr.md
Normal file
12
clients/web/pages/about/fr.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# À propos
|
||||||
|
|
||||||
|
Cette application vous permet de jouer au [trictrac](https://fr.wikipedia.org/wiki/Trictrac) contre un ami en ligne ou localement contre un bot.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Contact et rapport de bogue
|
||||||
|
|
||||||
|
Pour toute question, rapport de bogue ou retour d'expérience, vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr.
|
||||||
|
|
||||||
|
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é.
|
||||||
26
clients/web/pages/legal/en.md
Normal file
26
clients/web/pages/legal/en.md
Normal file
|
|
@ -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
|
||||||
28
clients/web/pages/legal/fr.md
Normal file
28
clients/web/pages/legal/fr.md
Normal file
|
|
@ -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
|
||||||
1
clients/web/pages/readme.txt
Normal file
1
clients/web/pages/readme.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Sync this folder to the PAGES_DIR directory of the server running `relay-server`.
|
||||||
|
|
@ -64,6 +64,12 @@ pub struct GameDetail {
|
||||||
pub participants: Vec<Participant>,
|
pub participants: Vec<Participant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct PageContent {
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Request bodies ────────────────────────────────────────────────────────────
|
// ── Request bodies ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -141,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<UserProfile, String> {
|
pub async fn get_user_profile(username: &str) -> Result<UserProfile, String> {
|
||||||
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
|
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
|
||||||
.credentials(web_sys::RequestCredentials::Include)
|
.credentials(web_sys::RequestCredentials::Include)
|
||||||
|
|
@ -242,6 +261,18 @@ pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_page(slug: &str, lang: &str) -> Result<PageContent, String> {
|
||||||
|
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::<PageContent>().await.map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
Err(format!("status {}", resp.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`.
|
/// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`.
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,37 @@ use crate::game::trictrac::backend::TrictracBackend;
|
||||||
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
use crate::portal::{
|
use crate::portal::{
|
||||||
account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage,
|
account::AccountPage, content_page::ContentPage, forgot_password::ForgotPasswordPage,
|
||||||
lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage,
|
game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage,
|
||||||
verify_email::VerifyEmailPage,
|
reset_password::ResetPasswordPage, verify_email::VerifyEmailPage,
|
||||||
};
|
};
|
||||||
use trictrac_store::CheckerMove;
|
use trictrac_store::CheckerMove;
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
const RELAY_URL: &str = "ws://localhost:8080/ws";
|
/// Newtype wrappers so context lookup can distinguish signals of the same inner type.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct AnonNickname(pub RwSignal<Option<String>>);
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct AuthEmailVerified(pub RwSignal<bool>);
|
||||||
|
/// One-shot message shown as a top banner and auto-dismissed after a few seconds.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct FlashMessage(pub RwSignal<Option<String>>);
|
||||||
|
|
||||||
|
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 GAME_ID: &str = "trictrac";
|
||||||
const STORAGE_KEY: &str = "trictrac_session";
|
const STORAGE_KEY: &str = "trictrac_session";
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
@ -155,14 +177,16 @@ pub fn App() -> impl IntoView {
|
||||||
let auth_username: RwSignal<Option<String>> = RwSignal::new(None);
|
let auth_username: RwSignal<Option<String>> = RwSignal::new(None);
|
||||||
let auth_email_verified: RwSignal<bool> = RwSignal::new(false);
|
let auth_email_verified: RwSignal<bool> = RwSignal::new(false);
|
||||||
provide_context(auth_username);
|
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
|
// Set to true once get_me resolves (success or failure) so lobby can
|
||||||
// decide immediately whether to show the nickname modal.
|
// decide immediately whether to show the nickname modal.
|
||||||
let auth_loaded: RwSignal<bool> = RwSignal::new(false);
|
let auth_loaded: RwSignal<bool> = RwSignal::new(false);
|
||||||
provide_context(auth_loaded);
|
provide_context(auth_loaded);
|
||||||
// Nickname chosen by an anonymous player; used instead of "Anonymous".
|
// Nickname chosen by an anonymous player; used instead of "Anonymous".
|
||||||
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
|
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
|
||||||
provide_context(anon_nickname);
|
provide_context(AnonNickname(anon_nickname));
|
||||||
|
let flash: RwSignal<Option<String>> = RwSignal::new(None);
|
||||||
|
provide_context(FlashMessage(flash));
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(me) = api::get_me().await {
|
if let Ok(me) = api::get_me().await {
|
||||||
auth_username.set(Some(me.username));
|
auth_username.set(Some(me.username));
|
||||||
|
|
@ -205,7 +229,7 @@ pub fn App() -> impl IntoView {
|
||||||
Some(NetCommand::CreateRoom { room }) => {
|
Some(NetCommand::CreateRoom { room }) => {
|
||||||
break Some((
|
break Some((
|
||||||
RoomConfig {
|
RoomConfig {
|
||||||
relay_url: RELAY_URL.to_string(),
|
relay_url: relay_url(),
|
||||||
game_id: GAME_ID.to_string(),
|
game_id: GAME_ID.to_string(),
|
||||||
room_id: room,
|
room_id: room,
|
||||||
rule_variation: 0,
|
rule_variation: 0,
|
||||||
|
|
@ -219,7 +243,7 @@ pub fn App() -> impl IntoView {
|
||||||
Some(NetCommand::JoinRoom { room }) => {
|
Some(NetCommand::JoinRoom { room }) => {
|
||||||
break Some((
|
break Some((
|
||||||
RoomConfig {
|
RoomConfig {
|
||||||
relay_url: RELAY_URL.to_string(),
|
relay_url: relay_url(),
|
||||||
game_id: GAME_ID.to_string(),
|
game_id: GAME_ID.to_string(),
|
||||||
room_id: room,
|
room_id: room,
|
||||||
rule_variation: 0,
|
rule_variation: 0,
|
||||||
|
|
@ -304,7 +328,7 @@ pub fn App() -> impl IntoView {
|
||||||
|
|
||||||
if !session.is_host {
|
if !session.is_host {
|
||||||
save_session(&StoredSession {
|
save_session(&StoredSession {
|
||||||
relay_url: RELAY_URL.to_string(),
|
relay_url: relay_url(),
|
||||||
game_id: GAME_ID.to_string(),
|
game_id: GAME_ID.to_string(),
|
||||||
room_id: room_id_for_storage.clone(),
|
room_id: room_id_for_storage.clone(),
|
||||||
token: session.reconnect_token,
|
token: session.reconnect_token,
|
||||||
|
|
@ -358,7 +382,7 @@ pub fn App() -> impl IntoView {
|
||||||
|
|
||||||
if is_host {
|
if is_host {
|
||||||
save_session(&StoredSession {
|
save_session(&StoredSession {
|
||||||
relay_url: RELAY_URL.to_string(),
|
relay_url: relay_url(),
|
||||||
game_id: GAME_ID.to_string(),
|
game_id: GAME_ID.to_string(),
|
||||||
room_id: room_id_for_storage.clone(),
|
room_id: room_id_for_storage.clone(),
|
||||||
token: reconnect_token,
|
token: reconnect_token,
|
||||||
|
|
@ -404,6 +428,7 @@ pub fn App() -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<Router>
|
<Router>
|
||||||
<SiteHamburger />
|
<SiteHamburger />
|
||||||
|
<FlashBanner />
|
||||||
<main>
|
<main>
|
||||||
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
||||||
<Route path=path!("/") view=LobbyPage />
|
<Route path=path!("/") view=LobbyPage />
|
||||||
|
|
@ -413,6 +438,7 @@ pub fn App() -> impl IntoView {
|
||||||
<Route path=path!("/verify-email") view=VerifyEmailPage />
|
<Route path=path!("/verify-email") view=VerifyEmailPage />
|
||||||
<Route path=path!("/forgot-password") view=ForgotPasswordPage />
|
<Route path=path!("/forgot-password") view=ForgotPasswordPage />
|
||||||
<Route path=path!("/reset-password") view=ResetPasswordPage />
|
<Route path=path!("/reset-password") view=ResetPasswordPage />
|
||||||
|
<Route path=path!("/page/:slug") view=ContentPage />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -421,6 +447,34 @@ 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::<FlashMessage>()
|
||||||
|
.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! {
|
||||||
|
<div class="flash-banner">
|
||||||
|
<span>{ msg }</span>
|
||||||
|
<button class="flash-dismiss" on:click=move |_| flash.set(None)>"✕"</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders the full-screen game overlay, but only when the current route is "/".
|
/// 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.
|
/// This lets the user navigate to profile/account pages while a game is running.
|
||||||
#[component]
|
#[component]
|
||||||
|
|
@ -544,47 +598,60 @@ fn SiteHamburger() -> impl IntoView {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
<div class="game-sidebar-section">
|
|
||||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
|
||||||
<path fill="currentColor" d="M240 192C240 147.8 275.8 112 320 112C364.2 112 400 147.8 400 192C400 236.2 364.2 272 320 272C275.8 272 240 236.2 240 192zM448 192C448 121.3 390.7 64 320 64C249.3 64 192 121.3 192 192C192 262.7 249.3 320 320 320C390.7 320 448 262.7 448 192zM144 544C144 473.3 201.3 416 272 416L368 416C438.7 416 496 473.3 496 544L496 552C496 565.3 506.7 576 520 576C533.3 576 544 565.3 544 552L544 544C544 446.8 465.2 368 368 368L272 368C174.8 368 96 446.8 96 544L96 552C96 565.3 106.7 576 120 576C133.3 576 144 565.3 144 552L144 544z"/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{move || match auth_username.get() {
|
{move || match auth_username.get() {
|
||||||
Some(u) => {
|
Some(u) => {
|
||||||
let href = format!("/profile/{u}");
|
let href = format!("/profile/{u}");
|
||||||
view! {
|
view! {
|
||||||
|
<div class="game-sidebar-section">
|
||||||
|
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
|
<path fill="currentColor" d="M240 192C240 147.8 275.8 112 320 112C364.2 112 400 147.8 400 192C400 236.2 364.2 272 320 272C275.8 272 240 236.2 240 192zM448 192C448 121.3 390.7 64 320 64C249.3 64 192 121.3 192 192C192 262.7 249.3 320 320 320C390.7 320 448 262.7 448 192zM144 544C144 473.3 201.3 416 272 416L368 416C438.7 416 496 473.3 496 544L496 552C496 565.3 506.7 576 520 576C533.3 576 544 565.3 544 552L544 544C544 446.8 465.2 368 368 368L272 368C174.8 368 96 446.8 96 544L96 552C96 565.3 106.7 576 120 576C133.3 576 144 565.3 144 552L144 544z"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
<A href=href attr:class="game-sidebar-link"
|
<A href=href attr:class="game-sidebar-link"
|
||||||
on:click=move |_| sidebar_open.set(false)>
|
on:click=move |_| sidebar_open.set(false)>
|
||||||
{u}
|
{u}
|
||||||
</A>
|
</A>
|
||||||
<button class="game-sidebar-btn" on:click=move |_| {
|
</div>
|
||||||
|
<div class="game-sidebar-section">
|
||||||
|
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
|
<path fill="currentColor" d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"/>
|
||||||
|
</svg>
|
||||||
|
<a class="game-sidebar-link" on:click=move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let _ = api::post_logout().await;
|
let _ = api::post_logout().await;
|
||||||
auth_username.set(None);
|
auth_username.set(None);
|
||||||
});
|
});
|
||||||
}>{t!(i18n, sign_out)}</button>
|
}>{t!(i18n, sign_out)}</a>
|
||||||
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
},
|
},
|
||||||
None => view! {
|
None => view! {
|
||||||
|
<div class="game-sidebar-section">
|
||||||
|
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
|
<path fill="currentColor" d="M416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160zM406.6 342.6C419.1 330.1 419.1 309.8 406.6 297.3L278.6 169.3C266.1 156.8 245.8 156.8 233.3 169.3C220.8 181.8 220.8 202.1 233.3 214.6L306.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L306.7 352L233.3 425.4C220.8 437.9 220.8 458.2 233.3 470.7C245.8 483.2 266.1 483.2 278.6 470.7L406.6 342.7z"/>
|
||||||
|
</svg>
|
||||||
<A href="/account" attr:class="game-sidebar-link"
|
<A href="/account" attr:class="game-sidebar-link"
|
||||||
on:click=move |_| sidebar_open.set(false)>
|
on:click=move |_| sidebar_open.set(false)>
|
||||||
{t!(i18n, sign_in)}
|
{t!(i18n, sign_in)}
|
||||||
</A>
|
</A>
|
||||||
|
</div>
|
||||||
}.into_any(),
|
}.into_any(),
|
||||||
}}
|
}}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
// ── Debug section ─────────────────────────────────────────────────
|
// ── Debug section ─────────────────────────────────────────────────
|
||||||
<div class="game-sidebar-section" style="flex-direction:column;gap:0.4rem">
|
|
||||||
<span class="game-sidebar-label">{t!(i18n, debug_section)}</span>
|
|
||||||
|
|
||||||
// "Take snapshot" — only visible while a game is in progress
|
// "Take snapshot" — only visible while a game is in progress
|
||||||
{move || {
|
{move || {
|
||||||
let Screen::Playing(ref state) = screen.get() else { return None; };
|
let Screen::Playing(ref state) = screen.get() else { return None; };
|
||||||
let vs = state.view_state.clone();
|
let vs = state.view_state.clone();
|
||||||
let tx = cmd_tx_snapshot.clone();
|
let tx = cmd_tx_snapshot.clone();
|
||||||
Some(view! {
|
Some(view! {
|
||||||
<button class="game-sidebar-btn" on:click=move |_| {
|
|
||||||
|
<div class="game-sidebar-section">
|
||||||
|
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
|
<path fill="currentColor" d="M257.1 96C238.4 96 220.9 105.4 210.5 120.9L184.5 160L128 160C92.7 160 64 188.7 64 224L64 480C64 515.3 92.7 544 128 544L512 544C547.3 544 576 515.3 576 480L576 224C576 188.7 547.3 160 512 160L455.5 160L429.5 120.9C419.1 105.4 401.6 96 382.9 96L257.1 96zM250.4 147.6C251.9 145.4 254.4 144 257.1 144L382.8 144C385.5 144 388 145.3 389.5 147.6L422.7 197.4C427.2 204.1 434.6 208.1 442.7 208.1L512 208.1C520.8 208.1 528 215.3 528 224.1L528 480.1C528 488.9 520.8 496.1 512 496.1L128 496C119.2 496 112 488.8 112 480L112 224C112 215.2 119.2 208 128 208L197.3 208C205.3 208 212.8 204 217.3 197.3L250.5 147.5zM320 448C381.9 448 432 397.9 432 336C432 274.1 381.9 224 320 224C258.1 224 208 274.1 208 336C208 397.9 258.1 448 320 448zM256 336C256 300.7 284.7 272 320 272C355.3 272 384 300.7 384 336C384 371.3 355.3 400 320 400C284.7 400 256 371.3 256 336z"/>
|
||||||
|
</svg>
|
||||||
|
<a class="game-sidebar-link" on:click=move |_| {
|
||||||
if let Ok(json) = serde_json::to_string(&vs) {
|
if let Ok(json) = serde_json::to_string(&vs) {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
|
|
@ -610,21 +677,34 @@ fn SiteHamburger() -> impl IntoView {
|
||||||
} else {
|
} else {
|
||||||
t_string!(i18n, take_snapshot).to_owned()
|
t_string!(i18n, take_snapshot).to_owned()
|
||||||
}}
|
}}
|
||||||
</button>
|
</a>
|
||||||
|
</div>
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// "Replay snapshot" — always visible
|
// "Replay snapshot" — always visible
|
||||||
<button class="game-sidebar-btn" on:click=move |_| {
|
<div class="game-sidebar-section">
|
||||||
|
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
|
<path fill="currentColor" d="M534.6 182.6C547.1 170.1 547.1 149.8 534.6 137.3L470.6 73.3C461.4 64.1 447.7 61.4 435.7 66.4C423.7 71.4 416 83.1 416 96L416 128L256 128C150 128 64 214 64 320C64 337.7 78.3 352 96 352C113.7 352 128 337.7 128 320C128 249.3 185.3 192 256 192L416 192L416 224C416 236.9 423.8 248.6 435.8 253.6C447.8 258.6 461.5 255.8 470.7 246.7L534.7 182.7zM105.4 457.4C92.9 469.9 92.9 490.2 105.4 502.7L169.4 566.7C178.6 575.9 192.3 578.6 204.3 573.6C216.3 568.6 224 556.9 224 544L224 512L384 512C490 512 576 426 576 320C576 302.3 561.7 288 544 288C526.3 288 512 302.3 512 320C512 390.7 454.7 448 384 448L224 448L224 416C224 403.1 216.2 391.4 204.2 386.4C192.2 381.4 178.5 384.2 169.3 393.3L105.3 457.3z"/>
|
||||||
|
</svg>
|
||||||
|
<a class="game-sidebar-link" on:click=move |_| {
|
||||||
replay_text.set(String::new());
|
replay_text.set(String::new());
|
||||||
replay_error.set(false);
|
replay_error.set(false);
|
||||||
replay_open.set(true);
|
replay_open.set(true);
|
||||||
sidebar_open.set(false);
|
sidebar_open.set(false);
|
||||||
}>{t!(i18n, replay_snapshot)}</button>
|
}>{t!(i18n, replay_snapshot)}</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="site-nav-infolinks">
|
||||||
|
<a href="/page/about">{t!(i18n, about)}</a>
|
||||||
|
<span> - </span>
|
||||||
|
<a href="/page/legal">{t!(i18n, legal)}</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="site-nav-version">"v" {VERSION}</span>
|
<span class="site-nav-version">"v" {VERSION}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// ── Replay snapshot modal ─────────────────────────────────────────────
|
// ── Replay snapshot modal ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use leptos::prelude::*;
|
||||||
use leptos_router::hooks::use_navigate;
|
use leptos_router::hooks::use_navigate;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
use crate::app::AuthEmailVerified;
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
|
|
@ -9,8 +10,8 @@ pub fn AccountPage() -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
let auth_username =
|
let auth_username =
|
||||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
let auth_email_verified =
|
let auth_email_verified = use_context::<AuthEmailVerified>()
|
||||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
.expect("auth_email_verified context not found").0;
|
||||||
let navigate = use_navigate();
|
let navigate = use_navigate();
|
||||||
|
|
||||||
// Only redirect to profile when the email is actually verified.
|
// Only redirect to profile when the email is actually verified.
|
||||||
|
|
@ -107,8 +108,8 @@ fn LoginForm() -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
let auth_username =
|
let auth_username =
|
||||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
let auth_email_verified =
|
let auth_email_verified = use_context::<AuthEmailVerified>()
|
||||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
.expect("auth_email_verified context not found").0;
|
||||||
let navigate = use_navigate();
|
let navigate = use_navigate();
|
||||||
|
|
||||||
let login = RwSignal::new(String::new());
|
let login = RwSignal::new(String::new());
|
||||||
|
|
@ -177,8 +178,8 @@ fn RegisterForm() -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
let auth_username =
|
let auth_username =
|
||||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
let auth_email_verified =
|
let auth_email_verified = use_context::<AuthEmailVerified>()
|
||||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
.expect("auth_email_verified context not found").0;
|
||||||
|
|
||||||
let username = RwSignal::new(String::new());
|
let username = RwSignal::new(String::new());
|
||||||
let email = RwSignal::new(String::new());
|
let email = RwSignal::new(String::new());
|
||||||
|
|
|
||||||
51
clients/web/src/portal/content_page.rs
Normal file
51
clients/web/src/portal/content_page.rs
Normal file
|
|
@ -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! {
|
||||||
|
<div class="portal-main">
|
||||||
|
{move || match page.get().map(|sw| sw.take()) {
|
||||||
|
None => view! {
|
||||||
|
<p class="portal-loading">{t!(i18n, loading)}</p>
|
||||||
|
}.into_any(),
|
||||||
|
Some(Err(_)) => view! {
|
||||||
|
<p class="portal-empty">"Page not found."</p>
|
||||||
|
}.into_any(),
|
||||||
|
Some(Ok(p)) => {
|
||||||
|
let html = md_to_html(&p.content);
|
||||||
|
view! {
|
||||||
|
<div class="portal-card content-page" inner_html=html />
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ use leptos::prelude::*;
|
||||||
use leptos_router::components::A;
|
use leptos_router::components::A;
|
||||||
use leptos_router::hooks::use_query_map;
|
use leptos_router::hooks::use_query_map;
|
||||||
|
|
||||||
use crate::app::{NetCommand, Screen};
|
use crate::app::{AnonNickname, NetCommand, Screen};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
// ── Room/nickname generation ──────────────────────────────────────────────────
|
// ── Room/nickname generation ──────────────────────────────────────────────────
|
||||||
|
|
@ -103,7 +103,7 @@ pub fn LobbyPage() -> impl IntoView {
|
||||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>().expect("NetCommand sender");
|
let cmd_tx = use_context::<UnboundedSender<NetCommand>>().expect("NetCommand sender");
|
||||||
let auth_username = use_context::<RwSignal<Option<String>>>().expect("auth_username context");
|
let auth_username = use_context::<RwSignal<Option<String>>>().expect("auth_username context");
|
||||||
let auth_loaded = use_context::<RwSignal<bool>>().expect("auth_loaded context");
|
let auth_loaded = use_context::<RwSignal<bool>>().expect("auth_loaded context");
|
||||||
let anon_nickname = use_context::<RwSignal<Option<String>>>().expect("anon_nickname context");
|
let anon_nickname = use_context::<AnonNickname>().expect("anon_nickname context").0;
|
||||||
let query = use_query_map();
|
let query = use_query_map();
|
||||||
|
|
||||||
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
|
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
|
||||||
|
|
@ -195,12 +195,9 @@ fn IdleCard(
|
||||||
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
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_bot = cmd_tx.clone();
|
||||||
let cmd_create = cmd_tx.clone();
|
let cmd_create = cmd_tx.clone();
|
||||||
let cmd_join = cmd_tx;
|
|
||||||
|
|
||||||
let on_create = move |_: leptos::ev::MouseEvent| {
|
let on_create = move |_: leptos::ev::MouseEvent| {
|
||||||
let code = generate_room_code();
|
let code = generate_room_code();
|
||||||
|
|
@ -232,48 +229,6 @@ fn IdleCard(
|
||||||
{t!(i18n, create_room)}
|
{t!(i18n, create_room)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Hidden "join by code" fallback
|
|
||||||
<div style="margin-top:1.25rem;text-align:center">
|
|
||||||
<button
|
|
||||||
class="portal-page-btn"
|
|
||||||
style="font-size:0.75rem;opacity:0.7"
|
|
||||||
on:click=move |_| join_open.update(|v| *v = !*v)
|
|
||||||
>
|
|
||||||
{move || if join_open.get() { "▲ " } else { "▼ " }}
|
|
||||||
{t!(i18n, join_code_label)}
|
|
||||||
</button>
|
|
||||||
{move || {
|
|
||||||
let cmd = cmd_join.clone();
|
|
||||||
join_open.get().then(|| view! {
|
|
||||||
<div style="margin-top:0.75rem;display:flex;gap:0.5rem">
|
|
||||||
<input
|
|
||||||
class="login-input"
|
|
||||||
style="margin:0"
|
|
||||||
type="text"
|
|
||||||
placeholder=move || t_string!(i18n, join_code_placeholder)
|
|
||||||
prop:value=move || join_code.get()
|
|
||||||
on:input=move |ev| join_code.set(event_target_value(&ev))
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="login-btn login-btn-secondary"
|
|
||||||
style="margin:0;padding:0 1rem"
|
|
||||||
disabled=move || join_code.get().is_empty()
|
|
||||||
on:click=move |_| {
|
|
||||||
let code = join_code.get();
|
|
||||||
if auth_username.get_untracked().is_some() {
|
|
||||||
cmd.unbounded_send(NetCommand::JoinRoom { room: code }).ok();
|
|
||||||
} else {
|
|
||||||
pending_action.set(Some(PendingLobbyAction::Join { code }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t!(i18n, join_room)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,8 +293,6 @@ fn NicknameModal(
|
||||||
{t!(i18n, nickname_modal_or)}
|
{t!(i18n, nickname_modal_or)}
|
||||||
" "
|
" "
|
||||||
<A href="/account">{t!(i18n, nickname_modal_sign_in)}</A>
|
<A href="/account">{t!(i18n, nickname_modal_sign_in)}</A>
|
||||||
" · "
|
|
||||||
<A href="/account">{t!(i18n, nickname_modal_register)}</A>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod account;
|
pub mod account;
|
||||||
|
pub mod content_page;
|
||||||
pub mod forgot_password;
|
pub mod forgot_password;
|
||||||
pub mod game_detail;
|
pub mod game_detail;
|
||||||
pub mod lobby;
|
pub mod lobby;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use leptos::prelude::*;
|
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::api::{self, GameSummary, UserProfile};
|
||||||
|
use crate::app::{AuthEmailVerified, FlashMessage};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
|
|
@ -30,6 +31,8 @@ pub fn ProfilePage() -> impl IntoView {
|
||||||
#[component]
|
#[component]
|
||||||
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
let page = RwSignal::new(0i64);
|
let page = RwSignal::new(0i64);
|
||||||
let games = LocalResource::new(move || {
|
let games = LocalResource::new(move || {
|
||||||
let u = username.clone();
|
let u = username.clone();
|
||||||
|
|
@ -46,7 +49,9 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
||||||
time_style: None,
|
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, &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! {
|
view! {
|
||||||
<div class="portal-card">
|
<div class="portal-card">
|
||||||
|
|
@ -83,6 +88,106 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{move || if is_own_profile() {
|
||||||
|
let uname = profile.username.clone();
|
||||||
|
view! { <DeleteAccountSection username=uname /> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn DeleteAccountSection(username: String) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
|
let auth_email_verified = use_context::<AuthEmailVerified>()
|
||||||
|
.expect("auth_email_verified context not found").0;
|
||||||
|
let flash = use_context::<FlashMessage>().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! {
|
||||||
|
<div class="portal-card portal-danger-zone">
|
||||||
|
<h2>{t!(i18n, delete_account_title)}</h2>
|
||||||
|
{move || if !confirming.get() {
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<p class="portal-meta" style="margin-bottom:1rem">
|
||||||
|
{t!(i18n, delete_account_warning)}
|
||||||
|
</p>
|
||||||
|
<button class="portal-danger-btn"
|
||||||
|
on:click=move |_| confirming.set(true)
|
||||||
|
>{t!(i18n, delete_account_btn)}</button>
|
||||||
|
</div>
|
||||||
|
}.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! {
|
||||||
|
<form on:submit=submit>
|
||||||
|
<p class="portal-meta" style="margin-bottom:1rem">
|
||||||
|
{t!(i18n, delete_account_warning)}
|
||||||
|
</p>
|
||||||
|
<label class="portal-label">{t!(i18n, delete_account_confirm_label)}</label>
|
||||||
|
<input class="portal-input" type="text" required
|
||||||
|
prop:value=move || confirm_input.get()
|
||||||
|
on:input=move |ev| confirm_input.set(event_target_value(&ev)) />
|
||||||
|
{move || if !error.get().is_empty() {
|
||||||
|
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
<div style="display:flex;gap:0.75rem;margin-top:1rem">
|
||||||
|
<button class="portal-danger-btn" type="submit"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>{t!(i18n, delete_account_confirm_btn)}</button>
|
||||||
|
<button class="portal-page-btn" type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
confirming.set(false);
|
||||||
|
confirm_input.set(String::new());
|
||||||
|
error.set(String::new());
|
||||||
|
}
|
||||||
|
>{t!(i18n, cancel)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use leptos::prelude::*;
|
||||||
use leptos_router::hooks::use_query_map;
|
use leptos_router::hooks::use_query_map;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
use crate::app::AuthEmailVerified;
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
|
|
@ -16,8 +17,8 @@ pub fn VerifyEmailPage() -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
let auth_username =
|
let auth_username =
|
||||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
let auth_email_verified =
|
let auth_email_verified = use_context::<AuthEmailVerified>()
|
||||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
.expect("auth_email_verified context not found").0;
|
||||||
|
|
||||||
let query = use_query_map();
|
let query = use_query_map();
|
||||||
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
|
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
|
||||||
|
|
|
||||||
6
container/flake.lock
generated
6
container/flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1778430510,
|
"lastModified": 1779467186,
|
||||||
"narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=",
|
"narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575",
|
"rev": "b77b3de8775677f84492abe84635f87b0e153f0f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
11
devenv.lock
11
devenv.lock
|
|
@ -3,10 +3,11 @@
|
||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1776863933,
|
"lastModified": 1779486363,
|
||||||
|
"narHash": "sha256-6rNmvwTngbmz/j3DGsEYkyakrHHY8N5wgRD9k35HyuM=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "863b4204725efaeeb73811e376f928232b720646",
|
"rev": "90692720b2ad7a7811204155900bf6bea3a3b420",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -18,10 +19,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1776734388,
|
"lastModified": 1779102034,
|
||||||
|
"narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac",
|
"rev": "687f05a9184cad4eaf905c48b63649e3a86f5433",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -34,6 +36,7 @@
|
||||||
"nixpkgs-cmake3": {
|
"nixpkgs-cmake3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1758213207,
|
"lastModified": 1758213207,
|
||||||
|
"narHash": "sha256-rqoqF0LEi+6ZT59tr+hTQlxVwrzQsET01U4uUdmqRtM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "f4b140d5b253f5e2a1ff4e5506edbf8267724bde",
|
"rev": "f4b140d5b253f5e2a1ff4e5506edbf8267724bde",
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
frontendCargoDeps = rustPlatform.fetchCargoVendor {
|
frontendCargoDeps = rustPlatform.fetchCargoVendor {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
name = "trictrac-frontend-vendor";
|
name = "trictrac-frontend-vendor";
|
||||||
hash = "sha256-neJh0ZQGa5LNY8vBu3kYkM+ARkXOW/EHx8sPBOsWsgE=";
|
hash = "sha256-E3MJbEehbXni7B8fQPS8fSri4f2b0A33r2djiK81E2Y=";
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
final.stdenv.mkDerivation {
|
final.stdenv.mkDerivation {
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
|
|
||||||
trictrac = with final; rustPlatform.buildRustPackage {
|
trictrac = with final; rustPlatform.buildRustPackage {
|
||||||
pname = "trictrac";
|
pname = "trictrac";
|
||||||
version = "0.2.12"; # trictrac-version
|
version = "0.2.13"; # trictrac-version
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
nativeBuildInputs = [ pkg-config ];
|
nativeBuildInputs = [ pkg-config ];
|
||||||
|
|
|
||||||
10
justfile
10
justfile
|
|
@ -13,6 +13,10 @@ bump version:
|
||||||
git commit -m "chore: bump version to {{version}}"
|
git commit -m "chore: bump version to {{version}}"
|
||||||
@echo "Done. Finish with: git flow release finish {{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:
|
doc:
|
||||||
cargo doc --no-deps
|
cargo doc --no-deps
|
||||||
shell:
|
shell:
|
||||||
|
|
@ -46,7 +50,7 @@ build:
|
||||||
|
|
||||||
[working-directory: 'deploy']
|
[working-directory: 'deploy']
|
||||||
run-relay:
|
run-relay:
|
||||||
./relay-server
|
PAGES_DIR=../clients/web/pages ./relay-server
|
||||||
|
|
||||||
build-relay:
|
build-relay:
|
||||||
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
|
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
|
||||||
|
|
@ -54,6 +58,10 @@ build-relay:
|
||||||
cp target/release/relay-server deploy
|
cp target/release/relay-server deploy
|
||||||
cp -u server/relay-server/GameConfig.json 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
|
# start a trictrac container with nixos-container
|
||||||
# `boot.enableContainers = true` must be set on local nixos system
|
# `boot.enableContainers = true` must be set on local nixos system
|
||||||
local:
|
local:
|
||||||
|
|
|
||||||
13
module.nix
13
module.nix
|
|
@ -29,6 +29,12 @@ in
|
||||||
description = "Web server protocol.";
|
description = "Web server protocol.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pages_dir = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/var/lib/trictrac/pages";
|
||||||
|
description = "Directory containing content pages.";
|
||||||
|
};
|
||||||
|
|
||||||
hostname = mkOption {
|
hostname = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "trictrac.localhost";
|
default = "trictrac.localhost";
|
||||||
|
|
@ -132,9 +138,9 @@ in
|
||||||
# Explicit listen so this vhost isn't shadowed by a default_server
|
# Explicit listen so this vhost isn't shadowed by a default_server
|
||||||
# created by other virtual hosts with forceSSL = true.
|
# created by other virtual hosts with forceSSL = true.
|
||||||
listen = [
|
listen = [
|
||||||
{ addr = "0.0.0.0"; port = listenPort; ssl = withSSL; }
|
{ addr = "0.0.0.0"; port = listenPort; ssl = withSSL; }
|
||||||
{ addr = "[::]"; port = listenPort; ssl = withSSL; }
|
{ addr = "[::]"; port = listenPort; ssl = withSSL; }
|
||||||
];
|
];
|
||||||
locations."/" = {
|
locations."/" = {
|
||||||
extraConfig = proxyConfig;
|
extraConfig = proxyConfig;
|
||||||
proxyPass = "http://trictrac-api/";
|
proxyPass = "http://trictrac-api/";
|
||||||
|
|
@ -195,6 +201,7 @@ in
|
||||||
environment = {
|
environment = {
|
||||||
DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}";
|
DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}";
|
||||||
APP_URL = "${cfg.protocol}://${cfg.hostname}";
|
APP_URL = "${cfg.protocol}://${cfg.hostname}";
|
||||||
|
PAGES_DIR = cfg.pages_dir;
|
||||||
SMTP_HOST = cfg.smtp.host;
|
SMTP_HOST = cfg.smtp.host;
|
||||||
SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port
|
SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port
|
||||||
else if cfg.smtp.tls then 465 else 1025);
|
else if cfg.smtp.tls then 465 else 1025);
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,25 @@ pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Resu
|
||||||
Ok(())
|
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 ──────────────────────────────────────────────────────────────
|
// ── Email tokens ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn create_email_token(
|
pub async fn create_email_token(
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
use axum_login::AuthSession;
|
use axum_login::AuthSession;
|
||||||
use rand::distributions::Alphanumeric;
|
use rand::distributions::Alphanumeric;
|
||||||
|
|
@ -48,10 +48,12 @@ pub fn router() -> Router<Arc<AppState>> {
|
||||||
.route("/auth/resend-verification", post(resend_verification))
|
.route("/auth/resend-verification", post(resend_verification))
|
||||||
.route("/auth/forgot-password", post(forgot_password))
|
.route("/auth/forgot-password", post(forgot_password))
|
||||||
.route("/auth/reset-password", post(reset_password))
|
.route("/auth/reset-password", post(reset_password))
|
||||||
|
.route("/auth/account", delete(delete_account))
|
||||||
.route("/users/{username}", get(user_profile))
|
.route("/users/{username}", get(user_profile))
|
||||||
.route("/users/{username}/games", get(user_games))
|
.route("/users/{username}/games", get(user_games))
|
||||||
.route("/games/result", post(game_result))
|
.route("/games/result", post(game_result))
|
||||||
.route("/games/{id}", get(game_detail))
|
.route("/games/{id}", get(game_detail))
|
||||||
|
.route("/pages/{slug}", get(get_page))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Token generation ──────────────────────────────────────────────────────────
|
// ── Token generation ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -245,6 +247,7 @@ async fn register(
|
||||||
|
|
||||||
async fn login(
|
async fn login(
|
||||||
mut auth_session: AuthSession<AuthBackend>,
|
mut auth_session: AuthSession<AuthBackend>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
Json(body): Json<LoginBody>,
|
Json(body): Json<LoginBody>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let creds = Credentials {
|
let creds = Credentials {
|
||||||
|
|
@ -260,6 +263,18 @@ async fn login(
|
||||||
|
|
||||||
auth_session.login(&user).await.map_err(|_| AppError::Internal)?;
|
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 {
|
Ok(Json(MeResponse {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|
@ -272,6 +287,16 @@ async fn logout(mut auth_session: AuthSession<AuthBackend>) -> Result<StatusCode
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_account(
|
||||||
|
mut auth_session: AuthSession<AuthBackend>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
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<AuthBackend>) -> Result<impl IntoResponse, AppError> {
|
async fn me(auth_session: AuthSession<AuthBackend>) -> Result<impl IntoResponse, AppError> {
|
||||||
match auth_session.user {
|
match auth_session.user {
|
||||||
Some(user) => Ok(Json(MeResponse {
|
Some(user) => Ok(Json(MeResponse {
|
||||||
|
|
@ -522,3 +547,66 @@ async fn game_result(
|
||||||
|
|
||||||
Ok(Json(GameResultResponse { game_record_id }))
|
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<String>,
|
||||||
|
Query(query): Query<LangQuery>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
// 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 }))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,15 +63,18 @@ pub struct AppState {
|
||||||
pub db: Pool,
|
pub db: Pool,
|
||||||
/// SMTP mailer for email verification and password reset.
|
/// SMTP mailer for email verification and password reset.
|
||||||
pub mailer: Mailer,
|
pub mailer: Mailer,
|
||||||
|
/// Directory containing static content pages as `{slug}/{lang}.md` files.
|
||||||
|
pub pages_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(db: Pool, mailer: Mailer) -> Self {
|
pub fn new(db: Pool, mailer: Mailer, pages_dir: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
rooms: Mutex::new(HashMap::new()),
|
rooms: Mutex::new(HashMap::new()),
|
||||||
configs: RwLock::new(HashMap::new()),
|
configs: RwLock::new(HashMap::new()),
|
||||||
db,
|
db,
|
||||||
mailer,
|
mailer,
|
||||||
|
pages_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@ async fn main() {
|
||||||
let auth_backend = AuthBackend::new(pool.clone());
|
let auth_backend = AuthBackend::new(pool.clone());
|
||||||
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
|
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();
|
let watchdog_state = app_state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min
|
||||||
|
|
@ -86,7 +87,7 @@ async fn main() {
|
||||||
.allow_origin(AllowOrigin::list([
|
.allow_origin(AllowOrigin::list([
|
||||||
"http://localhost:9091".parse().unwrap(), // unified web dev server
|
"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([
|
.allow_headers([
|
||||||
HeaderName::from_static("content-type"),
|
HeaderName::from_static("content-type"),
|
||||||
HeaderName::from_static("cookie"),
|
HeaderName::from_static("cookie"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue