mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -06:00
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:
@@ -0,0 +1,2 @@
|
|||||||
|
[env]
|
||||||
|
TS_RS_EXPORT_DIR = { value = "web/src/lib/bindings/", relative = true }
|
||||||
Vendored
+4
-3
@@ -1,5 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
/target
|
/target
|
||||||
/go/
|
|
||||||
.cargo/config.toml
|
# ts-rs bindings
|
||||||
src/scraper/README.md
|
web/src/lib/bindings/*.ts
|
||||||
|
!web/src/lib/bindings/index.ts
|
||||||
|
|||||||
Generated
+49
@@ -235,6 +235,7 @@ dependencies = [
|
|||||||
"fundu",
|
"fundu",
|
||||||
"futures",
|
"futures",
|
||||||
"governor",
|
"governor",
|
||||||
|
"html-escape",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"num-format",
|
"num-format",
|
||||||
@@ -257,6 +258,7 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"ts-rs",
|
||||||
"url",
|
"url",
|
||||||
"yansi",
|
"yansi",
|
||||||
]
|
]
|
||||||
@@ -1227,6 +1229,15 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "html-escape"
|
||||||
|
version = "0.2.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
|
||||||
|
dependencies = [
|
||||||
|
"utf8-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.2.12"
|
version = "0.2.12"
|
||||||
@@ -3256,6 +3267,15 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "termcolor"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -3648,6 +3668,29 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ts-rs"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
|
||||||
|
dependencies = [
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.16",
|
||||||
|
"ts-rs-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ts-rs-macros"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
"termcolor",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.21.0"
|
version = "0.21.0"
|
||||||
@@ -3776,6 +3819,12 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-width"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ clap = { version = "4.5", features = ["derive"] }
|
|||||||
rapidhash = "4.1.0"
|
rapidhash = "4.1.0"
|
||||||
yansi = "1.0.1"
|
yansi = "1.0.1"
|
||||||
extension-traits = "2"
|
extension-traits = "2"
|
||||||
|
ts-rs = { version = "11.1.0", features = ["serde-compat", "serde-json-impl"] }
|
||||||
|
html-escape = "0.2.13"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,20 @@ default:
|
|||||||
check:
|
check:
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
cargo clippy --all-features -- --deny warnings
|
cargo clippy --all-features -- --deny warnings
|
||||||
cargo nextest run
|
cargo nextest run -E 'not test(export_bindings)'
|
||||||
bun run --cwd web check
|
bun run --cwd web check
|
||||||
bun run --cwd web test
|
bun run --cwd web test
|
||||||
|
|
||||||
|
# Generate TypeScript bindings from Rust types (ts-rs)
|
||||||
|
bindings:
|
||||||
|
cargo test export_bindings
|
||||||
|
|
||||||
# Run all tests (Rust + frontend)
|
# Run all tests (Rust + frontend)
|
||||||
test: test-rust test-web
|
test: test-rust test-web
|
||||||
|
|
||||||
# Run only Rust tests
|
# Run only Rust tests (excludes ts-rs bindings generation)
|
||||||
test-rust *ARGS:
|
test-rust *ARGS:
|
||||||
cargo nextest run {{ARGS}}
|
cargo nextest run -E 'not test(export_bindings)' {{ARGS}}
|
||||||
|
|
||||||
# Run only frontend tests
|
# Run only frontend tests
|
||||||
test-web:
|
test-web:
|
||||||
@@ -26,7 +30,7 @@ test-web:
|
|||||||
# Quick check: clippy + tests + typecheck (skips formatting)
|
# Quick check: clippy + tests + typecheck (skips formatting)
|
||||||
check-quick:
|
check-quick:
|
||||||
cargo clippy --all-features -- --deny warnings
|
cargo clippy --all-features -- --deny warnings
|
||||||
cargo nextest run
|
cargo nextest run -E 'not test(export_bindings)'
|
||||||
bun run --cwd web check
|
bun run --cwd web check
|
||||||
|
|
||||||
# Run the Banner API search demo (hits live UTSA API, ~20s)
|
# Run the Banner API search demo (hits live UTSA API, ~20s)
|
||||||
|
|||||||
+3
-1
@@ -3,9 +3,11 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
/// Represents a meeting time stored as JSONB in the courses table.
|
/// 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 struct DbMeetingTime {
|
||||||
pub begin_time: Option<String>,
|
pub begin_time: Option<String>,
|
||||||
pub end_time: Option<String>,
|
pub end_time: Option<String>,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::data::models::ReferenceData;
|
use crate::data::models::ReferenceData;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use html_escape::decode_html_entities;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
/// Batch upsert reference data entries.
|
/// 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 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 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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -206,6 +206,19 @@ impl Scheduler {
|
|||||||
|
|
||||||
let mut all_entries = Vec::new();
|
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
|
// Subjects
|
||||||
match banner_api.get_subjects("", &term, 1, 500).await {
|
match banner_api.get_subjects("", &term, 1, 500).await {
|
||||||
Ok(pairs) => {
|
Ok(pairs) => {
|
||||||
|
|||||||
+3
-1
@@ -3,10 +3,12 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
/// Health status of a service.
|
/// Health status of a service.
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, PartialEq, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export)]
|
||||||
pub enum ServiceStatus {
|
pub enum ServiceStatus {
|
||||||
Starting,
|
Starting,
|
||||||
Active,
|
Active,
|
||||||
|
|||||||
+25
-18
@@ -18,6 +18,7 @@ use http::header;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::{collections::BTreeMap, time::Duration};
|
use std::{collections::BTreeMap, time::Duration};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::status::ServiceStatus;
|
use crate::status::ServiceStatus;
|
||||||
@@ -227,14 +228,16 @@ async fn health() -> Json<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
struct ServiceInfo {
|
#[ts(export)]
|
||||||
|
pub struct ServiceInfo {
|
||||||
name: String,
|
name: String,
|
||||||
status: ServiceStatus,
|
status: ServiceStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
struct StatusResponse {
|
#[ts(export)]
|
||||||
|
pub struct StatusResponse {
|
||||||
status: ServiceStatus,
|
status: ServiceStatus,
|
||||||
version: String,
|
version: String,
|
||||||
commit: String,
|
commit: String,
|
||||||
@@ -316,9 +319,10 @@ fn default_limit() -> i32 {
|
|||||||
25
|
25
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct CourseResponse {
|
#[ts(export)]
|
||||||
|
pub struct CourseResponse {
|
||||||
crn: String,
|
crn: String,
|
||||||
subject: String,
|
subject: String,
|
||||||
course_number: String,
|
course_number: String,
|
||||||
@@ -340,32 +344,35 @@ struct CourseResponse {
|
|||||||
link_identifier: Option<String>,
|
link_identifier: Option<String>,
|
||||||
is_section_linked: Option<bool>,
|
is_section_linked: Option<bool>,
|
||||||
part_of_term: Option<String>,
|
part_of_term: Option<String>,
|
||||||
meeting_times: Value,
|
meeting_times: Vec<crate::data::models::DbMeetingTime>,
|
||||||
attributes: Value,
|
attributes: Vec<String>,
|
||||||
instructors: Vec<InstructorResponse>,
|
instructors: Vec<InstructorResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct InstructorResponse {
|
#[ts(export)]
|
||||||
|
pub struct InstructorResponse {
|
||||||
banner_id: String,
|
banner_id: String,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
is_primary: bool,
|
is_primary: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct SearchResponse {
|
#[ts(export)]
|
||||||
|
pub struct SearchResponse {
|
||||||
courses: Vec<CourseResponse>,
|
courses: Vec<CourseResponse>,
|
||||||
total_count: i64,
|
total_count: i32,
|
||||||
offset: i32,
|
offset: i32,
|
||||||
limit: i32,
|
limit: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct CodeDescription {
|
#[ts(export)]
|
||||||
|
pub struct CodeDescription {
|
||||||
code: String,
|
code: String,
|
||||||
description: String,
|
description: String,
|
||||||
}
|
}
|
||||||
@@ -411,8 +418,8 @@ async fn build_course_response(
|
|||||||
link_identifier: course.link_identifier.clone(),
|
link_identifier: course.link_identifier.clone(),
|
||||||
is_section_linked: course.is_section_linked,
|
is_section_linked: course.is_section_linked,
|
||||||
part_of_term: course.part_of_term.clone(),
|
part_of_term: course.part_of_term.clone(),
|
||||||
meeting_times: course.meeting_times.clone(),
|
meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(),
|
||||||
attributes: course.attributes.clone(),
|
attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(),
|
||||||
instructors,
|
instructors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,7 +461,7 @@ async fn search_courses(
|
|||||||
|
|
||||||
Ok(Json(SearchResponse {
|
Ok(Json(SearchResponse {
|
||||||
courses: course_responses,
|
courses: course_responses,
|
||||||
total_count,
|
total_count: total_count as i32,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -12,6 +12,18 @@
|
|||||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo192.png" />
|
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo192.png" />
|
||||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||||
<title>Banner</title>
|
<title>Banner</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var stored = localStorage.getItem("theme");
|
||||||
|
var isDark =
|
||||||
|
stored === "dark" ||
|
||||||
|
(stored !== "light" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -61,4 +61,101 @@ describe("BannerApiClient", () => {
|
|||||||
"API request failed: 500 Internal Server Error"
|
"API request failed: 500 Internal Server Error"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should search courses with all params", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
courses: [],
|
||||||
|
totalCount: 0,
|
||||||
|
offset: 0,
|
||||||
|
limit: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await apiClient.searchCourses({
|
||||||
|
term: "202420",
|
||||||
|
subject: "CS",
|
||||||
|
q: "data",
|
||||||
|
open_only: true,
|
||||||
|
limit: 25,
|
||||||
|
offset: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/courses/search?term=202420&subject=CS&q=data&open_only=true&limit=25&offset=50"
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should search courses with minimal params", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
courses: [],
|
||||||
|
totalCount: 0,
|
||||||
|
offset: 0,
|
||||||
|
limit: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await apiClient.searchCourses({ term: "202420" });
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith("/api/courses/search?term=202420");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch terms", async () => {
|
||||||
|
const mockTerms = [
|
||||||
|
{ code: "202420", description: "Fall 2024" },
|
||||||
|
{ code: "202510", description: "Spring 2025" },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTerms),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await apiClient.getTerms();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith("/api/terms");
|
||||||
|
expect(result).toEqual(mockTerms);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch subjects for a term", async () => {
|
||||||
|
const mockSubjects = [
|
||||||
|
{ code: "CS", description: "Computer Science" },
|
||||||
|
{ code: "MAT", description: "Mathematics" },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockSubjects),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await apiClient.getSubjects("202420");
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith("/api/subjects?term=202420");
|
||||||
|
expect(result).toEqual(mockSubjects);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch reference data", async () => {
|
||||||
|
const mockRef = [
|
||||||
|
{ code: "F", description: "Face to Face" },
|
||||||
|
{ code: "OL", description: "Online" },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockRef),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await apiClient.getReference("instructional_methods");
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith("/api/reference/instructional_methods");
|
||||||
|
expect(result).toEqual(mockRef);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+68
-16
@@ -1,24 +1,41 @@
|
|||||||
|
import type {
|
||||||
|
CodeDescription,
|
||||||
|
CourseResponse,
|
||||||
|
DbMeetingTime,
|
||||||
|
InstructorResponse,
|
||||||
|
SearchResponse as SearchResponseGenerated,
|
||||||
|
ServiceInfo,
|
||||||
|
ServiceStatus,
|
||||||
|
StatusResponse,
|
||||||
|
} from "$lib/bindings";
|
||||||
|
|
||||||
const API_BASE_URL = "/api";
|
const API_BASE_URL = "/api";
|
||||||
|
|
||||||
|
// Re-export generated types under their canonical names
|
||||||
|
export type {
|
||||||
|
CodeDescription,
|
||||||
|
CourseResponse,
|
||||||
|
DbMeetingTime,
|
||||||
|
InstructorResponse,
|
||||||
|
ServiceInfo,
|
||||||
|
ServiceStatus,
|
||||||
|
StatusResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Semantic aliases — these all share the CodeDescription shape
|
||||||
|
export type Term = CodeDescription;
|
||||||
|
export type Subject = CodeDescription;
|
||||||
|
export type ReferenceEntry = CodeDescription;
|
||||||
|
|
||||||
|
// SearchResponse re-exported (aliased to strip the "Generated" suffix)
|
||||||
|
export type SearchResponse = SearchResponseGenerated;
|
||||||
|
|
||||||
|
// Health/metrics endpoints return ad-hoc JSON — keep manual types
|
||||||
export interface HealthResponse {
|
export interface HealthResponse {
|
||||||
status: string;
|
status: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Status = "starting" | "active" | "connected" | "disabled" | "error";
|
|
||||||
|
|
||||||
export interface ServiceInfo {
|
|
||||||
name: string;
|
|
||||||
status: Status;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatusResponse {
|
|
||||||
status: Status;
|
|
||||||
version: string;
|
|
||||||
commit: string;
|
|
||||||
services: Record<string, ServiceInfo>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MetricsResponse {
|
export interface MetricsResponse {
|
||||||
banner_api: {
|
banner_api: {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -26,15 +43,27 @@ export interface MetricsResponse {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client-side only — not generated from Rust
|
||||||
|
export interface SearchParams {
|
||||||
|
term: string;
|
||||||
|
subject?: string;
|
||||||
|
q?: string;
|
||||||
|
open_only?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class BannerApiClient {
|
export class BannerApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
private fetchFn: typeof fetch;
|
||||||
|
|
||||||
constructor(baseUrl: string = API_BASE_URL) {
|
constructor(baseUrl: string = API_BASE_URL, fetchFn: typeof fetch = fetch) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
this.fetchFn = fetchFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(endpoint: string): Promise<T> {
|
private async request<T>(endpoint: string): Promise<T> {
|
||||||
const response = await fetch(`${this.baseUrl}${endpoint}`);
|
const response = await this.fetchFn(`${this.baseUrl}${endpoint}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||||
@@ -54,6 +83,29 @@ export class BannerApiClient {
|
|||||||
async getMetrics(): Promise<MetricsResponse> {
|
async getMetrics(): Promise<MetricsResponse> {
|
||||||
return this.request<MetricsResponse>("/metrics");
|
return this.request<MetricsResponse>("/metrics");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchCourses(params: SearchParams): Promise<SearchResponse> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("term", params.term);
|
||||||
|
if (params.subject) query.set("subject", params.subject);
|
||||||
|
if (params.q) query.set("q", params.q);
|
||||||
|
if (params.open_only) query.set("open_only", "true");
|
||||||
|
if (params.limit !== undefined) query.set("limit", String(params.limit));
|
||||||
|
if (params.offset !== undefined) query.set("offset", String(params.offset));
|
||||||
|
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTerms(): Promise<Term[]> {
|
||||||
|
return this.request<Term[]>("/terms");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubjects(termCode: string): Promise<Subject[]> {
|
||||||
|
return this.request<Subject[]>(`/subjects?term=${encodeURIComponent(termCode)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReference(category: string): Promise<ReferenceEntry[]> {
|
||||||
|
return this.request<ReferenceEntry[]>(`/reference/${encodeURIComponent(category)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const client = new BannerApiClient();
|
export const client = new BannerApiClient();
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseResponse } from "$lib/api";
|
||||||
|
import {
|
||||||
|
formatTime,
|
||||||
|
formatMeetingDays,
|
||||||
|
formatCreditHours,
|
||||||
|
} from "$lib/course";
|
||||||
|
|
||||||
|
let { course }: { course: CourseResponse } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-muted p-4 text-sm border-b border-border">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<!-- Instructors -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-foreground mb-1">Instructors</h4>
|
||||||
|
{#if course.instructors.length > 0}
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{#each course.instructors as instructor}
|
||||||
|
<li class="text-muted-foreground">
|
||||||
|
{instructor.displayName}
|
||||||
|
{#if instructor.isPrimary}
|
||||||
|
<span class="text-xs bg-card border border-border rounded px-1 py-0.5 ml-1">primary</span>
|
||||||
|
{/if}
|
||||||
|
{#if instructor.email}
|
||||||
|
<span class="text-xs"> — {instructor.email}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">Staff</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meeting Times -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-foreground mb-1">Meeting Times</h4>
|
||||||
|
{#if course.meetingTimes.length > 0}
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each course.meetingTimes as mt}
|
||||||
|
<li class="text-muted-foreground">
|
||||||
|
<span class="font-mono">{formatMeetingDays(mt) || "TBA"}</span>
|
||||||
|
{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}
|
||||||
|
{#if mt.building || mt.room}
|
||||||
|
<span class="text-xs">
|
||||||
|
({mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<div class="text-xs opacity-70">{mt.start_date} – {mt.end_date}</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">TBA</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delivery -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-foreground mb-1">Delivery</h4>
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
{course.instructionalMethod ?? "—"}
|
||||||
|
{#if course.campus}
|
||||||
|
· {course.campus}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credits -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-foreground mb-1">Credits</h4>
|
||||||
|
<span class="text-muted-foreground">{formatCreditHours(course)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attributes -->
|
||||||
|
{#if course.attributes.length > 0}
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-foreground mb-1">Attributes</h4>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each course.attributes as attr}
|
||||||
|
<span class="text-xs bg-card border border-border rounded px-1.5 py-0.5 text-muted-foreground">
|
||||||
|
{attr}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Cross-list -->
|
||||||
|
{#if course.crossList}
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-foreground mb-1">Cross-list</h4>
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
{course.crossList}
|
||||||
|
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||||
|
({course.crossListCount}/{course.crossListCapacity})
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Waitlist -->
|
||||||
|
{#if course.waitCapacity > 0}
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-foreground mb-1">Waitlist</h4>
|
||||||
|
<span class="text-muted-foreground">{course.waitCount} / {course.waitCapacity}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CourseResponse } from "$lib/api";
|
||||||
|
import { abbreviateInstructor, formatMeetingTime, getPrimaryInstructor } from "$lib/course";
|
||||||
|
import CourseDetail from "./CourseDetail.svelte";
|
||||||
|
|
||||||
|
let { courses, loading }: { courses: CourseResponse[]; loading: boolean } = $props();
|
||||||
|
|
||||||
|
let expandedCrn: string | null = $state(null);
|
||||||
|
|
||||||
|
function toggleRow(crn: string) {
|
||||||
|
expandedCrn = expandedCrn === crn ? null : crn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seatsColor(course: CourseResponse): string {
|
||||||
|
return course.enrollment < course.maxEnrollment ? "text-status-green" : "text-status-red";
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryInstructorDisplay(course: CourseResponse): string {
|
||||||
|
const primary = getPrimaryInstructor(course.instructors);
|
||||||
|
if (!primary) return "Staff";
|
||||||
|
return abbreviateInstructor(primary.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeDisplay(course: CourseResponse): string {
|
||||||
|
if (course.meetingTimes.length === 0) return "TBA";
|
||||||
|
return formatMeetingTime(course.meetingTimes[0]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border text-left text-muted-foreground">
|
||||||
|
<th class="py-2 px-2 font-medium">CRN</th>
|
||||||
|
<th class="py-2 px-2 font-medium">Course</th>
|
||||||
|
<th class="py-2 px-2 font-medium">Title</th>
|
||||||
|
<th class="py-2 px-2 font-medium">Instructor</th>
|
||||||
|
<th class="py-2 px-2 font-medium">Time</th>
|
||||||
|
<th class="py-2 px-2 font-medium text-right">Seats</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if loading && courses.length === 0}
|
||||||
|
{#each Array(5) as _}
|
||||||
|
<tr class="border-b border-border">
|
||||||
|
<td class="py-2.5 px-2"><div class="h-4 w-12 bg-muted rounded animate-pulse"></div></td>
|
||||||
|
<td class="py-2.5 px-2"><div class="h-4 w-24 bg-muted rounded animate-pulse"></div></td>
|
||||||
|
<td class="py-2.5 px-2"><div class="h-4 w-40 bg-muted rounded animate-pulse"></div></td>
|
||||||
|
<td class="py-2.5 px-2"><div class="h-4 w-20 bg-muted rounded animate-pulse"></div></td>
|
||||||
|
<td class="py-2.5 px-2"><div class="h-4 w-28 bg-muted rounded animate-pulse"></div></td>
|
||||||
|
<td class="py-2.5 px-2"><div class="h-4 w-12 bg-muted rounded animate-pulse ml-auto"></div></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{:else if courses.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="py-12 text-center text-muted-foreground">
|
||||||
|
No courses found. Try adjusting your filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each courses as course (course.crn)}
|
||||||
|
<tr
|
||||||
|
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
|
||||||
|
onclick={() => toggleRow(course.crn)}
|
||||||
|
>
|
||||||
|
<td class="py-2 px-2 font-mono">{course.crn}</td>
|
||||||
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
|
{course.subject} {course.courseNumber}-{course.sequenceNumber ?? ""}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">{course.title}</td>
|
||||||
|
<td class="py-2 px-2 whitespace-nowrap">{primaryInstructorDisplay(course)}</td>
|
||||||
|
<td class="py-2 px-2 whitespace-nowrap">{timeDisplay(course)}</td>
|
||||||
|
<td class="py-2 px-2 text-right whitespace-nowrap {seatsColor(course)}">
|
||||||
|
{course.enrollment}/{course.maxEnrollment}
|
||||||
|
{#if course.waitCount > 0}
|
||||||
|
<div class="text-xs text-muted-foreground">WL: {course.waitCount}/{course.waitCapacity}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{#if expandedCrn === course.crn}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="p-0">
|
||||||
|
<CourseDetail {course} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { totalCount, offset, limit, onPageChange }: {
|
||||||
|
totalCount: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
onPageChange: (newOffset: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const start = $derived(offset + 1);
|
||||||
|
const end = $derived(Math.min(offset + limit, totalCount));
|
||||||
|
const hasPrev = $derived(offset > 0);
|
||||||
|
const hasNext = $derived(offset + limit < totalCount);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalCount > 0}
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
Showing {start}–{end} of {totalCount} courses
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onclick={() => onPageChange(offset - limit)}
|
||||||
|
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!hasNext}
|
||||||
|
onclick={() => onPageChange(offset + limit)}
|
||||||
|
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Term, Subject } from "$lib/api";
|
||||||
|
|
||||||
|
let {
|
||||||
|
terms,
|
||||||
|
subjects,
|
||||||
|
selectedTerm = $bindable(),
|
||||||
|
selectedSubject = $bindable(),
|
||||||
|
query = $bindable(),
|
||||||
|
openOnly = $bindable(),
|
||||||
|
}: {
|
||||||
|
terms: Term[];
|
||||||
|
subjects: Subject[];
|
||||||
|
selectedTerm: string;
|
||||||
|
selectedSubject: string;
|
||||||
|
query: string;
|
||||||
|
openOnly: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 items-center">
|
||||||
|
<select
|
||||||
|
bind:value={selectedTerm}
|
||||||
|
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{#each terms as term (term.code)}
|
||||||
|
<option value={term.code}>{term.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
bind:value={selectedSubject}
|
||||||
|
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All Subjects</option>
|
||||||
|
{#each subjects as subject (subject.code)}
|
||||||
|
<option value={subject.code}>{subject.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search courses..."
|
||||||
|
bind:value={query}
|
||||||
|
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm flex-1 min-w-[200px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={openOnly} />
|
||||||
|
Open only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
@@ -1,53 +1,66 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { tick } from "svelte";
|
||||||
import { Monitor, Moon, Sun } from "@lucide/svelte";
|
import { Moon, Sun } from "@lucide/svelte";
|
||||||
|
import { themeStore } from "$lib/stores/theme.svelte";
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "system";
|
/**
|
||||||
|
* Theme toggle with View Transitions API circular reveal animation.
|
||||||
|
* The clip-path circle expands from the click point to cover the viewport.
|
||||||
|
*/
|
||||||
|
async function handleToggle(event: MouseEvent) {
|
||||||
|
const supportsViewTransition =
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
"startViewTransition" in document &&
|
||||||
|
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
let theme = $state<Theme>("system");
|
if (!supportsViewTransition) {
|
||||||
|
themeStore.toggle();
|
||||||
if (browser) {
|
return;
|
||||||
theme = (localStorage.getItem("theme") as Theme) ?? "system";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextTheme = $derived<Theme>(
|
const x = event.clientX;
|
||||||
theme === "light" ? "dark" : theme === "dark" ? "system" : "light"
|
const y = event.clientY;
|
||||||
|
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
|
||||||
|
|
||||||
|
const transition = document.startViewTransition(async () => {
|
||||||
|
themeStore.toggle();
|
||||||
|
await tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
transition.ready.then(() => {
|
||||||
|
document.documentElement.animate(
|
||||||
|
{
|
||||||
|
clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
duration: 500,
|
||||||
|
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
|
pseudoElement: "::view-transition-new(root)",
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
});
|
||||||
function applyTheme(t: Theme) {
|
|
||||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
const isDark = t === "dark" || (t === "system" && prefersDark);
|
|
||||||
document.documentElement.classList.toggle("dark", isDark);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
const next = nextTheme;
|
|
||||||
|
|
||||||
const update = () => {
|
|
||||||
theme = next;
|
|
||||||
localStorage.setItem("theme", next);
|
|
||||||
applyTheme(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.startViewTransition) {
|
|
||||||
document.startViewTransition(update);
|
|
||||||
} else {
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={toggle}
|
type="button"
|
||||||
|
onclick={(e) => handleToggle(e)}
|
||||||
|
aria-label={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
class="cursor-pointer border-none rounded-md flex items-center justify-center p-2 scale-125
|
class="cursor-pointer border-none rounded-md flex items-center justify-center p-2 scale-125
|
||||||
text-muted-foreground hover:bg-muted bg-transparent transition-colors"
|
text-muted-foreground hover:bg-muted bg-transparent transition-colors"
|
||||||
aria-label="Toggle theme"
|
|
||||||
>
|
>
|
||||||
{#if nextTheme === "dark"}
|
<div class="relative size-[18px]">
|
||||||
<Moon size={18} />
|
<Sun
|
||||||
{:else if nextTheme === "system"}
|
size={18}
|
||||||
<Monitor size={18} />
|
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
|
||||||
{:else}
|
? 'rotate-90 scale-0 opacity-0'
|
||||||
<Sun size={18} />
|
: 'rotate-0 scale-100 opacity-100'}"
|
||||||
{/if}
|
/>
|
||||||
|
<Moon
|
||||||
|
size={18}
|
||||||
|
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
|
||||||
|
? 'rotate-0 scale-100 opacity-100'
|
||||||
|
: '-rotate-90 scale-0 opacity-0'}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
formatTime,
|
||||||
|
formatMeetingDays,
|
||||||
|
formatMeetingTime,
|
||||||
|
abbreviateInstructor,
|
||||||
|
formatCreditHours,
|
||||||
|
getPrimaryInstructor,
|
||||||
|
} from "$lib/course";
|
||||||
|
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
|
||||||
|
|
||||||
|
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
|
||||||
|
return {
|
||||||
|
begin_time: null,
|
||||||
|
end_time: null,
|
||||||
|
start_date: "2024-08-26",
|
||||||
|
end_date: "2024-12-12",
|
||||||
|
monday: false,
|
||||||
|
tuesday: false,
|
||||||
|
wednesday: false,
|
||||||
|
thursday: false,
|
||||||
|
friday: false,
|
||||||
|
saturday: false,
|
||||||
|
sunday: false,
|
||||||
|
building: null,
|
||||||
|
building_description: null,
|
||||||
|
room: null,
|
||||||
|
campus: null,
|
||||||
|
meeting_type: "CLAS",
|
||||||
|
meeting_schedule_type: "LEC",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatTime", () => {
|
||||||
|
it("converts 0900 to 9:00 AM", () => expect(formatTime("0900")).toBe("9:00 AM"));
|
||||||
|
it("converts 1330 to 1:30 PM", () => expect(formatTime("1330")).toBe("1:30 PM"));
|
||||||
|
it("converts 0000 to 12:00 AM", () => expect(formatTime("0000")).toBe("12:00 AM"));
|
||||||
|
it("converts 1200 to 12:00 PM", () => expect(formatTime("1200")).toBe("12:00 PM"));
|
||||||
|
it("converts 2359 to 11:59 PM", () => expect(formatTime("2359")).toBe("11:59 PM"));
|
||||||
|
it("returns TBA for null", () => expect(formatTime(null)).toBe("TBA"));
|
||||||
|
it("returns TBA for empty string", () => expect(formatTime("")).toBe("TBA"));
|
||||||
|
it("returns TBA for short string", () => expect(formatTime("09")).toBe("TBA"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatMeetingDays", () => {
|
||||||
|
it("returns MWF for mon/wed/fri", () => {
|
||||||
|
expect(formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true }))).toBe(
|
||||||
|
"MWF"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("returns TR for tue/thu", () => {
|
||||||
|
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TR");
|
||||||
|
});
|
||||||
|
it("returns empty string when no days", () => {
|
||||||
|
expect(formatMeetingDays(makeMeetingTime())).toBe("");
|
||||||
|
});
|
||||||
|
it("returns all days", () => {
|
||||||
|
expect(
|
||||||
|
formatMeetingDays(
|
||||||
|
makeMeetingTime({
|
||||||
|
monday: true,
|
||||||
|
tuesday: true,
|
||||||
|
wednesday: true,
|
||||||
|
thursday: true,
|
||||||
|
friday: true,
|
||||||
|
saturday: true,
|
||||||
|
sunday: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("MTWRFSU");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatMeetingTime", () => {
|
||||||
|
it("formats a standard meeting time", () => {
|
||||||
|
expect(
|
||||||
|
formatMeetingTime(
|
||||||
|
makeMeetingTime({ monday: true, wednesday: true, friday: true, begin_time: "0900", end_time: "0950" })
|
||||||
|
)
|
||||||
|
).toBe("MWF 9:00 AM–9:50 AM");
|
||||||
|
});
|
||||||
|
it("returns TBA when no days", () => {
|
||||||
|
expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe("TBA");
|
||||||
|
});
|
||||||
|
it("returns days + TBA when no times", () => {
|
||||||
|
expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("M TBA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("abbreviateInstructor", () => {
|
||||||
|
it("abbreviates standard name", () => expect(abbreviateInstructor("Heaps, John")).toBe("Heaps, J."));
|
||||||
|
it("handles no comma", () => expect(abbreviateInstructor("Staff")).toBe("Staff"));
|
||||||
|
it("handles multiple first names", () =>
|
||||||
|
expect(abbreviateInstructor("Smith, Mary Jane")).toBe("Smith, M."));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPrimaryInstructor", () => {
|
||||||
|
it("returns primary instructor", () => {
|
||||||
|
const instructors: InstructorResponse[] = [
|
||||||
|
{ bannerId: "1", displayName: "A", email: null, isPrimary: false },
|
||||||
|
{ bannerId: "2", displayName: "B", email: null, isPrimary: true },
|
||||||
|
];
|
||||||
|
expect(getPrimaryInstructor(instructors)?.displayName).toBe("B");
|
||||||
|
});
|
||||||
|
it("returns first instructor when no primary", () => {
|
||||||
|
const instructors: InstructorResponse[] = [
|
||||||
|
{ bannerId: "1", displayName: "A", email: null, isPrimary: false },
|
||||||
|
];
|
||||||
|
expect(getPrimaryInstructor(instructors)?.displayName).toBe("A");
|
||||||
|
});
|
||||||
|
it("returns undefined for empty array", () => {
|
||||||
|
expect(getPrimaryInstructor([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatCreditHours", () => {
|
||||||
|
it("returns creditHours when set", () => {
|
||||||
|
expect(
|
||||||
|
formatCreditHours({ creditHours: 3, creditHourLow: null, creditHourHigh: null } as CourseResponse)
|
||||||
|
).toBe("3");
|
||||||
|
});
|
||||||
|
it("returns range when variable", () => {
|
||||||
|
expect(
|
||||||
|
formatCreditHours({ creditHours: null, creditHourLow: 1, creditHourHigh: 3 } as CourseResponse)
|
||||||
|
).toBe("1–3");
|
||||||
|
});
|
||||||
|
it("returns dash when no credit info", () => {
|
||||||
|
expect(
|
||||||
|
formatCreditHours({ creditHours: null, creditHourLow: null, creditHourHigh: null } as CourseResponse)
|
||||||
|
).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
|
||||||
|
|
||||||
|
/** Convert "0900" to "9:00 AM" */
|
||||||
|
export function formatTime(time: string | null): string {
|
||||||
|
if (!time || time.length !== 4) return "TBA";
|
||||||
|
const hours = parseInt(time.slice(0, 2), 10);
|
||||||
|
const minutes = time.slice(2);
|
||||||
|
const period = hours >= 12 ? "PM" : "AM";
|
||||||
|
const display = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours;
|
||||||
|
return `${display}:${minutes} ${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get day abbreviation string like "MWF" from a meeting time */
|
||||||
|
export function formatMeetingDays(mt: DbMeetingTime): string {
|
||||||
|
const days: [boolean, string][] = [
|
||||||
|
[mt.monday, "M"],
|
||||||
|
[mt.tuesday, "T"],
|
||||||
|
[mt.wednesday, "W"],
|
||||||
|
[mt.thursday, "R"],
|
||||||
|
[mt.friday, "F"],
|
||||||
|
[mt.saturday, "S"],
|
||||||
|
[mt.sunday, "U"],
|
||||||
|
];
|
||||||
|
return days
|
||||||
|
.filter(([active]) => active)
|
||||||
|
.map(([, abbr]) => abbr)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Condensed meeting time: "MWF 9:00 AM–9:50 AM" */
|
||||||
|
export function formatMeetingTime(mt: DbMeetingTime): string {
|
||||||
|
const days = formatMeetingDays(mt);
|
||||||
|
if (!days) return "TBA";
|
||||||
|
const begin = formatTime(mt.begin_time);
|
||||||
|
const end = formatTime(mt.end_time);
|
||||||
|
if (begin === "TBA") return `${days} TBA`;
|
||||||
|
return `${days} ${begin}–${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abbreviate instructor name: "Heaps, John" → "Heaps, J." */
|
||||||
|
export function abbreviateInstructor(name: string): string {
|
||||||
|
const commaIdx = name.indexOf(", ");
|
||||||
|
if (commaIdx === -1) return name;
|
||||||
|
const last = name.slice(0, commaIdx);
|
||||||
|
const first = name.slice(commaIdx + 2);
|
||||||
|
return `${last}, ${first.charAt(0)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get primary instructor from a course, or first instructor */
|
||||||
|
export function getPrimaryInstructor(instructors: InstructorResponse[]): InstructorResponse | undefined {
|
||||||
|
return instructors.find((i) => i.isPrimary) ?? instructors[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format credit hours display */
|
||||||
|
export function formatCreditHours(course: CourseResponse): string {
|
||||||
|
if (course.creditHours != null) return String(course.creditHours);
|
||||||
|
if (course.creditHourLow != null && course.creditHourHigh != null) {
|
||||||
|
return `${course.creditHourLow}–${course.creditHourHigh}`;
|
||||||
|
}
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
class ThemeStore {
|
||||||
|
isDark = $state<boolean>(false);
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.initialized || typeof window === "undefined") return;
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
|
if (stored === "light" || stored === "dark") {
|
||||||
|
this.isDark = stored === "dark";
|
||||||
|
} else {
|
||||||
|
this.isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateDOMClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.isDark = !this.isDark;
|
||||||
|
localStorage.setItem("theme", this.isDark ? "dark" : "light");
|
||||||
|
this.updateDOMClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDOMClass() {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
if (this.isDark) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeStore = new ThemeStore();
|
||||||
@@ -1,22 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
|
import { onMount } from "svelte";
|
||||||
import { Tooltip } from "bits-ui";
|
import { Tooltip } from "bits-ui";
|
||||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
import { themeStore } from "$lib/stores/theme.svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
onMount(() => {
|
||||||
{@html `<script>
|
themeStore.init();
|
||||||
(function() {
|
});
|
||||||
const stored = localStorage.getItem("theme");
|
</script>
|
||||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
if (stored === "dark" || (!stored && prefersDark) || (stored === "system" && prefersDark)) {
|
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>`}
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<div class="fixed top-5 right-5 z-50">
|
<div class="fixed top-5 right-5 z-50">
|
||||||
|
|||||||
+131
-289
@@ -1,327 +1,169 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import {
|
import { goto } from "$app/navigation";
|
||||||
Activity,
|
import { type Subject, type SearchResponse, client } from "$lib/api";
|
||||||
Bot,
|
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
||||||
CheckCircle,
|
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||||
Circle,
|
import Pagination from "$lib/components/Pagination.svelte";
|
||||||
Clock,
|
|
||||||
Globe,
|
|
||||||
Hourglass,
|
|
||||||
MessageCircle,
|
|
||||||
WifiOff,
|
|
||||||
XCircle,
|
|
||||||
} from "@lucide/svelte";
|
|
||||||
import { Tooltip } from "bits-ui";
|
|
||||||
import { type Status, type ServiceInfo, type StatusResponse, client } from "$lib/api";
|
|
||||||
import { relativeTime } from "$lib/time";
|
|
||||||
|
|
||||||
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
|
let { data } = $props();
|
||||||
const REQUEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
const SERVICE_ICONS: Record<string, typeof Bot> = {
|
// Read initial state from URL params (intentionally captured once)
|
||||||
bot: Bot,
|
const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
||||||
banner: Globe,
|
|
||||||
discord: MessageCircle,
|
|
||||||
database: Activity,
|
|
||||||
web: Globe,
|
|
||||||
scraper: Clock,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ResponseTiming {
|
// Filter state
|
||||||
health: number | null;
|
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
||||||
status: number | null;
|
let selectedSubject = $state(initialParams.get("subject") ?? "");
|
||||||
|
let query = $state(initialParams.get("q") ?? "");
|
||||||
|
let openOnly = $state(initialParams.get("open") === "true");
|
||||||
|
let offset = $state(Number(initialParams.get("offset")) || 0);
|
||||||
|
const limit = 25;
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
let subjects: Subject[] = $state([]);
|
||||||
|
let searchResult: SearchResponse | null = $state(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch subjects when term changes
|
||||||
|
$effect(() => {
|
||||||
|
const term = selectedTerm;
|
||||||
|
if (!term) return;
|
||||||
|
client.getSubjects(term).then((s) => {
|
||||||
|
subjects = s;
|
||||||
|
if (selectedSubject && !s.some((sub) => sub.code === selectedSubject)) {
|
||||||
|
selectedSubject = "";
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
interface Service {
|
// Debounced search
|
||||||
name: string;
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
status: Status;
|
$effect(() => {
|
||||||
icon: typeof Bot;
|
const term = selectedTerm;
|
||||||
|
const subject = selectedSubject;
|
||||||
|
const q = query;
|
||||||
|
const open = openOnly;
|
||||||
|
const off = offset;
|
||||||
|
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
performSearch(term, subject, q, open, off);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(searchTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset offset when filters change (not offset itself)
|
||||||
|
let prevFilters = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
|
||||||
|
if (prevFilters && key !== prevFilters) {
|
||||||
|
offset = 0;
|
||||||
}
|
}
|
||||||
|
prevFilters = key;
|
||||||
|
});
|
||||||
|
|
||||||
type StatusState =
|
async function performSearch(
|
||||||
| { mode: "loading" }
|
term: string,
|
||||||
| { mode: "response"; timing: ResponseTiming; lastFetch: Date; status: StatusResponse }
|
subject: string,
|
||||||
| { mode: "error"; lastFetch: Date }
|
q: string,
|
||||||
| { mode: "timeout"; lastFetch: Date };
|
open: boolean,
|
||||||
|
off: number,
|
||||||
|
) {
|
||||||
|
if (!term) return;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
const STATUS_ICONS: Record<Status | "Unreachable", { icon: typeof CheckCircle; color: string }> = {
|
// Sync URL
|
||||||
active: { icon: CheckCircle, color: "var(--status-green)" },
|
const params = new URLSearchParams();
|
||||||
connected: { icon: CheckCircle, color: "var(--status-green)" },
|
params.set("term", term);
|
||||||
starting: { icon: Hourglass, color: "var(--status-orange)" },
|
if (subject) params.set("subject", subject);
|
||||||
disabled: { icon: Circle, color: "var(--status-gray)" },
|
if (q) params.set("q", q);
|
||||||
error: { icon: XCircle, color: "var(--status-red)" },
|
if (open) params.set("open", "true");
|
||||||
Unreachable: { icon: WifiOff, color: "var(--status-red)" },
|
if (off > 0) params.set("offset", String(off));
|
||||||
};
|
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
|
|
||||||
let statusState = $state({ mode: "loading" } as StatusState);
|
|
||||||
let now = $state(new Date());
|
|
||||||
|
|
||||||
const isLoading = $derived(statusState.mode === "loading");
|
|
||||||
const hasResponse = $derived(statusState.mode === "response");
|
|
||||||
const shouldShowSkeleton = $derived(statusState.mode === "loading" || statusState.mode === "error");
|
|
||||||
|
|
||||||
const overallHealth: Status | "Unreachable" = $derived(
|
|
||||||
statusState.mode === "timeout"
|
|
||||||
? "Unreachable"
|
|
||||||
: statusState.mode === "error"
|
|
||||||
? "error"
|
|
||||||
: statusState.mode === "response"
|
|
||||||
? statusState.status.status
|
|
||||||
: "error"
|
|
||||||
);
|
|
||||||
|
|
||||||
const overallIcon = $derived(STATUS_ICONS[overallHealth]);
|
|
||||||
|
|
||||||
const services: Service[] = $derived(
|
|
||||||
statusState.mode === "response"
|
|
||||||
? (Object.entries(statusState.status.services) as [string, ServiceInfo][]).map(
|
|
||||||
([id, info]) => ({
|
|
||||||
name: info.name,
|
|
||||||
status: info.status,
|
|
||||||
icon: SERVICE_ICONS[id] ?? Bot,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldShowTiming = $derived(
|
|
||||||
statusState.mode === "response" && statusState.timing.health !== null
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldShowLastFetch = $derived(
|
|
||||||
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
|
|
||||||
);
|
|
||||||
|
|
||||||
const lastFetch = $derived(
|
|
||||||
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
|
|
||||||
? statusState.lastFetch
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const relativeLastFetchResult = $derived(lastFetch ? relativeTime(lastFetch, now) : null);
|
|
||||||
const relativeLastFetch = $derived(relativeLastFetchResult?.text ?? "");
|
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
|
||||||
return num.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let requestTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let nowTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
// Adaptive tick: schedules the next `now` update based on when the
|
|
||||||
// relative time text would actually change (every ~1s for recent
|
|
||||||
// timestamps, every ~1m for minute-level, etc.)
|
|
||||||
function scheduleNowTick() {
|
|
||||||
const delay = relativeLastFetchResult?.nextUpdateMs ?? 1000;
|
|
||||||
nowTimeoutId = setTimeout(() => {
|
|
||||||
now = new Date();
|
|
||||||
scheduleNowTick();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
scheduleNowTick();
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
searchResult = await client.searchCourses({
|
||||||
|
term,
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
subject: subject || undefined,
|
||||||
requestTimeoutId = setTimeout(() => {
|
q: q || undefined,
|
||||||
reject(new Error("Request timeout"));
|
open_only: open || undefined,
|
||||||
}, REQUEST_TIMEOUT);
|
limit,
|
||||||
|
offset: off,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
const statusData = await Promise.race([client.getStatus(), timeoutPromise]);
|
error = e instanceof Error ? e.message : "Search failed";
|
||||||
|
} finally {
|
||||||
if (requestTimeoutId) {
|
loading = false;
|
||||||
clearTimeout(requestTimeoutId);
|
|
||||||
requestTimeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
|
||||||
|
|
||||||
statusState = {
|
|
||||||
mode: "response",
|
|
||||||
status: statusData,
|
|
||||||
timing: { health: responseTime, status: responseTime },
|
|
||||||
lastFetch: new Date(),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
if (requestTimeoutId) {
|
|
||||||
clearTimeout(requestTimeoutId);
|
|
||||||
requestTimeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = err instanceof Error ? err.message : "";
|
|
||||||
|
|
||||||
if (message === "Request timeout") {
|
|
||||||
statusState = { mode: "timeout", lastFetch: new Date() };
|
|
||||||
} else {
|
|
||||||
statusState = { mode: "error", lastFetch: new Date() };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeoutId = setTimeout(() => void fetchData(), REFRESH_INTERVAL);
|
function handlePageChange(newOffset: number) {
|
||||||
};
|
offset = newOffset;
|
||||||
|
}
|
||||||
void fetchData();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
if (requestTimeoutId) clearTimeout(requestTimeoutId);
|
|
||||||
if (nowTimeoutId) clearTimeout(nowTimeoutId);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex flex-col items-center justify-center p-5">
|
<div class="min-h-screen flex flex-col items-center p-5">
|
||||||
<div
|
<div class="w-full max-w-4xl flex flex-col gap-6">
|
||||||
class="bg-card text-card-foreground rounded-xl border border-border p-6 w-full max-w-[400px] shadow-sm"
|
<!-- Title -->
|
||||||
>
|
<div class="text-center pt-8 pb-2">
|
||||||
<div class="flex flex-col gap-4">
|
<h1 class="text-2xl font-semibold text-foreground">UTSA Course Search</h1>
|
||||||
<!-- Overall Status -->
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- Filters -->
|
||||||
<Activity
|
<SearchFilters
|
||||||
size={18}
|
terms={data.terms}
|
||||||
color={isLoading ? undefined : overallIcon.color}
|
{subjects}
|
||||||
class={isLoading ? "animate-pulse" : ""}
|
bind:selectedTerm
|
||||||
style="opacity: {isLoading ? 0.3 : 1}; transition: opacity 2s ease-in-out, color 2s ease-in-out;"
|
bind:selectedSubject
|
||||||
|
bind:query
|
||||||
|
bind:openOnly
|
||||||
/>
|
/>
|
||||||
<span class="text-base font-medium text-foreground">System Status</span>
|
|
||||||
|
<!-- Results -->
|
||||||
|
{#if error}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<p class="text-status-red">{error}</p>
|
||||||
|
<button
|
||||||
|
onclick={() => performSearch(selectedTerm, selectedSubject, query, openOnly, offset)}
|
||||||
|
class="mt-2 text-sm text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if isLoading}
|
|
||||||
<div class="h-5 w-20 bg-muted rounded animate-pulse"></div>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#if overallIcon}
|
<CourseTable courses={searchResult?.courses ?? []} {loading} />
|
||||||
{@const OverallIconComponent = overallIcon.icon}
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
class="text-sm"
|
|
||||||
class:text-muted-foreground={overallHealth === "disabled"}
|
|
||||||
class:opacity-70={overallHealth === "disabled"}
|
|
||||||
>
|
|
||||||
{overallHealth}
|
|
||||||
</span>
|
|
||||||
<OverallIconComponent size={16} color={overallIcon.color} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Services -->
|
{#if searchResult}
|
||||||
<div class="flex flex-col gap-3 mt-4">
|
<Pagination
|
||||||
{#if shouldShowSkeleton}
|
totalCount={searchResult.totalCount}
|
||||||
{#each Array(3) as _}
|
offset={searchResult.offset}
|
||||||
<div class="flex items-center justify-between">
|
{limit}
|
||||||
<div class="flex items-center gap-2">
|
onPageChange={handlePageChange}
|
||||||
<div class="h-6 w-[18px] bg-muted rounded animate-pulse"></div>
|
/>
|
||||||
<div class="h-6 w-[60px] bg-muted rounded animate-pulse"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="h-5 w-[50px] bg-muted rounded animate-pulse"></div>
|
|
||||||
<div class="h-5 w-4 bg-muted rounded animate-pulse"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each services as service (service.name)}
|
|
||||||
{@const statusInfo = STATUS_ICONS[service.status]}
|
|
||||||
{@const ServiceIcon = service.icon}
|
|
||||||
{@const StatusIconComponent = statusInfo.icon}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ServiceIcon size={18} />
|
|
||||||
<span class="text-muted-foreground">{service.name}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
class="text-sm"
|
|
||||||
class:text-muted-foreground={service.status === "disabled"}
|
|
||||||
class:opacity-70={service.status === "disabled"}
|
|
||||||
>
|
|
||||||
{service.status}
|
|
||||||
</span>
|
|
||||||
<StatusIconComponent size={16} color={statusInfo.color} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timing & Last Updated -->
|
|
||||||
<div class="flex flex-col gap-2 mt-4 pt-4 border-t border-border">
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Hourglass size={13} />
|
|
||||||
<span class="text-sm text-muted-foreground">Response Time</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-[18px] w-[50px] bg-muted rounded animate-pulse"></div>
|
|
||||||
</div>
|
|
||||||
{:else if shouldShowTiming && statusState.mode === "response"}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Hourglass size={13} />
|
|
||||||
<span class="text-sm text-muted-foreground">Response Time</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-muted-foreground">
|
|
||||||
{formatNumber(statusState.timing.health!)}ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Clock size={13} />
|
|
||||||
<span class="text-sm text-muted-foreground">Last Updated</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-muted-foreground pb-0.5">Loading...</span>
|
|
||||||
</div>
|
|
||||||
{:else if shouldShowLastFetch && lastFetch}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Clock size={13} />
|
|
||||||
<span class="text-sm text-muted-foreground">Last Updated</span>
|
|
||||||
</div>
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger>
|
|
||||||
<abbr
|
|
||||||
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
|
|
||||||
>
|
|
||||||
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
|
|
||||||
</abbr>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content
|
|
||||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
|
|
||||||
>
|
|
||||||
as of {lastFetch.toLocaleTimeString()}
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="flex justify-center items-center gap-2 mt-3">
|
<div class="flex justify-center items-center gap-2 mt-auto pt-6 pb-4">
|
||||||
{#if __APP_VERSION__}
|
{#if __APP_VERSION__}
|
||||||
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
|
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
|
||||||
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
|
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<a
|
<a
|
||||||
href={hasResponse && statusState.mode === "response" && statusState.status.commit
|
href="https://github.com/Xevion/banner"
|
||||||
? `https://github.com/Xevion/banner/commit/${statusState.status.commit}`
|
|
||||||
: "https://github.com/Xevion/banner"}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-xs text-muted-foreground no-underline hover:underline"
|
class="text-xs text-muted-foreground no-underline hover:underline"
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
|
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
|
||||||
|
<a href="/health" class="text-xs text-muted-foreground no-underline hover:underline">
|
||||||
|
Status
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
import { BannerApiClient } from "$lib/api";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ url, fetch }) => {
|
||||||
|
const client = new BannerApiClient(undefined, fetch);
|
||||||
|
const terms = await client.getTerms();
|
||||||
|
return { terms, url };
|
||||||
|
};
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Bot,
|
||||||
|
CheckCircle,
|
||||||
|
Circle,
|
||||||
|
Clock,
|
||||||
|
Globe,
|
||||||
|
Hourglass,
|
||||||
|
MessageCircle,
|
||||||
|
WifiOff,
|
||||||
|
XCircle,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import { Tooltip } from "bits-ui";
|
||||||
|
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
|
||||||
|
import { relativeTime } from "$lib/time";
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
|
||||||
|
const REQUEST_TIMEOUT = 10000;
|
||||||
|
|
||||||
|
const SERVICE_ICONS: Record<string, typeof Bot> = {
|
||||||
|
bot: Bot,
|
||||||
|
banner: Globe,
|
||||||
|
discord: MessageCircle,
|
||||||
|
database: Activity,
|
||||||
|
web: Globe,
|
||||||
|
scraper: Clock,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResponseTiming {
|
||||||
|
health: number | null;
|
||||||
|
status: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Service {
|
||||||
|
name: string;
|
||||||
|
status: ServiceStatus;
|
||||||
|
icon: typeof Bot;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusState =
|
||||||
|
| { mode: "loading" }
|
||||||
|
| { mode: "response"; timing: ResponseTiming; lastFetch: Date; status: StatusResponse }
|
||||||
|
| { mode: "error"; lastFetch: Date }
|
||||||
|
| { mode: "timeout"; lastFetch: Date };
|
||||||
|
|
||||||
|
const STATUS_ICONS: Record<ServiceStatus | "Unreachable", { icon: typeof CheckCircle; color: string }> = {
|
||||||
|
active: { icon: CheckCircle, color: "var(--status-green)" },
|
||||||
|
connected: { icon: CheckCircle, color: "var(--status-green)" },
|
||||||
|
starting: { icon: Hourglass, color: "var(--status-orange)" },
|
||||||
|
disabled: { icon: Circle, color: "var(--status-gray)" },
|
||||||
|
error: { icon: XCircle, color: "var(--status-red)" },
|
||||||
|
Unreachable: { icon: WifiOff, color: "var(--status-red)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
let statusState = $state({ mode: "loading" } as StatusState);
|
||||||
|
let now = $state(new Date());
|
||||||
|
|
||||||
|
const isLoading = $derived(statusState.mode === "loading");
|
||||||
|
const hasResponse = $derived(statusState.mode === "response");
|
||||||
|
const shouldShowSkeleton = $derived(statusState.mode === "loading" || statusState.mode === "error");
|
||||||
|
|
||||||
|
const overallHealth: ServiceStatus | "Unreachable" = $derived(
|
||||||
|
statusState.mode === "timeout"
|
||||||
|
? "Unreachable"
|
||||||
|
: statusState.mode === "error"
|
||||||
|
? "error"
|
||||||
|
: statusState.mode === "response"
|
||||||
|
? statusState.status.status
|
||||||
|
: "error"
|
||||||
|
);
|
||||||
|
|
||||||
|
const overallIcon = $derived(STATUS_ICONS[overallHealth]);
|
||||||
|
|
||||||
|
const services: Service[] = $derived(
|
||||||
|
statusState.mode === "response"
|
||||||
|
? (Object.entries(statusState.status.services) as [string, ServiceInfo][]).map(
|
||||||
|
([id, info]) => ({
|
||||||
|
name: info.name,
|
||||||
|
status: info.status,
|
||||||
|
icon: SERVICE_ICONS[id] ?? Bot,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldShowTiming = $derived(
|
||||||
|
statusState.mode === "response" && statusState.timing.health !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldShowLastFetch = $derived(
|
||||||
|
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastFetch = $derived(
|
||||||
|
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
|
||||||
|
? statusState.lastFetch
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const relativeLastFetchResult = $derived(lastFetch ? relativeTime(lastFetch, now) : null);
|
||||||
|
const relativeLastFetch = $derived(relativeLastFetchResult?.text ?? "");
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let requestTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let nowTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Adaptive tick: schedules the next `now` update based on when the
|
||||||
|
// relative time text would actually change (every ~1s for recent
|
||||||
|
// timestamps, every ~1m for minute-level, etc.)
|
||||||
|
function scheduleNowTick() {
|
||||||
|
const delay = relativeLastFetchResult?.nextUpdateMs ?? 1000;
|
||||||
|
nowTimeoutId = setTimeout(() => {
|
||||||
|
now = new Date();
|
||||||
|
scheduleNowTick();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
scheduleNowTick();
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
requestTimeoutId = setTimeout(() => {
|
||||||
|
reject(new Error("Request timeout"));
|
||||||
|
}, REQUEST_TIMEOUT);
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusData = await Promise.race([client.getStatus(), timeoutPromise]);
|
||||||
|
|
||||||
|
if (requestTimeoutId) {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
requestTimeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
statusState = {
|
||||||
|
mode: "response",
|
||||||
|
status: statusData,
|
||||||
|
timing: { health: responseTime, status: responseTime },
|
||||||
|
lastFetch: new Date(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (requestTimeoutId) {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
requestTimeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : "";
|
||||||
|
|
||||||
|
if (message === "Request timeout") {
|
||||||
|
statusState = { mode: "timeout", lastFetch: new Date() };
|
||||||
|
} else {
|
||||||
|
statusState = { mode: "error", lastFetch: new Date() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => void fetchData(), REFRESH_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
if (requestTimeoutId) clearTimeout(requestTimeoutId);
|
||||||
|
if (nowTimeoutId) clearTimeout(nowTimeoutId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex flex-col items-center justify-center p-5">
|
||||||
|
<div
|
||||||
|
class="bg-card text-card-foreground rounded-xl border border-border p-6 w-full max-w-[400px] shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Overall Status -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity
|
||||||
|
size={18}
|
||||||
|
color={isLoading ? undefined : overallIcon.color}
|
||||||
|
class={isLoading ? "animate-pulse" : ""}
|
||||||
|
style="opacity: {isLoading ? 0.3 : 1}; transition: opacity 2s ease-in-out, color 2s ease-in-out;"
|
||||||
|
/>
|
||||||
|
<span class="text-base font-medium text-foreground">System Status</span>
|
||||||
|
</div>
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="h-5 w-20 bg-muted rounded animate-pulse"></div>
|
||||||
|
{:else}
|
||||||
|
{#if overallIcon}
|
||||||
|
{@const OverallIconComponent = overallIcon.icon}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
class:text-muted-foreground={overallHealth === "disabled"}
|
||||||
|
class:opacity-70={overallHealth === "disabled"}
|
||||||
|
>
|
||||||
|
{overallHealth}
|
||||||
|
</span>
|
||||||
|
<OverallIconComponent size={16} color={overallIcon.color} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
<div class="flex flex-col gap-3 mt-4">
|
||||||
|
{#if shouldShowSkeleton}
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-6 w-[18px] bg-muted rounded animate-pulse"></div>
|
||||||
|
<div class="h-6 w-[60px] bg-muted rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-5 w-[50px] bg-muted rounded animate-pulse"></div>
|
||||||
|
<div class="h-5 w-4 bg-muted rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each services as service (service.name)}
|
||||||
|
{@const statusInfo = STATUS_ICONS[service.status]}
|
||||||
|
{@const ServiceIcon = service.icon}
|
||||||
|
{@const StatusIconComponent = statusInfo.icon}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ServiceIcon size={18} />
|
||||||
|
<span class="text-muted-foreground">{service.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
class:text-muted-foreground={service.status === "disabled"}
|
||||||
|
class:opacity-70={service.status === "disabled"}
|
||||||
|
>
|
||||||
|
{service.status}
|
||||||
|
</span>
|
||||||
|
<StatusIconComponent size={16} color={statusInfo.color} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timing & Last Updated -->
|
||||||
|
<div class="flex flex-col gap-2 mt-4 pt-4 border-t border-border">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Hourglass size={13} />
|
||||||
|
<span class="text-sm text-muted-foreground">Response Time</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-[18px] w-[50px] bg-muted rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
{:else if shouldShowTiming && statusState.mode === "response"}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Hourglass size={13} />
|
||||||
|
<span class="text-sm text-muted-foreground">Response Time</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{formatNumber(statusState.timing.health!)}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Clock size={13} />
|
||||||
|
<span class="text-sm text-muted-foreground">Last Updated</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-muted-foreground pb-0.5">Loading...</span>
|
||||||
|
</div>
|
||||||
|
{:else if shouldShowLastFetch && lastFetch}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Clock size={13} />
|
||||||
|
<span class="text-sm text-muted-foreground">Last Updated</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<abbr
|
||||||
|
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
|
||||||
|
</abbr>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content
|
||||||
|
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
|
||||||
|
>
|
||||||
|
as of {lastFetch.toLocaleTimeString()}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-center items-center gap-2 mt-3">
|
||||||
|
{#if __APP_VERSION__}
|
||||||
|
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
|
||||||
|
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href={hasResponse && statusState.mode === "response" && statusState.status.commit
|
||||||
|
? `https://github.com/Xevion/banner/commit/${statusState.status.commit}`
|
||||||
|
: "https://github.com/Xevion/banner"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-xs text-muted-foreground no-underline hover:underline"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -69,6 +69,13 @@ body * {
|
|||||||
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
|
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* View Transitions API - disable default cross-fade so JS can animate clip-path */
|
||||||
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|||||||
Reference in New Issue
Block a user