mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 02:23:34 -06:00
788 lines
40 KiB
Svelte
788 lines
40 KiB
Svelte
<script lang="ts">
|
|
import type { CourseResponse } from "$lib/api";
|
|
import {
|
|
abbreviateInstructor,
|
|
concernAccentColor,
|
|
formatLocationDisplay,
|
|
formatLocationTooltip,
|
|
formatMeetingDays,
|
|
formatMeetingTimesTooltip,
|
|
formatTimeRange,
|
|
getDeliveryConcern,
|
|
getPrimaryInstructor,
|
|
isMeetingTimeTBA,
|
|
isTimeTBA,
|
|
openSeats,
|
|
seatsColor,
|
|
seatsDotColor,
|
|
ratingStyle,
|
|
rmpUrl,
|
|
RMP_CONFIDENCE_THRESHOLD,
|
|
} from "$lib/course";
|
|
import { themeStore } from "$lib/stores/theme.svelte";
|
|
import { useClipboard } from "$lib/composables/useClipboard.svelte";
|
|
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
|
|
import CourseDetail from "./CourseDetail.svelte";
|
|
import { fade, fly, slide } from "svelte/transition";
|
|
import { flip } from "svelte/animate";
|
|
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
|
import {
|
|
getCoreRowModel,
|
|
getSortedRowModel,
|
|
type ColumnDef,
|
|
type SortingState,
|
|
type VisibilityState,
|
|
type Updater,
|
|
} from "@tanstack/table-core";
|
|
import {
|
|
ArrowUp,
|
|
ArrowDown,
|
|
ArrowUpDown,
|
|
Columns3,
|
|
Check,
|
|
RotateCcw,
|
|
Star,
|
|
Triangle,
|
|
ExternalLink,
|
|
} from "@lucide/svelte";
|
|
import { DropdownMenu, ContextMenu, Tooltip } from "bits-ui";
|
|
import { cn, tooltipContentClass } from "$lib/utils";
|
|
import SimpleTooltip from "./SimpleTooltip.svelte";
|
|
|
|
let {
|
|
courses,
|
|
loading,
|
|
sorting = [],
|
|
onSortingChange,
|
|
manualSorting = false,
|
|
subjectMap = {},
|
|
}: {
|
|
courses: CourseResponse[];
|
|
loading: boolean;
|
|
sorting?: SortingState;
|
|
onSortingChange?: (sorting: SortingState) => void;
|
|
manualSorting?: boolean;
|
|
subjectMap?: Record<string, string>;
|
|
} = $props();
|
|
|
|
let expandedCrn: string | null = $state(null);
|
|
let tableWrapper: HTMLDivElement = undefined!;
|
|
const clipboard = useClipboard(1000);
|
|
|
|
// 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;
|
|
});
|
|
|
|
useOverlayScrollbars(() => tableWrapper, {
|
|
overflow: { x: "scroll", y: "hidden" },
|
|
scrollbars: { autoHide: "never" },
|
|
});
|
|
|
|
// Column visibility state
|
|
let columnVisibility: VisibilityState = $state({});
|
|
|
|
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 primaryRating(
|
|
course: CourseResponse
|
|
): { rating: number; count: number; legacyId: number | null } | null {
|
|
const primary = getPrimaryInstructor(course.instructors);
|
|
if (!primary?.rmpRating) return null;
|
|
return {
|
|
rating: primary.rmpRating,
|
|
count: primary.rmpNumRatings ?? 0,
|
|
legacyId: primary.rmpLegacyId ?? null,
|
|
};
|
|
}
|
|
|
|
function timeIsTBA(course: CourseResponse): boolean {
|
|
if (course.meetingTimes.length === 0) return true;
|
|
const mt = course.meetingTimes[0];
|
|
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
|
|
}
|
|
|
|
// 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 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"
|
|
>
|
|
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}
|
|
|
|
<!-- Toolbar: View columns button -->
|
|
<div class="flex items-center justify-end pb-2">
|
|
<DropdownMenu.Root>
|
|
<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"
|
|
>
|
|
<Columns3 class="size-3.5" />
|
|
View
|
|
</DropdownMenu.Trigger>
|
|
<DropdownMenu.Portal>
|
|
<DropdownMenu.Content
|
|
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
|
align="end"
|
|
sideOffset={4}
|
|
forceMount
|
|
>
|
|
{#snippet child({ wrapperProps, props, open })}
|
|
{#if open}
|
|
<div {...wrapperProps}>
|
|
<div
|
|
{...props}
|
|
transition:fly={{ duration: 150, y: -10 }}
|
|
>
|
|
{@render columnVisibilityGroup(
|
|
DropdownMenu.Group,
|
|
DropdownMenu.GroupHeading,
|
|
DropdownMenu.CheckboxItem,
|
|
DropdownMenu.Separator,
|
|
DropdownMenu.Item,
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
</DropdownMenu.Content>
|
|
</DropdownMenu.Portal>
|
|
</DropdownMenu.Root>
|
|
</div>
|
|
|
|
<!-- Table with context menu on header -->
|
|
<div bind:this={tableWrapper} class="overflow-x-auto">
|
|
<ContextMenu.Root>
|
|
<ContextMenu.Trigger class="contents">
|
|
<table class="w-full 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 {header.id ===
|
|
'seats'
|
|
? 'text-right'
|
|
: ''}"
|
|
class:cursor-pointer={header.column.getCanSort()}
|
|
class:select-none={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>
|
|
{#each Array(5) as _}
|
|
<tr class="border-b border-border">
|
|
{#each table.getVisibleLeafColumns() as col}
|
|
<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>
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
{:else if courses.length === 0}
|
|
<tbody>
|
|
<tr>
|
|
<td
|
|
colspan={visibleColumnIds.length}
|
|
class="py-12 text-center text-muted-foreground"
|
|
>
|
|
No courses found. Try adjusting your filters.
|
|
</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
|
|
animate:flip={{ duration: 300 }}
|
|
in:fade={{
|
|
duration: 200,
|
|
delay: Math.min(i * 20, 400),
|
|
}}
|
|
>
|
|
<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 row.getVisibleCells() as cell (cell.id)}
|
|
{@const colId = cell.column.id}
|
|
{#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 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]}
|
|
<td class="py-2 px-2 whitespace-nowrap">
|
|
<SimpleTooltip
|
|
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>
|
|
</td>
|
|
{:else if colId === "title"}
|
|
<td
|
|
class="py-2 px-2 font-medium max-w-50 truncate"
|
|
>
|
|
<SimpleTooltip
|
|
text={course.title}
|
|
delay={200}
|
|
side="bottom"
|
|
passthrough
|
|
>
|
|
<span class="block truncate"
|
|
>{course.title}</span
|
|
>
|
|
</SimpleTooltip>
|
|
</td>
|
|
{:else if colId === "instructor"}
|
|
{@const primary = getPrimaryInstructor(
|
|
course.instructors,
|
|
)}
|
|
{@const display =
|
|
primaryInstructorDisplay(course)}
|
|
{@const commaIdx =
|
|
display.indexOf(", ")}
|
|
{@const ratingData =
|
|
primaryRating(course)}
|
|
<td class="py-2 px-2 whitespace-nowrap">
|
|
{#if display === "Staff"}
|
|
<span
|
|
class="text-xs text-muted-foreground/60 uppercase"
|
|
>Staff</span
|
|
>
|
|
{:else}
|
|
<SimpleTooltip
|
|
text={primary?.displayName ??
|
|
"Staff"}
|
|
delay={200}
|
|
side="bottom"
|
|
passthrough
|
|
>
|
|
{#if commaIdx !== -1}
|
|
<span
|
|
>{display.slice(
|
|
0,
|
|
commaIdx,
|
|
)},
|
|
<span
|
|
class="text-muted-foreground"
|
|
>{display.slice(
|
|
commaIdx +
|
|
1,
|
|
)}</span
|
|
></span
|
|
>
|
|
{:else}
|
|
<span>{display}</span>
|
|
{/if}
|
|
</SimpleTooltip>
|
|
{/if}
|
|
{#if ratingData}
|
|
{@const lowConfidence =
|
|
ratingData.count <
|
|
RMP_CONFIDENCE_THRESHOLD}
|
|
<Tooltip.Root
|
|
delayDuration={150}
|
|
>
|
|
<Tooltip.Trigger>
|
|
<span
|
|
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
|
|
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>
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content
|
|
side="bottom"
|
|
sideOffset={6}
|
|
class={cn(
|
|
tooltipContentClass,
|
|
"px-2.5 py-1.5",
|
|
)}
|
|
>
|
|
<span
|
|
class="inline-flex items-center gap-1.5 text-xs"
|
|
>
|
|
{ratingData.rating.toFixed(
|
|
1,
|
|
)}/5 · {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>
|
|
</Tooltip.Content>
|
|
</Tooltip.Root>
|
|
{/if}
|
|
</td>
|
|
{:else if colId === "time"}
|
|
<td class="py-2 px-2 whitespace-nowrap">
|
|
<SimpleTooltip
|
|
text={formatMeetingTimesTooltip(
|
|
course.meetingTimes,
|
|
)}
|
|
passthrough
|
|
>
|
|
{#if timeIsTBA(course)}
|
|
<span
|
|
class="text-xs text-muted-foreground/60"
|
|
>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"
|
|
>TBA</span
|
|
>
|
|
{/if}
|
|
{#if course.meetingTimes.length > 1}
|
|
<span
|
|
class="ml-1 text-xs text-muted-foreground/70 font-medium"
|
|
>+{course
|
|
.meetingTimes
|
|
.length -
|
|
1}</span
|
|
>
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
</SimpleTooltip>
|
|
</td>
|
|
{:else if colId === "location"}
|
|
{@const concern =
|
|
getDeliveryConcern(course)}
|
|
{@const accentColor =
|
|
concernAccentColor(concern)}
|
|
{@const locTooltip =
|
|
formatLocationTooltip(course)}
|
|
{@const locDisplay =
|
|
formatLocationDisplay(course)}
|
|
<td class="py-2 px-2 whitespace-nowrap">
|
|
{#if locTooltip}
|
|
<SimpleTooltip
|
|
text={locTooltip}
|
|
delay={200}
|
|
passthrough
|
|
>
|
|
<span
|
|
class="text-muted-foreground"
|
|
class:pl-2={accentColor !==
|
|
null}
|
|
style:border-left={accentColor
|
|
? `2px solid ${accentColor}`
|
|
: undefined}
|
|
>
|
|
{locDisplay ?? "—"}
|
|
</span>
|
|
</SimpleTooltip>
|
|
{:else if locDisplay}
|
|
<span
|
|
class="text-muted-foreground"
|
|
>
|
|
{locDisplay}
|
|
</span>
|
|
{:else}
|
|
<span
|
|
class="text-xs text-muted-foreground/50"
|
|
>—</span
|
|
>
|
|
{/if}
|
|
</td>
|
|
{:else if colId === "seats"}
|
|
<td
|
|
class="py-2 px-2 text-right whitespace-nowrap"
|
|
>
|
|
<SimpleTooltip
|
|
text="{openSeats(
|
|
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>
|
|
</SimpleTooltip>
|
|
</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>
|