From 0490964dde35f24a9dce0e624188765454256d9a Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Fri, 5 Dec 2025 10:13:27 -0500 Subject: [PATCH 1/6] feat: add CB loaner page skeleton --- src/pages/loaner_page.rs | 131 +++++++++++++++++++++++++++++++++++++++ src/pages/mod.rs | 1 + 2 files changed, 132 insertions(+) create mode 100644 src/pages/loaner_page.rs diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs new file mode 100644 index 0000000..5253420 --- /dev/null +++ b/src/pages/loaner_page.rs @@ -0,0 +1,131 @@ +use chrono::{DateTime, Utc}; +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use leptos::logging::log; + +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; + +#[cfg(feature = "ssr")] +use serde_dynamo::{from_item, to_item}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct LoanerReturnOutput { + /// The first name of the student that is borrowing. + name: String, + /// The last name of the student that is borrowing. + last_name: String, + /// The type of loan that was given to the student. + loan: String, + /// The date that the student borrowed the loaner. + date: DateTime, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct LoanerBorrowInput { + /// 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 email address of the student that is borrowing. + email: String, + /// The collateral that the student is leaving. Usually a phone, earbuds, etc. + collateral: String, + /// The type of loan that was given to the student. May be a Chromebook, headphones, etc. + loan: String, + /// The date that the loaner was borrowed. + date: DateTime, +} + +#[server] +async fn get_loaners_return_list() -> Result, 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("name, date") + .send() + .await { + Ok(output) => { + if let Some(items) = output.items { + Ok(items.into_iter().map(|item| from_item(item).unwrap()).collect()) + } else { + Ok(vec![]) + } + }, + Err(err) => { + let msg = err.message().unwrap_or("Unknown error"); + Err(ServerFnError::new(msg)) + }, + } +} + +#[server] +async fn create_borrow_entry(input: LoanerBorrowInput) -> Result<(), ServerFnError> { + let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); + let item = to_item(input)?; + + match dbclient + .put_item() + .table_name("loaner-info") + .set_item(Some(item)) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred."); + Err(ServerFnError::new(msg)) + } + } +} + +/// # Loaner Page +/// +/// This page is going to temporarily piggy-back 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 { + // Register server actions. + let create_entry_action = ServerAction::::new(); + + // Register server resources + let return_list_resource: Resource> = Resource::new( + move || (), + async |_| get_loaners_return_list().await.unwrap_or_else(|e| { + log!("There was an error getting the loaner list: {}", e); + vec![] + }) + ); + + // We'll create the view here. Just remember that we want to have two sections, where they + // preferably slide in when they're activated. + view! {} +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 5752a59..156d685 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,5 +1,6 @@ mod about_page; mod home_page; mod unauthenticated_page; +mod loaner_page; pub use self::{about_page::*, home_page::*, unauthenticated_page::*}; -- 2.54.0 From 24fe9802b9a22ea338211216008057ca052ad2de Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Sun, 7 Dec 2025 12:15:22 -0500 Subject: [PATCH 2/6] feat: create framework for loaner app forms --- src/app.rs | 17 ++- src/common/expandable_info.rs | 4 +- src/pages/loaner_page.rs | 251 +++++++++++++++++++++++++++------- src/pages/mod.rs | 1 + 4 files changed, 216 insertions(+), 57 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1c7065b..29ee240 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,10 @@ use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; -use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge, LoginLink}; -use leptos_router::{ - StaticSegment, - components::{Route, Router, Routes}, -}; +use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; +use leptos_router::{components::{Route, Router, Routes}, path}; use url::Url; -use crate::pages::{AboutPage, HomePage, TestPage, UnauthenticatedPage}; +use crate::pages::{AboutPage, HomePage, LoanerPage, TestPage}; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { @@ -65,9 +62,11 @@ pub fn App() -> impl IntoView {
// TODO Create a 404 page - - - + + + + +
diff --git a/src/common/expandable_info.rs b/src/common/expandable_info.rs index 8f6c2b4..a209606 100644 --- a/src/common/expandable_info.rs +++ b/src/common/expandable_info.rs @@ -25,9 +25,9 @@ pub struct ExpandableInfo { } impl ExpandableInfo { - pub fn new(key: String) -> Self { + pub fn new(key: impl Into) -> Self { Self { - subject: key, + subject: key.into(), data: HashMap::new(), } } diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 5253420..416e09a 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -1,40 +1,35 @@ -use chrono::{DateTime, Utc}; -use leptos::prelude::*; -use serde::{Deserialize, Serialize}; -use leptos::logging::log; - -#[cfg(feature = "ssr")] -use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata}; #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; -#[derive(Serialize, Deserialize, Debug, Clone)] -struct LoanerReturnOutput { - /// The first name of the student that is borrowing. - name: String, - /// The last name of the student that is borrowing. - last_name: String, - /// The type of loan that was given to the student. - loan: String, - /// The date that the student borrowed the loaner. - date: DateTime, +use chrono::{DateTime, Utc}; +use leptos::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_query}; +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; + +#[derive(Params, PartialEq)] +struct LoanerParams { + form_name: Option } #[derive(Serialize, Deserialize, Debug, Clone)] -struct LoanerBorrowInput { +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 email address of the student that is borrowing. - email: String, - /// The collateral that the student is leaving. Usually a phone, earbuds, etc. - collateral: String, - /// The type of loan that was given to the student. May be a Chromebook, headphones, etc. - loan: String, - /// The date that the loaner was borrowed. - date: DateTime, } #[server] @@ -48,17 +43,25 @@ async fn get_loaners_return_list() -> Result, ServerFnEr match dbclient .scan() .table_name("loaner-info") - .projection_expression("name, date") + .projection_expression("subject, first_name, last_name") .send() .await { Ok(output) => { if let Some(items) = output.items { - Ok(items.into_iter().map(|item| from_item(item).unwrap()).collect()) + Ok(items.into_iter().map(|item| { + let expandable: ExpandableInfo = from_item(item).unwrap(); + 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(), + subject: expandable.subject + } + }).collect()) } else { Ok(vec![]) } }, Err(err) => { + log!("{:?}", err); let msg = err.message().unwrap_or("Unknown error"); Err(ServerFnError::new(msg)) }, @@ -66,21 +69,36 @@ async fn get_loaners_return_list() -> Result, ServerFnEr } #[server] -async fn create_borrow_entry(input: LoanerBorrowInput) -> Result<(), ServerFnError> { +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 item = to_item(input)?; - - match dbclient - .put_item() - .table_name("loaner-info") - .set_item(Some(item)) - .send() - .await - { - Ok(_) => Ok(()), - Err(err) => { - let msg = err.message().unwrap_or("An unknown error occurred."); - Err(ServerFnError::new(msg)) + + 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 write 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)); + } + }; + } } } } @@ -113,9 +131,138 @@ async fn create_borrow_entry(input: LoanerBorrowInput) -> Result<(), ServerFnErr /// 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::(); + 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! { + +
+
+ +
+ {form_view} +
+
+
+ } +} + +#[component] +pub fn LoanerBorrowForm() -> impl IntoView { // Register server actions. let create_entry_action = ServerAction::::new(); + let navigate = use_navigate(); + let temp = ExpandableInfo::new("test"); + let temp_reactive = temp.as_reactive(); + let elements_disabled = RwSignal::new(false); + + view! { + + + + + + + + + + + + "Submit" + + } +} + +#[component] +pub fn LoanerReturnForm() -> impl IntoView { // Register server resources let return_list_resource: Resource> = Resource::new( move || (), @@ -124,8 +271,20 @@ pub fn LoanerPage() -> impl IntoView { vec![] }) ); - - // We'll create the view here. Just remember that we want to have two sections, where they - // preferably slide in when they're activated. - view! {} + + view! { + +
+ {move || return_list_resource.get() + .map(move |loaner_list| { + loaner_list.into_iter().map(move |student| { + view! { + {student.first_name}" "{student.last_name} + } + }).collect_view() + }) + } +
+
+ } } diff --git a/src/pages/mod.rs b/src/pages/mod.rs index a1e22e0..35bb3ce 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -7,4 +7,5 @@ mod loaner_page; pub use self::{ about_page::*, home_page::*, provider_portal::*, test_page::*, unauthenticated_page::*, + loaner_page::LoanerPage, }; -- 2.54.0 From adc0d4ea0999393b9597ff74f15459ac392baf45 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 8 Dec 2025 16:13:52 -0500 Subject: [PATCH 3/6] feat: implement full loaner return form The only thing missing from this component would be sorting entries by their most recent date. I'm not super invested in sorting by this right now. --- src/pages/loaner_page.rs | 174 ++++++++++++++++++++++++++++++++++----- 1 file changed, 153 insertions(+), 21 deletions(-) diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 416e09a..9e224e0 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -1,21 +1,22 @@ #[cfg(feature = "ssr")] -use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata}; +use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; -use chrono::{DateTime, Utc}; -use leptos::either::EitherOf3; +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_query}; +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::{DateTime, FixedOffset, TimeZone, Utc}; +use leptos::html::Dialog; #[derive(Params, PartialEq)] struct LoanerParams { @@ -30,6 +31,8 @@ struct LoanerReturnOutput { 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] @@ -43,16 +46,18 @@ async fn get_loaners_return_list() -> Result, ServerFnEr match dbclient .scan() .table_name("loaner-info") - .projection_expression("subject, first_name, last_name") + .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()) @@ -68,11 +73,37 @@ async fn get_loaners_return_list() -> Result, ServerFnEr } } +#[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 @@ -88,7 +119,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> log!("{:?}", err); match err.code() { Some("ConditionalCheckFailedException") => { - // Retry database write until we have a good subject ID. + // 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 @@ -105,7 +136,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> /// # Loaner Page /// -/// This page is going to temporarily piggy-back on the general scholarship app, at least until +/// 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 @@ -187,11 +218,15 @@ pub fn LoanerBorrowForm() -> impl IntoView { let create_entry_action = ServerAction::::new(); let navigate = use_navigate(); - let temp = ExpandableInfo::new("test"); + let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string()); let temp_reactive = temp.as_reactive(); let elements_disabled = RwSignal::new(false); view! { +
+

