Merge branch 'main' into feature/form-schema

This commit is contained in:
Lucas Vas authored and GitHub committed 2025-12-12 11:33:40 -05:00
commit dc239cdaba
42 files changed
+687 -51

No files matched your search

+58 -18
View File
@@ -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! {
<!DOCTYPE html>
@@ -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 <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css"/>
// sets the document title
<Title text="R15 Scholarship App DEV"/>
// 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>
}
}
+48
View File
@@ -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>
}
}
+8 -2
View File
@@ -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
/>
}
+11 -13
View File
@@ -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
+61
View File
@@ -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>
}
}
+6 -3
View File
@@ -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::*,
};
+9 -3
View File
@@ -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
/>
}
+10
View File
@@ -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)
}
+422
View File
@@ -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>
}
}
+7 -1
View File
@@ -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::*,
};
+1
View File
@@ -0,0 +1 @@
+28
View File
@@ -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>
}
}
+3
View File
@@ -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();