v1.2.0: Add course info popovers, favicon, and viewport-fitted layout
- Course info popovers with description, instructors, and specialization tags; opens on hover (desktop) or tap (mobile) with smart positioning - Page title and graduation cap favicon in NYU Stern purple - Desktop layout fits viewport without page-level scrolling
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { coursesBySet } from '../data/lookups';
|
||||
import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions';
|
||||
import { courseById } from '../data/lookups';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
import type { Term } from '../data/types';
|
||||
import type { SetAnalysis } from '../solver/decisionTree';
|
||||
|
||||
@@ -12,6 +16,164 @@ for (const spec of SPECIALIZATIONS) {
|
||||
}
|
||||
}
|
||||
|
||||
// specId → full spec name
|
||||
const specNameById: Record<string, string> = {};
|
||||
for (const spec of SPECIALIZATIONS) {
|
||||
specNameById[spec.id] = spec.name;
|
||||
}
|
||||
|
||||
function CourseInfoPopover({
|
||||
courseId,
|
||||
courseName,
|
||||
anchorRect,
|
||||
onClose,
|
||||
onHoverEnter,
|
||||
onHoverLeave,
|
||||
}: {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
anchorRect: DOMRect | null;
|
||||
onClose: () => void;
|
||||
onHoverEnter: () => void;
|
||||
onHoverLeave: () => void;
|
||||
}) {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const breakpoint = useMediaQuery();
|
||||
const isMobile = breakpoint === 'mobile';
|
||||
const info = COURSE_DESCRIPTIONS[courseId];
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
// Defer so the opening click doesn't immediately close
|
||||
const id = setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
const instructorLabel = info.instructors.length > 1 ? 'Instructors' : 'Instructor';
|
||||
const instructorText = info.instructors.length > 0
|
||||
? info.instructors.join(', ')
|
||||
: null;
|
||||
|
||||
// Mobile: centered fixed overlay. Desktop: anchored near the icon, flipping above if needed.
|
||||
const popoverMaxHeight = 300;
|
||||
const spaceBelow = anchorRect ? window.innerHeight - anchorRect.bottom - 6 : popoverMaxHeight;
|
||||
const spaceAbove = anchorRect ? anchorRect.top - 6 : popoverMaxHeight;
|
||||
const placeAbove = spaceBelow < Math.min(popoverMaxHeight, 150) && spaceAbove > spaceBelow;
|
||||
|
||||
const positionStyle: React.CSSProperties = isMobile
|
||||
? {
|
||||
position: 'fixed',
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
}
|
||||
: {
|
||||
position: 'fixed',
|
||||
left: anchorRect ? Math.min(anchorRect.left, window.innerWidth - 340) : 0,
|
||||
...(placeAbove
|
||||
? { bottom: anchorRect ? window.innerHeight - anchorRect.top + 6 : 0, maxHeight: Math.min(popoverMaxHeight, spaceAbove) }
|
||||
: { top: anchorRect ? anchorRect.bottom + 6 : 0, maxHeight: Math.min(popoverMaxHeight, spaceBelow) }),
|
||||
maxWidth: '320px',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
zIndex: 999,
|
||||
}} />
|
||||
)}
|
||||
<div
|
||||
ref={popoverRef}
|
||||
onMouseEnter={onHoverEnter}
|
||||
onMouseLeave={onHoverLeave}
|
||||
style={{
|
||||
...positionStyle,
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b', flex: 1, paddingRight: '8px' }}>
|
||||
{courseName}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: '16px', color: '#94a3b8', padding: '0 2px',
|
||||
lineHeight: 1, flexShrink: 0,
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{instructorText && (
|
||||
<div style={{ fontSize: '12px', color: '#6366f1', marginBottom: '6px', fontWeight: 500 }}>
|
||||
{instructorLabel}: {instructorText}
|
||||
</div>
|
||||
)}
|
||||
{courseById[courseId]?.qualifications.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginBottom: '8px' }}>
|
||||
{courseById[courseId].qualifications.map((q) => (
|
||||
<span
|
||||
key={q.specId}
|
||||
title={specNameById[q.specId]}
|
||||
style={{
|
||||
fontSize: '10px', fontWeight: 600,
|
||||
padding: '2px 6px', borderRadius: '3px',
|
||||
background: '#f0f0ff', color: '#4338ca',
|
||||
border: '1px solid #e0e0ff',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{q.specId}{q.marker !== 'standard' ? ` (${q.marker})` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
fontSize: '12px', color: '#475569', lineHeight: 1.5,
|
||||
overflowY: 'auto', flex: 1,
|
||||
whiteSpace: 'pre-line',
|
||||
}}>
|
||||
{info.description}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CourseSelectionProps {
|
||||
pinnedCourses: Record<string, string | null>;
|
||||
treeResults: SetAnalysis[];
|
||||
@@ -31,6 +193,11 @@ function ElectiveSet({
|
||||
disabledCourseIds,
|
||||
onPin,
|
||||
onUnpin,
|
||||
openPopoverId,
|
||||
onOpenPopover,
|
||||
onClosePopover,
|
||||
onHoverOpen,
|
||||
onHoverLeave,
|
||||
}: {
|
||||
setId: string;
|
||||
setName: string;
|
||||
@@ -40,6 +207,11 @@ function ElectiveSet({
|
||||
disabledCourseIds: Set<string>;
|
||||
onPin: (courseId: string) => void;
|
||||
onUnpin: () => void;
|
||||
openPopoverId: string | null;
|
||||
onOpenPopover: (courseId: string, rect: DOMRect) => void;
|
||||
onClosePopover: () => void;
|
||||
onHoverOpen: (courseId: string, rect: DOMRect) => void;
|
||||
onHoverLeave: () => void;
|
||||
}) {
|
||||
const courses = coursesBySet[setId];
|
||||
const isPinned = pinnedCourseId != null;
|
||||
@@ -110,6 +282,7 @@ function ElectiveSet({
|
||||
const ceiling = ceilingMap.get(course.id);
|
||||
const reqFor = requiredForSpec[course.id];
|
||||
const showSkeleton = loading && !analysis;
|
||||
const hasInfo = !!COURSE_DESCRIPTIONS[course.id];
|
||||
return (
|
||||
<button
|
||||
key={course.id}
|
||||
@@ -131,6 +304,9 @@ function ElectiveSet({
|
||||
flex: 1,
|
||||
textDecoration: isCancelled ? 'line-through' : 'none',
|
||||
fontStyle: isCancelled ? 'italic' : 'normal',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{course.name}
|
||||
{isCancelled && (
|
||||
@@ -143,6 +319,57 @@ function ElectiveSet({
|
||||
(Already selected)
|
||||
</span>
|
||||
)}
|
||||
{!isUnavailable && hasInfo && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Info about ${course.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onHoverOpen(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
onHoverLeave();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
border: '1px solid #cbd5e1', background: openPopoverId === course.id ? '#e0e7ff' : '#f1f5f9',
|
||||
color: '#6366f1', fontSize: '10px', fontWeight: 700,
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
fontStyle: 'normal', textDecoration: 'none',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
i
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!isUnavailable && showSkeleton ? (
|
||||
<span
|
||||
@@ -193,6 +420,44 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
||||
// Index tree results by setId for O(1) lookup
|
||||
const treeBySet = new Map(treeResults.map((a) => [a.setId, a]));
|
||||
|
||||
// Single popover state: only one open at a time
|
||||
const [popover, setPopover] = useState<{ courseId: string; courseName: string; anchorRect: DOMRect } | null>(null);
|
||||
const hoverCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelHoverClose = useCallback(() => {
|
||||
if (hoverCloseTimer.current) {
|
||||
clearTimeout(hoverCloseTimer.current);
|
||||
hoverCloseTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenPopover = useCallback((courseId: string, rect: DOMRect) => {
|
||||
cancelHoverClose();
|
||||
const allCourses = Object.values(coursesBySet).flat();
|
||||
const course = allCourses.find(c => c.id === courseId);
|
||||
setPopover({ courseId, courseName: course?.name ?? '', anchorRect: rect });
|
||||
}, [cancelHoverClose]);
|
||||
|
||||
const handleClosePopover = useCallback(() => {
|
||||
cancelHoverClose();
|
||||
setPopover(null);
|
||||
}, [cancelHoverClose]);
|
||||
|
||||
// Hover open: same as click open but cancels any pending close
|
||||
const handleHoverOpen = useCallback((courseId: string, rect: DOMRect) => {
|
||||
cancelHoverClose();
|
||||
const allCourses = Object.values(coursesBySet).flat();
|
||||
const course = allCourses.find(c => c.id === courseId);
|
||||
setPopover({ courseId, courseName: course?.name ?? '', anchorRect: rect });
|
||||
}, [cancelHoverClose]);
|
||||
|
||||
// Hover leave: delayed close so mouse can move from icon to popover
|
||||
const handleHoverLeave = useCallback(() => {
|
||||
hoverCloseTimer.current = setTimeout(() => {
|
||||
setPopover(null);
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<style>{skeletonStyle}</style>
|
||||
@@ -230,10 +495,25 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
||||
disabledCourseIds={disabledCourseIds}
|
||||
onPin={(courseId) => onPin(set.id, courseId)}
|
||||
onUnpin={() => onUnpin(set.id)}
|
||||
openPopoverId={popover?.courseId ?? null}
|
||||
onOpenPopover={handleOpenPopover}
|
||||
onClosePopover={handleClosePopover}
|
||||
onHoverOpen={handleHoverOpen}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{popover && (
|
||||
<CourseInfoPopover
|
||||
courseId={popover.courseId}
|
||||
courseName={popover.courseName}
|
||||
anchorRect={popover.anchorRect}
|
||||
onClose={handleClosePopover}
|
||||
onHoverEnter={cancelHoverClose}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user