diff --git a/Dockerfile b/Dockerfile index c24d621..85ace70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -FROM rust:1.86 AS builder +FROM rust:1.90 AS builder # Install node for tailwind capability -RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 +RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash @@ -15,6 +15,7 @@ WORKDIR /app COPY . . # Setup fnm and build with leptos +# If this doesn't work, we should use nvm as this is a proven solution. RUN cargo binstall fnm -y \ && fnm --version \ && eval "$(fnm env)" \ @@ -26,7 +27,7 @@ RUN cargo binstall fnm -y \ # Start second phase of build - install ca-certs and copy all build outputs from previous step into a # Debian image in the /app directory. -FROM debian:bookworm-slim AS runtime +FROM debian:trixie-slim AS runtime WORKDIR /app RUN apt-get update -y \ && apt-get install -y --no-install-recommends openssl ca-certificates \ @@ -46,6 +47,8 @@ COPY --from=builder /app/Cargo.toml /app/ ENV RUST_LOG="info" ENV LEPTOS_SITE_ADDR="0.0.0.0:8080" ENV LEPTOS_SITE_ROOT="site" +# Staging URL for server. +ENV LP_SITE_ORIGIN="http://testing-url.com" EXPOSE 8080 # Don't forget about project_name diff --git a/docker-compose.yml b/docker-compose.yml index 02e8ee8..cc725fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,6 @@ - -services: - leptos-watch: - image: rust:1.86 +services: + scholarships-rs: + build: . + image: scholarships-rs-runner:latest ports: - - '3000:3000' - volumes: - - .:/app - command: cargo leptos watch + - '8080:8080' diff --git a/public/Book.png b/public/Book.png new file mode 100644 index 0000000..939c4da Binary files /dev/null and b/public/Book.png differ diff --git a/public/Book_Style2.png b/public/Book_Style2.png new file mode 100644 index 0000000..66f1f94 Binary files /dev/null and b/public/Book_Style2.png differ diff --git a/public/Book_Style3.png b/public/Book_Style3.png new file mode 100644 index 0000000..4fc3e06 Binary files /dev/null and b/public/Book_Style3.png differ diff --git a/public/Bulb.png b/public/Bulb.png new file mode 100644 index 0000000..2940e9e Binary files /dev/null and b/public/Bulb.png differ diff --git a/public/Bulb_Style2.png b/public/Bulb_Style2.png new file mode 100644 index 0000000..6778936 Binary files /dev/null and b/public/Bulb_Style2.png differ diff --git a/public/Calendar.png b/public/Calendar.png new file mode 100644 index 0000000..b40ab22 Binary files /dev/null and b/public/Calendar.png differ diff --git a/public/Calendar_Style2.png b/public/Calendar_Style2.png new file mode 100644 index 0000000..38d570c Binary files /dev/null and b/public/Calendar_Style2.png differ diff --git a/public/Check.png b/public/Check.png new file mode 100644 index 0000000..27c9cd4 Binary files /dev/null and b/public/Check.png differ diff --git a/public/Check_Style2.png b/public/Check_Style2.png new file mode 100644 index 0000000..4de2ba3 Binary files /dev/null and b/public/Check_Style2.png differ diff --git a/public/Cog_Black.png b/public/Cog_Black.png new file mode 100644 index 0000000..4c574b6 Binary files /dev/null and b/public/Cog_Black.png differ diff --git a/public/Cog_Style3.png b/public/Cog_Style3.png new file mode 100644 index 0000000..df5d926 Binary files /dev/null and b/public/Cog_Style3.png differ diff --git a/public/Create.png b/public/Create.png new file mode 100644 index 0000000..5b0118b Binary files /dev/null and b/public/Create.png differ diff --git a/public/Create_Black.png b/public/Create_Black.png new file mode 100644 index 0000000..43cc0ed Binary files /dev/null and b/public/Create_Black.png differ diff --git a/public/Edit.png b/public/Edit.png new file mode 100644 index 0000000..a691d08 Binary files /dev/null and b/public/Edit.png differ diff --git a/public/Form.png b/public/Form.png new file mode 100644 index 0000000..c534471 Binary files /dev/null and b/public/Form.png differ diff --git a/public/Form_Black.png b/public/Form_Black.png new file mode 100644 index 0000000..528abaf Binary files /dev/null and b/public/Form_Black.png differ diff --git a/public/Medal_Black.png b/public/Medal_Black.png new file mode 100644 index 0000000..9c1e2a5 Binary files /dev/null and b/public/Medal_Black.png differ diff --git a/public/PHS_Claw_Logo.png b/public/PHS_Claw_Logo.png new file mode 100644 index 0000000..9bcb67b Binary files /dev/null and b/public/PHS_Claw_Logo.png differ diff --git a/public/PHS_Stacked_Acronym.png b/public/PHS_Stacked_Acronym.png new file mode 100644 index 0000000..94b47ce Binary files /dev/null and b/public/PHS_Stacked_Acronym.png differ diff --git a/public/Person.png b/public/Person.png new file mode 100644 index 0000000..35f5e14 Binary files /dev/null and b/public/Person.png differ diff --git a/public/Person_Black.png b/public/Person_Black.png new file mode 100644 index 0000000..76891b8 Binary files /dev/null and b/public/Person_Black.png differ diff --git a/public/R15_logo.png b/public/R15_logo.png new file mode 100644 index 0000000..802df65 Binary files /dev/null and b/public/R15_logo.png differ diff --git a/public/R15_logo_colorMatchWhite.png b/public/R15_logo_colorMatchWhite.png new file mode 100644 index 0000000..6842325 Binary files /dev/null and b/public/R15_logo_colorMatchWhite.png differ diff --git a/public/Warning.png b/public/Warning.png new file mode 100644 index 0000000..60efb17 Binary files /dev/null and b/public/Warning.png differ diff --git a/public/Warning_Style2.png b/public/Warning_Style2.png new file mode 100644 index 0000000..bc301e0 Binary files /dev/null and b/public/Warning_Style2.png differ diff --git a/public/medal.png b/public/medal.png new file mode 100644 index 0000000..0fc6b76 Binary files /dev/null and b/public/medal.png differ diff --git a/public/square-user.svg b/public/square-user.svg new file mode 100644 index 0000000..92f1016 --- /dev/null +++ b/public/square-user.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index ba7f12c..c242bdd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,11 @@ +use leptos::logging::log; use leptos::prelude::*; -use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; +use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_router::{components::{Route, Router, Routes}, path}; +use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, TestPage}; use url::Url; -use crate::pages::{AboutPage, ComparisonTestPage, HomePage, ScholarshipInfoPage}; - pub fn shell(options: LeptosOptions) -> impl IntoView { view! { @@ -29,12 +29,53 @@ pub fn App() -> impl IntoView { // Provides context that manages stylesheets, titles, meta tags, etc. provide_meta_context(); + view! { + // injects a stylesheet into the document + // id=leptos means cargo-leptos will hot-reload this stylesheet + + + // sets the document title + + + // content for this welcome page + <Router> + <AppWithRoutes /> + </Router> + } +} + +fn use_origin() -> String { + #[cfg(target_arch = "wasm32")] { + use leptos::web_sys; + // Just get the current URL origin. + web_sys::window() + .unwrap() + .location() + .origin() + .unwrap_or("http://localhost:3000/".to_string()) + } + + #[cfg(not(target_arch = "wasm32"))] { + // Read the expected origin out of an environment variable. + std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string()) + } +} + +#[component] +pub fn AppWithRoutes() -> impl IntoView { + provide_meta_context(); + + let current_origin = use_origin(); + log!("Current origin: {}", current_origin); + // Authentication setup let parameters = AuthParameters { issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM".into(), client_id: "10jr2h3vtpu9n7gj46pvg5qo2q".into(), - redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(), - post_logout_redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(), + redirect_uri: format!("{}/", use_origin()), + // redirect_uri: Url::parse(format!("{}", current_origin).as_str()).unwrap().to_string(), + // redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(), + post_logout_redirect_uri: current_origin, scope: Some("openid%20profile%20email".into()), audience: None, challenge: Challenge::None, @@ -56,19 +97,18 @@ pub fn App() -> impl IntoView { // sets the document title <Title text="R15 Scholarship App DEV" /> - - // content for this welcome page - <Router> - <main> - // TODO Create a 404 page - <Routes fallback=|| "Page not found.".into_view()> - <Route path=path!("") view=HomePage /> - <Route path=path!("about") view=AboutPage /> - <Route path=path!("comparison") view=ComparisonTestPage /> - <Route path=path!("scholarship/:id") view=ScholarshipInfoPage /> - </Routes> - </main> - </Router> + + <main> + // TODO Create a 404 page + <Routes fallback=|| "Page not found.".into_view()> + <Route path=path!("") view=HomePage/> + <Route path=path!("about") view=AboutPage/> + <Route path=path!("test_page") view=TestPage/> + <Route path=path!("comparison") view=ComparisonTestPage / + <Route path=path!("loaners") view=LoanerPage /> + <Route path=path!("loaners/:form_name") view=LoanerPage /> + </Routes> + </main> } } diff --git a/src/components/banner.rs b/src/components/banner.rs new file mode 100644 index 0000000..79b95ca --- /dev/null +++ b/src/components/banner.rs @@ -0,0 +1,48 @@ +use leptos::prelude::*; +use leptos_router::hooks::use_navigate; + +/// # Banner Component +/// +/// This component displays a red banner at the top of the page with a title and a logo. +/// Clicking the logo will redirect a user to specified page, preferably a home page. +/// +/// Example usage: +/// ``` +/// <Banner +/// title="Test Page" +/// logo="medal.png" +/// path="/" +/// /> +/// ``` +#[component] +pub fn Banner( + /// The title of the banner. + #[prop(into)] + title: String, + /// The file path or URL of a logo displayed at the top left of the banner. + #[prop(into)] + logo: String, + /// The route that the navigates user to a page (home page). + #[prop(into, default = String::from("/"))] + path: String, +) -> impl IntoView { + let navigate = use_navigate(); + + let on_click = move |_| navigate(&path, Default::default()); + + view! { + <div class="mx-auto"> + <div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]"> + <div class="flex items-center gap-4 p-6"> + <div class="flex-shrink-0"> + <img src={logo.clone()} class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click/> + </div> + <div class="flex-1"> + <h2 class="text-white text-lg font-bold">{title.clone()}</h2> + </div> + </div> + </div> + </div> + + } +} diff --git a/src/components/checkbox.rs b/src/components/checkbox.rs index a245834..ba711dc 100644 --- a/src/components/checkbox.rs +++ b/src/components/checkbox.rs @@ -1,4 +1,5 @@ use crate::common::ValueType; +use crate::components::utils::create_unique_id; use leptos::prelude::*; use std::collections::HashMap; @@ -7,10 +8,13 @@ pub fn Checkbox( #[prop()] checked: Signal<bool>, #[prop(into)] on_change: Callback<(), ()>, #[prop(into)] value: String, + #[prop(into)] name: String, #[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, ) -> impl IntoView { + let id = create_unique_id(&name, &value); + view! { - <label for=value class="flex items-center"> + <label for=id class="flex items-center"> <input // Using the "peer" class links this input with the div "peer" at the same level. // By using the "peer-checked" selector this div can toggle states based on this @@ -19,7 +23,8 @@ pub fn Checkbox( relative peer shrink-0 hidden" type="checkbox" - id=value.clone() + id=id.clone() + name=name.clone() on:change=move |_| on_change.run(()) prop:checked=checked disabled=disabled @@ -109,6 +114,7 @@ pub fn CheckboxList( on_change=on_change // The actual selected values are tracked by this element, not by the checkboxes themselves. value=item.clone() + name=data_member.clone() disabled=disabled /> } diff --git a/src/components/chips.rs b/src/components/chips.rs index bc3a31c..6c117b0 100644 --- a/src/components/chips.rs +++ b/src/components/chips.rs @@ -1,13 +1,14 @@ -use std::collections::HashMap; -use leptos::prelude::*; use crate::common::ValueType; +use crate::components::utils::create_unique_id; +use leptos::prelude::*; +use std::collections::HashMap; /// # Chip Component -/// +/// /// This component defines a single chip for use in the `ChipsList` component. It functions almost /// identically to the `Checkbox` component. It should only be created from within its parent /// `CheckboxList` component. -/// +/// /// Example usage: /// ``` /// view! { @@ -31,11 +32,8 @@ pub fn Chip( ) -> impl IntoView { // Generate a unique id - ensure that the value doesn't contain spaces. // Without this id, checkbox/radio inputs can interfere with each other. - let value_no_spaces = value.clone().chars() - .filter(|c| !c.is_whitespace()) - .collect::<String>(); - let id = format!("{}-{}", name.clone(), value_no_spaces); - + let id = create_unique_id(&name, &value); + view! { <label for=id> <input @@ -62,10 +60,10 @@ pub fn Chip( } /// # Chips List Component -/// +/// /// This defines a list of `Chip` components. The list of values is created based on the data from the /// `data_map` prop, which is found using the given `data_member`. -/// +/// /// Any values selected from the list are stored within the `data_map` immediately as part of a callback /// passed to the `on_change` prop for each individual `Chip` component. #[component] @@ -97,7 +95,7 @@ pub fn ChipsList( .contains(&ValueType::String(Some(item_name.clone()))) } }); - + let on_change = { let item_name = value.clone(); let data_member = data_member.clone(); @@ -129,7 +127,7 @@ pub fn ChipsList( }); } }; - + view! { <Chip checked=checked_signal diff --git a/src/components/dashboard_button.rs b/src/components/dashboard_button.rs new file mode 100644 index 0000000..f471e33 --- /dev/null +++ b/src/components/dashboard_button.rs @@ -0,0 +1,61 @@ +use leptos::prelude::*; +use leptos_router::hooks::use_navigate; + +/// # Dashboard Button Component +/// +/// This component displays a large panel-like button that redirects a user to a specified page. +/// It's best used on a user dashboard, where multiple instances of the button are present. +/// It has a title, a description, and icon +/// +/// Example usage: +/// ``` +/// <div class="p-6 space-y-4 max-w-1/3"> +/// <DashboardButton +/// title="Home Page" +/// description="Navigate to Home Page" +/// icon="square-user.svg" +/// path="/" +/// /> +/// </div> +/// ``` +#[component] +pub fn DashboardButton( + /// The main label displayed on the button. + #[prop(into)] + title: String, + /// A brief explanatory text that describes the destination page. + #[prop(into)] + description: String, + /// The file path or URL of the icon displayed at the top of the button. + #[prop(into)] + icon: String, + /// The route that the user is navigated to when clicking the button. + #[prop(into)] + path: String, +) -> impl IntoView { + let navigate = use_navigate(); + + let on_click = move |_| navigate(&path, Default::default()); + + view! { + <button + type="button" + class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6 + hover:bg-gray-100 transition cursor-pointer w-full text-left + shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]" + on:click=on_click> + + <div class="flex flex-col"> + <img src={icon.clone()} class="h-8 w-8" alt="icon"/> + <h3 class="font-semibold text-base"> + {title.clone()} + </h3> + + <p class="text-sm text-gray-600 pt-6"> + {description.clone()} + </p> + + </div> + </button> + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 2616640..a66f141 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,5 +1,8 @@ mod action_button; +mod banner; mod checkbox; +mod chips; +mod dashboard_button; mod loading; mod multi_entry; mod panel; @@ -7,9 +10,9 @@ mod radio; mod row; mod select; mod text_field; -mod chips; +mod utils; pub use self::{ - action_button::*, checkbox::*, loading::*, multi_entry::*, panel::*, radio::*, row::*, - select::*, text_field::*, chips::* + action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*, + multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*, }; diff --git a/src/components/radio.rs b/src/components/radio.rs index 73cc789..72047ef 100644 --- a/src/components/radio.rs +++ b/src/components/radio.rs @@ -1,4 +1,5 @@ use crate::common::ValueType; +use crate::components::utils::create_unique_id; use leptos::prelude::*; use std::collections::HashMap; @@ -7,10 +8,13 @@ pub fn Radio( #[prop()] checked: Signal<bool>, #[prop(into)] on_change: Callback<(), ()>, #[prop(into)] value: String, + #[prop(into)] name: String, #[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, ) -> impl IntoView { + let id = create_unique_id(&name, &value); + view! { - <label for=value class="flex items-center"> + <label for=id class="flex items-center"> <input // Using the "peer" class links this input with the div "peer" at the same level. // By using the "peer-checked" selector this div can toggle states based on this @@ -18,8 +22,9 @@ pub fn Radio( class=" relative peer shrink-0 hidden" - type="checkbox" - id=value.clone() + type="radio" + id=id.clone() + name=name.clone() on:change=move |_| on_change.run(()) prop:checked=checked disabled=disabled @@ -96,6 +101,7 @@ pub fn RadioList( on_change=on_change // The actual selected values are tracked by this element, not by the checkboxes themselves. value=item.clone() + name=data_member.clone() disabled=disabled /> } diff --git a/src/components/utils.rs b/src/components/utils.rs new file mode 100644 index 0000000..6dba9c9 --- /dev/null +++ b/src/components/utils.rs @@ -0,0 +1,10 @@ +/// Creates a unique ID for use in components like the [`CheckboxList`], +/// [`RadioList`], and [`ChipsList`]. +pub fn create_unique_id(name: &String, value: &String) -> String { + let value_no_spaces = value + .clone() + .chars() + .filter(|c| !c.is_whitespace()) + .collect::<String>(); + format!("{}-{}", name.clone(), value_no_spaces) +} diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs new file mode 100644 index 0000000..9595913 --- /dev/null +++ b/src/pages/loaner_page.rs @@ -0,0 +1,422 @@ +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; + +#[cfg(feature = "ssr")] +use serde_dynamo::{from_item, to_item}; + +use leptos::either::{Either, EitherOf3}; +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use leptos::logging::log; +use leptos_router::params::Params; +use leptos_router::hooks::{use_navigate, use_params}; +use leptos::Params; +use traits::{AsReactive, ReactiveCapture}; +use crate::common::{ExpandableInfo, ValueType}; +use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select}; +use std::collections::HashMap; +use chrono::{FixedOffset, TimeZone}; +use leptos::html::Dialog; + +#[derive(Params, PartialEq)] +struct LoanerParams { + form_name: Option<String> +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct LoanerReturnOutput { + /// The subject ID of the student entry. + subject: String, + /// The first name of the student that is borrowing. + first_name: String, + /// The last name of the student that is borrowing. + last_name: String, + /// The date that the student borrowed the loaner. + date: String, +} + +#[server] +async fn get_loaners_return_list() -> Result<Vec<LoanerReturnOutput>, ServerFnError> { + // We want to query the database for all loaners, but we only want the name and + // date of borrowing. This will be cheaper than querying the entire table. + let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); + + log!("Getting loaners from database."); + + match dbclient + .scan() + .table_name("loaner-info") + .projection_expression("subject, first_name, last_name, date_taken") + .send() + .await { + Ok(output) => { + if let Some(items) = output.items { + Ok(items.into_iter().map(|item| { + let expandable: ExpandableInfo = from_item(item).unwrap(); + // This set of function calls is ridiculous, we need to make a better way to do this. + LoanerReturnOutput { + first_name: expandable.data.get("first_name").unwrap().as_string().unwrap_or_default().unwrap_or_default(), + last_name: expandable.data.get("last_name").unwrap().as_string().unwrap_or_default().unwrap_or_default(), + date: expandable.data.get("date_taken").unwrap().as_string().unwrap_or_default().unwrap_or_default(), + subject: expandable.subject + } + }).collect()) + } else { + Ok(vec![]) + } + }, + Err(err) => { + log!("{:?}", err); + let msg = err.message().unwrap_or("Unknown error"); + Err(ServerFnError::new(msg)) + }, + } +} + +#[server] +async fn check_in_loaner(student: LoanerReturnOutput) -> Result<(), ServerFnError> { + let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); + + match dbclient + .delete_item() + .table_name("loaner-info") + .key("subject", AttributeValue::S(student.subject)) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) => { + log!("{:?}", err); + let msg = err.message().unwrap_or("Unknown error"); + Err(ServerFnError::new(msg)) + } + } +} + +#[server] +async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> { + let mut input = input; + let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); + + let current_time = chrono::Utc::now().format("%H:%M, %m/%d/%Y").to_string(); + log!("Creating entry with timestamp {}", current_time); + + // Add the timestamp to the data map as a string and then push it to the database. + input.data.insert("date_taken".to_string(), ValueType::String(Some(current_time.clone()))); + + loop { + let item: serde_dynamo::Item = to_item(&input)?; + match dbclient + .put_item() + .table_name("loaner-info") + .set_item(Some(HashMap::from(item.clone()))) + .condition_expression("attribute_not_exists(subject)") + .send() + .await + { + Ok(_) => return Ok(()), + Err(err) => { + log!("{:?}", err); + match err.code() { + Some("ConditionalCheckFailedException") => { + // Retry database writes until we have a good subject ID. + log!("Failed conditional check, forcing new subject ID."); + input.subject = uuid::Uuid::new_v4().to_string(); + continue + }, + _ => { + let msg = err.message().unwrap_or("An unknown error occurred."); + return Err(ServerFnError::new(msg)); + } + }; + } + } + } +} + +/// # Loaner Page +/// +/// This page is going to temporarily piggyback on the general scholarship app, at least until +/// we spin off the components used here into a new standalone crate. +/// +/// The loaner app handles management of Chromebook loaners to students. It was originally hosted +/// using Google Apps Script, but has recently been broken by some unknown API error. I've chosen +/// to rewrite this into a single new application, which we will host in AWS with a real database. +/// +/// It should use all the same components as the scholarship loaner, maybe with a few extra +/// alterations. The page will only use a single database table, and it will only have a few very +/// basic APIs. +/// +/// # Design +/// +/// The page should have two sections - one for logging a loaner that's being taken out (borrowing) +/// and another for a loaner that's being brought back (returning). As of right now, we have two +/// separate buttons that handle these functions, and they slide open a panel at the bottom of the +/// screen. The borrowing menu has a single, simple form, and the returning side has a list of +/// buttons that allow students to choose their name and remove it from the list. +/// +/// # New features +/// +/// I believe that with a new workflow and an overhauled server with real data management, we should +/// create a true admin page for this, simply to be able to survey and override/remove information +/// from the lists. +#[component] +pub fn LoanerPage() -> impl IntoView { + // Check URL params. Depending on what string is visible here, we'll display a different form. + let params = use_params::<LoanerParams>(); + let form_passed = move || { + params.read() + .as_ref() + .ok() + .and_then(|params| params.form_name.clone()) + .unwrap_or_default() + }; + + let form_view = move || { + match form_passed().as_str() { + "borrowing" => EitherOf3::A(LoanerBorrowForm), + "returning" => EitherOf3::B(LoanerReturnForm), + _ => EitherOf3::C("Choose something from the left.".into_view()) + } + }; + + // We'll create the page here - the sidebar contains all the buttons, while the form + // container either shows forms or shows placeholder text. + view! { + <Banner + title="Chromebook Loaners" + logo="/PHS_Stacked_Acronym.png" + path="/loaners" + /> + <div class="flex m-5"> + <div class="flex flex-1 flex-row gap-3"> + <div id="sidebar" class="flex flex-1 flex-col gap-2"> + <DashboardButton + title="Borrowing" + description="Select to take out a loaner." + icon="/Edit.png" + path="/loaners/borrowing" + /> + <DashboardButton + title="Returning" + description="Select to return a loaner." + icon="/Person_Black.png" + path="/loaners/returning" + /> + </div> + <div class="flex flex-2"> + <Panel>{form_view}</Panel> + </div> + </div> + </div> + } +} + +#[component] +pub fn LoanerBorrowForm() -> impl IntoView { + // Register server actions. + let create_entry_action = ServerAction::<CreateBorrowEntry>::new(); + let navigate = use_navigate(); + + let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string()); + let temp_reactive = temp.as_reactive(); + let elements_disabled = RwSignal::new(false); + + view! { + <div class="flex flex-col justify-center gap-2 mb-1 py-3 px-2"> + <h2 class="text-2xl font-bold">"Borrower Information"</h2> + <p class="text-lg">"Please fill out the form below."</p> + </div> + <Row> + <OutlinedTextField + label="First Name:" + placeholder="John" + disabled=elements_disabled + data_member="first_name" + data_map=temp_reactive.data + /> + <OutlinedTextField + label="Last Name:" + placeholder="Smith" + disabled=elements_disabled + data_member="last_name" + data_map=temp_reactive.data + /> + </Row> + <Row> + <OutlinedTextField + label="Region 15 Email:" + placeholder="example@region15.org" + disabled=elements_disabled + data_member="email" + data_map=temp_reactive.data + /> + </Row> + <Row> + <Select + label="Collateral:" + value_list=vec![ + "Phone".to_string(), + "Earbuds/Headphones".to_string(), + "Keys".to_string(), + "Wallet".to_string(), + "Laptop".to_string(), + ] + disabled=elements_disabled + data_member="collateral" + data_map=temp_reactive.data + /> + </Row> + <Row> + <Select + label="Loan Taken:" + value_list=vec![ + "Chromebook".to_string(), + "Charger".to_string(), + "Headphones".to_string(), + ] + disabled=elements_disabled + data_member="loan" + data_map=temp_reactive.data + /> + </Row> + <Row> + <ActionButton + on:click=move |_| { + let captured = temp_reactive.capture(); + log!("Loaner record taken."); + log!("{:?}", captured); + create_entry_action.dispatch(CreateBorrowEntry { + input: captured + }); + navigate("/loaners", Default::default()); + } + >"Submit"</ActionButton> + </Row> + } +} + +#[component] +pub fn LoanerReturnForm() -> impl IntoView { + // Register server actions + let check_in_action = ServerAction::<CheckInLoaner>::new(); + + // Register server resources + let return_list_refresh = RwSignal::new(0); + let return_list_resource: Resource<Vec<LoanerReturnOutput>> = Resource::new( + move || return_list_refresh.get(), + async |_| get_loaners_return_list().await.unwrap_or_else(|e| { + log!("There was an error getting the loaner list: {}", e); + vec![] + }) + ); + + let dialog_ref = NodeRef::<Dialog>::new(); + let chosen_student = RwSignal::new(None::<LoanerReturnOutput>); + let dialog_disabled = RwSignal::new(false); + + // Create an effect to run after check-in - refreshes the list with another scan request. + // This could be beneficial if we ever decided to make multiple loaner kiosks (think SAT days) + Effect::new(move || { + if let Some(Ok(_)) = check_in_action.value().get() { + // Check the local loaner list and remove the indicated student from it. + return_list_refresh.update(|n| *n += 1); + if let Some(dialog) = dialog_ref.get() { + dialog.close(); + } + } + }); + + // We can't use a Memo in our custom components for the disabled signal. + Effect::new(move || { + if check_in_action.pending().get() { + dialog_disabled.set(true); + } else { + dialog_disabled.set(false); + } + }); + + // Handles checking in a student. + let dialog_check_in = move |_| { + chosen_student.with(|opt_student| { + if let Some(student) = opt_student.as_ref() { + check_in_action.dispatch(CheckInLoaner { student: student.clone() }); + } + }) + }; + + // Handles closing the dialog menu. + let close_dialog = move |_| { + if let Some(dialog) = dialog_ref.get() { + dialog.close(); + } + }; + + view! { + <dialog + class="p-3 m-auto rounded-md shadow-lg/33 backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter" + node_ref=dialog_ref> + <h2 class="text-2xl font-bold">"Confirmation"</h2> + <h4 class="text-xl mb-3"> + {move || chosen_student.with(|opt_student| { + if let Some(student) = opt_student.as_ref() { + format!("Are you sure want to check in {} {}?", student.first_name, student.last_name) + } else { + "There is no student selected.".to_string() + } + })} + </h4> + <ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton> + <ActionButton on:click=dialog_check_in disabled=dialog_disabled>"Check In"</ActionButton> + </dialog> + <div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2"> + <h2 class="text-2xl font-bold">"Student List"</h2> + <p class="text-lg">"Please select your name from the list."</p> + </div> + <Suspense fallback=move || "Loading...".into_view()> + <div class="flex flex-col gap-2 m-2"> + {move || { + match return_list_resource.get() { + Some(loaner_list) => { + let list_view = loaner_list.iter().map(|student| { + let on_click = { + let student_click = student.clone(); + move |_| { + log!("Running on_click using student {:?}", student_click); + if let Some(dialog) = dialog_ref.get() { + chosen_student.set(Some(student_click.clone())); + dialog.show_modal().expect("Couldn't show modal dialog."); + } + } + }; + + let naive_time = chrono::NaiveDateTime::parse_from_str(&student.date, "%H:%M, %m/%d/%Y") + .unwrap(); + let time_zone = FixedOffset::west_opt(5 * 3600).unwrap(); + let student_date = time_zone.from_utc_datetime(&naive_time) + .format("%l:%M %p, %m/%d/%Y") + .to_string(); + + log!("Found current date {:?}", student_date); + + view! { + <div class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33" on:click=on_click> + <div class="flex-1"> + {student.first_name.clone()}" "{student.last_name.clone()} + </div> + <div class="flex-none"> + {student_date.clone()} + </div> + </div> + } + }).collect_view(); + + Either::Left(list_view) + }, + None => Either::Right("No loaners found.".into_view()) + } + }} + </div> + </Suspense> + } +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 0173c74..6c06aaa 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,9 +1,15 @@ mod about_page; mod comparison_test; mod home_page; +mod provider_portal; +mod test_page; mod unauthenticated_page; mod scholarship_info; mod utils; mod api; +mod loaner_page; -pub use self::{about_page::*, comparison_test::*, home_page::*, unauthenticated_page::*, scholarship_info::*}; +pub use self::{ + about_page::*, home_page::*, provider_portal::*, test_page::*, unauthenticated_page::*, + loaner_page::LoanerPage, comparison_test::*, +}; diff --git a/src/pages/provider_portal.rs b/src/pages/provider_portal.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/pages/provider_portal.rs @@ -0,0 +1 @@ + diff --git a/src/pages/test_page.rs b/src/pages/test_page.rs new file mode 100644 index 0000000..f3e1f28 --- /dev/null +++ b/src/pages/test_page.rs @@ -0,0 +1,28 @@ +use crate::components::DashboardButton; +use leptos::prelude::*; + +#[component] +pub fn TestPage() -> impl IntoView { + view! { + <div class="p-6 space-y-4 max-w-1/3"> + <DashboardButton + title="Home Page" + description="Navigate to Home Page" + icon="Book.png" + path="/" + /> + <DashboardButton + title="Home Page" + description="Navigate to Home Page" + icon="Calendar.png" + path="/" + /> + <DashboardButton + title="Home Page" + description="Navigate to Home Page" + icon="Edit.png" + path="/" + /> + </div> + } +} diff --git a/src/pages/unauthenticated_page.rs b/src/pages/unauthenticated_page.rs index b590de9..44f50fb 100644 --- a/src/pages/unauthenticated_page.rs +++ b/src/pages/unauthenticated_page.rs @@ -1,3 +1,4 @@ +use leptos::logging::log; use crate::components::ActionButton; use leptos::prelude::*; use leptos_oidc::AuthSignal; @@ -11,6 +12,8 @@ pub fn UnauthenticatedPage() -> impl IntoView { .map(|unauthenticated| unauthenticated.login_url()) }) }; + + Effect::new(move || log!("Generated login url: {:?}", login_url())); let navigate = leptos_router::hooks::use_navigate();