chore: integrate multiplayer code (wip)

This commit is contained in:
Henri Bourcereau 2026-04-22 17:42:05 +02:00
parent 2838d59f30
commit 4f5e21becb
66 changed files with 6423 additions and 18 deletions

View file

@ -0,0 +1,17 @@
[package]
name = "web-user-portal"
version = "0.1.0"
edition = "2024"
[dependencies]
leptos = { version = "0.7", features = ["csr"] }
leptos_router = { version = "0.7" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
gloo-net = { version = "0.5", features = ["http"] }
js-sys = "0.3"
web-sys = { version = "0.3", features = ["RequestCredentials"] }

View file

@ -0,0 +1,2 @@
[serve]
port = 9092

View file

@ -0,0 +1,103 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #f5f5f5;
color: #1a1a1a;
min-height: 100vh;
}
nav {
background: #1a1a2e;
color: #fff;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
nav a { color: #ccc; text-decoration: none; }
nav a:hover { color: #fff; }
nav .brand { font-weight: 700; font-size: 1.1rem; color: #fff; }
nav .spacer { flex: 1; }
main { max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.6rem; margin-bottom: 1rem; }
h2 { font-size: 1.2rem; margin-bottom: 0.75rem; }
.card {
background: #fff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; }
.tab-btn {
padding: 0.5rem 1.25rem;
border: 1px solid #ddd;
background: #f5f5f5;
cursor: pointer;
font-size: 0.95rem;
}
.tab-btn:first-child { border-radius: 6px 0 0 6px; }
.tab-btn:last-child { border-radius: 0 6px 6px 0; border-left: none; }
.tab-btn.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
label { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; color: #555; }
input[type=text], input[type=email], input[type=password] {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
input:focus { outline: none; border-color: #1a1a2e; }
button[type=submit], .btn {
padding: 0.5rem 1.25rem;
background: #1a1a2e;
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.95rem;
}
button[type=submit]:hover, .btn:hover { background: #2d2d5e; }
button[type=submit]:disabled { opacity: 0.6; cursor: not-allowed; }
.error { color: #c0392b; font-size: 0.875rem; margin-top: 0.5rem; }
.success { color: #27ae60; font-size: 0.875rem; margin-top: 0.5rem; }
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-box {
background: #fff;
border-radius: 8px;
padding: 1rem;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-box .value { font-size: 2rem; font-weight: 700; }
.stat-box .label { font-size: 0.8rem; color: #777; margin-top: 0.25rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #eee; color: #555; }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
a { color: #2c5cc5; text-decoration: none; }
a:hover { text-decoration: underline; }
.outcome-win { color: #27ae60; font-weight: 600; }
.outcome-loss { color: #c0392b; font-weight: 600; }
.outcome-draw { color: #e67e22; font-weight: 600; }
.loading { color: #777; padding: 1rem 0; }
.empty { color: #aaa; font-style: italic; padding: 1rem 0; }

View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Player Portal</title>
<link data-trunk rel="rust" data-wasm-opt="z" />
<link data-trunk rel="css" href="assets/style.css" />
</head>
<body></body>
</html>

View file

@ -0,0 +1,191 @@
use serde::{Deserialize, Serialize};
// In debug builds trunk serves on 9092 while the relay is on 8080 — use full URL.
// In release builds the portal is served by the relay itself — use relative paths.
#[cfg(debug_assertions)]
const BASE: &str = "http://localhost:8080";
#[cfg(not(debug_assertions))]
const BASE: &str = "";
fn url(path: &str) -> String {
format!("{BASE}{path}")
}
// ── Response types ────────────────────────────────────────────────────────────
#[derive(Clone, Debug, Deserialize)]
pub struct MeResponse {
pub id: i64,
pub username: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UserProfile {
pub id: i64,
pub username: String,
pub created_at: i64,
pub total_games: i64,
pub wins: i64,
pub losses: i64,
pub draws: i64,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GameSummary {
pub id: i64,
pub game_id: String,
pub room_code: String,
pub started_at: i64,
pub ended_at: Option<i64>,
pub result: Option<String>,
pub outcome: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GamesResponse {
pub games: Vec<GameSummary>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Participant {
pub player_id: i64,
pub outcome: Option<String>,
pub username: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GameDetail {
pub id: i64,
pub game_id: String,
pub room_code: String,
pub started_at: i64,
pub ended_at: Option<i64>,
pub result: Option<String>,
pub participants: Vec<Participant>,
}
// ── Request bodies ────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct RegisterBody<'a> {
pub username: &'a str,
pub email: &'a str,
pub password: &'a str,
}
#[derive(Serialize)]
pub struct LoginBody<'a> {
pub username: &'a str,
pub password: &'a str,
}
// ── Fetch helpers ─────────────────────────────────────────────────────────────
pub async fn get_me() -> Result<MeResponse, String> {
let resp = gloo_net::http::Request::get(&url("/auth/me"))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn post_login(username: &str, password: &str) -> Result<MeResponse, String> {
let body = LoginBody { username, password };
let resp = gloo_net::http::Request::post(&url("/auth/login"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
pub async fn post_register(username: &str, email: &str, password: &str) -> Result<MeResponse, String> {
let body = RegisterBody { username, email, password };
let resp = gloo_net::http::Request::post(&url("/auth/register"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 201 {
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
pub async fn post_logout() -> Result<(), String> {
let resp = gloo_net::http::Request::post(&url("/auth/logout"))
.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)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<UserProfile>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn get_user_games(username: &str, page: i64) -> Result<GamesResponse, String> {
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}/games?page={page}&per_page=20")))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<GamesResponse>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn get_game_detail(id: i64) -> Result<GameDetail, String> {
let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}")))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<GameDetail>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
// ── Utilities ─────────────────────────────────────────────────────────────────
pub fn format_ts(ts: i64) -> String {
let ms = (ts * 1000) as f64;
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms));
date.to_locale_string("en-GB", &wasm_bindgen::JsValue::UNDEFINED)
.as_string()
.unwrap_or_default()
}

View file

@ -0,0 +1,67 @@
use leptos::prelude::*;
use leptos_router::{components::{Route, Router, Routes, A}, path};
use crate::api::{self, MeResponse};
use crate::pages::{home::HomePage, profile::ProfilePage, game::GamePage};
#[derive(Clone, Debug)]
pub struct AuthState {
pub user: RwSignal<Option<MeResponse>>,
}
#[component]
pub fn App() -> impl IntoView {
let user = RwSignal::new(None::<MeResponse>);
provide_context(AuthState { user });
// Probe session on load.
let auth = use_context::<AuthState>().unwrap();
let _ = LocalResource::new(move || async move {
if let Ok(me) = api::get_me().await {
auth.user.set(Some(me));
}
});
view! {
<Router>
<Nav />
<main>
<Routes fallback=|| view! { <p class="empty">"Page not found."</p> }>
<Route path=path!("/") view=HomePage />
<Route path=path!("/profile/:username") view=ProfilePage />
<Route path=path!("/games/:id") view=GamePage />
</Routes>
</main>
</Router>
}
}
#[component]
fn Nav() -> impl IntoView {
let auth = use_context::<AuthState>().unwrap();
let logout = move |_| {
wasm_bindgen_futures::spawn_local(async move {
let _ = api::post_logout().await;
auth.user.set(None);
});
};
view! {
<nav>
<A href="/" attr:class="brand">"Player Portal"</A>
<span class="spacer" />
{move || match auth.user.get() {
Some(u) => view! {
<A href=format!("/profile/{}", u.username)>
{ u.username.clone() }
</A>
<button class="btn" on:click=logout style="padding:0.25rem 0.75rem">
"Logout"
</button>
}.into_any(),
None => view! { <A href="/">"Login"</A> }.into_any(),
}}
</nav>
}
}

View file

@ -0,0 +1,7 @@
mod api;
mod app;
mod pages;
fn main() {
leptos::mount::mount_to_body(app::App);
}

View file

@ -0,0 +1,95 @@
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
use crate::api::{self, GameDetail, Participant};
#[component]
pub fn GamePage() -> impl IntoView {
let params = use_params_map();
let id_str = move || params.read().get("id").unwrap_or_default();
let detail = LocalResource::new(move || {
let s = id_str();
async move {
let id: i64 = s.parse().map_err(|_| "invalid game id".to_string())?;
api::get_game_detail(id).await
}
});
view! {
<div>
{move || match detail.get().map(|sw| sw.take()) {
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
Some(Ok(g)) => view! { <GameDetailView game=g /> }.into_any(),
}}
</div>
}
}
#[component]
fn GameDetailView(game: GameDetail) -> impl IntoView {
let started = api::format_ts(game.started_at);
let ended = game.ended_at.map(api::format_ts).unwrap_or_else(|| "ongoing".into());
view! {
<div class="card">
<h1 style="margin-bottom:0.25rem">"Game " { game.room_code.clone() }</h1>
<p style="color:#777;margin-bottom:1.5rem">
"Started: " { started.clone() } " · Ended: " { ended }
</p>
<h2>"Players"</h2>
<table>
<thead>
<tr>
<th>"Player"</th>
<th>"Username"</th>
<th>"Outcome"</th>
</tr>
</thead>
<tbody>
{game.participants.iter().map(|p| {
view! { <ParticipantRow participant=p.clone() /> }
}).collect_view()}
</tbody>
</table>
{game.result.as_ref().map(|r| view! {
<div style="margin-top:1.5rem">
<h2>"Result data"</h2>
<pre style="background:#f5f5f5;padding:0.75rem;border-radius:5px;overflow:auto;font-size:0.85rem">
{ r.clone() }
</pre>
</div>
})}
</div>
}
}
#[component]
fn ParticipantRow(participant: Participant) -> impl IntoView {
let outcome_class = match participant.outcome.as_deref() {
Some("win") => "outcome-win",
Some("loss") => "outcome-loss",
Some("draw") => "outcome-draw",
_ => "",
};
let outcome_text = participant.outcome.clone().unwrap_or_else(|| "".into());
let name = participant.username.clone();
view! {
<tr>
<td>"Player " { participant.player_id }</td>
<td>
{match name {
Some(u) => view! {
<A href=format!("/profile/{u}")>{ u }</A>
}.into_any(),
None => view! { <span style="color:#aaa">"anonymous"</span> }.into_any(),
}}
</td>
<td class=outcome_class>{ outcome_text }</td>
</tr>
}
}

View file

@ -0,0 +1,152 @@
use leptos::prelude::*;
use leptos_router::hooks::use_navigate;
use crate::api;
use crate::app::AuthState;
#[component]
pub fn HomePage() -> impl IntoView {
let auth = use_context::<AuthState>().unwrap();
let navigate = use_navigate();
// Redirect to own profile when already logged in.
Effect::new(move |_| {
if let Some(u) = auth.user.get() {
navigate(&format!("/profile/{}", u.username), Default::default());
}
});
let tab = RwSignal::new("login");
view! {
<div class="card" style="max-width:420px;margin:3rem auto">
<div class="tabs">
<button
class=move || if tab.get() == "login" { "tab-btn active" } else { "tab-btn" }
on:click=move |_| tab.set("login")
>"Login"</button>
<button
class=move || if tab.get() == "register" { "tab-btn active" } else { "tab-btn" }
on:click=move |_| tab.set("register")
>"Register"</button>
</div>
{move || if tab.get() == "login" {
view! { <LoginForm /> }.into_any()
} else {
view! { <RegisterForm /> }.into_any()
}}
</div>
}
}
#[component]
fn LoginForm() -> impl IntoView {
let auth = use_context::<AuthState>().unwrap();
let navigate = use_navigate();
let username = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let error = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
pending.set(true);
error.set(String::new());
let u = username.get();
let p = password.get();
let navigate = navigate.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::post_login(&u, &p).await {
Ok(me) => {
let dest = format!("/profile/{}", me.username);
auth.user.set(Some(me));
navigate(&dest, Default::default());
}
Err(e) => {
error.set(e);
pending.set(false);
}
}
});
};
view! {
<form on:submit=submit>
<label>"Username"</label>
<input type="text" required
prop:value=move || username.get()
on:input=move |ev| username.set(event_target_value(&ev)) />
<label>"Password"</label>
<input type="password" required
prop:value=move || password.get()
on:input=move |ev| password.set(event_target_value(&ev)) />
<button type="submit" disabled=move || pending.get()>"Login"</button>
{move || if !error.get().is_empty() {
view! { <p class="error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</form>
}
}
#[component]
fn RegisterForm() -> impl IntoView {
let auth = use_context::<AuthState>().unwrap();
let navigate = use_navigate();
let username = RwSignal::new(String::new());
let email = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let error = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
pending.set(true);
error.set(String::new());
let u = username.get();
let e = email.get();
let p = password.get();
let navigate = navigate.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::post_register(&u, &e, &p).await {
Ok(me) => {
let dest = format!("/profile/{}", me.username);
auth.user.set(Some(me));
navigate(&dest, Default::default());
}
Err(err) => {
error.set(err);
pending.set(false);
}
}
});
};
view! {
<form on:submit=submit>
<label>"Username"</label>
<input type="text" required
prop:value=move || username.get()
on:input=move |ev| username.set(event_target_value(&ev)) />
<label>"Email"</label>
<input type="email" required
prop:value=move || email.get()
on:input=move |ev| email.set(event_target_value(&ev)) />
<label>"Password"</label>
<input type="password" required
prop:value=move || password.get()
on:input=move |ev| password.set(event_target_value(&ev)) />
<button type="submit" disabled=move || pending.get()>"Register"</button>
{move || if !error.get().is_empty() {
view! { <p class="error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</form>
}
}

View file

@ -0,0 +1,3 @@
pub mod game;
pub mod home;
pub mod profile;

View file

@ -0,0 +1,137 @@
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
use crate::api::{self, GameSummary, UserProfile};
#[component]
pub fn ProfilePage() -> impl IntoView {
let params = use_params_map();
let username = move || params.read().get("username").unwrap_or_default();
let profile = LocalResource::new(move || {
let u = username();
async move { api::get_user_profile(&u).await }
});
view! {
<div>
{move || match profile.get().map(|sw| sw.take()) {
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
Some(Ok(p)) => view! { <ProfileContent profile=p username=username() /> }.into_any(),
}}
</div>
}
}
#[component]
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
let page = RwSignal::new(0i64);
let games = LocalResource::new(move || {
let u = username.clone();
let p = page.get();
async move { api::get_user_games(&u, p).await }
});
let joined = crate::api::format_ts(profile.created_at);
view! {
<h1>{ profile.username.clone() }</h1>
<p style="color:#777;margin-bottom:1.5rem">"Joined: " { joined }</p>
<div class="stats-grid">
<div class="stat-box">
<div class="value">{ profile.total_games }</div>
<div class="label">"Games"</div>
</div>
<div class="stat-box">
<div class="value outcome-win">{ profile.wins }</div>
<div class="label">"Wins"</div>
</div>
<div class="stat-box">
<div class="value outcome-loss">{ profile.losses }</div>
<div class="label">"Losses"</div>
</div>
<div class="stat-box">
<div class="value outcome-draw">{ profile.draws }</div>
<div class="label">"Draws"</div>
</div>
</div>
<div class="card">
<h2>"Game History"</h2>
{move || match games.get().map(|sw| sw.take()) {
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
Some(Ok(r)) => {
if r.games.is_empty() {
view! { <p class="empty">"No games recorded yet."</p> }.into_any()
} else {
view! { <GamesTable games=r.games page=page /> }.into_any()
}
}
}}
</div>
}
}
#[component]
fn GamesTable(games: Vec<GameSummary>, page: RwSignal<i64>) -> impl IntoView {
let rows = games.clone();
let has_next = games.len() == 20;
view! {
<table>
<thead>
<tr>
<th>"Room"</th>
<th>"Started"</th>
<th>"Ended"</th>
<th>"Outcome"</th>
<th>"Detail"</th>
</tr>
</thead>
<tbody>
{rows.into_iter().map(|g| {
let started = crate::api::format_ts(g.started_at);
let ended = g.ended_at.map(crate::api::format_ts).unwrap_or_else(|| "".into());
let outcome_class = match g.outcome.as_deref() {
Some("win") => "outcome-win",
Some("loss") => "outcome-loss",
Some("draw") => "outcome-draw",
_ => "",
};
let outcome_text = g.outcome.clone().unwrap_or_else(|| "".into());
view! {
<tr>
<td>{ g.room_code.clone() }</td>
<td>{ started }</td>
<td>{ ended }</td>
<td class=outcome_class>{ outcome_text }</td>
<td>
<A href=format!("/games/{}", g.id)>"View"</A>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
<div style="display:flex;gap:0.75rem;margin-top:1rem;align-items:center">
{move || if page.get() > 0 {
view! {
<button class="btn" on:click=move |_| page.update(|p| *p -= 1)>"← Prev"</button>
}.into_any()
} else {
view! { <span /> }.into_any()
}}
<span style="color:#777">"Page " { move || page.get() + 1 }</span>
{if has_next {
view! {
<button class="btn" on:click=move |_| page.update(|p| *p += 1)>"Next →"</button>
}.into_any()
} else {
view! { <span /> }.into_any()
}}
</div>
}
}