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 1c7065b..9ee77e8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,13 +1,9 @@
+use leptos::logging::log;
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 url::Url;
-
-use crate::pages::{AboutPage, HomePage, TestPage, UnauthenticatedPage};
+use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge};
+use leptos_router::{components::{Route, Router, Routes}, path};
+use crate::pages::{AboutPage, HomePage, LoanerPage, TestPage};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
@@ -32,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
+
+
+
+ }
+}
+
+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,
@@ -50,27 +87,16 @@ pub fn App() -> impl IntoView {
let _ = Auth::init(parameters);
view! {
- // injects a stylesheet into the document
- // id=leptos means cargo-leptos will hot-reload this stylesheet
-
-
- //
"Authentication is loading"
- //
-
- // sets the document title
-
-
- // content for this welcome page
-
-
- // TODO Create a 404 page
-
-
-
-
-
-
-
+
+ // 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
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
+}
+
+#[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, 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::();
+ 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(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."
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "Submit"
+
+ }
+}
+
+#[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 || 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::