Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 109 additions & 15 deletions apps/web/src/app/dashboard/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react";
import { CalendarIcon, ListIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useNextTerm, useNextYear } from "@/components/AppConfigProvider";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import ViewSelector from "@/components/ViewSelector";
import { useSearchParam } from "@/hooks/use-search-param";
import { CourseSelector } from "@/modules/course-selection";
Expand All @@ -14,6 +16,7 @@ import type {
CourseOfferingWithCourse,
} from "@/modules/course-selection/types";
import {
type Class,
getUserClassesByTerm,
ScheduleCalendar,
} from "@/modules/schedule-calendar/schedule-calendar";
Expand All @@ -26,9 +29,50 @@ const RegisterPage = () => {
const [hoveredCourse, setHoveredCourse] = useState<CourseOffering | null>(
null,
);
const [selectedCourse, setSelectedCourse] = useState<Class | null>(null);
const [mobileView, setMobileView] = useState<"selector" | "calendar">(
"selector",
);
const [previousMobileView, setPreviousMobileView] = useState<
"selector" | "calendar"
>("selector");
const [isMobile, setIsMobile] = useState(false);

// TODO: save the state to cookie
const [showAlternatives, setShowAlternatives] = useState(true);

useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};

checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);

useEffect(() => {
if (selectedCourse && isMobile && mobileView === "calendar") {
setPreviousMobileView("calendar");
setMobileView("selector");
}
}, [selectedCourse, isMobile, mobileView]);

const handleCourseSelect = (course: Class | null) => {
if (!course && isMobile && previousMobileView === "calendar") {
// When closing detail panel on mobile, return to calendar view
setMobileView("calendar");
}
setSelectedCourse(course);
};

// clear selected course when switching tabs
const handleMobileViewChange = (view: "selector" | "calendar") => {
setMobileView(view);
if (view === "calendar" && selectedCourse) {
setSelectedCourse(null);
}
};

// Search param state with debouncing and URL sync
const { searchValue, setSearchValue, debouncedSearchValue } = useSearchParam({
Expand Down Expand Up @@ -68,7 +112,16 @@ const RegisterPage = () => {
}
}, [results, debouncedSearchValue, status]);

const classes = getUserClassesByTerm(allClasses, currentYear, currentTerm);
const allClassesForTerm = getUserClassesByTerm(
allClasses,
currentYear,
currentTerm,
);

// Filter out alternatives if toggle is off
const classes = showAlternatives
? allClassesForTerm
: allClassesForTerm?.filter((c) => !c.alternativeOf);

const isSearching =
status === "LoadingFirstPage" &&
Expand All @@ -84,13 +137,32 @@ const RegisterPage = () => {
return <CourseSelectorSkeleton />;
}

const AltToggle = () => (
<>
<Switch
id="alt-switcher"
className="order-1 h-4 w-6 after:absolute after:inset-0 [&_span]:size-3 data-[state=checked]:[&_span]:translate-x-2 data-[state=checked]:[&_span]:rtl:-translate-x-2"
checked={showAlternatives}
onCheckedChange={setShowAlternatives}
/>
<div className="grid grow gap-2">
<Label htmlFor="alt-switcher">Show alternative courses</Label>
<p className="text-xs text-muted-foreground">
You can set one course as alternative for another.
</p>
</div>
</>
);

