feat(web): build responsive layout with mobile card view

This commit is contained in:
2026-02-01 00:40:58 -06:00
parent 7e7fc1df94
commit bd2acee6f4
27 changed files with 1798 additions and 294 deletions
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import { formatMeetingTimeSummary } from "$lib/course";
import type { CourseResponse, DbMeetingTime } from "$lib/api";
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
return {
begin_time: null,
end_time: null,
start_date: "2025-01-13",
end_date: "2025-05-08",
monday: false,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
building: null,
building_description: null,
room: null,
campus: null,
meeting_type: "CLAS",
meeting_schedule_type: "LEC",
...overrides,
};
}
function makeCourse(overrides: Partial<CourseResponse> = {}): CourseResponse {
return {
crn: "12345",
subject: "CS",
courseNumber: "1234",
title: "Test Course",
termCode: "202510",
sequenceNumber: null,
instructionalMethod: null,
campus: null,
enrollment: 10,
maxEnrollment: 30,
waitCount: 0,
waitCapacity: 0,
creditHours: 3,
creditHourLow: null,
creditHourHigh: null,
crossList: null,
crossListCapacity: null,
crossListCount: null,
linkIdentifier: null,
isSectionLinked: null,
partOfTerm: null,
meetingTimes: [],
attributes: [],
instructors: [],
...overrides,
};
}
describe("formatMeetingTimeSummary", () => {
it("returns 'Async' for async online courses", () => {
const course = makeCourse({
meetingTimes: [makeMeetingTime({ building: "INT" })],
});
expect(formatMeetingTimeSummary(course)).toBe("Async");
});
it("returns 'TBA' for courses with no meeting times", () => {
const course = makeCourse({ meetingTimes: [] });
expect(formatMeetingTimeSummary(course)).toBe("TBA");
});
it("returns 'TBA' when days and times are all TBA", () => {
const course = makeCourse({
meetingTimes: [makeMeetingTime()],
});
expect(formatMeetingTimeSummary(course)).toBe("TBA");
});
it("returns formatted days and time for normal meeting", () => {
const course = makeCourse({
meetingTimes: [
makeMeetingTime({
monday: true,
wednesday: true,
friday: true,
begin_time: "0900",
end_time: "0950",
}),
],
});
expect(formatMeetingTimeSummary(course)).toBe("MWF 9:009:50 AM");
});
it("returns formatted days with TBA time", () => {
const course = makeCourse({
meetingTimes: [
makeMeetingTime({
tuesday: true,
thursday: true,
}),
],
});
// Days are set but time is TBA — not both TBA, so it enters the final branch
expect(formatMeetingTimeSummary(course)).toBe("TTh TBA");
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { parseTimeInput, formatTime, toggleDay, toggleValue } from "$lib/filters";
describe("parseTimeInput", () => {
it("parses AM time", () => {
expect(parseTimeInput("10:30 AM")).toBe("1030");
});
it("parses PM time", () => {
expect(parseTimeInput("3:00 PM")).toBe("1500");
});
it("parses 12:00 PM as noon", () => {
expect(parseTimeInput("12:00 PM")).toBe("1200");
});
it("parses 12:00 AM as midnight", () => {
expect(parseTimeInput("12:00 AM")).toBe("0000");
});
it("parses case-insensitive AM/PM", () => {
expect(parseTimeInput("9:15 am")).toBe("0915");
expect(parseTimeInput("2:45 Pm")).toBe("1445");
});
it("parses military time", () => {
expect(parseTimeInput("14:30")).toBe("1430");
expect(parseTimeInput("9:05")).toBe("0905");
});
it("returns null for empty string", () => {
expect(parseTimeInput("")).toBeNull();
expect(parseTimeInput(" ")).toBeNull();
});
it("returns null for non-time strings", () => {
expect(parseTimeInput("abc")).toBeNull();
expect(parseTimeInput("hello world")).toBeNull();
});
it("parses out-of-range military time (no validation beyond format)", () => {
// The regex matches but doesn't validate hour/minute ranges
expect(parseTimeInput("25:00")).toBe("2500");
});
it("trims whitespace", () => {
expect(parseTimeInput(" 10:00 AM ")).toBe("1000");
});
});
describe("formatTime", () => {
it("formats morning time", () => {
expect(formatTime("0930")).toBe("9:30 AM");
});
it("formats afternoon time", () => {
expect(formatTime("1500")).toBe("3:00 PM");
});
it("formats noon", () => {
expect(formatTime("1200")).toBe("12:00 PM");
});
it("formats midnight", () => {
expect(formatTime("0000")).toBe("12:00 AM");
});
it("returns empty string for null", () => {
expect(formatTime(null)).toBe("");
});
it("returns empty string for invalid length", () => {
expect(formatTime("12")).toBe("");
expect(formatTime("123456")).toBe("");
});
});
describe("toggleDay", () => {
it("adds a day not in the list", () => {
expect(toggleDay(["monday"], "wednesday")).toEqual(["monday", "wednesday"]);
});
it("removes a day already in the list", () => {
expect(toggleDay(["monday", "wednesday"], "monday")).toEqual(["wednesday"]);
});
it("adds to empty list", () => {
expect(toggleDay([], "friday")).toEqual(["friday"]);
});
it("removes last day", () => {
expect(toggleDay(["monday"], "monday")).toEqual([]);
});
});
describe("toggleValue", () => {
it("adds a value not in the array", () => {
expect(toggleValue(["OA"], "HB")).toEqual(["OA", "HB"]);
});
it("removes a value already in the array", () => {
expect(toggleValue(["OA", "HB"], "OA")).toEqual(["HB"]);
});
it("adds to empty array", () => {
expect(toggleValue([], "OA")).toEqual(["OA"]);
});
it("removes last value", () => {
expect(toggleValue(["OA"], "OA")).toEqual([]);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import {
FADE_DISTANCE,
FADE_PERCENT,
leftOpacity,
rightOpacity,
maskGradient,
type ScrollMetrics,
} from "$lib/scroll-fade";
describe("leftOpacity", () => {
it("returns 0 when scrollLeft is 0", () => {
expect(leftOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(0);
});
it("returns 1 when scrollLeft >= FADE_DISTANCE", () => {
expect(leftOpacity({ scrollLeft: FADE_DISTANCE, scrollWidth: 1000, clientWidth: 500 })).toBe(1);
expect(
leftOpacity({ scrollLeft: FADE_DISTANCE + 50, scrollWidth: 1000, clientWidth: 500 })
).toBe(1);
});
it("returns proportional value for partial scroll", () => {
const half = FADE_DISTANCE / 2;
expect(leftOpacity({ scrollLeft: half, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5);
});
});
describe("rightOpacity", () => {
it("returns 0 when content fits (no scroll needed)", () => {
expect(rightOpacity({ scrollLeft: 0, scrollWidth: 500, clientWidth: 500 })).toBe(0);
});
it("returns 0 when scrolled to the end", () => {
expect(rightOpacity({ scrollLeft: 500, scrollWidth: 1000, clientWidth: 500 })).toBe(0);
});
it("returns 1 when far from the end", () => {
expect(rightOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(1);
});
it("returns proportional value near the end", () => {
const maxScroll = 500; // scrollWidth(1000) - clientWidth(500)
const remaining = FADE_DISTANCE / 2;
const scrollLeft = maxScroll - remaining;
expect(rightOpacity({ scrollLeft, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5);
});
});
describe("maskGradient", () => {
it("returns full transparent-to-transparent gradient when no scroll", () => {
const metrics: ScrollMetrics = { scrollLeft: 0, scrollWidth: 500, clientWidth: 500 };
const result = maskGradient(metrics);
// leftOpacity=0, rightOpacity=0 → leftEnd=0%, rightStart=100%
expect(result).toBe(
"linear-gradient(to right, transparent 0%, black 0%, black 100%, transparent 100%)"
);
});
it("includes fade zones when scrolled to the middle", () => {
const metrics: ScrollMetrics = {
scrollLeft: FADE_DISTANCE,
scrollWidth: 1000,
clientWidth: 500,
};
const result = maskGradient(metrics);
// leftOpacity=1 → leftEnd=FADE_PERCENT%, rightOpacity=1 → rightStart=100-FADE_PERCENT%
expect(result).toContain(`black ${FADE_PERCENT}%`);
expect(result).toContain(`black ${100 - FADE_PERCENT}%`);
});
});
@@ -1,5 +1,6 @@
<script lang="ts">
import type { CodeDescription } from "$lib/bindings";
import { toggleValue } from "$lib/filters";
import FilterPopover from "./FilterPopover.svelte";
let {
@@ -28,10 +29,6 @@ const hasActiveFilters = $derived(
attributes.length > 0
);
function toggleValue(arr: string[], code: string): string[] {
return arr.includes(code) ? arr.filter((v) => v !== code) : [...arr, code];
}
const sections: {
label: string;
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes";
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { fly, fade } from "svelte/transition";
const DISMISS_THRESHOLD = 100;
let {
open = $bindable(false),
maxHeight = "80vh",
label,
children,
}: {
open: boolean;
maxHeight?: string;
label?: string;
children: Snippet;
} = $props();
let dragOffset = $state(0);
let dragging = $state(false);
let dragStartY = 0;
function close() {
open = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
function onPointerDown(e: PointerEvent) {
dragging = true;
dragStartY = e.clientY;
dragOffset = 0;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const delta = e.clientY - dragStartY;
dragOffset = Math.max(0, delta);
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
if (dragOffset > DISMISS_THRESHOLD) {
close();
}
dragOffset = 0;
}
$effect(() => {
if (open) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}
});
</script>
<svelte:window onkeydown={onKeydown} />
{#if open}
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-40 bg-black/40"
transition:fade={{ duration: 200 }}
onclick={close}
></div>
<!-- Sheet -->
<div
class="fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-2xl border-t border-border bg-background shadow-[0_-4px_20px_rgba(0,0,0,0.1)] pb-[env(safe-area-inset-bottom)]"
style="max-height: {maxHeight}; transform: translateY({dragOffset}px);"
class:transition-transform={!dragging}
class:duration-250={!dragging}
class:ease-out={!dragging}
transition:fly={{ y: 300, duration: 250 }}
role="dialog"
aria-modal="true"
aria-label={label}
>
<!-- Drag handle -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="flex shrink-0 cursor-grab items-center justify-center py-3 touch-none"
class:cursor-grabbing={dragging}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="h-1 w-10 rounded-full bg-muted-foreground/30"></div>
</div>
<!-- Content -->
<div class="overflow-y-auto">
{@render children()}
</div>
</div>
{/if}
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import {
abbreviateInstructor,
formatMeetingTimeSummary,
getPrimaryInstructor,
openSeats,
seatsColor,
seatsDotColor,
} from "$lib/course";
import { formatNumber } from "$lib/utils";
import { slide } from "svelte/transition";
import CourseDetail from "./CourseDetail.svelte";
let {
course,
expanded,
onToggle,
}: {
course: CourseResponse;
expanded: boolean;
onToggle: () => void;
} = $props();
</script>
<div
class="rounded-lg border border-border bg-card overflow-hidden transition-colors
{expanded ? 'border-border/80' : 'hover:bg-muted/30'}"
>
<button
class="w-full text-left p-3 cursor-pointer"
aria-expanded={expanded}
onclick={onToggle}
>
<!-- Line 1: Course code + title + seats -->
<div class="flex items-baseline justify-between gap-2">
<div class="flex items-baseline gap-1.5 min-w-0">
<span class="font-mono font-semibold text-sm tracking-tight shrink-0">
{course.subject} {course.courseNumber}
</span>
<span class="text-sm text-muted-foreground truncate">{course.title}</span>
</div>
<span class="inline-flex items-center gap-1 shrink-0 text-xs select-none">
<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)}/{formatNumber(course.maxEnrollment)}{/if}
</span>
</span>
</div>
<!-- Line 2: Instructor + time -->
<div class="flex items-center justify-between gap-2 mt-1">
<span class="text-xs text-muted-foreground truncate">
{abbreviateInstructor(getPrimaryInstructor(course.instructors)?.displayName ?? "Staff")}
</span>
<span class="text-xs text-muted-foreground shrink-0">
{formatMeetingTimeSummary(course)}
</span>
</div>
</button>
{#if expanded}
<div transition:slide={{ duration: 200 }}>
<CourseDetail {course} />
</div>
{/if}
</div>
+51 -4
View File
@@ -47,6 +47,7 @@ import { ContextMenu, DropdownMenu } from "bits-ui";
import { flip } from "svelte/animate";
import { cubicOut } from "svelte/easing";
import { fade, slide } from "svelte/transition";
import CourseCard from "./CourseCard.svelte";
import CourseDetail from "./CourseDetail.svelte";
import RichTooltip from "./RichTooltip.svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
@@ -241,6 +242,12 @@ const table = createSvelteTable({
});
</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,
@@ -293,10 +300,50 @@ const table = createSvelteTable({
{/if}
{/snippet}
<!-- Table with context menu on header -->
<!-- Mobile cards -->
<div class="flex flex-col gap-2 sm:hidden">
{#if loading && courses.length === 0}
{#each Array(skeletonRowCount) as _}
<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>
{/each}
{: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="overflow-x-auto overflow-y-hidden transition-[height] duration-200"
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"
@@ -304,7 +351,7 @@ const table = createSvelteTable({
>
<ContextMenu.Root>
<ContextMenu.Trigger class="contents">
<table bind:this={tableElement} class="w-full min-w-160 border-collapse text-sm">
<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
@@ -389,7 +436,7 @@ const table = createSvelteTable({
colspan={visibleColumnIds.length}
class="py-12 text-center text-muted-foreground"
>
No courses found. Try adjusting your filters.
{@render emptyState()}
</td>
</tr>
</tbody>
+1 -1
View File
@@ -10,7 +10,7 @@ let {
<button
type="button"
class="inline-flex items-center rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-muted/60 transition-colors cursor-pointer select-none"
class="inline-flex items-center rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-muted/60 transition-colors cursor-pointer select-none whitespace-nowrap shrink-0"
onclick={onRemove}
aria-label="Remove {label} filter"
>
@@ -0,0 +1,350 @@
<script lang="ts">
import type { CodeDescription } from "$lib/bindings";
import {
DAY_OPTIONS,
toggleDay as _toggleDay,
parseTimeInput,
formatTime,
toggleValue,
} from "$lib/filters";
import { ChevronDown } from "@lucide/svelte";
import BottomSheet from "./BottomSheet.svelte";
import RangeSlider from "./RangeSlider.svelte";
let {
open = $bindable(false),
openOnly = $bindable(),
waitCountMax = $bindable(),
days = $bindable(),
timeStart = $bindable(),
timeEnd = $bindable(),
instructionalMethod = $bindable(),
campus = $bindable(),
partOfTerm = $bindable(),
attributes = $bindable(),
creditHourMin = $bindable(),
creditHourMax = $bindable(),
instructor = $bindable(),
courseNumberMin = $bindable(),
courseNumberMax = $bindable(),
referenceData,
ranges,
}: {
open: boolean;
openOnly: boolean;
waitCountMax: number | null;
days: string[];
timeStart: string | null;
timeEnd: string | null;
instructionalMethod: string[];
campus: string[];
partOfTerm: string[];
attributes: string[];
creditHourMin: number | null;
creditHourMax: number | null;
instructor: string;
courseNumberMin: number | null;
courseNumberMax: number | null;
referenceData: {
instructionalMethods: CodeDescription[];
campuses: CodeDescription[];
partsOfTerm: CodeDescription[];
attributes: CodeDescription[];
};
ranges: {
courseNumber: { min: number; max: number };
creditHours: { min: number; max: number };
waitCount: { max: number };
};
} = $props();
let expandedSection = $state<string | null>(null);
function toggleSection(id: string) {
expandedSection = expandedSection === id ? null : id;
}
function toggleDay(day: string) {
days = _toggleDay(days, day);
}
const attributeSections: {
label: string;
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes";
dataKey: "instructionalMethods" | "campuses" | "partsOfTerm" | "attributes";
}[] = [
{ label: "Instructional Method", key: "instructionalMethod", dataKey: "instructionalMethods" },
{ label: "Campus", key: "campus", dataKey: "campuses" },
{ label: "Part of Term", key: "partOfTerm", dataKey: "partsOfTerm" },
{ label: "Course Attributes", key: "attributes", dataKey: "attributes" },
];
function getAttrSelected(
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes"
): string[] {
if (key === "instructionalMethod") return instructionalMethod;
if (key === "campus") return campus;
if (key === "partOfTerm") return partOfTerm;
return attributes;
}
function toggleAttr(
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes",
code: string
) {
if (key === "instructionalMethod") instructionalMethod = toggleValue(instructionalMethod, code);
else if (key === "campus") campus = toggleValue(campus, code);
else if (key === "partOfTerm") partOfTerm = toggleValue(partOfTerm, code);
else attributes = toggleValue(attributes, code);
}
</script>
<BottomSheet bind:open>
<div class="flex flex-col">
<!-- Status section -->
<button
onclick={() => toggleSection("status")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
Status
{#if openOnly || waitCountMax !== null}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'status'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "status"}
<div class="px-4 pb-3 flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Availability</span>
<button
type="button"
aria-pressed={openOnly}
class="inline-flex items-center justify-center rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer select-none
{openOnly
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => (openOnly = !openOnly)}
>
Open only
</button>
</div>
<div class="h-px bg-border"></div>
{#if ranges.waitCount.max > 0}
<RangeSlider
min={0}
max={ranges.waitCount.max}
step={5}
bind:value={waitCountMax}
label="Max waitlist"
dual={false}
pips
pipstep={2}
formatValue={(v) => (v === 0 ? "Off" : String(v))}
/>
{:else}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Max waitlist</span>
<span class="text-xs text-muted-foreground select-none">No waitlisted courses</span>
</div>
{/if}
</div>
{/if}
<div class="h-px bg-border mx-4"></div>
<!-- Schedule section -->
<button
onclick={() => toggleSection("schedule")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
Schedule
{#if days.length > 0 || timeStart !== null || timeEnd !== null}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'schedule'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "schedule"}
<div class="px-4 pb-3 flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Days of week</span>
<div class="flex gap-1">
{#each DAY_OPTIONS as { label, value } (value)}
<button
type="button"
aria-label={value.charAt(0).toUpperCase() + value.slice(1)}
aria-pressed={days.includes(value)}
class="flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors cursor-pointer select-none min-w-[2rem]
{days.includes(value)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleDay(value)}
>
{label}
</button>
{/each}
</div>
</div>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Time range</span>
<div class="flex items-center gap-2">
<input
type="text"
placeholder="10:00 AM"
autocomplete="off"
value={formatTime(timeStart)}
onchange={(e) => {
timeStart = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeStart);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<span class="text-xs text-muted-foreground select-none">to</span>
<input
type="text"
placeholder="3:00 PM"
autocomplete="off"
value={formatTime(timeEnd)}
onchange={(e) => {
timeEnd = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeEnd);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
</div>
</div>
{/if}
<div class="h-px bg-border mx-4"></div>
<!-- Attributes section -->
<button
onclick={() => toggleSection("attributes")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
Attributes
{#if instructionalMethod.length > 0 || campus.length > 0 || partOfTerm.length > 0 || attributes.length > 0}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'attributes'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "attributes"}
<div class="px-4 pb-3 flex flex-col gap-3">
{#each attributeSections as { label, key, dataKey }, i (key)}
{#if i > 0}
<div class="h-px bg-border"></div>
{/if}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">{label}</span>
<div class="flex flex-wrap gap-1">
{#each referenceData[dataKey] as item (item.code)}
{@const selected = getAttrSelected(key)}
<button
type="button"
aria-pressed={selected.includes(item.code)}
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer select-none
{selected.includes(item.code)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleAttr(key, item.code)}
title={item.description}
>
{item.description}
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
<div class="h-px bg-border mx-4"></div>
<!-- More section -->
<button
onclick={() => toggleSection("more")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
More
{#if creditHourMin !== null || creditHourMax !== null || instructor !== "" || courseNumberMin !== null || courseNumberMax !== null}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'more'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "more"}
<div class="px-4 pb-3 flex flex-col gap-3">
<RangeSlider
min={ranges.creditHours.min}
max={ranges.creditHours.max}
step={1}
bind:valueLow={creditHourMin}
bind:valueHigh={creditHourMax}
label="Credit hours"
pips
all="label"
/>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<label
for="mobile-instructor-input"
class="text-xs font-medium text-muted-foreground select-none"
>
Instructor
</label>
<input
id="mobile-instructor-input"
type="text"
placeholder="Search by name..."
autocomplete="off"
bind:value={instructor}
class="h-8 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
<div class="h-px bg-border"></div>
<RangeSlider
min={ranges.courseNumber.min}
max={ranges.courseNumber.max}
step={100}
bind:valueLow={courseNumberMin}
bind:valueHigh={courseNumberMax}
label="Course number"
pips
pipstep={10}
/>
</div>
{/if}
</div>
</BottomSheet>
+201 -18
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import { navbar } from "$lib/stores/navigation.svelte";
import { Clock, Search, User } from "@lucide/svelte";
import ThemeToggle from "./ThemeToggle.svelte";
@@ -28,34 +29,216 @@ function isActive(tabHref: string): boolean {
}
return page.url.pathname.startsWith(tabHref);
}
/** Label expansion check using a deferred path that updates only after
* view transitions finish, so CSS transitions run on visible DOM. */
function isLabelExpanded(tabHref: string): boolean {
if (tabHref === "/") return navbar.path === "/";
if (tabHref === "/profile") {
return APP_PREFIXES.some((p) => navbar.path.startsWith(p));
}
return navbar.path.startsWith(tabHref);
}
// DOM refs
let tabRefs: HTMLAnchorElement[] = $state([]);
let containerRef: HTMLDivElement | undefined = $state();
let pillRef: HTMLDivElement | undefined = $state();
// Pill animation state — driven by JS, not CSS transitions
let targetLeft = 0;
let targetWidth = 0;
let currentLeft = 0;
let currentWidth = 0;
let animationId: number | null = null;
let mounted = $state(false);
const ANIMATION_DURATION = 300;
const EASING = cubicOut;
function cubicOut(t: number): number {
const f = t - 1;
return f * f * f + 1;
}
function allTabs() {
return [...staticTabs.map((t) => t.href), profileTab.href];
}
function activeIndex(): number {
return allTabs().findIndex((href) => isActive(href));
}
function measureActiveTab(): { left: number; width: number } | null {
const idx = activeIndex();
if (idx < 0 || !tabRefs[idx] || !containerRef) return null;
const containerRect = containerRef.getBoundingClientRect();
const tabRect = tabRefs[idx].getBoundingClientRect();
return {
left: tabRect.left - containerRect.left,
width: tabRect.width,
};
}
function applyPill(left: number, width: number) {
if (!pillRef) return;
pillRef.style.transform = `translateX(${left}px)`;
pillRef.style.width = `${width}px`;
currentLeft = left;
currentWidth = width;
}
function animatePill(fromLeft: number, fromWidth: number, toLeft: number, toWidth: number) {
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
const startTime = performance.now();
function tick(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / ANIMATION_DURATION, 1);
const eased = EASING(progress);
const left = fromLeft + (toLeft - fromLeft) * eased;
const width = fromWidth + (toWidth - fromWidth) * eased;
applyPill(left, width);
if (progress < 1) {
animationId = requestAnimationFrame(tick);
} else {
animationId = null;
}
}
animationId = requestAnimationFrame(tick);
}
function updateTarget() {
const measured = measureActiveTab();
if (!measured) return;
targetLeft = measured.left;
targetWidth = measured.width;
if (!mounted) {
// First render — snap immediately, no animation
applyPill(targetLeft, targetWidth);
mounted = true;
return;
}
// Always (re)start animation from current position — handles both fresh
// navigations and rapid route changes that interrupt a running animation
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
animatePill(currentLeft, currentWidth, targetLeft, targetWidth);
}
function updateTargetFromResize() {
const measured = measureActiveTab();
if (!measured) return;
const newLeft = measured.left;
const newWidth = measured.width;
// If nothing changed, skip
if (newLeft === targetLeft && newWidth === targetWidth) return;
targetLeft = newLeft;
targetWidth = newWidth;
if (animationId !== null) {
// Animation in progress — retarget it smoothly by starting a new
// animation from the current interpolated position to the new target
cancelAnimationFrame(animationId);
animationId = null;
animatePill(currentLeft, currentWidth, targetLeft, targetWidth);
} else {
// No animation running — snap (this handles window resize, etc.)
applyPill(targetLeft, targetWidth);
}
}
// Start animation when route changes
$effect(() => {
page.url.pathname;
profileTab.href;
requestAnimationFrame(() => {
updateTarget();
});
});
// Track the active tab's size during label transitions and window resizes
$effect(() => {
if (!containerRef) return;
const observer = new ResizeObserver(() => {
updateTargetFromResize();
});
observer.observe(containerRef);
for (const ref of tabRefs) {
if (ref) observer.observe(ref);
}
return () => observer.disconnect();
});
</script>
<nav class="w-full flex justify-center pt-5 px-5">
<nav class="w-full flex justify-center pt-5 px-3 sm:px-5">
<div class="w-full max-w-6xl flex items-center justify-between">
<!-- pointer-events-auto: root layout wraps nav in pointer-events-none overlay -->
<div class="flex items-center gap-1 rounded-lg bg-muted p-1 pointer-events-auto">
{#each staticTabs as tab}
<div
class="relative flex items-center gap-1 rounded-lg bg-muted p-1 pointer-events-auto"
bind:this={containerRef}
>
<!-- Sliding pill — animated via JS (RAF) to stay smooth even when
heavy page transitions cause CSS transition skipping -->
<div
class="absolute top-1 bottom-1 left-0 rounded-md bg-background shadow-sm will-change-[transform,width]"
bind:this={pillRef}
></div>
{#each staticTabs as tab, i}
<a
href={tab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline select-none
{isActive(tab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
bind:this={tabRefs[i]}
class="relative z-10 flex items-center gap-1.5 rounded-md px-2 sm:px-3 py-1.5 text-sm font-medium transition-colors no-underline select-none
{isActive(tab.href) ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}"
>
<tab.icon size={15} strokeWidth={2} />
{tab.label}
<span
class="grid overflow-hidden transition-[grid-template-columns,opacity] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]
{isLabelExpanded(tab.href)
? 'grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0 sm:grid-cols-[1fr] sm:opacity-100'}"
>
<span class="overflow-hidden whitespace-nowrap">{tab.label}</span>
</span>
</a>
{/each}
<a
href={profileTab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline select-none
{isActive(profileTab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
>
<User size={15} strokeWidth={2} />
{#if profileTab.label}{profileTab.label}{/if}
</a>
<a
href={profileTab.href}
bind:this={tabRefs[staticTabs.length]}
class="relative z-10 flex items-center gap-1.5 rounded-md px-2 sm:px-3 py-1.5 text-sm font-medium transition-colors no-underline select-none
{isActive(profileTab.href)
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
<User size={15} strokeWidth={2} />
{#if profileTab.label}
<span
class="grid overflow-hidden transition-[grid-template-columns,opacity] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]
{isLabelExpanded(profileTab.href)
? 'grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0 sm:grid-cols-[1fr] sm:opacity-100'}"
>
<span class="overflow-hidden whitespace-nowrap">{profileTab.label}</span>
</span>
{/if}
</a>
<ThemeToggle />
</div>
</div>
@@ -1,77 +0,0 @@
<script lang="ts">
import { navigationStore } from "$lib/stores/navigation.svelte";
import type { Snippet } from "svelte";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
type Axis = "horizontal" | "vertical";
let {
key,
children,
axis = "horizontal",
inDelay = 0,
outDelay = 0,
}: {
key: string;
children: Snippet;
axis?: Axis;
inDelay?: number;
outDelay?: number;
} = $props();
const DURATION = 400;
const OFFSET = 40;
function translate(axis: Axis, value: number): string {
return axis === "vertical" ? `translateY(${value}px)` : `translateX(${value}px)`;
}
function inTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
if (dir === "fade") {
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}`,
};
}
const offset = dir === "right" ? OFFSET : -OFFSET;
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
function outTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
const base =
"position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none";
if (dir === "fade") {
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}`,
};
}
const offset = dir === "right" ? -OFFSET : OFFSET;
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
</script>
<div class="relative flex flex-1 flex-col overflow-hidden p-8">
{#key key}
<div in:inTransition out:outTransition class="flex flex-1 flex-col -m-8">
{@render children()}
</div>
{/key}
</div>
+8 -2
View File
@@ -68,11 +68,14 @@ const selectValue = $derived(String(currentPage));
<div class="flex items-start text-xs mt-2 pl-2">
<!-- Left zone: result count -->
<div class="flex-1">
<span class="text-muted-foreground select-none">
<span class="text-muted-foreground select-none hidden md:inline">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</span>
<span class="text-muted-foreground select-none tabular-nums md:hidden">
{formatNumber(start)}&ndash;{formatNumber(end)} / {formatNumber(totalCount)}
</span>
</div>
<!-- Center zone: page buttons -->
@@ -187,10 +190,13 @@ const selectValue = $derived(String(currentPage));
{:else if totalCount > 0}
<!-- Single page: just show the count, no pagination controls -->
<div class="flex items-start text-xs mt-2 pl-2">
<span class="text-muted-foreground select-none">
<span class="text-muted-foreground select-none hidden md:inline">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</span>
<span class="text-muted-foreground select-none tabular-nums md:hidden">
{formatNumber(start)}&ndash;{formatNumber(end)} / {formatNumber(totalCount)}
</span>
</div>
{/if}
+2 -48
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { DAY_OPTIONS, toggleDay as _toggleDay, parseTimeInput, formatTime } from "$lib/filters";
import FilterPopover from "./FilterPopover.svelte";
let {
@@ -11,57 +12,10 @@ let {
timeEnd: string | null;
} = $props();
const DAY_OPTIONS: { label: string; value: string }[] = [
{ label: "M", value: "monday" },
{ label: "T", value: "tuesday" },
{ label: "W", value: "wednesday" },
{ label: "Th", value: "thursday" },
{ label: "F", value: "friday" },
{ label: "Sa", value: "saturday" },
{ label: "Su", value: "sunday" },
];
const hasActiveFilters = $derived(days.length > 0 || timeStart !== null || timeEnd !== null);
function toggleDay(day: string) {
if (days.includes(day)) {
days = days.filter((d) => d !== day);
} else {
days = [...days, day];
}
}
function parseTimeInput(input: string): string | null {
const trimmed = input.trim();
if (trimmed === "") return null;
const ampmMatch = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (ampmMatch) {
let hours = parseInt(ampmMatch[1], 10);
const minutes = parseInt(ampmMatch[2], 10);
const period = ampmMatch[3].toUpperCase();
if (period === "PM" && hours !== 12) hours += 12;
if (period === "AM" && hours === 12) hours = 0;
return String(hours).padStart(2, "0") + String(minutes).padStart(2, "0");
}
const militaryMatch = trimmed.match(/^(\d{1,2}):(\d{2})$/);
if (militaryMatch) {
const hours = parseInt(militaryMatch[1], 10);
const minutes = parseInt(militaryMatch[2], 10);
return String(hours).padStart(2, "0") + String(minutes).padStart(2, "0");
}
return null;
}
function formatTime(time: string | null): string {
if (time === null || time.length !== 4) return "";
const hours = parseInt(time.slice(0, 2), 10);
const minutes = time.slice(2);
const period = hours >= 12 ? "PM" : "AM";
const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return `${displayHours}:${minutes} ${period}`;
days = _toggleDay(days, day);
}
</script>
+85 -6
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import type { CodeDescription, Subject, Term } from "$lib/api";
import { SlidersHorizontal } from "@lucide/svelte";
import AttributesPopover from "./AttributesPopover.svelte";
import MobileFilterSheet from "./MobileFilterSheet.svelte";
import MorePopover from "./MorePopover.svelte";
import SchedulePopover from "./SchedulePopover.svelte";
import StatusPopover from "./StatusPopover.svelte";
@@ -61,14 +63,70 @@ let {
waitCount: { max: number };
};
} = $props();
// Mobile bottom sheet state
let filterSheetOpen = $state(false);
let activeFilterCount = $derived(
[
openOnly,
waitCountMax !== null,
days.length > 0,
timeStart !== null || timeEnd !== null,
instructionalMethod.length > 0,
campus.length > 0,
partOfTerm.length > 0,
attributes.length > 0,
creditHourMin !== null || creditHourMax !== null,
instructor !== "",
courseNumberMin !== null || courseNumberMax !== null,
].filter(Boolean).length
);
</script>
<!-- Row 1: Primary filters -->
<div class="flex flex-wrap gap-3 items-start">
<!-- Mobile row 1: Term + Subject side by side -->
<div class="flex gap-2 md:hidden">
<div class="flex-1 min-w-0">
<TermCombobox {terms} bind:value={selectedTerm} />
</div>
<div class="flex-1 min-w-0">
<SubjectCombobox {subjects} bind:value={selectedSubjects} />
</div>
</div>
<!-- Mobile row 2: Search + Filters button -->
<div class="flex gap-2 md:hidden">
<input
type="text"
placeholder="Search courses..."
aria-label="Search courses"
bind:value={query}
class="h-9 border border-border bg-card text-foreground rounded-md px-3 text-sm flex-1 min-w-0
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
transition-colors"
/>
<button
onclick={() => (filterSheetOpen = true)}
class="inline-flex items-center gap-1.5 rounded-md border h-9 px-3 text-sm font-medium transition-colors cursor-pointer select-none shrink-0
{activeFilterCount > 0
? 'border-primary/50 bg-primary/10 text-primary'
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
<SlidersHorizontal class="size-3.5" />
Filters
{#if activeFilterCount > 0}
<span
class="inline-flex items-center justify-center size-4 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold"
>{activeFilterCount}</span
>
{/if}
</button>
</div>
<!-- Desktop row 1: Term + Subject + Search (unchanged) -->
<div class="hidden md:flex flex-wrap gap-3 items-start">
<TermCombobox {terms} bind:value={selectedTerm} />
<SubjectCombobox {subjects} bind:value={selectedSubjects} />
<input
type="text"
placeholder="Search courses..."
@@ -80,8 +138,8 @@ let {
/>
</div>
<!-- Row 2: Category popovers -->
<div class="flex flex-wrap gap-2 items-center">
<!-- Desktop row 2: Category filter popovers -->
<div class="hidden md:flex flex-wrap gap-2 items-center">
<StatusPopover bind:openOnly bind:waitCountMax waitCountMaxRange={ranges.waitCount.max} />
<SchedulePopover bind:days bind:timeStart bind:timeEnd />
<AttributesPopover
@@ -100,3 +158,24 @@ let {
ranges={{ courseNumber: ranges.courseNumber, creditHours: ranges.creditHours }}
/>
</div>
<!-- Mobile: Filter bottom sheet -->
<MobileFilterSheet
bind:open={filterSheetOpen}
bind:openOnly
bind:waitCountMax
bind:days
bind:timeStart
bind:timeEnd
bind:instructionalMethod
bind:campus
bind:partOfTerm
bind:attributes
bind:creditHourMin
bind:creditHourMax
bind:instructor
bind:courseNumberMin
bind:courseNumberMax
{referenceData}
{ranges}
/>
+1 -1
View File
@@ -10,7 +10,7 @@ let {
{#if segments.length > 0}
<span
class="inline-flex items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground"
class="inline-flex items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground shrink-0"
>
{#each segments as segment, i}
{#if i > 0}
@@ -72,7 +72,7 @@ $effect(() => {
<!-- 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
flex flex-nowrap items-center gap-1 w-full md: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(); }}
+1 -1
View File
@@ -60,7 +60,7 @@ $effect(() => {
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative h-9 rounded-md border border-border bg-card
flex items-center w-40 cursor-pointer
flex items-center w-full md:w-40 cursor-pointer
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background"
role="presentation"
bind:this={containerEl}
@@ -23,11 +23,19 @@ async function handleToggle(event: MouseEvent) {
const y = event.clientY;
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
// Suppress named view-transition elements during theme change so they don't
// get their own transition group and snap to the new theme ahead of the mask.
document.documentElement.classList.add("theme-transitioning");
const transition = document.startViewTransition(async () => {
themeStore.toggle();
await tick();
});
transition.finished.finally(() => {
document.documentElement.classList.remove("theme-transitioning");
});
transition.ready.then(() => {
document.documentElement.animate(
{
+137 -92
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import BottomSheet from "$lib/components/BottomSheet.svelte";
import { DRAWER_WIDTH } from "$lib/timeline/constants";
import { getSubjectColor } from "$lib/timeline/data";
import { Filter, X } from "@lucide/svelte";
@@ -32,107 +33,151 @@ function onKeyDown(e: KeyboardEvent) {
}
</script>
{#snippet followStatus()}
{#if followEnabled}
<div
class="px-2 py-1 rounded-md text-xs font-medium text-center
bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20"
>
FOLLOWING
</div>
{:else}
<button
class="w-full px-2 py-1 rounded-md text-xs font-medium text-center
bg-muted/80 text-muted-foreground hover:text-foreground
border border-border/50 transition-colors cursor-pointer"
onclick={onResumeFollow}
aria-label="Resume following current time"
>
FOLLOW
</button>
{/if}
{/snippet}
{#snippet subjectToggles()}
<div class="flex items-center justify-between mb-2 text-xs text-muted-foreground">
<span class="uppercase tracking-wider font-medium">Subjects</span>
<div class="flex gap-1.5">
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onEnableAll}>All</button
>
<span class="opacity-40">|</span>
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onDisableAll}>None</button
>
</div>
</div>
<div class="flex flex-col gap-y-0.5">
{#each subjects as subject}
{@const enabled = enabledSubjects.has(subject)}
{@const color = getSubjectColor(subject)}
<button
class="flex items-center gap-2 w-full px-1.5 py-1 rounded text-xs
hover:bg-muted/50 transition-colors cursor-pointer text-left"
onclick={() => onToggleSubject(subject)}
>
<span
class="inline-block w-3 h-3 rounded-sm shrink-0 transition-opacity"
style="background: {color}; opacity: {enabled ? 1 : 0.2};"
></span>
<span
class="transition-opacity {enabled
? 'text-foreground'
: 'text-muted-foreground/50'}"
>
{subject}
</span>
</button>
{/each}
</div>
{/snippet}
<svelte:window onkeydown={onKeyDown} />
<!-- Filter toggle button — slides out when drawer opens -->
<button
class="absolute right-3 z-50 p-2 rounded-md
bg-black text-white dark:bg-white dark:text-black
hover:bg-neutral-800 dark:hover:bg-neutral-200
border border-black/20 dark:border-white/20
shadow-md transition-all duration-200 ease-in-out cursor-pointer
{open ? 'opacity-0 pointer-events-none' : 'opacity-100'}"
style="top: 20%; transform: translateX({open ? '60px' : '0'});"
onclick={() => (open = true)}
aria-label="Open filters"
>
<Filter size={18} strokeWidth={2} />
</button>
<!-- Drawer panel -->
<div
class="absolute right-0 z-40 rounded-l-lg shadow-xl transition-transform duration-200 ease-in-out {open ? '' : 'pointer-events-none'}"
style="top: 20%; width: {DRAWER_WIDTH}px; height: 60%; transform: translateX({open
? 0
: DRAWER_WIDTH}px);"
>
<div
class="h-full flex flex-col bg-background/90 backdrop-blur-md border border-border/40 rounded-l-lg overflow-hidden"
style="width: {DRAWER_WIDTH}px;"
<!-- Desktop: Filter toggle button — slides out when drawer opens -->
<div class="hidden md:block">
<button
class="absolute right-3 z-50 p-2 rounded-md
bg-black text-white dark:bg-white dark:text-black
hover:bg-neutral-800 dark:hover:bg-neutral-200
border border-black/20 dark:border-white/20
shadow-md transition-all duration-200 ease-in-out cursor-pointer
{open ? 'opacity-0 pointer-events-none' : 'opacity-100'}"
style="top: 20%; transform: translateX({open ? '60px' : '0'});"
onclick={() => (open = true)}
aria-label="Open filters"
>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2.5 border-b border-border/40">
<span class="text-xs font-semibold text-foreground">Filters</span>
<button
class="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onclick={() => (open = false)}
aria-label="Close filters"
>
<X size={14} strokeWidth={2} />
</button>
</div>
<Filter size={18} strokeWidth={2} />
</button>
</div>
<!-- Follow status -->
<div class="px-3 py-2 border-b border-border/40">
{#if followEnabled}
<div
class="px-2 py-1 rounded-md text-[10px] font-medium text-center
bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20"
>
FOLLOWING
</div>
{:else}
<!-- Desktop: Drawer panel -->
<div class="hidden md:block">
<div
class="absolute right-0 z-40 rounded-l-lg shadow-xl transition-transform duration-200 ease-in-out {open ? '' : 'pointer-events-none'}"
style="top: 20%; width: {DRAWER_WIDTH}px; height: 60%; transform: translateX({open
? 0
: DRAWER_WIDTH}px);"
>
<div
class="h-full flex flex-col bg-background/90 backdrop-blur-md border border-border/40 rounded-l-lg overflow-hidden"
style="width: {DRAWER_WIDTH}px;"
>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2.5 border-b border-border/40">
<span class="text-xs font-semibold text-foreground">Filters</span>
<button
class="w-full px-2 py-1 rounded-md text-[10px] font-medium text-center
bg-muted/80 text-muted-foreground hover:text-foreground
border border-border/50 transition-colors cursor-pointer"
onclick={onResumeFollow}
aria-label="Resume following current time"
class="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onclick={() => (open = false)}
aria-label="Close filters"
>
FOLLOW
<X size={14} strokeWidth={2} />
</button>
{/if}
</div>
<!-- Subject toggles -->
<div class="flex-1 overflow-y-auto px-3 py-2">
<div class="flex items-center justify-between mb-2 text-[10px] text-muted-foreground">
<span class="uppercase tracking-wider font-medium">Subjects</span>
<div class="flex gap-1.5">
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onEnableAll}>All</button
>
<span class="opacity-40">|</span>
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onDisableAll}>None</button
>
</div>
</div>
<div class="flex flex-col gap-y-0.5">
{#each subjects as subject}
{@const enabled = enabledSubjects.has(subject)}
{@const color = getSubjectColor(subject)}
<button
class="flex items-center gap-2 w-full px-1.5 py-1 rounded text-xs
hover:bg-muted/50 transition-colors cursor-pointer text-left"
onclick={() => onToggleSubject(subject)}
>
<span
class="inline-block w-3 h-3 rounded-sm shrink-0 transition-opacity"
style="background: {color}; opacity: {enabled ? 1 : 0.2};"
></span>
<span
class="transition-opacity {enabled
? 'text-foreground'
: 'text-muted-foreground/50'}"
>
{subject}
</span>
</button>
{/each}
<!-- Follow status -->
<div class="px-3 py-2 border-b border-border/40">
{@render followStatus()}
</div>
<!-- Subject toggles -->
<div class="flex-1 overflow-y-auto px-3 py-2">
{@render subjectToggles()}
</div>
</div>
</div>
</div>
<!-- Mobile: Floating filter button -->
<button
class="fixed right-3 bottom-3 z-50 p-3 rounded-full md:hidden
bg-black text-white dark:bg-white dark:text-black
hover:bg-neutral-800 dark:hover:bg-neutral-200
border border-black/20 dark:border-white/20
shadow-lg cursor-pointer
{open ? 'opacity-0 pointer-events-none' : 'opacity-100'}
transition-opacity duration-200"
onclick={() => (open = true)}
aria-label="Open filters"
>
<Filter size={20} strokeWidth={2} />
</button>
<!-- Mobile: Bottom sheet -->
<div class="md:hidden">
<BottomSheet bind:open maxHeight="50vh">
<div class="flex flex-col">
<!-- Follow status -->
<div class="px-4 py-2 border-b border-border/40">
{@render followStatus()}
</div>
<!-- Subject toggles -->
<div class="flex-1 overflow-y-auto px-4 py-2">
{@render subjectToggles()}
</div>
</div>
</BottomSheet>
</div>
+9
View File
@@ -447,6 +447,15 @@ export function formatInstructorName(displayName: string): string {
return `${rest} ${last}`;
}
/** Compact meeting time summary for mobile cards: "MWF 9:009:50 AM", "Async", or "TBA" */
export function formatMeetingTimeSummary(course: CourseResponse): string {
if (isAsyncOnline(course)) return "Async";
if (course.meetingTimes.length === 0) return "TBA";
const mt = course.meetingTimes[0];
if (isMeetingTimeTBA(mt) && isTimeTBA(mt)) return "TBA";
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`;
}
/** Check if a rating value represents real data (not the 0.0 placeholder for unrated professors). */
export function isRatingValid(avgRating: number | null, numRatings: number): boolean {
return avgRating !== null && !(avgRating === 0 && numRatings === 0);
+50
View File
@@ -0,0 +1,50 @@
export const DAY_OPTIONS: { label: string; value: string }[] = [
{ label: "M", value: "monday" },
{ label: "T", value: "tuesday" },
{ label: "W", value: "wednesday" },
{ label: "Th", value: "thursday" },
{ label: "F", value: "friday" },
{ label: "Sa", value: "saturday" },
{ label: "Su", value: "sunday" },
];
export function toggleDay(days: string[], day: string): string[] {
return days.includes(day) ? days.filter((d) => d !== day) : [...days, day];
}
export function parseTimeInput(input: string): string | null {
const trimmed = input.trim();
if (trimmed === "") return null;
const ampmMatch = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (ampmMatch) {
let hours = parseInt(ampmMatch[1], 10);
const minutes = parseInt(ampmMatch[2], 10);
const period = ampmMatch[3].toUpperCase();
if (period === "PM" && hours !== 12) hours += 12;
if (period === "AM" && hours === 12) hours = 0;
return String(hours).padStart(2, "0") + String(minutes).padStart(2, "0");
}
const militaryMatch = trimmed.match(/^(\d{1,2}):(\d{2})$/);
if (militaryMatch) {
const hours = parseInt(militaryMatch[1], 10);
const minutes = parseInt(militaryMatch[2], 10);
return String(hours).padStart(2, "0") + String(minutes).padStart(2, "0");
}
return null;
}
export function formatTime(time: string | null): string {
if (time === null || time.length !== 4) return "";
const hours = parseInt(time.slice(0, 2), 10);
const minutes = time.slice(2);
const period = hours >= 12 ? "PM" : "AM";
const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return `${displayHours}:${minutes} ${period}`;
}
export function toggleValue(arr: string[], code: string): string[] {
return arr.includes(code) ? arr.filter((v) => v !== code) : [...arr, code];
}
+34
View File
@@ -0,0 +1,34 @@
/** Distance in pixels before the fade is fully opaque */
export const FADE_DISTANCE = 60;
/** Percentage of container width used for each fade zone */
export const FADE_PERCENT = 8;
export interface ScrollMetrics {
scrollLeft: number;
scrollWidth: number;
clientWidth: number;
}
/** Compute left edge opacity: 0 when scrollLeft = 0, 1 when scrollLeft >= FADE_DISTANCE */
export function leftOpacity(metrics: ScrollMetrics): number {
return Math.min(metrics.scrollLeft / FADE_DISTANCE, 1);
}
/** Compute right edge opacity: 0 when at max scroll, 1 when remaining >= FADE_DISTANCE */
export function rightOpacity(metrics: ScrollMetrics): number {
const { scrollLeft, scrollWidth, clientWidth } = metrics;
if (scrollWidth <= clientWidth) return 0;
const maxScroll = scrollWidth - clientWidth;
const remainingScroll = maxScroll - scrollLeft;
return Math.min(remainingScroll / FADE_DISTANCE, 1);
}
/** Build a CSS mask-image gradient from scroll metrics */
export function maskGradient(metrics: ScrollMetrics): string {
const left = leftOpacity(metrics);
const right = rightOpacity(metrics);
const leftEnd = left * FADE_PERCENT;
const rightStart = 100 - right * FADE_PERCENT;
return `linear-gradient(to right, transparent 0%, black ${leftEnd}%, black ${rightStart}%, transparent 100%)`;
}
+48 -6
View File
@@ -1,6 +1,18 @@
import { beforeNavigate } from "$app/navigation";
import { beforeNavigate, onNavigate } from "$app/navigation";
export type NavDirection = "left" | "right" | "fade";
export type NavAxis = "horizontal" | "vertical";
/**
* Path used for navbar label expansion. Deferred during view transitions so
* CSS transitions run on visible DOM instead of snapping while hidden.
* The pill animation (JS/RAF-driven) uses page.url.pathname directly.
*/
class NavbarState {
path = $state(typeof window !== "undefined" ? window.location.pathname : "/");
}
export const navbar = new NavbarState();
/** Sidebar nav order — indexes determine slide direction for same-depth siblings */
const SIDEBAR_NAV_ORDER = [
@@ -12,6 +24,8 @@ const SIDEBAR_NAV_ORDER = [
"/admin/users",
];
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
function getDepth(path: string): number {
return path.replace(/\/$/, "").split("/").filter(Boolean).length;
}
@@ -37,16 +51,44 @@ function computeDirection(from: string, to: string): NavDirection {
return "fade";
}
class NavigationStore {
direction: NavDirection = $state("fade");
function computeAxis(from: string, to: string): NavAxis {
const fromIsApp = APP_PREFIXES.some((p) => from.startsWith(p));
const toIsApp = APP_PREFIXES.some((p) => to.startsWith(p));
return fromIsApp && toIsApp ? "vertical" : "horizontal";
}
export const navigationStore = new NavigationStore();
/** Call once from root layout to start tracking navigation direction */
export function initNavigation() {
navbar.path = window.location.pathname;
beforeNavigate(({ from, to }) => {
if (!from?.url || !to?.url) return;
navigationStore.direction = computeDirection(from.url.pathname, to.url.pathname);
const fromPath = from.url.pathname;
const toPath = to.url.pathname;
document.documentElement.dataset.navDirection = computeDirection(fromPath, toPath);
document.documentElement.dataset.navAxis = computeAxis(fromPath, toPath);
});
onNavigate((navigation) => {
if (!document.startViewTransition) {
// No view transitions — update path when navigation completes
navigation.complete.then(() => {
navbar.path = window.location.pathname;
});
return;
}
return new Promise((resolve) => {
const vt = document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
// Update navbar path only after the view transition finishes and the
// real DOM is visible again, so CSS transitions can actually run.
vt.finished.then(() => {
navbar.path = window.location.pathname;
});
});
});
}
+93 -6
View File
@@ -2,8 +2,8 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import BottomSheet from "$lib/components/BottomSheet.svelte";
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
import PageTransition from "$lib/components/PageTransition.svelte";
import {
Activity,
ClipboardList,
@@ -11,6 +11,7 @@ import {
GraduationCap,
LayoutDashboard,
LogOut,
MoreHorizontal,
Settings,
User,
Users,
@@ -19,6 +20,8 @@ import { tick } from "svelte";
let { children } = $props();
let moreSheetOpen = $state(false);
// Track boundary reset function so navigation can auto-clear errors
let boundaryReset = $state<(() => void) | null>(null);
let errorPathname = $state<string | null>(null);
@@ -65,6 +68,27 @@ function isActive(href: string): boolean {
if (href === "/admin") return page.url.pathname === "/admin";
return page.url.pathname.startsWith(href);
}
// Bottom tab bar definitions
const adminTabs = [
{ href: "/profile", icon: User, label: "Profile" },
{ href: "/admin", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/admin/scraper", icon: Activity, label: "Scraper" },
] as const;
const nonAdminTabs = [
{ href: "/profile", icon: User, label: "Profile" },
{ href: "/settings", icon: Settings, label: "Settings" },
] as const;
// "More" sheet items (admin only, items not in the tab bar)
const moreSheetItems = [
{ href: "/settings", icon: Settings, label: "Settings" },
{ href: "/admin/jobs", icon: ClipboardList, label: "Scrape Jobs" },
{ href: "/admin/audit", icon: FileText, label: "Audit Log" },
{ href: "/admin/users", icon: Users, label: "Users" },
{ href: "/admin/instructors", icon: GraduationCap, label: "Instructors" },
] as const;
</script>
{#if authStore.isLoading}
@@ -80,10 +104,10 @@ function isActive(href: string): boolean {
</div>
</div>
{:else}
<div class="flex flex-col items-center px-5 pb-5 pt-20">
<div class="flex flex-col items-center px-3 md:px-5 pb-20 md:pb-5 pt-20">
<div class="w-full max-w-6xl flex gap-8">
<!-- Inline sidebar -->
<aside class="w-48 shrink-0 pt-1">
<aside class="hidden md:block w-48 shrink-0 pt-1">
{#if authStore.user}
<div class="mb-4 px-2">
<p class="text-sm font-medium text-foreground">{authStore.user.discordUsername}</p>
@@ -135,11 +159,9 @@ function isActive(href: string): boolean {
</aside>
<!-- Content -->
<main class="flex-1 min-w-0">
<main class="flex-1 min-w-0" style="view-transition-name: app-content">
<svelte:boundary onerror={onBoundaryError}>
<PageTransition key={page.url.pathname} axis="vertical">
{@render children()}
</PageTransition>
{#snippet failed(error, reset)}
<ErrorBoundaryFallback title="Page error" {error} {reset} />
@@ -148,4 +170,69 @@ function isActive(href: string): boolean {
</main>
</div>
</div>
<!-- Mobile bottom tab bar -->
<nav class="fixed bottom-0 inset-x-0 z-30 md:hidden bg-background/95 backdrop-blur-md border-t border-border pb-[env(safe-area-inset-bottom)]">
<div class="flex">
{#each authStore.isAdmin ? adminTabs : nonAdminTabs as tab}
<a
href={tab.href}
class="flex flex-col items-center justify-center gap-0.5 flex-1 py-2 min-h-[56px] no-underline
{isActive(tab.href) ? 'text-foreground' : 'text-muted-foreground'}"
>
<tab.icon size={20} strokeWidth={1.75} />
<span class="text-[10px] font-medium">{tab.label}</span>
</a>
{/each}
{#if authStore.isAdmin}
<button
onclick={() => (moreSheetOpen = true)}
class="flex flex-col items-center justify-center gap-0.5 flex-1 py-2 min-h-[56px]
bg-transparent border-none cursor-pointer text-muted-foreground"
>
<MoreHorizontal size={20} strokeWidth={1.75} />
<span class="text-[10px] font-medium">More</span>
</button>
{:else}
<button
onclick={() => authStore.logout()}
class="flex flex-col items-center justify-center gap-0.5 flex-1 py-2 min-h-[56px]
bg-transparent border-none cursor-pointer text-muted-foreground"
>
<LogOut size={20} strokeWidth={1.75} />
<span class="text-[10px] font-medium">Sign Out</span>
</button>
{/if}
</div>
</nav>
<!-- Admin "More" bottom sheet -->
{#if authStore.isAdmin}
<BottomSheet bind:open={moreSheetOpen} maxHeight="60vh" label="More options">
<nav class="flex flex-col gap-0.5 px-4 pb-4">
{#each moreSheetItems as item}
<a
href={item.href}
onclick={() => (moreSheetOpen = false)}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm no-underline transition-colors
{isActive(item.href)
? 'text-foreground bg-muted font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<item.icon size={15} strokeWidth={2} />
{item.label}
</a>
{/each}
<div class="my-2 mx-2 border-t border-border"></div>
<button
onclick={() => { moreSheetOpen = false; authStore.logout(); }}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer
bg-transparent border-none text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<LogOut size={15} strokeWidth={2} />
Sign Out
</button>
</nav>
</BottomSheet>
{/if}
{/if}
+2 -16
View File
@@ -1,11 +1,9 @@
<script lang="ts">
import "overlayscrollbars/overlayscrollbars.css";
import "./layout.css";
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
import NavBar from "$lib/components/NavBar.svelte";
import PageTransition from "$lib/components/PageTransition.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import { initNavigation } from "$lib/stores/navigation.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
@@ -14,16 +12,6 @@ import { onMount } from "svelte";
let { children } = $props();
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
/**
* Coarsened key so sub-route navigation within the (app) layout group
* doesn't re-trigger the root page transition — the shared layout handles its own.
*/
let transitionKey = $derived(
APP_PREFIXES.some((p) => page.url.pathname.startsWith(p)) ? "/app" : page.url.pathname
);
initNavigation();
useOverlayScrollbars(() => document.body, {
@@ -40,17 +28,15 @@ onMount(() => {
</script>
<Tooltip.Provider delayDuration={150} skipDelayDuration={50}>
<div class="relative flex min-h-screen flex-col">
<div class="relative flex min-h-screen flex-col overflow-x-hidden">
<!-- pointer-events-none so the navbar doesn't block canvas interactions;
NavBar re-enables pointer-events on its own container. -->
<div class="absolute inset-x-0 top-0 z-50 pointer-events-none">
<div class="absolute inset-x-0 top-0 z-50 pointer-events-none" style="view-transition-name: navbar">
<NavBar />
</div>
<svelte:boundary onerror={(e) => console.error("[root boundary]", e)}>
<PageTransition key={transitionKey}>
{@render children()}
</PageTransition>
{#snippet failed(error, reset)}
<ErrorBoundaryFallback {error} {reset} />
+85 -5
View File
@@ -20,6 +20,7 @@ import { Check, Columns3, RotateCcw } from "@lucide/svelte";
import type { SortingState, VisibilityState } from "@tanstack/table-core";
import { DropdownMenu } from "bits-ui";
import { tick, untrack } from "svelte";
import { type ScrollMetrics, maskGradient as computeMaskGradient } from "$lib/scroll-fade";
import { fly } from "svelte/transition";
let { data } = $props();
@@ -482,8 +483,47 @@ function handlePageChange(newOffset: number) {
// Column visibility state (lifted from CourseTable)
let columnVisibility: VisibilityState = $state({});
// Responsive column hiding: hide CRN and Location in the sm-to-md range (640-768px)
let isCompactTable = $state(false);
// Track columns the user has explicitly toggled so we don't override their choices
let userToggledColumns = $state(new Set<string>());
$effect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia("(min-width: 640px) and (max-width: 767px)");
isCompactTable = mql.matches;
const handler = (e: MediaQueryListEvent) => {
isCompactTable = e.matches;
};
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
});
// Auto-hide/show columns based on compact mode (only for columns the user hasn't manually toggled)
const autoHideColumns = ["crn", "location"];
$effect(() => {
const compact = isCompactTable;
const toggled = userToggledColumns;
const current = untrack(() => columnVisibility);
let changed = false;
const next = { ...current };
for (const col of autoHideColumns) {
if (toggled.has(col)) continue;
const shouldHide = compact;
if (shouldHide && next[col] !== false) {
next[col] = false;
changed = true;
} else if (!shouldHide && next[col] === false) {
delete next[col];
changed = true;
}
}
if (changed) columnVisibility = next;
});
function resetColumnVisibility() {
columnVisibility = {};
userToggledColumns = new Set();
}
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
@@ -572,17 +612,54 @@ function clearAllFilters() {
courseNumberMin = null;
courseNumberMax = null;
}
// Scroll-based fade mask for chips container
let chipsContainer: HTMLDivElement | undefined = $state();
let scrollMetrics = $state<ScrollMetrics>({ scrollLeft: 0, scrollWidth: 0, clientWidth: 0 });
const maskGradient = $derived(computeMaskGradient(scrollMetrics));
function updateScrollMetrics() {
if (!chipsContainer) return;
scrollMetrics = {
scrollLeft: chipsContainer.scrollLeft,
scrollWidth: chipsContainer.scrollWidth,
clientWidth: chipsContainer.clientWidth,
};
}
$effect(() => {
if (!chipsContainer) return;
const el = chipsContainer; // capture for cleanup
const ro = new ResizeObserver(updateScrollMetrics);
ro.observe(el);
el.addEventListener("scroll", updateScrollMetrics, { passive: true });
updateScrollMetrics(); // initial measurement
return () => {
ro.disconnect();
el.removeEventListener("scroll", updateScrollMetrics);
};
});
</script>
<div class="min-h-screen flex flex-col items-center px-5 pb-5 pt-20">
<div class="min-h-screen flex flex-col items-center px-3 md:px-5 pb-5 pt-20">
<div class="w-full max-w-6xl flex flex-col pt-2">
<!-- Chips bar: status | chips | view button -->
<div class="flex items-end gap-3 min-h-7">
<div class="flex flex-col md:flex-row md:items-end gap-1 md:gap-3 min-h-7">
<SearchStatus meta={searchMeta} {loading} />
<!-- Active filter chips -->
<div
class="flex items-center gap-1.5 flex-1 min-w-0 flex-wrap pb-1.5"
bind:this={chipsContainer}
class="flex items-center gap-1.5 flex-1 min-w-0
flex-nowrap overflow-x-auto md:flex-wrap md:overflow-x-visible
-mx-3 px-3 md:mx-0 md:px-0
pb-1.5 scrollbar-none"
style:mask-image={maskGradient}
style:-webkit-mask-image={maskGradient}
>
{#if selectedSubjects.length > 0}
<SegmentedChip
@@ -686,16 +763,18 @@ function clearAllFilters() {
{#if activeFilterCount >= 2}
<button
type="button"
class="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none ml-1"
class="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none ml-1 shrink-0"
onclick={clearAllFilters}
>
Clear all
</button>
{/if}
<!-- Trailing spacer so last chip scrolls past the fade mask -->
<div class="shrink-0 w-6 md:hidden" aria-hidden="true"></div>
</div>
<!-- View columns dropdown (moved from CourseTable) -->
<div class="pb-1.5">
<div class="hidden md:block pb-1.5">
<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 select-none shrink-0"
@@ -735,6 +814,7 @@ function clearAllFilters() {
onCheckedChange={(
checked,
) => {
userToggledColumns = new Set(userToggledColumns).add(col.id);
columnVisibility = {
...columnVisibility,
[col.id]: checked,
+170 -6
View File
@@ -153,12 +153,7 @@ input[type="checkbox"]:checked::before {
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
/* View Transitions API - disable default cross-fade for scoped table transitions */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* View Transitions API — base root rules (overridden by directional rules below) */
/* OverlayScrollbars — handle colors via inherited custom properties (set in :root / .dark) */
.os-scrollbar-handle {
@@ -177,6 +172,17 @@ body::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar utility — for horizontal scroll strips.
Custom utility since Tailwind CSS v4 doesn't provide a built-in scrollbar-hiding class. */
.scrollbar-none {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
/* Native scrollbars — theme-aware via inherited custom properties (no .dark * selectors) */
* {
scrollbar-width: thin;
@@ -247,3 +253,161 @@ body::-webkit-scrollbar {
::view-transition-new(search-results) {
animation-duration: 200ms;
}
/* During theme transitions, suppress named view-transition elements so they
don't get their own transition group and snap to the new theme prematurely. */
.theme-transitioning * {
view-transition-name: none !important;
}
/* View Transitions — page navigation animations */
/* Exclude navbar from page transitions */
::view-transition-group(navbar) {
animation: none;
}
/* Default: crossfade */
@keyframes vt-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes vt-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
::view-transition-old(root),
::view-transition-old(app-content) {
animation: vt-fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-new(root),
::view-transition-new(app-content) {
animation: vt-fade-in 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Horizontal slide transitions (root-level navigation) */
@keyframes vt-slide-out-left {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-40px);
}
}
@keyframes vt-slide-in-right {
from {
opacity: 0;
transform: translateX(40px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes vt-slide-out-right {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(40px);
}
}
@keyframes vt-slide-in-left {
from {
opacity: 0;
transform: translateX(-40px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
:root[data-nav-axis="horizontal"][data-nav-direction="right"] ::view-transition-old(root) {
animation: vt-slide-out-left 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
:root[data-nav-axis="horizontal"][data-nav-direction="right"] ::view-transition-new(root) {
animation: vt-slide-in-right 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
:root[data-nav-axis="horizontal"][data-nav-direction="left"] ::view-transition-old(root) {
animation: vt-slide-out-right 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
:root[data-nav-axis="horizontal"][data-nav-direction="left"] ::view-transition-new(root) {
animation: vt-slide-in-left 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Vertical slide transitions (app sub-route navigation) */
@keyframes vt-slide-out-up {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-40px);
}
}
@keyframes vt-slide-in-down {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes vt-slide-out-down {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(40px);
}
}
@keyframes vt-slide-in-up {
from {
opacity: 0;
transform: translateY(-40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:root[data-nav-axis="vertical"][data-nav-direction="right"] ::view-transition-old(app-content) {
animation: vt-slide-out-up 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
:root[data-nav-axis="vertical"][data-nav-direction="right"] ::view-transition-new(app-content) {
animation: vt-slide-in-down 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
:root[data-nav-axis="vertical"][data-nav-direction="left"] ::view-transition-old(app-content) {
animation: vt-slide-out-down 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
:root[data-nav-axis="vertical"][data-nav-direction="left"] ::view-transition-new(app-content) {
animation: vt-slide-in-up 400ms cubic-bezier(0.4, 0, 0.2, 1);
}