diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5fba26e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = [ "--cfg", "engine" ] diff --git a/Cargo.toml b/Cargo.toml index 50d469a..c49c59c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,19 +5,30 @@ version = "0.1.0" edition = "2021" [dependencies] -perseus = { version = "0.4.2", features = [ "hydrate" ] } -sycamore = "0.8.2" +perseus = { version = "0.4.2", features = ["hydrate"] } +sycamore = { version = "0.8.2", features = [ + "suspense", + "web", + "wasm-bindgen-interning", +] } serde = { version = "1", features = ["derive"] } serde_json = "1" env_logger = "0.10.0" log = "0.4.20" once_cell = "1.18.0" +web-sys = "0.3.64" +cfg-if = "1.0.0" +chrono = { version = "0.4.31", features = ["serde"] } [target.'cfg(engine)'.dev-dependencies] fantoccini = "0.19" [target.'cfg(engine)'.dependencies] -tokio = { version = "1", features = [ "macros", "rt", "rt-multi-thread" ] } -perseus-axum = { version = "0.4.2", features = [ "dflt-server" ] } +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } +perseus-axum = { version = "0.4.2" } +axum = "0.6" +tower-http = { version = "0.3", features = ["fs"] } [target.'cfg(client)'.dependencies] +wasm-bindgen = "0.2" +reqwest = { version = "0.11", features = ["json"] } diff --git a/README.md b/README.md index 4db8f72..4266666 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ https://nodejs.org/en ## 3. Install Perseus, for real-time updates while developing `cargo install perseus-cli` -`cargo build --target wasm32-unknown-unknown` +`rustup target add wasm32-unknown-unknown` ## 4. Install tailwindcss, for styling @@ -43,7 +43,7 @@ To build CSS run: `npm run build` To build the project for testing, run -`perseus serve` +`perseus serve --verbose` # Deploying the project diff --git a/src/components/layout.rs b/src/components/layout.rs index 383ac75..a561828 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -2,24 +2,18 @@ use sycamore::prelude::*; #[derive(Prop)] pub struct LayoutProps<'a, G: Html> { - /// The title of the page, which will be displayed in the header. pub title: &'a str, - /// The content to put inside the layout. pub children: Children<'a, G>, } #[component] pub fn Layout<'a, G: Html>( cx: Scope<'a>, - LayoutProps { title, children }: LayoutProps<'a, G>, + LayoutProps { title: _, children }: LayoutProps<'a, G>, ) -> View { let children = children.call(cx); - // example params - // p { (title.to_string()) } - view! { cx, - // These elements are styled with bright colors for demonstration purposes header { div (class = "flex items-center justify-between") { div (class = "w-full text-gray-700 md:text-center text-2xl font-semibold") { diff --git a/src/data/mod.rs b/src/data/mod.rs index 733a262..68c233a 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1,2 +1,5 @@ pub mod pool_match; -pub mod store; \ No newline at end of file +pub mod user; + +#[cfg(engine)] +pub mod store; diff --git a/src/data/pool_match.rs b/src/data/pool_match.rs index 31e3e6e..3ddf92d 100644 --- a/src/data/pool_match.rs +++ b/src/data/pool_match.rs @@ -1,11 +1,56 @@ +use crate::data::user::PlayerId; +use chrono::serde::ts_seconds; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Clone)] -pub struct PoolMatch { - pub players: Vec, - pub winner: String, +pub type MatchId = u32; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum MatchData { + Standard8Ball { + winner: PlayerId, + loser: PlayerId, + }, + Standard9Ball { + winner: PlayerId, + loser: PlayerId, + }, + CutThroat { + winner: PlayerId, + losers: [PlayerId; 2], + }, } -#[derive(Serialize, Deserialize, Clone)] + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PoolMatch { + pub id: MatchId, + pub data: MatchData, + #[serde(with = "ts_seconds")] + pub time: DateTime, +} +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct PoolMatchList { pub pool_matches: Vec, + pub max_id: MatchId, +} + +impl PoolMatch { + pub fn new(data: MatchData, time: DateTime) -> PoolMatch { + PoolMatch { id: 0, data, time } + } +} + +impl PoolMatchList { + pub fn new() -> PoolMatchList { + PoolMatchList { + pool_matches: vec![], + max_id: 0, + } + } + + pub fn add_pool_match(&mut self, mut pool_match: PoolMatch) { + pool_match.id = self.max_id + 1; + self.max_id += 1; + self.pool_matches.push(pool_match); + } } diff --git a/src/data/store.rs b/src/data/store.rs index a3c3889..839a49b 100644 --- a/src/data/store.rs +++ b/src/data/store.rs @@ -1,13 +1,9 @@ -#![cfg(engine)] +// (Server only) In-memory data storage and persistent storage -use std::collections::HashMap; +use crate::data::pool_match::PoolMatchList; use once_cell::sync::Lazy; -use std::sync::Mutex; -use serde::{Serialize, Deserialize}; -use crate::data::pool_match::{PoolMatchList, PoolMatch}; -use std::fs; -use std::path::Path; - +use serde::{Deserialize, Serialize}; +use std::{fs, path::Path, sync::Mutex}; #[derive(Serialize, Deserialize, Clone)] pub struct Store { @@ -16,25 +12,23 @@ pub struct Store { impl Store { fn new() -> Store { - fs::create_dir_all("data"); + fs::create_dir_all("data").unwrap(); match Path::new("data/store.json").exists() { - false => { - Store { - matches: PoolMatchList { pool_matches: vec![] }, - } - } + false => Store { + matches: PoolMatchList::new(), + }, true => { let contents = fs::read_to_string("data/store.json").unwrap(); serde_json::from_str(&contents).unwrap() } } } + // TODO -> Store data + #[allow(dead_code)] pub fn write(&self) { let contents = serde_json::to_string(&self).unwrap(); fs::write("data/store.json", contents).unwrap(); } } -pub static DATA: Lazy> = Lazy::new(|| { - Mutex::new(Store::new()) -}); +pub static DATA: Lazy> = Lazy::new(|| Mutex::new(Store::new())); diff --git a/src/endpoints.rs b/src/endpoints.rs new file mode 100644 index 0000000..5cda3da --- /dev/null +++ b/src/endpoints.rs @@ -0,0 +1 @@ +pub const MATCH: &str = "/api/post-match"; diff --git a/src/main.rs b/src/main.rs index e7e5f1f..a5c1e32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,52 @@ mod components; -mod templates; mod data; +mod endpoints; mod error_views; +#[cfg(engine)] +mod server; +mod templates; use perseus::prelude::*; use sycamore::prelude::view; -#[perseus::main(perseus_axum::dflt_server)] +cfg_if::cfg_if! { + if #[cfg(engine)] { + use std::net::SocketAddr; + use perseus::{ + i18n::TranslationsManager, + server::ServerOptions, + stores::MutableStore, + turbine::Turbine, + }; + use crate::server::routes::register_routes; + } +} + +#[cfg(engine)] +pub async fn dflt_server( + turbine: &'static Turbine, + opts: ServerOptions, + (host, port): (String, u16), +) { + let addr: SocketAddr = format!("{}:{}", host, port) + .parse() + .expect("Invalid address provided to bind to."); + let mut app = perseus_axum::get_router(turbine, opts).await; + + app = register_routes(app); + + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); +} + +#[perseus::main(dflt_server)] pub fn main() -> PerseusApp { env_logger::init(); PerseusApp::new() + .global_state_creator(crate::templates::global_state::get_global_state_creator()) .template(crate::templates::index::get_template()) .template(crate::templates::add_game_form::get_template()) .template(crate::templates::one_v_one_board::get_template()) diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..9bebc39 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,2 @@ +pub mod routes; + diff --git a/src/server/routes.rs b/src/server/routes.rs new file mode 100644 index 0000000..934c557 --- /dev/null +++ b/src/server/routes.rs @@ -0,0 +1,34 @@ +// (Server only) Routes + +use crate::{ + data::{ + pool_match::{PoolMatch, PoolMatchList}, + store::DATA, + }, + endpoints::MATCH, +}; +use axum::{ + extract::Json, + routing::{post, Router}, +}; +use std::thread; + +pub fn register_routes(app: Router) -> Router { + let app = app.route(MATCH, post(post_match)); + app +} + +async fn post_match(Json(pool_match): Json) -> Json { + // Update the store with the new match + let matches = thread::spawn(move || { + // Get the store + let mut data = DATA.lock().unwrap(); + (*data).matches.add_pool_match(pool_match); + println!("{:?}", (*data).matches.pool_matches); + (*data).matches.clone() + }) + .join() + .unwrap(); + + Json(matches) +} diff --git a/src/templates/add_game_form.rs b/src/templates/add_game_form.rs index 3fb2d8e..285b9a9 100644 --- a/src/templates/add_game_form.rs +++ b/src/templates/add_game_form.rs @@ -1,21 +1,66 @@ -use crate::components::layout::Layout; +use crate::{components::layout::Layout, data::pool_match::MatchData}; use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; +use web_sys::Event; + +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::data::pool_match::{PoolMatch, PoolMatchList}; + use crate::templates::global_state::AppStateRx; + use crate::endpoints::MATCH; + use crate::templates::get_api_path; + use chrono::Utc; + } +} // Reactive page #[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] struct PageState { - + name: String, } fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View { + let handle_add_match = move |_event: Event| { + #[cfg(client)] + { + // state.name.get().as_ref().clone() + spawn_local_scoped(cx, async move { + let new_match = PoolMatch::new(MatchData::Standard8Ball { winner: 1, loser: 2 }, Utc::now()); + let client = reqwest::Client::new(); + let new_matches = client + .post(get_api_path(MATCH).as_str()) + .json(&new_match) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.matches.set(new_matches); + }) + } + }; + view! { cx, Layout(title = "Add Game Results") { - // Anything we put in here will be rendered inside the `
` block of the layout - p { "Results" } + div (class = "flex flex-wrap") { + input (bind:value = state.name, + class = "appearance-none block w-full bg-gray-200 text-gray-700 border \ + border-red-500 rounded py-3 px-4 mb-3 leading-tight focus:outline-none \ + focus:bg-white",) + } + div (class = "flex flex-wrap") { + button(on:click = handle_add_match, + class = "flex-shrink-0 bg-teal-500 hover:bg-teal-700 border-teal-500 \ + hover:border-teal-700 text-sm border-4 text-white py-1 px-2 rounded", + ) { + "Add result" + } + } } } } @@ -23,9 +68,11 @@ fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStat #[engine_only_fn] async fn get_request_state( _info: StateGeneratorInfo<()>, - req: Request, + _req: Request, ) -> Result> { - Ok(PageState {}) + Ok(PageState { + name: "Ferris".to_string(), + }) } #[engine_only_fn] @@ -35,7 +82,6 @@ fn head(cx: Scope) -> View { } } - // Template pub fn get_template() -> Template { diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs new file mode 100644 index 0000000..4ac7502 --- /dev/null +++ b/src/templates/global_state.rs @@ -0,0 +1,44 @@ +// Not a page, global state that is shared between all pages + +use crate::data::pool_match::PoolMatchList; +use perseus::{prelude::*, state::GlobalStateCreator}; +use serde::{Deserialize, Serialize}; + +cfg_if::cfg_if! { + if #[cfg(engine)] { + use std::thread; + use std::ops::Deref; + use crate::data::store::DATA; + } +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "AppStateRx")] +pub struct AppState { + pub matches: PoolMatchList, +} + +pub fn get_global_state_creator() -> GlobalStateCreator { + GlobalStateCreator::new() + .build_state_fn(get_build_state) + .request_state_fn(get_request_state) +} + +#[engine_only_fn] +fn get_state() -> AppState { + let matches = thread::spawn(move || DATA.lock().unwrap().deref().matches.clone()) + .join() + .unwrap(); + + AppState { matches } +} + +#[engine_only_fn] +pub async fn get_build_state() -> AppState { + get_state() +} + +#[engine_only_fn] +pub async fn get_request_state(_req: Request) -> AppState { + get_state() +} diff --git a/src/templates/index.rs b/src/templates/index.rs index dda860d..ff585f8 100644 --- a/src/templates/index.rs +++ b/src/templates/index.rs @@ -20,8 +20,5 @@ fn head(cx: Scope) -> View { } pub fn get_template() -> Template { - Template::build("") - .view(index_page) - .head(head) - .build() + Template::build("").view(index_page).head(head).build() } diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 697dd20..8e589c5 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -1,4 +1,22 @@ -pub mod index; pub mod add_game_form; +pub mod global_state; +pub mod index; pub mod one_v_one_board; pub mod overall_board; + +#[cfg(client)] +use perseus::utils::get_path_prefix_client; + +#[allow(dead_code)] +pub fn get_api_path(path: &str) -> String { + #[cfg(engine)] + { + path.to_string() + } + #[cfg(client)] + { + let origin = web_sys::window().unwrap().origin(); + let base_path = get_path_prefix_client(); + format!("{}{}{}", origin, base_path, path) + } +} diff --git a/src/templates/one_v_one_board.rs b/src/templates/one_v_one_board.rs index 787cffb..cccbe82 100644 --- a/src/templates/one_v_one_board.rs +++ b/src/templates/one_v_one_board.rs @@ -3,18 +3,13 @@ use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -// Reactive page - #[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] -struct PageState { +struct PageState {} -} - -fn one_v_one_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View { +fn one_v_one_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageStateRx) -> View { view! { cx, Layout(title = "1v1 Leaderboard") { - // Anything we put in here will be rendered inside the `
` block of the layout p { "leaderboard" } } } @@ -23,7 +18,7 @@ fn one_v_one_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageSt #[engine_only_fn] async fn get_request_state( _info: StateGeneratorInfo<()>, - req: Request, + _req: Request, ) -> Result> { Ok(PageState {}) } @@ -35,9 +30,6 @@ fn head(cx: Scope) -> View { } } - -// Template - pub fn get_template() -> Template { Template::build("one-v-one-board") .request_state_fn(get_request_state) diff --git a/src/templates/overall_board.rs b/src/templates/overall_board.rs index acbed55..cd86580 100644 --- a/src/templates/overall_board.rs +++ b/src/templates/overall_board.rs @@ -1,40 +1,30 @@ -use crate::components::layout::Layout; +use crate::{components::layout::Layout, templates::global_state::AppStateRx}; + use perseus::prelude::*; use serde::{Deserialize, Serialize}; -#[cfg(engine)] -use crate::data::store::DATA; -#[cfg(engine)] -use std::thread; use sycamore::prelude::*; -use crate::data::pool_match::{ - PoolMatchList, PoolMatch -}; - -// Reactive page - #[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "PageStateRx")] -struct PageState { - matches: PoolMatchList, -} +struct PageState {} + +fn overall_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageStateRx) -> View { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); -fn overall_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View { view! { cx, Layout(title = "Overall Leaderboard") { - // Anything we put in here will be rendered inside the `
` block of the layout ul { (View::new_fragment( - state.matches.get() + global_state.matches.get() .pool_matches .iter() .rev() .enumerate() - .map(|(index, item)| { + .map(|(_index, item)| { let game = item.clone(); view! { cx, li { - (game.winner) + (game.id) } } }) @@ -48,22 +38,9 @@ fn overall_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStat #[engine_only_fn] async fn get_request_state( _info: StateGeneratorInfo<()>, - req: Request, + _req: Request, ) -> Result> { - - let matches = thread::spawn(move || { - let mut db = DATA.lock().unwrap(); - db.matches.pool_matches.push(PoolMatch { - players: vec![], - winner: "lol".to_string(), - }); - db.write(); - db.matches.clone() - }).join().unwrap(); - - Ok(PageState { - matches - }) + Ok(PageState {}) } #[engine_only_fn] @@ -73,9 +50,6 @@ fn head(cx: Scope) -> View { } } - -// Template - pub fn get_template() -> Template { Template::build("overall-board") .request_state_fn(get_request_state)