diff --git a/web/biome.json b/web/biome.json
index 6949e12..fc01adc 100644
--- a/web/biome.json
+++ b/web/biome.json
@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
- "ignore": ["dist/", "node_modules/", ".svelte-kit/"]
+ "ignore": ["dist/", "node_modules/", ".svelte-kit/", "src/lib/bindings/"]
},
"formatter": {
"enabled": true,
diff --git a/web/src/lib/bindings/index.ts b/web/src/lib/bindings/index.ts
new file mode 100644
index 0000000..96c06ba
--- /dev/null
+++ b/web/src/lib/bindings/index.ts
@@ -0,0 +1,8 @@
+export type { CodeDescription } from "./CodeDescription";
+export type { CourseResponse } from "./CourseResponse";
+export type { DbMeetingTime } from "./DbMeetingTime";
+export type { InstructorResponse } from "./InstructorResponse";
+export type { SearchResponse } from "./SearchResponse";
+export type { ServiceInfo } from "./ServiceInfo";
+export type { ServiceStatus } from "./ServiceStatus";
+export type { StatusResponse } from "./StatusResponse";
diff --git a/web/src/lib/components/CourseDetail.svelte b/web/src/lib/components/CourseDetail.svelte
index 172685f..1749534 100644
--- a/web/src/lib/components/CourseDetail.svelte
+++ b/web/src/lib/components/CourseDetail.svelte
@@ -18,11 +18,15 @@ let copiedEmail: string | null = $state(null);
async function copyEmail(email: string, event: MouseEvent) {
event.stopPropagation();
- await navigator.clipboard.writeText(email);
- copiedEmail = email;
- setTimeout(() => {
- copiedEmail = null;
- }, 2000);
+ try {
+ await navigator.clipboard.writeText(email);
+ copiedEmail = email;
+ setTimeout(() => {
+ copiedEmail = null;
+ }, 2000);
+ } catch (err) {
+ console.error("Failed to copy email:", err);
+ }
}
@@ -42,8 +46,8 @@ async function copyEmail(email: string, event: MouseEvent) {
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
>
{instructor.displayName}
- {#if 'rmpRating' in instructor && instructor.rmpRating}
- {@const rating = instructor.rmpRating as number}
+ {#if instructor.rmpRating != null}
+ {@const rating = instructor.rmpRating}
{rating.toFixed(1)}★
@@ -59,9 +63,9 @@ async function copyEmail(email: string, event: MouseEvent) {
{#if instructor.isPrimary}
Primary instructor
{/if}
- {#if 'rmpRating' in instructor && instructor.rmpRating}
+ {#if instructor.rmpRating != null}
- {(instructor.rmpRating as number).toFixed(1)}/5 ({(instructor as any).rmpNumRatings ?? 0} ratings)
+ {instructor.rmpRating.toFixed(1)}/5 ({instructor.rmpNumRatings ?? 0} ratings)
{/if}
{#if instructor.email}
diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte
index a5bcd15..e778b37 100644
--- a/web/src/lib/components/CourseTable.svelte
+++ b/web/src/lib/components/CourseTable.svelte
@@ -262,97 +262,56 @@ const table = createSvelteTable({
});
-{#snippet columnVisibilityItems(variant: "dropdown" | "context")}
- {#if variant === "dropdown"}
-
-
+
+ Toggle columns
+
+ {#each columns as col}
+ {@const id = col.id!}
+ {@const label =
+ typeof col.header === "string" ? col.header : id}
+ {
+ columnVisibility = {
+ ...columnVisibility,
+ [id]: checked,
+ };
+ }}
+ class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
- Toggle columns
-
- {#each columns as col}
- {@const id = col.id!}
- {@const label =
- typeof col.header === "string" ? col.header : id}
- {
- columnVisibility = {
- ...columnVisibility,
- [id]: checked,
- };
- }}
- class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
- >
- {#snippet children({ checked })}
-
- {#if checked}
-
- {/if}
-
- {label}
- {/snippet}
-
- {/each}
-
- {#if hasCustomVisibility}
-
-
-
- Reset to default
-
- {/if}
- {:else}
-
-
- Toggle columns
-
- {#each columns as col}
- {@const id = col.id!}
- {@const label =
- typeof col.header === "string" ? col.header : id}
- {
- columnVisibility = {
- ...columnVisibility,
- [id]: checked,
- };
- }}
- class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
- >
- {#snippet children({ checked })}
-
- {#if checked}
-
- {/if}
-
- {label}
- {/snippet}
-
- {/each}
-
- {#if hasCustomVisibility}
-
-
-
- Reset to default
-
- {/if}
+ {#snippet children({ checked })}
+
+ {#if checked}
+
+ {/if}
+
+ {label}
+ {/snippet}
+
+ {/each}
+
+ {#if hasCustomVisibility}
+
+ -
+
+ Reset to default
+
{/if}
{/snippet}
@@ -379,7 +338,13 @@ const table = createSvelteTable({
{...props}
transition:fly={{ duration: 150, y: -10 }}
>
- {@render columnVisibilityItems("dropdown")}
+ {@render columnVisibilityGroup(
+ DropdownMenu.Group,
+ DropdownMenu.GroupHeading,
+ DropdownMenu.CheckboxItem,
+ DropdownMenu.Separator,
+ DropdownMenu.Item,
+ )}
{/if}
@@ -574,6 +539,7 @@ const table = createSvelteTable({
)}
{@const display = primaryInstructorDisplay(course)}
{@const commaIdx = display.indexOf(", ")}
+ {@const ratingData = primaryRating(course)}
{#if display === "Staff"}
{/if}
- {#if primaryRating(course)}
- {@const r =
- primaryRating(course)!}
+ {#if ratingData}
{r.rating.toFixed(
+ >{ratingData.rating.toFixed(
1,
)}★
@@ -772,7 +736,13 @@ const table = createSvelteTable({
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
>
- {@render columnVisibilityItems("context")}
+ {@render columnVisibilityGroup(
+ ContextMenu.Group,
+ ContextMenu.GroupHeading,
+ ContextMenu.CheckboxItem,
+ ContextMenu.Separator,
+ ContextMenu.Item,
+ )}
{/if}
diff --git a/web/src/lib/components/SearchFilters.svelte b/web/src/lib/components/SearchFilters.svelte
index 9d15f48..e3facd3 100644
--- a/web/src/lib/components/SearchFilters.svelte
+++ b/web/src/lib/components/SearchFilters.svelte
@@ -29,6 +29,7 @@ let {
+
{ containerEl?.querySelector('input')?.focus(); }}
+ onkeydown={() => { containerEl?.querySelector('input')?.focus(); }}
>
(searchValue = e.currentTarget.value)}
diff --git a/web/src/lib/course.test.ts b/web/src/lib/course.test.ts
index c3a2aca..5b5d2be 100644
--- a/web/src/lib/course.test.ts
+++ b/web/src/lib/course.test.ts
@@ -186,14 +186,35 @@ describe("abbreviateInstructor", () => {
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 },
+ {
+ bannerId: "1",
+ displayName: "A",
+ email: null,
+ isPrimary: false,
+ rmpRating: null,
+ rmpNumRatings: null,
+ },
+ {
+ bannerId: "2",
+ displayName: "B",
+ email: null,
+ isPrimary: true,
+ rmpRating: null,
+ rmpNumRatings: null,
+ },
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("B");
});
it("returns first instructor when no primary", () => {
const instructors: InstructorResponse[] = [
- { bannerId: "1", displayName: "A", email: null, isPrimary: false },
+ {
+ bannerId: "1",
+ displayName: "A",
+ email: null,
+ isPrimary: false,
+ rmpRating: null,
+ rmpNumRatings: null,
+ },
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("A");
});
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 0543fdc..ff92419 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -26,8 +26,19 @@ onMount(() => {
},
});
+ const unwatch = $effect.root(() => {
+ $effect(() => {
+ osInstance.options({
+ scrollbars: {
+ theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
+ },
+ });
+ });
+ });
+
return () => {
- osInstance?.destroy();
+ unwatch();
+ osInstance.destroy();
};
});
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte
index ccf7b6c..07a55a6 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -62,11 +62,16 @@ let error = $state(null);
$effect(() => {
const term = selectedTerm;
if (!term) return;
- client.getSubjects(term).then((s) => {
- subjects = s;
- const validCodes = new Set(s.map((sub) => sub.code));
- selectedSubjects = selectedSubjects.filter((code) => validCodes.has(code));
- });
+ client
+ .getSubjects(term)
+ .then((s) => {
+ subjects = s;
+ const validCodes = new Set(s.map((sub) => sub.code));
+ selectedSubjects = selectedSubjects.filter((code) => validCodes.has(code));
+ })
+ .catch((e) => {
+ console.error("Failed to fetch subjects:", e);
+ });
});
// Centralized throttle configuration - maps trigger source to throttle delay (ms)
diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts
index ab7fbd8..3f0a270 100644
--- a/web/src/routes/+page.ts
+++ b/web/src/routes/+page.ts
@@ -3,6 +3,11 @@ 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 };
+ try {
+ const terms = await client.getTerms();
+ return { terms, url };
+ } catch (e) {
+ console.error("Failed to load terms:", e);
+ return { terms: [], url };
+ }
};
|