mirror of
https://github.com/Xevion/banner.git
synced 2026-02-01 08:23:40 -06:00
refactor(web): split CourseTable into modular component structure
Decompose monolithic CourseTable.svelte into separate desktop/mobile views with dedicated cell components and extracted state management for improved maintainability and code organization.
This commit is contained in:
@@ -1,801 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
|
||||
import { useClipboard } from "$lib/composables/useClipboard.svelte";
|
||||
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
|
||||
import {
|
||||
RMP_CONFIDENCE_THRESHOLD,
|
||||
abbreviateInstructor,
|
||||
concernAccentColor,
|
||||
formatLocationDisplay,
|
||||
formatLocationTooltip,
|
||||
formatMeetingDays,
|
||||
formatMeetingTimesTooltip,
|
||||
formatTimeRange,
|
||||
getDeliveryConcern,
|
||||
getPrimaryInstructor,
|
||||
isAsyncOnline,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
openSeats,
|
||||
ratingStyle,
|
||||
rmpUrl,
|
||||
seatsColor,
|
||||
seatsDotColor,
|
||||
} from "$lib/course";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
Star,
|
||||
Triangle,
|
||||
} from "@lucide/svelte";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { ContextMenu, DropdownMenu } from "bits-ui";
|
||||
import { flip } from "svelte/animate";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import { useTooltipDelegation } from "$lib/composables/useTooltipDelegation";
|
||||
import CourseCard from "./CourseCard.svelte";
|
||||
import CourseDetail from "./CourseDetail.svelte";
|
||||
import LazyRichTooltip from "./LazyRichTooltip.svelte";
|
||||
|
||||
let {
|
||||
courses,
|
||||
loading,
|
||||
sorting = [],
|
||||
onSortingChange,
|
||||
manualSorting = false,
|
||||
subjectMap = {},
|
||||
limit = 25,
|
||||
columnVisibility = $bindable({}),
|
||||
}: {
|
||||
courses: CourseResponse[];
|
||||
loading: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
manualSorting?: boolean;
|
||||
subjectMap?: Record<string, string>;
|
||||
limit?: number;
|
||||
columnVisibility?: VisibilityState;
|
||||
} = $props();
|
||||
|
||||
let expandedCrn: string | null = $state(null);
|
||||
let tableWrapper: HTMLDivElement = undefined!;
|
||||
let tableElement: HTMLTableElement = undefined!;
|
||||
const clipboard = useClipboard(1000);
|
||||
|
||||
// Track previous row count so skeleton matches expected result size
|
||||
let previousRowCount = $state(0);
|
||||
$effect(() => {
|
||||
if (courses.length > 0) {
|
||||
previousRowCount = courses.length;
|
||||
}
|
||||
});
|
||||
let skeletonRowCount = $derived(previousRowCount > 0 ? previousRowCount : limit);
|
||||
|
||||
// Animate container height via ResizeObserver
|
||||
let contentHeight = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
if (!tableElement) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
contentHeight = entry.contentRect.height;
|
||||
});
|
||||
observer.observe(tableElement);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Collapse expanded row when the dataset changes to avoid stale detail rows
|
||||
// and FLIP position calculation glitches from lingering expanded content
|
||||
$effect(() => {
|
||||
courses; // track dependency
|
||||
expandedCrn = null;
|
||||
});
|
||||
|
||||
// Skip FLIP on initial load: all items are new so there's nothing to animate,
|
||||
// but Svelte still measures every element's position. $effect runs AFTER the
|
||||
// DOM update, so hadResults is still false during the first results render.
|
||||
let hadResults = $state(false);
|
||||
$effect(() => {
|
||||
if (courses.length > 0) hadResults = true;
|
||||
});
|
||||
|
||||
useOverlayScrollbars(() => tableWrapper, {
|
||||
overflow: { x: "scroll", y: "hidden" },
|
||||
scrollbars: { autoHide: "never" },
|
||||
});
|
||||
|
||||
// Singleton tooltip: one imperative tooltip element for all data-tooltip cells
|
||||
$effect(() => {
|
||||
if (!tableElement) return;
|
||||
const { destroy } = useTooltipDelegation(tableElement);
|
||||
return destroy;
|
||||
});
|
||||
|
||||
function resetColumnVisibility() {
|
||||
columnVisibility = {};
|
||||
}
|
||||
|
||||
function handleVisibilityChange(updater: Updater<VisibilityState>) {
|
||||
const newVisibility = typeof updater === "function" ? updater(columnVisibility) : updater;
|
||||
columnVisibility = newVisibility;
|
||||
}
|
||||
|
||||
// visibleColumnIds and hasCustomVisibility derived after column definitions below
|
||||
|
||||
function toggleRow(crn: string) {
|
||||
expandedCrn = expandedCrn === crn ? null : crn;
|
||||
}
|
||||
|
||||
function primaryInstructorDisplay(course: CourseResponse): string {
|
||||
const primary = getPrimaryInstructor(course.instructors);
|
||||
if (!primary) return "Staff";
|
||||
return abbreviateInstructor(primary.displayName);
|
||||
}
|
||||
|
||||
function timeIsTBA(course: CourseResponse): boolean {
|
||||
if (course.meetingTimes.length === 0) return true;
|
||||
const mt = course.meetingTimes[0];
|
||||
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
|
||||
}
|
||||
|
||||
// Skeleton widths per column ID (used by the raw-HTML skeleton builder)
|
||||
const SKELETON_WIDTHS: Record<string, string> = {
|
||||
crn: "w-10",
|
||||
course_code: "w-20",
|
||||
title: "w-40",
|
||||
instructor: "w-20",
|
||||
time: "w-20",
|
||||
location: "w-20",
|
||||
seats: "w-14 ml-auto",
|
||||
};
|
||||
|
||||
/** Build skeleton rows as a raw HTML string — one innerHTML instead of N×M reactive nodes. */
|
||||
function buildSkeletonHtml(colIds: string[], rowCount: number): string {
|
||||
const cells = colIds
|
||||
.map((id) => {
|
||||
const w = SKELETON_WIDTHS[id] ?? "w-20";
|
||||
return `<td class="py-2.5 px-2"><div class="h-4 bg-muted rounded animate-pulse ${w}"></div></td>`;
|
||||
})
|
||||
.join("");
|
||||
const row = `<tr class="border-b border-border">${cells}</tr>`;
|
||||
return row.repeat(rowCount);
|
||||
}
|
||||
|
||||
/** Build mobile card skeletons as raw HTML. */
|
||||
function buildCardSkeletonHtml(count: number): string {
|
||||
const card = `<div class="rounded-lg border border-border bg-card p-3 animate-pulse"><div class="flex items-baseline justify-between gap-2"><div class="flex items-baseline gap-1.5"><div class="h-4 w-16 bg-muted rounded"></div><div class="h-4 w-32 bg-muted rounded"></div></div><div class="h-4 w-10 bg-muted rounded"></div></div><div class="flex items-center justify-between gap-2 mt-1"><div class="h-3 w-24 bg-muted rounded"></div><div class="h-3 w-20 bg-muted rounded"></div></div></div>`;
|
||||
return card.repeat(count);
|
||||
}
|
||||
|
||||
// Calculate max subject code length for alignment
|
||||
let maxSubjectLength = $derived(
|
||||
courses.length > 0 ? Math.max(...courses.map((c) => c.subject.length)) : 3
|
||||
);
|
||||
|
||||
// Column definitions
|
||||
const columns: ColumnDef<CourseResponse, unknown>[] = [
|
||||
{
|
||||
id: "crn",
|
||||
accessorKey: "crn",
|
||||
header: "CRN",
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "course_code",
|
||||
accessorFn: (row) => `${row.subject} ${row.courseNumber}`,
|
||||
header: "Course",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "title",
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "instructor",
|
||||
accessorFn: (row) => primaryInstructorDisplay(row),
|
||||
header: "Instructor",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "time",
|
||||
accessorFn: (row) => {
|
||||
if (row.meetingTimes.length === 0) return "";
|
||||
const mt = row.meetingTimes[0];
|
||||
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`;
|
||||
},
|
||||
header: "Time",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
accessorFn: (row) => formatLocationDisplay(row) ?? "",
|
||||
header: "Location",
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "seats",
|
||||
accessorFn: (row) => openSeats(row),
|
||||
header: "Seats",
|
||||
enableSorting: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Column IDs that are currently visible */
|
||||
let visibleColumnIds = $derived(
|
||||
columns.map((c) => c.id!).filter((id) => columnVisibility[id] !== false)
|
||||
);
|
||||
|
||||
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
|
||||
|
||||
function handleSortingChange(updater: Updater<SortingState>) {
|
||||
const newSorting = typeof updater === "function" ? updater(sorting) : updater;
|
||||
onSortingChange?.(newSorting);
|
||||
}
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return courses;
|
||||
},
|
||||
getRowId: (row) => String(row.crn),
|
||||
columns,
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting;
|
||||
},
|
||||
get columnVisibility() {
|
||||
return columnVisibility;
|
||||
},
|
||||
},
|
||||
onSortingChange: handleSortingChange,
|
||||
onColumnVisibilityChange: handleVisibilityChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
get getSortedRowModel() {
|
||||
return manualSorting ? undefined : getSortedRowModel<CourseResponse>();
|
||||
},
|
||||
get manualSorting() {
|
||||
return manualSorting;
|
||||
},
|
||||
enableSortingRemoval: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet emptyState()}
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">
|
||||
No courses found. Try adjusting your filters.
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet columnVisibilityGroup(
|
||||
Group: typeof DropdownMenu.Group,
|
||||
GroupHeading: typeof DropdownMenu.GroupHeading,
|
||||
CheckboxItem: typeof DropdownMenu.CheckboxItem,
|
||||
Separator: typeof DropdownMenu.Separator,
|
||||
Item: typeof DropdownMenu.Item,
|
||||
)}
|
||||
<Group>
|
||||
<GroupHeading
|
||||
class="px-2 py-1.5 text-xs font-medium text-muted-foreground select-none"
|
||||
>
|
||||
Toggle columns
|
||||
</GroupHeading>
|
||||
{#each columns as col}
|
||||
{@const id = col.id!}
|
||||
{@const label = typeof col.header === "string" ? col.header : id}
|
||||
<CheckboxItem
|
||||
checked={columnVisibility[id] !== false}
|
||||
closeOnSelect={false}
|
||||
onCheckedChange={(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"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span
|
||||
class="flex size-4 items-center justify-center rounded-sm border border-border"
|
||||
>
|
||||
{#if checked}
|
||||
<Check class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
{label}
|
||||
{/snippet}
|
||||
</CheckboxItem>
|
||||
{/each}
|
||||
</Group>
|
||||
{#if hasCustomVisibility}
|
||||
<Separator class="mx-1 my-1 h-px bg-border" />
|
||||
<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"
|
||||
onSelect={resetColumnVisibility}
|
||||
>
|
||||
<RotateCcw class="size-3.5" />
|
||||
Reset to default
|
||||
</Item>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="flex flex-col gap-2 sm:hidden">
|
||||
{#if loading && courses.length === 0}
|
||||
{@html buildCardSkeletonHtml(skeletonRowCount)}
|
||||
{:else if courses.length === 0 && !loading}
|
||||
{@render emptyState()}
|
||||
{:else}
|
||||
{#each courses as course (course.crn)}
|
||||
<div class="transition-opacity duration-200 {loading ? 'opacity-45 pointer-events-none' : ''}">
|
||||
<CourseCard
|
||||
{course}
|
||||
expanded={expandedCrn === course.crn}
|
||||
onToggle={() => toggleRow(course.crn)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- CourseTable uses sm: (640px) for card/table switch intentionally.
|
||||
The table renders well at smaller widths than the full app layout (which uses md: 768px). -->
|
||||
|
||||
<!-- Desktop table
|
||||
IMPORTANT: !important flags on hidden/block are required because OverlayScrollbars
|
||||
applies inline styles (style="display: ...") to set up its custom scrollbar UI.
|
||||
Inline styles have higher CSS specificity than class utilities, so without !important,
|
||||
the table would remain visible at all viewport widths instead of hiding below 640px. -->
|
||||
<div
|
||||
bind:this={tableWrapper}
|
||||
class="!hidden sm:!block overflow-x-auto overflow-y-hidden transition-[height] duration-200"
|
||||
style:height={contentHeight != null ? `${contentHeight}px` : undefined}
|
||||
style:view-transition-name="search-results"
|
||||
style:contain="layout"
|
||||
data-search-results
|
||||
>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger class="contents">
|
||||
<table bind:this={tableElement} class="w-full min-w-120 md:min-w-160 border-collapse text-sm">
|
||||
<thead>
|
||||
{#each table.getHeaderGroups() as headerGroup}
|
||||
<tr
|
||||
class="border-b border-border text-left text-muted-foreground"
|
||||
>
|
||||
{#each headerGroup.headers as header}
|
||||
{#if header.column.getIsVisible()}
|
||||
<th
|
||||
class="py-2 px-2 font-medium select-none {header.id ===
|
||||
'seats'
|
||||
? 'text-right'
|
||||
: ''}"
|
||||
class:cursor-pointer={header.column.getCanSort()}
|
||||
onclick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{#if header.column.getCanSort()}
|
||||
<span
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
{#if typeof header.column.columnDef.header === "string"}
|
||||
{header.column.columnDef
|
||||
.header}
|
||||
{:else}
|
||||
<FlexRender
|
||||
content={header.column
|
||||
.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
{#if header.column.getIsSorted() === "asc"}
|
||||
<ArrowUp class="size-3.5" />
|
||||
{:else if header.column.getIsSorted() === "desc"}
|
||||
<ArrowDown
|
||||
class="size-3.5"
|
||||
/>
|
||||
{:else}
|
||||
<ArrowUpDown
|
||||
class="size-3.5 text-muted-foreground/40"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{:else if typeof header.column.columnDef.header === "string"}
|
||||
{header.column.columnDef.header}
|
||||
{:else}
|
||||
<FlexRender
|
||||
content={header.column.columnDef
|
||||
.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</th>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</thead>
|
||||
{#if loading && courses.length === 0}
|
||||
<tbody>
|
||||
{@html buildSkeletonHtml(visibleColumnIds, skeletonRowCount)}
|
||||
</tbody>
|
||||
{:else if courses.length === 0 && !loading}
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
colspan={visibleColumnIds.length}
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{@render emptyState()}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{:else}
|
||||
<!-- No out: transition — Svelte outros break table layout (tbody loses positioning and overlaps) -->
|
||||
{#each table.getRowModel().rows as row, i (row.id)}
|
||||
{@const course = row.original}
|
||||
<tbody
|
||||
class="transition-opacity duration-200 animate-fade-in {loading ? 'opacity-45 pointer-events-none' : ''}"
|
||||
animate:flip={{ duration: hadResults ? 300 : 0 }}
|
||||
style:animation-delay="{Math.min(i * 25, 300)}ms"
|
||||
>
|
||||
<tr
|
||||
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)}
|
||||
>
|
||||
{#each visibleColumnIds as colId (colId)}
|
||||
{#if colId === "crn"}
|
||||
<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 select-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70"
|
||||
onclick={(e) =>
|
||||
clipboard.copy(
|
||||
course.crn,
|
||||
e,
|
||||
)}
|
||||
onkeydown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault();
|
||||
clipboard.copy(
|
||||
course.crn,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}}
|
||||
aria-label="Copy CRN {course.crn} to clipboard"
|
||||
>
|
||||
{course.crn}
|
||||
{#if clipboard.copiedValue === 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"}
|
||||
{@const subjectDesc =
|
||||
subjectMap[course.subject]}
|
||||
{@const paddedSubject =
|
||||
course.subject.padStart(
|
||||
maxSubjectLength,
|
||||
" ",
|
||||
)}
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
<span
|
||||
data-tooltip={subjectDesc
|
||||
? `${subjectDesc} ${course.courseNumber}`
|
||||
: `${course.subject} ${course.courseNumber}`}
|
||||
data-tooltip-side="bottom"
|
||||
data-tooltip-delay="200"
|
||||
>
|
||||
<span class="font-semibold font-mono tracking-tight whitespace-pre">{paddedSubject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground font-mono tracking-tight">-{course.sequenceNumber}</span>{/if}
|
||||
</span>
|
||||
</td>
|
||||
{:else if colId === "title"}
|
||||
<td
|
||||
class="py-2 px-2 font-medium max-w-50 truncate"
|
||||
>
|
||||
<span
|
||||
class="block truncate"
|
||||
data-tooltip={course.title}
|
||||
data-tooltip-side="bottom"
|
||||
data-tooltip-delay="200"
|
||||
>{course.title}</span>
|
||||
</td>
|
||||
{:else if colId === "instructor"}
|
||||
{@const primary = getPrimaryInstructor(
|
||||
course.instructors,
|
||||
)}
|
||||
{@const display = primary
|
||||
? abbreviateInstructor(primary.displayName)
|
||||
: "Staff"}
|
||||
{@const commaIdx =
|
||||
display.indexOf(", ")}
|
||||
{@const ratingData = primary?.rmpRating != null
|
||||
? {
|
||||
rating: primary.rmpRating,
|
||||
count: primary.rmpNumRatings ?? 0,
|
||||
legacyId: primary.rmpLegacyId ?? null,
|
||||
}
|
||||
: null}
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
{#if display === "Staff"}
|
||||
<span
|
||||
class="text-xs text-muted-foreground/60 uppercase select-none"
|
||||
>Staff</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
data-tooltip={primary?.displayName ?? "Staff"}
|
||||
data-tooltip-side="bottom"
|
||||
data-tooltip-delay="200"
|
||||
>
|
||||
{#if commaIdx !== -1}
|
||||
<span
|
||||
>{display.slice(
|
||||
0,
|
||||
commaIdx,
|
||||
)},
|
||||
<span
|
||||
class="text-muted-foreground"
|
||||
>{display.slice(
|
||||
commaIdx +
|
||||
1,
|
||||
)}</span
|
||||
></span
|
||||
>
|
||||
{:else}
|
||||
<span>{display}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if ratingData}
|
||||
{@const lowConfidence =
|
||||
ratingData.count <
|
||||
RMP_CONFIDENCE_THRESHOLD}
|
||||
<LazyRichTooltip
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
contentClass="px-2.5 py-1.5"
|
||||
>
|
||||
{#snippet children()}
|
||||
<span
|
||||
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5 select-none"
|
||||
style={ratingStyle(
|
||||
ratingData.rating,
|
||||
themeStore.isDark,
|
||||
)}
|
||||
>
|
||||
{ratingData.rating.toFixed(
|
||||
1,
|
||||
)}
|
||||
{#if lowConfidence}
|
||||
<Triangle
|
||||
class="size-2 fill-current"
|
||||
/>
|
||||
{:else}
|
||||
<Star
|
||||
class="size-2.5 fill-current"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
{ratingData.rating.toFixed(
|
||||
1,
|
||||
)}/5 · {formatNumber(ratingData.count)}
|
||||
ratings
|
||||
{#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD}
|
||||
(low)
|
||||
{/if}
|
||||
{#if ratingData.legacyId != null}
|
||||
·
|
||||
<a
|
||||
href={rmpUrl(
|
||||
ratingData.legacyId,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
RMP
|
||||
<ExternalLink
|
||||
class="size-3"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</LazyRichTooltip>
|
||||
{/if}
|
||||
</td>
|
||||
{:else if colId === "time"}
|
||||
<td
|
||||
class="py-2 px-2 whitespace-nowrap"
|
||||
data-tooltip={formatMeetingTimesTooltip(course.meetingTimes)}
|
||||
>
|
||||
{#if isAsyncOnline(course)}
|
||||
<span
|
||||
class="text-xs text-muted-foreground/60 select-none"
|
||||
>Async</span
|
||||
>
|
||||
{:else if timeIsTBA(course)}
|
||||
<span
|
||||
class="text-xs text-muted-foreground/60 select-none"
|
||||
>TBA</span
|
||||
>
|
||||
{:else}
|
||||
{@const mt =
|
||||
course.meetingTimes[0]}
|
||||
<span>
|
||||
{#if !isMeetingTimeTBA(mt)}
|
||||
<span
|
||||
class="font-mono font-medium"
|
||||
>{formatMeetingDays(
|
||||
mt,
|
||||
)}</span
|
||||
>
|
||||
{" "}
|
||||
{/if}
|
||||
{#if !isTimeTBA(mt)}
|
||||
<span
|
||||
class="text-muted-foreground"
|
||||
>{formatTimeRange(
|
||||
mt.begin_time,
|
||||
mt.end_time,
|
||||
)}</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs text-muted-foreground/60 select-none"
|
||||
>TBA</span
|
||||
>
|
||||
{/if}
|
||||
{#if course.meetingTimes.length > 1}
|
||||
<span
|
||||
class="ml-1 text-xs text-muted-foreground/70 font-medium select-none"
|
||||
>+{course
|
||||
.meetingTimes
|
||||
.length -
|
||||
1}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
{:else if colId === "location"}
|
||||
{@const concern =
|
||||
getDeliveryConcern(course)}
|
||||
{@const accentColor =
|
||||
concernAccentColor(concern)}
|
||||
{@const locTooltip =
|
||||
formatLocationTooltip(course, concern)}
|
||||
{@const locDisplay =
|
||||
formatLocationDisplay(course, concern)}
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
{#if locDisplay}
|
||||
<span
|
||||
class="text-muted-foreground"
|
||||
class:pl-2={accentColor !== null}
|
||||
style:border-left={accentColor
|
||||
? `2px solid ${accentColor}`
|
||||
: undefined}
|
||||
data-tooltip={locTooltip}
|
||||
data-tooltip-delay="200"
|
||||
>
|
||||
{locDisplay}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs text-muted-foreground/50"
|
||||
>—</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
{:else if colId === "seats"}
|
||||
{@const open = openSeats(course)}
|
||||
{@const seatsTip = `${formatNumber(open)} of ${formatNumber(course.maxEnrollment)} seats open, ${formatNumber(course.enrollment)} enrolled${course.waitCount > 0 ? `, ${formatNumber(course.waitCount)} waitlisted` : ""}`}
|
||||
<td
|
||||
class="py-2 px-2 text-right whitespace-nowrap"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 select-none"
|
||||
data-tooltip={seatsTip}
|
||||
data-tooltip-side="left"
|
||||
data-tooltip-delay="200"
|
||||
>
|
||||
<span
|
||||
class="size-1.5 rounded-full {seatsDotColor(
|
||||
course,
|
||||
)} shrink-0"
|
||||
></span>
|
||||
<span
|
||||
class="{seatsColor(
|
||||
course,
|
||||
)} font-medium tabular-nums"
|
||||
>{#if open === 0}Full{:else}{open} open{/if}</span
|
||||
>
|
||||
<span
|
||||
class="text-muted-foreground/60 tabular-nums"
|
||||
>{formatNumber(course.enrollment)}/{formatNumber(course.maxEnrollment)}{#if course.waitCount > 0}
|
||||
· WL {formatNumber(course.waitCount)}/{formatNumber(course.waitCapacity)}{/if}</span
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
{#if expandedCrn === course.crn}
|
||||
<tr>
|
||||
<td
|
||||
colspan={visibleColumnIds.length}
|
||||
class="p-0"
|
||||
>
|
||||
<div
|
||||
transition:slide={{ duration: 200 }}
|
||||
>
|
||||
<CourseDetail {course} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
{/each}
|
||||
{/if}
|
||||
</table>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content
|
||||
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
||||
forceMount
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div
|
||||
{...props}
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
>
|
||||
{@render columnVisibilityGroup(
|
||||
ContextMenu.Group,
|
||||
ContextMenu.GroupHeading,
|
||||
ContextMenu.CheckboxItem,
|
||||
ContextMenu.Separator,
|
||||
ContextMenu.Item,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import type { SortingState, VisibilityState } from "@tanstack/table-core";
|
||||
import { useCourseTableState } from "./useCourseTableState.svelte";
|
||||
import CourseTableDesktop from "./CourseTableDesktop.svelte";
|
||||
import CourseTableMobile from "./CourseTableMobile.svelte";
|
||||
|
||||
let {
|
||||
courses,
|
||||
loading,
|
||||
sorting = [],
|
||||
onSortingChange,
|
||||
manualSorting = false,
|
||||
subjectMap = {},
|
||||
limit = 25,
|
||||
columnVisibility = $bindable({}),
|
||||
}: {
|
||||
courses: CourseResponse[];
|
||||
loading: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
manualSorting?: boolean;
|
||||
subjectMap?: Record<string, string>;
|
||||
limit?: number;
|
||||
columnVisibility?: VisibilityState;
|
||||
} = $props();
|
||||
|
||||
const state = useCourseTableState(
|
||||
() => courses,
|
||||
() => limit
|
||||
);
|
||||
</script>
|
||||
|
||||
<CourseTableMobile
|
||||
{courses}
|
||||
{loading}
|
||||
skeletonRowCount={state.skeletonRowCount}
|
||||
expandedCrn={state.expandedCrn}
|
||||
onToggle={state.toggleRow}
|
||||
/>
|
||||
|
||||
<CourseTableDesktop
|
||||
{courses}
|
||||
{loading}
|
||||
{sorting}
|
||||
{onSortingChange}
|
||||
{manualSorting}
|
||||
{subjectMap}
|
||||
bind:columnVisibility
|
||||
expandedCrn={state.expandedCrn}
|
||||
onToggle={state.toggleRow}
|
||||
skeletonRowCount={state.skeletonRowCount}
|
||||
hadResults={state.hadResults}
|
||||
observeHeight={state.observeHeight}
|
||||
contentHeight={state.contentHeight}
|
||||
/>
|
||||
@@ -0,0 +1,308 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
|
||||
import { useClipboard } from "$lib/composables/useClipboard.svelte";
|
||||
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
|
||||
import { useTooltipDelegation } from "$lib/composables/useTooltipDelegation";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Check, RotateCcw } from "@lucide/svelte";
|
||||
import {
|
||||
type SortingState,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { ContextMenu } from "bits-ui";
|
||||
import { flip } from "svelte/animate";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import { setContext } from "svelte";
|
||||
import CourseDetail from "$lib/components/CourseDetail.svelte";
|
||||
import { COLUMN_DEFS, CELL_COMPONENTS } from "./columns";
|
||||
import { buildSkeletonHtml } from "./skeletons";
|
||||
import EmptyState from "./EmptyState.svelte";
|
||||
import { TABLE_CONTEXT_KEY } from "./context";
|
||||
|
||||
let {
|
||||
courses,
|
||||
loading,
|
||||
sorting = [],
|
||||
onSortingChange,
|
||||
manualSorting = false,
|
||||
subjectMap = {},
|
||||
columnVisibility = $bindable({}),
|
||||
expandedCrn,
|
||||
onToggle,
|
||||
skeletonRowCount,
|
||||
hadResults,
|
||||
observeHeight,
|
||||
contentHeight,
|
||||
}: {
|
||||
courses: CourseResponse[];
|
||||
loading: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
manualSorting?: boolean;
|
||||
subjectMap?: Record<string, string>;
|
||||
columnVisibility?: VisibilityState;
|
||||
expandedCrn: string | null;
|
||||
onToggle: (crn: string) => void;
|
||||
skeletonRowCount: number;
|
||||
hadResults: boolean;
|
||||
observeHeight: (el: HTMLTableElement) => () => void;
|
||||
contentHeight: number | null;
|
||||
} = $props();
|
||||
|
||||
let tableWrapper: HTMLDivElement = undefined!;
|
||||
let tableElement: HTMLTableElement = undefined!;
|
||||
const clipboard = useClipboard(1000);
|
||||
|
||||
// Set context once for all cells - shared utilities
|
||||
setContext(TABLE_CONTEXT_KEY, {
|
||||
clipboard,
|
||||
get subjectMap() {
|
||||
return subjectMap;
|
||||
},
|
||||
get maxSubjectLength() {
|
||||
return maxSubjectLength;
|
||||
},
|
||||
});
|
||||
|
||||
useOverlayScrollbars(() => tableWrapper, {
|
||||
overflow: { x: "scroll", y: "hidden" },
|
||||
scrollbars: { autoHide: "never" },
|
||||
});
|
||||
|
||||
// Singleton tooltip delegation
|
||||
$effect(() => {
|
||||
if (!tableElement) return;
|
||||
const { destroy } = useTooltipDelegation(tableElement);
|
||||
return destroy;
|
||||
});
|
||||
|
||||
// Height observation via composable
|
||||
$effect(() => {
|
||||
if (!tableElement) return;
|
||||
return observeHeight(tableElement);
|
||||
});
|
||||
|
||||
let maxSubjectLength = $derived(
|
||||
courses.length > 0 ? Math.max(...courses.map((c) => c.subject.length)) : 3
|
||||
);
|
||||
|
||||
let visibleColumnIds = $derived(
|
||||
COLUMN_DEFS.map((c) => c.id!).filter((id) => columnVisibility[id] !== false)
|
||||
);
|
||||
|
||||
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
|
||||
|
||||
function resetColumnVisibility() {
|
||||
columnVisibility = {};
|
||||
}
|
||||
|
||||
function handleVisibilityChange(updater: Updater<VisibilityState>) {
|
||||
const newVisibility = typeof updater === "function" ? updater(columnVisibility) : updater;
|
||||
columnVisibility = newVisibility;
|
||||
}
|
||||
|
||||
function handleSortingChange(updater: Updater<SortingState>) {
|
||||
const newSorting = typeof updater === "function" ? updater(sorting) : updater;
|
||||
onSortingChange?.(newSorting);
|
||||
}
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return courses;
|
||||
},
|
||||
getRowId: (row) => String(row.crn),
|
||||
columns: COLUMN_DEFS,
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting;
|
||||
},
|
||||
get columnVisibility() {
|
||||
return columnVisibility;
|
||||
},
|
||||
},
|
||||
onSortingChange: handleSortingChange,
|
||||
onColumnVisibilityChange: handleVisibilityChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
get getSortedRowModel() {
|
||||
return manualSorting ? undefined : getSortedRowModel<CourseResponse>();
|
||||
},
|
||||
get manualSorting() {
|
||||
return manualSorting;
|
||||
},
|
||||
enableSortingRemoval: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Desktop table
|
||||
IMPORTANT: !important flags on hidden/block are required because OverlayScrollbars
|
||||
applies inline styles (style="display: ...") to set up its custom scrollbar UI. -->
|
||||
<div
|
||||
bind:this={tableWrapper}
|
||||
class="!hidden sm:!block overflow-x-auto overflow-y-hidden transition-[height] duration-200"
|
||||
style:height={contentHeight != null ? `${contentHeight}px` : undefined}
|
||||
style:view-transition-name="search-results"
|
||||
style:contain="layout"
|
||||
data-search-results
|
||||
>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger class="contents">
|
||||
<table bind:this={tableElement} class="w-full min-w-120 md:min-w-160 border-collapse text-sm">
|
||||
<thead>
|
||||
{#each table.getHeaderGroups() as headerGroup}
|
||||
<tr class="border-b border-border text-left text-muted-foreground">
|
||||
{#each headerGroup.headers as header}
|
||||
{#if header.column.getIsVisible()}
|
||||
<th
|
||||
class="py-2 px-2 font-medium select-none {header.id === 'seats' ? 'text-right' : ''}"
|
||||
class:cursor-pointer={header.column.getCanSort()}
|
||||
onclick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{#if header.column.getCanSort()}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{#if typeof header.column.columnDef.header === "string"}
|
||||
{header.column.columnDef.header}
|
||||
{:else}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
{#if header.column.getIsSorted() === "asc"}
|
||||
<ArrowUp class="size-3.5" />
|
||||
{:else if header.column.getIsSorted() === "desc"}
|
||||
<ArrowDown class="size-3.5" />
|
||||
{:else}
|
||||
<ArrowUpDown class="size-3.5 text-muted-foreground/40" />
|
||||
{/if}
|
||||
</span>
|
||||
{:else if typeof header.column.columnDef.header === "string"}
|
||||
{header.column.columnDef.header}
|
||||
{:else}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</th>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</thead>
|
||||
{#if loading && courses.length === 0}
|
||||
<tbody>
|
||||
{@html buildSkeletonHtml(visibleColumnIds, skeletonRowCount)}
|
||||
</tbody>
|
||||
{:else if courses.length === 0 && !loading}
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
colspan={visibleColumnIds.length}
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
<EmptyState />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{:else}
|
||||
{#each table.getRowModel().rows as row, i (row.id)}
|
||||
{@const course = row.original}
|
||||
<tbody
|
||||
class="transition-opacity duration-200 animate-fade-in {loading ? 'opacity-45 pointer-events-none' : ''}"
|
||||
animate:flip={{ duration: hadResults ? 300 : 0 }}
|
||||
style:animation-delay="{Math.min(i * 25, 300)}ms"
|
||||
>
|
||||
<tr
|
||||
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
|
||||
onclick={() => onToggle(course.crn)}
|
||||
>
|
||||
{#each visibleColumnIds as colId (colId)}
|
||||
{@const CellComponent = CELL_COMPONENTS[colId]}
|
||||
{#if CellComponent}
|
||||
<CellComponent {course} />
|
||||
{:else}
|
||||
<td class="py-2 px-2 text-muted-foreground">—</td>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
{#if expandedCrn === course.crn}
|
||||
<tr>
|
||||
<td colspan={visibleColumnIds.length} class="p-0">
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<CourseDetail {course} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
{/each}
|
||||
{/if}
|
||||
</table>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content
|
||||
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
||||
forceMount
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div
|
||||
{...props}
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
>
|
||||
<ContextMenu.Group>
|
||||
<ContextMenu.GroupHeading
|
||||
class="px-2 py-1.5 text-xs font-medium text-muted-foreground select-none"
|
||||
>
|
||||
Toggle columns
|
||||
</ContextMenu.GroupHeading>
|
||||
{#each COLUMN_DEFS as col}
|
||||
{@const id = col.id!}
|
||||
{@const label = typeof col.header === "string" ? col.header : id}
|
||||
<ContextMenu.CheckboxItem
|
||||
checked={columnVisibility[id] !== false}
|
||||
closeOnSelect={false}
|
||||
onCheckedChange={(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"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span
|
||||
class="flex size-4 items-center justify-center rounded-sm border border-border"
|
||||
>
|
||||
{#if checked}
|
||||
<Check class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
{label}
|
||||
{/snippet}
|
||||
</ContextMenu.CheckboxItem>
|
||||
{/each}
|
||||
</ContextMenu.Group>
|
||||
{#if hasCustomVisibility}
|
||||
<ContextMenu.Separator class="mx-1 my-1 h-px bg-border" />
|
||||
<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"
|
||||
onSelect={resetColumnVisibility}
|
||||
>
|
||||
<RotateCcw class="size-3.5" />
|
||||
Reset to default
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import CourseCard from "$lib/components/CourseCard.svelte";
|
||||
import { buildCardSkeletonHtml } from "./skeletons";
|
||||
import EmptyState from "./EmptyState.svelte";
|
||||
|
||||
let {
|
||||
courses,
|
||||
loading,
|
||||
skeletonRowCount,
|
||||
expandedCrn,
|
||||
onToggle,
|
||||
}: {
|
||||
courses: CourseResponse[];
|
||||
loading: boolean;
|
||||
skeletonRowCount: number;
|
||||
expandedCrn: string | null;
|
||||
onToggle: (crn: string) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:hidden">
|
||||
{#if loading && courses.length === 0}
|
||||
{@html buildCardSkeletonHtml(skeletonRowCount)}
|
||||
{:else if courses.length === 0 && !loading}
|
||||
<EmptyState />
|
||||
{:else}
|
||||
{#each courses as course (course.crn)}
|
||||
<div class="transition-opacity duration-200 {loading ? 'opacity-45 pointer-events-none' : ''}">
|
||||
<CourseCard
|
||||
{course}
|
||||
expanded={expandedCrn === course.crn}
|
||||
onToggle={() => onToggle(course.crn)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
// Empty state component for course table
|
||||
</script>
|
||||
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">
|
||||
No courses found. Try adjusting your filters.
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import { getTableContext } from "../context";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
const { subjectMap, maxSubjectLength } = getTableContext();
|
||||
|
||||
let subjectDesc = $derived(subjectMap[course.subject]);
|
||||
let paddedSubject = $derived(course.subject.padStart(maxSubjectLength, " "));
|
||||
</script>
|
||||
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
<span
|
||||
data-tooltip={subjectDesc
|
||||
? `${subjectDesc} ${course.courseNumber}`
|
||||
: `${course.subject} ${course.courseNumber}`}
|
||||
data-tooltip-side="bottom"
|
||||
data-tooltip-delay="200"
|
||||
>
|
||||
<span class="font-semibold font-mono tracking-tight whitespace-pre">{paddedSubject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground font-mono tracking-tight">-{course.sequenceNumber}</span>{/if}
|
||||
</span>
|
||||
</td>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import { fade } from "svelte/transition";
|
||||
import { getTableContext } from "../context";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
const { clipboard } = getTableContext();
|
||||
</script>
|
||||
|
||||
<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 select-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70"
|
||||
onclick={(e) => clipboard.copy(course.crn, e)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
clipboard.copy(course.crn, e);
|
||||
}
|
||||
}}
|
||||
aria-label="Copy CRN {course.crn} to clipboard"
|
||||
>
|
||||
{course.crn}
|
||||
{#if clipboard.copiedValue === 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>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
RMP_CONFIDENCE_THRESHOLD,
|
||||
abbreviateInstructor,
|
||||
getPrimaryInstructor,
|
||||
ratingStyle,
|
||||
rmpUrl,
|
||||
} from "$lib/course";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
import { ExternalLink, Star, Triangle } from "@lucide/svelte";
|
||||
import LazyRichTooltip from "$lib/components/LazyRichTooltip.svelte";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
let primary = $derived(getPrimaryInstructor(course.instructors));
|
||||
let display = $derived(primary ? abbreviateInstructor(primary.displayName) : "Staff");
|
||||
let commaIdx = $derived(display.indexOf(", "));
|
||||
let ratingData = $derived(
|
||||
primary?.rmpRating != null
|
||||
? {
|
||||
rating: primary.rmpRating,
|
||||
count: primary.rmpNumRatings ?? 0,
|
||||
legacyId: primary.rmpLegacyId ?? null,
|
||||
}
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
{#if display === "Staff"}
|
||||
<span class="text-xs text-muted-foreground/60 uppercase select-none">Staff</span>
|
||||
{:else}
|
||||
<span
|
||||
data-tooltip={primary?.displayName ?? "Staff"}
|
||||
data-tooltip-side="bottom"
|
||||
data-tooltip-delay="200"
|
||||
>
|
||||
{#if commaIdx !== -1}
|
||||
<span
|
||||
>{display.slice(0, commaIdx)},
|
||||
<span class="text-muted-foreground"
|
||||
>{display.slice(commaIdx + 1)}</span
|
||||
></span
|
||||
>
|
||||
{:else}
|
||||
<span>{display}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if ratingData}
|
||||
{@const lowConfidence = ratingData.count < RMP_CONFIDENCE_THRESHOLD}
|
||||
<LazyRichTooltip side="bottom" sideOffset={6} contentClass="px-2.5 py-1.5">
|
||||
{#snippet children()}
|
||||
<span
|
||||
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5 select-none"
|
||||
style={ratingStyle(ratingData.rating, themeStore.isDark)}
|
||||
>
|
||||
{ratingData.rating.toFixed(1)}
|
||||
{#if lowConfidence}
|
||||
<Triangle class="size-2 fill-current" />
|
||||
{:else}
|
||||
<Star class="size-2.5 fill-current" />
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs">
|
||||
{ratingData.rating.toFixed(1)}/5 · {formatNumber(ratingData.count)}
|
||||
ratings
|
||||
{#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD}
|
||||
(low)
|
||||
{/if}
|
||||
{#if ratingData.legacyId != null}
|
||||
·
|
||||
<a
|
||||
href={rmpUrl(ratingData.legacyId)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
RMP
|
||||
<ExternalLink class="size-3" />
|
||||
</a>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</LazyRichTooltip>
|
||||
{/if}
|
||||
</td>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
concernAccentColor,
|
||||
formatLocationDisplay,
|
||||
formatLocationTooltip,
|
||||
getDeliveryConcern,
|
||||
} from "$lib/course";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
let concern = $derived(getDeliveryConcern(course));
|
||||
let accentColor = $derived(concernAccentColor(concern));
|
||||
let locTooltip = $derived(formatLocationTooltip(course, concern));
|
||||
let locDisplay = $derived(formatLocationDisplay(course, concern));
|
||||
</script>
|
||||
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
{#if locDisplay}
|
||||
<span
|
||||
class="text-muted-foreground"
|
||||
class:pl-2={accentColor !== null}
|
||||
style:border-left={accentColor ? `2px solid ${accentColor}` : undefined}
|
||||
data-tooltip={locTooltip}
|
||||
data-tooltip-delay="200"
|
||||
>
|
||||
{locDisplay}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground/50">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import { openSeats, seatsColor, seatsDotColor } from "$lib/course";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
let open = $derived(openSeats(course));
|
||||
let seatsTip = $derived(
|
||||
`${formatNumber(open)} of ${formatNumber(course.maxEnrollment)} seats open, ${formatNumber(course.enrollment)} enrolled${course.waitCount > 0 ? `, ${formatNumber(course.waitCount)} waitlisted` : ""}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<td class="py-2 px-2 text-right whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 select-none"
|
||||
data-tooltip={seatsTip}
|
||||
data-tooltip-side="left"
|
||||
data-tooltip-delay="200"
|
||||
>
|
||||
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
|
||||
<span class="{seatsColor(course)} font-medium tabular-nums"
|
||||
>{#if open === 0}Full{:else}{open} open{/if}</span
|
||||
>
|
||||
<span class="text-muted-foreground/60 tabular-nums"
|
||||
>{formatNumber(course.enrollment)}/{formatNumber(course.maxEnrollment)}{#if course.waitCount > 0}
|
||||
· WL {formatNumber(course.waitCount)}/{formatNumber(course.waitCapacity)}{/if}</span
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
formatMeetingDays,
|
||||
formatMeetingTimesTooltip,
|
||||
formatTimeRange,
|
||||
isAsyncOnline,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
} from "$lib/course";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
function timeIsTBA(c: CourseResponse): boolean {
|
||||
if (c.meetingTimes.length === 0) return true;
|
||||
const mt = c.meetingTimes[0];
|
||||
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
|
||||
}
|
||||
</script>
|
||||
|
||||
<td
|
||||
class="py-2 px-2 whitespace-nowrap"
|
||||
data-tooltip={formatMeetingTimesTooltip(course.meetingTimes)}
|
||||
>
|
||||
{#if isAsyncOnline(course)}
|
||||
<span class="text-xs text-muted-foreground/60 select-none">Async</span>
|
||||
{:else if timeIsTBA(course)}
|
||||
<span class="text-xs text-muted-foreground/60 select-none">TBA</span>
|
||||
{:else}
|
||||
{@const mt = course.meetingTimes[0]}
|
||||
<span>
|
||||
{#if !isMeetingTimeTBA(mt)}
|
||||
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
|
||||
{" "}
|
||||
{/if}
|
||||
{#if !isTimeTBA(mt)}
|
||||
<span class="text-muted-foreground"
|
||||
>{formatTimeRange(mt.begin_time, mt.end_time)}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground/60 select-none">TBA</span>
|
||||
{/if}
|
||||
{#if course.meetingTimes.length > 1}
|
||||
<span class="ml-1 text-xs text-muted-foreground/70 font-medium select-none"
|
||||
>+{course.meetingTimes.length - 1}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
</script>
|
||||
|
||||
<td class="py-2 px-2 font-medium max-w-50 truncate">
|
||||
<span
|
||||
class="block truncate"
|
||||
data-tooltip={course.title}
|
||||
data-tooltip-side="bottom"
|
||||
data-tooltip-delay="200"
|
||||
>{course.title}</span>
|
||||
</td>
|
||||
@@ -0,0 +1,85 @@
|
||||
// columns.ts
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import type { ColumnDef } from "@tanstack/table-core";
|
||||
import type { Component } from "svelte";
|
||||
import {
|
||||
abbreviateInstructor,
|
||||
formatLocationDisplay,
|
||||
formatMeetingDays,
|
||||
formatTimeRange,
|
||||
getPrimaryInstructor,
|
||||
openSeats,
|
||||
} from "$lib/course";
|
||||
|
||||
import CrnCell from "./cells/CrnCell.svelte";
|
||||
import CourseCodeCell from "./cells/CourseCodeCell.svelte";
|
||||
import TitleCell from "./cells/TitleCell.svelte";
|
||||
import InstructorCell from "./cells/InstructorCell.svelte";
|
||||
import TimeCell from "./cells/TimeCell.svelte";
|
||||
import LocationCell from "./cells/LocationCell.svelte";
|
||||
import SeatsCell from "./cells/SeatsCell.svelte";
|
||||
|
||||
export const COLUMN_DEFS: ColumnDef<CourseResponse, unknown>[] = [
|
||||
{
|
||||
id: "crn",
|
||||
accessorKey: "crn",
|
||||
header: "CRN",
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "course_code",
|
||||
accessorFn: (row) => `${row.subject} ${row.courseNumber}`,
|
||||
header: "Course",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "title",
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "instructor",
|
||||
accessorFn: (row) => {
|
||||
const primary = getPrimaryInstructor(row.instructors);
|
||||
if (!primary) return "Staff";
|
||||
return abbreviateInstructor(primary.displayName);
|
||||
},
|
||||
header: "Instructor",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "time",
|
||||
accessorFn: (row) => {
|
||||
if (row.meetingTimes.length === 0) return "";
|
||||
const mt = row.meetingTimes[0];
|
||||
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`;
|
||||
},
|
||||
header: "Time",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
accessorFn: (row) => formatLocationDisplay(row) ?? "",
|
||||
header: "Location",
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "seats",
|
||||
accessorFn: (row) => openSeats(row),
|
||||
header: "Seats",
|
||||
enableSorting: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Column ID to Svelte cell component. Used by the row renderer. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const CELL_COMPONENTS: Record<string, Component<any>> = {
|
||||
crn: CrnCell,
|
||||
course_code: CourseCodeCell,
|
||||
title: TitleCell,
|
||||
instructor: InstructorCell,
|
||||
time: TimeCell,
|
||||
location: LocationCell,
|
||||
seats: SeatsCell,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { useClipboard } from "$lib/composables/useClipboard.svelte";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
export const TABLE_CONTEXT_KEY = Symbol("table-context");
|
||||
|
||||
export type TableContext = {
|
||||
clipboard: ReturnType<typeof useClipboard>;
|
||||
subjectMap: Record<string, string>;
|
||||
maxSubjectLength: number;
|
||||
};
|
||||
|
||||
/** Type-safe utility for accessing table context in cell components */
|
||||
export function getTableContext(): TableContext {
|
||||
return getContext<TableContext>(TABLE_CONTEXT_KEY);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CourseTable } from "./CourseTable.svelte";
|
||||
@@ -0,0 +1,25 @@
|
||||
export const SKELETON_WIDTHS: Record<string, string> = {
|
||||
crn: "w-10",
|
||||
course_code: "w-20",
|
||||
title: "w-40",
|
||||
instructor: "w-20",
|
||||
time: "w-20",
|
||||
location: "w-20",
|
||||
seats: "w-14 ml-auto",
|
||||
};
|
||||
|
||||
export function buildSkeletonHtml(colIds: string[], rowCount: number): string {
|
||||
const cells = colIds
|
||||
.map((id) => {
|
||||
const w = SKELETON_WIDTHS[id] ?? "w-20";
|
||||
return `<td class="py-2.5 px-2"><div class="h-4 bg-muted rounded animate-pulse ${w}"></div></td>`;
|
||||
})
|
||||
.join("");
|
||||
const row = `<tr class="border-b border-border">${cells}</tr>`;
|
||||
return row.repeat(rowCount);
|
||||
}
|
||||
|
||||
export function buildCardSkeletonHtml(count: number): string {
|
||||
const card = `<div class="rounded-lg border border-border bg-card p-3 animate-pulse"><div class="flex items-baseline justify-between gap-2"><div class="flex items-baseline gap-1.5"><div class="h-4 w-16 bg-muted rounded"></div><div class="h-4 w-32 bg-muted rounded"></div></div><div class="h-4 w-10 bg-muted rounded"></div></div><div class="flex items-center justify-between gap-2 mt-1"><div class="h-3 w-24 bg-muted rounded"></div><div class="h-3 w-20 bg-muted rounded"></div></div></div>`;
|
||||
return card.repeat(count);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// useCourseTableState.svelte.ts
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
|
||||
export function useCourseTableState(getCourses: () => CourseResponse[], getLimit: () => number) {
|
||||
let expandedCrn: string | null = $state(null);
|
||||
let previousRowCount = $state(0);
|
||||
let hadResults = $state(false);
|
||||
let contentHeight = $state<number | null>(null);
|
||||
|
||||
// Track previous row count so skeleton matches expected result size
|
||||
$effect(() => {
|
||||
const courses = getCourses();
|
||||
if (courses.length > 0) {
|
||||
previousRowCount = courses.length;
|
||||
}
|
||||
});
|
||||
|
||||
let skeletonRowCount = $derived(previousRowCount > 0 ? previousRowCount : getLimit());
|
||||
|
||||
// Collapse expanded row when dataset changes
|
||||
$effect(() => {
|
||||
getCourses(); // track dependency
|
||||
expandedCrn = null;
|
||||
});
|
||||
|
||||
// Skip FLIP on initial load
|
||||
$effect(() => {
|
||||
if (getCourses().length > 0) hadResults = true;
|
||||
});
|
||||
|
||||
function toggleRow(crn: string) {
|
||||
expandedCrn = expandedCrn === crn ? null : crn;
|
||||
}
|
||||
|
||||
/** Bind to the table element to track content height via ResizeObserver */
|
||||
function observeHeight(tableElement: HTMLTableElement) {
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
contentHeight = entry.contentRect.height;
|
||||
});
|
||||
observer.observe(tableElement);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
return {
|
||||
get expandedCrn() {
|
||||
return expandedCrn;
|
||||
},
|
||||
get skeletonRowCount() {
|
||||
return skeletonRowCount;
|
||||
},
|
||||
get hadResults() {
|
||||
return hadResults;
|
||||
},
|
||||
get contentHeight() {
|
||||
return contentHeight;
|
||||
},
|
||||
toggleRow,
|
||||
observeHeight,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type Subject,
|
||||
client,
|
||||
} from "$lib/api";
|
||||
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||
import { CourseTable } from "$lib/components/course-table";
|
||||
import FilterChip from "$lib/components/FilterChip.svelte";
|
||||
import Footer from "$lib/components/Footer.svelte";
|
||||
import Pagination from "$lib/components/Pagination.svelte";
|
||||
|
||||
Reference in New Issue
Block a user