return (
<div className="flex flex-col gap-4 w-full">
{/* Mobile toggle buttons */}
<div className="md:hidden shrink-0 p-2">
<ViewSelector
value={mobileView}
onValueChange={setMobileView}
onValueChange={(val) =>
handleMobileViewChange(val as "selector" | "calendar")
}
tabs={[
{ value: "selector", label: "Courses", icon: ListIcon },
{ value: "calendar", label: "Schedule", icon: CalendarIcon },
Expand All @@ -109,31 +181,53 @@ const RegisterPage = () => {
loadMore={loadMore}
status={status}
isSearching={isSearching}
selectedCourse={selectedCourse}
onCourseSelect={handleCourseSelect}
selectedClassNumbers={selectedClassNumbers}
/>
) : (
<div className="h-full">
<ScheduleCalendar classes={classes} hoveredCourse={hoveredCourse} />
<div className="h-full flex flex-col space-y-2">
<div className="md:hidden relative flex w-full items-start gap-2 rounded-md border border-input p-4 shadow-xs outline-none has-data-[state=checked]:border-primary/50">
<AltToggle />
</div>
<ScheduleCalendar
classes={classes}
hoveredCourse={hoveredCourse}
selectedCourse={selectedCourse}
onCourseSelect={handleCourseSelect}
/>
</div>
)}
</div>

{/* Desktop view */}
<div className="hidden md:flex gap-4 flex-1 min-h-0">
<CourseSelector
courseOfferingsWithCourses={displayedResults}
onHover={setHoveredCourse}
onSearchChange={setSearchValue}
searchQuery={searchValue}
loadMore={loadMore}
status={status}
isSearching={isSearching}
selectedClassNumbers={selectedClassNumbers}
/>
<div className="flex flex-col space-y-4">
<div className="relative flex w-full items-start gap-2 rounded-md border border-input p-4 shadow-xs outline-none has-data-[state=checked]:border-primary/50">
<AltToggle />
</div>
<CourseSelector
courseOfferingsWithCourses={displayedResults}
onHover={setHoveredCourse}
onSearchChange={setSearchValue}
searchQuery={searchValue}
loadMore={loadMore}
status={status}
isSearching={isSearching}
selectedCourse={selectedCourse}
onCourseSelect={handleCourseSelect}
selectedClassNumbers={selectedClassNumbers}
/>
</div>

<div className="flex-1 min-w-0">
<div className="sticky top-0">
<ScheduleCalendar classes={classes} hoveredCourse={hoveredCourse} />
<ScheduleCalendar
classes={classes}
hoveredCourse={hoveredCourse}
selectedCourse={selectedCourse}
onCourseSelect={handleCourseSelect}
/>
</div>
</div>
</div>
Expand Down
32 changes: 15 additions & 17 deletions apps/web/src/hooks/use-search-param.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useDebounce } from "./use-debounce";

interface UseSearchParamOptions {
Expand All @@ -19,25 +19,23 @@ export function useSearchParam(options: UseSearchParamOptions) {
);
const debouncedSearchValue = useDebounce(searchValue, debounceDelay);

// Track the last URL-synced value to prevent infinite loops
const lastSyncedValue = useRef<string | null>(searchParams.get(paramKey));

// Update URL with debounced search value
useEffect(() => {
const currentValue = searchParams.get(paramKey) ?? "";

if (
(debouncedSearchValue === "" && currentValue === "") ||
debouncedSearchValue === currentValue
) {
return;
}

const params = new URLSearchParams(searchParams);
if (debouncedSearchValue) {
params.set(paramKey, debouncedSearchValue);
} else {
params.delete(paramKey);
// Only update if the debounced value differs from what's already in the URL
if (debouncedSearchValue !== lastSyncedValue.current) {
const params = new URLSearchParams(window.location.search);
if (debouncedSearchValue) {
params.set(paramKey, debouncedSearchValue);
} else {
params.delete(paramKey);
}
lastSyncedValue.current = debouncedSearchValue || null;
router.replace(`?${params.toString()}`, { scroll: false });
}
router.replace(`?${params.toString()}`, { scroll: false });
}, [debouncedSearchValue, router, searchParams, paramKey]);
}, [debouncedSearchValue, paramKey, router]);

return {
searchValue,
Expand Down
80 changes: 24 additions & 56 deletions apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"use client";
import { api } from "@albert-plus/server/convex/_generated/api";
import type { Doc } from "@albert-plus/server/convex/_generated/dataModel";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useMutation } from "convex/react";
import type { FunctionReturnType } from "convex/server";
import { ConvexError } from "convex/values";
import React, { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useSearchParam } from "@/hooks/use-search-param";
Expand Down Expand Up @@ -74,33 +73,24 @@ const CoursePlanSelector = ({
});
}, [courses, status]);

const parentRef = React.useRef<HTMLDivElement>(null);

const rowVirtualizer = useVirtualizer({
count: filteredCourses.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
overscan: 5,
gap: 8,
});

const virtualItems = rowVirtualizer.getVirtualItems();
const observerTarget = useRef<HTMLDivElement>(null);

useEffect(() => {
if (status !== "CanLoadMore") {
return;
}

const [lastItem] = [...virtualItems].reverse();

if (!lastItem) {
return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && status === "CanLoadMore") {
loadMore(200);
}
},
{ threshold: 0.1 },
);

if (observerTarget.current) {
observer.observe(observerTarget.current);
}

if (lastItem.index >= filteredCourses.length - 1) {
loadMore(200);
}
}, [status, loadMore, filteredCourses.length, virtualItems]);
return () => observer.disconnect();
}, [status, loadMore]);

const handleCourseAdd = async (
courseCode: string,
Expand Down Expand Up @@ -172,37 +162,15 @@ const CoursePlanSelector = ({
)}

{filteredCourses.length > 0 && (
<div
ref={parentRef}
className="overflow-auto no-scrollbar w-full flex-1 min-h-0"
>
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
const course = filteredCourses[virtualItem.index];

return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={rowVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${virtualItem.start}px)`,
}}
>
<CoursePlanCard
course={course}
onAdd={() => setSelectedCourse(course)}
/>
</div>
);
})}
</div>
<div className="overflow-auto no-scrollbar w-full flex-1 min-h-0 space-y-2">
{filteredCourses.map((course) => (
<CoursePlanCard
key={course._id}
course={course}
onAdd={() => setSelectedCourse(course)}
/>
))}
<div ref={observerTarget} className="h-1" />
</div>
)}

Expand Down
Loading