Compare commits
7 commits
58f5722551
...
09f02aaa00
| Author | SHA1 | Date | |
|---|---|---|---|
| 09f02aaa00 | |||
| 849b31dbb1 | |||
| 68a8535397 | |||
| 50f5c43a21 | |||
| 9e2ff3a9f1 | |||
| 20b8353cfb | |||
| 6fd3499d7b |
24 changed files with 653 additions and 24 deletions
39
Cargo.lock
generated
39
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace.package]
|
||||
version = "0.2.12"
|
||||
version = "0.2.13"
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -2084,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;
|
||||
|
|
@ -2093,3 +2163,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,5 +140,14 @@
|
|||
"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.",
|
||||
"about": "About",
|
||||
"legal": "Legal notices"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,5 +138,14 @@
|
|||
"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é.",
|
||||
"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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct PageContent {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
// ── Request bodies ────────────────────────────────────────────────────────────
|
||||
|
||||
#[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> {
|
||||
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
|
||||
.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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -34,6 +34,9 @@ use std::collections::VecDeque;
|
|||
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)]
|
||||
|
|
@ -182,6 +185,8 @@ pub fn App() -> impl IntoView {
|
|||
// Nickname chosen by an anonymous player; used instead of "Anonymous".
|
||||
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
|
||||
provide_context(AnonNickname(anon_nickname));
|
||||
let flash: RwSignal<Option<String>> = 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! {
|
||||
<Router>
|
||||
<SiteHamburger />
|
||||
<FlashBanner />
|
||||
<main>
|
||||
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
||||
<Route path=path!("/") view=LobbyPage />
|
||||
|
|
@ -432,6 +438,7 @@ pub fn App() -> impl IntoView {
|
|||
<Route path=path!("/verify-email") view=VerifyEmailPage />
|
||||
<Route path=path!("/forgot-password") view=ForgotPasswordPage />
|
||||
<Route path=path!("/reset-password") view=ResetPasswordPage />
|
||||
<Route path=path!("/page/:slug") view=ContentPage />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
|
|
@ -440,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 "/".
|
||||
/// This lets the user navigate to profile/account pages while a game is running.
|
||||
#[component]
|
||||
|
|
@ -659,6 +694,13 @@ fn SiteHamburger() -> impl IntoView {
|
|||
sidebar_open.set(false);
|
||||
}>{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>
|
||||
<span class="site-nav-version">"v" {VERSION}</span>
|
||||
</div>
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod account;
|
||||
pub mod content_page;
|
||||
pub mod forgot_password;
|
||||
pub mod game_detail;
|
||||
pub mod lobby;
|
||||
|
|
|
|||
|
|
@ -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::<RwSignal<Option<String>>>().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! {
|
||||
<div class="portal-card">
|
||||
|
|
@ -83,6 +88,106 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
|||
}
|
||||
}}
|
||||
</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>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
6
container/flake.lock
generated
6
container/flake.lock
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
frontendCargoDeps = rustPlatform.fetchCargoVendor {
|
||||
src = ./.;
|
||||
name = "trictrac-frontend-vendor";
|
||||
hash = "sha256-neJh0ZQGa5LNY8vBu3kYkM+ARkXOW/EHx8sPBOsWsgE=";
|
||||
hash = "sha256-E3MJbEehbXni7B8fQPS8fSri4f2b0A33r2djiK81E2Y=";
|
||||
};
|
||||
in
|
||||
final.stdenv.mkDerivation {
|
||||
|
|
@ -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 ];
|
||||
|
|
|
|||
7
justfile
7
justfile
|
|
@ -11,9 +11,12 @@ 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
|
||||
pages-deploy:
|
||||
rsync -av clients/web/pages/ raspberry:/var/lib/trictrac/pages/
|
||||
|
||||
doc:
|
||||
cargo doc --no-deps
|
||||
shell:
|
||||
|
|
@ -47,7 +50,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
|
||||
|
|
|
|||
13
module.nix
13
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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,10 +48,12 @@ pub fn router() -> Router<Arc<AppState>> {
|
|||
.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))
|
||||
.route("/games/{id}", get(game_detail))
|
||||
.route("/pages/{slug}", get(get_page))
|
||||
}
|
||||
|
||||
// ── Token generation ──────────────────────────────────────────────────────────
|
||||
|
|
@ -285,6 +287,16 @@ async fn logout(mut auth_session: AuthSession<AuthBackend>) -> Result<StatusCode
|
|||
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> {
|
||||
match auth_session.user {
|
||||
Some(user) => Ok(Json(MeResponse {
|
||||
|
|
@ -535,3 +547,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<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,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -86,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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue