diff --git a/core/src/event.rs b/core/src/event.rs index 57033d9..bc25763 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -2,7 +2,9 @@ use crate::edit::prelude::*; use crate::util::PartyError; #[cfg(feature = "sycamore")] -use lan_party_macros::WebEdit; +use crate::view::prelude::*; +#[cfg(feature = "sycamore")] +use lan_party_macros::{WebEdit, WebView}; use paste::paste; #[cfg(feature = "openapi")] use schemars::JsonSchema; @@ -23,6 +25,7 @@ pub struct EventOutcome { #[derive(Clone, Debug)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "sycamore", derive(WebView))] pub struct Event { /// Has this event concluded? #[cfg_attr(feature = "serde", serde(default))] @@ -55,7 +58,7 @@ impl Event { #[derive(Clone, Debug, Default, PartialEq)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[cfg_attr(feature = "sycamore", derive(WebEdit))] +#[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub struct EventSpec { pub name: String, pub description: String, @@ -71,7 +74,7 @@ macro_rules! events { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum EventTypeSpec { $($name($module::[<$name Spec>]),)* } @@ -82,7 +85,7 @@ macro_rules! events { #[derive(Clone, Debug)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum EventUpdate { $($name($module::[<$name Update>]),)* } @@ -93,7 +96,7 @@ macro_rules! events { #[derive(Clone, Debug)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum EventType { $($name($module::$name),)* } @@ -160,7 +163,7 @@ pub mod test { #[derive(Clone, Debug, Default)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub struct Test { pub num_players: i64, } @@ -168,7 +171,7 @@ pub mod test { #[derive(Clone, Debug, Default, PartialEq)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub struct TestSpec { pub num_players: i64, } @@ -176,7 +179,7 @@ pub mod test { #[derive(Clone, Debug, Default)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub struct TestUpdate { pub win_game: bool, } @@ -219,7 +222,7 @@ pub mod team_game { #[derive(Clone, Debug, Default)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub struct TeamGame { /// Map of teams with a name as key and an array of players as value pub teams: HashMap>, @@ -231,7 +234,7 @@ pub mod team_game { #[derive(Clone, Debug, PartialEq, Default)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub struct TeamGameSpec { /// Map of teams with a name as key and an array of players as value pub teams: HashMap>, @@ -255,7 +258,7 @@ pub mod team_game { #[derive(Clone, Debug, Default)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub struct TeamGameUpdateSetTeam { pub team: String, pub members: Vec, @@ -264,7 +267,7 @@ pub mod team_game { #[derive(Clone, Debug)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[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), @@ -283,7 +286,7 @@ pub mod team_game { #[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))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum TeamGameFfaInheritedUpdate { /// Change the ranking and scores Ranking(FreeForAllGameUpdateRanking), @@ -301,10 +304,14 @@ pub mod team_game { #[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))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum TeamGameUpdate { + /// # Team + /// /// Team specific updates Team(TeamGameUpdateInner), + /// # Other + /// /// Inherited from FreeForAllGame Ffa(TeamGameFfaInheritedUpdate), } @@ -390,7 +397,7 @@ pub mod free_for_all_game { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[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.) @@ -417,7 +424,7 @@ pub mod free_for_all_game { #[derive(Clone, Debug, Default)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub struct FreeForAllGame { /// Ranking of participants by user name or team name (first element is first place, second element is second /// place, etc.) @@ -439,7 +446,7 @@ pub mod free_for_all_game { #[derive(Clone, Debug, PartialEq, Default)] #[cfg_attr(feature = "openapi", derive(JsonSchema))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[cfg_attr(feature = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub struct FreeForAllGameSpec { /// Array of user ids that participate in the game pub participants: HashSet, @@ -457,7 +464,7 @@ 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 = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum FreeForAllGameUpdateRanking { /// Replace the current ranking with the given ranking SetRanking(FreeForAllGameRanking), @@ -475,7 +482,7 @@ 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 = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum FreeForAllGameUpdateRewards { /// Set rewards for winning the game SetWinRewards(Vec), @@ -493,7 +500,7 @@ 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 = "sycamore", derive(WebEdit))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum FreeForAllGameUpdateParticipants { /// Set list of participants participating in the game SetParticipants(HashSet), @@ -515,7 +522,7 @@ pub mod free_for_all_game { #[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))] + #[cfg_attr(feature = "sycamore", derive(WebEdit, WebView))] pub enum FreeForAllGameUpdate { /// Change the ranking and scores Ranking(FreeForAllGameUpdateRanking), diff --git a/core/src/lib.rs b/core/src/lib.rs index 39158db..c6d979a 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -5,6 +5,9 @@ pub mod util; #[cfg(feature = "sycamore")] pub mod edit; +#[cfg(feature = "sycamore")] +pub mod view; + #[cfg(feature = "sycamore")] pub mod components; diff --git a/core/src/view.rs b/core/src/view.rs new file mode 100644 index 0000000..f6d56b5 --- /dev/null +++ b/core/src/view.rs @@ -0,0 +1,209 @@ +use std::{ + collections::{HashMap, HashSet}, + hash::Hash, + marker::PhantomData, +}; +use sycamore::view::IntoView as _; + +use sycamore::{builder::prelude::*, prelude::*}; + +pub mod prelude { + pub use super::{IntoView, ViewProps, Viewable, Viewer, WebView}; + pub use crate::components::Block; + pub use paste::paste; + pub use sycamore::prelude::*; +} + +use crate::components::{Block, BlockProps}; + +#[macro_export] +macro_rules! viewable { + ($type:ty => $viewer:ty) => { + impl<'a, G: Html> Viewable<'a, G> for $type { + type Viewer = $viewer; + } + }; +} + +#[derive(Prop)] +pub struct ViewProps<'a, T> { + pub state: &'a Signal, +} + +impl<'a, T> From<&'a Signal> for ViewProps<'a, T> { + fn from(state: &'a Signal) -> Self { + ViewProps { state } + } +} + +pub trait IntoView<'a, G: Html> { + fn view(self, cx: Scope<'a>) -> View; +} + +impl<'a, G: Html, T: Viewable<'a, G>> IntoView<'a, G> for &'a Signal +where + ViewProps<'a, T>: From<&'a Signal>, +{ + fn view(self, cx: Scope<'a>) -> View { + T::view(cx, self.into()) + } +} + +pub trait WebView<'a, G: Html>: Sized { + fn view(cx: Scope<'a>, props: ViewProps<'a, Self>) -> View; +} + +impl<'a, G, E, Type> WebView<'a, G> for Type +where + G: Html, + E: Viewer<'a, G, Type>, + Type: Viewable<'a, G, Viewer = E>, +{ + fn view(cx: Scope<'a>, props: ViewProps<'a, Self>) -> View { + E::view(cx, props) + } +} + +pub trait Viewer<'a, G: Html, Type>: Sized { + fn view(cx: Scope<'a>, props: ViewProps<'a, Type>) -> View; +} + +pub trait Viewable<'a, G: Html>: Sized { + type Viewer: Viewer<'a, G, Self>; +} + +pub struct StringView; + +impl<'a, G: Html, T: 'a> Viewer<'a, G, T> for StringView +where + T: sycamore::view::IntoView, +{ + fn view(cx: Scope<'a>, props: ViewProps<'a, T>) -> View { + view! { cx, + (props.state.get().clone().create()) + } + } +} + +viewable!(String => StringView); + +viewable!(i64 => StringView); +viewable!(i32 => StringView); +viewable!(isize => StringView); + +viewable!(u64 => StringView); +viewable!(u32 => StringView); +viewable!(usize => StringView); + +viewable!(f64 => StringView); +viewable!(f32 => StringView); + +pub struct BoolView; + +impl<'a, G: Html> Viewer<'a, G, bool> for BoolView { + fn view(cx: Scope<'a>, props: ViewProps<'a, bool>) -> View { + let signal = create_signal(cx, props.state.get()); + view! { cx, + input(type="checkbox", checked=*signal.get().as_ref().clone(), disabled=true) + } + } +} + +viewable!(bool => BoolView); + +pub struct VecView; + +impl<'a, G, T, I> Viewer<'a, G, I> for VecView +where + G: Html, + T: for<'b> Viewable<'b, G> + Clone + PartialEq + 'a, + I: IntoIterator + FromIterator + Clone, +{ + fn view(cx: Scope<'a>, props: ViewProps<'a, I>) -> View { + Block( + cx, + BlockProps { + title: "List".into(), + children: Children::new(cx, move |_| { + view! { cx, + //Block(title="List".into()) { + div { + (View::new_fragment(props.state.get().as_ref().clone().into_iter().map(|x| { + let x = create_signal(cx, x); + div().c(x.view(cx)).c(br()).view(cx) + }).collect())) + } + //} + } + }), + }, + ) + } +} + +impl<'a, G, T> Viewable<'a, G> for Vec +where + G: Html, + T: for<'b> Viewable<'b, G> + Clone + PartialEq + 'a, +{ + type Viewer = VecView; +} + +impl<'a, G, T> Viewable<'a, G> for HashSet +where + G: Html, + T: for<'b> Viewable<'b, G> + Clone + PartialEq + Hash + Eq + 'a, +{ + type Viewer = VecView; +} + +impl<'a, G, K, V> Viewable<'a, G> for HashMap +where + G: Html, + K: Clone + Hash + Eq, + V: Clone, + (K, V): for<'b> Viewable<'b, G> + Clone + PartialEq + Hash + Eq + 'a, +{ + type Viewer = VecView; +} + +pub struct TupleView; + +impl<'a, G: Html, A: for<'b> Viewable<'b, G> + Clone, B: for<'b> Viewable<'b, G> + Clone> + Viewer<'a, G, (A, B)> for TupleView +{ + fn view(cx: Scope<'a>, props: ViewProps<'a, (A, B)>) -> View { + let a = create_signal(cx, props.state.get().as_ref().clone().0); + let b = create_signal(cx, props.state.get().as_ref().clone().1); + view! { cx, + Block(title="Tuple".into()) { + (a.view(cx)) + (b.view(cx)) + } + } + } +} + +impl<'a, G, A, B> Viewable<'a, G> for (A, B) +where + G: Html, + A: for<'b> Viewable<'b, G> + Clone, + B: for<'b> Viewable<'b, G> + Clone, +{ + type Viewer = TupleView; +} + +pub struct OptionView; + +impl<'a, G: Html, T: for<'b> Viewable<'b, G> + Clone> Viewer<'a, G, Option> for OptionView { + fn view(cx: Scope<'a>, props: ViewProps<'a, Option>) -> View { + match props.state.get().as_ref().clone() { + Some(x) => view! { cx, (create_signal(cx, x.clone()).view(cx)) }, + None => view! { cx, "None" }, + } + } +} + +impl<'a, G: Html, T: for<'b> Viewable<'b, G> + Clone> Viewable<'a, G> for Option { + type Viewer = OptionView; +} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index f506675..9172c75 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -2,8 +2,425 @@ mod edit; use convert_case::{Case, Casing}; use proc_macro::TokenStream; -use quote::{format_ident, quote}; -use syn::Fields; +use quote::{__private::TokenStream as TokenStream2, format_ident, quote}; +use syn::{token::Do, Attribute, DataEnum, DataStruct, Fields, Ident, Path, Type}; + +#[derive(Debug)] +enum Documentation { + Title(String), + Description(String), + None, +} + +impl Documentation { + fn parse(attr: &Attribute) -> Documentation { + if !attr.path.is_ident("doc") { + return Documentation::None; + } + + let text = attr.tokens.to_string(); + + let text = text.trim_matches(|c: char| c == '\"' || c == '=' || c.is_whitespace()); + + match text.get(0..1) { + Some("#") => Documentation::Title(text.trim_matches('#').trim().into()), + Some(&_) => Documentation::Description(text.trim().into()), + None => Documentation::None, + } + } +} + +fn get_title_description(attrs: &[Attribute]) -> (Option, Option) { + let docs: Vec<_> = attrs.iter().map(Documentation::parse).collect(); + + let mut title = None; + let mut description: Option = None; + + for doc in docs { + 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); + } + _ => {} + } + } + + (title, description) +} + +struct ItemProps { + name: Ident, + title: String, + description: Option, +} + +struct StructField { + name: Ident, + name_str: String, + title: Option, + description: Option, +} + +struct EnumVariant { + variant: Ident, + variant_lower: Ident, + inner: Type, + title: Option, + description: Option, +} + +fn struct_edit(props: &ItemProps, s: DataStruct) -> TokenStream2 { + let ItemProps { + name, + title, + description, + } = props; + + let fields = s.fields.iter().map(|f| { + let name = f + .ident + .as_ref() + .expect("each struct field must be named") + .clone(); + let name_str = name.to_string(); + let (title, description) = get_title_description(&f.attrs); + + StructField { + name_str, + name, + title, + description, + } + }); + + let fields_view = fields.clone().map(|f| { + let title = f.title.unwrap_or(f.name_str.to_case(Case::Title)); + let description = if let Some(d) = f.description { + quote! { + span(class="description") { + " " #d + } + } + } else { + quote! {} + }; + let name = f.name; + quote! { + p { + label { (#title) br() #description } + (#name.edit(cx)) + } + } + }); + + let signals = fields.clone().map(|f| { + let name = f.name; + quote! { + let #name = create_signal(cx, state.get_untracked().#name.clone()); + } + }); + + let effect_fields = fields.clone().map(|f| { + let name = f.name; + quote! { + #name: #name.get().as_ref().clone() + } + }); + + quote! { + let state = props.state; + + #(#signals)* + + create_effect(cx, || { + state.set(#name { + #(#effect_fields,)* + ..Default::default() + }); + }); + + view! { cx, + Block(title=#title.to_string()) { + p { + #description + } + #(#fields_view)* + } + } + } +} + +fn enum_edit(props: &ItemProps, e: DataEnum) -> TokenStream2 { + let ItemProps { + name, + title, + description, + } = props; + + let variants = 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 (title, description) = get_title_description(&v.attrs); + let variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake)); + EnumVariant { + variant_lower, + variant, + inner, + title, + description, + } + }); + + let first = variants.clone().next().unwrap().variant_lower; + let first_str = first.to_string(); + + let options = variants.clone().map(|v| { + let lower = v.variant_lower; + let title = v + .title + .unwrap_or(v.variant.to_string().to_case(Case::Title)); + let selected = first == lower; + quote! { option(value={stringify!(#lower)}, selected=#selected) { (#title) } } + }); + + let view_match = variants.clone().map(|v| { + let lower = v.variant_lower; + let lower_str = format!("{}", lower); + + quote! { + #lower_str => #lower.edit(cx) + } + }); + + let view_description = variants.clone().map(|v| { + let lower = v.variant_lower; + let lower_str = format!("{}", lower); + let description = if let Some(d) = v.description { + quote! { + span(class="description") { + " " #d + } + } + } else { + quote! {} + }; + + quote! { + #lower_str => view! { cx, #description } + } + }); + + let signals = variants.clone().map( + |EnumVariant { + variant, + variant_lower, + inner, + .. + }| { + quote! { + let #variant_lower = if let #name::#variant(v) = state.get_untracked().as_ref().clone() { + create_signal(cx, v.clone()) + } else { + create_signal(cx, <#inner>::default()) + }; + } + }, + ); + + let effect_match = variants.clone().map(|v| { + let lower = v.variant_lower; + let variant = v.variant; + let lower_str = format!("{}", lower); + + quote! { + #lower_str => state.set(#name::#variant(#lower.get().as_ref().clone())) + } + }); + + quote! { + let state = props.state; + + let selected = create_signal(cx, String::from(#first_str)); + + #(#signals)* + + create_effect(cx, || { + match selected.get().as_str() { + #(#effect_match,)* + _ => {} + } + }); + + view! { cx, + Block(title=#title.to_string()) { + p { + #description + } + select(bind:value=selected) { + #(#options)* + } + (match selected.get().as_str() { + #(#view_description,)* + _ => view! { cx, } + }) + (match selected.get().as_str() { + #(#view_match,)* + _ => view! { cx, } + }) + } + } + } +} + +fn struct_view(props: &ItemProps, s: DataStruct) -> TokenStream2 { + let ItemProps { + name, + title, + description, + } = props; + + let fields = s.fields.iter().map(|f| { + let name = f + .ident + .as_ref() + .expect("each struct field must be named") + .clone(); + let name_str = name.to_string(); + let (title, description) = get_title_description(&f.attrs); + + StructField { + name_str, + name, + title, + description, + } + }); + + let signals = fields.clone().map(|f| { + let name = f.name; + quote! { + let #name = create_signal(cx, state.get().#name.clone()); + } + }); + + let fields_view = fields.clone().map(|f| { + let title = f.title.unwrap_or(f.name_str.to_case(Case::Title)); + let description = if let Some(d) = f.description { + quote! { + span(class="description") { + " " #d + } + } + } else { + quote! {} + }; + let name = f.name; + quote! { + p { + label { (#title) br() #description } + (#name.view(cx)) + } + } + }); + + quote! { + let state = props.state; + + #(#signals)* + + view! { cx, + Block(title=#title.to_string()) { + p { + #description + } + #(#fields_view)* + } + } + } +} + +fn enum_view(props: &ItemProps, e: DataEnum) -> TokenStream2 { + let ItemProps { + name, + title, + description, + } = props; + + let variants = 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 (title, description) = get_title_description(&v.attrs); + let variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake)); + EnumVariant { + variant_lower, + variant, + inner, + title, + description, + } + }); + + let view_match = variants.clone().map(|v| { + let variant = v.variant; + + quote! { + #name::#variant(x) => create_signal(cx, x).view(cx) + } + }); + + let view_description = variants.clone().map(|v| { + let variant = v.variant; + let description = if let Some(d) = v.description { + quote! { + span(class="description") { + " " #d + } + } + } else { + quote! {} + }; + + quote! { + #name::#variant(_) => view! { cx, #description } + } + }); + + quote! { + let state = props.state; + + view! { cx, + Block(title=#title.to_string()) { + p { + #description + } + (match state.get().as_ref().clone() { + #(#view_description,)* + _ => view! { cx, } + }) + (match state.get().as_ref().clone() { + #(#view_match,)* + _ => view! { cx, } + }) + } + } + } +} #[proc_macro_derive(WebEdit)] pub fn web_edit(tokens: TokenStream) -> TokenStream { @@ -12,161 +429,77 @@ pub fn web_edit(tokens: TokenStream) -> TokenStream { let name = input.ident; let edit_ident = format_ident!("{}Edit", name); - let editable = quote! { + let (t, d) = get_title_description(&input.attrs); + + let title = t.unwrap_or(name.to_string()); + let description = d; + + let props = ItemProps { + name: name.clone(), + title: title.clone(), + description: description.clone(), + }; + + let inner = match input.data { + syn::Data::Struct(s) => struct_edit(&props, s), + syn::Data::Enum(e) => enum_edit(&props, e), + _ => unimplemented!(), + }; + + 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 { + #inner + } + } + impl<'a, G: Html> Editable<'a, G> for #name { type Editor = #edit_ident; } }; - let derived = match input.data { - syn::Data::Struct(s) => { - let fields = s.fields.iter().map(|f| { - let name = f - .ident - .as_ref() - .expect("each struct field must be named") - .clone(); - let name_str = name.to_string(); - (name_str, name) - }); + TokenStream::from(res) +} - let fields_view = fields.clone().map(|(name_str, name)| { - let title = name_str.to_case(Case::Title); - quote! { - p { - label { (#title) } - (#name.edit(cx)) - } - } - }); +#[proc_macro_derive(WebView)] +pub fn web_view(tokens: TokenStream) -> TokenStream { + let input: syn::DeriveInput = syn::parse(tokens).unwrap(); - let signals = fields.clone().map(|(_name_str, name)| { - quote! { - let #name = create_signal(cx, state.get_untracked().#name.clone()); - } - }); + let name = input.ident; + let view_ident = format_ident!("{}View", name); - let effect_fields = fields.clone().map(|(_name_str, name)| { - quote! { - #name: #name.get().as_ref().clone() - } - }); + let (t, d) = get_title_description(&input.attrs); - quote! { - impl<'a, G: Html> Editor<'a, G, #name> for #edit_ident { - fn edit(cx: Scope<'a>, props: EditProps<'a, #name>) -> View { - let state = props.state; + let title = t.unwrap_or(name.to_string()); + let description = d; - #(#signals)* + let props = ItemProps { + name: name.clone(), + title: title.clone(), + description: description.clone(), + }; - create_effect(cx, || { - state.set(#name { - #(#effect_fields,)* - ..Default::default() - }); - }); - - view! { cx, - Block(title=stringify!(#name).to_string()) { - #(#fields_view)* - } - } - } - } - } - } - syn::Data::Enum(e) => { - //dbg!(&e); - - let variants = 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 variant_lower = format_ident!("{}", variant.to_string().to_case(Case::Snake)); - (variant_lower, variant, inner) - }); - - let first = variants.clone().next().unwrap().0; - - let options = variants.clone().map(|(lower, variant, _inner)| { - let selected = first == lower; - quote! { option(value={stringify!(#lower)}, selected=#selected) { (stringify!(#variant)) } } - }); - - let view_match = variants.clone().map(|(lower, _variant, _inner)| { - let lower_str = format!("{}", lower); - - quote! { - #lower_str => #lower.edit(cx) - } - }); - - let signals = variants.clone().map(|(lower, variant, inner)| { - quote! { - let #lower = if let #name::#variant(v) = state.get_untracked().as_ref().clone() { - create_signal(cx, v.clone()) - } else { - create_signal(cx, <#inner>::default()) - }; - } - }); - - let effect_match = variants.clone().map(|(lower, variant, _inner)| { - let lower_str = format!("{}", lower); - - quote! { - #lower_str => state.set(#name::#variant(#lower.get().as_ref().clone())) - } - }); - - quote! { - impl<'a, G: Html> Editor<'a, G, #name> for #edit_ident { - fn edit(cx: Scope<'a>, props: EditProps<'a, #name>) -> View { - let state = props.state; - - let selected = create_signal(cx, String::from("0")); - - #(#signals)* - - create_effect(cx, || { - match selected.get().as_str() { - #(#effect_match,)* - _ => {} - //_ => unreachable!() - } - }); - - view! { cx, - Block(title=stringify!(#name).to_string()) { - select(bind:value=selected) { - #(#options)* - } - (match selected.get().as_str() { - #(#view_match,)* - _ => view! { cx, } - //_ => unreachable!() - }) - } - } - } - } - } - } + let inner = match input.data { + syn::Data::Struct(s) => struct_view(&props, s), + syn::Data::Enum(e) => enum_view(&props, e), _ => unimplemented!(), }; - println!("{}", &derived.to_string()); - TokenStream::from(quote! { - #derived + let res = quote! { + pub struct #view_ident; - #editable - }) + impl<'a, G: Html> Viewer<'a, G, #name> for #view_ident { + fn view(cx: Scope<'a>, props: ViewProps<'a, #name>) -> View { + #inner + } + } + + impl<'a, G: Html> Viewable<'a, G> for #name { + type Viewer = #view_ident; + } + }; + + TokenStream::from(res) } diff --git a/web/dist/index.html b/web/dist/index.html index 2f5209d..4250ef0 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/pages/events.rs b/web/src/pages/events.rs index 94bf768..8605c6d 100644 --- a/web/src/pages/events.rs +++ b/web/src/pages/events.rs @@ -1,22 +1,65 @@ use lan_party_core::{ edit::IntoEdit, - event::{EventSpec, EventUpdate}, + event::{Event, EventSpec, EventUpdate}, + view::IntoView, }; use log::debug; -use sycamore::prelude::*; +use reqwasm::http::Method; +use sycamore::{futures::spawn_local_scoped, prelude::*}; -use crate::components::{Block, Button}; +use crate::{ + components::{Block, Button}, + util::api_request, +}; #[component] pub fn EventsPage<'a, G: Html>(cx: Scope<'a>) -> View { let event_spec = create_signal(cx, EventSpec::default()); let event_update = create_signal(cx, EventUpdate::default()); + let events = create_signal(cx, None); + let test_event = create_signal(cx, None); + + spawn_local_scoped(cx, async move { + events.set(Some( + api_request::<_, Vec>(Method::GET, "/event", Option::<()>::None) + .await + .map(|inner| inner.unwrap()) + .unwrap(), + )); + test_event.set(Some(events.get().unwrap().get(0).unwrap().clone())); + debug!("{:#?}", test_event); + }); + + let onadd = move |_| { + spawn_local_scoped(cx, async move { + let new_event = api_request::( + Method::POST, + "/event", + Some((*event_spec).get().as_ref().clone()), + ) + .await + .unwrap(); + debug!("{:#?}", new_event); + let mut cloned = (*events).get().as_ref().clone(); + cloned.unwrap().push(new_event.unwrap()); + events.set(cloned); + }); + }; + view! { cx, + Block(title="Events".into()) { + (test_event.view(cx)) + (events.view(cx)) + } Block(title="Create new event".into()) { (event_spec.edit(cx)) + Button(icon="mdi-check".into(), onclick=onadd) + } + br() + Block(title="Update an event".into()) { (event_update.edit(cx)) - Button(icon="mdi-check".into(), onclick=move |_| debug!("{:#?}", event_spec.get())) + Button(icon="mdi-check".into(), onclick=move |_| debug!("{:#?}", event_update.get())) } } } diff --git a/web/style.css b/web/style.css index b6dd280..677b804 100644 --- a/web/style.css +++ b/web/style.css @@ -58,3 +58,7 @@ textarea:focus, input:focus{ outline: none; } +.description { + font-size: 0.9rem; + font-style: italic; +}