diff --git a/client/src/api/types/company.rs b/client/src/api/types/company.rs index 19a5eb7..3aaa9b6 100644 --- a/client/src/api/types/company.rs +++ b/client/src/api/types/company.rs @@ -1,8 +1,16 @@ use serde::{Deserialize, Serialize}; +use crate::components::base_async_select::IntoAsyncSelectListItem; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Company { pub id: i32, pub name: String, pub slug: String, } + +impl IntoAsyncSelectListItem for Company { + fn to_select_list_item(&self) -> String { + format!("{}", self.name) + } +} diff --git a/client/src/components/base_async_select.rs b/client/src/components/base_async_select.rs new file mode 100644 index 0000000..53a1f2b --- /dev/null +++ b/client/src/components/base_async_select.rs @@ -0,0 +1,97 @@ +use serde::Deserialize; +use sycamore::prelude::*; + +pub trait IntoAsyncSelectListItem { + fn to_select_list_item(&self) -> String; +} + +#[derive(Clone)] +pub struct AsyncSelectRx +where + T: 'static + PartialEq + Clone + IntoAsyncSelectListItem, +{ + pub selected_item: Signal>, +} + +#[component(BaseAsyncSelect)] +pub fn create_component(AsyncSelectRx { selected_item }: AsyncSelectRx) -> View +where + T: 'static + PartialEq + Clone + IntoAsyncSelectListItem, + for<'de> T: Deserialize<'de>, +{ + let input = Signal::new("".to_string()); + + let visible = Signal::new(false); + let hide_dropdown = cloned!((visible, selected_item, input) => move |_| { + visible.set(false); + if selected_item.get().is_none() { + input.set("".to_string()); + } + }); + let item_list: Signal> = Signal::new(vec![]); + let selected = Signal::new(false); + create_effect( + cloned!((input, visible, item_list, selected, selected_item) => move || { + // Early return if: + // - The input is empty, there is nothing to search for nor to show + // - We just selected an item + if input.get().is_empty() || *selected.get_untracked() { + selected.set(false); + visible.set(false); + return; + } + + selected_item.set(None); + let url = "http://localhost:8000/v1/company/"; + if G::IS_BROWSER { + perseus::spawn_local( + cloned!((input, visible, item_list) => async move { + let res = reqwasm::http::Request::get( + &format!( "{}{}?limit={}", url, input.get(), 5) + ) + .send() + .await + .unwrap() + .json::>() + .await + .unwrap(); + visible.set(!res.is_empty()); + item_list.set(res); + }), + ); + } + }), + ); + + let input2 = input.clone(); + let visible2 = visible.clone(); + + view! { + 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") { + div (class=format!("absolute -top-1 w-80 rounded-b-md dark:bg-slate-800 bg-slate-300 {}", if *visible.get() { "visible" } else { "collapse" })) { + ul { + Indexed(IndexedProps { + iterable: item_list.handle(), + template: move |x| { + view! { + li ( + class="w-full p-2 cursor-pointer dark:hover:bg-slate-900 hover:bg-slate-400", + on:mousedown=cloned!((x, input2, selected_item, visible2, selected) => move |_| { + selected.set(true); + selected_item.set(Some(x.clone())); + input2.set(x.clone().to_select_list_item()); + visible2.set(false); + }), + ) + { + (x.to_select_list_item()) + } + } + }, + }) + } + } + } + } +} diff --git a/client/src/components/base_button.rs b/client/src/components/base_button.rs new file mode 100644 index 0000000..59764ea --- /dev/null +++ b/client/src/components/base_button.rs @@ -0,0 +1,29 @@ +use sycamore::prelude::*; + +#[derive(Clone)] +pub struct BaseButtonStateRx { + pub label: ReadSignal, + pub disabled: ReadSignal, + pub clicked: Signal, +} + +#[component(BaseButton)] +pub fn create_component( + BaseButtonStateRx { + label, + disabled, + clicked, + }: BaseButtonStateRx, +) -> View { + let click_event = cloned!((clicked) => move |_| { clicked.set(true) }); + + view! { + button ( + class="my-2 z-0 p-2 bg-slate-300 dark:bg-slate-800 hover:bg-slate-400 dark:hover:bg-slate-900 disabled:cursor-not-allowed hover:cursor-pointer rounded-md ", + disabled=*disabled.get(), + on:click=click_event, + ) { + (label.get()) + } + } +} diff --git a/client/src/components/mod.rs b/client/src/components/mod.rs index 0713042..9905cc0 100644 --- a/client/src/components/mod.rs +++ b/client/src/components/mod.rs @@ -1,3 +1,5 @@ +pub mod base_async_select; +pub mod base_button; pub mod base_table; pub mod loading; pub mod paginated_data_table; diff --git a/client/src/components/paginated_data_table.rs b/client/src/components/paginated_data_table.rs index 5ad4b91..d5193c9 100644 --- a/client/src/components/paginated_data_table.rs +++ b/client/src/components/paginated_data_table.rs @@ -89,7 +89,7 @@ where p (class="text-right") { (format!("{} transactions", n_rows.get())) } div (class="flex flex-row justify-between") { select (bind:value=page_size_string, - class="p-2 justify-end text-slate-700 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 rounded-md", + class="p-2 justify-end text-slate-700 dark:text-slate-100 bg-slate-200 dark:bg-slate-800 rounded-md", id="size-select", ) { option (value="20") { "20" } diff --git a/client/src/templates/index.rs b/client/src/templates/index.rs index c7a60df..543ae2b 100644 --- a/client/src/templates/index.rs +++ b/client/src/templates/index.rs @@ -1,14 +1,15 @@ use std::marker::PhantomData; -use perseus::{Html, RenderFnResult, RenderFnResultWithCause, SsrNode, Template}; -use sycamore::{ - prelude::{view, View}, - reactive::{cloned, Signal}, -}; +use perseus::{navigate, Html, RenderFnResult, RenderFnResultWithCause, SsrNode, Template}; +use sycamore::prelude::*; use crate::{ - api::types::transaction::TransactionCompany, - components::paginated_data_table::{PaginatedTable, PaginatedTableStateRx}, + api::types::{company::Company, transaction::TransactionCompany}, + components::{ + base_async_select::{AsyncSelectRx, BaseAsyncSelect}, + base_button::{BaseButton, BaseButtonStateRx}, + paginated_data_table::{PaginatedTable, PaginatedTableStateRx}, + }, global_state::AppStateRx, }; @@ -23,6 +24,20 @@ pub fn index_page(IndexPageStateRx { req }: IndexPageStateRx, global_state: AppS let dark_mode_2 = dark_mode.clone(); let dark_mode_3 = dark_mode.clone(); + let expand = Signal::new(false); + let filter_expand = BaseButtonStateRx { + label: Signal::new("Filters".to_string()).handle(), + disabled: Signal::new(false).handle(), + clicked: Signal::new(false), + }; + + create_effect(cloned!((filter_expand, expand) => move || { + if *filter_expand.clicked.get() { + filter_expand.clicked.set(false); + expand.set(!*expand.get()); + } + })); + let toggle_dark_mode = cloned!(() => move |_| dark_mode_2.set(!*dark_mode.get())); let paginated_table_state: PaginatedTableStateRx = PaginatedTableStateRx { @@ -30,6 +45,26 @@ pub fn index_page(IndexPageStateRx { req }: IndexPageStateRx, global_state: AppS ph_data: Signal::new(PhantomData), }; + let async_select_prop: AsyncSelectRx = AsyncSelectRx { + selected_item: Signal::new(None), + }; + let async_select_prop2 = async_select_prop.clone(); + + let search_button = BaseButtonStateRx { + label: Signal::new("Search".to_string()).handle(), + disabled: create_memo(cloned!((async_select_prop) => move || { + async_select_prop.selected_item.get().is_none() + })), + clicked: Signal::new(false), + }; + + create_effect(cloned!((search_button) => move || { + if *search_button.clicked.get() { + search_button.clicked.set(false); + navigate(&format!("/index/{}", (*async_select_prop2.selected_item.get()).clone().map_or("".to_string(), |c| c.slug))); + } + })); + view! { main (class=if *dark_mode_3.get() { "dark" } else { "" }) { link (rel="stylesheet", href = "/.perseus/static/tailwind.css") {} @@ -52,10 +87,23 @@ pub fn index_page(IndexPageStateRx { req }: IndexPageStateRx, global_state: AppS a (class="hover:underline", href="/") { h1 ( class="text-center text-lg" - ) { + ) { "Insider Transactions published by the AMF" } } + BaseButton(filter_expand) + div () {} // Without this useless div, the code doesn't run in the browser + div (class=format!("p-2 border rounded-lg border-slate-200 dark:border-slate-800 bg-slate-200 dark:bg-slate-700 transition-all ease-in {}", + if *expand.get() { "h-40 visible" } else { "h-0 collapse" }, + ) + ) + { + div (class="w-80") { + p () {"Search for a company:"} + BaseAsyncSelect(async_select_prop) + BaseButton(search_button) + } + } PaginatedTable(paginated_table_state) } } diff --git a/client/static/tailwind.css b/client/static/tailwind.css index 1df2ad2..6b8bbf1 100644 --- a/client/static/tailwind.css +++ b/client/static/tailwind.css @@ -513,6 +513,14 @@ video { --tw-backdrop-sepia: ; } +.visible { + visibility: visible; +} + +.collapse { + visibility: collapse; +} + .static { position: static; } @@ -521,6 +529,22 @@ video { position: fixed; } +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.-top-1 { + top: -0.25rem; +} + +.z-0 { + z-index: 0; +} + .m-2 { margin: 0.5rem; } @@ -533,16 +557,16 @@ video { margin: 2.5rem; } -.mx-auto { - margin-left: auto; - margin-right: auto; -} - .my-2 { margin-top: 0.5rem; margin-bottom: 0.5rem; } +.mx-auto { + margin-left: auto; + margin-right: auto; +} + .mx-1 { margin-left: 0.25rem; margin-right: 0.25rem; @@ -564,6 +588,10 @@ video { display: table; } +.hidden { + display: none; +} + .h-10 { height: 2.5rem; } @@ -572,10 +600,30 @@ video { height: 3rem; } +.h-40 { + height: 10rem; +} + +.h-0 { + height: 0px; +} + +.h-auto { + height: auto; +} + +.h-\[320\] { + height: 320; +} + .w-full { width: 100%; } +.w-80 { + width: 20rem; +} + .w-4\/5 { width: 80%; } @@ -588,6 +636,10 @@ video { 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; } @@ -612,18 +664,23 @@ video { justify-content: space-between; } -.rounded-lg { - border-radius: 0.5rem; -} - .rounded-md { border-radius: 0.375rem; } +.rounded-lg { + border-radius: 0.5rem; +} + .rounded-full { border-radius: 9999px; } +.rounded-b-md { + border-bottom-right-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; +} + .border { border-width: 1px; } @@ -646,11 +703,21 @@ video { border-color: rgb(100 116 139 / var(--tw-border-opacity)); } +.border-slate-200 { + --tw-border-opacity: 1; + border-color: rgb(226 232 240 / var(--tw-border-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)); +} + .bg-slate-100 { --tw-bg-opacity: 1; background-color: rgb(241 245 249 / var(--tw-bg-opacity)); @@ -666,6 +733,11 @@ video { background-color: rgb(251 207 232 / var(--tw-bg-opacity)); } +.bg-slate-700 { + --tw-bg-opacity: 1; + background-color: rgb(51 65 85 / var(--tw-bg-opacity)); +} + .p-2 { padding: 0.5rem; } @@ -739,6 +811,30 @@ video { 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-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.ease-in { + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); +} + +.hover\:cursor-pointer:hover { + cursor: pointer; +} + +.hover\:bg-slate-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(203 213 225 / var(--tw-bg-opacity)); +} + +.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; } @@ -752,6 +848,20 @@ video { text-decoration-line: underline; } +.disabled\:cursor-not-allowed:disabled { + cursor: not-allowed; +} + +.dark .dark\:border-slate-800 { + --tw-border-opacity: 1; + border-color: rgb(30 41 59 / var(--tw-border-opacity)); +} + +.dark .dark\:bg-slate-700 { + --tw-bg-opacity: 1; + background-color: rgb(51 65 85 / var(--tw-bg-opacity)); +} + .dark .dark\:bg-slate-800 { --tw-bg-opacity: 1; background-color: rgb(30 41 59 / var(--tw-bg-opacity)); @@ -766,11 +876,6 @@ video { background-color: rgb(219 39 119 / var(--tw-bg-opacity)); } -.dark .dark\:bg-slate-700 { - --tw-bg-opacity: 1; - background-color: rgb(51 65 85 / var(--tw-bg-opacity)); -} - .dark .dark\:bg-slate-600 { --tw-bg-opacity: 1; background-color: rgb(71 85 105 / var(--tw-bg-opacity)); @@ -786,6 +891,16 @@ video { color: rgb(165 180 252 / var(--tw-text-opacity)); } +.dark .dark\:hover\:bg-slate-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(30 41 59 / var(--tw-bg-opacity)); +} + +.dark .dark\:hover\:bg-slate-900:hover { + --tw-bg-opacity: 1; + background-color: rgb(15 23 42 / var(--tw-bg-opacity)); +} + .dark .dark\:hover\:text-indigo-600:hover { --tw-text-opacity: 1; color: rgb(79 70 229 / var(--tw-text-opacity)); diff --git a/makefile b/makefile index 740de7f..3cb6228 100644 --- a/makefile +++ b/makefile @@ -15,3 +15,7 @@ tailwind: serve: cd client && \ perseus serve -w + +client-deploy: + cd client && \ + perseus deploy diff --git a/server/src/route/company.rs b/server/src/route/company.rs index 046f8e6..2ccd2db 100644 --- a/server/src/route/company.rs +++ b/server/src/route/company.rs @@ -1,5 +1,5 @@ use rocket::{http::Status, response::status::Custom}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; use sea_orm_rocket::rocket::serde::json::Json; use sea_orm_rocket::Connection; use serde::{Deserialize, Serialize}; @@ -27,14 +27,16 @@ pub async fn get_all( Ok(Json(res)) } -#[get("/company/")] +#[get("/company/?")] pub async fn get_by_isin( conn: Connection<'_, Db>, name: String, + limit: Option, ) -> Result>, Custom> { let db = conn.into_inner(); let res = company::Entity::find() .filter(company::Column::Name.contains(&name)) + .limit(limit.unwrap_or(10)) .all(db) .await .map_err(|e| {