diff --git a/core/src/edit.rs b/core/src/edit.rs index a4fce2a..20e1eee 100644 --- a/core/src/edit.rs +++ b/core/src/edit.rs @@ -263,7 +263,7 @@ pub struct VecEdit; impl<'a, G, T, I> Editor<'a, G, I> for VecEdit where G: Html, - T: for<'b> Editable<'b, G> + Clone + PartialEq + Default + std::fmt::Debug + 'a, + T: for<'b> Editable<'b, G> + Clone + PartialEq + Default + 'a, I: IntoIterator + FromIterator + Clone, { fn edit(cx: Scope<'a>, props: EditProps<'a, I>) -> View { @@ -332,7 +332,7 @@ where impl<'a, G, T> Editable<'a, G> for Vec where G: Html, - T: for<'b> Editable<'b, G> + Clone + PartialEq + Default + std::fmt::Debug + 'a, + T: for<'b> Editable<'b, G> + Clone + PartialEq + Default + 'a, { type Editor = VecEdit; } @@ -340,7 +340,7 @@ where impl<'a, G, T> Editable<'a, G> for HashSet where G: Html, - T: for<'b> Editable<'b, G> + Clone + PartialEq + Default + Hash + Eq + std::fmt::Debug + 'a, + T: for<'b> Editable<'b, G> + Clone + PartialEq + Default + Hash + Eq + 'a, { type Editor = VecEdit; } @@ -350,8 +350,7 @@ where G: Html, K: Clone + Hash + Eq, V: Clone, - (K, V): - for<'b> Editable<'b, G> + Clone + PartialEq + Default + Hash + Eq + std::fmt::Debug + 'a, + (K, V): for<'b> Editable<'b, G> + Clone + PartialEq + Default + Hash + Eq + 'a, { type Editor = VecEdit; } diff --git a/core/src/event.rs b/core/src/event.rs index c58b684..8ea4f1b 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -10,7 +10,37 @@ use paste::paste; use schemars::JsonSchema; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::{ + collections::{HashMap, HashSet}, + hash::Hash, +}; + +#[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 { + /// Ranking of participants by user name or team name (first element is first place, second element is second + /// place, etc.) + Ranking(Vec), + /// Score based ranking of participants/teams + Scores(HashMap), +} + +impl Default for Ranking { + fn default() -> Self { + Self::Ranking(Vec::default()) + } +} + +impl Ranking { + pub fn is_valid(&self, participants: &HashSet) -> 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)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] @@ -105,6 +135,10 @@ macro_rules! events { impl EventSpec { pub fn create_event(self) -> Result { + if self.name.is_empty() { + return Err(PartyError::Unknown("invalid name".into())) + } + let event_type = match self.event_type { $(EventTypeSpec::$name(s) => { EventType::$name($module::$name::from_spec(s)) @@ -213,11 +247,7 @@ pub mod test { pub mod team_game { use super::{ - free_for_all_game::{ - FreeForAllGame, FreeForAllGameSpec, FreeForAllGameUpdate, - FreeForAllGameUpdateParticipants, FreeForAllGameUpdateRanking, - FreeForAllGameUpdateRewards, - }, + free_for_all_game::{FreeForAllGame, FreeForAllGameSpec, FreeForAllGameUpdate}, *, }; @@ -266,40 +296,12 @@ pub mod team_game { pub members: Vec, } - #[derive(Clone, Debug)] + #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] #[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()) - } + pub struct Team { + name: String, } #[derive(Clone, Debug)] @@ -308,19 +310,23 @@ pub mod team_game { #[cfg_attr(feature = "serde", serde(untagged))] #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum TeamGameUpdate { - /// # Team - /// - /// Team specific updates - Team(TeamGameUpdateInner), - /// # Other - /// - /// Inherited from FreeForAllGame - Ffa(TeamGameFfaInheritedUpdate), + /// Add or replace a team with the given name and array of members + SetTeam(TeamGameUpdateSetTeam), + /// Remove team with given name + RemoveTeam(String), + /// Replace the current ranking with the given ranking + SetRanking(Ranking), + /// If the current ranking is of type `Scores`, apply the given score deltas + ScoreDelta(HashMap), + /// Set rewards for winning the game + SetWinRewards(Vec), + /// Set rewards for losing the game + SetLoseRewards(Vec), } impl Default for TeamGameUpdate { fn default() -> Self { - TeamGameUpdate::Team(TeamGameUpdateInner::default()) + TeamGameUpdate::SetTeam(TeamGameUpdateSetTeam::default()) } } @@ -343,32 +349,30 @@ pub mod team_game { fn apply_update(&mut self, update: Self::Update) -> Result<(), PartyError> { match update { - TeamGameUpdate::Ffa(update) => match update { - TeamGameFfaInheritedUpdate::Ranking(u) => { - self.ffa_game.apply_update(FreeForAllGameUpdate::Ranking(u)) - } - TeamGameFfaInheritedUpdate::Rewards(u) => { - self.ffa_game.apply_update(FreeForAllGameUpdate::Rewards(u)) - } - }, - TeamGameUpdate::Team(update) => match update { - TeamGameUpdateInner::SetTeam(u) => { - self.ffa_game - .apply_update(FreeForAllGameUpdate::Participants( - FreeForAllGameUpdateParticipants::AddParticipant(u.team.clone()), - ))?; - self.teams.insert(u.team, u.members); - Ok(()) - } - TeamGameUpdateInner::RemoveTeam(team) => { - self.ffa_game - .apply_update(FreeForAllGameUpdate::Participants( - FreeForAllGameUpdateParticipants::RemoveParticipant(team.clone()), - ))?; - self.teams.remove(&team); - Ok(()) - } - }, + TeamGameUpdate::SetRanking(x) => self + .ffa_game + .apply_update(FreeForAllGameUpdate::SetRanking(x)), + TeamGameUpdate::ScoreDelta(x) => self + .ffa_game + .apply_update(FreeForAllGameUpdate::ScoreDelta(x)), + TeamGameUpdate::SetWinRewards(x) => self + .ffa_game + .apply_update(FreeForAllGameUpdate::SetWinRewards(x)), + TeamGameUpdate::SetLoseRewards(x) => self + .ffa_game + .apply_update(FreeForAllGameUpdate::SetLoseRewards(x)), + TeamGameUpdate::SetTeam(u) => { + self.ffa_game + .apply_update(FreeForAllGameUpdate::AddParticipant(u.team.clone()))?; + self.teams.insert(u.team, u.members); + Ok(()) + } + TeamGameUpdate::RemoveTeam(team) => { + self.ffa_game + .apply_update(FreeForAllGameUpdate::RemoveParticipant(team.clone()))?; + self.teams.remove(&team); + Ok(()) + } } } @@ -396,31 +400,12 @@ pub mod free_for_all_game { use super::*; - #[derive(Clone, Debug, PartialEq)] + #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] - pub enum FreeForAllGameRanking { - /// Ranking of participants by user name or team name (first element is first place, second element is second - /// place, etc.) - Ranking(Vec), - /// Score based ranking of participants/teams - Scores(HashMap), - } - - impl Default for FreeForAllGameRanking { - fn default() -> Self { - Self::Ranking(Vec::default()) - } - } - - impl FreeForAllGameRanking { - pub fn is_valid(&self, participants: &HashSet) -> bool { - match self { - Self::Ranking(v) => v.iter().all(|p| participants.contains(p)), - Self::Scores(m) => m.keys().all(|p| participants.contains(p)), - } - } + pub struct User { + name: String, } #[derive(Clone, Debug, Default, PartialEq)] @@ -430,7 +415,7 @@ pub mod free_for_all_game { pub struct FreeForAllGame { /// Ranking of participants by user name or team name (first element is first place, second element is second /// place, etc.) - pub ranking: Option, + pub ranking: Option>, /// Specification of the game #[cfg_attr(feature = "serde", serde(flatten))] @@ -466,77 +451,28 @@ pub mod free_for_all_game { #[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 FreeForAllGameUpdateRanking { + pub enum FreeForAllGameUpdate { /// Replace the current ranking with the given ranking - SetRanking(FreeForAllGameRanking), - + SetRanking(Ranking), /// If the current ranking is of type `Scores`, apply the given score deltas ScoreDelta(HashMap), - } - - 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), - /// Set rewards for losing the game SetLoseRewards(Vec), - } - - 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), - /// 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)] - #[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 FreeForAllGameUpdate { - /// Change the ranking and scores - Ranking(FreeForAllGameUpdateRanking), - /// Update rewards - Rewards(FreeForAllGameUpdateRewards), - /// Update participants - Participants(FreeForAllGameUpdateParticipants), - } - impl Default for FreeForAllGameUpdate { fn default() -> Self { - Self::Ranking(FreeForAllGameUpdateRanking::default()) + Self::AddParticipant(String::new()) } } @@ -553,71 +489,65 @@ pub mod free_for_all_game { fn apply_update(&mut self, update: Self::Update) -> Result<(), PartyError> { match update { - FreeForAllGameUpdate::Ranking(update) => match update { - FreeForAllGameUpdateRanking::SetRanking(r) => { - if !r.is_valid(&self.spec.participants) { - return Err(PartyError::Unknown("invalid ranking, all participants mentioned in ranking must be participating".into())); - } - self.ranking = Some(r) + FreeForAllGameUpdate::SetRanking(r) => { + if !r.is_valid(&self.spec.participants) { + return Err(PartyError::Unknown("invalid ranking, all participants mentioned in ranking must be participating".into())); } - FreeForAllGameUpdateRanking::ScoreDelta(d) => match &mut self.ranking { - Some(FreeForAllGameRanking::Ranking(_)) | None => { - return Err(PartyError::Unknown("cannot apply score delta".into())) - } - Some(FreeForAllGameRanking::Scores(s)) => { - for (participant, delta) in d.iter() { - if let Some(value) = s.get(participant) { - s.insert(participant.clone(), value + delta); - } + self.ranking = Some(r) + } + FreeForAllGameUpdate::ScoreDelta(d) => match &mut self.ranking { + Some(Ranking::Ranking(_)) | None => { + return Err(PartyError::Unknown("cannot apply score delta".into())) + } + Some(Ranking::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 { - FreeForAllGameUpdateParticipants::AddParticipant(name) => { - self.spec.participants.insert(name); } - FreeForAllGameUpdateParticipants::RemoveParticipant(name) => { - self.spec.participants.remove(&name); + }, + FreeForAllGameUpdate::AddParticipant(name) => { + self.spec.participants.insert(name); + } + FreeForAllGameUpdate::RemoveParticipant(name) => { + self.spec.participants.remove(&name); - if !self - .ranking - .as_ref() - .map(|r| r.is_valid(&self.spec.participants)) - .unwrap_or(true) - { - self.spec.participants.insert(name); - return Err(PartyError::Unknown("cannot remove participant, all participants mentioned in ranking must be participating".into())); - } + if !self + .ranking + .as_ref() + .map(|r| r.is_valid(&self.spec.participants)) + .unwrap_or(true) + { + self.spec.participants.insert(name); + return Err(PartyError::Unknown("cannot remove participant, all participants mentioned in ranking must be participating".into())); } - FreeForAllGameUpdateParticipants::SetParticipants(participants) => { - if !self - .ranking - .as_ref() - .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())); - } - self.spec.participants = participants; + } + FreeForAllGameUpdate::SetParticipants(participants) => { + if !self + .ranking + .as_ref() + .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())); } - }, - FreeForAllGameUpdate::Rewards(update) => match update { - FreeForAllGameUpdateRewards::SetWinRewards(rewards) => { - self.spec.win_rewards = rewards; - } - FreeForAllGameUpdateRewards::SetLoseRewards(rewards) => { - self.spec.lose_rewards = rewards; - } - }, + self.spec.participants = participants; + } + FreeForAllGameUpdate::SetWinRewards(rewards) => { + self.spec.win_rewards = rewards; + } + FreeForAllGameUpdate::SetLoseRewards(rewards) => { + self.spec.lose_rewards = rewards; + } } Ok(()) } fn outcome(&self) -> EventOutcome { let ranking = match &self.ranking { - Some(FreeForAllGameRanking::Ranking(r)) => r.clone(), - Some(FreeForAllGameRanking::Scores(s)) => { + Some(Ranking::Ranking(r)) => r.clone(), + Some(Ranking::Scores(s)) => { let mut results: Vec<(_, _)> = s.iter().collect(); results.sort_by(|a, b| b.1.cmp(a.1)); results.into_iter().map(|(k, _)| k.clone()).collect() diff --git a/macros/src/edit.rs b/macros/src/edit.rs deleted file mode 100644 index e69de29..0000000 diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 7c746a8..0030314 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,16 +1,15 @@ -mod edit; - use convert_case::{Case, Casing}; use proc_macro::TokenStream; use quote::{__private::TokenStream as TokenStream2, format_ident, quote}; use syn::{ - parse::Parse, parse_str, token::Do, Attribute, DataEnum, DataStruct, Fields, Ident, Lit, - MetaNameValue, Path, Type, + parse::Parse, parse_str, token::Do, Attribute, DataEnum, DataStruct, Fields, Generics, Ident, + LifetimeDef, Lit, MetaNameValue, Path, PredicateType, Type, TypeParam, }; enum ParsedAttribute { Documentation(Documentation), View(ViewAttribute), + Serde(SerdeAttribute), None, } @@ -26,6 +25,11 @@ enum ViewAttribute { None, } +enum SerdeAttribute { + Untagged, + None, +} + impl Documentation { fn parse(attr: &Attribute) -> Documentation { if !attr.path.is_ident("doc") { @@ -56,8 +60,6 @@ impl ViewAttribute { .trim_matches(|c: char| c == '(' || c == ')'), ); - dbg!(&parsed); - match parsed { Ok(p) => match (p.path.get_ident().unwrap().to_string().as_str(), p.lit) { ("title", Lit::Str(ls)) => Self::Title(format_ident!("{}", ls.value())), @@ -68,11 +70,34 @@ impl ViewAttribute { } } +impl SerdeAttribute { + fn parse(attr: &Attribute) -> SerdeAttribute { + if !attr.path.is_ident("serde") { + return Self::None; + } + + let parsed: Result = 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() == "web_view_attr" => Self::View(ViewAttribute::parse(attr)), + Some(i) if i.to_string() == "serde" => Self::Serde(SerdeAttribute::parse(attr)), _ => Self::None, } } @@ -82,6 +107,7 @@ struct Attributes { title: Option, title_field: Option, description: Option, + untagged: bool, } impl Attributes { @@ -91,6 +117,7 @@ impl Attributes { let mut title = None; let mut title_field = None; let mut description: Option = None; + let mut untagged = false; for attr in parsed { match attr { @@ -110,6 +137,10 @@ impl Attributes { ViewAttribute::Title(t) => title_field = Some(t), _ => {} }, + ParsedAttribute::Serde(s) => match s { + SerdeAttribute::Untagged => untagged = true, + _ => {} + }, _ => {} } } @@ -118,6 +149,7 @@ impl Attributes { title, description, title_field, + untagged, } } } @@ -129,9 +161,8 @@ pub fn web_view_attr(_attr: TokenStream, item: TokenStream) -> TokenStream { struct ItemProps { name: Ident, - title: String, - title_field: Option, - description: Option, + attributes: Attributes, + generics: Generics, } struct StructField { @@ -152,10 +183,12 @@ struct EnumVariant { fn struct_edit(props: &ItemProps, s: DataStruct) -> TokenStream2 { let ItemProps { name, - title, - description, - .. + attributes: Attributes { + title, description, .. + }, + generics, } = props; + let title = title.clone().unwrap_or(name.to_string()); let fields = s.fields.iter().map(|f| { let name = f @@ -168,6 +201,7 @@ fn struct_edit(props: &ItemProps, s: DataStruct) -> TokenStream2 { title, title_field: _, description, + .. } = Attributes::parse(&f.attrs); StructField { @@ -238,10 +272,12 @@ fn struct_edit(props: &ItemProps, s: DataStruct) -> TokenStream2 { fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 { let ItemProps { name, - title, - description, - .. + attributes: Attributes { + title, description, .. + }, + generics, } = props; + let title = title.clone().unwrap_or(name.to_string()); let variants = e.variants.iter().map(|v| { let variant = v.ident.clone(); @@ -255,6 +291,7 @@ fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 { title, title_field: _, description, + .. } = Attributes::parse(&v.attrs); let variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake)); EnumVariant { @@ -358,6 +395,7 @@ fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 { #(#view_description,)* _ => view! { cx, } }) + br() (match selected.get().as_str() { #(#view_match,)* _ => view! { cx, } @@ -370,10 +408,16 @@ fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 { fn struct_view(props: &ItemProps, s: DataStruct) -> TokenStream2 { let ItemProps { name, - title, - description, - title_field, + attributes: + Attributes { + title, + description, + title_field, + .. + }, + generics, } = props; + let title = title.clone().unwrap_or(name.to_string()); let fields = s.fields.iter().map(|f| { let name = f @@ -386,6 +430,7 @@ fn struct_view(props: &ItemProps, s: DataStruct) -> TokenStream2 { title, title_field: _, description, + .. } = Attributes::parse(&f.attrs); StructField { @@ -449,13 +494,41 @@ fn struct_view(props: &ItemProps, s: DataStruct) -> TokenStream2 { } } +fn enum_fields<'a>(e: &'a DataEnum) -> impl Iterator + 'a { + e.variants.iter().map(|v| { + let variant = v.ident.clone(); + + let inner = match &v.fields { + Fields::Unnamed(u) => u.unnamed.first().expect("the should be a field").ty.clone(), + _ => unimplemented!(), + }; + + let Attributes { + title, + title_field: _, + description, + .. + } = Attributes::parse(&v.attrs); + let variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake)); + EnumVariant { + variant_lower, + variant, + inner, + title, + description, + } + }) +} + fn enum_view(props: &ItemProps, e: DataEnum) -> TokenStream2 { let ItemProps { name, - title, - description, - .. + attributes: Attributes { + title, description, .. + }, + generics, } = props; + let title = title.clone().unwrap_or(name.to_string()); let variants = e.variants.iter().map(|v| { let variant = v.ident.clone(); @@ -469,6 +542,7 @@ fn enum_view(props: &ItemProps, e: DataEnum) -> TokenStream2 { title, title_field: _, description, + .. } = Attributes::parse(&v.attrs); let variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake)); EnumVariant { @@ -533,20 +607,12 @@ pub fn web_edit(tokens: TokenStream) -> TokenStream { let name = input.ident; let edit_ident = format_ident!("{}Edit", name); - let Attributes { - title: t, - title_field, - description: d, - } = Attributes::parse(&input.attrs); - - let title = t.unwrap_or(name.to_string()); - let description = d; + let attrs = Attributes::parse(&input.attrs); let props = ItemProps { name: name.clone(), - title: title.clone(), - description: description.clone(), - title_field: title_field.clone(), + attributes: attrs, + generics: input.generics.clone(), }; let inner = match input.data { @@ -555,20 +621,49 @@ pub fn web_edit(tokens: TokenStream) -> TokenStream { _ => 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! { pub struct #edit_ident; - impl<'a, G: Html> Editor<'a, G, #name> for #edit_ident { - fn edit(cx: Scope<'a>, props: EditProps<'a, #name>) -> View { + impl #impl_generics Editor<'a, G, #name #input_ty_generics> for #edit_ident #where_clause { + fn edit(cx: Scope<'a>, props: EditProps<'a, #name #input_ty_generics>) -> View { #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; } }; + println!("{}", &res.to_string()); + TokenStream::from(res) } @@ -579,20 +674,12 @@ pub fn web_view(tokens: TokenStream) -> TokenStream { let name = input.ident; let view_ident = format_ident!("{}View", name); - let Attributes { - title: t, - title_field, - description: d, - } = Attributes::parse(&input.attrs); - - let title = t.unwrap_or(name.to_string()); - let description = d; + let attrs = Attributes::parse(&input.attrs); let props = ItemProps { name: name.clone(), - title: title.clone(), - description: description.clone(), - title_field: title_field.clone(), + attributes: attrs, + generics: input.generics.clone(), }; let inner = match input.data { @@ -601,19 +688,50 @@ pub fn web_view(tokens: TokenStream) -> TokenStream { _ => 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! { pub struct #view_ident; - impl<'a, G: Html> Viewer<'a, G, #name> for #view_ident { - fn view(cx: Scope<'a>, props: ViewProps<'a, #name>) -> View { + impl #impl_generics Viewer<'a, G, #name #input_ty_generics> for #view_ident #where_clause { + fn view(cx: Scope<'a>, props: ViewProps<'a, #name #input_ty_generics>) -> View { #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; } }; + println!("{}", &res.to_string()); + TokenStream::from(res) } diff --git a/web/dist/index.html b/web/dist/index.html index f7a7920..9404915 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -2,11 +2,11 @@ - + LAN Party - - + + - \ No newline at end of file + \ No newline at end of file diff --git a/web/src/components/messages.rs b/web/src/components/messages.rs index dd31d00..c24a93a 100644 --- a/web/src/components/messages.rs +++ b/web/src/components/messages.rs @@ -35,6 +35,26 @@ impl Messenger { self.add_message(Message::new(title.into(), text.into())); } + pub fn add_result( + &self, + result: anyhow::Result, + success: Option>, + fail: Option>, + ) { + 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); } diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index 514507c..61711f5 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,33 +1,9 @@ pub mod messages; +pub use lan_party_core::components::Button; use sycamore::{builder::prelude::*, prelude::*}; use web_sys::Event; -#[derive(Prop)] -pub struct ButtonProps { - 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) -> View { - 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)] pub struct TableProps<'a, G: Html> { pub headers: Vec, @@ -58,64 +34,32 @@ pub fn Table<'a, G: Html>(cx: Scope<'a>, props: TableProps<'a, G>) -> View { } #[derive(Prop)] -pub struct BlockProps<'a, G: Html> { +pub struct ModalProps<'a, G: Html> { + pub open: &'a Signal, pub title: String, pub children: Children<'a, G>, } #[component] -pub fn Block<'a, G: Html>(cx: Scope<'a>, props: BlockProps<'a, G>) -> View { +pub fn Modal<'a, G: Html>(cx: Scope<'a>, props: ModalProps<'a, G>) -> View { let children = props.children.call(cx); + let class = create_memo(cx, || { + if *props.open.get() { + "modal" + } else { + "modal hidden" + } + }); + view! { cx, - details { - summary { (props.title) } - p { (children) } + div(class=class) { + h4 { (props.title) } + div(class="modal-close") { + Button(icon="mdi-close".into(), onclick=move |_| props.open.set(false)) + } + + (children) } } } - -/* -#[derive(Prop)] -pub struct TestProps<'a> { - pub text: &'a str, -} - -#[component] -pub fn Test<'a, G: Html>(cx: Scope<'a>, props: TestProps<'a>) -> View { - let text = create_ref(cx, props.text.clone()); - - // This is okay, but I don't know why - create_child_scope(cx, move |_| { - println!("{}", props.text); - drop(props.text); - }); - - // This is fine - create_child_scope(cx, move |_| { - println!("{}", text); - drop(text); - }); - - // Builders always seem to work just fine - let _: View = div().c(p().t(text)).view(cx); - let _: View = div().dyn_c_scoped(|cx| p().t(text).view(cx)).view(cx); - let _: View = div() - .dyn_c_scoped(|cx| p().t(props.text).view(cx)) - .t(props.text) - .view(cx); - - // error[E0521]: borrowed data escapes outside of function - let _: View = view! { cx, - p { (text) } - }; - - // error[E0521]: borrowed data escapes outside of function - let _: View = view! { cx, - p { (props.text) } - p { (props.text) } - }; - - view! { cx, } -} -*/ diff --git a/web/src/main.rs b/web/src/main.rs index ae0923a..2e3ad3b 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -1,9 +1,11 @@ mod components; mod pages; +pub mod state; pub mod util; use components::messages::{Messages, Messenger}; use pages::{EventsPage, UsersPage}; +use state::{Events, Users}; use sycamore::prelude::*; use sycamore_router::{HistoryIntegration, Route, Router}; @@ -66,6 +68,12 @@ fn main() { let messenger = Messenger::default(); provide_context(cx, messenger); + let users = Users::default(); + provide_context(cx, users); + + let events = Events::default(); + provide_context(cx, events); + /* let messages = use_context::(cx); diff --git a/web/src/pages/events.rs b/web/src/pages/events.rs index fae98e9..b348ef4 100644 --- a/web/src/pages/events.rs +++ b/web/src/pages/events.rs @@ -1,7 +1,8 @@ +use anyhow::anyhow; use lan_party_core::{ components::Block, edit::IntoEdit, - event::{Event, EventSpec, EventUpdate}, + event::{Event, EventOutcome, EventSpec, EventUpdate}, view::IntoView, }; use log::debug; @@ -9,10 +10,20 @@ use reqwasm::http::Method; use sycamore::{futures::spawn_local_scoped, prelude::*}; use crate::{ - components::{messages::Messenger, Button}, + components::{messages::Messenger, Button, Modal, Table}, + state::Events, util::api_request, }; +pub enum Msg { + Reload, + Add(EventSpec), + Update(String, EventUpdate), + Delete(String), + Stop(String), + ViewOutcome(String), +} + #[component] pub fn EventsPage<'a, G: Html>(cx: Scope<'a>) -> View { let messenger = use_context::(cx); @@ -21,119 +32,120 @@ pub fn EventsPage<'a, G: Html>(cx: Scope<'a>) -> View { let event_update = create_signal(cx, EventUpdate::default()); let event_update_name = create_signal(cx, String::new()); - let events: &'a Signal> = create_signal(cx, Vec::::new()); + let current_event = create_signal(cx, String::new()); + let show_outcome = create_signal(cx, false); + let event_outcome = create_signal(cx, EventOutcome::default()); - let update_events = move || async move { - events.set( - api_request::<_, Vec>(Method::GET, "/event", Option::<()>::None) - .await - .map(|inner| inner.unwrap()) - .unwrap(), - ); - }; + let events = use_context::(cx); - spawn_local_scoped(cx, update_events()); - - let onadd = move |_| { + let dispatch = move |msg: Msg| { spawn_local_scoped(cx, async move { - let new_event = api_request::( - Method::POST, - "/event", - Some((*event_spec).get().as_ref().clone()), - ) - .await; - - if let Ok(Some(new_event)) = new_event { - messenger.info( - "Added new event", - format!( - "Successfully created a new event with name \"{}\"", - new_event.name - ), - ); - events.modify().push(new_event); - } else { - messenger.info("Error when adding an event", "Unable to create a new event"); - } - }); - }; - - let onupdate = move |_| { - spawn_local_scoped(cx, async move { - let res = api_request::( - Method::POST, - &format!("/event/{}", event_update_name), - Some((*event_update).get().as_ref().clone()), - ) - .await; - - if let Ok(_) = res { - update_events().await; - messenger.info( - "Updated event", - format!( - "Successfully updated event with name \"{}\"", - event_update_name - ), - ); - } else { - messenger.info( - "Error when updating event", - format!("Unable to update event with name \"{}\"", event_update_name), - ); - } - }); - }; - - let onviewoutcome = move |event_name: String| { - move |_| { - let event_name = event_name.clone(); - spawn_local_scoped(cx, async move {}); - } - }; - - let onstop = move |event_name: String| { - move |_| { - let event_name = event_name.clone(); - spawn_local_scoped(cx, async move {}); - } - }; - - let ondelete = move |event_name: String| { - move |_| { - let event_name = event_name.clone(); - spawn_local_scoped(cx, async move { - let res = - api_request::<(), ()>(Method::DELETE, &format!("/event/{}", event_name), None) - .await; - - if let Ok(_) = res { - update_events().await; - messenger.info( - "Removed event", - format!("Successfully removed event with name \"{}\"", event_name), - ); - } else { - messenger.info( - "Error when removing event", - format!("Unable to remove event with name \"{}\"", event_name), + match msg { + Msg::Add(event_spec) => { + let name = event_spec.name.clone(); + messenger.add_result( + events.add(event_spec).await, + Some(format!("Created a new event with name \"{}\"", name)), + Some("Error when adding an event"), ); } - }); - } + Msg::Reload => messenger.add_result( + events.load().await, + Option::::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 { + debug!("{:#?}", outcome); + event_outcome.set(outcome); + } else { + debug!("oh no"); + messenger.add_result( + res, + Option::::None, + Some("Failed to load outcome"), + ) + } + } + _ => {} + } + }); }; + 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.sort_by_key(|(_, s)| -*s); + vec + }); + view! { cx, + Modal(open=show_outcome, title="Event outcome".into()) { + Table(headers=vec!["Username".into(), "Score".into()]) { + Indexed( + iterable=&outcome_points, + view=move |cx, (k, v)| { + view! { cx, + tr { + td { (k.clone()) } + td { (v.clone()) } + } + } + } + ) + } + Button(text="Reload".into(), icon="mdi-refresh".into(), onclick=move |_| dispatch(Msg::ViewOutcome(current_event.get().as_ref().clone()))) + } div(class="events-cols") { div { Block(title="Events".into()) { + Button(text="Reload".into(), icon="mdi-refresh".into(), onclick=move |_| dispatch(Msg::Reload)) Indexed( - iterable=&events, + iterable=&events.get(), view=move |cx, event| { let event = create_ref(cx, event); view! { cx, - //(event.view(cx)) - EventView(event=event, ondelete=ondelete(event.name.clone()), onviewoutcome=onviewoutcome(event.name.clone()), onstop=onstop(event.name.clone())) + 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() } }, @@ -143,14 +155,14 @@ pub fn EventsPage<'a, G: Html>(cx: Scope<'a>) -> View { div(class="events-right") { Block(title="Create new event".into()) { (event_spec.edit(cx)) - Button(icon="mdi-check".into(), onclick=onadd) + 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" } select(bind:value=event_update_name) { Indexed( - iterable=&events, + iterable=&events.get(), view=move |cx, event| { let event = create_ref(cx, event); view! { cx, @@ -160,44 +172,15 @@ pub fn EventsPage<'a, G: Html>(cx: Scope<'a>) -> View { ) } (event_update.edit(cx)) - Button(icon="mdi-check".into(), onclick=onupdate) + Button( + icon="mdi-check".into(), + onclick=move |_| dispatch(Msg::Update( + event_update_name.get().as_ref().clone(), + event_update.get().as_ref().clone() + )) + ) } } } } } - -#[derive(Prop)] -struct EventViewProps<'a, F1, F2, F3> -where - F1: FnMut(web_sys::Event), - F2: FnMut(web_sys::Event), - F3: FnMut(web_sys::Event), -{ - pub event: &'a Event, - - pub ondelete: F1, - pub onviewoutcome: F2, - pub onstop: F3, -} - -#[component] -fn EventView<'a, G, F1, F2, F3>(cx: Scope<'a>, props: EventViewProps<'a, F1, F2, F3>) -> View -where - F1: FnMut(web_sys::Event) + 'a, - F2: FnMut(web_sys::Event) + 'a, - F3: FnMut(web_sys::Event) + 'a, - G: Html, -{ - view! { cx, - Block(title=props.event.name.clone()) { - (props.event.description) - br() - Button(text="Delete".into(), icon="mdi-delete".into(), onclick=props.ondelete) - Button(text="View outcome".into(), onclick=props.onviewoutcome) - Button(text="Finish".into(), onclick=props.onstop) - br() - (props.event.event_type.view(cx)) - } - } -} diff --git a/web/src/pages/users.rs b/web/src/pages/users.rs index 731b244..3eed0ab 100644 --- a/web/src/pages/users.rs +++ b/web/src/pages/users.rs @@ -1,84 +1,54 @@ -use crate::components::{Button, Table}; -use lan_party_core::user::User; -use log::debug; -use reqwasm::http::Method; +use crate::{ + components::{messages::Messenger, Button, Table}, + state::Users, +}; 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 { - let users = create_signal(cx, Vec::::new()); + //let users = create_signal(cx, Vec::::new()); + let messenger = use_context::(cx); + let users = use_context::(cx); let headers = vec!["Username".into(), "Score".into(), "".into()]; let score_edit = create_signal(cx, Option::::None); let new_score = create_signal(cx, String::new()); let new_username = create_signal(cx, String::new()); - spawn_local_scoped(cx, async move { - users.set( - api_request::<_, Vec>(Method::GET, "/user", Option::<()>::None) - .await - .map(|inner| inner.unwrap()) - .unwrap(), - ); - }); + let reload = move || { + spawn_local_scoped(cx, async move { + messenger.add_result( + users.load().await, + Option::::None, + Some("Failed to load users"), + ); + }); + }; + + reload(); let ondelete = move |name: String| { - move |event: Event| { + move |_| { let name = name.clone(); spawn_local_scoped(cx, async move { - debug!("Delete {:#?}", event); - let users_ref = users.get(); - let user: &User = users_ref.iter().find(|user| user.name == name).unwrap(); - api_request::<_, ()>( - 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); + messenger.add_result( + users.delete(&name).await, + Some(format!("Deleted user {}", name)), + Some("Failed to delete user"), + ); }); } }; let oncheck = move |_| { spawn_local_scoped(cx, async move { - if let (Some(score_edit), Ok(score)) = - (score_edit.get().as_ref(), new_score.get().parse()) - { + if let (Some(name), Ok(score)) = (score_edit.get().as_ref(), new_score.get().parse()) { let score: i64 = score; - let users_ref = users.get(); - let user: &User = users_ref - .iter() - .find(|user| &user.name == score_edit) - .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); + messenger.add_result( + users.update_score(&name, score).await, + Some(format!("Updated score for user {}", name)), + Some("Failed to delete user"), + ); } score_edit.set(None); }) @@ -86,21 +56,19 @@ pub fn UsersPage<'a, G: Html>(cx: Scope<'a>) -> View { let onadd = move |_| { spawn_local_scoped(cx, async move { - let user = api_request::( - Method::POST, - "/user", - Some((*new_username).get().as_ref().clone()), - ) - .await - .unwrap(); - users.modify().push(user.unwrap()); + messenger.add_result( + users.add(&new_username.get()).await, + Some(format!("Added new user {}", new_username.get())), + Some("Failed to add user"), + ); }); }; view! { cx, + Button(text="Reload".into(), icon="mdi-refresh".into(), onclick=move |_| reload()) Table(headers=headers) { Keyed( - iterable=users, + iterable=users.get(), view=move |cx, user| { let user = create_ref(cx, user); view! { cx, diff --git a/web/src/state.rs b/web/src/state.rs new file mode 100644 index 0000000..51ff4fb --- /dev/null +++ b/web/src/state.rs @@ -0,0 +1,128 @@ +use anyhow::{anyhow, Result}; +use lan_party_core::{ + event::{Event, EventSpec, EventUpdate}, + user::User, +}; +use reqwasm::http::Method; +use sycamore::prelude::*; + +use crate::util::api_request; + +#[derive(Clone, PartialEq, Default)] +pub struct Users(RcSignal>); + +impl Users { + pub fn get(&self) -> &RcSignal> { + &self.0 + } + + pub async fn load(&self) -> Result<()> { + self.0.set( + api_request::<_, Vec>( + Method::GET, + "/user?sort=score&order=desc", + Option::<()>::None, + ) + .await + .and_then(|inner| inner.ok_or(anyhow!("missing body")))?, + ); + Ok(()) + } + + pub 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(()) + } + + pub 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(()) + } + + pub 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(()) + } +} + +#[derive(Clone, PartialEq, Default)] +pub struct Events(RcSignal>); + +impl Events { + pub fn get(&self) -> &RcSignal> { + &self.0 + } + + pub async fn load(&self) -> Result<()> { + self.0.set( + api_request::<_, Vec>(Method::GET, "/event", Option::<()>::None) + .await + .and_then(|inner| inner.ok_or(anyhow!("missing body")))?, + ); + Ok(()) + } + + pub async fn add(&self, event_spec: EventSpec) -> Result<()> { + let new_event = + api_request::(Method::POST, "/event", Some(event_spec)).await?; + + self.0 + .modify() + .push(new_event.ok_or(anyhow!("missing body"))?); + Ok(()) + } + + pub async fn update_event(&self, event_name: &str, event_update: EventUpdate) -> Result<()> { + api_request::( + Method::POST, + &format!("/event/{}", event_name), + Some(event_update), + ) + .await?; + + self.load().await?; + + Ok(()) + } + + pub async fn delete(&self, event_name: &str) -> Result<()> { + api_request::<(), ()>(Method::DELETE, &format!("/event/{}", event_name), None).await?; + + self.load().await?; + + Ok(()) + } +} diff --git a/web/style.css b/web/style.css index 5e04eec..1118f77 100644 --- a/web/style.css +++ b/web/style.css @@ -109,3 +109,39 @@ body { .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; +}