mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 06:23:37 -06:00
220 lines
6.1 KiB
Svelte
220 lines
6.1 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import {
|
|
type Subject,
|
|
type SearchResponse,
|
|
type SortColumn,
|
|
type SortDirection,
|
|
client,
|
|
} from "$lib/api";
|
|
import type { SortingState } from "@tanstack/table-core";
|
|
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
|
import CourseTable from "$lib/components/CourseTable.svelte";
|
|
import Pagination from "$lib/components/Pagination.svelte";
|
|
|
|
let { data } = $props();
|
|
|
|
// Read initial state from URL params (intentionally captured once)
|
|
const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
|
|
|
// Filter state
|
|
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
|
let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("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;
|
|
|
|
// Sorting state — maps TanStack column IDs to server sort params
|
|
const SORT_COLUMN_MAP: Record<string, SortColumn> = {
|
|
course_code: "course_code",
|
|
title: "title",
|
|
instructor: "instructor",
|
|
time: "time",
|
|
seats: "seats",
|
|
};
|
|
|
|
let sorting: SortingState = $state(
|
|
(() => {
|
|
const sortBy = initialParams.get("sort_by");
|
|
const sortDir = initialParams.get("sort_dir");
|
|
if (!sortBy) return [];
|
|
return [{ id: sortBy, desc: sortDir === "desc" }];
|
|
})()
|
|
);
|
|
|
|
function handleSortingChange(newSorting: SortingState) {
|
|
sorting = newSorting;
|
|
offset = 0;
|
|
}
|
|
|
|
// Data state
|
|
let subjects: Subject[] = $state([]);
|
|
let subjectMap: Record<string, string> = $derived(
|
|
Object.fromEntries(subjects.map((s) => [s.code, s.description]))
|
|
);
|
|
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;
|
|
const validCodes = new Set(s.map((sub) => sub.code));
|
|
selectedSubjects = selectedSubjects.filter((code) => validCodes.has(code));
|
|
});
|
|
});
|
|
|
|
// Debounced search
|
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
$effect(() => {
|
|
const term = selectedTerm;
|
|
const subs = selectedSubjects;
|
|
const q = query;
|
|
const open = openOnly;
|
|
const off = offset;
|
|
const sort = sorting;
|
|
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
performSearch(term, subs, q, open, off, sort);
|
|
}, 300);
|
|
|
|
return () => clearTimeout(searchTimeout);
|
|
});
|
|
|
|
// Reset offset when filters change (not offset itself)
|
|
let prevFilters = $state("");
|
|
$effect(() => {
|
|
const key = `${selectedTerm}|${selectedSubjects.join(",")}|${query}|${openOnly}`;
|
|
if (prevFilters && key !== prevFilters) {
|
|
offset = 0;
|
|
}
|
|
prevFilters = key;
|
|
});
|
|
|
|
async function performSearch(
|
|
term: string,
|
|
subjects: string[],
|
|
q: string,
|
|
open: boolean,
|
|
off: number,
|
|
sort: SortingState
|
|
) {
|
|
if (!term) return;
|
|
loading = true;
|
|
error = null;
|
|
|
|
const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined;
|
|
const sortDir: SortDirection | undefined =
|
|
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
|
|
|
|
const params = new URLSearchParams();
|
|
params.set("term", term);
|
|
for (const s of subjects) {
|
|
params.append("subject", s);
|
|
}
|
|
if (q) params.set("q", q);
|
|
if (open) params.set("open", "true");
|
|
if (off > 0) params.set("offset", String(off));
|
|
if (sortBy) params.set("sort_by", sortBy);
|
|
if (sortDir && sortBy) params.set("sort_dir", sortDir);
|
|
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
|
|
|
try {
|
|
searchResult = await client.searchCourses({
|
|
term,
|
|
subjects: subjects.length > 0 ? subjects : undefined,
|
|
q: q || undefined,
|
|
open_only: open || undefined,
|
|
limit,
|
|
offset: off,
|
|
sort_by: sortBy,
|
|
sort_dir: sortDir,
|
|
});
|
|
} 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 p-5">
|
|
<div class="w-full max-w-6xl 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>
|
|
|
|
<!-- Filters -->
|
|
<SearchFilters
|
|
terms={data.terms}
|
|
{subjects}
|
|
bind:selectedTerm
|
|
bind:selectedSubjects
|
|
bind:query
|
|
bind:openOnly
|
|
/>
|
|
|
|
<!-- Results -->
|
|
{#if error}
|
|
<div class="text-center py-8">
|
|
<p class="text-status-red">{error}</p>
|
|
<button
|
|
onclick={() => performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting)}
|
|
class="mt-2 text-sm text-muted-foreground hover:underline"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<CourseTable
|
|
courses={searchResult?.courses ?? []}
|
|
{loading}
|
|
{sorting}
|
|
onSortingChange={handleSortingChange}
|
|
manualSorting={true}
|
|
{subjectMap}
|
|
/>
|
|
|
|
{#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>
|
|
</div>
|