chore: integrate multiplayer code (wip)
This commit is contained in:
parent
2838d59f30
commit
4f5e21becb
66 changed files with 6423 additions and 18 deletions
17
clients/web-user-portal/Cargo.toml
Normal file
17
clients/web-user-portal/Cargo.toml
Normal 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"] }
|
||||
2
clients/web-user-portal/Trunk.toml
Normal file
2
clients/web-user-portal/Trunk.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[serve]
|
||||
port = 9092
|
||||
103
clients/web-user-portal/assets/style.css
Normal file
103
clients/web-user-portal/assets/style.css
Normal 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; }
|
||||
11
clients/web-user-portal/index.html
Normal file
11
clients/web-user-portal/index.html
Normal 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>
|
||||
191
clients/web-user-portal/src/api.rs
Normal file
191
clients/web-user-portal/src/api.rs
Normal 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()
|
||||
}
|
||||
67
clients/web-user-portal/src/app.rs
Normal file
67
clients/web-user-portal/src/app.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
7
clients/web-user-portal/src/main.rs
Normal file
7
clients/web-user-portal/src/main.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod api;
|
||||
mod app;
|
||||
mod pages;
|
||||
|
||||
fn main() {
|
||||
leptos::mount::mount_to_body(app::App);
|
||||
}
|
||||
95
clients/web-user-portal/src/pages/game.rs
Normal file
95
clients/web-user-portal/src/pages/game.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
152
clients/web-user-portal/src/pages/home.rs
Normal file
152
clients/web-user-portal/src/pages/home.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
3
clients/web-user-portal/src/pages/mod.rs
Normal file
3
clients/web-user-portal/src/pages/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod game;
|
||||
pub mod home;
|
||||
pub mod profile;
|
||||
137
clients/web-user-portal/src/pages/profile.rs
Normal file
137
clients/web-user-portal/src/pages/profile.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue