Skip to content

Commit d916e95

Browse files
xyspgchenxin-yan
andauthored
feat(web): add course register sidepanel (#90)
Co-authored-by: Chenxin Yan <[email protected]>
1 parent 85836cf commit d916e95

File tree

17 files changed

+1312
-163
lines changed

17 files changed

+1312
-163
lines changed

apps/web/src/app/dashboard/register/page.tsx

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react";
55
import { CalendarIcon, ListIcon } from "lucide-react";
66
import { useEffect, useRef, useState } from "react";
77
import { useNextTerm, useNextYear } from "@/components/AppConfigProvider";
8+
import { Label } from "@/components/ui/label";
9+
import { Switch } from "@/components/ui/switch";
810
import ViewSelector from "@/components/ViewSelector";
911
import { useSearchParam } from "@/hooks/use-search-param";
1012
import { CourseSelector } from "@/modules/course-selection";
@@ -14,6 +16,7 @@ import type {
1416
CourseOfferingWithCourse,
1517
} from "@/modules/course-selection/types";
1618
import {
19+
type Class,
1720
getUserClassesByTerm,
1821
ScheduleCalendar,
1922
} from "@/modules/schedule-calendar/schedule-calendar";
@@ -26,9 +29,50 @@ const RegisterPage = () => {
2629
const [hoveredCourse, setHoveredCourse] = useState<CourseOffering | null>(
2730
null,
2831
);
32+
const [selectedCourse, setSelectedCourse] = useState<Class | null>(null);
2933
const [mobileView, setMobileView] = useState<"selector" | "calendar">(
3034
"selector",
3135
);
36+
const [previousMobileView, setPreviousMobileView] = useState<
37+
"selector" | "calendar"
38+
>("selector");
39+
const [isMobile, setIsMobile] = useState(false);
40+
41+
// TODO: save the state to cookie
42+
const [showAlternatives, setShowAlternatives] = useState(true);
43+
44+
useEffect(() => {
45+
const checkMobile = () => {
46+
setIsMobile(window.innerWidth < 768);
47+
};
48+
49+
checkMobile();
50+
window.addEventListener("resize", checkMobile);
51+
return () => window.removeEventListener("resize", checkMobile);
52+
}, []);
53+
54+
useEffect(() => {
55+
if (selectedCourse && isMobile && mobileView === "calendar") {
56+
setPreviousMobileView("calendar");
57+
setMobileView("selector");
58+
}
59+
}, [selectedCourse, isMobile, mobileView]);
60+
61+
const handleCourseSelect = (course: Class | null) => {
62+
if (!course && isMobile && previousMobileView === "calendar") {
63+
// When closing detail panel on mobile, return to calendar view
64+
setMobileView("calendar");
65+
}
66+
setSelectedCourse(course);
67+
};
68+
69+
// clear selected course when switching tabs
70+
const handleMobileViewChange = (view: "selector" | "calendar") => {
71+
setMobileView(view);
72+
if (view === "calendar" && selectedCourse) {
73+
setSelectedCourse(null);
74+
}
75+
};
3276

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

71-
const classes = getUserClassesByTerm(allClasses, currentYear, currentTerm);
115+
const allClassesForTerm = getUserClassesByTerm(
116+
allClasses,
117+
currentYear,
118+
currentTerm,
119+
);
120+
121+
// Filter out alternatives if toggle is off
122+
const classes = showAlternatives
123+
? allClassesForTerm
124+
: allClassesForTerm?.filter((c) => !c.alternativeOf);
72125

73126
const isSearching =
74127
status === "LoadingFirstPage" &&
@@ -84,13 +137,32 @@ const RegisterPage = () => {
84137
return <CourseSelectorSkeleton />;
85138
}
86139

140+
const AltToggle = () => (
141+
<>
142+
<Switch
143+
id="alt-switcher"
144+
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"
145+
checked={showAlternatives}
146+
onCheckedChange={setShowAlternatives}
147+
/>
148+
<div className="grid grow gap-2">
149+
<Label htmlFor="alt-switcher">Show alternative courses</Label>
150+
<p className="text-xs text-muted-foreground">
151+
You can set one course as alternative for another.
152+
</p>
153+
</div>
154+
</>
155+
);
156+
87157
return (
88158
<div className="flex flex-col gap-4 w-full">
89159
{/* Mobile toggle buttons */}
90160
<div className="md:hidden shrink-0 p-2">
91161
<ViewSelector
92162
value={mobileView}
93-
onValueChange={setMobileView}
163+
onValueChange={(val) =>
164+
handleMobileViewChange(val as "selector" | "calendar")
165+
}
94166
tabs={[
95167
{ value: "selector", label: "Courses", icon: ListIcon },
96168
{ value: "calendar", label: "Schedule", icon: CalendarIcon },
@@ -109,31 +181,53 @@ const RegisterPage = () => {
109181
loadMore={loadMore}
110182
status={status}
111183
isSearching={isSearching}
184+
selectedCourse={selectedCourse}
185+
onCourseSelect={handleCourseSelect}
112186
selectedClassNumbers={selectedClassNumbers}
113187
/>
114188
) : (
115-
<div className="h-full">
116-
<ScheduleCalendar classes={classes} hoveredCourse={hoveredCourse} />
189+
<div className="h-full flex flex-col space-y-2">
190+
<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">
191+
<AltToggle />
192+
</div>
193+
<ScheduleCalendar
194+
classes={classes}
195+
hoveredCourse={hoveredCourse}
196+
selectedCourse={selectedCourse}
197+
onCourseSelect={handleCourseSelect}
198+
/>
117199
</div>
118200
)}
119201
</div>
120202

121203
{/* Desktop view */}
122204
<div className="hidden md:flex gap-4 flex-1 min-h-0">
123-
<CourseSelector
124-
courseOfferingsWithCourses={displayedResults}
125-
onHover={setHoveredCourse}
126-
onSearchChange={setSearchValue}
127-
searchQuery={searchValue}
128-
loadMore={loadMore}
129-
status={status}
130-
isSearching={isSearching}
131-
selectedClassNumbers={selectedClassNumbers}
132-
/>
205+
<div className="flex flex-col space-y-4">
206+
<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">
207+
<AltToggle />
208+
</div>
209+
<CourseSelector
210+
courseOfferingsWithCourses={displayedResults}
211+
onHover={setHoveredCourse}
212+
onSearchChange={setSearchValue}
213+
searchQuery={searchValue}
214+
loadMore={loadMore}
215+
status={status}
216+
isSearching={isSearching}
217+
selectedCourse={selectedCourse}
218+
onCourseSelect={handleCourseSelect}
219+
selectedClassNumbers={selectedClassNumbers}
220+
/>
221+
</div>
133222

134223
<div className="flex-1 min-w-0">
135224
<div className="sticky top-0">
136-
<ScheduleCalendar classes={classes} hoveredCourse={hoveredCourse} />
225+
<ScheduleCalendar
226+
classes={classes}
227+
hoveredCourse={hoveredCourse}
228+
selectedCourse={selectedCourse}
229+
onCourseSelect={handleCourseSelect}
230+
/>
137231
</div>
138232
</div>
139233
</div>

apps/web/src/hooks/use-search-param.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useRouter, useSearchParams } from "next/navigation";
2-
import { useEffect, useState } from "react";
2+
import { useEffect, useRef, useState } from "react";
33
import { useDebounce } from "./use-debounce";
44

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

22+
// Track the last URL-synced value to prevent infinite loops
23+
const lastSyncedValue = useRef<string | null>(searchParams.get(paramKey));
24+
2225
// Update URL with debounced search value
2326
useEffect(() => {
24-
const currentValue = searchParams.get(paramKey) ?? "";
25-
26-
if (
27-
(debouncedSearchValue === "" && currentValue === "") ||
28-
debouncedSearchValue === currentValue
29-
) {
30-
return;
31-
}
32-
33-
const params = new URLSearchParams(searchParams);
34-
if (debouncedSearchValue) {
35-
params.set(paramKey, debouncedSearchValue);
36-
} else {
37-
params.delete(paramKey);
27+
// Only update if the debounced value differs from what's already in the URL
28+
if (debouncedSearchValue !== lastSyncedValue.current) {
29+
const params = new URLSearchParams(window.location.search);
30+
if (debouncedSearchValue) {
31+
params.set(paramKey, debouncedSearchValue);
32+
} else {
33+
params.delete(paramKey);
34+
}
35+
lastSyncedValue.current = debouncedSearchValue || null;
36+
router.replace(`?${params.toString()}`, { scroll: false });
3837
}
39-
router.replace(`?${params.toString()}`, { scroll: false });
40-
}, [debouncedSearchValue, router, searchParams, paramKey]);
38+
}, [debouncedSearchValue, paramKey, router]);
4139

4240
return {
4341
searchValue,

apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx

Lines changed: 24 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"use client";
22
import { api } from "@albert-plus/server/convex/_generated/api";
33
import type { Doc } from "@albert-plus/server/convex/_generated/dataModel";
4-
import { useVirtualizer } from "@tanstack/react-virtual";
54
import { useMutation } from "convex/react";
65
import type { FunctionReturnType } from "convex/server";
76
import { ConvexError } from "convex/values";
8-
import React, { useEffect, useState } from "react";
7+
import { useEffect, useRef, useState } from "react";
98
import { toast } from "sonner";
109
import { Button } from "@/components/ui/button";
1110
import { useSearchParam } from "@/hooks/use-search-param";
@@ -74,33 +73,24 @@ const CoursePlanSelector = ({
7473
});
7574
}, [courses, status]);
7675

77-
const parentRef = React.useRef<HTMLDivElement>(null);
78-
79-
const rowVirtualizer = useVirtualizer({
80-
count: filteredCourses.length,
81-
getScrollElement: () => parentRef.current,
82-
estimateSize: () => 80,
83-
overscan: 5,
84-
gap: 8,
85-
});
86-
87-
const virtualItems = rowVirtualizer.getVirtualItems();
76+
const observerTarget = useRef<HTMLDivElement>(null);
8877

8978
useEffect(() => {
90-
if (status !== "CanLoadMore") {
91-
return;
92-
}
93-
94-
const [lastItem] = [...virtualItems].reverse();
95-
96-
if (!lastItem) {
97-
return;
79+
const observer = new IntersectionObserver(
80+
(entries) => {
81+
if (entries[0].isIntersecting && status === "CanLoadMore") {
82+
loadMore(200);
83+
}
84+
},
85+
{ threshold: 0.1 },
86+
);
87+
88+
if (observerTarget.current) {
89+
observer.observe(observerTarget.current);
9890
}
9991

100-
if (lastItem.index >= filteredCourses.length - 1) {
101-
loadMore(200);
102-
}
103-
}, [status, loadMore, filteredCourses.length, virtualItems]);
92+
return () => observer.disconnect();
93+
}, [status, loadMore]);
10494

