mirror of
https://github.com/Xevion/banner.git
synced 2026-02-01 04:23:40 -06:00
feat(web): build responsive layout with mobile card view
This commit is contained in:
@@ -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:00–9: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");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)}–{formatNumber(end)} of {formatNumber(
|
||||
totalCount,
|
||||
)} courses
|
||||
</span>
|
||||
<span class="text-muted-foreground select-none tabular-nums md:hidden">
|
||||
{formatNumber(start)}–{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)}–{formatNumber(end)} of {formatNumber(
|
||||
totalCount,
|
||||
)} courses
|
||||
</span>
|
||||
<span class="text-muted-foreground select-none tabular-nums md:hidden">
|
||||
{formatNumber(start)}–{formatNumber(end)} / {formatNumber(totalCount)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(); }}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -447,6 +447,15 @@ export function formatInstructorName(displayName: string): string {
|
||||
return `${rest} ${last}`;
|
||||
}
|
||||
|
||||
/** Compact meeting time summary for mobile cards: "MWF 9:00–9: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);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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%)`;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user