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,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>
}
}