mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 04:23:34 -06:00
feat: add multi-select subject filtering with searchable comboboxes
This commit is contained in:
Generated
+58
-11
@@ -149,9 +149,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.8.4"
|
version = "0.8.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
|
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -168,8 +168,7 @@ dependencies = [
|
|||||||
"mime",
|
"mime",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"serde_core",
|
||||||
"serde",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
@@ -183,9 +182,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-core"
|
name = "axum-core"
|
||||||
version = "0.5.2"
|
version = "0.5.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6"
|
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -194,13 +193,37 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"mime",
|
"mime",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
|
||||||
"sync_wrapper 1.0.2",
|
"sync_wrapper 1.0.2",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-extra"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"http 1.3.1",
|
||||||
|
"http-body 1.0.1",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"serde_html_form",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backtrace"
|
name = "backtrace"
|
||||||
version = "0.3.75"
|
version = "0.3.75"
|
||||||
@@ -223,6 +246,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2688,9 +2712,19 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.219"
|
version = "1.0.228"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
@@ -2706,15 +2740,28 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.219"
|
version = "1.0.228"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_html_form"
|
||||||
|
version = "0.2.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.143"
|
version = "1.0.143"
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ yansi = "1.0.1"
|
|||||||
extension-traits = "2"
|
extension-traits = "2"
|
||||||
ts-rs = { version = "11.1.0", features = ["serde-compat", "serde-json-impl"] }
|
ts-rs = { version = "11.1.0", features = ["serde-compat", "serde-json-impl"] }
|
||||||
html-escape = "0.2.13"
|
html-escape = "0.2.13"
|
||||||
|
axum-extra = { version = "0.12.5", features = ["query"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
||||||
|
|||||||
+29
-3
@@ -12,7 +12,7 @@ use sqlx::PgPool;
|
|||||||
pub async fn search_courses(
|
pub async fn search_courses(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
term_code: &str,
|
term_code: &str,
|
||||||
subject: Option<&str>,
|
subject: Option<&[String]>,
|
||||||
title_query: Option<&str>,
|
title_query: Option<&str>,
|
||||||
course_number_low: Option<i32>,
|
course_number_low: Option<i32>,
|
||||||
course_number_high: Option<i32>,
|
course_number_high: Option<i32>,
|
||||||
@@ -34,7 +34,7 @@ pub async fn search_courses(
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE term_code = $1
|
WHERE term_code = $1
|
||||||
AND ($2::text IS NULL OR subject = $2)
|
AND ($2::text[] IS NULL OR subject = ANY($2))
|
||||||
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
|
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
|
||||||
AND ($4::int IS NULL OR course_number::int >= $4)
|
AND ($4::int IS NULL OR course_number::int >= $4)
|
||||||
AND ($5::int IS NULL OR course_number::int <= $5)
|
AND ($5::int IS NULL OR course_number::int <= $5)
|
||||||
@@ -65,7 +65,7 @@ pub async fn search_courses(
|
|||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE term_code = $1
|
WHERE term_code = $1
|
||||||
AND ($2::text IS NULL OR subject = $2)
|
AND ($2::text[] IS NULL OR subject = ANY($2))
|
||||||
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
|
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
|
||||||
AND ($4::int IS NULL OR course_number::int >= $4)
|
AND ($4::int IS NULL OR course_number::int >= $4)
|
||||||
AND ($5::int IS NULL OR course_number::int <= $5)
|
AND ($5::int IS NULL OR course_number::int <= $5)
|
||||||
@@ -143,6 +143,32 @@ pub async fn get_course_instructors(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get subjects for a term, sorted by total enrollment (descending).
|
||||||
|
///
|
||||||
|
/// Returns only subjects that have courses in the given term, with their
|
||||||
|
/// descriptions from reference_data and enrollment totals for ranking.
|
||||||
|
pub async fn get_subjects_by_enrollment(
|
||||||
|
db_pool: &PgPool,
|
||||||
|
term_code: &str,
|
||||||
|
) -> Result<Vec<(String, String, i64)>> {
|
||||||
|
let rows: Vec<(String, String, i64)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT c.subject,
|
||||||
|
COALESCE(rd.description, c.subject),
|
||||||
|
COALESCE(SUM(c.enrollment), 0) as total_enrollment
|
||||||
|
FROM courses c
|
||||||
|
LEFT JOIN reference_data rd ON rd.category = 'subject' AND rd.code = c.subject
|
||||||
|
WHERE c.term_code = $1
|
||||||
|
GROUP BY c.subject, rd.description
|
||||||
|
ORDER BY total_enrollment DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(term_code)
|
||||||
|
.fetch_all(db_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all distinct term codes that have courses in the DB.
|
/// Get all distinct term codes that have courses in the DB.
|
||||||
pub async fn get_available_terms(db_pool: &PgPool) -> Result<Vec<String>> {
|
pub async fn get_available_terms(db_pool: &PgPool) -> Result<Vec<String>> {
|
||||||
let rows: Vec<(String,)> =
|
let rows: Vec<(String,)> =
|
||||||
|
|||||||
+22
-11
@@ -298,10 +298,16 @@ async fn metrics() -> Json<Value> {
|
|||||||
// Course search & detail API
|
// Course search & detail API
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SubjectsParams {
|
||||||
|
term: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct SearchParams {
|
struct SearchParams {
|
||||||
term: String,
|
term: String,
|
||||||
subject: Option<String>,
|
#[serde(default)]
|
||||||
|
subject: Vec<String>,
|
||||||
q: Option<String>,
|
q: Option<String>,
|
||||||
course_number_low: Option<i32>,
|
course_number_low: Option<i32>,
|
||||||
course_number_high: Option<i32>,
|
course_number_high: Option<i32>,
|
||||||
@@ -484,7 +490,7 @@ async fn build_course_response(
|
|||||||
/// `GET /api/courses/search`
|
/// `GET /api/courses/search`
|
||||||
async fn search_courses(
|
async fn search_courses(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<SearchParams>,
|
axum_extra::extract::Query(params): axum_extra::extract::Query<SearchParams>,
|
||||||
) -> Result<Json<SearchResponse>, (AxumStatusCode, String)> {
|
) -> Result<Json<SearchResponse>, (AxumStatusCode, String)> {
|
||||||
let limit = params.limit.clamp(1, 100);
|
let limit = params.limit.clamp(1, 100);
|
||||||
let offset = params.offset.max(0);
|
let offset = params.offset.max(0);
|
||||||
@@ -494,7 +500,7 @@ async fn search_courses(
|
|||||||
let (courses, total_count) = crate::data::courses::search_courses(
|
let (courses, total_count) = crate::data::courses::search_courses(
|
||||||
&state.db_pool,
|
&state.db_pool,
|
||||||
¶ms.term,
|
¶ms.term,
|
||||||
params.subject.as_deref(),
|
if params.subject.is_empty() { None } else { Some(¶ms.subject) },
|
||||||
params.q.as_deref(),
|
params.q.as_deref(),
|
||||||
params.course_number_low,
|
params.course_number_low,
|
||||||
params.course_number_high,
|
params.course_number_high,
|
||||||
@@ -575,19 +581,24 @@ async fn get_terms(
|
|||||||
Ok(Json(terms))
|
Ok(Json(terms))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/subjects?term=202420`
|
/// `GET /api/subjects?term=202620`
|
||||||
async fn get_subjects(
|
async fn get_subjects(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<SubjectsParams>,
|
||||||
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
|
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
|
||||||
let cache = state.reference_cache.read().await;
|
let rows = crate::data::courses::get_subjects_by_enrollment(&state.db_pool, ¶ms.term)
|
||||||
let entries = cache.entries_for_category("subject");
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to get subjects");
|
||||||
|
(
|
||||||
|
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to get subjects".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let subjects: Vec<CodeDescription> = entries
|
let subjects: Vec<CodeDescription> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(code, description)| CodeDescription {
|
.map(|(code, description, _enrollment)| CodeDescription { code, description })
|
||||||
code: code.to_string(),
|
|
||||||
description: description.to_string(),
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(subjects))
|
Ok(Json(subjects))
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe("BannerApiClient", () => {
|
|||||||
|
|
||||||
const result = await apiClient.searchCourses({
|
const result = await apiClient.searchCourses({
|
||||||
term: "202420",
|
term: "202420",
|
||||||
subject: "CS",
|
subjects: ["CS"],
|
||||||
q: "data",
|
q: "data",
|
||||||
open_only: true,
|
open_only: true,
|
||||||
limit: 25,
|
limit: 25,
|
||||||
|
|||||||
+6
-2
@@ -49,7 +49,7 @@ export type SortDirection = "asc" | "desc";
|
|||||||
|
|
||||||
export interface SearchParams {
|
export interface SearchParams {
|
||||||
term: string;
|
term: string;
|
||||||
subject?: string;
|
subjects?: string[];
|
||||||
q?: string;
|
q?: string;
|
||||||
open_only?: boolean;
|
open_only?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -92,7 +92,11 @@ export class BannerApiClient {
|
|||||||
async searchCourses(params: SearchParams): Promise<SearchResponse> {
|
async searchCourses(params: SearchParams): Promise<SearchResponse> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
query.set("term", params.term);
|
query.set("term", params.term);
|
||||||
if (params.subject) query.set("subject", params.subject);
|
if (params.subjects) {
|
||||||
|
for (const s of params.subjects) {
|
||||||
|
query.append("subject", s);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (params.q) query.set("q", params.q);
|
if (params.q) query.set("q", params.q);
|
||||||
if (params.open_only) query.set("open_only", "true");
|
if (params.open_only) query.set("open_only", "true");
|
||||||
if (params.limit !== undefined) query.set("limit", String(params.limit));
|
if (params.limit !== undefined) query.set("limit", String(params.limit));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
isTimeTBA,
|
isTimeTBA,
|
||||||
} from "$lib/course";
|
} from "$lib/course";
|
||||||
import CourseDetail from "./CourseDetail.svelte";
|
import CourseDetail from "./CourseDetail.svelte";
|
||||||
import { slide } from "svelte/transition";
|
import { fade, fly, slide } from "svelte/transition";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { OverlayScrollbars } from "overlayscrollbars";
|
import { OverlayScrollbars } from "overlayscrollbars";
|
||||||
import { themeStore } from "$lib/stores/theme.svelte";
|
import { themeStore } from "$lib/stores/theme.svelte";
|
||||||
@@ -24,9 +24,8 @@ import {
|
|||||||
type Updater,
|
type Updater,
|
||||||
} from "@tanstack/table-core";
|
} from "@tanstack/table-core";
|
||||||
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte";
|
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte";
|
||||||
import { DropdownMenu, ContextMenu, Tooltip } from "bits-ui";
|
import { DropdownMenu, ContextMenu } from "bits-ui";
|
||||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||||
import { fade, fly } from "svelte/transition";
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
courses,
|
courses,
|
||||||
@@ -46,6 +45,8 @@ let {
|
|||||||
|
|
||||||
let expandedCrn: string | null = $state(null);
|
let expandedCrn: string | null = $state(null);
|
||||||
let tableWrapper: HTMLDivElement = undefined!;
|
let tableWrapper: HTMLDivElement = undefined!;
|
||||||
|
let copiedCrn: string | null = $state(null);
|
||||||
|
let copyTimeoutId: number | undefined;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const osInstance = OverlayScrollbars(tableWrapper, {
|
const osInstance = OverlayScrollbars(tableWrapper, {
|
||||||
@@ -76,10 +77,8 @@ onMount(() => {
|
|||||||
// Column visibility state
|
// Column visibility state
|
||||||
let columnVisibility: VisibilityState = $state({});
|
let columnVisibility: VisibilityState = $state({});
|
||||||
|
|
||||||
const DEFAULT_VISIBILITY: VisibilityState = {};
|
|
||||||
|
|
||||||
function resetColumnVisibility() {
|
function resetColumnVisibility() {
|
||||||
columnVisibility = { ...DEFAULT_VISIBILITY };
|
columnVisibility = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVisibilityChange(updater: Updater<VisibilityState>) {
|
function handleVisibilityChange(updater: Updater<VisibilityState>) {
|
||||||
@@ -93,6 +92,33 @@ function toggleRow(crn: string) {
|
|||||||
expandedCrn = expandedCrn === crn ? null : crn;
|
expandedCrn = expandedCrn === crn ? null : crn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCopyCrn(event: MouseEvent | KeyboardEvent, crn: string) {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(crn);
|
||||||
|
|
||||||
|
if (copyTimeoutId !== undefined) {
|
||||||
|
clearTimeout(copyTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
copiedCrn = crn;
|
||||||
|
copyTimeoutId = window.setTimeout(() => {
|
||||||
|
copiedCrn = null;
|
||||||
|
copyTimeoutId = undefined;
|
||||||
|
}, 1000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy CRN:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCrnKeydown(event: KeyboardEvent, crn: string) {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleCopyCrn(event, crn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openSeats(course: CourseResponse): number {
|
function openSeats(course: CourseResponse): number {
|
||||||
return Math.max(0, course.maxEnrollment - course.enrollment);
|
return Math.max(0, course.maxEnrollment - course.enrollment);
|
||||||
}
|
}
|
||||||
@@ -226,22 +252,30 @@ const table = createSvelteTable({
|
|||||||
{#snippet columnVisibilityItems(variant: "dropdown" | "context")}
|
{#snippet columnVisibilityItems(variant: "dropdown" | "context")}
|
||||||
{#if variant === "dropdown"}
|
{#if variant === "dropdown"}
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
<DropdownMenu.GroupHeading
|
||||||
|
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
Toggle columns
|
Toggle columns
|
||||||
</DropdownMenu.GroupHeading>
|
</DropdownMenu.GroupHeading>
|
||||||
{#each columns as col}
|
{#each columns as col}
|
||||||
{@const id = col.id!}
|
{@const id = col.id!}
|
||||||
{@const label = typeof col.header === "string" ? col.header : id}
|
{@const label =
|
||||||
|
typeof col.header === "string" ? col.header : id}
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
checked={columnVisibility[id] !== false}
|
checked={columnVisibility[id] !== false}
|
||||||
closeOnSelect={false}
|
closeOnSelect={false}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
columnVisibility = { ...columnVisibility, [id]: checked };
|
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"
|
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 })}
|
{#snippet children({ checked })}
|
||||||
<span class="flex size-4 items-center justify-center rounded-sm border border-border">
|
<span
|
||||||
|
class="flex size-4 items-center justify-center rounded-sm border border-border"
|
||||||
|
>
|
||||||
{#if checked}
|
{#if checked}
|
||||||
<Check class="size-3" />
|
<Check class="size-3" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -254,7 +288,7 @@ const table = createSvelteTable({
|
|||||||
{#if hasCustomVisibility}
|
{#if hasCustomVisibility}
|
||||||
<DropdownMenu.Separator class="mx-1 my-1 h-px bg-border" />
|
<DropdownMenu.Separator class="mx-1 my-1 h-px bg-border" />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="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"
|
class="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"
|
||||||
onSelect={resetColumnVisibility}
|
onSelect={resetColumnVisibility}
|
||||||
>
|
>
|
||||||
<RotateCcw class="size-3.5" />
|
<RotateCcw class="size-3.5" />
|
||||||
@@ -263,22 +297,30 @@ const table = createSvelteTable({
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<ContextMenu.Group>
|
<ContextMenu.Group>
|
||||||
<ContextMenu.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
<ContextMenu.GroupHeading
|
||||||
|
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
Toggle columns
|
Toggle columns
|
||||||
</ContextMenu.GroupHeading>
|
</ContextMenu.GroupHeading>
|
||||||
{#each columns as col}
|
{#each columns as col}
|
||||||
{@const id = col.id!}
|
{@const id = col.id!}
|
||||||
{@const label = typeof col.header === "string" ? col.header : id}
|
{@const label =
|
||||||
|
typeof col.header === "string" ? col.header : id}
|
||||||
<ContextMenu.CheckboxItem
|
<ContextMenu.CheckboxItem
|
||||||
checked={columnVisibility[id] !== false}
|
checked={columnVisibility[id] !== false}
|
||||||
closeOnSelect={false}
|
closeOnSelect={false}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
columnVisibility = { ...columnVisibility, [id]: checked };
|
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"
|
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 })}
|
{#snippet children({ checked })}
|
||||||
<span class="flex size-4 items-center justify-center rounded-sm border border-border">
|
<span
|
||||||
|
class="flex size-4 items-center justify-center rounded-sm border border-border"
|
||||||
|
>
|
||||||
{#if checked}
|
{#if checked}
|
||||||
<Check class="size-3" />
|
<Check class="size-3" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -291,7 +333,7 @@ const table = createSvelteTable({
|
|||||||
{#if hasCustomVisibility}
|
{#if hasCustomVisibility}
|
||||||
<ContextMenu.Separator class="mx-1 my-1 h-px bg-border" />
|
<ContextMenu.Separator class="mx-1 my-1 h-px bg-border" />
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
class="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"
|
class="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"
|
||||||
onSelect={resetColumnVisibility}
|
onSelect={resetColumnVisibility}
|
||||||
>
|
>
|
||||||
<RotateCcw class="size-3.5" />
|
<RotateCcw class="size-3.5" />
|
||||||
@@ -304,27 +346,15 @@ const table = createSvelteTable({
|
|||||||
<!-- Toolbar: View columns button -->
|
<!-- Toolbar: View columns button -->
|
||||||
<div class="flex items-center justify-end pb-2">
|
<div class="flex items-center justify-end pb-2">
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<Tooltip.Root delayDuration={150} disableHoverableContent>
|
|
||||||
<Tooltip.Trigger>
|
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
|
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<Columns3 class="size-3.5" />
|
<Columns3 class="size-3.5" />
|
||||||
View
|
View
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content
|
|
||||||
side="bottom"
|
|
||||||
sideOffset={6}
|
|
||||||
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
|
|
||||||
>
|
|
||||||
Show or hide table columns
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
|
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
class="z-50 min-w-[160px] rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
forceMount
|
forceMount
|
||||||
@@ -332,7 +362,10 @@ const table = createSvelteTable({
|
|||||||
{#snippet child({ wrapperProps, props, open })}
|
{#snippet child({ wrapperProps, props, open })}
|
||||||
{#if open}
|
{#if open}
|
||||||
<div {...wrapperProps}>
|
<div {...wrapperProps}>
|
||||||
<div {...props} transition:fly={{ duration: 150, y: -10 }}>
|
<div
|
||||||
|
{...props}
|
||||||
|
transition:fly={{ duration: 150, y: -10 }}
|
||||||
|
>
|
||||||
{@render columnVisibilityItems("dropdown")}
|
{@render columnVisibilityItems("dropdown")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,37 +380,57 @@ const table = createSvelteTable({
|
|||||||
<div bind:this={tableWrapper} class="overflow-x-auto">
|
<div bind:this={tableWrapper} class="overflow-x-auto">
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
<ContextMenu.Trigger class="contents">
|
<ContextMenu.Trigger class="contents">
|
||||||
<table class="w-full min-w-[640px] border-collapse text-sm">
|
<table class="w-full min-w-160 border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
{#each table.getHeaderGroups() as headerGroup}
|
{#each table.getHeaderGroups() as headerGroup}
|
||||||
<tr class="border-b border-border text-left text-muted-foreground">
|
<tr
|
||||||
|
class="border-b border-border text-left text-muted-foreground"
|
||||||
|
>
|
||||||
{#each headerGroup.headers as header}
|
{#each headerGroup.headers as header}
|
||||||
{#if header.column.getIsVisible()}
|
{#if header.column.getIsVisible()}
|
||||||
<th
|
<th
|
||||||
class="py-2 px-2 font-medium {header.id === 'seats' ? 'text-right' : ''}"
|
class="py-2 px-2 font-medium {header.id ===
|
||||||
|
'seats'
|
||||||
|
? 'text-right'
|
||||||
|
: ''}"
|
||||||
class:cursor-pointer={header.column.getCanSort()}
|
class:cursor-pointer={header.column.getCanSort()}
|
||||||
class:select-none={header.column.getCanSort()}
|
class:select-none={header.column.getCanSort()}
|
||||||
onclick={header.column.getToggleSortingHandler()}
|
onclick={header.column.getToggleSortingHandler()}
|
||||||
>
|
>
|
||||||
{#if header.column.getCanSort()}
|
{#if header.column.getCanSort()}
|
||||||
<span class="inline-flex items-center gap-1">
|
<span
|
||||||
|
class="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
{#if typeof header.column.columnDef.header === "string"}
|
{#if typeof header.column.columnDef.header === "string"}
|
||||||
{header.column.columnDef.header}
|
{header.column.columnDef
|
||||||
|
.header}
|
||||||
{:else}
|
{:else}
|
||||||
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
|
<FlexRender
|
||||||
|
content={header.column
|
||||||
|
.columnDef.header}
|
||||||
|
context={header.getContext()}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if header.column.getIsSorted() === "asc"}
|
{#if header.column.getIsSorted() === "asc"}
|
||||||
<ArrowUp class="size-3.5" />
|
<ArrowUp class="size-3.5" />
|
||||||
{:else if header.column.getIsSorted() === "desc"}
|
{:else if header.column.getIsSorted() === "desc"}
|
||||||
<ArrowDown class="size-3.5" />
|
<ArrowDown
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ArrowUpDown class="size-3.5 text-muted-foreground/40" />
|
<ArrowUpDown
|
||||||
|
class="size-3.5 text-muted-foreground/40"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{:else if typeof header.column.columnDef.header === "string"}
|
{:else if typeof header.column.columnDef.header === "string"}
|
||||||
{header.column.columnDef.header}
|
{header.column.columnDef.header}
|
||||||
{:else}
|
{:else}
|
||||||
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
|
<FlexRender
|
||||||
|
content={header.column.columnDef
|
||||||
|
.header}
|
||||||
|
context={header.getContext()}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</th>
|
</th>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -391,14 +444,26 @@ const table = createSvelteTable({
|
|||||||
<tr class="border-b border-border">
|
<tr class="border-b border-border">
|
||||||
{#each table.getVisibleLeafColumns() as col}
|
{#each table.getVisibleLeafColumns() as col}
|
||||||
<td class="py-2.5 px-2">
|
<td class="py-2.5 px-2">
|
||||||
<div class="h-4 bg-muted rounded animate-pulse {col.id === 'seats' ? 'w-14 ml-auto' : col.id === 'title' ? 'w-40' : col.id === 'crn' ? 'w-10' : 'w-20'}"></div>
|
<div
|
||||||
|
class="h-4 bg-muted rounded animate-pulse {col.id ===
|
||||||
|
'seats'
|
||||||
|
? 'w-14 ml-auto'
|
||||||
|
: col.id === 'title'
|
||||||
|
? 'w-40'
|
||||||
|
: col.id === 'crn'
|
||||||
|
? 'w-10'
|
||||||
|
: 'w-20'}"
|
||||||
|
></div>
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if courses.length === 0}
|
{:else if courses.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan={visibleColumnIds.length} class="py-12 text-center text-muted-foreground">
|
<td
|
||||||
|
colspan={visibleColumnIds.length}
|
||||||
|
class="py-12 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
No courses found. Try adjusting your filters.
|
No courses found. Try adjusting your filters.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -406,73 +471,210 @@ const table = createSvelteTable({
|
|||||||
{#each table.getRowModel().rows as row (row.id)}
|
{#each table.getRowModel().rows as row (row.id)}
|
||||||
{@const course = row.original}
|
{@const course = row.original}
|
||||||
<tr
|
<tr
|
||||||
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
|
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn ===
|
||||||
|
course.crn
|
||||||
|
? 'bg-muted/30'
|
||||||
|
: ''}"
|
||||||
onclick={() => toggleRow(course.crn)}
|
onclick={() => toggleRow(course.crn)}
|
||||||
>
|
>
|
||||||
{#each row.getVisibleCells() as cell (cell.id)}
|
{#each row.getVisibleCells() as cell (cell.id)}
|
||||||
{@const colId = cell.column.id}
|
{@const colId = cell.column.id}
|
||||||
{#if colId === "crn"}
|
{#if colId === "crn"}
|
||||||
<td class="py-2 px-2 font-mono text-xs text-muted-foreground/70">{course.crn}</td>
|
<td class="py-2 px-2 relative">
|
||||||
|
<button
|
||||||
|
class="relative inline-flex items-center rounded-full px-2 py-0.5 border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-foreground/30 transition-colors duration-150 cursor-copy focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70"
|
||||||
|
onclick={(e) =>
|
||||||
|
handleCopyCrn(
|
||||||
|
e,
|
||||||
|
course.crn,
|
||||||
|
)}
|
||||||
|
onkeydown={(e) =>
|
||||||
|
handleCrnKeydown(
|
||||||
|
e,
|
||||||
|
course.crn,
|
||||||
|
)}
|
||||||
|
aria-label="Copy CRN {course.crn} to clipboard"
|
||||||
|
>
|
||||||
|
{course.crn}
|
||||||
|
{#if copiedCrn === course.crn}
|
||||||
|
<span
|
||||||
|
class="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs px-2 py-1 rounded-md bg-green-500/10 border border-green-500/20 text-green-700 dark:text-green-300 pointer-events-none z-10"
|
||||||
|
in:fade={{
|
||||||
|
duration: 100,
|
||||||
|
}}
|
||||||
|
out:fade={{
|
||||||
|
duration: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copied!
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
{:else if colId === "course_code"}
|
{:else if colId === "course_code"}
|
||||||
{@const subjectDesc = subjectMap[course.subject]}
|
{@const subjectDesc =
|
||||||
|
subjectMap[course.subject]}
|
||||||
<td class="py-2 px-2 whitespace-nowrap">
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
<SimpleTooltip text={subjectDesc ? `${subjectDesc} ${course.courseNumber}` : `${course.subject} ${course.courseNumber}`} delay={200} side="bottom" passthrough>
|
<SimpleTooltip
|
||||||
<span class="font-semibold">{course.subject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground">-{course.sequenceNumber}</span>{/if}
|
text={subjectDesc
|
||||||
|
? `${subjectDesc} ${course.courseNumber}`
|
||||||
|
: `${course.subject} ${course.courseNumber}`}
|
||||||
|
delay={200}
|
||||||
|
side="bottom"
|
||||||
|
passthrough
|
||||||
|
>
|
||||||
|
<span class="font-semibold"
|
||||||
|
>{course.subject}
|
||||||
|
{course.courseNumber}</span
|
||||||
|
>{#if course.sequenceNumber}<span
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>-{course.sequenceNumber}</span
|
||||||
|
>{/if}
|
||||||
</SimpleTooltip>
|
</SimpleTooltip>
|
||||||
</td>
|
</td>
|
||||||
{:else if colId === "title"}
|
{:else if colId === "title"}
|
||||||
<td class="py-2 px-2 font-medium max-w-[200px] truncate">
|
<td
|
||||||
<SimpleTooltip text={course.title} delay={200} side="bottom" passthrough>
|
class="py-2 px-2 font-medium max-w-50 truncate"
|
||||||
<span class="block truncate">{course.title}</span>
|
>
|
||||||
|
<SimpleTooltip
|
||||||
|
text={course.title}
|
||||||
|
delay={200}
|
||||||
|
side="bottom"
|
||||||
|
passthrough
|
||||||
|
>
|
||||||
|
<span class="block truncate"
|
||||||
|
>{course.title}</span
|
||||||
|
>
|
||||||
</SimpleTooltip>
|
</SimpleTooltip>
|
||||||
</td>
|
</td>
|
||||||
{:else if colId === "instructor"}
|
{:else if colId === "instructor"}
|
||||||
{@const primary = getPrimaryInstructor(course.instructors)}
|
{@const primary = getPrimaryInstructor(
|
||||||
|
course.instructors,
|
||||||
|
)}
|
||||||
<td class="py-2 px-2 whitespace-nowrap">
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
<SimpleTooltip text={primary?.displayName ?? "Staff"} delay={200} side="bottom" passthrough>
|
<SimpleTooltip
|
||||||
<span>{primaryInstructorDisplay(course)}</span>
|
text={primary?.displayName ??
|
||||||
|
"Staff"}
|
||||||
|
delay={200}
|
||||||
|
side="bottom"
|
||||||
|
passthrough
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
>{primaryInstructorDisplay(
|
||||||
|
course,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
</SimpleTooltip>
|
</SimpleTooltip>
|
||||||
{#if primaryRating(course)}
|
{#if primaryRating(course)}
|
||||||
{@const r = primaryRating(course)!}
|
{@const r =
|
||||||
<SimpleTooltip text="{r.rating.toFixed(1)}/5 ({r.count} ratings on RateMyProfessors)" delay={150} side="bottom" passthrough>
|
primaryRating(course)!}
|
||||||
|
<SimpleTooltip
|
||||||
|
text="{r.rating.toFixed(
|
||||||
|
1,
|
||||||
|
)}/5 ({r.count} ratings on RateMyProfessors)"
|
||||||
|
delay={150}
|
||||||
|
side="bottom"
|
||||||
|
passthrough
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="ml-1 text-xs font-medium {ratingColor(r.rating)}"
|
class="ml-1 text-xs font-medium {ratingColor(
|
||||||
>{r.rating.toFixed(1)}★</span>
|
r.rating,
|
||||||
|
)}"
|
||||||
|
>{r.rating.toFixed(
|
||||||
|
1,
|
||||||
|
)}★</span
|
||||||
|
>
|
||||||
</SimpleTooltip>
|
</SimpleTooltip>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{:else if colId === "time"}
|
{:else if colId === "time"}
|
||||||
<td class="py-2 px-2 whitespace-nowrap">
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
{#if timeIsTBA(course)}
|
{#if timeIsTBA(course)}
|
||||||
<span class="text-xs text-muted-foreground/60">TBA</span>
|
<span
|
||||||
|
class="text-xs text-muted-foreground/60"
|
||||||
|
>TBA</span
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
{@const mt = course.meetingTimes[0]}
|
{@const mt =
|
||||||
|
course.meetingTimes[0]}
|
||||||
{#if !isMeetingTimeTBA(mt)}
|
{#if !isMeetingTimeTBA(mt)}
|
||||||
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
|
<span
|
||||||
|
class="font-mono font-medium"
|
||||||
|
>{formatMeetingDays(
|
||||||
|
mt,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
{" "}
|
{" "}
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isTimeTBA(mt)}
|
{#if !isTimeTBA(mt)}
|
||||||
<span class="text-muted-foreground">{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}</span>
|
<span
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>{formatTime(
|
||||||
|
mt.begin_time,
|
||||||
|
)}–{formatTime(
|
||||||
|
mt.end_time,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xs text-muted-foreground/60">TBA</span>
|
<span
|
||||||
|
class="text-xs text-muted-foreground/60"
|
||||||
|
>TBA</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{:else if colId === "location"}
|
{:else if colId === "location"}
|
||||||
<td class="py-2 px-2 whitespace-nowrap">
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
{#if formatLocation(course)}
|
{#if formatLocation(course)}
|
||||||
<span class="text-muted-foreground">{formatLocation(course)}</span>
|
<span
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>{formatLocation(
|
||||||
|
course,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xs text-muted-foreground/50">—</span>
|
<span
|
||||||
|
class="text-xs text-muted-foreground/50"
|
||||||
|
>—</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{:else if colId === "seats"}
|
{:else if colId === "seats"}
|
||||||
<td class="py-2 px-2 text-right whitespace-nowrap">
|
<td
|
||||||
<SimpleTooltip text="{openSeats(course)} of {course.maxEnrollment} seats open, {course.enrollment} enrolled{course.waitCount > 0 ? `, ${course.waitCount} waitlisted` : ''}" delay={200} side="left" passthrough>
|
class="py-2 px-2 text-right whitespace-nowrap"
|
||||||
<span class="inline-flex items-center gap-1.5">
|
>
|
||||||
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
|
<SimpleTooltip
|
||||||
<span class="{seatsColor(course)} font-medium tabular-nums">{#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if}</span>
|
text="{openSeats(
|
||||||
<span class="text-muted-foreground/60 tabular-nums">{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if}</span>
|
course,
|
||||||
|
)} of {course.maxEnrollment} seats open, {course.enrollment} enrolled{course.waitCount >
|
||||||
|
0
|
||||||
|
? `, ${course.waitCount} waitlisted`
|
||||||
|
: ''}"
|
||||||
|
delay={200}
|
||||||
|
side="left"
|
||||||
|
passthrough
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-1.5 rounded-full {seatsDotColor(
|
||||||
|
course,
|
||||||
|
)} shrink-0"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="{seatsColor(
|
||||||
|
course,
|
||||||
|
)} font-medium tabular-nums"
|
||||||
|
>{#if openSeats(course) === 0}Full{:else}{openSeats(
|
||||||
|
course,
|
||||||
|
)} open{/if}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-muted-foreground/60 tabular-nums"
|
||||||
|
>{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0}
|
||||||
|
· WL {course.waitCount}/{course.waitCapacity}{/if}</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</SimpleTooltip>
|
</SimpleTooltip>
|
||||||
</td>
|
</td>
|
||||||
@@ -481,8 +683,13 @@ const table = createSvelteTable({
|
|||||||
</tr>
|
</tr>
|
||||||
{#if expandedCrn === course.crn}
|
{#if expandedCrn === course.crn}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan={visibleColumnIds.length} class="p-0">
|
<td
|
||||||
<div transition:slide={{ duration: 200 }}>
|
colspan={visibleColumnIds.length}
|
||||||
|
class="p-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
transition:slide={{ duration: 200 }}
|
||||||
|
>
|
||||||
<CourseDetail {course} />
|
<CourseDetail {course} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -495,13 +702,17 @@ const table = createSvelteTable({
|
|||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Portal>
|
<ContextMenu.Portal>
|
||||||
<ContextMenu.Content
|
<ContextMenu.Content
|
||||||
class="z-50 min-w-[160px] rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
||||||
forceMount
|
forceMount
|
||||||
>
|
>
|
||||||
{#snippet child({ wrapperProps, props, open })}
|
{#snippet child({ wrapperProps, props, open })}
|
||||||
{#if open}
|
{#if open}
|
||||||
<div {...wrapperProps}>
|
<div {...wrapperProps}>
|
||||||
<div {...props} in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
|
<div
|
||||||
|
{...props}
|
||||||
|
in:fade={{ duration: 100 }}
|
||||||
|
out:fade={{ duration: 100 }}
|
||||||
|
>
|
||||||
{@render columnVisibilityItems("context")}
|
{@render columnVisibilityItems("context")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,53 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Term, Subject } from "$lib/api";
|
import type { Term, Subject } from "$lib/api";
|
||||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||||
|
import TermCombobox from "./TermCombobox.svelte";
|
||||||
|
import SubjectCombobox from "./SubjectCombobox.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
terms,
|
terms,
|
||||||
subjects,
|
subjects,
|
||||||
selectedTerm = $bindable(),
|
selectedTerm = $bindable(),
|
||||||
selectedSubject = $bindable(),
|
selectedSubjects = $bindable(),
|
||||||
query = $bindable(),
|
query = $bindable(),
|
||||||
openOnly = $bindable(),
|
openOnly = $bindable(),
|
||||||
}: {
|
}: {
|
||||||
terms: Term[];
|
terms: Term[];
|
||||||
subjects: Subject[];
|
subjects: Subject[];
|
||||||
selectedTerm: string;
|
selectedTerm: string;
|
||||||
selectedSubject: string;
|
selectedSubjects: string[];
|
||||||
query: string;
|
query: string;
|
||||||
openOnly: boolean;
|
openOnly: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 items-center">
|
<div class="flex flex-wrap gap-3 items-start">
|
||||||
<select
|
<TermCombobox {terms} bind:value={selectedTerm} />
|
||||||
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
|
<SubjectCombobox {subjects} bind:value={selectedSubjects} />
|
||||||
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search courses..."
|
placeholder="Search courses..."
|
||||||
bind:value={query}
|
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]"
|
class="h-9 border border-border bg-card text-foreground rounded-md px-3 text-sm flex-1 min-w-[200px]
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
|
||||||
|
transition-colors"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SimpleTooltip text="Show only courses with available seats" delay={200} passthrough>
|
<SimpleTooltip text="Show only courses with available seats" delay={200} passthrough>
|
||||||
<label class="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer">
|
<label class="flex items-center gap-1.5 h-9 text-sm text-muted-foreground cursor-pointer">
|
||||||
<input type="checkbox" bind:checked={openOnly} />
|
<input type="checkbox" bind:checked={openOnly} />
|
||||||
Open only
|
Open only
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Combobox } from "bits-ui";
|
||||||
|
import { Check, ChevronsUpDown } from "@lucide/svelte";
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
import type { Subject } from "$lib/api";
|
||||||
|
|
||||||
|
let {
|
||||||
|
subjects,
|
||||||
|
value = $bindable(),
|
||||||
|
}: {
|
||||||
|
subjects: Subject[];
|
||||||
|
value: string[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let searchValue = $state("");
|
||||||
|
let containerEl = $state<HTMLDivElement>(null!);
|
||||||
|
|
||||||
|
const filteredSubjects = $derived.by(() => {
|
||||||
|
const query = searchValue.toLowerCase().trim();
|
||||||
|
if (query === "") return subjects;
|
||||||
|
|
||||||
|
const exactCode: Subject[] = [];
|
||||||
|
const codeStartsWith: Subject[] = [];
|
||||||
|
const descriptionMatch: Subject[] = [];
|
||||||
|
|
||||||
|
for (const s of subjects) {
|
||||||
|
const codeLower = s.code.toLowerCase();
|
||||||
|
const descLower = s.description.toLowerCase();
|
||||||
|
|
||||||
|
if (codeLower === query) {
|
||||||
|
exactCode.push(s);
|
||||||
|
} else if (codeLower.startsWith(query)) {
|
||||||
|
codeStartsWith.push(s);
|
||||||
|
} else if (descLower.includes(query) || codeLower.includes(query)) {
|
||||||
|
descriptionMatch.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...exactCode, ...codeStartsWith, ...descriptionMatch];
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_VISIBLE_CHIPS = 3;
|
||||||
|
const visibleChips = $derived(value.slice(0, MAX_VISIBLE_CHIPS));
|
||||||
|
const overflowCount = $derived(Math.max(0, value.length - MAX_VISIBLE_CHIPS));
|
||||||
|
|
||||||
|
function removeSubject(code: string) {
|
||||||
|
value = value.filter((v) => v !== code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// bits-ui sets the input text to the last selected item's label — clear it
|
||||||
|
$effect(() => {
|
||||||
|
value;
|
||||||
|
const input = containerEl?.querySelector("input");
|
||||||
|
if (input) {
|
||||||
|
input.value = "";
|
||||||
|
searchValue = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Combobox.Root
|
||||||
|
type="multiple"
|
||||||
|
bind:value
|
||||||
|
bind:open
|
||||||
|
onOpenChange={(o: boolean) => {
|
||||||
|
if (!o) searchValue = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="relative h-9 rounded-md border border-border bg-card
|
||||||
|
flex flex-nowrap items-center gap-1 w-56 pr-9 overflow-hidden cursor-pointer
|
||||||
|
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background"
|
||||||
|
bind:this={containerEl}
|
||||||
|
onclick={() => { containerEl?.querySelector('input')?.focus(); }}
|
||||||
|
>
|
||||||
|
{#if value.length > 0}
|
||||||
|
{#each (open ? value : visibleChips) as code (code)}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onmousedown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||||
|
onclick={(e) => { e.stopPropagation(); removeSubject(code); }}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.stopPropagation(); removeSubject(code); } }}
|
||||||
|
class="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs font-mono shrink-0
|
||||||
|
text-muted-foreground hover:outline hover:outline-1 hover:outline-ring
|
||||||
|
cursor-pointer transition-[outline] duration-100 first:ml-2"
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
{#if !open && overflowCount > 0}
|
||||||
|
<span class="text-xs text-muted-foreground shrink-0">+{overflowCount}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<Combobox.Input
|
||||||
|
|
||||||
|
oninput={(e) => (searchValue = e.currentTarget.value)}
|
||||||
|
onfocus={() => { open = true; }}
|
||||||
|
class="h-full min-w-0 flex-1 bg-transparent text-muted-foreground text-sm
|
||||||
|
placeholder:text-muted-foreground outline-none border-none
|
||||||
|
{value.length > 0 ? 'pl-1' : 'pl-3'}"
|
||||||
|
placeholder={value.length > 0 ? "Filter..." : "All Subjects"}
|
||||||
|
aria-label="Search subjects"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck={false}
|
||||||
|
/>
|
||||||
|
<span class="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
|
||||||
|
<ChevronsUpDown class="size-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Combobox.Portal>
|
||||||
|
<Combobox.Content
|
||||||
|
customAnchor={containerEl}
|
||||||
|
class="border border-border bg-card shadow-md
|
||||||
|
outline-hidden z-50
|
||||||
|
max-h-72 min-w-[var(--bits-combobox-anchor-width)] w-max max-w-96
|
||||||
|
select-none rounded-md p-1
|
||||||
|
data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1"
|
||||||
|
sideOffset={4}
|
||||||
|
forceMount
|
||||||
|
>
|
||||||
|
{#snippet child({ wrapperProps, props, open: isOpen })}
|
||||||
|
{#if isOpen}
|
||||||
|
<div {...wrapperProps}>
|
||||||
|
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
|
||||||
|
<Combobox.Viewport class="p-0.5">
|
||||||
|
{#each filteredSubjects as subject (subject.code)}
|
||||||
|
<Combobox.Item
|
||||||
|
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center gap-2 px-2 text-sm whitespace-nowrap
|
||||||
|
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
|
value={subject.code}
|
||||||
|
label={subject.description}
|
||||||
|
>
|
||||||
|
{#snippet children({ selected })}
|
||||||
|
<span class="inline-flex items-center justify-center rounded bg-muted px-1 py-0.5
|
||||||
|
text-xs font-mono text-muted-foreground w-10 shrink-0 text-center">
|
||||||
|
{subject.code}
|
||||||
|
</span>
|
||||||
|
<span class="flex-1">{subject.description}</span>
|
||||||
|
{#if selected}
|
||||||
|
<Check class="ml-auto size-4 shrink-0" />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Combobox.Item>
|
||||||
|
{:else}
|
||||||
|
<span class="block px-2 py-2 text-sm text-muted-foreground">
|
||||||
|
No subjects found.
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</Combobox.Viewport>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Combobox.Content>
|
||||||
|
</Combobox.Portal>
|
||||||
|
</Combobox.Root>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Combobox } from "bits-ui";
|
||||||
|
import { Check, ChevronsUpDown } from "@lucide/svelte";
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
import type { Term } from "$lib/api";
|
||||||
|
|
||||||
|
let {
|
||||||
|
terms,
|
||||||
|
value = $bindable(),
|
||||||
|
}: {
|
||||||
|
terms: Term[];
|
||||||
|
value: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let searchValue = $state("");
|
||||||
|
let containerEl = $state<HTMLDivElement>(null!);
|
||||||
|
|
||||||
|
const currentTermCode = $derived(
|
||||||
|
terms.find((t) => !t.description.includes("(View Only)"))?.code ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedLabel = $derived(
|
||||||
|
terms.find((t) => t.code === value)?.description ?? "Select term..."
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTerms = $derived.by(() => {
|
||||||
|
const query = searchValue.toLowerCase();
|
||||||
|
const matched =
|
||||||
|
query === "" ? terms : terms.filter((t) => t.description.toLowerCase().includes(query));
|
||||||
|
|
||||||
|
const current = matched.find((t) => t.code === currentTermCode);
|
||||||
|
const rest = matched.filter((t) => t.code !== currentTermCode);
|
||||||
|
return current ? [current, ...rest] : rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manage DOM input text: clear when open for searching, restore label when closed
|
||||||
|
$effect(() => {
|
||||||
|
const _open = open;
|
||||||
|
void value; // track selection changes
|
||||||
|
const _label = selectedLabel;
|
||||||
|
const input = containerEl?.querySelector("input");
|
||||||
|
if (!input) return;
|
||||||
|
if (_open) {
|
||||||
|
input.value = "";
|
||||||
|
searchValue = "";
|
||||||
|
} else {
|
||||||
|
input.value = _label;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Combobox.Root
|
||||||
|
type="single"
|
||||||
|
bind:value={() => value, (v) => { if (v) value = v; }}
|
||||||
|
bind:open
|
||||||
|
onOpenChange={(o: boolean) => {
|
||||||
|
if (!o) searchValue = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative h-9 rounded-md border border-border bg-card
|
||||||
|
flex items-center w-56 cursor-pointer
|
||||||
|
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background"
|
||||||
|
bind:this={containerEl}
|
||||||
|
onclick={() => { containerEl?.querySelector('input')?.focus(); }}
|
||||||
|
>
|
||||||
|
<Combobox.Input
|
||||||
|
oninput={(e) => (searchValue = e.currentTarget.value)}
|
||||||
|
onfocus={() => { open = true; }}
|
||||||
|
class="h-full w-full bg-transparent text-muted-foreground text-sm
|
||||||
|
placeholder:text-muted-foreground outline-none border-none
|
||||||
|
pl-3 pr-9 truncate"
|
||||||
|
placeholder="Select term..."
|
||||||
|
aria-label="Select term"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck={false}
|
||||||
|
/>
|
||||||
|
<span class="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
|
||||||
|
<ChevronsUpDown class="size-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Combobox.Portal>
|
||||||
|
<Combobox.Content
|
||||||
|
customAnchor={containerEl}
|
||||||
|
class="border border-border bg-card shadow-md
|
||||||
|
outline-hidden z-50
|
||||||
|
max-h-72 min-w-[var(--bits-combobox-anchor-width)]
|
||||||
|
select-none rounded-md p-1
|
||||||
|
data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1"
|
||||||
|
sideOffset={4}
|
||||||
|
forceMount
|
||||||
|
>
|
||||||
|
{#snippet child({ wrapperProps, props, open: isOpen })}
|
||||||
|
{#if isOpen}
|
||||||
|
<div {...wrapperProps}>
|
||||||
|
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
|
||||||
|
<Combobox.Viewport class="p-0.5">
|
||||||
|
{#each filteredTerms as term, i (term.code)}
|
||||||
|
{#if i === 1 && term.code !== currentTermCode && filteredTerms[0]?.code === currentTermCode}
|
||||||
|
<div class="mx-2 my-1 h-px bg-border"></div>
|
||||||
|
{/if}
|
||||||
|
<Combobox.Item
|
||||||
|
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center px-2 text-sm
|
||||||
|
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
|
||||||
|
{term.code === value ? 'cursor-default' : 'cursor-pointer'}
|
||||||
|
{term.code === currentTermCode ? 'font-medium text-foreground' : 'text-foreground'}"
|
||||||
|
value={term.code}
|
||||||
|
label={term.description}
|
||||||
|
>
|
||||||
|
{#snippet children({ selected })}
|
||||||
|
<span class="flex-1 truncate">
|
||||||
|
{term.description}
|
||||||
|
{#if term.code === currentTermCode}
|
||||||
|
<span class="ml-1.5 text-xs text-muted-foreground font-normal">current</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if selected}
|
||||||
|
<Check class="ml-2 size-4 shrink-0" />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Combobox.Item>
|
||||||
|
{:else}
|
||||||
|
<span class="block px-2 py-2 text-sm text-muted-foreground">
|
||||||
|
No terms found.
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</Combobox.Viewport>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Combobox.Content>
|
||||||
|
</Combobox.Portal>
|
||||||
|
</Combobox.Root>
|
||||||
+14
-15
@@ -20,7 +20,7 @@ const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
|||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
||||||
let selectedSubject = $state(initialParams.get("subject") ?? "");
|
let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("subject")));
|
||||||
let query = $state(initialParams.get("q") ?? "");
|
let query = $state(initialParams.get("q") ?? "");
|
||||||
let openOnly = $state(initialParams.get("open") === "true");
|
let openOnly = $state(initialParams.get("open") === "true");
|
||||||
let offset = $state(Number(initialParams.get("offset")) || 0);
|
let offset = $state(Number(initialParams.get("offset")) || 0);
|
||||||
@@ -64,9 +64,8 @@ $effect(() => {
|
|||||||
if (!term) return;
|
if (!term) return;
|
||||||
client.getSubjects(term).then((s) => {
|
client.getSubjects(term).then((s) => {
|
||||||
subjects = s;
|
subjects = s;
|
||||||
if (selectedSubject && !s.some((sub) => sub.code === selectedSubject)) {
|
const validCodes = new Set(s.map((sub) => sub.code));
|
||||||
selectedSubject = "";
|
selectedSubjects = selectedSubjects.filter((code) => validCodes.has(code));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +73,7 @@ $effect(() => {
|
|||||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const term = selectedTerm;
|
const term = selectedTerm;
|
||||||
const subject = selectedSubject;
|
const subs = selectedSubjects;
|
||||||
const q = query;
|
const q = query;
|
||||||
const open = openOnly;
|
const open = openOnly;
|
||||||
const off = offset;
|
const off = offset;
|
||||||
@@ -82,7 +81,7 @@ $effect(() => {
|
|||||||
|
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
performSearch(term, subject, q, open, off, sort);
|
performSearch(term, subs, q, open, off, sort);
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(searchTimeout);
|
return () => clearTimeout(searchTimeout);
|
||||||
@@ -91,7 +90,7 @@ $effect(() => {
|
|||||||
// Reset offset when filters change (not offset itself)
|
// Reset offset when filters change (not offset itself)
|
||||||
let prevFilters = $state("");
|
let prevFilters = $state("");
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
|
const key = `${selectedTerm}|${selectedSubjects.join(",")}|${query}|${openOnly}`;
|
||||||
if (prevFilters && key !== prevFilters) {
|
if (prevFilters && key !== prevFilters) {
|
||||||
offset = 0;
|
offset = 0;
|
||||||
}
|
}
|
||||||
@@ -100,7 +99,7 @@ $effect(() => {
|
|||||||
|
|
||||||
async function performSearch(
|
async function performSearch(
|
||||||
term: string,
|
term: string,
|
||||||
subject: string,
|
subjects: string[],
|
||||||
q: string,
|
q: string,
|
||||||
open: boolean,
|
open: boolean,
|
||||||
off: number,
|
off: number,
|
||||||
@@ -110,15 +109,15 @@ async function performSearch(
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
// Derive server sort params from TanStack sorting state
|
|
||||||
const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined;
|
const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined;
|
||||||
const sortDir: SortDirection | undefined =
|
const sortDir: SortDirection | undefined =
|
||||||
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
|
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
|
||||||
|
|
||||||
// Sync URL
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("term", term);
|
params.set("term", term);
|
||||||
if (subject) params.set("subject", subject);
|
for (const s of subjects) {
|
||||||
|
params.append("subject", s);
|
||||||
|
}
|
||||||
if (q) params.set("q", q);
|
if (q) params.set("q", q);
|
||||||
if (open) params.set("open", "true");
|
if (open) params.set("open", "true");
|
||||||
if (off > 0) params.set("offset", String(off));
|
if (off > 0) params.set("offset", String(off));
|
||||||
@@ -129,7 +128,7 @@ async function performSearch(
|
|||||||
try {
|
try {
|
||||||
searchResult = await client.searchCourses({
|
searchResult = await client.searchCourses({
|
||||||
term,
|
term,
|
||||||
subject: subject || undefined,
|
subjects: subjects.length > 0 ? subjects : undefined,
|
||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
open_only: open || undefined,
|
open_only: open || undefined,
|
||||||
limit,
|
limit,
|
||||||
@@ -150,7 +149,7 @@ function handlePageChange(newOffset: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex flex-col items-center p-5">
|
<div class="min-h-screen flex flex-col items-center p-5">
|
||||||
<div class="w-full max-w-4xl flex flex-col gap-6">
|
<div class="w-full max-w-6xl flex flex-col gap-6">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div class="text-center pt-8 pb-2">
|
<div class="text-center pt-8 pb-2">
|
||||||
<h1 class="text-2xl font-semibold text-foreground">UTSA Course Search</h1>
|
<h1 class="text-2xl font-semibold text-foreground">UTSA Course Search</h1>
|
||||||
@@ -161,7 +160,7 @@ function handlePageChange(newOffset: number) {
|
|||||||
terms={data.terms}
|
terms={data.terms}
|
||||||
{subjects}
|
{subjects}
|
||||||
bind:selectedTerm
|
bind:selectedTerm
|
||||||
bind:selectedSubject
|
bind:selectedSubjects
|
||||||
bind:query
|
bind:query
|
||||||
bind:openOnly
|
bind:openOnly
|
||||||
/>
|
/>
|
||||||
@@ -171,7 +170,7 @@ function handlePageChange(newOffset: number) {
|
|||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<p class="text-status-red">{error}</p>
|
<p class="text-status-red">{error}</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => performSearch(selectedTerm, selectedSubject, query, openOnly, offset, sorting)}
|
onclick={() => performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting)}
|
||||||
class="mt-2 text-sm text-muted-foreground hover:underline"
|
class="mt-2 text-sm text-muted-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
|
|||||||
@@ -70,6 +70,65 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Focus styling - only visible on keyboard navigation */
|
||||||
|
*:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs get outline-based focus directly on border */
|
||||||
|
input[type="text"]:focus-visible,
|
||||||
|
input[type="search"]:focus-visible,
|
||||||
|
input[type="email"]:focus-visible,
|
||||||
|
input[type="password"]:focus-visible,
|
||||||
|
input[type="number"]:focus-visible,
|
||||||
|
input[type="url"]:focus-visible,
|
||||||
|
input[type="tel"]:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons get rounded box-shadow focus (outline doesn't support border-radius) */
|
||||||
|
button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes get direct outline focus */
|
||||||
|
input[type="checkbox"]:focus-visible,
|
||||||
|
input[type="radio"]:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox styling - theme-aware appearance */
|
||||||
|
input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: var(--card);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-grid;
|
||||||
|
place-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked {
|
||||||
|
background-color: var(--foreground);
|
||||||
|
border-color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background-color: var(--background);
|
||||||
|
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||||
|
}
|
||||||
|
|
||||||
html:not(.no-transition) body,
|
html:not(.no-transition) body,
|
||||||
html:not(.no-transition) body * {
|
html:not(.no-transition) body * {
|
||||||
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
|
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
|
||||||
|
|||||||
Reference in New Issue
Block a user