feat: add course search UI with ts-rs type bindings

Integrate ts-rs for Rust-to-TypeScript type generation, build course
search page with filters, pagination, and expandable detail rows,
and refactor theme toggle into a reactive store with view transition
animation.
This commit is contained in:
2026-01-28 22:11:17 -06:00
parent 15256ff91c
commit 5fab8c216a
26 changed files with 1360 additions and 401 deletions
+3 -1
View File
@@ -3,9 +3,11 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use ts_rs::TS;
/// Represents a meeting time stored as JSONB in the courses table.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct DbMeetingTime {
pub begin_time: Option<String>,
pub end_time: Option<String>,
+5 -1
View File
@@ -2,6 +2,7 @@
use crate::data::models::ReferenceData;
use crate::error::Result;
use html_escape::decode_html_entities;
use sqlx::PgPool;
/// Batch upsert reference data entries.
@@ -12,7 +13,10 @@ pub async fn batch_upsert(entries: &[ReferenceData], db_pool: &PgPool) -> Result
let categories: Vec<&str> = entries.iter().map(|e| e.category.as_str()).collect();
let codes: Vec<&str> = entries.iter().map(|e| e.code.as_str()).collect();
let descriptions: Vec<&str> = entries.iter().map(|e| e.description.as_str()).collect();
let descriptions: Vec<String> = entries
.iter()
.map(|e| decode_html_entities(&e.description).into_owned())
.collect();
sqlx::query(
r#"
+13
View File
@@ -206,6 +206,19 @@ impl Scheduler {
let mut all_entries = Vec::new();
// Terms (fetched via session pool, no active session needed)
match banner_api.sessions.get_terms("", 1, 500).await {
Ok(terms) => {
debug!(count = terms.len(), "Fetched terms");
all_entries.extend(terms.into_iter().map(|t| ReferenceData {
category: "term".to_string(),
code: t.code,
description: t.description,
}));
}
Err(e) => warn!(error = ?e, "Failed to fetch terms"),
}
// Subjects
match banner_api.get_subjects("", &term, 1, 500).await {
Ok(pairs) => {
+3 -1
View File
@@ -3,10 +3,12 @@ use std::time::Instant;
use dashmap::DashMap;
use serde::Serialize;
use ts_rs::TS;
/// Health status of a service.
#[derive(Debug, Clone, Serialize, PartialEq)]
#[derive(Debug, Clone, Serialize, PartialEq, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum ServiceStatus {
Starting,
Active,
+25 -18
View File
@@ -18,6 +18,7 @@ use http::header;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{collections::BTreeMap, time::Duration};
use ts_rs::TS;
use crate::state::AppState;
use crate::status::ServiceStatus;
@@ -227,14 +228,16 @@ async fn health() -> Json<Value> {
}))
}
#[derive(Serialize)]
struct ServiceInfo {
#[derive(Serialize, TS)]
#[ts(export)]
pub struct ServiceInfo {
name: String,
status: ServiceStatus,
}
#[derive(Serialize)]
struct StatusResponse {
#[derive(Serialize, TS)]
#[ts(export)]
pub struct StatusResponse {
status: ServiceStatus,
version: String,
commit: String,
@@ -316,9 +319,10 @@ fn default_limit() -> i32 {
25
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
struct CourseResponse {
#[ts(export)]
pub struct CourseResponse {
crn: String,
subject: String,
course_number: String,
@@ -340,32 +344,35 @@ struct CourseResponse {
link_identifier: Option<String>,
is_section_linked: Option<bool>,
part_of_term: Option<String>,
meeting_times: Value,
attributes: Value,
meeting_times: Vec<crate::data::models::DbMeetingTime>,
attributes: Vec<String>,
instructors: Vec<InstructorResponse>,
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
struct InstructorResponse {
#[ts(export)]
pub struct InstructorResponse {
banner_id: String,
display_name: String,
email: Option<String>,
is_primary: bool,
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
struct SearchResponse {
#[ts(export)]
pub struct SearchResponse {
courses: Vec<CourseResponse>,
total_count: i64,
total_count: i32,
offset: i32,
limit: i32,
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
struct CodeDescription {
#[ts(export)]
pub struct CodeDescription {
code: String,
description: String,
}
@@ -411,8 +418,8 @@ async fn build_course_response(
link_identifier: course.link_identifier.clone(),
is_section_linked: course.is_section_linked,
part_of_term: course.part_of_term.clone(),
meeting_times: course.meeting_times.clone(),
attributes: course.attributes.clone(),
meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(),
attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(),
instructors,
}
}
@@ -454,7 +461,7 @@ async fn search_courses(
Ok(Json(SearchResponse {
courses: course_responses,
total_count,
total_count: total_count as i32,
offset,
limit,
}))