10595
const handleCourseAdd = async (
10696
courseCode: string,
@@ -172,37 +162,15 @@ const CoursePlanSelector = ({
172162
)}
173163

174164
{filteredCourses.length > 0 && (
175-
<div
176-
ref={parentRef}
177-
className="overflow-auto no-scrollbar w-full flex-1 min-h-0"
178-
>
179-
<div
180-
className="relative w-full"
181-
style={{
182-
height: `${rowVirtualizer.getTotalSize()}px`,
183-
}}
184-
>
185-
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
186-
const course = filteredCourses[virtualItem.index];
187-
188-
return (
189-
<div
190-
key={virtualItem.key}
191-
data-index={virtualItem.index}
192-
ref={rowVirtualizer.measureElement}
193-
className="absolute top-0 left-0 w-full"
194-
style={{
195-
transform: `translateY(${virtualItem.start}px)`,
196-
}}
197-
>
198-
<CoursePlanCard
199-
course={course}
200-
onAdd={() => setSelectedCourse(course)}
201-
/>
202-
</div>
203-
);
204-
})}
205-
</div>
165+
<div className="overflow-auto no-scrollbar w-full flex-1 min-h-0 space-y-2">
166+
{filteredCourses.map((course) => (
167+
<CoursePlanCard
168+
key={course._id}
169+
course={course}
170+
onAdd={() => setSelectedCourse(course)}
171+
/>
172+
))}
173+
<div ref={observerTarget} className="h-1" />
206174
</div>
207175
)}
208176

0 commit comments

Comments
 (0)