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
+68 -16
View File
@@ -1,24 +1,41 @@
import type {
CodeDescription,
CourseResponse,
DbMeetingTime,
InstructorResponse,
SearchResponse as SearchResponseGenerated,
ServiceInfo,
ServiceStatus,
StatusResponse,
} from "$lib/bindings";
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 {
status: 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 {
banner_api: {
status: string;
@@ -26,15 +43,27 @@ export interface MetricsResponse {
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 {
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.fetchFn = fetchFn;
}
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) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
@@ -54,6 +83,29 @@ export class BannerApiClient {
async getMetrics(): Promise<MetricsResponse> {
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();