"Borrower Information"

+

"Please fill out the form below."

+
impl IntoView { #[component] pub fn LoanerReturnForm() -> impl IntoView { + // Register server actions + let check_in_action = ServerAction::::new(); + // Register server resources + let return_list_refresh = RwSignal::new(0); let return_list_resource: Resource> = Resource::new( - move || (), + 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::::new(); + let chosen_student = RwSignal::new(None::); + 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! { + +

"Confirmation"

+

+ {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() + } + })} +

+ "Close" + "Check In" +
+
+

"Student List"

+

"Please select your name from the list."

+
-
- {move || return_list_resource.get() - .map(move |loaner_list| { - loaner_list.into_iter().map(move |student| { - view! { - {student.first_name}" "{student.last_name} - } - }).collect_view() - }) - } +
+ {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! { +
+
+ {student.first_name.clone()}" "{student.last_name.clone()} +
+
+ {student_date.clone()} +
+
+ } + }).collect_view(); + + Either::Left(list_view) + }, + None => Either::Right("No loaners found.".into_view()) + } + }}
} -- 2.54.0 From 1d6d141128f42825a11bdf2e638f6e640011ba9d Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 8 Dec 2025 16:14:30 -0500 Subject: [PATCH 4/6] chore: remove some unused imports --- src/pages/loaner_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 9e224e0..08284dd 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -15,7 +15,7 @@ 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::{DateTime, FixedOffset, TimeZone, Utc}; +use chrono::{FixedOffset, TimeZone, Utc}; use leptos::html::Dialog; #[derive(Params, PartialEq)] -- 2.54.0 From a01feb5e3410edd4296997fb8b872731ca8fb5b6 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 8 Dec 2025 16:14:45 -0500 Subject: [PATCH 5/6] chore: remove a second unused import --- src/pages/loaner_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 08284dd..9595913 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -15,7 +15,7 @@ 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, Utc}; +use chrono::{FixedOffset, TimeZone}; use leptos::html::Dialog; #[derive(Params, PartialEq)] -- 2.54.0 From 15b8aee8448a50e4c7d7b01c7b82fb55dd4619e6 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 11 Dec 2025 13:49:35 -0500 Subject: [PATCH 6/6] feat: create docker dependencies for cloud deployment --- Dockerfile | 9 ++-- docker-compose.yml | 13 ++--- src/app.rs | 81 ++++++++++++++++++++----------- src/pages/unauthenticated_page.rs | 3 ++ 4 files changed, 68 insertions(+), 38 deletions(-) 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/src/app.rs b/src/app.rs index 29ee240..9ee77e8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,8 @@ +use leptos::logging::log; use leptos::prelude::*; 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 url::Url; - use crate::pages::{AboutPage, HomePage, LoanerPage, TestPage}; pub fn shell(options: LeptosOptions) -> impl IntoView { @@ -29,12 +28,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, @@ -47,29 +87,16 @@ pub fn App() -> impl IntoView { let _ = Auth::init(parameters); view! { - // injects a stylesheet into the document <head> - // id=leptos means cargo-leptos will hot-reload this stylesheet - <Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css"/> - - // <AuthLoading><p>"Authentication is loading"</p></AuthLoading> - // <AuthErrorContext><AuthErrorView/></AuthErrorContext> - - // 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!("test_page") view=TestPage/> - <Route path=path!("loaners") view=LoanerPage /> - <Route path=path!("loaners/:form_name") view=LoanerPage /> - </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!("loaners") view=LoanerPage /> + <Route path=path!("loaners/:form_name") view=LoanerPage /> + </Routes> + </main> } } 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(); -- 2.54.0