diff --git a/Cargo.toml b/Cargo.toml index 3feffed..8503df0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,18 +22,6 @@ chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] } lazy_static = "1.5" strum = "0.26.2" strum_macros = "0.26.2" -polars = { version = "0.39.2", default-features = false, features = [ - "fmt_no_tty", - "rows", - "lazy", - "concat_str", - "strings", - "regex", - "csv", - "json", - "dtype-struct", - "serde", -] } [target.'cfg(engine)'.dev-dependencies] fantoccini = "0.19" @@ -52,6 +40,18 @@ sea-orm = { version = "1.0", features = [ jsonwebtoken = "9.3.0" argon2 = "0.5" tower-http = { version = "0.3", features = ["fs"] } +polars = { version = "0.39.2", default-features = false, features = [ + "fmt_no_tty", + "rows", + "lazy", + "concat_str", + "strings", + "regex", + "csv", + "json", + "dtype-struct", + "serde", +] } [target.'cfg(client)'.dependencies] wasm-bindgen = "0.2.93" diff --git a/README.md b/README.md index 68aa91e..7f07b96 100644 --- a/README.md +++ b/README.md @@ -91,11 +91,21 @@ See https://docs.rs/polars/latest/polars/#config-with-env-vars Default Windows: -`Env:RUST_LOG = "info"; $Env:POLARS_FMT_MAX_COLS = "100"; $Env:POLARS_TABLE_WIDTH = "200";` +`$Env:RUST_LOG = "info"; $Env:POLARS_FMT_MAX_COLS = "100"; $Env:POLARS_TABLE_WIDTH = "200";` # Deploying the project First run `perseus deploy` +For windows local testing, you can use: + +`sudo New-Item -ItemType SymbolicLink -Name 'card_downloader' -Target '.\..\card_downloader'` + +`sudo New-Item -ItemType SymbolicLink -Name 'data' -Target '.\..\data'` + +For ubuntu, use: + +`TODO` + The folder with everything necessary will be in `/pkg` diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 06702f0..c48b48b 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -1,12 +1,18 @@ pub use sea_orm_migration::prelude::*; mod m20240813_000001_create_users; +mod m20240906_000002_create_cards; +mod m20240906_000003_create_user_prefs; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m20240813_000001_create_users::Migration)] + vec![ + Box::new(m20240813_000001_create_users::Migration), + Box::new(m20240906_000002_create_cards::Migration), + Box::new(m20240906_000003_create_user_prefs::Migration), + ] } } diff --git a/migration/src/m20240906_000002_create_cards.rs b/migration/src/m20240906_000002_create_cards.rs new file mode 100644 index 0000000..a4e9597 --- /dev/null +++ b/migration/src/m20240906_000002_create_cards.rs @@ -0,0 +1,32 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // User table + // @todo verify all data saved is length-checked + manager + .create_table( + Table::create() + .table(YugiohCard::Table) + .col(pk_auto(YugiohCard::Id)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(YugiohCard::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum YugiohCard { + Table, + Id, +} diff --git a/migration/src/m20240906_000003_create_user_prefs.rs b/migration/src/m20240906_000003_create_user_prefs.rs new file mode 100644 index 0000000..dacfebf --- /dev/null +++ b/migration/src/m20240906_000003_create_user_prefs.rs @@ -0,0 +1,75 @@ +use crate::m20240813_000001_create_users::User; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // User table + // @todo verify all data saved is length-checked + manager + .create_table( + Table::create() + .table(UserPrefEntry::Table) + .col(pk_auto(UserPrefEntry::Id)) + .col(boolean(UserPrefEntry::LoadLocalCardData)) + .to_owned(), + ) + .await?; + + // User to UserPref assoc + manager + .create_table( + Table::create() + .table(UserToUserPref::Table) + .col(integer(UserToUserPref::UserId)) + .col(integer(UserToUserPref::UserPrefId)) + .primary_key( + Index::create() + .name("pk-user_to_user_pref") + .col(UserToUserPref::UserId) + .col(UserToUserPref::UserPrefId), + ) + .foreign_key( + ForeignKey::create() + .name("fk-user_to_user_pref-user_id") + .from(UserToUserPref::Table, UserToUserPref::UserId) + .to(User::Table, User::Id), + ) + .foreign_key( + ForeignKey::create() + .name("fk-user_to_user_pref-user_pref_id") + .from(UserToUserPref::Table, UserToUserPref::UserPrefId) + .to(UserPrefEntry::Table, UserPrefEntry::Id), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UserToUserPref::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(UserPrefEntry::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum UserPrefEntry { + Table, + Id, + LoadLocalCardData, +} + +// Assoc +#[derive(DeriveIden)] +enum UserToUserPref { + Table, + UserId, + UserPrefId, +} diff --git a/src/auth.rs b/src/auth.rs deleted file mode 100644 index 6f79b1a..0000000 --- a/src/auth.rs +++ /dev/null @@ -1,19 +0,0 @@ -use crate::user; -use axum_login::{AuthUser, AuthnBackend, UserId}; -use serde::{Deserialize, Serialize}; - -// References -// https://github.com/maxcountryman/axum-login/tree/main/examples/sqlite/src -// https://framesurge.sh/perseus/en-US/docs/0.4.x/state/intro - -impl AuthUser for user::Model { - type Id = i32; - - fn id(&self) -> Self::Id { - self.id - } - - fn session_auth_hash(&self) -> &[u8] { - self.password.as_bytes() - } -} diff --git a/src/capsules/forgot_password_form.rs b/src/capsules/forgot_password_form.rs index 1e96f4c..0e59091 100644 --- a/src/capsules/forgot_password_form.rs +++ b/src/capsules/forgot_password_form.rs @@ -9,12 +9,12 @@ use crate::{ static_components::close_button::CloseButtonSvg, sub_components::error_block::ErrorBlock, }, global_state::AppStateRx, + state_enums::OpenState, }; cfg_if::cfg_if! { if #[cfg(client)] { use crate::{ - state_enums::{ OpenState}, templates::get_api_path, endpoints::FORGOT_PASSWORD, models::{ @@ -35,7 +35,6 @@ lazy_static! { #[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "ForgotPasswordFormStateRx")] struct ForgotPasswordFormState { - username: String, how_to_reach: String, error: String, } @@ -43,7 +42,6 @@ struct ForgotPasswordFormState { impl ForgotPasswordFormStateRx { #[cfg(client)] fn reset(&self) { - self.username.set(String::new()); self.how_to_reach.set(String::new()); self.error.set(String::new()); } @@ -58,13 +56,7 @@ fn forgot_password_form_capsule( state: &ForgotPasswordFormStateRx, _props: ForgotPasswordFormProps, ) -> View { - // If there's a tentative username, set it let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - state - .username - .set((*global_state.auth.pending_username.get()).clone()); - global_state.auth.pending_username.set(String::new()); - let close_modal = move |_event: Event| { #[cfg(client)] { @@ -84,7 +76,7 @@ fn forgot_password_form_capsule( { spawn_local_scoped(cx, async move { let request = ForgotPasswordRequest { - username: state.username.get().as_ref().clone(), + username: global_state.auth.pending_username.get().as_ref().clone(), contact_info: state.how_to_reach.get().as_ref().clone(), }; @@ -116,29 +108,37 @@ fn forgot_password_form_capsule( }; view! { cx, - dialog (class="modal-open modal modal-bottom sm:modal-middle animate-none") { - div (class="modal-box"){ - // Header row - title and close button - h3 (class="text-lg font-bold mb-4 text-center"){"Forgot Password"} - button (on:click = close_modal, class = "btn btn-circle right-2 top-2 absolute") { CloseButtonSvg {} } + (match *global_state.modals_open.forgot_password.get() { + OpenState::Open => { view! { cx, + dialog (class="modal-open modal modal-bottom sm:modal-middle animate-none") { + div (class="modal-box"){ + // Header row - title and close button + h3 (class="text-lg font-bold mb-4 text-center"){"Forgot Password"} + button (on:click = close_modal, class = "btn btn-circle right-2 top-2 absolute") { CloseButtonSvg {} } - // Add component for handling error messages - ErrorBlock(error = state.error.clone()) + // Add component for handling error messages + ErrorBlock(error = state.error.clone()) - // Username field - div (class = "label") { span (class = "label-text") { "Username" } } - input (bind:value = state.username, class = "input input-bordered w-full") + // Username field + div (class = "label") { span (class = "label-text") { "Username" } } + input (bind:value = global_state.auth.pending_username, class = "input input-bordered w-full") - // Password field - div (class = "label") { span (class = "label-text") { "Contact Info" } } - input (bind:value = state.how_to_reach, class = "input input-bordered w-full") + // Password field + div (class = "label") { span (class = "label-text") { "Contact Info" } } + input (bind:value = state.how_to_reach, class = "input input-bordered w-full") - // Submit button - div (class = "flex justify-center mt-6") { - button (on:click = handle_submit, class="btn"){"Submit"} + // Submit button + div (class = "flex justify-center mt-6") { + button (on:click = handle_submit, class="btn"){"Submit"} + } + } } + } } + OpenState::Closed => { + view!{ cx, } } - } + }) + } } @@ -152,7 +152,6 @@ pub fn get_capsule() -> Capsule { #[engine_only_fn] async fn get_build_state(_info: StateGeneratorInfo<()>) -> ForgotPasswordFormState { ForgotPasswordFormState { - username: String::new(), how_to_reach: String::new(), error: String::new(), } diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index e61e106..4b9da37 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -9,6 +9,7 @@ use crate::{ static_components::close_button::CloseButtonSvg, sub_components::error_block::ErrorBlock, }, global_state::AppStateRx, + state_enums::OpenState, }; cfg_if::cfg_if! { @@ -17,7 +18,6 @@ cfg_if::cfg_if! { endpoints::LOGIN, models::auth::{LoginInfo, LoginResponse, WebAuthInfo}, models::generic::GenericResponse, - state_enums::{OpenState}, templates::get_api_path, }; use reqwest::StatusCode; @@ -31,7 +31,6 @@ lazy_static! { #[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "LoginFormStateRx")] struct LoginFormState { - username: String, password: String, remember_me: bool, error: String, @@ -39,8 +38,7 @@ struct LoginFormState { impl LoginFormStateRx { #[cfg(client)] - fn reset(&self) { - self.username.set(String::new()); + fn reset_state(&self) { self.password.set(String::new()); self.remember_me.set(false); self.error.set(String::new()); @@ -58,19 +56,13 @@ fn login_form_capsule( state: &LoginFormStateRx, props: LoginFormProps, ) -> View { - // If there's a tentative username, set it let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - state - .username - .set((*global_state.auth.pending_username.get()).clone()); - global_state.auth.pending_username.set(String::new()); - let close_modal = move |_event: Event| { #[cfg(client)] { spawn_local_scoped(cx, async move { let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - state.reset(); + state.reset_state(); global_state.modals_open.login.set(OpenState::Closed) }); } @@ -82,12 +74,6 @@ fn login_form_capsule( spawn_local_scoped(cx, async move { let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - // Update tentative username - global_state - .auth - .pending_username - .set((*state.username.get()).clone()); - // Open new modal global_state .modals_open @@ -95,7 +81,7 @@ fn login_form_capsule( .set(OpenState::Open); // Close modal - state.reset(); + state.reset_state(); global_state.modals_open.login.set(OpenState::Closed); }); } @@ -106,7 +92,7 @@ fn login_form_capsule( { spawn_local_scoped(cx, async move { let remember_me = *state.remember_me.get().as_ref(); - let username = state.username.get().as_ref().clone(); + let username = global_state.auth.pending_username.get().as_ref().clone(); let login_info = LoginInfo { username: username.clone(), password: state.password.get().as_ref().clone(), @@ -139,56 +125,65 @@ fn login_form_capsule( username, remember_me, }); + // Update preferences + global_state.handle_user_prefs(response.prefs); // Close modal - state.reset(); + state.reset_state(); global_state.modals_open.login.set(OpenState::Closed); }); } }; view! { cx, - dialog (class="modal-open modal modal-bottom sm:modal-middle") { - div (class="modal-box"){ - // Header row - title and close button - h3 (class="text-lg font-bold mb-4 text-center"){"Sign in"} - button (on:click = close_modal, class = "btn btn-circle right-2 top-2 absolute") { CloseButtonSvg {} } - // Add component for handling error messages - ErrorBlock(error = state.error.clone()) + (match *global_state.modals_open.login.get() { + OpenState::Open => { view! { cx, + dialog (class="modal-open modal modal-bottom sm:modal-middle") { + div (class="modal-box"){ + // Header row - title and close button + h3 (class="text-lg font-bold mb-4 text-center"){"Sign in"} + button (on:click = close_modal, class = "btn btn-circle right-2 top-2 absolute") { CloseButtonSvg {} } - // Username field - div (class = "label") { span (class = "label-text") { "Username" } } - input (bind:value = state.username, class = "input input-bordered w-full") + // Add component for handling error messages + ErrorBlock(error = state.error.clone()) - // Password field - div (class = "label") { span (class = "label-text") { "Password" } } - input (bind:value = state.password, type = "password", class = "input input-bordered w-full") + // Username field + div (class = "label") { span (class = "label-text") { "Username" } } + input (bind:value = global_state.auth.pending_username, class = "input input-bordered w-full") - // Remember me button and forget password button - div (class="flex justify-between items-center mt-1"){ - // Remember me button - (match props.remember_me { - true => { view!{ cx, - div (class = "flex items-start form-control") { - label (class = "label cursor-pointer") { - span (class = "label-text mr-4") { "Remember me" } - input (bind:checked = state.remember_me, type = "checkbox", class = "checkbox") - } - } - }}, - false => view!{cx, }, - }) - // Forget password button - button (on:click = handle_forgot_password, class="flex link link-primary"){"Lost Password?"} - } - - // Log in button - div (class = "flex justify-center") { - button (on:click = handle_log_in, class="btn"){"Log in"} + // Password field + div (class = "label") { span (class = "label-text") { "Password" } } + input (bind:value = state.password, type = "password", class = "input input-bordered w-full") + + // Remember me button and forget password button + div (class="flex justify-between items-center mt-1"){ + // Remember me button + (match props.remember_me { + true => { view!{ cx, + div (class = "flex items-start form-control") { + label (class = "label cursor-pointer") { + span (class = "label-text mr-4") { "Remember me" } + input (bind:checked = state.remember_me, type = "checkbox", class = "checkbox") + } + } + }}, + false => view!{cx, }, + }) + // Forget password button + button (on:click = handle_forgot_password, class="flex link link-primary"){"Lost Password?"} + } + // Log in button + div (class = "flex justify-center") { + button (on:click = handle_log_in, class="btn"){"Log in"} + } + } } + } } + OpenState::Closed => { + view!{ cx, } } - } + }) } } @@ -202,7 +197,6 @@ pub fn get_capsule() -> Capsule { #[engine_only_fn] async fn get_build_state(_info: StateGeneratorInfo<()>) -> LoginFormState { LoginFormState { - username: String::new(), password: String::new(), remember_me: false, error: String::new(), diff --git a/src/capsules/register_form.rs b/src/capsules/register_form.rs index ca26fa7..3ccda89 100644 --- a/src/capsules/register_form.rs +++ b/src/capsules/register_form.rs @@ -1,11 +1,15 @@ +use crate::global_state::AppStateRx; use lazy_static::lazy_static; use perseus::prelude::*; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; use web_sys::Event; -use crate::components::{ - static_components::close_button::CloseButtonSvg, sub_components::error_block::ErrorBlock, +use crate::{ + components::{ + static_components::close_button::CloseButtonSvg, sub_components::error_block::ErrorBlock, + }, + state_enums::OpenState, }; cfg_if::cfg_if! { @@ -13,9 +17,7 @@ cfg_if::cfg_if! { use crate::{ models::auth::{RegisterRequest}, endpoints::REGISTER, - state_enums::OpenState, templates::get_api_path, - global_state::AppStateRx, models::{ generic::GenericResponse }, @@ -31,7 +33,6 @@ lazy_static! { #[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "RegisterFormStateRx")] struct RegisterFormState { - username: String, password: String, nickname: String, registration_code: String, @@ -42,7 +43,6 @@ struct RegisterFormState { impl RegisterFormStateRx { #[cfg(client)] fn reset(&self) { - self.username.set(String::new()); self.password.set(String::new()); self.nickname.set(String::new()); self.registration_code.set(String::new()); @@ -64,6 +64,7 @@ fn register_form_capsule( state: &RegisterFormStateRx, props: RegisterFormProps, ) -> View { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); let close_modal = move |_event: Event| { #[cfg(client)] { @@ -81,7 +82,7 @@ fn register_form_capsule( let registration_code = state.registration_code.get().as_ref().clone(); spawn_local_scoped(cx, async move { let register_info = RegisterRequest { - username: state.username.get().as_ref().clone(), + username: global_state.auth.pending_username.get().as_ref().clone(), password: state.password.get().as_ref().clone(), nickname: state.nickname.get().as_ref().clone(), email: state.email.get().as_ref().clone(), @@ -106,12 +107,6 @@ fn register_form_capsule( return; } - // Update tentative username - global_state - .auth - .pending_username - .set((*state.username.get()).clone()); - // Open login modal global_state.modals_open.login.set(OpenState::Open); @@ -123,51 +118,58 @@ fn register_form_capsule( }; view! { cx, - dialog (class="modal-open modal modal-bottom sm:modal-middle"){ - div (class="modal-box") { - // Header row - title and close button - h3 (class="text-lg font-bold mb-4 text-center"){"Register"} - button (on:click = close_modal, class = "btn btn-circle right-2 top-2 absolute") { CloseButtonSvg {} } + (match *global_state.modals_open.register.get() { + OpenState::Open => { view! { cx, + dialog (class="modal-open modal modal-bottom sm:modal-middle"){ + div (class="modal-box") { + // Header row - title and close button + h3 (class="text-lg font-bold mb-4 text-center"){"Register"} + button (on:click = close_modal, class = "btn btn-circle right-2 top-2 absolute") { CloseButtonSvg {} } - // Add component for handling error messages - ErrorBlock(error = state.error.clone()) + // Add component for handling error messages + ErrorBlock(error = state.error.clone()) - // Username field - div (class = "label") { span (class = "label-text") { "Username" } } - input (bind:value = state.username, class = "input input-bordered w-full") + // Username field + div (class = "label") { span (class = "label-text") { "Username" } } + input (bind:value = global_state.auth.pending_username, class = "input input-bordered w-full") - // Password field - div (class = "label") { span (class = "label-text") { "Password" } } - input (bind:value = state.password, type = "password", class = "input input-bordered w-full") + // Password field + div (class = "label") { span (class = "label-text") { "Password" } } + input (bind:value = state.password, type = "password", class = "input input-bordered w-full") - (match props.registration_code { - true => { view! {cx, - div (class = "label") { span (class = "label-text") { "Registration Code" } } - input (bind:value = state.registration_code, class = "input input-bordered w-full") - }}, - false => {view!{cx,}}, - }) - (match props.nickname { - true => { view! {cx, - div (class = "label") { span (class = "label-text") { "Nickname (Optional)" } } - input (bind:value = state.nickname, class = "input input-bordered w-full") - }}, - false => {view!{cx,}}, - }) - (match props.email { - true => { view! {cx, - div (class = "label") { span (class = "label-text") { "Email (Optional)" } } - input (bind:value = state.email, class = "input input-bordered w-full") - }}, - false => {view!{cx,}}, - }) + (match props.registration_code { + true => { view! {cx, + div (class = "label") { span (class = "label-text") { "Registration Code" } } + input (bind:value = state.registration_code, class = "input input-bordered w-full") + }}, + false => {view!{cx,}}, + }) + (match props.nickname { + true => { view! {cx, + div (class = "label") { span (class = "label-text") { "Nickname (Optional)" } } + input (bind:value = state.nickname, class = "input input-bordered w-full") + }}, + false => {view!{cx,}}, + }) + (match props.email { + true => { view! {cx, + div (class = "label") { span (class = "label-text") { "Email (Optional)" } } + input (bind:value = state.email, class = "input input-bordered w-full") + }}, + false => {view!{cx,}}, + }) - // Register button - div (class = "flex justify-center mt-6") { - button (on:click = handle_register, class="btn"){"Register"} + // Register button + div (class = "flex justify-center mt-6") { + button (on:click = handle_register, class="btn"){"Register"} + } + } } + } } + OpenState::Closed => { + view!{ cx, } } - } + }) } } @@ -181,7 +183,6 @@ pub fn get_capsule() -> Capsule { #[engine_only_fn] async fn get_build_state(_info: StateGeneratorInfo<()>) -> RegisterFormState { RegisterFormState { - username: String::new(), password: String::new(), error: String::new(), nickname: String::new(), diff --git a/src/components/header.rs b/src/components/header.rs index 1cad8b8..a4fe0df 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -82,6 +82,7 @@ pub fn Header(cx: Scope, props: HeaderProps) -> View { spawn_local_scoped(cx, async move { let global_state = Reactor::::from_cx(cx).get_global_state::(cx); global_state.auth.handle_log_out(); + global_state.handle_user_pref_log_out(); }); } }; diff --git a/src/components/layout.rs b/src/components/layout.rs index 646a9e8..554e818 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -53,35 +53,15 @@ pub fn Layout<'a, G: Html>( let children = children.call(cx); - // Check if the client is authenticated or not + // Check if the client is authenticated or not, load prefs, etc #[cfg(client)] - global_state.auth.detect_state(); + global_state.detect_state(); let content_state_header = content_state.clone(); + // Load cards from server, if user preference is set #[cfg(client)] - { - // TODO -> try to use suspense - spawn_local_scoped(cx, async move { - let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - - let local_card_user_pref = (*global_state.user_pref.local_card_data.get()).clone(); - let card_table_loaded = (*global_state.constants.is_loaded.get()).clone(); - if local_card_user_pref && !card_table_loaded { - let client = reqwest::Client::new(); - let response = client - .get(get_api_path(CARD_INFO).as_str()) - .send() - .await - .unwrap(); - - // TODO add error handling - let response = response.json::().await.unwrap(); - global_state.constants.card_table.set(Some(response)); - global_state.constants.is_loaded.set(true); - } - }); - } + global_state.check_dl_cards_locally(cx); view! { cx, // Main page header, including login functionality @@ -89,55 +69,22 @@ pub fn Layout<'a, G: Html>( // Modals section(class = "flex-2") { - // (match (*global_state.constants.card_table.get()).clone() { - // Some(card_table) => { view!{ cx, - // p { "DONE" } - // } }, - // None => { view!{ cx, p { "Loading cards" } } }, - // }) + (match (*global_state.constants.card_table.get()).clone() { + Some(card_table) => { view!{ cx, + p { "DONE" } + } }, + None => { view!{ cx, p { "Loading cards" } } }, + }) - (match *global_state.modals_open.login.get() { - OpenState::Open => { - view! { cx, - (LOGIN_FORM.widget(cx, "", - LoginFormProps{ - remember_me: true, - } - )) - } + (LOGIN_FORM.widget(cx, "", LoginFormProps{ remember_me: true, })) + (FORGOT_PASSWORD_FORM.widget(cx, "", ForgotPasswordFormProps{})) + (REGISTER_FORM.widget(cx, "", + RegisterFormProps{ + registration_code: true, + nickname: true, + email: true, } - OpenState::Closed => { - view!{ cx, } - } - }) - (match *global_state.modals_open.register.get() { - OpenState::Open => { - view! { cx, - (REGISTER_FORM.widget(cx, "", - RegisterFormProps{ - registration_code: true, - nickname: true, - email: true, - } - )) - } - } - OpenState::Closed => { - view!{ cx, } - } - }) - (match *global_state.modals_open.forgot_password.get() { - OpenState::Open => { - view! { cx, - (FORGOT_PASSWORD_FORM.widget(cx, "", - ForgotPasswordFormProps{} - )) - } - } - OpenState::Closed => { - view!{ cx, } - } - }) + )) } main(style = "my-8") { diff --git a/src/endpoints.rs b/src/endpoints.rs index 5e2ea9b..8830bfb 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -1,5 +1,6 @@ pub const REGISTER: &str = "/api/register"; pub const LOGIN: &str = "/api/login"; +pub const UPDATE_USER_PREFS: &str = "/api/user-prefs"; // TODO -> remove once it's used #[cfg(engine)] pub const LOGIN_TEST: &str = "/api/login-test"; diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 5e9350e..0ab5b78 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -3,3 +3,6 @@ pub mod prelude; pub mod user; +pub mod user_pref_entry; +pub mod user_to_user_pref; +pub mod yugioh_card; diff --git a/src/entity/prelude.rs b/src/entity/prelude.rs index 6437ecc..c943ade 100644 --- a/src/entity/prelude.rs +++ b/src/entity/prelude.rs @@ -1,3 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 pub use super::user::Entity as User; +pub use super::user_pref_entry::Entity as UserPrefEntry; +pub use super::user_to_user_pref::Entity as UserToUserPref; +pub use super::yugioh_card::Entity as YugiohCard; diff --git a/src/entity/user.rs b/src/entity/user.rs index 378a2e4..44c05e6 100644 --- a/src/entity/user.rs +++ b/src/entity/user.rs @@ -21,6 +21,24 @@ pub struct Model { } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} +pub enum Relation { + #[sea_orm(has_many = "super::user_to_user_pref::Entity")] + UserToUserPref, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserToUserPref.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::user_to_user_pref::Relation::UserPrefEntry.def() + } + fn via() -> Option { + Some(super::user_to_user_pref::Relation::User.def().rev()) + } +} impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/user_pref_entry.rs b/src/entity/user_pref_entry.rs new file mode 100644 index 0000000..1ea21de --- /dev/null +++ b/src/entity/user_pref_entry.rs @@ -0,0 +1,39 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "user_pref_entry")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub load_local_card_data: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::user_to_user_pref::Entity")] + UserToUserPref, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserToUserPref.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::user_to_user_pref::Relation::User.def() + } + fn via() -> Option { + Some( + super::user_to_user_pref::Relation::UserPrefEntry + .def() + .rev(), + ) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/user_to_user_pref.rs b/src/entity/user_to_user_pref.rs new file mode 100644 index 0000000..cd7ec52 --- /dev/null +++ b/src/entity/user_to_user_pref.rs @@ -0,0 +1,47 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "user_to_user_pref")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub user_pref_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + User, + #[sea_orm( + belongs_to = "super::user_pref_entry::Entity", + from = "Column::UserPrefId", + to = "super::user_pref_entry::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + UserPrefEntry, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserPrefEntry.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/yugioh_card.rs b/src/entity/yugioh_card.rs new file mode 100644 index 0000000..733c8cc --- /dev/null +++ b/src/entity/yugioh_card.rs @@ -0,0 +1,16 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "yugioh_card")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/global_state.rs b/src/global_state.rs index 234f247..4d4b1f8 100644 --- a/src/global_state.rs +++ b/src/global_state.rs @@ -9,11 +9,21 @@ use crate::{ DEFAULT_THEME, }; +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::endpoints::CARD_INFO; + use crate::templates::get_api_path; + use crate::models::user::UserPreferences; + use sycamore::futures::spawn_local; + use sycamore::prelude::Scope; + } +} + #[derive(Serialize, Deserialize, ReactiveState, Clone)] #[rx(alias = "AppStateRx")] pub struct AppState { #[rx(nested)] - pub user_pref: UserPreferences, + pub user_prefs: UserPrefs, #[rx(nested)] pub constants: ConstData, #[rx(nested)] @@ -25,8 +35,9 @@ pub struct AppState { } #[derive(Serialize, Deserialize, ReactiveState, Clone)] -#[rx(alias = "UserPreferencesRx")] -pub struct UserPreferences { +#[rx(alias = "UserPrefsRx")] +pub struct UserPrefs { + pub loaded: bool, pub local_card_data: bool, } @@ -76,7 +87,8 @@ pub fn get_global_state_creator() -> GlobalStateCreator { #[engine_only_fn] pub async fn get_build_state() -> AppState { AppState { - user_pref: UserPreferences { + user_prefs: UserPrefs { + loaded: false, local_card_data: false, }, constants: ConstData { @@ -123,12 +135,13 @@ impl AuthDataRx { let value = serde_json::to_string(&auth_info).unwrap(); storage.set_item("auth", &value).unwrap(); - // Save token to session storage + // Set user state self.username.set(Some(auth_info.username.clone())); self.remember_me.set(Some(auth_info.remember_me)); self.auth_info.set(Some(auth_info)); self.state.set(LoginState::Authenticated); } + #[cfg(client)] pub fn handle_log_out(&self) { // Delete persistent storage @@ -148,12 +161,9 @@ impl AuthDataRx { self.remember_me.set(None); self.state.set(LoginState::NotAuthenticated); } -} -// Client only code to check if they're authenticated -#[cfg(client)] -impl AuthDataRx { - pub fn detect_state(&self) { + #[cfg(client)] + fn detect_state(&self) { // If the user is in a known state, return if let LoginState::Authenticated | LoginState::NotAuthenticated = *self.state.get() { return; @@ -190,3 +200,106 @@ impl AuthDataRx { } } } + +impl AppStateRx { + #[cfg(client)] + pub fn detect_state(&self) { + self.auth.detect_state(); + if *self.user_prefs.loaded.get() == true { + return; + } + + // User prefs + let storage: web_sys::Storage = + web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let saved_prefs = storage.get("prefs").unwrap(); + match saved_prefs { + Some(prefs_info) => { + // TODO check if session is expiring + let prefs_info = serde_json::from_str(&prefs_info).unwrap(); + self.handle_user_prefs(prefs_info); + } + None => { + // Try session storage + let storage: web_sys::Storage = web_sys::window() + .unwrap() + .session_storage() + .unwrap() + .unwrap(); + let saved_prefs = storage.get("prefs").unwrap(); + match saved_prefs { + Some(prefs_info) => { + let prefs_info = serde_json::from_str(&prefs_info).unwrap(); + self.handle_user_prefs(prefs_info); + } + None => { + self.handle_user_prefs(UserPreferences::new()); + } + } + } + } + } + + #[cfg(client)] + pub fn handle_user_prefs(&self, prefs: UserPreferences) { + // Save prefs to global storage + if let Some(remember_me) = *self.auth.remember_me.get() { + if remember_me { + let storage: web_sys::Storage = + web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let value = serde_json::to_string(&prefs).unwrap(); + storage.set_item("prefs", &value).unwrap(); + } + } + // Save prefs to session storage + let storage: web_sys::Storage = web_sys::window() + .unwrap() + .session_storage() + .unwrap() + .unwrap(); + let value = serde_json::to_string(&prefs).unwrap(); + storage.set_item("prefs", &value).unwrap(); + + // Set prefs state + self.user_prefs.local_card_data.set(prefs.use_local_card_db); + } + #[cfg(client)] + pub fn handle_user_pref_log_out(&self) { + // TODO -> handle error if local storage is not readable in browser + let storage: web_sys::Storage = + web_sys::window().unwrap().local_storage().unwrap().unwrap(); + storage.remove_item("prefs").unwrap(); + let storage: web_sys::Storage = web_sys::window() + .unwrap() + .session_storage() + .unwrap() + .unwrap(); + storage.remove_item("prefs").unwrap(); + // Set default prefs + self.handle_user_prefs(UserPreferences::new()); + } + + // Client only code to check if card info should be loaded + #[cfg(client)] + pub fn check_dl_cards_locally<'a>(&'a self, cx: Scope<'a>) { + spawn_local_scoped(cx, async move { + let global_state = self.clone(); + + let local_card_user_pref = (*global_state.user_prefs.local_card_data.get()).clone(); + let card_table_loaded = (*global_state.constants.is_loaded.get()).clone(); + if local_card_user_pref && !card_table_loaded { + let client = reqwest::Client::new(); + let response = client + .get(get_api_path(CARD_INFO).as_str()) + .send() + .await + .unwrap(); + + // TODO add error handling + let response = response.json::().await.unwrap(); + global_state.constants.card_table.set(Some(response)); + global_state.constants.is_loaded.set(true); + } + }); + } +} diff --git a/src/models/auth.rs b/src/models/auth.rs index e1850f6..3489c4e 100644 --- a/src/models/auth.rs +++ b/src/models/auth.rs @@ -1,6 +1,7 @@ use chrono::serde::ts_seconds; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::models::user::UserPreferences; #[derive(Serialize, Deserialize, Clone)] pub struct LoginInfo { @@ -14,6 +15,7 @@ pub struct LoginResponse { pub token: String, #[serde(with = "ts_seconds")] pub expires: DateTime, + pub prefs: UserPreferences, } #[derive(Serialize, Deserialize, Clone)] diff --git a/src/models/card.rs b/src/models/card.rs index 411f414..c0aa20e 100644 --- a/src/models/card.rs +++ b/src/models/card.rs @@ -1,5 +1,7 @@ use core::panic; use once_cell::sync::Lazy; + +#[cfg(engine)] use polars::prelude::*; use serde::{Deserialize, Serialize}; use std::io::Cursor; @@ -116,10 +118,11 @@ pub struct CardTable { pub sets: HashMap, pub archetypes: HashMap, pub monster_types: HashSet, - pub df: DataFrame, + // pub df: DataFrame, } impl CardTable { + #[cfg(engine)] pub fn df_to_card_info_test(df: DataFrame) -> Vec { let id_idx = df.get_column_index("id").unwrap(); let name_idx = df.get_column_index("name").unwrap(); @@ -394,8 +397,9 @@ impl CardTable { "ygoprodeck_url", "linkval", "race", + "typeline", ])]) - // Remove link markers, unless it's needed later + // TODO add link marker support .select([col("*").exclude(["linkmarkers"])]) // TODO add banlist support .select([col("*").exclude(["banlist_info"])]) @@ -428,26 +432,26 @@ impl CardTable { sets, archetypes, monster_types, - df, + // df, } } #[cfg(client)] pub fn new_from_client_json() -> Self { - let id_col = UInt32Chunked::new("id_row", &[1]).into_series(); + // let id_col = UInt32Chunked::new("id_row", &[1]).into_series(); let cards = HashMap::new(); let sets = HashMap::new(); let archetypes = HashMap::new(); let monster_types = HashSet::new(); - let df = DataFrame::new(vec![id_col]).unwrap(); + // let df = DataFrame::new(vec![id_col]).unwrap(); Self { cards, sets, archetypes, monster_types, - df, + // df, } } } diff --git a/src/models/theme.rs b/src/models/theme.rs index 4b52094..45f2d63 100644 --- a/src/models/theme.rs +++ b/src/models/theme.rs @@ -2,6 +2,6 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub enum Theme { - Plain, + Standard, Purrely, } diff --git a/src/models/user.rs b/src/models/user.rs index f63f5e9..77dff6c 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,4 +1,8 @@ +use sea_orm::Set; use serde::{Deserialize, Serialize}; +use std::convert::{From, Into}; + +use crate::entity::user_pref_entry; use super::theme::Theme; @@ -7,3 +11,30 @@ pub struct UserPreferences { pub use_local_card_db: bool, pub theme: Theme, } + +impl UserPreferences { + pub fn new() -> Self { + UserPreferences { + use_local_card_db: false, + theme: Theme::Standard, + } + } +} + +impl From for UserPreferences { + fn from(model: user_pref_entry::Model) -> Self { + UserPreferences { + use_local_card_db: model.load_local_card_data, + theme: Theme::Standard, + } + } +} + +impl Into for UserPreferences { + fn into(self) -> user_pref_entry::ActiveModel { + user_pref_entry::ActiveModel { + load_local_card_data: Set(self.use_local_card_db), + ..Default::default() + } + } +} diff --git a/src/server/auth/login.rs b/src/server/auth/login.rs index 908c7d7..5e0a27d 100644 --- a/src/server/auth/login.rs +++ b/src/server/auth/login.rs @@ -2,10 +2,13 @@ use crate::{ entity::{ prelude::*, user::{self}, + user_pref_entry, user_to_user_pref, }, models::{ auth::{Claims, LoginInfo, LoginResponse}, generic::GenericResponse, + theme::Theme, + user::UserPreferences, }, server::server_state::ServerState, }; @@ -15,7 +18,7 @@ use axum::{ http::{HeaderMap, StatusCode}, }; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use sea_orm::{ColumnTrait, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait}; pub async fn credentials_are_correct( username: &str, @@ -85,7 +88,29 @@ pub async fn post_login_user( } }; - (StatusCode::OK, Ok(Json(LoginResponse { token, expires }))) + // Get user preferences + // TODO error handling + let prefs = user_pref_entry::Entity::find() + .join( + JoinType::InnerJoin, + user_pref_entry::Relation::UserToUserPref.def(), + ) + .join(JoinType::InnerJoin, user_to_user_pref::Relation::User.def()) + .filter(user::Column::Username.eq(login_info.username)) + .one(&state.db_conn) + .await + .unwrap() + // Assuming username exists here + .unwrap(); + let prefs = UserPreferences::from(prefs); + ( + StatusCode::OK, + Ok(Json(LoginResponse { + token, + expires, + prefs, + })), + ) } } } diff --git a/src/server/auth/register.rs b/src/server/auth/register.rs index d75f6de..241386e 100644 --- a/src/server/auth/register.rs +++ b/src/server/auth/register.rs @@ -1,6 +1,6 @@ use crate::{ - entity::{prelude::*, user}, - models::{auth::RegisterRequest, generic::GenericResponse}, + entity::{prelude::*, user, user_pref_entry, user_to_user_pref}, + models::{auth::RegisterRequest, generic::GenericResponse, user::UserPreferences}, server::server_state::ServerState, }; use argon2::{ @@ -72,8 +72,22 @@ pub async fn post_register_user( ..Default::default() }; let db_resp = user::Entity::insert(new_user).exec(&state.db_conn).await; - match db_resp { - Ok(_) => {} + let user_id = match db_resp { + Ok(entry) => entry.last_insert_id, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(GenericResponse::err("Database error")), + ); + } + }; + // Add user preferences + let default_prefs: user_pref_entry::ActiveModel = UserPreferences::new().into(); + let db_resp = user_pref_entry::Entity::insert(default_prefs) + .exec(&state.db_conn) + .await; + let user_pref_id = match db_resp { + Ok(entry) => entry.last_insert_id, Err(_) => { return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -82,5 +96,19 @@ pub async fn post_register_user( } }; + // Add assoc entry + let db_resp = user_to_user_pref::Entity::insert(user_to_user_pref::ActiveModel { + user_id: Set(user_id), + user_pref_id: Set(user_pref_id), + }) + .exec(&state.db_conn) + .await; + if db_resp.is_err() { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(GenericResponse::err("Database error")), + ); + }; + (StatusCode::OK, Json(GenericResponse::ok())) } diff --git a/src/server/mod.rs b/src/server/mod.rs index f9b6973..d45a726 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -2,3 +2,4 @@ pub mod auth; pub mod constants; pub mod routes; pub mod server_state; +pub mod user; diff --git a/src/server/routes.rs b/src/server/routes.rs index aa6fb68..085fd9c 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -1,6 +1,8 @@ // (Server only) Routes -use crate::endpoints::{CARD_INFO, FORGOT_PASSWORD, LOGIN, LOGIN_TEST, REGISTER}; -use axum::routing::{get, post, Router}; +use crate::endpoints::{ + CARD_INFO, FORGOT_PASSWORD, LOGIN, LOGIN_TEST, REGISTER, UPDATE_USER_PREFS, +}; +use axum::routing::{get, post, put, Router}; use super::{ auth::{ @@ -10,6 +12,7 @@ use super::{ }, constants::card_table::get_card_table, server_state::ServerState, + user::update_prefs::put_user_prefs, }; pub fn get_api_router(state: ServerState) -> Router { @@ -19,5 +22,6 @@ pub fn get_api_router(state: ServerState) -> Router { .route(LOGIN_TEST, post(post_test_login)) .route(FORGOT_PASSWORD, post(post_forgot_password)) .route(CARD_INFO, get(get_card_table)) + .route(UPDATE_USER_PREFS, put(put_user_prefs)) .with_state(state) } diff --git a/src/server/user/mod.rs b/src/server/user/mod.rs new file mode 100644 index 0000000..f7e3486 --- /dev/null +++ b/src/server/user/mod.rs @@ -0,0 +1 @@ +pub mod update_prefs; diff --git a/src/server/user/update_prefs.rs b/src/server/user/update_prefs.rs new file mode 100644 index 0000000..a701a0f --- /dev/null +++ b/src/server/user/update_prefs.rs @@ -0,0 +1,73 @@ +use crate::{ + entity::{user, user_pref_entry, user_to_user_pref}, + models::{auth::Claims, generic::GenericResponse, user::UserPreferences}, + server::server_state::ServerState, +}; +use axum::{ + extract::{Json, State}, + http::{HeaderMap, StatusCode}, +}; +use jsonwebtoken::{decode, DecodingKey, Validation}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait, + Set, +}; + +pub async fn put_user_prefs( + State(state): State, + header_map: HeaderMap, + Json(user_prefs): Json, +) -> (StatusCode, Json) { + if let Some(auth_header) = header_map.get("Authorization") { + if let Ok(auth_header_str) = auth_header.to_str() { + if auth_header_str.starts_with("Bearer ") { + let token = auth_header_str.trim_start_matches("Bearer ").to_string(); + // @todo change secret + if let Ok(claims) = decode::( + &token, + &DecodingKey::from_secret("secret".as_ref()), + &Validation::default(), + ) { + let username = claims.claims.sub; + + // Get user prefs + let db_user_prefs: Option = + user_pref_entry::Entity::find() + .join( + JoinType::InnerJoin, + user_pref_entry::Relation::UserToUserPref.def(), + ) + .join(JoinType::InnerJoin, user_to_user_pref::Relation::User.def()) + .filter(user::Column::Username.eq(username)) + .one(&state.db_conn) + .await + .unwrap(); + if db_user_prefs.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(GenericResponse::err("User incorrect")), + ); + } + let db_user_prefs = db_user_prefs.unwrap(); + // Get new active model + let mut new_user_prefs: user_pref_entry::ActiveModel = user_prefs.into(); + new_user_prefs.id = Set(db_user_prefs.id); + let result = new_user_prefs.update(&state.db_conn).await; + match result { + Ok(_) => return (StatusCode::OK, Json(GenericResponse::ok())), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(GenericResponse::err("Database error")), + ) + } + } + } + } + } + } + ( + StatusCode::UNAUTHORIZED, + Json(GenericResponse::err("Unauthorized")), + ) +} diff --git a/src/templates/user/index.rs b/src/templates/user/index.rs index b8cce29..0ef233c 100644 --- a/src/templates/user/index.rs +++ b/src/templates/user/index.rs @@ -1,13 +1,96 @@ +use crate::components::sub_components::error_block::ErrorBlock; +use crate::global_state::AppStateRx; +use crate::state_enums::LoginState; use crate::{components::layout::Layout, state_enums::ContentState}; use perseus::prelude::*; +use serde::{Deserialize, Serialize}; use sycamore::prelude::*; +use web_sys::Event; + +cfg_if::cfg_if! { + if #[cfg(client)] { + use crate::models::generic::GenericResponse; + use crate::models::theme::Theme; + use crate::models::user::UserPreferences; + use reqwest::StatusCode; + use crate::endpoints::UPDATE_USER_PREFS; + use crate::templates::get_api_path; + } +} + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "UserPrefStateRx")] +struct UserPrefState { + error: String, +} + +fn user_index_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a UserPrefStateRx) -> View { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + let check_card_load = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + // TODO bring out of function + // TODO get theme + let new_prefs = UserPreferences { + use_local_card_db: *global_state.user_prefs.local_card_data.get(), + theme: Theme::Standard, + }; + + // Set local state + global_state.handle_user_prefs(new_prefs.clone()); + + // Now that preferences were updated, if the cards should be downloaded locally, get it + global_state.check_dl_cards_locally(cx); + + // Also update the server + let client = reqwest::Client::new(); + let token = match (*global_state.auth.auth_info.get()).clone() { + Some(auth_info) => auth_info.token, + None => String::new(), + }; + let response = client + .put(get_api_path(UPDATE_USER_PREFS).as_str()) + .bearer_auth(token) + .json(&new_prefs) + .send() + .await + .unwrap(); + if response.status() != StatusCode::OK { + let response = response.json::().await.unwrap(); + state.error.set(response.status.to_string()); + return; + } + }); + } + }; -fn user_index_page(cx: Scope) -> View { view! { cx, Layout(content_state = ContentState::User) { + // Add component for handling error messages + ErrorBlock(error = state.error.clone()) + // Anything we put in here will be rendered inside the `
` block of the layout - p { "Hello World!" } - br {} + (match *global_state.auth.state.get() { + LoginState::Authenticated => { view! { cx, + label (class = "label cursor-pointer") { + input ( + bind:checked = global_state.user_prefs.local_card_data, + on:change = check_card_load, + type = "checkbox", class = "checkbox mr-4") + span (class = "label-text") { "Use local yugioh database" } + } + } }, + LoginState::NotAuthenticated => { view! { cx, + h2 { + "You must log in to modify user preferences" + } + } }, + LoginState::Unknown => { view! { cx, + } }, + } ) } } } @@ -21,7 +104,17 @@ fn head(cx: Scope) -> View { pub fn get_template() -> Template { Template::build("user") - .view(user_index_page) + .build_state_fn(get_build_state) + .view_with_state(user_index_page) .head(head) .build() } + +#[engine_only_fn] +async fn get_build_state( + _info: StateGeneratorInfo<()>, +) -> Result> { + Ok(UserPrefState { + error: String::new(), + }) +}