diff --git a/client/src/api/routes/authenticated/mod.rs b/client/src/api/routes/authenticated/mod.rs index c2c4651..943b9a3 100644 --- a/client/src/api/routes/authenticated/mod.rs +++ b/client/src/api/routes/authenticated/mod.rs @@ -1,8 +1,18 @@ -use crate::api::{types::user::UserProfile, FastInsidersApi}; +use crate::api::{ + types::{ + company::Company, + paginated_response::PaginatedResponse, + user::{FollowCompany, UserProfile}, + }, + FastInsidersApi, +}; #[cfg(client)] use super::user::set_token_cookie; +#[cfg(client)] +use serde::Serialize; + #[cfg(engine)] type Response = reqwest::Response; @@ -28,6 +38,23 @@ async fn get_auth_route(route: &str) -> Result { Ok(resp) } +#[cfg(client)] +async fn post_auth_route(route: &str, body: T) -> Result +where + T: Serialize, +{ + let token = wasm_cookies::get_raw("token").unwrap_or_default(); + let resp = reqwasm::http::Request::post(route) + .header("Authorization", &format!("Bearer {}", token)) + .header("Content-type", "application/json") + .body(serde_json::to_string(&body).unwrap()) + .send() + .await + .map_err(|_| ())?; + update_token(&resp); + Ok(resp) +} + impl FastInsidersApi { /// This is only a route to verify that we are authenticated pub async fn is_authenticated(&self) -> Result { @@ -65,4 +92,49 @@ impl FastInsidersApi { Ok(res) } + + pub async fn follow_company(&self, company_id: i32) -> Result<(), ()> { + let route = &format!("{}/auth/follow_company", self.url); + + #[cfg(client)] + let res = { + let body = FollowCompany { company_id }; + let resp = post_auth_route(route, body).await?; + }; + + #[cfg(engine)] + return Err(()); + + Ok(()) + } + + pub async fn get_followed_companies( + &self, + page: i64, + size: i64, + ) -> Result, ()> { + let route = &format!( + "{}/auth/get_followed_companies?page={}&size={}", + self.url, page, size + ); + + #[cfg(client)] + let res = { + let resp = get_auth_route(route).await?; + resp.json::>() + .await + .map_err(|_| ())? + }; + + #[cfg(engine)] + #[cfg(engine)] + let res = reqwest::get(route) + .await + .map_err(|_| ())? + .json::>() + .await + .map_err(|_| ())?; + + Ok(res) + } } diff --git a/client/src/api/types/paginated_response.rs b/client/src/api/types/paginated_response.rs index 1e8e8c3..2407b9a 100644 --- a/client/src/api/types/paginated_response.rs +++ b/client/src/api/types/paginated_response.rs @@ -3,7 +3,7 @@ use sycamore::prelude::*; use crate::components::base_table::TableContent; -use super::transaction::{TransactionCompany, TransactionsAggregated, LatestTransaction, MajorTransactions}; +use super::{transaction::{TransactionCompany, TransactionsAggregated, LatestTransaction, MajorTransactions}, company::Company}; pub trait IntoTableData where @@ -190,3 +190,36 @@ where } } } + +impl IntoTableData for PaginatedResponse +where + G: GenericNode, +{ + fn into_table_data(self, cx: Scope) -> TableContent { + let headers_view = vec![ + view! {cx, "Company" }, + ]; + + let data_view: Vec>> = self + .list + .into_iter() + .map(|t| { + let mut res = vec![]; + res.push(view! {cx, + a (href=format!("transactions/{}", t.slug), + class="text-indigo-800 dark:text-indigo-300 hover:text-indigo-500 hover:underline dark:hover:text-indigo-600", + ) { + (t.name.to_owned()) + } + }); + + res + }) + .collect(); + + TableContent { + headers_view, + data_view, + } + } +} diff --git a/client/src/api/types/user.rs b/client/src/api/types/user.rs index 81f5d6b..5d285dd 100644 --- a/client/src/api/types/user.rs +++ b/client/src/api/types/user.rs @@ -1,7 +1,12 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize)] pub struct UserProfile { pub email: Option, pub name: String, } + +#[derive(Debug, Clone, Serialize)] +pub struct FollowCompany { + pub company_id: i32, +} diff --git a/client/src/components/base_async_select.rs b/client/src/components/base_async_select.rs index 03a5529..66caf46 100644 --- a/client/src/components/base_async_select.rs +++ b/client/src/components/base_async_select.rs @@ -17,6 +17,7 @@ where { pub route: &'a R, pub selected_item: &'a Signal>, + pub clear: &'a Signal, } #[component] @@ -62,6 +63,14 @@ where }); }); + create_effect(cx, move || { + if *props.clear.get() { + props.clear.set(false); + props.selected_item.set(None); + input.set("".to_string()); + } + }); + view! { cx, input (bind:value=input, class="p-2 w-full rounded-md bg-slate-300 dark:bg-slate-800", on:blur=hide_dropdown) {} div (class="relative") { diff --git a/client/src/components/paginated_data_table.rs b/client/src/components/paginated_data_table.rs index 34ea068..79db666 100644 --- a/client/src/components/paginated_data_table.rs +++ b/client/src/components/paginated_data_table.rs @@ -21,6 +21,7 @@ where pub route: &'a C, pub filter: Option, pub table_class: &'a String, + pub refresh: &'a Signal, } impl<'a, M, F, C> PaginatedTableStateRx<'a, M, F, C> @@ -81,21 +82,39 @@ where page_size_string.track(); page.set(0); }); + let props_sig = create_signal(cx, props); + #[cfg(client)] - create_effect(cx, move || { - let page = *page.get(); - let page_size_s = page_size_string.get(); - let page_size = page_size_s.parse().unwrap_or(20); + let data_fetch = move || { spawn_local_scoped(cx, async move { - let res = props_sig.get().get_data(page, page_size).await.unwrap(); + let res = props_sig + .get() + .get_data(*page.get(), page_size_string.get().parse().unwrap_or(20)) + .await + .unwrap(); paginated_data.set(Some(res.clone())); n_rows.set(res.count); let table_content = res.into_table_data(cx); table_prop.data_view.set(table_content.data_view); table_prop.headers_view.set(table_content.headers_view); n_page.set((*paginated_data.get()).as_ref().map_or(0, |t| t.num_pages)); - }); + }) + }; + + #[cfg(client)] + create_effect(cx, move || { + if *props_sig.get().refresh.get() { + props_sig.get().refresh.set(false); + data_fetch() + } + }); + + #[cfg(client)] + create_effect(cx, move || { + page.track(); + page_size_string.track(); + data_fetch(); }); view! { cx, diff --git a/client/src/templates/index.rs b/client/src/templates/index.rs index 9cd04ea..fa0a5c2 100644 --- a/client/src/templates/index.rs +++ b/client/src/templates/index.rs @@ -29,6 +29,7 @@ fn index_page(cx: BoundedScope) -> View { route: route_ref, filter: Some("72".to_string()), table_class: table_classes, + refresh: create_signal(cx, true), }; let route_ref = create_ref(cx, move |c, p, s| { @@ -40,6 +41,7 @@ fn index_page(cx: BoundedScope) -> View { route: route_ref, filter: Some((24 * 30).to_string()), table_class: table_classes, + refresh: create_signal(cx, true), }; let route_ref = create_ref(cx, move |c, p, s| { @@ -51,6 +53,7 @@ fn index_page(cx: BoundedScope) -> View { route: route_ref, filter: Some((24 * 30).to_string()), table_class: table_classes, + refresh: create_signal(cx, true), }; let dark_mode_class = create_memo(cx, || { diff --git a/client/src/templates/profile.rs b/client/src/templates/profile.rs index 82794c9..5900580 100644 --- a/client/src/templates/profile.rs +++ b/client/src/templates/profile.rs @@ -1,9 +1,17 @@ +use std::rc::Rc; + use perseus::prelude::*; use sycamore::{prelude::*, rt::Event}; use crate::{ components::{ - loading::Loading, main_content_container::MainContentContainer, the_header::TheHeader, + base_async_select::{AsyncSelectRx, BaseAsyncSelect}, + base_button::{BaseButton, BaseButtonStateRx}, + base_table::BaseTable, + loading::Loading, + main_content_container::MainContentContainer, + paginated_data_table::{PaginatedTable, PaginatedTableStateRx}, + the_header::TheHeader, }, global_state::AppStateRx, }; @@ -46,15 +54,53 @@ fn profile_page(cx: BoundedScope) -> View { } }); + let async_select_route_ref = create_ref(cx, |n, l| api_scope_ref.get_company_by_name(n, l)); + let async_select_prop: AsyncSelectRx<_, _, _> = AsyncSelectRx { + route: async_select_route_ref, + selected_item: create_signal(cx, None), + clear: create_signal(cx, false), + }; + + let table_route_ref = create_ref(cx, move |_, p, s| { + api_scope_ref.get_followed_companies(p, s) + }); + let paginated_table_state: PaginatedTableStateRx<_, _, _> = PaginatedTableStateRx { + record_label: "Companies".to_owned(), + route: table_route_ref, + filter: None, + table_class: create_ref(cx, "w-full".to_string()), + refresh: create_signal(cx, true), + }; + + let follow_button = BaseButtonStateRx { + label: create_signal(cx, "Follow".to_string()), + disabled: create_memo(cx, move || async_select_prop.selected_item.get().is_none()), + clicked: create_signal(cx, false), + }; + + create_effect(cx, move || { + if *follow_button.clicked.get() && async_select_prop.selected_item.get_untracked().is_some() + { + follow_button.clicked.set(false); + if let Some(company) = (*async_select_prop.selected_item.get_untracked()).clone() { + spawn_local_scoped(cx, async move { + api_scope_ref.follow_company(company.id).await.unwrap(); + paginated_table_state.refresh.set(true); + }); + } + async_select_prop.clear.set(true); + } + }); + view! {cx, main (class=format!("{} flex flex-1", dark_mode_class)) { div (class="flex-1 font-sans bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100") { TheHeader() MainContentContainer(useless_prop=1) { - div(class="items-center m-auto w-2/5 min-w-70") { h1(class="text-lg text-center") { "Profile page" } + div(class="m-auto w-full max-w-2xl") { (if !*loading.get() { view! {cx, p() { @@ -83,7 +129,13 @@ fn profile_page(cx: BoundedScope) -> View { Loading() } }) + h2(class="mt-3 font-bold") { + "Follow companies" } + BaseAsyncSelect(async_select_prop) + BaseButton(follow_button) + PaginatedTable(paginated_table_state) + } } } } diff --git a/client/src/templates/transactions.rs b/client/src/templates/transactions.rs index b30b2d0..cbb6105 100644 --- a/client/src/templates/transactions.rs +++ b/client/src/templates/transactions.rs @@ -45,12 +45,14 @@ fn transactions_page<'a, G: Html>(cx: Scope, state: &TransactionsPageStateRx) -> route: route_ref, filter: (*state.company_slug.get()).clone(), table_class: create_ref(cx, "".to_string()), + refresh: create_signal(cx, true), }; let route_ref = create_ref(cx, |n, l| api_scope_ref.get_company_by_name(n, l)); let async_select_prop: AsyncSelectRx<_, _, _> = AsyncSelectRx { route: route_ref, selected_item: create_signal(cx, None), + clear: create_signal(cx, false), }; let search_button = BaseButtonStateRx { diff --git a/client/static/tailwind.css b/client/static/tailwind.css index 5901a2c..fbe347c 100644 --- a/client/static/tailwind.css +++ b/client/static/tailwind.css @@ -1 +1 @@ -/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-feature-settings:normal;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.absolute{position:absolute}.relative{position:relative}.-top-1{top:-.25rem}.z-0{z-index:0}.m-1{margin:.25rem}.m-10{margin:2.5rem}.m-2{margin:.5rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-bottom:.5rem;margin-top:.5rem}.mb-1{margin-bottom:.25rem}.mr-12{margin-right:3rem}.mt-0{margin-top:0}.flex{display:flex}.table{display:table}.contents{display:contents}.h-0{height:0}.h-11{height:2.75rem}.h-40{height:10rem}.h-6{height:1.5rem}.w-1\/3{width:33.333333%}.w-4\/5{width:80%}.w-6{width:1.5rem}.w-80{width:20rem}.w-full{width:100%}.w-2\/5{width:40%}.flex-1{flex:1 1 0%}.flex-none{flex:none}.flex-grow,.grow{flex-grow:1}.table-auto{table-layout:auto}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-4{gap:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-b-md{border-bottom-left-radius:.375rem;border-bottom-right-radius:.375rem}.border{border-width:1px}.border-x{border-left-width:1px;border-right-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-dashed{border-style:dashed}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-3{padding:.75rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.pl-3{padding-left:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-lg{font-size:1.125rem;line-height:1.75rem}.italic{font-style:italic}.text-indigo-800{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity))}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-lg{--tw-backdrop-blur:blur(16px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}html{height:100%;width:100%}body{display:flex;flex-direction:column;min-height:100%}div#root{display:flex;flex:1;flex-direction:column}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-slate-400:hover{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity))}.hover\:font-bold:hover{font-weight:700}.hover\:text-indigo-500:hover{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}:is(.dark .dark\:border-slate-300){--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity))}:is(.dark .dark\:border-slate-800){--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity))}:is(.dark .dark\:bg-slate-500\/30){background-color:#64748b4d}:is(.dark .dark\:bg-slate-600){--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-700){--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-800){--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity))}:is(.dark .dark\:text-indigo-300){--tw-text-opacity:1;color:rgb(165 180 252/var(--tw-text-opacity))}:is(.dark .dark\:text-rose-500){--tw-text-opacity:1;color:rgb(244 63 94/var(--tw-text-opacity))}:is(.dark .dark\:text-slate-100){--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity))}:is(.dark .dark\:hover\:bg-slate-900:hover){--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity))}:is(.dark .dark\:hover\:text-indigo-600:hover){--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))} \ No newline at end of file +/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-feature-settings:normal;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.absolute{position:absolute}.relative{position:relative}.-top-1{top:-.25rem}.z-0{z-index:0}.m-1{margin:.25rem}.m-10{margin:2.5rem}.m-2{margin:.5rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-bottom:.5rem;margin-top:.5rem}.mx-10{margin-left:2.5rem;margin-right:2.5rem}.my-auto{margin-bottom:auto;margin-top:auto}.mb-1{margin-bottom:.25rem}.mr-12{margin-right:3rem}.mt-0{margin-top:0}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.flex{display:flex}.table{display:table}.contents{display:contents}.h-0{height:0}.h-11{height:2.75rem}.h-40{height:10rem}.h-6{height:1.5rem}.w-1\/3{width:33.333333%}.w-2\/5{width:40%}.w-4\/5{width:80%}.w-6{width:1.5rem}.w-80{width:20rem}.w-full{width:100%}.max-w-xs{max-width:20rem}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.max-w-2xl{max-width:42rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.flex-grow,.grow{flex-grow:1}.table-auto{table-layout:auto}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-4{gap:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-b-md{border-bottom-left-radius:.375rem;border-bottom-right-radius:.375rem}.border{border-width:1px}.border-x{border-left-width:1px;border-right-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-dashed{border-style:dashed}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-3{padding:.75rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.pl-3{padding-left:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-lg{font-size:1.125rem;line-height:1.75rem}.font-bold{font-weight:700}.italic{font-style:italic}.text-indigo-800{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity))}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-lg{--tw-backdrop-blur:blur(16px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}html{height:100%;width:100%}body{display:flex;flex-direction:column;min-height:100%}div#root{display:flex;flex:1;flex-direction:column}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-slate-400:hover{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity))}.hover\:font-bold:hover{font-weight:700}.hover\:text-indigo-500:hover{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}:is(.dark .dark\:border-slate-300){--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity))}:is(.dark .dark\:border-slate-800){--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity))}:is(.dark .dark\:bg-slate-500\/30){background-color:#64748b4d}:is(.dark .dark\:bg-slate-600){--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-700){--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-800){--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity))}:is(.dark .dark\:text-indigo-300){--tw-text-opacity:1;color:rgb(165 180 252/var(--tw-text-opacity))}:is(.dark .dark\:text-rose-500){--tw-text-opacity:1;color:rgb(244 63 94/var(--tw-text-opacity))}:is(.dark .dark\:text-slate-100){--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity))}:is(.dark .dark\:hover\:bg-slate-900:hover){--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity))}:is(.dark .dark\:hover\:text-indigo-600:hover){--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))} \ No newline at end of file diff --git a/server/migration/src/m20231126_093416_create_user_company_table.rs b/server/migration/src/m20231126_093416_create_user_company_table.rs index b77b420..4254191 100644 --- a/server/migration/src/m20231126_093416_create_user_company_table.rs +++ b/server/migration/src/m20231126_093416_create_user_company_table.rs @@ -23,7 +23,7 @@ impl MigrationTrait for Migration { .foreign_key( ForeignKey::create() .name("FK_user") - .from(UserCompany::Table, UserCompany::CompanyId) + .from(UserCompany::Table, UserCompany::UserId) .to(user::User::Table, user::User::Id) .on_update(ForeignKeyAction::Cascade) .on_delete(ForeignKeyAction::Cascade), diff --git a/server/src/db/paginate.rs b/server/src/db/paginate.rs index 4cf3ce6..2d8abbe 100644 --- a/server/src/db/paginate.rs +++ b/server/src/db/paginate.rs @@ -48,6 +48,7 @@ where Ok(res) } +/// Use for 1-1 relationships, retrieves the entity and the single related record pub async fn paginate_also_related( db: &DatabaseConnection, page: Option, diff --git a/server/src/main.rs b/server/src/main.rs index 8fe0ef2..518ec25 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -46,7 +46,10 @@ mod task; use crate::{ crypto::{jwt_numeric_date, JWTSecretManager}, route::{ - authenticated::{get_profile, is_authenticated}, + authenticated::{ + follow_company_route, get_followed_companies, get_profile, is_authenticated, + unfollow_company_route, + }, user, }, task::run_tasks, @@ -191,6 +194,9 @@ pub async fn main() -> Result<(), Box> { let authenticated_routes = Router::::new() .route("/is_authenticated", get(is_authenticated)) .route("/profile", get(get_profile)) + .route("/get_followed_companies", get(get_followed_companies)) + .route("/follow_company", post(follow_company_route)) + .route("/unfollow_company", post(unfollow_company_route)) .layer(from_fn_with_state(shared_state.clone(), authenticator)) .with_state(shared_state.clone()); diff --git a/server/src/model/user_company.rs b/server/src/model/user_company.rs index d6046e7..40e3c5c 100644 --- a/server/src/model/user_company.rs +++ b/server/src/model/user_company.rs @@ -24,7 +24,7 @@ pub enum Relation { Company, #[sea_orm( belongs_to = "super::user::Entity", - from = "Column::CompanyId", + from = "Column::UserId", to = "super::user::Column::Id", on_update = "Cascade", on_delete = "Cascade" diff --git a/server/src/repo/user.rs b/server/src/repo/user.rs index f4ca18c..86c0409 100644 --- a/server/src/repo/user.rs +++ b/server/src/repo/user.rs @@ -1,5 +1,8 @@ use crate::model::user::ActiveModel; -use sea_orm::{ActiveModelTrait, ConnectionTrait, DbErr, DeriveIntoActiveModel, IntoActiveModel}; +use sea_orm::{ + ActiveModelTrait, ActiveValue, ConnectionTrait, DbErr, DeriveIntoActiveModel, EntityTrait, + IntoActiveModel, +}; use serde::{Deserialize, Serialize}; use crate::model; @@ -21,3 +24,28 @@ impl NewUser { Ok(res) } } + +pub async fn follow_company(db: &C, user_id: i32, company_id: i32) -> Result<(), DbErr> +where + C: ConnectionTrait, +{ + let relation = model::user_company::ActiveModel { + user_id: ActiveValue::Set(user_id), + company_id: ActiveValue::Set(company_id), + }; + + relation.insert(db).await?; + + Ok(()) +} + +pub async fn unfollow_company(db: &C, user_id: i32, company_id: i32) -> Result<(), DbErr> +where + C: ConnectionTrait, +{ + model::user_company::Entity::delete_by_id((user_id, company_id)) + .exec(db) + .await?; + + Ok(()) +} diff --git a/server/src/route/authenticated.rs b/server/src/route/authenticated.rs index fd1ec22..fe71518 100644 --- a/server/src/route/authenticated.rs +++ b/server/src/route/authenticated.rs @@ -1,8 +1,24 @@ -use axum::{extract::State, Extension, Json}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use axum::{ + extract::{Query, State}, + Extension, Json, +}; +use sea_orm::{ + ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, +}; use serde::{Deserialize, Serialize}; -use crate::{error::AppError, model, AppState, UserJWTClaim}; +use crate::{ + db::paginate::PaginatedResponse, + error::AppError, + model::{ + self, + prelude::{Company, User}, + }, + repo::user::{follow_company, unfollow_company}, + AppState, UserJWTClaim, +}; + +use super::Pagination; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserProfile { @@ -44,3 +60,65 @@ pub async fn get_profile( "Authenticated user does not exist".to_string(), )) } + +#[derive(Deserialize)] +pub struct FollowCompany { + company_id: i32, +} + +pub async fn follow_company_route( + Extension(token_data): Extension, + State(state): State, + Json(payload): Json, +) -> Result<(), AppError> { + let db = &state.db; + + follow_company(db, token_data.user_id, payload.company_id).await?; + Ok(()) +} + +pub async fn unfollow_company_route( + Extension(token_data): Extension, + State(state): State, + Json(payload): Json, +) -> Result<(), AppError> { + let db = &state.db; + + unfollow_company(db, token_data.user_id, payload.company_id).await?; + Ok(()) +} + +pub async fn get_followed_companies( + Extension(token_data): Extension, + State(state): State, + Query(Pagination { page, size }): Query, +) -> Result>, AppError> { + let db = &state.db; + + let s = size.unwrap_or(10); + let p = page.unwrap_or_default(); + + let user = User::find_by_id(token_data.user_id) + .one(db) + .await? + .ok_or(AppError::NotFound("User not found".to_string()))?; + + let (count_res, companies) = tokio::join!(user.find_related(Company).count(db), async { + user.find_related(Company) + .order_by(model::company::Column::Name, sea_orm::Order::Asc) + .offset(p * s) + .limit(s) + .all(db) + .await + }); + + let count = count_res?; + + let res = PaginatedResponse { + count, + num_pages: count / s + 1, + list: companies?, + }; + + Ok(Json(res)) +}