Make the web client game-generic against the platform API
Send creation options as the nested options object the lobby expects, drive the creation form from each game's options_schema, and render history/leaderboard from the generic result payload (nullable teams, float points). The lobby uses the payload's seats_open, and the scopone-specific table, hand-end and game-over views are gated on its game_type with an unsupported-game fallback for future games. The websocket protocol is unchanged.
This commit is contained in:
+34
-5
@@ -329,13 +329,20 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
}
|
||||
Some(g) if g.phase == "lobby" => lobby_view(g),
|
||||
Some(g) => table_view(g, on_hand_card, selected, now),
|
||||
// The live table is scopone-specific; other games get a
|
||||
// fallback until they ship their own play view.
|
||||
Some(g) if g.game_type == "scopone_scientifico" => {
|
||||
table_view(g, on_hand_card, selected, now)
|
||||
}
|
||||
Some(g) => unsupported_game_view(g),
|
||||
})
|
||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||
capture_picker(card, options, socket, capture_choice)
|
||||
}))
|
||||
(move || match game.get_clone() {
|
||||
Some(g) if g.phase == "hand_end" => hand_summary_modal(g, socket, now),
|
||||
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
||||
hand_summary_modal(g, socket, now)
|
||||
}
|
||||
_ => view! {},
|
||||
})
|
||||
(move || game_over_view(over.get_clone(), game.get_clone()))
|
||||
@@ -343,9 +350,13 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lobby view while waiting for the fourth player.
|
||||
/// Lobby view while waiting for the remaining players. The seat count
|
||||
/// comes from the payload (`seats_open`), so games with other player
|
||||
/// counts render correctly.
|
||||
fn lobby_view(game: GameView) -> View {
|
||||
let seats_open = 4usize.saturating_sub(game.players.len());
|
||||
let seats_open = game
|
||||
.seats_open
|
||||
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
||||
let join_code = game.join_code.clone();
|
||||
let players = game
|
||||
.players
|
||||
@@ -374,6 +385,18 @@ fn lobby_view(game: GameView) -> View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback for live games the web client has no play view for yet.
|
||||
fn unsupported_game_view(game: GameView) -> View {
|
||||
let game_type = game.game_type.clone();
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
h2 { "Unsupported game" }
|
||||
p { "Live play for “" (game_type) "” isn't supported in the web client yet." }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||
game.players.iter().find(|p| p.seat == seat).cloned()
|
||||
}
|
||||
@@ -537,8 +560,14 @@ fn capture_picker(
|
||||
}
|
||||
}
|
||||
|
||||
/// End-of-match overlay.
|
||||
/// End-of-match overlay (scopone's team score; other games render
|
||||
/// nothing until they ship their own play view).
|
||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||
if let Some(g) = &game {
|
||||
if g.game_type != "scopone_scientifico" {
|
||||
return view! {};
|
||||
}
|
||||
}
|
||||
let result = over.or_else(|| {
|
||||
game.clone()
|
||||
.filter(|g| g.phase == "finished")
|
||||
|
||||
+123
-30
@@ -1,10 +1,72 @@
|
||||
//! Match history page.
|
||||
//!
|
||||
//! Rendering is game-generic: teams, scores and the winner are read from
|
||||
//! each match's game-specific `result` object, degrading gracefully when
|
||||
//! a game reports a different shape.
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::MatchesPage;
|
||||
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
||||
|
||||
/// Distinct team labels in seat order; players without a team render in
|
||||
/// a shared "Players" column.
|
||||
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
|
||||
let mut teams = BTreeSet::new();
|
||||
for p in &m.players {
|
||||
teams.insert(p.team.clone());
|
||||
}
|
||||
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
|
||||
// Seated teams first (in first-seat order), then the team-less column.
|
||||
ordered.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
ordered
|
||||
}
|
||||
|
||||
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
||||
m.players
|
||||
.iter()
|
||||
.filter(|p| &p.team == team)
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ")
|
||||
}
|
||||
|
||||
fn team_header(team: &Option<String>) -> String {
|
||||
match team {
|
||||
Some(t) => format!("Team {t}"),
|
||||
None => "Players".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// "11 – 7" when the result carries both team scores, "—" otherwise.
|
||||
fn score_text(m: &MatchSummary) -> String {
|
||||
match (m.result_i64("team_a_score"), m.result_i64("team_b_score")) {
|
||||
(Some(a), Some(b)) => format!("{a} – {b}"),
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
||||
fn winner_text(m: &MatchSummary) -> String {
|
||||
if let Some(team) = m.result_str("winner_team") {
|
||||
return format!("Team {team}");
|
||||
}
|
||||
m.result_str("winner").unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn game_name(game_types: &[GameTypeInfo], game_type: &str) -> String {
|
||||
game_types
|
||||
.iter()
|
||||
.find(|g| g.id == game_type)
|
||||
.map(|g| g.name.clone())
|
||||
.unwrap_or_else(|| game_type.to_string())
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn HistoryPage() -> View {
|
||||
@@ -13,6 +75,7 @@ pub fn HistoryPage() -> View {
|
||||
let cursor = create_signal(Option::<String>::None);
|
||||
// Accumulated rows across "load more" clicks.
|
||||
let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
|
||||
let game_types = create_signal(Vec::<GameTypeInfo>::new());
|
||||
|
||||
let load = move |next: Option<String>| {
|
||||
spawn_local(async move {
|
||||
@@ -27,6 +90,12 @@ pub fn HistoryPage() -> View {
|
||||
});
|
||||
};
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(types) = api::game_types().await {
|
||||
game_types.set(types);
|
||||
}
|
||||
});
|
||||
|
||||
let load2 = load;
|
||||
spawn_local(async move {
|
||||
match api::my_matches(None).await {
|
||||
@@ -53,51 +122,75 @@ pub fn HistoryPage() -> View {
|
||||
p(class="status") { "No matches played yet." }
|
||||
},
|
||||
Some(_) => {
|
||||
let names = game_types.get_clone();
|
||||
let table_rows = rows
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let team_a: String = m
|
||||
.players
|
||||
let finished = m
|
||||
.finished_at
|
||||
.replace('T', " ")
|
||||
.chars()
|
||||
.take(16)
|
||||
.collect::<String>();
|
||||
let game = game_name(&names, &m.game_type);
|
||||
let mut cells: Vec<View> = team_columns(&m)
|
||||
.iter()
|
||||
.filter(|p| p.team == "A")
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ");
|
||||
let team_b: String = m
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| p.team == "B")
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ");
|
||||
.map(|t| team_names(&m, t))
|
||||
.map(|c| view! { td { (c) } })
|
||||
.collect();
|
||||
let score = score_text(&m);
|
||||
let winner = winner_text(&m);
|
||||
let outcome = if m.you_won { "Won" } else { "Lost" };
|
||||
let elo_delta = m
|
||||
.your_elo_delta
|
||||
.map(|d| if d >= 0 { format!("+{d}") } else { d.to_string() })
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
view! {
|
||||
tr {
|
||||
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
|
||||
td { (team_a) }
|
||||
td { (team_b) }
|
||||
td { (m.team_a_score) " – " (m.team_b_score) }
|
||||
td { "Team " (m.winner_team) }
|
||||
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
|
||||
td(class=if m.you_won { "won" } else { "lost" }) {
|
||||
(elo_delta)
|
||||
.map(|d| {
|
||||
if d >= 0 {
|
||||
format!("+{d}")
|
||||
} else {
|
||||
d.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
let cls = if m.you_won { "won" } else { "lost" };
|
||||
let mut row_cells = vec![
|
||||
view! { td { (finished) } },
|
||||
view! { td { (game) } },
|
||||
];
|
||||
row_cells.append(&mut cells);
|
||||
row_cells.push(view! { td { (score) } });
|
||||
row_cells.push(view! { td { (winner) } });
|
||||
row_cells.push(view! { td(class=cls) { (outcome) } });
|
||||
row_cells.push(view! { td(class=cls) { (elo_delta) } });
|
||||
view! { tr { (row_cells) } }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// Team columns are per-row (matches may mix games), so
|
||||
// the header shows the union across the loaded rows.
|
||||
let mut header_teams = BTreeSet::new();
|
||||
for m in rows.get_clone().iter() {
|
||||
for t in team_columns(m) {
|
||||
header_teams.insert(t);
|
||||
}
|
||||
}
|
||||
let mut header_teams: Vec<Option<String>> =
|
||||
header_teams.into_iter().collect();
|
||||
header_teams.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
let header_cells: Vec<View> = header_teams
|
||||
.iter()
|
||||
.map(team_header)
|
||||
.map(|h| view! { th { (h) } })
|
||||
.collect();
|
||||
view! {
|
||||
table(class="matches") {
|
||||
thead {
|
||||
tr {
|
||||
th { "Finished" }
|
||||
th { "Team A" }
|
||||
th { "Team B" }
|
||||
th { "Game" }
|
||||
(header_cells)
|
||||
th { "Score" }
|
||||
th { "Winner" }
|
||||
th { "You" }
|
||||
|
||||
@@ -4,7 +4,7 @@ use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::LeaderboardPage;
|
||||
use crate::model::{fmt_points, LeaderboardPage};
|
||||
|
||||
#[component]
|
||||
pub fn LeaderboardPage() -> View {
|
||||
@@ -42,7 +42,7 @@ pub fn LeaderboardPage() -> View {
|
||||
td { (e.elo) }
|
||||
td { (e.wins) }
|
||||
td { (e.matches) }
|
||||
td { (e.points) }
|
||||
td { (fmt_points(e.points)) }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+229
-27
@@ -1,11 +1,17 @@
|
||||
//! Lobby page: login prompt, match creation and joining by code.
|
||||
//!
|
||||
//! Match creation is game-generic: the form is rendered from the selected
|
||||
//! game's `options_schema` (see [`crate::model::GameTypeInfo`]), so new
|
||||
//! games get a working creation UI without frontend changes.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
use sycamore_router::navigate;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{GameTypeInfo, User};
|
||||
use crate::model::{GameTypeInfo, OptionField, OptionKind, PlayerRating, User};
|
||||
|
||||
/// Used when the game-types fetch fails: match creation must still work.
|
||||
fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
@@ -13,20 +19,184 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
id: "scopone_scientifico".to_string(),
|
||||
name: "Scopone scientifico".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 4,
|
||||
max_players: 4,
|
||||
options_schema: serde_json::Value::Null,
|
||||
}]
|
||||
}
|
||||
|
||||
fn json_to_string(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One creation option: renders the control matching the schema kind and
|
||||
/// writes the parsed value back into the shared `options` map.
|
||||
#[derive(Props)]
|
||||
struct OptionInputProps {
|
||||
field: OptionField,
|
||||
options: Signal<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn OptionInput(props: OptionInputProps) -> View {
|
||||
let field = props.field;
|
||||
let options: Signal<HashMap<String, serde_json::Value>> = props.options;
|
||||
let key = field.key.clone();
|
||||
let title = field.title.clone();
|
||||
match field.kind.clone() {
|
||||
OptionKind::Boolean => {
|
||||
let initial = options
|
||||
.get_clone()
|
||||
.get(&key)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or_else(|| field.default.as_bool().unwrap_or(false));
|
||||
let flag = create_signal(initial);
|
||||
let write_key = key.clone();
|
||||
let id_attr = key.clone();
|
||||
let for_attr = key;
|
||||
create_effect(move || {
|
||||
let value = flag.get_clone();
|
||||
options.update(|m| {
|
||||
m.insert(write_key.clone(), serde_json::Value::Bool(value));
|
||||
});
|
||||
});
|
||||
view! {
|
||||
label(class="check", r#for=for_attr) {
|
||||
input(id=id_attr, r#type="checkbox", bind:checked=flag)
|
||||
" " (title)
|
||||
}
|
||||
}
|
||||
}
|
||||
OptionKind::Enum { values } => {
|
||||
let initial = options
|
||||
.get_clone()
|
||||
.get(&key)
|
||||
.map(json_to_string)
|
||||
.unwrap_or_else(|| json_to_string(&field.default));
|
||||
let value = create_signal(initial);
|
||||
let default = field.default.clone();
|
||||
let write_key = key.clone();
|
||||
create_effect(move || {
|
||||
let text = value.get_clone();
|
||||
let parsed = if text.is_empty() {
|
||||
default.clone()
|
||||
} else {
|
||||
serde_json::Value::String(text)
|
||||
};
|
||||
options.update(|m| {
|
||||
m.insert(write_key.clone(), parsed);
|
||||
});
|
||||
});
|
||||
let items = create_signal(values);
|
||||
let id_attr = key.clone();
|
||||
let for_attr = key;
|
||||
view! {
|
||||
label(r#for=for_attr) { (title) }
|
||||
select(id=id_attr, bind:value=value) {
|
||||
Keyed(
|
||||
list=items,
|
||||
view=|v| {
|
||||
let val = v.clone();
|
||||
let label = v;
|
||||
view! { option(value=val) { (label) } }
|
||||
},
|
||||
key=|v| v.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OptionKind::Integer { min, max } => {
|
||||
let initial = options
|
||||
.get_clone()
|
||||
.get(&key)
|
||||
.map(json_to_string)
|
||||
.unwrap_or_else(|| json_to_string(&field.default));
|
||||
let value = create_signal(initial);
|
||||
let default = field.default.clone();
|
||||
let write_key = key.clone();
|
||||
create_effect(move || {
|
||||
let text = value.get_clone();
|
||||
let parsed = text
|
||||
.parse::<i64>()
|
||||
.map(serde_json::Value::from)
|
||||
.unwrap_or_else(|_| default.clone());
|
||||
options.update(|m| {
|
||||
m.insert(write_key.clone(), parsed);
|
||||
});
|
||||
});
|
||||
let min_attr = min.map(|m| m.to_string()).unwrap_or_default();
|
||||
let max_attr = max.map(|m| m.to_string()).unwrap_or_default();
|
||||
let id_attr = key.clone();
|
||||
let for_attr = key;
|
||||
view! {
|
||||
label(r#for=for_attr) { (title) }
|
||||
input(
|
||||
id=id_attr,
|
||||
r#type="number",
|
||||
min=min_attr,
|
||||
max=max_attr,
|
||||
bind:value=value,
|
||||
)
|
||||
}
|
||||
}
|
||||
OptionKind::Text => {
|
||||
let initial = options
|
||||
.get_clone()
|
||||
.get(&key)
|
||||
.map(json_to_string)
|
||||
.unwrap_or_else(|| json_to_string(&field.default));
|
||||
let value = create_signal(initial);
|
||||
let write_key = key.clone();
|
||||
create_effect(move || {
|
||||
let text = value.get_clone();
|
||||
options.update(|m| {
|
||||
m.insert(write_key.clone(), serde_json::Value::String(text));
|
||||
});
|
||||
});
|
||||
let id_attr = key.clone();
|
||||
let for_attr = key;
|
||||
view! {
|
||||
label(r#for=for_attr) { (title) }
|
||||
input(id=id_attr, r#type="text", bind:value=value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LobbyPage() -> View {
|
||||
// Outer None = still loading; Some(None) = logged out.
|
||||
let user = create_signal(Option::<Option<User>>::None);
|
||||
// The player's Elo rating for the first rated game; None while loading.
|
||||
let rating = create_signal(Option::<i32>::None);
|
||||
// The player's Elo ratings, one row per game type played.
|
||||
let ratings = create_signal(Vec::<PlayerRating>::new());
|
||||
let error = create_signal(Option::<String>::None);
|
||||
let code = create_signal(String::new());
|
||||
let game_types = create_signal(fallback_game_types());
|
||||
let selected_game = create_signal("scopone_scientifico".to_string());
|
||||
let napola = create_signal(true);
|
||||
// Current creation-form values (option key -> JSON value), reset to
|
||||
// the schema defaults whenever the selection changes.
|
||||
let options = create_signal(HashMap::<String, serde_json::Value>::new());
|
||||
let fields = create_signal(Vec::<OptionField>::new());
|
||||
|
||||
create_effect(move || {
|
||||
let id = selected_game.get_clone();
|
||||
let game = game_types.get_clone().into_iter().find(|g| g.id == id);
|
||||
match game {
|
||||
Some(g) => {
|
||||
options.set(g.default_options().into_iter().collect());
|
||||
fields.set(g.option_fields());
|
||||
}
|
||||
None => {
|
||||
options.set(HashMap::new());
|
||||
fields.set(Vec::new());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
match api::me().await {
|
||||
@@ -34,8 +204,7 @@ pub fn LobbyPage() -> View {
|
||||
if me.is_some() {
|
||||
spawn_local(async move {
|
||||
if let Ok(p) = api::my_ratings().await {
|
||||
// Unrated players sit at the initial 1500.
|
||||
rating.set(Some(p.results.first().map(|r| r.rating).unwrap_or(1500)));
|
||||
ratings.set(p.results);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -58,11 +227,12 @@ pub fn LobbyPage() -> View {
|
||||
}
|
||||
});
|
||||
|
||||
let on_create = move |target: i32| {
|
||||
let on_create = move |_| {
|
||||
let game_type = selected_game.get_clone();
|
||||
let napola = napola.get();
|
||||
let opts: serde_json::Map<String, serde_json::Value> =
|
||||
options.get_clone().into_iter().collect();
|
||||
spawn_local(async move {
|
||||
match api::create_game(&game_type, target, napola).await {
|
||||
match api::create_game(&game_type, serde_json::Value::Object(opts)).await {
|
||||
Ok(game) => navigate(&format!("/game/{}", game.id)),
|
||||
Err(e) => error.set(Some(e)),
|
||||
}
|
||||
@@ -84,7 +254,7 @@ pub fn LobbyPage() -> View {
|
||||
|
||||
view! {
|
||||
div(class="lobby") {
|
||||
h1 { "Scopone scientifico" }
|
||||
h1 { "tavolo" }
|
||||
(toast(error))
|
||||
(move || match user.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
@@ -102,9 +272,13 @@ pub fn LobbyPage() -> View {
|
||||
nav(class="top-nav") {
|
||||
span(class="whoami") {
|
||||
"Signed in as " strong { (me.name.clone()) }
|
||||
(rating.get_clone().map(|r| view! {
|
||||
span(class="rating") { " · Elo " (r) }
|
||||
}))
|
||||
(move || ratings
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.find(|r| r.game_type == selected_game.get_clone())
|
||||
.map(|r| view! {
|
||||
span(class="rating") { " · Elo " (r.rating) }
|
||||
}))
|
||||
}
|
||||
a(href="/history") { "My matches" }
|
||||
a(href="/leaderboard") { "Leaderboard" }
|
||||
@@ -120,20 +294,48 @@ pub fn LobbyPage() -> View {
|
||||
key=|g| g.id.clone(),
|
||||
)
|
||||
}
|
||||
label(class="check", r#for="napola") {
|
||||
input(id="napola", r#type="checkbox", bind:checked=napola)
|
||||
" Napola"
|
||||
}
|
||||
p(class="hint") {
|
||||
"A-2-3 of denari scores 3 points, plus 1 per extra "
|
||||
"consecutive denari card; sweeping the whole suit "
|
||||
"wins the match instantly."
|
||||
}
|
||||
p { "First team to reach the target score wins." }
|
||||
div(class="target-buttons") {
|
||||
button(class="button", on:click=move |_| on_create(11)) { "Target 11" }
|
||||
button(class="button", on:click=move |_| on_create(16)) { "Target 16" }
|
||||
button(class="button", on:click=move |_| on_create(21)) { "Target 21" }
|
||||
(move || game_types
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.find(|g| g.id == selected_game.get_clone())
|
||||
.map(|g| {
|
||||
let players_note = match (g.min_players, g.max_players) {
|
||||
(0, 0) => None,
|
||||
(min, max) if min == max => {
|
||||
Some(format!("{min} players"))
|
||||
}
|
||||
(min, max) => {
|
||||
Some(format!("{min}–{max} players"))
|
||||
}
|
||||
};
|
||||
let description: View = if g.description.is_empty() {
|
||||
view! {}
|
||||
} else {
|
||||
let text = g.description.clone();
|
||||
view! {
|
||||
p(class="hint") { (text) }
|
||||
}
|
||||
};
|
||||
let note: View = match players_note {
|
||||
Some(note) => view! {
|
||||
p(class="hint") { (note) }
|
||||
},
|
||||
None => view! {},
|
||||
};
|
||||
view! {
|
||||
(description)
|
||||
(note)
|
||||
}
|
||||
}))
|
||||
Keyed(
|
||||
list=fields,
|
||||
view=move |f| view! {
|
||||
OptionInput(field=f.clone(), options=options)
|
||||
},
|
||||
key=|f| f.key.clone(),
|
||||
)
|
||||
button(class="button primary", on:click=on_create) {
|
||||
"Create match"
|
||||
}
|
||||
}
|
||||
div(class="panel") {
|
||||
|
||||
Reference in New Issue
Block a user