Compare commits

..

7 Commits

Author SHA1 Message Date
Daan Vanoverloop 0b9318b5c4
Bugfixes 2022-09-16 14:22:40 +02:00
Daan Vanoverloop c1a61d28a4
Bugfix 2022-09-16 12:01:34 +02:00
Daan Vanoverloop f2d6d3a20f
It's a buggy mess, but it works 2022-09-15 16:32:42 +02:00
Daan Vanoverloop 3ef2c282b0
Frontend refactor and generics in macros 2022-09-15 11:46:20 +02:00
Daan Vanoverloop 59eeabc888
Temp 2022-09-13 18:39:38 +02:00
Daan Vanoverloop 44745b252a
Messages 2022-09-12 20:26:41 +02:00
Daan Vanoverloop 667f2c5bbf
Cleanup 2022-09-12 18:09:24 +02:00
28 changed files with 1365 additions and 1191 deletions

49
Cargo.lock generated
View File

@ -376,8 +376,18 @@ version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.13.4",
"darling_macro", "darling_macro 0.13.4",
]
[[package]]
name = "darling"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4529658bdda7fd6769b8614be250cdcfc3aeb0ee72fe66f9e41e5e5eb73eac02"
dependencies = [
"darling_core 0.14.1",
"darling_macro 0.14.1",
] ]
[[package]] [[package]]
@ -394,13 +404,38 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "darling_core"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "649c91bc01e8b1eac09fb91e8dbc7d517684ca6be8ebc75bb9cafc894f9fdb6f"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.13.4" version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.13.4",
"quote",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddfc69c5bfcbd2fc09a0f38451d2daf0e372e367986a83906d1b0dbc88134fb5"
dependencies = [
"darling_core 0.14.1",
"quote", "quote",
"syn", "syn",
] ]
@ -1173,6 +1208,7 @@ name = "lan_party_macros"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"convert_case", "convert_case",
"darling 0.14.1",
"paste", "paste",
"quote", "quote",
"sycamore", "sycamore",
@ -1184,7 +1220,10 @@ name = "lan_party_web"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"console_log", "console_log",
"futures",
"gloo-timers",
"js-sys", "js-sys",
"lan_party_core", "lan_party_core",
"log", "log",
@ -1964,7 +2003,7 @@ version = "0.8.0-rc.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54f94d1ffe41472e08463d7a2674f1db04dc4df745285e8369b33d3cfd6b0308" checksum = "54f94d1ffe41472e08463d7a2674f1db04dc4df745285e8369b33d3cfd6b0308"
dependencies = [ dependencies = [
"darling", "darling 0.13.4",
"proc-macro2", "proc-macro2",
"quote", "quote",
"rocket_http", "rocket_http",
@ -2212,7 +2251,7 @@ version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082"
dependencies = [ dependencies = [
"darling", "darling 0.13.4",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",

View File

@ -14,13 +14,14 @@ api_routes!(
update_event, update_event,
get_all_events, get_all_events,
event_outcome, event_outcome,
delete_event,
); );
pub async fn apply_outcome(outcome: &EventOutcome, db: &Connection<Db>) -> Result<(), PartyError> { pub async fn apply_outcome(outcome: &EventOutcome, db: &Connection<Db>) -> Result<(), PartyError> {
for (player, reward) in outcome.points.iter() { for (player, reward) in outcome.points.iter() {
db.users() db.users()
.update_one( .update_one(
doc! { "id": player }, doc! { "name": player.to_string() },
doc! { "$inc": { "score": reward } }, doc! { "$inc": { "score": reward } },
None, None,
) )
@ -164,3 +165,16 @@ pub async fn stop_event(
Ok(Json(outcome)) Ok(Json(outcome))
} }
/// # Delete event by name
#[openapi(tag = "Event")]
#[delete("/<name>")]
pub async fn delete_event(
_api_key: ApiKey,
db: Connection<Db>,
name: String,
) -> Result<Status, PartyError> {
db.events().delete_one(doc! { "name": name }, None).await?;
Ok(Status::Ok)
}

View File

@ -1,4 +1,10 @@
use rocket::{http::Status, response, response::Responder, Request}; use std::borrow::Borrow;
use rocket::{
http::Status,
response::{self, status, Responder},
Request,
};
use rocket_db_pools::mongodb; use rocket_db_pools::mongodb;
use rocket_okapi::response::OpenApiResponderInner; use rocket_okapi::response::OpenApiResponderInner;
use schemars::JsonSchema; use schemars::JsonSchema;
@ -30,7 +36,7 @@ impl ToString for Ordering {
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum PartyError { pub enum PartyError {
#[error("internal error {source:?}")] #[error("{source}")]
CoreError { CoreError {
#[from] #[from]
source: CoreError, source: CoreError,
@ -68,13 +74,16 @@ impl OpenApiResponderInner for PartyError {
impl<'r, 'o: 'r> Responder<'r, 'o> for PartyError { impl<'r, 'o: 'r> Responder<'r, 'o> for PartyError {
fn respond_to(self, req: &'r Request<'_>) -> response::Result<'o> { fn respond_to(self, req: &'r Request<'_>) -> response::Result<'o> {
println!("{:#?}", &self); println!("{}", self.to_string());
let message = self.to_string();
match self { match self {
Self::CoreError { source } => match source { Self::CoreError { source } => match source {
CoreError::UserNotFound(_) | CoreError::EventNotFound(_) => Status::NotFound, CoreError::UserNotFound(_) | CoreError::EventNotFound(_) => {
_ => Status::InternalServerError, (Status::NotFound, "not found".to_string())
}
_ => (Status::BadRequest, message),
}, },
_ => Status::InternalServerError, _ => (Status::InternalServerError, "unknown error".to_string()),
} }
.respond_to(req) .respond_to(req)
} }

View File

@ -67,7 +67,7 @@ pub fn Block<'a, G: Html>(cx: Scope<'a>, props: BlockProps<'a, G>) -> View<G> {
let children = props.children.call(cx); let children = props.children.call(cx);
view! { cx, view! { cx,
details { details(open=true) {
summary { (props.title) } summary { (props.title) }
p { (children) } p { (children) }
} }

View File

@ -5,7 +5,11 @@ use std::{
str::FromStr, str::FromStr,
}; };
use crate::components::Block; use crate::{
components::Block,
util::{ContextOptions, WithContext},
view::Viewable,
};
use log::debug; use log::debug;
use paste::paste; use paste::paste;
use sycamore::prelude::*; use sycamore::prelude::*;
@ -284,7 +288,6 @@ where
vec.get() vec.get()
.as_ref() .as_ref()
.iter() .iter()
.cloned()
.map(|x| x.get().as_ref().clone()) .map(|x| x.get().as_ref().clone())
.collect(), .collect(),
) )
@ -459,6 +462,58 @@ impl From<User> for WithLabel<String> {
} }
} }
pub struct ContextEdit;
impl<'a, G, Ctx, T> Editor<'a, G, WithContext<Ctx, T>> for ContextEdit
where
G: Html,
Ctx: ContextOptions<T> + 'static,
T: FromStr + ToString + Default + Clone + PartialEq,
{
fn edit(cx: Scope<'a>, props: EditProps<'a, WithContext<Ctx, T>>) -> View<G> {
let ctx = use_context::<Ctx>(cx);
let value = create_signal(cx, props.state.get_untracked().to_string());
create_effect(cx, move || {
if value.get_untracked().is_empty() {
if let Some(first) = ctx.options(cx).get().get(0) {
value.set(first.to_string());
}
}
});
create_effect(cx, move || {
props
.state
.set((*value.get()).parse::<T>().unwrap_or(T::default()).into())
});
view! { cx,
select(bind:value=value) {
Indexed(
iterable=ctx.options(cx),
view=move |cx: BoundedScope<'_, 'a>, x| {
let x = create_ref(cx, x);
view! { cx,
option(value=x.to_string(), selected=x.to_string()==*value.get()) { (x.to_string()) }
}
},
)
}
" "
}
}
}
impl<'a, G, Ctx, T> Editable<'a, G> for WithContext<Ctx, T>
where
G: Html,
Ctx: ContextOptions<T> + 'static,
T: FromStr + ToString + Default + Clone + PartialEq,
{
type Editor = ContextEdit;
}
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]
pub struct Test { pub struct Test {
inner: TestInner, inner: TestInner,

View File

@ -1,8 +1,11 @@
#[cfg(feature = "sycamore")] #[cfg(feature = "sycamore")]
use crate::edit::prelude::*; use crate::edit::prelude::*;
use crate::util::PartyError;
#[cfg(feature = "sycamore")] #[cfg(feature = "sycamore")]
use crate::view::prelude::*; use crate::view::prelude::*;
use crate::{
state::Users,
util::{PartyError, WithContext},
};
#[cfg(feature = "sycamore")] #[cfg(feature = "sycamore")]
use lan_party_macros::{WebEdit, WebView}; use lan_party_macros::{WebEdit, WebView};
use paste::paste; use paste::paste;
@ -10,13 +13,54 @@ use paste::paste;
use schemars::JsonSchema; use schemars::JsonSchema;
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::{
collections::{HashMap, HashSet},
hash::Hash,
};
#[derive(Clone, Debug, Default)] type User = WithContext<Users, String>;
type Team = String;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub enum Ranking<T: Hash + PartialEq + Eq + Clone + Default + std::fmt::Debug> {
/// Ranking of participants by user name or team name (first element is first place, second element is second
/// place, etc.)
Ranking(Vec<T>),
/// Score based ranking of participants/teams
Scores(HashMap<T, i64>),
}
impl<T: Hash + PartialEq + Eq + Clone + Default + std::fmt::Debug> Default for Ranking<T> {
fn default() -> Self {
Self::Ranking(Vec::default())
}
}
impl<T: Hash + PartialEq + Eq + Clone + Default + std::fmt::Debug> Ranking<T> {
pub fn is_valid(&self, participants: &HashSet<T>) -> bool {
match self {
Self::Ranking(v) => v.iter().all(|p| participants.contains(p)),
Self::Scores(m) => m.keys().all(|p| participants.contains(p)),
}
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EventOutcome { pub struct EventOutcome {
pub points: HashMap<String, i64>, pub points: HashMap<User, i64>,
}
impl Default for EventOutcome {
fn default() -> Self {
Self {
points: HashMap::<User, _>::default(),
}
}
} }
/// # Event /// # Event
@ -26,6 +70,7 @@ pub struct EventOutcome {
#[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "sycamore", derive(WebView))] #[cfg_attr(feature = "sycamore", derive(WebView))]
#[cfg_attr(feature = "sycamore", view(title = "name"))]
pub struct Event { pub struct Event {
/// Has this event concluded? /// Has this event concluded?
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
@ -104,6 +149,10 @@ macro_rules! events {
impl EventSpec { impl EventSpec {
pub fn create_event(self) -> Result<Event, PartyError> { pub fn create_event(self) -> Result<Event, PartyError> {
if self.name.is_empty() {
return Err(PartyError::Other("invalid name".into()))
}
let event_type = match self.event_type { let event_type = match self.event_type {
$(EventTypeSpec::$name(s) => { $(EventTypeSpec::$name(s) => {
EventType::$name($module::$name::from_spec(s)) EventType::$name($module::$name::from_spec(s))
@ -127,7 +176,7 @@ macro_rules! events {
$((EventType::$name(s), EventUpdate::$name(u)) => { $((EventType::$name(s), EventUpdate::$name(u)) => {
s.apply_update(u) s.apply_update(u)
})* })*
_ => Err(PartyError::Unknown("invalid update".into())), _ => Err(PartyError::Other("invalid update: update type does not match event type".into())),
} }
} }
@ -204,19 +253,17 @@ pub mod test {
fn outcome(&self) -> EventOutcome { fn outcome(&self) -> EventOutcome {
let mut points = HashMap::new(); let mut points = HashMap::new();
points.insert("420".into(), self.num_players); points.insert("420".to_string().into(), self.num_players);
EventOutcome { points } EventOutcome { points }
} }
} }
} }
pub mod team_game { pub mod team_game {
use std::ops::Deref;
use super::{ use super::{
free_for_all_game::{ free_for_all_game::{FreeForAllGame, FreeForAllGameSpec, FreeForAllGameUpdate},
FreeForAllGame, FreeForAllGameSpec, FreeForAllGameUpdate,
FreeForAllGameUpdateParticipants, FreeForAllGameUpdateRanking,
FreeForAllGameUpdateRewards,
},
*, *,
}; };
@ -226,7 +273,7 @@ pub mod team_game {
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub struct TeamGame { pub struct TeamGame {
/// Map of teams with a name as key and an array of players as value /// Map of teams with a name as key and an array of players as value
pub teams: HashMap<String, Vec<String>>, pub teams: HashMap<Team, Vec<User>>,
#[cfg_attr(feature = "serde", serde(flatten))] #[cfg_attr(feature = "serde", serde(flatten))]
pub ffa_game: FreeForAllGame, pub ffa_game: FreeForAllGame,
@ -238,7 +285,7 @@ pub mod team_game {
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub struct TeamGameSpec { pub struct TeamGameSpec {
/// Map of teams with a name as key and an array of players as value /// Map of teams with a name as key and an array of players as value
pub teams: HashMap<String, Vec<String>>, pub teams: HashMap<Team, Vec<User>>,
/// Rewards for winning the game (first element for first place, second element for second /// Rewards for winning the game (first element for first place, second element for second
/// place, etc.) /// place, etc.)
@ -261,44 +308,8 @@ pub mod team_game {
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub struct TeamGameUpdateSetTeam { pub struct TeamGameUpdateSetTeam {
pub team: String, pub team: Team,
pub members: Vec<String>, pub members: Vec<User>,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub enum TeamGameUpdateInner {
/// Add or replace a team with the given name and array of members
SetTeam(TeamGameUpdateSetTeam),
/// Remove team with given name
RemoveTeam(String),
}
impl Default for TeamGameUpdateInner {
fn default() -> Self {
Self::SetTeam(TeamGameUpdateSetTeam::default())
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(untagged))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub enum TeamGameFfaInheritedUpdate {
/// Change the ranking and scores
Ranking(FreeForAllGameUpdateRanking),
/// Update rewards
Rewards(FreeForAllGameUpdateRewards),
}
impl Default for TeamGameFfaInheritedUpdate {
fn default() -> Self {
Self::Ranking(FreeForAllGameUpdateRanking::default())
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -307,19 +318,34 @@ pub mod team_game {
#[cfg_attr(feature = "serde", serde(untagged))] #[cfg_attr(feature = "serde", serde(untagged))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub enum TeamGameUpdate { pub enum TeamGameUpdate {
/// # Team /// Add or replace a team with the given name and array of members
/// SetTeam(TeamGameUpdateSetTeam),
/// Team specific updates /// Remove team with given name
Team(TeamGameUpdateInner), RemoveTeam(Team),
/// # Other /// Replace the current ranking with the given ranking
/// SetRanking(Ranking<Team>),
/// Inherited from FreeForAllGame /// If the current ranking is of type `Scores`, apply the given score deltas
Ffa(TeamGameFfaInheritedUpdate), ScoreDelta(HashMap<Team, i64>),
/// Set rewards for winning the game
SetWinRewards(Vec<i64>),
/// Set rewards for losing the game
SetLoseRewards(Vec<i64>),
} }
impl Default for TeamGameUpdate { impl Default for TeamGameUpdate {
fn default() -> Self { fn default() -> Self {
TeamGameUpdate::Team(TeamGameUpdateInner::default()) TeamGameUpdate::SetTeam(TeamGameUpdateSetTeam::default())
}
}
impl From<Ranking<Team>> for Ranking<User> {
fn from(r: Ranking<Team>) -> Self {
match r {
Ranking::Scores(s) => {
Ranking::Scores(s.into_iter().map(|(k, v)| (k.into(), v)).collect())
}
Ranking::Ranking(s) => Ranking::Ranking(s.into_iter().map(|k| k.into()).collect()),
}
} }
} }
@ -329,7 +355,7 @@ pub mod team_game {
fn from_spec(spec: TeamGameSpec) -> Self { fn from_spec(spec: TeamGameSpec) -> Self {
let ffa_game_spec = FreeForAllGameSpec { let ffa_game_spec = FreeForAllGameSpec {
participants: spec.teams.keys().cloned().collect(), participants: spec.teams.keys().map(|k| User::from(k.clone())).collect(),
win_rewards: spec.win_rewards, win_rewards: spec.win_rewards,
lose_rewards: spec.lose_rewards, lose_rewards: spec.lose_rewards,
}; };
@ -342,32 +368,42 @@ pub mod team_game {
fn apply_update(&mut self, update: Self::Update) -> Result<(), PartyError> { fn apply_update(&mut self, update: Self::Update) -> Result<(), PartyError> {
match update { match update {
TeamGameUpdate::Ffa(update) => match update { TeamGameUpdate::SetRanking(x) => self
TeamGameFfaInheritedUpdate::Ranking(u) => { .ffa_game
self.ffa_game.apply_update(FreeForAllGameUpdate::Ranking(u)) .apply_update(FreeForAllGameUpdate::SetRanking(x.into())),
} TeamGameUpdate::ScoreDelta(x) => {
TeamGameFfaInheritedUpdate::Rewards(u) => { self.ffa_game.apply_update(FreeForAllGameUpdate::ScoreDelta(
self.ffa_game.apply_update(FreeForAllGameUpdate::Rewards(u)) x.into_iter().map(|(k, v)| (k.into(), v)).collect(),
} ))
}, }
TeamGameUpdate::Team(update) => match update { TeamGameUpdate::SetWinRewards(x) => self
TeamGameUpdateInner::SetTeam(u) => { .ffa_game
self.ffa_game .apply_update(FreeForAllGameUpdate::SetWinRewards(x)),
.apply_update(FreeForAllGameUpdate::Participants( TeamGameUpdate::SetLoseRewards(x) => self
FreeForAllGameUpdateParticipants::AddParticipant(u.team.clone()), .ffa_game
))?; .apply_update(FreeForAllGameUpdate::SetLoseRewards(x)),
self.teams.insert(u.team, u.members); TeamGameUpdate::SetTeam(u) => {
Ok(()) self.ffa_game
} .apply_update(FreeForAllGameUpdate::AddParticipant(
TeamGameUpdateInner::RemoveTeam(team) => { u.team.clone().into(),
self.ffa_game ))?;
.apply_update(FreeForAllGameUpdate::Participants( self.teams.insert(
FreeForAllGameUpdateParticipants::RemoveParticipant(team.clone()), u.team,
))?; u.members
self.teams.remove(&team); .into_iter()
Ok(()) .map(|t| (t.clone()).deref().clone().into())
} .collect(),
}, );
Ok(())
}
TeamGameUpdate::RemoveTeam(team) => {
self.ffa_game
.apply_update(FreeForAllGameUpdate::RemoveParticipant(
team.clone().into(),
))?;
self.teams.remove(&team);
Ok(())
}
} }
} }
@ -377,10 +413,10 @@ pub mod team_game {
let mut points = HashMap::new(); let mut points = HashMap::new();
for (team, reward) in ffa_outcome.points.iter() { for (team, reward) in ffa_outcome.points.iter() {
if let Some(team) = self.teams.get(team) { if let Some(team) = self.teams.get(&*team.to_string()) {
for player in team { for player in team {
let score = points.get(player).unwrap_or(&0); let score = points.get(&player.clone().into()).unwrap_or(&0);
points.insert(player.clone(), score + reward); points.insert(User::from(player.clone()), score + reward);
} }
} }
} }
@ -395,32 +431,15 @@ pub mod free_for_all_game {
use super::*; use super::*;
#[derive(Clone, Debug, PartialEq)] /*
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub enum FreeForAllGameRanking { pub struct User {
/// Ranking of participants by user name or team name (first element is first place, second element is second name: String,
/// place, etc.)
Ranking(Vec<String>),
/// Score based ranking of participants/teams
Scores(HashMap<String, i64>),
}
impl Default for FreeForAllGameRanking {
fn default() -> Self {
Self::Ranking(Vec::default())
}
}
impl FreeForAllGameRanking {
pub fn is_valid(&self, participants: &HashSet<String>) -> bool {
match self {
Self::Ranking(v) => v.iter().all(|p| participants.contains(p)),
Self::Scores(m) => m.keys().all(|p| participants.contains(p)),
}
}
} }
*/
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "openapi", derive(JsonSchema))]
@ -429,7 +448,7 @@ pub mod free_for_all_game {
pub struct FreeForAllGame { pub struct FreeForAllGame {
/// Ranking of participants by user name or team name (first element is first place, second element is second /// Ranking of participants by user name or team name (first element is first place, second element is second
/// place, etc.) /// place, etc.)
pub ranking: Option<FreeForAllGameRanking>, pub ranking: Option<Ranking<User>>,
/// Specification of the game /// Specification of the game
#[cfg_attr(feature = "serde", serde(flatten))] #[cfg_attr(feature = "serde", serde(flatten))]
@ -450,7 +469,7 @@ pub mod free_for_all_game {
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub struct FreeForAllGameSpec { pub struct FreeForAllGameSpec {
/// Array of user ids that participate in the game /// Array of user ids that participate in the game
pub participants: HashSet<String>, pub participants: HashSet<User>,
/// Rewards for winning the game (first element for first place, second element for second /// Rewards for winning the game (first element for first place, second element for second
/// place, etc.) /// place, etc.)
@ -462,80 +481,31 @@ pub mod free_for_all_game {
pub lose_rewards: Vec<i64>, pub lose_rewards: Vec<i64>,
} }
#[derive(Clone, Debug)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub enum FreeForAllGameUpdateRanking {
/// Replace the current ranking with the given ranking
SetRanking(FreeForAllGameRanking),
/// If the current ranking is of type `Scores`, apply the given score deltas
ScoreDelta(HashMap<String, i64>),
}
impl Default for FreeForAllGameUpdateRanking {
fn default() -> Self {
Self::SetRanking(FreeForAllGameRanking::default())
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub enum FreeForAllGameUpdateRewards {
/// Set rewards for winning the game
SetWinRewards(Vec<i64>),
/// Set rewards for losing the game
SetLoseRewards(Vec<i64>),
}
impl Default for FreeForAllGameUpdateRewards {
fn default() -> Self {
Self::SetWinRewards(Vec::default())
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub enum FreeForAllGameUpdateParticipants {
/// Set list of participants participating in the game
SetParticipants(HashSet<String>),
/// Add participant by name
AddParticipant(String),
/// Remove participant by name
RemoveParticipant(String),
}
impl Default for FreeForAllGameUpdateParticipants {
fn default() -> Self {
Self::SetParticipants(HashSet::default())
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "openapi", derive(JsonSchema))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(untagged))] #[cfg_attr(feature = "serde", serde(untagged))]
#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))]
pub enum FreeForAllGameUpdate { pub enum FreeForAllGameUpdate {
/// Change the ranking and scores /// Replace the current ranking with the given ranking
Ranking(FreeForAllGameUpdateRanking), SetRanking(Ranking<User>),
/// Update rewards /// If the current ranking is of type `Scores`, apply the given score deltas
Rewards(FreeForAllGameUpdateRewards), ScoreDelta(HashMap<User, i64>),
/// Update participants /// Set rewards for winning the game
Participants(FreeForAllGameUpdateParticipants), SetWinRewards(Vec<i64>),
/// Set rewards for losing the game
SetLoseRewards(Vec<i64>),
/// Set list of participants participating in the game
SetParticipants(HashSet<User>),
/// Add participant by name
AddParticipant(User),
/// Remove participant by name
RemoveParticipant(User),
} }
impl Default for FreeForAllGameUpdate { impl Default for FreeForAllGameUpdate {
fn default() -> Self { fn default() -> Self {
Self::Ranking(FreeForAllGameUpdateRanking::default()) Self::AddParticipant(String::new().into())
} }
} }
@ -552,71 +522,65 @@ pub mod free_for_all_game {
fn apply_update(&mut self, update: Self::Update) -> Result<(), PartyError> { fn apply_update(&mut self, update: Self::Update) -> Result<(), PartyError> {
match update { match update {
FreeForAllGameUpdate::Ranking(update) => match update { FreeForAllGameUpdate::SetRanking(r) => {
FreeForAllGameUpdateRanking::SetRanking(r) => { if !r.is_valid(&self.spec.participants) {
if !r.is_valid(&self.spec.participants) { return Err(PartyError::Other("invalid ranking, all participants mentioned in ranking must be participating".into()));
return Err(PartyError::Unknown("invalid ranking, all participants mentioned in ranking must be participating".into()));
}
self.ranking = Some(r)
} }
FreeForAllGameUpdateRanking::ScoreDelta(d) => match &mut self.ranking { self.ranking = Some(r)
Some(FreeForAllGameRanking::Ranking(_)) | None => { }
return Err(PartyError::Unknown("cannot apply score delta".into())) FreeForAllGameUpdate::ScoreDelta(d) => match &mut self.ranking {
Some(Ranking::Ranking(_)) => {
return Err(PartyError::Other("cannot apply score delta".into()))
}
None => self.ranking = Some(Ranking::Scores(d)),
Some(Ranking::Scores(s)) => {
for (participant, delta) in d.iter() {
let value = s.get(participant).unwrap_or(&0);
s.insert(participant.clone(), value + delta);
} }
Some(FreeForAllGameRanking::Scores(s)) => { }
for (participant, delta) in d.iter() {
if let Some(value) = s.get(participant) {
s.insert(participant.clone(), value + delta);
}
}
}
},
}, },
FreeForAllGameUpdate::Participants(update) => match update { FreeForAllGameUpdate::AddParticipant(name) => {
FreeForAllGameUpdateParticipants::AddParticipant(name) => { self.spec.participants.insert(name);
self.spec.participants.insert(name); }
} FreeForAllGameUpdate::RemoveParticipant(name) => {
FreeForAllGameUpdateParticipants::RemoveParticipant(name) => { self.spec.participants.remove(&name);
self.spec.participants.remove(&name);
if !self if !self
.ranking .ranking
.as_ref() .as_ref()
.map(|r| r.is_valid(&self.spec.participants)) .map(|r| r.is_valid(&self.spec.participants))
.unwrap_or(true) .unwrap_or(true)
{ {
self.spec.participants.insert(name); self.spec.participants.insert(name);
return Err(PartyError::Unknown("cannot remove participant, all participants mentioned in ranking must be participating".into())); return Err(PartyError::Other("cannot remove participant, all participants mentioned in ranking must be participating".into()));
}
} }
FreeForAllGameUpdateParticipants::SetParticipants(participants) => { }
if !self FreeForAllGameUpdate::SetParticipants(participants) => {
.ranking if !self
.as_ref() .ranking
.map(|r| r.is_valid(&participants)) .as_ref()
.unwrap_or(true) .map(|r| r.is_valid(&participants))
{ .unwrap_or(true)
return Err(PartyError::Unknown("invalid list of participants, all participants mentioned in ranking must be participating".into())); {
} return Err(PartyError::Other("invalid list of participants, all participants mentioned in ranking must be participating".into()));
self.spec.participants = participants;
} }
}, self.spec.participants = participants;
FreeForAllGameUpdate::Rewards(update) => match update { }
FreeForAllGameUpdateRewards::SetWinRewards(rewards) => { FreeForAllGameUpdate::SetWinRewards(rewards) => {
self.spec.win_rewards = rewards; self.spec.win_rewards = rewards;
} }
FreeForAllGameUpdateRewards::SetLoseRewards(rewards) => { FreeForAllGameUpdate::SetLoseRewards(rewards) => {
self.spec.lose_rewards = rewards; self.spec.lose_rewards = rewards;
} }
},
} }
Ok(()) Ok(())
} }
fn outcome(&self) -> EventOutcome { fn outcome(&self) -> EventOutcome {
let ranking = match &self.ranking { let ranking = match &self.ranking {
Some(FreeForAllGameRanking::Ranking(r)) => r.clone(), Some(Ranking::Ranking(r)) => r.clone(),
Some(FreeForAllGameRanking::Scores(s)) => { Some(Ranking::Scores(s)) => {
let mut results: Vec<(_, _)> = s.iter().collect(); let mut results: Vec<(_, _)> = s.iter().collect();
results.sort_by(|a, b| b.1.cmp(a.1)); results.sort_by(|a, b| b.1.cmp(a.1));
results.into_iter().map(|(k, _)| k.clone()).collect() results.into_iter().map(|(k, _)| k.clone()).collect()

View File

@ -1,4 +1,5 @@
pub mod event; pub mod event;
pub mod state;
pub mod user; pub mod user;
pub mod util; pub mod util;

53
core/src/state.rs Normal file
View File

@ -0,0 +1,53 @@
#[cfg(feature = "sycamore")]
use sycamore::prelude::*;
use crate::{event::Event, user::User};
#[cfg(feature = "sycamore")]
use crate::util::ContextOptions;
#[cfg(feature = "sycamore")]
#[derive(Clone, PartialEq, Default)]
pub struct Users(pub RcSignal<Vec<User>>);
#[cfg(not(feature = "sycamore"))]
#[derive(Clone, PartialEq, Default)]
pub struct Users;
#[cfg(feature = "sycamore")]
impl Users {
pub fn get(&self) -> &RcSignal<Vec<User>> {
&self.0
}
}
#[cfg(feature = "sycamore")]
impl ContextOptions<String> for Users {
fn options<'a>(&'a self, cx: Scope<'a>) -> &'a ReadSignal<Vec<String>> {
self.get()
.map(cx, |v| v.iter().map(|u| u.name.clone()).collect())
}
}
#[cfg(feature = "sycamore")]
#[derive(Clone, PartialEq, Default)]
pub struct Events(pub RcSignal<Vec<Event>>);
#[cfg(not(feature = "sycamore"))]
#[derive(Clone, PartialEq, Default)]
pub struct Events;
#[cfg(feature = "sycamore")]
impl Events {
pub fn get(&self) -> &RcSignal<Vec<Event>> {
&self.0
}
}
#[cfg(feature = "sycamore")]
impl ContextOptions<String> for Events {
fn options<'a>(&'a self, cx: Scope<'a>) -> &'a ReadSignal<Vec<String>> {
self.get()
.map(cx, |v| v.iter().map(|u| u.name.clone()).collect())
}
}

View File

@ -4,6 +4,12 @@ use rocket::FromFormField;
use schemars::JsonSchema; use schemars::JsonSchema;
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{
fmt::Debug,
hash::Hash,
marker::PhantomData,
ops::{Deref, DerefMut},
};
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -12,8 +18,8 @@ pub enum PartyError {
UserNotFound(String), UserNotFound(String),
#[error("event `{0}` does not exist")] #[error("event `{0}` does not exist")]
EventNotFound(String), EventNotFound(String),
#[error("unknown error: {0}")] #[error("{0}")]
Unknown(String), Other(String),
#[error("invalid parameter: {0}")] #[error("invalid parameter: {0}")]
InvalidParameter(String), InvalidParameter(String),
} }
@ -42,3 +48,86 @@ impl ToString for Ordering {
.into() .into()
} }
} }
#[derive(Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[serde(transparent)]
pub struct WithContext<Ctx, T> {
#[serde(skip)]
_ctx: PhantomData<Ctx>,
inner: T,
}
#[cfg(feature = "openapi")]
impl<Ctx, T> JsonSchema for WithContext<Ctx, T>
where
T: JsonSchema,
{
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
T::json_schema(gen)
}
fn schema_name() -> String {
T::schema_name()
}
}
impl<Ctx, T> Debug for WithContext<Ctx, T>
where
T: Debug,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.inner.fmt(f)
}
}
impl<Ctx, T> Hash for WithContext<Ctx, T>
where
T: Hash,
{
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.inner.hash(state)
}
}
impl<Ctx, T> PartialEq for WithContext<Ctx, T>
where
T: PartialEq,
{
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner
}
}
impl<Ctx, T> Eq for WithContext<Ctx, T> where T: Eq {}
impl<Ctx, T> Deref for WithContext<Ctx, T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<Ctx, T> DerefMut for WithContext<Ctx, T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
impl<Ctx, T> From<T> for WithContext<Ctx, T> {
fn from(inner: T) -> Self {
Self {
_ctx: PhantomData,
inner,
}
}
}
#[cfg(feature = "sycamore")]
use sycamore::reactive::{ReadSignal, Scope};
#[cfg(feature = "sycamore")]
pub trait ContextOptions<T> {
fn options<'a>(&'a self, cx: Scope<'a>) -> &'a ReadSignal<Vec<T>>;
}

View File

@ -2,6 +2,7 @@ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
hash::Hash, hash::Hash,
marker::PhantomData, marker::PhantomData,
ops::Deref,
}; };
use sycamore::view::IntoView as _; use sycamore::view::IntoView as _;
@ -14,7 +15,10 @@ pub mod prelude {
pub use sycamore::prelude::*; pub use sycamore::prelude::*;
} }
use crate::components::{Block, BlockProps}; use crate::{
components::{Block, BlockProps},
util::WithContext,
};
#[macro_export] #[macro_export]
macro_rules! viewable { macro_rules! viewable {
@ -174,6 +178,7 @@ impl<'a, G: Html, A: for<'b> Viewable<'b, G> + Clone, B: for<'b> Viewable<'b, G>
view! { cx, view! { cx,
Block(title="Tuple".into()) { Block(title="Tuple".into()) {
(props.state.0.view(cx)) (props.state.0.view(cx))
br()
(props.state.1.view(cx)) (props.state.1.view(cx))
} }
} }
@ -203,3 +208,17 @@ impl<'a, G: Html, T: for<'b> Viewable<'b, G> + Clone> Viewer<'a, G, Option<T>> f
impl<'a, G: Html, T: for<'b> Viewable<'b, G> + Clone> Viewable<'a, G> for Option<T> { impl<'a, G: Html, T: for<'b> Viewable<'b, G> + Clone> Viewable<'a, G> for Option<T> {
type Viewer = OptionView; type Viewer = OptionView;
} }
pub struct ContextView;
impl<'a, G: Html, Ctx, T: for<'b> Viewable<'b, G> + Clone> Viewer<'a, G, WithContext<Ctx, T>>
for ContextView
{
fn view(cx: Scope<'a>, props: ViewProps<'a, WithContext<Ctx, T>>) -> View<G> {
props.state.deref().view(cx)
}
}
impl<'a, G: Html, Ctx, T: for<'b> Viewable<'b, G> + Clone> Viewable<'a, G> for WithContext<Ctx, T> {
type Viewer = ContextView;
}

View File

@ -14,4 +14,5 @@ quote = "1.0"
sycamore = { version = "0.8.1", features = ["serde", "suspense"] } sycamore = { version = "0.8.1", features = ["serde", "suspense"] }
paste = "1.0" paste = "1.0"
convert_case = "0.6" convert_case = "0.6"
darling = "0.14"
#lan_party_core = { path = "../core", features = ["sycamore"] } #lan_party_core = { path = "../core", features = ["sycamore"] }

View File

View File

@ -1,9 +1,20 @@
mod edit;
use convert_case::{Case, Casing}; use convert_case::{Case, Casing};
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::{__private::TokenStream as TokenStream2, format_ident, quote}; use quote::{__private::TokenStream as TokenStream2, format_ident, quote};
use syn::{token::Do, Attribute, DataEnum, DataStruct, Fields, Ident, Path, Type}; use syn::{
parse::Parse, parse_str, token::Do, Attribute, DataEnum, DataStruct, Fields, Generics, Ident,
LifetimeDef, Lit, MetaNameValue, Path, PredicateType, Type, TypeParam,
};
#[macro_use]
extern crate darling;
enum ParsedAttribute {
Documentation(Documentation),
View(ViewAttribute),
Serde(SerdeAttribute),
None,
}
#[derive(Debug)] #[derive(Debug)]
enum Documentation { enum Documentation {
@ -12,6 +23,16 @@ enum Documentation {
None, None,
} }
enum ViewAttribute {
Title(Ident),
None,
}
enum SerdeAttribute {
Untagged,
None,
}
impl Documentation { impl Documentation {
fn parse(attr: &Attribute) -> Documentation { fn parse(attr: &Attribute) -> Documentation {
if !attr.path.is_ident("doc") { if !attr.path.is_ident("doc") {
@ -30,34 +51,116 @@ impl Documentation {
} }
} }
fn get_title_description(attrs: &[Attribute]) -> (Option<String>, Option<String>) { impl ViewAttribute {
let docs: Vec<_> = attrs.iter().map(Documentation::parse).collect(); fn parse(attr: &Attribute) -> ViewAttribute {
if !attr.path.is_ident("view") {
return Self::None;
}
let mut title = None; let parsed: Result<MetaNameValue, _> = parse_str(
let mut description: Option<String> = None; attr.tokens
.to_string()
.trim_matches(|c: char| c == '(' || c == ')'),
);
for doc in docs { match parsed {
match doc { Ok(p) => match (p.path.get_ident().unwrap().to_string().as_str(), p.lit) {
Documentation::Title(t) => title = Some(t), ("title", Lit::Str(ls)) => Self::Title(format_ident!("{}", ls.value())),
Documentation::Description(d) => { _ => Self::None,
if description.is_some() { },
description.as_mut().unwrap().push(' '); Err(_) => Self::None,
} else {
let _ = description.insert(String::new());
}
description.as_mut().unwrap().push_str(&d);
}
_ => {}
} }
} }
}
(title, description) impl SerdeAttribute {
fn parse(attr: &Attribute) -> SerdeAttribute {
if !attr.path.is_ident("serde") {
return Self::None;
}
let parsed: Result<Ident, _> = parse_str(
attr.tokens
.to_string()
.trim_matches(|c: char| c == '(' || c == ')'),
);
match parsed {
Ok(p) => match p.to_string().as_str() {
"untagged" => Self::Untagged,
_ => Self::None,
},
Err(_) => Self::None,
}
}
}
impl ParsedAttribute {
fn parse(attr: &Attribute) -> ParsedAttribute {
match attr.path.get_ident() {
Some(i) if i.to_string() == "doc" => Self::Documentation(Documentation::parse(attr)),
Some(i) if i.to_string() == "view" => Self::View(ViewAttribute::parse(attr)),
Some(i) if i.to_string() == "serde" => Self::Serde(SerdeAttribute::parse(attr)),
_ => Self::None,
}
}
}
struct Attributes {
title: Option<String>,
title_field: Option<Ident>,
description: Option<String>,
untagged: bool,
}
impl Attributes {
pub fn parse(attrs: &[Attribute]) -> Self {
let parsed: Vec<_> = attrs.iter().map(ParsedAttribute::parse).collect();
let mut title = None;
let mut title_field = None;
let mut description: Option<String> = None;
let mut untagged = false;
for attr in parsed {
match attr {
ParsedAttribute::Documentation(doc) => match doc {
Documentation::Title(t) => title = Some(t),
Documentation::Description(d) => {
if description.is_some() {
description.as_mut().unwrap().push(' ');
} else {
let _ = description.insert(String::new());
}
description.as_mut().unwrap().push_str(&d);
}
_ => {}
},
ParsedAttribute::View(v) => match v {
ViewAttribute::Title(t) => title_field = Some(t),
_ => {}
},
ParsedAttribute::Serde(s) => match s {
SerdeAttribute::Untagged => untagged = true,
_ => {}
},
_ => {}
}
}
Self {
title,
description,
title_field,
untagged,
}
}
} }
struct ItemProps { struct ItemProps {
name: Ident, name: Ident,
title: String, attributes: Attributes,
description: Option<String>, generics: Generics,
} }
struct StructField { struct StructField {
@ -78,9 +181,12 @@ struct EnumVariant {
fn struct_edit(props: &ItemProps, s: DataStruct) -> TokenStream2 { fn struct_edit(props: &ItemProps, s: DataStruct) -> TokenStream2 {
let ItemProps { let ItemProps {
name, name,
title, attributes: Attributes {
description, title, description, ..
},
generics,
} = props; } = props;
let title = title.clone().unwrap_or(name.to_string());
let fields = s.fields.iter().map(|f| { let fields = s.fields.iter().map(|f| {
let name = f let name = f
@ -89,7 +195,12 @@ fn struct_edit(props: &ItemProps, s: DataStruct) -> TokenStream2 {
.expect("each struct field must be named") .expect("each struct field must be named")
.clone(); .clone();
let name_str = name.to_string(); let name_str = name.to_string();
let (title, description) = get_title_description(&f.attrs); let Attributes {
title,
title_field: _,
description,
..
} = Attributes::parse(&f.attrs);
StructField { StructField {
name_str, name_str,
@ -159,9 +270,12 @@ fn struct_edit(props: &ItemProps, s: DataStruct) -> TokenStream2 {
fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 { fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 {
let ItemProps { let ItemProps {
name, name,
title, attributes: Attributes {
description, title, description, ..
},
generics,
} = props; } = props;
let title = title.clone().unwrap_or(name.to_string());
let variants = e.variants.iter().map(|v| { let variants = e.variants.iter().map(|v| {
let variant = v.ident.clone(); let variant = v.ident.clone();
@ -171,7 +285,12 @@ fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 {
_ => unimplemented!(), _ => unimplemented!(),
}; };
let (title, description) = get_title_description(&v.attrs); let Attributes {
title,
title_field: _,
description,
..
} = Attributes::parse(&v.attrs);
let variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake)); let variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake));
EnumVariant { EnumVariant {
variant_lower, variant_lower,
@ -274,6 +393,7 @@ fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 {
#(#view_description,)* #(#view_description,)*
_ => view! { cx, } _ => view! { cx, }
}) })
br()
(match selected.get().as_str() { (match selected.get().as_str() {
#(#view_match,)* #(#view_match,)*
_ => view! { cx, } _ => view! { cx, }
@ -286,9 +406,16 @@ fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 {
fn struct_view(props: &ItemProps, s: DataStruct) -> TokenStream2 { fn struct_view(props: &ItemProps, s: DataStruct) -> TokenStream2 {
let ItemProps { let ItemProps {
name, name,
title, attributes:
description, Attributes {
title,
description,
title_field,
..
},
generics,
} = props; } = props;
let title = title.clone().unwrap_or(name.to_string());
let fields = s.fields.iter().map(|f| { let fields = s.fields.iter().map(|f| {
let name = f let name = f
@ -297,7 +424,12 @@ fn struct_view(props: &ItemProps, s: DataStruct) -> TokenStream2 {
.expect("each struct field must be named") .expect("each struct field must be named")
.clone(); .clone();
let name_str = name.to_string(); let name_str = name.to_string();
let (title, description) = get_title_description(&f.attrs); let Attributes {
title,
title_field: _,
description,
..
} = Attributes::parse(&f.attrs);
StructField { StructField {
name_str, name_str,
@ -334,6 +466,16 @@ fn struct_view(props: &ItemProps, s: DataStruct) -> TokenStream2 {
} }
}); });
let title = if let Some(title_field) = title_field {
quote! {
state.#title_field
}
} else {
quote! {
#title
}
};
quote! { quote! {
let state = props.state; let state = props.state;
@ -353,9 +495,12 @@ fn struct_view(props: &ItemProps, s: DataStruct) -> TokenStream2 {
fn enum_view(props: &ItemProps, e: DataEnum) -> TokenStream2 { fn enum_view(props: &ItemProps, e: DataEnum) -> TokenStream2 {
let ItemProps { let ItemProps {
name, name,
title, attributes: Attributes {
description, title, description, ..
},
generics,
} = props; } = props;
let title = title.clone().unwrap_or(name.to_string());
let variants = e.variants.iter().map(|v| { let variants = e.variants.iter().map(|v| {
let variant = v.ident.clone(); let variant = v.ident.clone();
@ -365,7 +510,12 @@ fn enum_view(props: &ItemProps, e: DataEnum) -> TokenStream2 {
_ => unimplemented!(), _ => unimplemented!(),
}; };
let (title, description) = get_title_description(&v.attrs); let Attributes {
title,
title_field: _,
description,
..
} = Attributes::parse(&v.attrs);
let variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake)); let variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake));
EnumVariant { EnumVariant {
variant_lower, variant_lower,
@ -429,15 +579,12 @@ pub fn web_edit(tokens: TokenStream) -> TokenStream {
let name = input.ident; let name = input.ident;
let edit_ident = format_ident!("{}Edit", name); let edit_ident = format_ident!("{}Edit", name);
let (t, d) = get_title_description(&input.attrs); let attrs = Attributes::parse(&input.attrs);
let title = t.unwrap_or(name.to_string());
let description = d;
let props = ItemProps { let props = ItemProps {
name: name.clone(), name: name.clone(),
title: title.clone(), attributes: attrs,
description: description.clone(), generics: input.generics.clone(),
}; };
let inner = match input.data { let inner = match input.data {
@ -446,16 +593,43 @@ pub fn web_edit(tokens: TokenStream) -> TokenStream {
_ => unimplemented!(), _ => unimplemented!(),
}; };
let mut generics = input.generics.clone();
let input_generics = input.generics;
//if generics.type_params().count() == 0 {
generics
.params
.push(syn::GenericParam::Lifetime(syn::parse_str("'a").unwrap()));
generics
.params
.push(syn::GenericParam::Type(syn::parse_str("G: Html").unwrap()));
//}
let (input_impl_generics, input_ty_generics, input_where_clause) =
input_generics.split_for_impl();
for ty_param in input_generics.type_params() {
generics.make_where_clause().predicates.push(
syn::parse_str(&format!(
"{}: for<'b> Editable<'b, G>",
ty_param.ident.to_string()
))
.unwrap(),
);
}
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
let res = quote! { let res = quote! {
pub struct #edit_ident; pub struct #edit_ident;
impl<'a, G: Html> Editor<'a, G, #name> for #edit_ident { impl #impl_generics Editor<'a, G, #name #input_ty_generics> for #edit_ident #where_clause {
fn edit(cx: Scope<'a>, props: EditProps<'a, #name>) -> View<G> { fn edit(cx: Scope<'a>, props: EditProps<'a, #name #input_ty_generics>) -> View<G> {
#inner #inner
} }
} }
impl<'a, G: Html> Editable<'a, G> for #name { impl #impl_generics Editable<'a, G> for #name #input_ty_generics #where_clause {
type Editor = #edit_ident; type Editor = #edit_ident;
} }
}; };
@ -463,22 +637,19 @@ pub fn web_edit(tokens: TokenStream) -> TokenStream {
TokenStream::from(res) TokenStream::from(res)
} }
#[proc_macro_derive(WebView)] #[proc_macro_derive(WebView, attributes(view))]
pub fn web_view(tokens: TokenStream) -> TokenStream { pub fn web_view(tokens: TokenStream) -> TokenStream {
let input: syn::DeriveInput = syn::parse(tokens).unwrap(); let input: syn::DeriveInput = syn::parse(tokens).unwrap();
let name = input.ident; let name = input.ident;
let view_ident = format_ident!("{}View", name); let view_ident = format_ident!("{}View", name);
let (t, d) = get_title_description(&input.attrs); let attrs = Attributes::parse(&input.attrs);
let title = t.unwrap_or(name.to_string());
let description = d;
let props = ItemProps { let props = ItemProps {
name: name.clone(), name: name.clone(),
title: title.clone(), attributes: attrs,
description: description.clone(), generics: input.generics.clone(),
}; };
let inner = match input.data { let inner = match input.data {
@ -487,16 +658,45 @@ pub fn web_view(tokens: TokenStream) -> TokenStream {
_ => unimplemented!(), _ => unimplemented!(),
}; };
let mut generics = input.generics.clone();
let input_generics = input.generics;
//if generics.type_params().count() == 0 {
generics
.params
.push(syn::GenericParam::Lifetime(syn::parse_str("'a").unwrap()));
generics
.params
.push(syn::GenericParam::Type(syn::parse_str("G: Html").unwrap()));
//}
let (input_impl_generics, input_ty_generics, input_where_clause) =
input_generics.split_for_impl();
for ty_param in input_generics.type_params() {
generics.make_where_clause().predicates.push(
syn::parse_str(&format!(
"{}: for<'b> Viewable<'b, G>",
ty_param.ident.to_string()
))
.unwrap(),
);
}
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
//println!("{}", &inner.to_string());
let res = quote! { let res = quote! {
pub struct #view_ident; pub struct #view_ident;
impl<'a, G: Html> Viewer<'a, G, #name> for #view_ident { impl #impl_generics Viewer<'a, G, #name #input_ty_generics> for #view_ident #where_clause {
fn view(cx: Scope<'a>, props: ViewProps<'a, #name>) -> View<G> { fn view(cx: Scope<'a>, props: ViewProps<'a, #name #input_ty_generics>) -> View<G> {
#inner #inner
} }
} }
impl<'a, G: Html> Viewable<'a, G> for #name { impl #impl_generics Viewable<'a, G> for #name #input_ty_generics #where_clause {
type Viewer = #view_ident; type Viewer = #view_ident;
} }
}; };

View File

@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
podman run -d --rm --name lan_party_db \ podman run -d --rm --name lan_party_db \
-v lan-party-db:/data/db:z \ -v lan-party-db-2:/data/db:z \
-p "27017:27017" \ -p "27017:27017" \
-e MONGO_INITDB_ROOT_USERNAME=root \ -e MONGO_INITDB_ROOT_USERNAME=root \
-e MONGO_INITDB_ROOT_PASSWORD=example \ -e MONGO_INITDB_ROOT_PASSWORD=example \

View File

@ -27,6 +27,6 @@ sycamore-router = "0.8.0"
reqwasm = "0.5" reqwasm = "0.5"
console_log = "0.2" console_log = "0.2"
log = "0.4" log = "0.4"
gloo-timers = "0.2"
async-trait = "0.1"
futures = "0.3"

8
web/dist/index.html vendored
View File

@ -2,11 +2,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<!--<link data-trunk href="tailwind.css" rel="css">--> <!--<link data-trunk href="tailwind.css" rel="css">-->
<link rel="stylesheet" href="/simple.min-d15f5ff500b4c62a.css"> <link rel="stylesheet" href="/simple.min-d15f5ff500b4c62a.css">
<link rel="stylesheet" href="/style-5e1a36be9e479fbe.css"> <link rel="stylesheet" href="/style-95f29b132ea1fddf.css">
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css" rel="stylesheet">
<title>LAN Party</title> <title>LAN Party</title>
<link rel="preload" href="/index-61979c95126900f4_bg.wasm" as="fetch" type="application/wasm" crossorigin=""> <link rel="preload" href="/index-1e8437b6157bd12b_bg.wasm" as="fetch" type="application/wasm" crossorigin="">
<link rel="modulepreload" href="/index-61979c95126900f4.js"></head> <link rel="modulepreload" href="/index-1e8437b6157bd12b.js"></head>
<body> <body>
<script type="module">import init from '/index-61979c95126900f4.js';init('/index-61979c95126900f4_bg.wasm');</script></body></html> <script type="module">import init from '/index-1e8437b6157bd12b.js';init('/index-1e8437b6157bd12b_bg.wasm');</script></body></html>

139
web/src/'
View File

@ -1,139 +0,0 @@
use lan_party_core::user::User;
use log::debug;
use sycamore::{futures::spawn_local_scoped, prelude::*};
use web_sys::Event;
use crate::util::api_request;
#[component]
pub fn UsersPage<'a, G: Html>(cx: Scope<'a>) -> View<G> {
let users = create_signal(cx, Vec::<User>::new());
spawn_local_scoped(cx, async move {
users.set(
api_request::<_, Vec<User>>(reqwasm::http::Method::GET, "/user", Option::<()>::None)
.await
.map(|inner| inner.unwrap())
.unwrap(),
);
});
let handle_click = move |event: Event| {
debug!("{:#?}", event);
};
view! { cx,
Page {
ul {
Keyed(
iterable=users,
view=|cx, user| view! { cx,
li { (user.name) }
},
key=|user| user.name.clone(),
)
}
Button(onclick=handle_click,text="Click me".to_string())
}
}
}
// Temp
#[derive(Prop)]
pub struct PageProps<'a, G: Html> {
pub children: Children<'a, G>,
}
#[component]
pub fn Page<'a, G: Html>(cx: Scope<'a>, props: PageProps<'a, G>) -> View<G> {
let children = props.children.call(cx);
view! { cx,
div(class="max-w-7xl mx-auto") { (children) }
}
}
#[derive(Prop)]
pub struct Props<F: FnMut(Event)> {
pub onclick: F,
#[builder(default)]
pub text: String,
#[builder(default)]
pub icon: String,
}
#[component]
pub fn Button<'a, G: Html, F: 'a + FnMut(Event)>(cx: Scope<'a>, props: Props<F>) -> View<G> {
let mut icon_class = String::from("mdi text-lg ");
if !props.icon.is_empty() {
icon_class.push_str(&props.icon);
}
view! { cx,
button(class="bg-gray-700 hover:bg-gray-800 text-gray-400 font-bold py-2 px-2 rounded inline-flex items-center",on:click=props.onclick) {
span(class=icon_class)
span { (props.text) }
}
}
}
#[derive(Prop)]
pub struct TableProps<'a, G: Html> {
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>,
pub loading: bool,
#[builder(default)]
pub children: Children<'a, G>,
}
#[component]
pub fn table<'a, G: Html>(props: TableProps<'a, G>) -> View<G> {
let children = props.children.call(cx);
view! { cx,
div(class="inline-block min-w-full py-2 align-middle") {
div(class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg") {
table(class="min-w-full divide-y divide-gray-600") {
thead(class="bg-gray-800") {
tr {
(props.headers.iter().map(|header| view! { cx,
th(
scope="col",
class="px-3 py-3.5 text-left
text-sm font-semibold text-gray-400"
) {
(header)
}
}).collect())
}
}
tbody(class="divide-y divide-gray-600 bg-gray-700") {
(props.rows.iter().map(|row| view! { cx,
tr {
{row.iter().map(|value| html! {
td(class="whitespace-nowrap px-3 py-4 text-sm text-gray-400"){
(value)
}
}).collect()}
}
}).collect())
(children)
(if props.loading == true { view! {
tr {
td(colspan={(props.headers.len() + 1).to_string()} class="py-3") {
div(class="grid place-items-center") {
"Loading ..."
}
}
}
}} else { view! {} })
}
}
}
}
}
}

View File

@ -0,0 +1,105 @@
use gloo_timers::future::TimeoutFuture;
use log::debug;
use sycamore::{futures::spawn_local_scoped, prelude::*};
use wasm_bindgen_futures::spawn_local;
const MESSAGE_TIME: u32 = 5000;
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Message {
id: usize,
title: String,
text: String,
}
impl Message {
pub fn new(title: String, text: String) -> Self {
Self { id: 0, title, text }
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Messenger {
counter: RcSignal<usize>,
messages: RcSignal<Vec<Message>>,
}
impl Messenger {
pub fn add_message(&self, mut message: Message) {
message.id = *self.counter.get_untracked();
self.counter.set(*self.counter.get_untracked() + 1);
self.messages.modify().push(message);
}
pub fn info(&self, title: impl Into<String>, text: impl Into<String>) {
self.add_message(Message::new(title.into(), text.into()));
}
pub fn add_result<T>(
&self,
result: anyhow::Result<T>,
success: Option<impl Into<String>>,
fail: Option<impl Into<String>>,
) {
match result {
Ok(_) => {
if let Some(success) = success {
self.add_message(Message::new(success.into(), String::new()))
}
}
Err(e) => {
if let Some(fail) = fail {
self.add_message(Message::new(fail.into(), e.to_string()))
}
}
}
}
pub fn remove_message(&self) {
self.messages.modify().remove(0);
}
pub fn len(&self) -> usize {
self.messages.get_untracked().len()
}
}
#[component]
pub fn Messages<'a, G: Html>(cx: Scope<'a>) -> View<G> {
let messages = use_context::<Messenger>(cx);
create_effect(cx, move || {
messages.messages.track();
if messages.len() > 0 {
spawn_local_scoped(cx, async move {
debug!("Spawn");
TimeoutFuture::new(MESSAGE_TIME).await;
debug!("Remove");
messages.remove_message();
});
}
});
view! { cx,
div(class="messages") {
Keyed(
iterable=&messages.messages,
view=move |cx, message| {
view! { cx,
div(class="message") {
p(class="message-title") {
(message.title)
}
p(class="message-text") {
(message.text)
}
}
}
},
key=|message| (message.id),
)
}
}
}

View File

@ -1,31 +1,9 @@
use sycamore::prelude::*; pub mod messages;
pub use lan_party_core::components::Button;
use sycamore::{builder::prelude::*, prelude::*};
use web_sys::Event; use web_sys::Event;
#[derive(Prop)]
pub struct ButtonProps<F: FnMut(Event)> {
pub onclick: F,
#[builder(default)]
pub text: String,
#[builder(default)]
pub icon: String,
}
#[component]
pub fn Button<'a, G: Html, F: 'a + FnMut(Event)>(cx: Scope<'a>, props: ButtonProps<F>) -> View<G> {
let mut icon_class = String::from("mdi ");
if !props.icon.is_empty() {
icon_class.push_str(&props.icon);
}
view! { cx,
button(on:click=props.onclick) {
span(class=icon_class)
span { (props.text) }
}
}
}
#[derive(Prop)] #[derive(Prop)]
pub struct TableProps<'a, G: Html> { pub struct TableProps<'a, G: Html> {
pub headers: Vec<String>, pub headers: Vec<String>,
@ -56,19 +34,32 @@ pub fn Table<'a, G: Html>(cx: Scope<'a>, props: TableProps<'a, G>) -> View<G> {
} }
#[derive(Prop)] #[derive(Prop)]
pub struct BlockProps<'a, G: Html> { pub struct ModalProps<'a, G: Html> {
pub open: &'a Signal<bool>,
pub title: String, pub title: String,
pub children: Children<'a, G>, pub children: Children<'a, G>,
} }
#[component] #[component]
pub fn Block<'a, G: Html>(cx: Scope<'a>, props: BlockProps<'a, G>) -> View<G> { pub fn Modal<'a, G: Html>(cx: Scope<'a>, props: ModalProps<'a, G>) -> View<G> {
let children = props.children.call(cx); let children = props.children.call(cx);
let class = create_memo(cx, || {
if *props.open.get() {
"modal"
} else {
"modal hidden"
}
});
view! { cx, view! { cx,
details { div(class=class) {
summary { (props.title) } h4 { (props.title) }
p { (children) } div(class="modal-close") {
Button(icon="mdi-close".into(), onclick=move |_| props.open.set(false))
}
(children)
} }
} }
} }

View File

@ -1,448 +0,0 @@
use crate::components::*;
use std::collections::{HashMap, HashSet};
use lan_party_core::event::{
free_for_all_game::{FreeForAllGame, FreeForAllGameRanking, FreeForAllGameSpec},
team_game::{TeamGame, TeamGameSpec},
test::{Test, TestSpec},
*,
};
use paste::paste;
use wasm_bindgen::JsValue;
use yew::prelude::*;
use crate::{bind, bind_change, bind_value, clone, clone_cb};
macro_rules! view_fields {
($(($name:expr, $prop:expr)),* $(,)?) => {
html! {
<>
$(
<p>
{ $name }
{ $prop.view() }
</p>
)*
</>
}
};
}
macro_rules! view_struct {
($struct:path: $title:expr => $(($name:expr, $prop:ident)),* $(,)?) => {
impl View for $struct {
fn view(&self) -> Html {
html! {
<Block title={$title}>
{ view_fields!(
$(($name, self.$prop),)*
)}
</Block>
}
}
}
};
($struct:path as $self:ident => $body:expr) => {
impl View for $struct {
fn view(&$self) -> Html {
$body
}
}
};
}
macro_rules! view_enum_simple {
($enum:path: $($variant:ident),* $(,)?) => {
impl View for $enum {
fn view(&self) -> Html {
html! {
{match self {
$(
Self::$variant(i) => html! {
<>
{ i.view() }
</>
},
)*
}}
}
}
}
};
}
#[derive(Debug, Clone, Properties, PartialEq)]
pub struct BlockProps {
title: String,
children: Children,
}
#[function_component(Block)]
pub fn block(props: &BlockProps) -> Html {
let open = use_state(|| false);
let mut class = classes!("overflow-hidden");
if !*open {
class.push("max-h-1");
}
html! {
<>
<div class="px-3 py-3 rounded-lg border-solid border-gray-800 border-2 my-3">
<p class="cursor-pointer text-lg" onclick={clone_cb!(open => move |_| open.set(!*open))}>{ &props.title }</p>
<div {class}>
<br />
<div>
{for props.children.iter()}
</div>
</div>
</div>
</>
}
}
pub trait View {
fn view(&self) -> Html;
}
impl View for bool {
fn view(&self) -> Html {
html! {
<input type="checkbox" value={self.to_string()} disabled={true} />
}
}
}
pub trait ViewPlain: Into<Html> + std::fmt::Display {}
impl<T> View for T
where
T: ViewPlain,
{
fn view(&self) -> Html {
self.into()
}
}
impl ViewPlain for i64 {}
impl ViewPlain for i32 {}
impl ViewPlain for isize {}
impl ViewPlain for u64 {}
impl ViewPlain for u32 {}
impl ViewPlain for usize {}
impl ViewPlain for f64 {}
impl ViewPlain for f32 {}
impl ViewPlain for String {}
impl<'a> ViewPlain for &'a str {}
macro_rules! view_iter {
($t:ident => $type:ty) => {
impl<$t: View> View for $type {
fn view(&self) -> Html {
html! {
<Block title="List">
<ul>
{ self.iter().map(|x| html! { <li>{ x.view() }</li> }).collect::<Html>() }
</ul>
</Block>
}
}
}
};
}
view_iter!(T => Vec<T>);
view_iter!(T => HashSet<T>);
view_iter!(T => &[T]);
impl<T: View> View for Option<T> {
fn view(&self) -> Html {
match self {
Some(content) => content.view(),
None => html! { "None" },
}
}
}
impl<K: View, V: View> View for HashMap<K, V> {
fn view(&self) -> Html {
html! {
<Block title={"Map"}>
{self.iter().map(|(k, v)| {
html! { <p><span>{k.view()}</span>{ ": " }<span>{v.view()}</span></p> }
}).collect::<Html>()}
</Block>
}
}
}
view_struct!(
lan_party_core::event::Event: "Event" =>
("Name: ", name),
("Description: ", description),
("Concluded: ", concluded),
("Event type: ", event_type),
);
view_enum_simple!(
lan_party_core::event::EventType: Test,
TeamGame,
FreeForAllGame
);
view_struct!(
lan_party_core::event::test::Test: "Test" =>
("Number of players: ", num_players)
);
view_struct!(FreeForAllGame as self =>
html! {
<Block title={"FreeForAllGame"}>
{ view_fields!(("Ranking: ", self.ranking)) }
{ view_fields!(
("Participants: ", self.spec.participants),
("Win rewards: ", self.spec.win_rewards),
("Lose rewards: ", self.spec.lose_rewards),
) }
</Block>
}
);
view_enum_simple!(
lan_party_core::event::free_for_all_game::FreeForAllGameRanking: Ranking,
Scores
);
view_struct!(TeamGame as self =>
html! {
<Block title={"TeamGame"}>
{ view_fields!(("Teams: ", self.teams)) }
{ view_fields!(("Ranking: ", self.ffa_game.ranking)) }
{ view_fields!(
("Participants: ", self.ffa_game.spec.participants),
("Win rewards: ", self.ffa_game.spec.win_rewards),
("Lose rewards: ", self.ffa_game.spec.lose_rewards),
) }
</Block>
}
);
macro_rules! edit_fields {
($(($name:expr, $prop:expr)),* $(,)?) => {
html! {
<>
$(
<p>
{ $name }
{ $prop.edit() }
</p>
)*
</>
}
};
}
macro_rules! link_fields {
($($field:ident),* $(,)? => $state:ident as $t:ident) => {
$(let $field = use_state(|| $state.$field.clone());)*
use_effect_with_deps(
clone!($($field,)* $state =>
move |_| {
$state.set($t {
$($field: (*$field).clone(),)*
..Default::default()
});
|| { $(drop($field);)* drop($state); }
}
),
($($field.clone(),)*),
);
};
}
macro_rules! link_variants {
($selected:ident => $($index:literal: $var_name:ident = $variant:ident: $var_type:ty),* $(,)? => $state:ident as $t:ident) => {
let $selected = use_state(|| 0 as usize);
$(let $var_name = if let $t::$variant(v) = &*$state {
use_state(|| v.clone())
} else {
use_state(|| <$var_type>::default())
};)*
use_effect_with_deps(
clone!($($var_name,)* $state, $selected =>
move |_| {
match *$selected {
$($index => $state.set($t::$variant((*$var_name).clone())),)*
_ => unreachable!()
}
|| { $(drop($var_name);)* drop($selected); drop($state); }
}
),
($($var_name.clone(),)* $selected.clone()),
);
};
}
macro_rules! edit_struct {
($struct:ident => $(($name:expr, $prop:ident)),* $(,)?) => {
paste! {
#[function_component([<$struct Edit>])]
pub fn [<$struct:lower _edit>](props: &EditProps<$struct>) -> Html {
let state = props.state.clone();
link_fields!($($prop,)* => state as $struct);
html! {
<Block title={stringify!($struct)}>
{ edit_fields!($(($name, $prop)),*) }
</Block>
}
}
impl Editable for $struct {
type Edit = [<$struct Edit>];
}
}
};
}
macro_rules! edit_enum {
($enum:ident => $selected:ident => $($index:literal: $var_name:ident = $variant:ident: $var_type:ty),* $(,)?) => {
paste! {
#[function_component([<$enum Edit>])]
pub fn [<$enum:lower _edit>](props: &EditProps<$enum>) -> Html {
let state = props.state.clone();
link_variants!($selected =>
$($index: $var_name = $variant: $var_type,)*
=> state as $enum
);
html! {
<Block title={stringify!($enum)}>
<Select class="" bind={bind!($selected)}>
$(<option value={stringify!($index)}>{ stringify!($variant) }</option>)*
</Select>
{ match &*state {
$($enum::$variant(_) => $var_name.edit(),)*
}}
</Block>
}
}
impl Editable for $enum {
type Edit = [<$enum Edit>];
}
}
};
}
#[derive(PartialEq, Properties)]
pub struct EditProps<T: PartialEq> {
pub state: UseStateHandle<T>,
}
pub trait Edit {
fn edit(&self) -> Html;
}
impl<Comp: Component<Properties = EditProps<Type>>, Type: Editable<Edit = Comp> + PartialEq> Edit
for UseStateHandle<Type>
{
fn edit(&self) -> Html {
html!(<Comp state={self.clone()} />)
}
}
pub trait Editable {
type Edit: Component;
}
#[function_component(InputEdit)]
pub fn input_edit<T>(props: &EditProps<T>) -> Html
where
T: PartialEq + Clone + 'static,
Binding<String>: From<Binding<T>>,
{
let string = props.state.clone();
html! {
<TextInput
class={"mx-2 appearance-none block bg-gray-700 text-slate-400 border border-gray-600 rounded py-2 px-2 w-50 leading-tight focus:outline-none focus:ring-1 focus:ring-gray-500 focus:border-gray-500"}
bind={bind!(string)}
/>
}
}
impl<T> Editable for T
where
T: PartialEq + Clone + 'static,
Binding<String>: From<Binding<T>>,
{
type Edit = InputEdit<T>;
}
#[function_component(Stub)]
pub fn stub<T: PartialEq>(props: &EditProps<T>) -> Html {
html! { "stub" }
}
impl Editable for Vec<String> {
type Edit = Stub<Vec<String>>;
}
impl Editable for HashMap<String, i64> {
type Edit = Stub<HashMap<String, i64>>;
}
edit_struct!(EventSpec => ("Name: ", name), ("Description: ", description), ("Event type: ", event_type));
edit_struct!(TestSpec => ("Number of players: ", num_players));
edit_struct!(TeamGameSpec => );
edit_struct!(FreeForAllGameSpec => );
edit_enum!(EventTypeSpec => selected =>
0: test = Test: TestSpec,
1: team_game = TeamGame: TeamGameSpec,
2: free_for_all_game = FreeForAllGame: FreeForAllGameSpec
);
edit_enum!(FreeForAllGameRanking => selected =>
0: ranking = Ranking: Vec<String>,
1: scores = Scores: HashMap<String, i64>,
);
/*
#[function_component(EventTypeSpecEdit)]
pub fn event_type_spec_edit(props: &EditProps<EventTypeSpec>) -> Html {
let state = props.state.clone();
link_variants!(selected =>
0: test = Test: TestSpec,
1: team_game = TeamGame: TeamGameSpec,
2: free_for_all_game = FreeForAllGame: FreeForAllGameSpec
=> state as EventTypeSpec
);
html! {
<Block title={"EventTypeSpec"}>
<Select class="" bind={bind!(selected)}>
<option value="0">{ "Test" }</option>
<option value="1">{ "TeamGame" }</option>
<option value="2">{ "FreeForAllGame" }</option>
</Select>
{ match &*state {
EventTypeSpec::Test(_) => test.edit(),
EventTypeSpec::TeamGame(_) => team_game.edit(),
EventTypeSpec::FreeForAllGame(_) => free_for_all_game.edit(),
}}
</Block>
}
}
impl Editable for EventTypeSpec {
type Edit = EventTypeSpecEdit;
}
*/

View File

@ -1,9 +1,14 @@
mod components; mod components;
mod pages; mod pages;
pub mod state;
pub mod util; pub mod util;
use components::messages::{Messages, Messenger};
use futures::join;
use lan_party_core::state::{Events, Users};
use pages::{EventsPage, UsersPage}; use pages::{EventsPage, UsersPage};
use sycamore::prelude::*; use state::{EventsExt, UsersExt};
use sycamore::{futures::spawn_local_scoped, prelude::*};
use sycamore_router::{HistoryIntegration, Route, Router}; use sycamore_router::{HistoryIntegration, Route, Router};
#[derive(Route)] #[derive(Route)]
@ -62,11 +67,55 @@ fn main() {
]; ];
sycamore::render(move |cx| { sycamore::render(move |cx| {
let messenger = Messenger::default();
provide_context(cx, messenger);
let users = Users::default();
provide_context(cx, users);
let events = Events::default();
provide_context(cx, events);
spawn_local_scoped(cx, async move {
let (users_res, events_res) = join!(
use_context::<Users>(cx).load(),
use_context::<Events>(cx).load(),
);
use_context::<Messenger>(cx).add_result(
users_res,
Option::<String>::None,
Some("failed to load users"),
);
use_context::<Messenger>(cx).add_result(
events_res,
Option::<String>::None,
Some("failed to load events"),
);
});
/*
let messages = use_context::<MessagesState>(cx);
spawn_local_scoped(cx, async move {
TimeoutFuture::new(1000).await;
messages.add_message(Message::new(
"Hello there".into(),
"This is a message".into(),
));
TimeoutFuture::new(1000).await;
messages.add_message(Message::new("Oh look!".into(), "Another message".into()));
});
*/
view! { cx, view! { cx,
Router( Router(
integration=HistoryIntegration::new(), integration=HistoryIntegration::new(),
view=|cx, route: &ReadSignal<AppRoutes>| { view=|cx, route: &ReadSignal<AppRoutes>| {
view! { cx, view! { cx,
Messages()
header { header {
Navbar(pages=pages) { Navbar(pages=pages) {

View File

@ -1,6 +1,11 @@
use std::ops::Deref;
use anyhow::anyhow;
use lan_party_core::{ use lan_party_core::{
components::Block,
edit::IntoEdit, edit::IntoEdit,
event::{Event, EventSpec, EventUpdate}, event::{Event, EventOutcome, EventSpec, EventUpdate},
util::WithContext,
view::IntoView, view::IntoView,
}; };
use log::debug; use log::debug;
@ -8,63 +13,182 @@ use reqwasm::http::Method;
use sycamore::{futures::spawn_local_scoped, prelude::*}; use sycamore::{futures::spawn_local_scoped, prelude::*};
use crate::{ use crate::{
components::{Block, Button}, components::{messages::Messenger, Button, Modal, Table},
state::{EventsExt, UsersExt},
util::api_request, util::api_request,
}; };
use lan_party_core::state::{Events, Users};
pub enum Msg {
Reload,
Add(EventSpec),
Update(String, EventUpdate),
Delete(String),
Stop(String),
ViewOutcome(String),
}
#[component] #[component]
pub fn EventsPage<'a, G: Html>(cx: Scope<'a>) -> View<G> { pub fn EventsPage<'a, G: Html>(cx: Scope<'a>) -> View<G> {
let messenger = use_context::<Messenger>(cx);
let event_spec = create_signal(cx, EventSpec::default()); let event_spec = create_signal(cx, EventSpec::default());
let event_update = create_signal(cx, EventUpdate::default()); let event_update = create_signal(cx, EventUpdate::default());
let event_update_name = create_signal(cx, WithContext::<Events, String>::from(String::new()));
let events: &'a Signal<Vec<Event>> = create_signal(cx, Vec::<Event>::new()); let current_event = create_signal(cx, String::new());
let show_outcome = create_signal(cx, false);
let event_outcome = create_signal(cx, EventOutcome::default());
spawn_local_scoped(cx, async move { let events = use_context::<Events>(cx);
events.set( let users = use_context::<Users>(cx);
api_request::<_, Vec<Event>>(Method::GET, "/event", Option::<()>::None)
.await
.map(|inner| inner.unwrap())
.unwrap(),
);
});
let onadd = move |_| { let dispatch = move |msg: Msg| {
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
let new_event = api_request::<EventSpec, Event>( match msg {
Method::POST, Msg::Add(event_spec) => {
"/event", let name = event_spec.name.clone();
Some((*event_spec).get().as_ref().clone()), messenger.add_result(
) events.add(event_spec).await,
.await Some(format!("Created a new event with name \"{}\"", name)),
.unwrap(); Some("Error when adding an event"),
debug!("{:#?}", new_event); );
events.modify().push(new_event.unwrap()); }
Msg::Reload => messenger.add_result(
events.load().await,
Option::<String>::None,
Some("Failed to load events"),
),
Msg::Update(event_update_name, event_update) => {
messenger.add_result(
events.update_event(&event_update_name, event_update).await,
Some(format!("Updated event with name \"{}\"", event_update_name)),
Some("Error when updating event"),
);
}
Msg::Delete(event_name) => {
messenger.add_result(
events.delete(&event_name).await,
Some(format!("Deleted event with name \"{}\"", event_name)),
Some("Error when deleting event"),
);
}
Msg::ViewOutcome(event_name) => {
show_outcome.set(true);
current_event.set(event_name.clone());
let res = api_request::<(), EventOutcome>(
Method::GET,
&format!("/event/{}/outcome", event_name),
None,
)
.await
.and_then(|inner| inner.ok_or(anyhow!("missing body")));
if let Ok(outcome) = res {
event_outcome.set(outcome);
} else {
messenger.add_result(
res,
Option::<String>::None,
Some("Failed to load outcome"),
)
}
}
Msg::Stop(event_name) => {
let res = api_request::<(), EventOutcome>(
Method::POST,
&format!("/event/{}/stop", event_name),
None,
)
.await;
messenger.add_result(
res,
Some("Event finished, scores were applied"),
Some("Unable to finish event"),
);
let _ = users.load();
}
}
}); });
}; };
dispatch(Msg::Reload);
let outcome_points = create_memo(cx, || {
let cloned = event_outcome.get().as_ref().clone();
let mut vec = cloned.points.into_iter().collect::<Vec<_>>();
vec.sort_by_key(|(_, s)| -*s);
vec
});
view! { cx, view! { cx,
Block(title="Events".into()) { Modal(open=show_outcome, title="Event outcome".into()) {
Keyed( Table(headers=vec!["Username".into(), "Score".into()]) {
iterable=&events, Indexed(
view=move |cx, event| { iterable=&outcome_points,
let event = create_ref(cx, event); view=move |cx, (k, v)| {
view! { cx, view! { cx,
(event.view(cx)) tr {
br() td { (*k.clone()) }
td { (v.clone()) }
}
}
} }
}, )
key=|event| (event.name.clone()), }
) Button(text="Reload".into(), icon="mdi-refresh".into(), onclick=move |_| dispatch(Msg::ViewOutcome(current_event.get().as_ref().clone())))
} }
br() div(class="events-cols") {
Block(title="Create new event".into()) { div {
(event_spec.edit(cx)) Block(title="Events".into()) {
Button(icon="mdi-check".into(), onclick=onadd) Button(text="Reload".into(), icon="mdi-refresh".into(), onclick=move |_| dispatch(Msg::Reload))
} Indexed(
br() iterable=&events.get(),
Block(title="Update an event".into()) { view=move |cx, event| {
(event_update.edit(cx)) let event = create_ref(cx, event);
Button(icon="mdi-check".into(), onclick=move |_| debug!("{:#?}", event_update.get())) view! { cx,
Block(title=event.name.clone()) {
(event.description)
br()
span(class="event-action") {
Button(text="Delete".into(), icon="mdi-delete".into(), onclick=move |_| dispatch(Msg::Delete(event.name.clone())))
}
span(class="event-action") {
Button(text="View outcome".into(), onclick=move |_| dispatch(Msg::ViewOutcome(event.name.clone())))
}
span(class="event-action") {
Button(text="Finish".into(), onclick=move |_| dispatch(Msg::Stop(event.name.clone())))
}
br()
(event.event_type.view(cx))
}
br()
}
},
)
}
}
div(class="events-right") {
Block(title="Create new event".into()) {
(event_spec.edit(cx))
Button(icon="mdi-check".into(), onclick=move |_| dispatch(Msg::Add(event_spec.get().as_ref().clone())))
}
br()
Block(title="Update an event".into()) {
label { "Event name" }
(event_update_name.edit(cx))
(event_update.edit(cx))
Button(
icon="mdi-check".into(),
onclick=move |_| dispatch(Msg::Update(
event_update_name.get().as_ref().deref().clone(),
event_update.get().as_ref().clone()
))
)
}
}
} }
} }
} }

View File

@ -1,39 +0,0 @@
use crate::{
components::{
event::{Edit, EventSpecEdit},
Button, Page, View,
},
init,
};
use lan_party_core::event::EventSpec;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_hooks::*;
use crate::{clone, clone_cb, util::api_request};
#[function_component(EventsPage)]
pub fn events_page() -> Html {
let events = use_state(|| Vec::new());
init!(events => {
events.set(api_request::<_, Vec<lan_party_core::event::Event>>(reqwasm::http::Method::GET, "/event", Option::<()>::None)
.await
.map(|inner| inner.unwrap())
.unwrap())
});
//let edit_event = use_state(|| EventSpecEditHandle::to_edit(EventSpec::default()));
let event_spec = use_state(|| EventSpec::default());
html! {
<Page>
{ events.view() }
{ event_spec.edit() }
<Button text="Create" onclick={clone_cb!(event_spec => move |_| web_sys::console::log_1(&JsValue::from_serde(&*event_spec).unwrap()))} />
</Page>
}
}

View File

@ -1,84 +1,55 @@
use crate::components::{Button, Table}; use crate::{
use lan_party_core::user::User; components::{messages::Messenger, Button, Table},
use log::debug; state::{EventsExt, UsersExt},
use reqwasm::http::Method; };
use lan_party_core::state::{Events, Users};
use sycamore::{futures::spawn_local_scoped, prelude::*}; use sycamore::{futures::spawn_local_scoped, prelude::*};
use web_sys::Event;
use crate::util::api_request;
#[component] #[component]
pub fn UsersPage<'a, G: Html>(cx: Scope<'a>) -> View<G> { pub fn UsersPage<'a, G: Html>(cx: Scope<'a>) -> View<G> {
let users = create_signal(cx, Vec::<User>::new()); //let users = create_signal(cx, Vec::<User>::new());
let messenger = use_context::<Messenger>(cx);
let users = use_context::<Users>(cx);
let headers = vec!["Username".into(), "Score".into(), "".into()]; let headers = vec!["Username".into(), "Score".into(), "".into()];
let score_edit = create_signal(cx, Option::<String>::None); let score_edit = create_signal(cx, Option::<String>::None);
let new_score = create_signal(cx, String::new()); let new_score = create_signal(cx, String::new());
let new_username = create_signal(cx, String::new()); let new_username = create_signal(cx, String::new());
spawn_local_scoped(cx, async move { let reload = move || {
users.set( spawn_local_scoped(cx, async move {
api_request::<_, Vec<User>>(Method::GET, "/user", Option::<()>::None) messenger.add_result(
.await users.load().await,
.map(|inner| inner.unwrap()) Option::<String>::None,
.unwrap(), Some("Failed to load users"),
); );
}); });
};
reload();
let ondelete = move |name: String| { let ondelete = move |name: String| {
move |event: Event| { move |_| {
let name = name.clone(); let name = name.clone();
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
debug!("Delete {:#?}", event); messenger.add_result(
let users_ref = users.get(); users.delete(&name).await,
let user: &User = users_ref.iter().find(|user| user.name == name).unwrap(); Some(format!("Deleted user {}", name)),
api_request::<_, ()>( Some("Failed to delete user"),
Method::DELETE, );
&format!("/user/{}", user.name),
Option::<()>::None,
)
.await
.unwrap();
let cloned = (*users_ref)
.clone()
.iter()
.cloned()
.filter(|u| u.name != user.name)
.collect();
users.set(cloned);
}); });
} }
}; };
let oncheck = move |_| { let oncheck = move |_| {
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
if let (Some(score_edit), Ok(score)) = if let (Some(name), Ok(score)) = (score_edit.get().as_ref(), new_score.get().parse()) {
(score_edit.get().as_ref(), new_score.get().parse())
{
let score: i64 = score; let score: i64 = score;
let users_ref = users.get(); messenger.add_result(
let user: &User = users_ref users.update_score(&name, score).await,
.iter() Some(format!("Updated score for user {}", name)),
.find(|user| &user.name == score_edit) Some("Failed to delete user"),
.unwrap(); );
api_request::<_, ()>(
Method::POST,
&format!("/user/{}/score", user.name),
Some(score),
)
.await
.unwrap();
let cloned = (*users_ref).clone();
let new_users: Vec<_> = cloned
.into_iter()
.map(|mut user| {
if &user.name == score_edit {
user.score = score
}
user
})
.collect();
users.set(new_users);
} }
score_edit.set(None); score_edit.set(None);
}) })
@ -86,21 +57,19 @@ pub fn UsersPage<'a, G: Html>(cx: Scope<'a>) -> View<G> {
let onadd = move |_| { let onadd = move |_| {
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
let user = api_request::<String, User>( messenger.add_result(
Method::POST, users.add(&new_username.get()).await,
"/user", Some(format!("Added new user {}", new_username.get())),
Some((*new_username).get().as_ref().clone()), Some("Failed to add user"),
) );
.await
.unwrap();
users.modify().push(user.unwrap());
}); });
}; };
view! { cx, view! { cx,
Button(text="Reload".into(), icon="mdi-refresh".into(), onclick=move |_| reload())
Table(headers=headers) { Table(headers=headers) {
Keyed( Keyed(
iterable=users, iterable=users.get(),
view=move |cx, user| { view=move |cx, user| {
let user = create_ref(cx, user); let user = create_ref(cx, user);
view! { cx, view! { cx,

View File

@ -1,109 +0,0 @@
use crate::{
bind, bind_change, bind_value, clone, clone_cb, clone_cb_spawn,
components::{Binding, Button, Loading, Page, Table, TextInput},
init,
util::api_request,
};
use lan_party_core::user::User;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_hooks::*;
#[function_component(UsersPage)]
pub fn users_page() -> Html {
let headers = vec!["Username".into(), "Score".into(), "".into()];
let new_username = use_state(|| String::new());
let score_edit: UseStateHandle<Option<usize>> = use_state(|| Option::None);
let current_score = use_state(|| String::new());
let users = use_state(|| Vec::new());
init!(users => {
users.set(api_request::<_, Vec<User>>(reqwasm::http::Method::GET, "/user", Option::<()>::None)
.await
.map(|inner| inner.unwrap())
.unwrap());
});
let oncheck = clone_cb_spawn!(score_edit, current_score, users => {
if let (Some(score_edit), Ok(score)) = (*score_edit, current_score.parse()) {
let user: &User = &users[score_edit];
api_request::<_, ()>(reqwasm::http::Method::POST, &format!("/user/{}/score", user.name), Some(score))
.await
.unwrap();
let mut cloned = (*users).clone();
cloned[score_edit].score = score;
users.set(cloned);
}
score_edit.set(None);
});
let onedit = clone_cb!(current_score, score_edit, users => i => move |_| {
let user: &User = &users[i];
current_score.set(user.score.to_string());
score_edit.set(Some(i));
});
let ondelete = clone_cb_spawn!(users => i => {
let user: &User = &users[i];
api_request::<_, ()>(reqwasm::http::Method::DELETE, &format!("/user/{}", user.name), Option::<()>::None).await.unwrap();
let cloned = users.iter().cloned().filter(|u| u.name != user.name).collect();
users.set(cloned);
});
let onadd = clone_cb_spawn!(new_username, users => {
let user = api_request::<String, User>(reqwasm::http::Method::POST, "/user", Some((*new_username).clone())).await.unwrap();
let mut cloned = (*users).clone();
cloned.push(user.unwrap());
users.set(cloned);
});
html! {
<Page>
<Table headers={headers.clone()} loading=false rows={vec![]}>
{users.iter().enumerate().map(move |(i, user)| html! {
<tr>
<td class="whitespace-nowrap px-3 py-4 text-sm text-slate-400">{&user.name}</td>
<td class="whitespace-nowrap px-3 text-sm text-slate-400">
{if Some(i) == *score_edit.clone() { html! {
<>
<span class="inline-block">
<TextInput
class={"mx-2 appearance-none block bg-gray-700 text-slate-400 border border-gray-600 rounded py-2 px-2 w-20 leading-tight focus:outline-none focus:ring-1 focus:ring-gray-500 focus:border-gray-500"}
bind={bind!(current_score)}
/>
</span>
<Button icon={"mdi-check"} onclick={oncheck.clone()} />
</>
}} else { html! {
<>
<span class="my-3 w-20">
{user.score}
</span>
<Button icon={"mdi-pencil"} onclick={onedit.clone()(i)} />
</>
}}}
</td>
<td class="whitespace-nowrap py-4 text-sm text-slate-400">
<Button icon={"mdi-delete"} onclick={ondelete.clone()(i)} />
</td>
</tr>
}).collect::<Html>()}
<tr>
<td class="whitespace-nowrap px-3 text-sm text-slate-400">
<span class="inline-block">
<TextInput
class={"mx-2 appearance-none block bg-gray-700 text-slate-400 border border-gray-600 rounded py-2 px-2 w-50 leading-tight focus:outline-none focus:ring-1 focus:ring-gray-500 focus:border-gray-500"}
bind={bind!(new_username)}
/>
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-slate-400"></td>
<td class="whitespace-nowrap py-4 text-sm text-slate-400">
<Button icon={"mdi-plus"} onclick={onadd} />
</td>
</tr>
</Table>
</Page>
}
}

140
web/src/state.rs Normal file
View File

@ -0,0 +1,140 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use lan_party_core::{
event::{Event, EventSpec, EventUpdate},
state::{Events, Users},
user::User,
};
use reqwasm::http::Method;
use sycamore::prelude::*;
use crate::util::api_request;
#[async_trait(?Send)]
pub trait UsersExt {
async fn load(&self) -> Result<()>;
async fn delete(&self, name: &str) -> Result<()>;
async fn update_score(&self, name: &str, score: i64) -> Result<()>;
async fn add(&self, name: &str) -> Result<()>;
}
#[async_trait(?Send)]
impl UsersExt for Users {
async fn load(&self) -> Result<()> {
self.0.set(
api_request::<_, Vec<User>>(
Method::GET,
"/user?sort=score&order=desc",
Option::<()>::None,
)
.await
.and_then(|inner| inner.ok_or(anyhow!("missing body")))?,
);
Ok(())
}
async fn delete(&self, name: &str) -> Result<()> {
let users_ref = self.0.get();
let user: &User = users_ref.iter().find(|user| user.name == name).unwrap();
api_request::<_, ()>(
Method::DELETE,
&format!("/user/{}", user.name),
Option::<()>::None,
)
.await?;
let cloned = (*users_ref)
.clone()
.iter()
.cloned()
.filter(|u| u.name != user.name)
.collect();
self.0.set(cloned);
Ok(())
}
async fn update_score(&self, name: &str, score: i64) -> Result<()> {
let users_ref = self.0.get();
let user: &User = users_ref.iter().find(|user| &user.name == name).unwrap();
api_request::<_, ()>(
Method::POST,
&format!("/user/{}/score", user.name),
Some(score),
)
.await?;
let cloned = (*users_ref).clone();
let new_users: Vec<_> = cloned
.into_iter()
.map(|mut user| {
if &user.name == name {
user.score = score
}
user
})
.collect();
self.0.set(new_users);
Ok(())
}
async fn add(&self, name: &str) -> Result<()> {
let user = api_request::<&str, User>(Method::POST, "/user", Some(name)).await?;
self.0.modify().push(user.ok_or(anyhow!("missing body"))?);
Ok(())
}
}
#[async_trait(?Send)]
pub trait EventsExt {
async fn load(&self) -> Result<()>;
async fn add(&self, event_spec: EventSpec) -> Result<()>;
async fn update_event(&self, event_name: &str, event_update: EventUpdate) -> Result<()>;
async fn delete(&self, event_name: &str) -> Result<()>;
}
#[async_trait(?Send)]
impl EventsExt for Events {
async fn load(&self) -> Result<()> {
self.0.set(
api_request::<_, Vec<Event>>(Method::GET, "/event?concluded=false", Option::<()>::None)
.await
.and_then(|inner| inner.ok_or(anyhow!("missing body")))?,
);
Ok(())
}
async fn add(&self, event_spec: EventSpec) -> Result<()> {
let new_event =
api_request::<EventSpec, Event>(Method::POST, "/event", Some(event_spec)).await?;
self.0
.modify()
.push(new_event.ok_or(anyhow!("missing body"))?);
Ok(())
}
async fn update_event(&self, event_name: &str, event_update: EventUpdate) -> Result<()> {
api_request::<EventUpdate, ()>(
Method::POST,
&format!("/event/{}", event_name),
Some(event_update),
)
.await?;
self.load().await?;
Ok(())
}
async fn delete(&self, event_name: &str) -> Result<()> {
api_request::<(), ()>(Method::DELETE, &format!("/event/{}", event_name), None).await?;
self.load().await?;
Ok(())
}
}

View File

@ -33,10 +33,18 @@ pub async fn api_request<B: Serialize, R: for<'a> Deserialize<'a>>(
let res = req.send().await?; let res = req.send().await?;
if let Ok(json) = res.json().await { if res.ok() {
Ok(Some(json)) if let Ok(json) = res.json().await {
Ok(Some(json))
} else {
Ok(None)
}
} else { } else {
Ok(None) if let Ok(text) = res.text().await {
Err(anyhow!("Request failed: {}", text.to_string()))
} else {
Err(anyhow!("Request failed"))
}
} }
} }

View File

@ -64,5 +64,84 @@ textarea:focus, input:focus{
} }
body { body {
grid-template-columns: 1fr min(60rem, 90%) 1fr; grid-template-columns: 1fr min(100rem, 90%) 1fr;
}
.messages {
position: fixed;
z-index: 10;
top: 0.5em;
left: 0.5em;
}
.message {
margin: 0.5em;
background-color: var(--accent);
padding: 0.5em;
border-radius: 5px;
min-width: 30em;
max-width: 30em;
}
.message-title {
margin-top: 0;
}
.message-text {
margin-bottom: 0;
font-size: 1rem;
}
.events-cols {
display: grid;
grid-template-columns: 50% 50%;
}
.events-cols > * {
padding-left: 1em;
padding-right: 1em;
}
.events-cols:nth-child(1) {
grid-column: 1;
}
.events-cols:nth-child(2) {
grid-column: 2;
}
.event-action {
margin: 0.5em;
}
.hidden {
display: none;
}
.modal {
z-index: 5;
left: 15vw;
top: 10vh;
position: fixed;
width: 70vw;
height: 80vh;
padding: 1em;
background-color: var(--accent-bg);
border: 1px solid var(--border);
border-radius: 5px;
}
.modal > h4 {
margin-top: 0.2em;
margin-bottom: 0.5em;
}
.modal-close {
position: absolute;
right: 1em;
top: 1em;
}
.modal-close > * {
margin: 0;
} }