mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 12:23:33 -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:
@@ -12,6 +12,18 @@
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo192.png" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||
<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%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -61,4 +61,101 @@ describe("BannerApiClient", () => {
|
||||
"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";
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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">
|
||||
import { browser } from "$app/environment";
|
||||
import { Monitor, Moon, Sun } from "@lucide/svelte";
|
||||
import { tick } from "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 (browser) {
|
||||
theme = (localStorage.getItem("theme") as Theme) ?? "system";
|
||||
}
|
||||
|
||||
const nextTheme = $derived<Theme>(
|
||||
theme === "light" ? "dark" : theme === "dark" ? "system" : "light"
|
||||
);
|
||||
|
||||
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();
|
||||
if (!supportsViewTransition) {
|
||||
themeStore.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
const x = event.clientX;
|
||||
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)",
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<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
|
||||
text-muted-foreground hover:bg-muted bg-transparent transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{#if nextTheme === "dark"}
|
||||
<Moon size={18} />
|
||||
{:else if nextTheme === "system"}
|
||||
<Monitor size={18} />
|
||||
{:else}
|
||||
<Sun size={18} />
|
||||
{/if}
|
||||
<div class="relative size-[18px]">
|
||||
<Sun
|
||||
size={18}
|
||||
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
|
||||
? 'rotate-90 scale-0 opacity-0'
|
||||
: 'rotate-0 scale-100 opacity-100'}"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
import "./layout.css";
|
||||
import { onMount } from "svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{@html `<script>
|
||||
(function() {
|
||||
const stored = localStorage.getItem("theme");
|
||||
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>
|
||||
onMount(() => {
|
||||
themeStore.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<div class="fixed top-5 right-5 z-50">
|
||||
|
||||
+146
-304
@@ -1,327 +1,169 @@
|
||||
<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 Status, type ServiceInfo, type StatusResponse, client } from "$lib/api";
|
||||
import { relativeTime } from "$lib/time";
|
||||
import { untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { type Subject, type SearchResponse, client } from "$lib/api";
|
||||
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
||||
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||
import Pagination from "$lib/components/Pagination.svelte";
|
||||
|
||||
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
|
||||
const REQUEST_TIMEOUT = 10000;
|
||||
let { data } = $props();
|
||||
|
||||
const SERVICE_ICONS: Record<string, typeof Bot> = {
|
||||
bot: Bot,
|
||||
banner: Globe,
|
||||
discord: MessageCircle,
|
||||
database: Activity,
|
||||
web: Globe,
|
||||
scraper: Clock,
|
||||
};
|
||||
// Read initial state from URL params (intentionally captured once)
|
||||
const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
||||
|
||||
interface ResponseTiming {
|
||||
health: number | null;
|
||||
status: number | null;
|
||||
}
|
||||
// Filter state
|
||||
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
||||
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;
|
||||
|
||||
interface Service {
|
||||
name: string;
|
||||
status: Status;
|
||||
icon: typeof Bot;
|
||||
}
|
||||
// Data state
|
||||
let subjects: Subject[] = $state([]);
|
||||
let searchResult: SearchResponse | null = $state(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
type StatusState =
|
||||
| { mode: "loading" }
|
||||
| { mode: "response"; timing: ResponseTiming; lastFetch: Date; status: StatusResponse }
|
||||
| { mode: "error"; lastFetch: Date }
|
||||
| { mode: "timeout"; lastFetch: Date };
|
||||
|
||||
const STATUS_ICONS: Record<Status | "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: 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 {
|
||||
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;
|
||||
// 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 = "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
$effect(() => {
|
||||
const term = selectedTerm;
|
||||
const subject = selectedSubject;
|
||||
const q = query;
|
||||
const open = openOnly;
|
||||
const off = offset;
|
||||
|
||||
statusState = {
|
||||
mode: "response",
|
||||
status: statusData,
|
||||
timing: { health: responseTime, status: responseTime },
|
||||
lastFetch: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
if (requestTimeoutId) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
requestTimeoutId = null;
|
||||
}
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch(term, subject, q, open, off);
|
||||
}, 300);
|
||||
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
if (message === "Request timeout") {
|
||||
statusState = { mode: "timeout", lastFetch: new Date() };
|
||||
} else {
|
||||
statusState = { mode: "error", lastFetch: new Date() };
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
|
||||
timeoutId = setTimeout(() => void fetchData(), REFRESH_INTERVAL);
|
||||
};
|
||||
async function performSearch(
|
||||
term: string,
|
||||
subject: string,
|
||||
q: string,
|
||||
open: boolean,
|
||||
off: number,
|
||||
) {
|
||||
if (!term) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
void fetchData();
|
||||
// Sync URL
|
||||
const params = new URLSearchParams();
|
||||
params.set("term", term);
|
||||
if (subject) params.set("subject", subject);
|
||||
if (q) params.set("q", q);
|
||||
if (open) params.set("open", "true");
|
||||
if (off > 0) params.set("offset", String(off));
|
||||
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (requestTimeoutId) clearTimeout(requestTimeoutId);
|
||||
if (nowTimeoutId) clearTimeout(nowTimeoutId);
|
||||
};
|
||||
});
|
||||
try {
|
||||
searchResult = await client.searchCourses({
|
||||
term,
|
||||
subject: subject || undefined,
|
||||
q: q || undefined,
|
||||
open_only: open || undefined,
|
||||
limit,
|
||||
offset: off,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Search failed";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(newOffset: number) {
|
||||
offset = newOffset;
|
||||
}
|
||||
</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>
|
||||
<div class="min-h-screen flex flex-col items-center p-5">
|
||||
<div class="w-full max-w-4xl flex flex-col gap-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center pt-8 pb-2">
|
||||
<h1 class="text-2xl font-semibold text-foreground">UTSA Course Search</h1>
|
||||
</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>
|
||||
<!-- Filters -->
|
||||
<SearchFilters
|
||||
terms={data.terms}
|
||||
{subjects}
|
||||
bind:selectedTerm
|
||||
bind:selectedSubject
|
||||
bind:query
|
||||
bind:openOnly
|
||||
/>
|
||||
|
||||
<!-- 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}
|
||||
<!-- 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>
|
||||
{:else}
|
||||
<CourseTable courses={searchResult?.courses ?? []} {loading} />
|
||||
|
||||
{#if searchResult}
|
||||
<Pagination
|
||||
totalCount={searchResult.totalCount}
|
||||
offset={searchResult.offset}
|
||||
{limit}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-center items-center gap-2 mt-auto pt-6 pb-4">
|
||||
{#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="https://github.com/Xevion/banner"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-muted-foreground no-underline hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
Reference in New Issue
Block a user