diff --git a/client/src/components/SearchResults.tsx b/client/src/components/SearchResults.tsx index 7da1f74..4c15b79 100644 --- a/client/src/components/SearchResults.tsx +++ b/client/src/components/SearchResults.tsx @@ -11,7 +11,7 @@ import { normalizeName } from "../utils/index"; import { useDb } from "../utils/useDb"; import Search from "./Search"; import SearchResultsContent from "./SearchResultsContent"; -import SectionList from "./SectionList"; +import { SectionList } from "./SectionList"; const Container = styled.div` display: block; @@ -52,7 +52,10 @@ interface ResultsProps { } const Results = React.memo(function Results({ search, sectionId, router }: ResultsProps) { + // Track current page for SectionList pagination + const [currentPage, setCurrentPage] = useState(1); const scrollRef = useRef(null); + const hasAutoSelected = useRef(false); const { data: db } = useDb(); @@ -74,6 +77,36 @@ const Results = React.memo(function Results({ search, sectionId, router }: Resul } ); + // Auto-select first section when sections load and no section is selected + useEffect(() => { + if (sections && sections.length > 0 && !sectionId && !hasAutoSelected.current) { + hasAutoSelected.current = true; + const firstSection = sections[0]; + if (firstSection) { + void router.push({ + pathname: "/results", + query: { search, sectionId: firstSection.id }, + }, undefined, { shallow: true }); + } + } + }, [sections, sectionId, search, router]); + + // Reset auto-select flag when search changes + useEffect(() => { + hasAutoSelected.current = false; + }, [search]); + + // Update page when sectionId changes (arrow navigation or click) + useEffect(() => { + if (sections && sections.length > 0) { + const idx = sections.findIndex(s => s.id === sectionId); + if (idx !== -1) { + const newPage = Math.floor(idx / 5) + 1; + setCurrentPage(newPage); + } + } + }, [sectionId, sections]); + // get the section data const { data: section, @@ -238,13 +271,66 @@ const Results = React.memo(function Results({ search, sectionId, router }: Resul void debouncedNavigate(id); }, [sectionId, debouncedNavigate]); + // Arrow key navigation between sections + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Don't handle arrow keys if user is typing in an input field or textarea + const target = event.target as HTMLElement; + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ) { + return; + } + + // Only handle arrow keys when a section is selected + if (!sections || sections.length === 0 || !sectionId) { + return; + } + + // Find the current section index + const currentIndex = sections.findIndex((s) => s.id === sectionId); + + if (currentIndex === -1) { + return; + } + + let newIndex = -1; + + if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + // Navigate to previous section + newIndex = currentIndex > 0 ? currentIndex - 1 : currentIndex; + event.preventDefault(); + } else if (event.key === "ArrowRight" || event.key === "ArrowDown") { + // Navigate to next section + newIndex = currentIndex < sections.length - 1 ? currentIndex + 1 : currentIndex; + event.preventDefault(); + } + + // Navigate to the new section if index changed + if (newIndex !== -1 && newIndex !== currentIndex) { + const target = sections[newIndex]; + if (target && typeof target.id === "number") { + handleClick(target.id); + } + } + }; + + // Add event listener + window.addEventListener("keydown", handleKeyDown); + // Cleanup + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [sections, sectionId, handleClick]); const handleSubmit = useCallback(({ search }: SearchQuery) => { void stableRouter.current.push({ pathname: "/results", query: { search }, - }).catch(error => { + }).catch((error: unknown) => { console.error('Navigation error:', error); }); }, []); @@ -265,7 +351,7 @@ const Results = React.memo(function Results({ search, sectionId, router }: Resul duration: 400, smooth: true }); - }).catch(error => { + }).catch((error: unknown) => { console.error('Navigation error:', error); }); }, []); @@ -288,6 +374,8 @@ const Results = React.memo(function Results({ search, sectionId, router }: Resul loading={sectionsStatus === "loading"} id={sectionId} error={sectionsError} + page={currentPage} + setPage={setCurrentPage} /> @@ -314,4 +402,4 @@ const Results = React.memo(function Results({ search, sectionId, router }: Resul prevProps.sectionId === nextProps.sectionId; }); -export default Results; +export default Results; \ No newline at end of file diff --git a/client/src/components/SearchResultsContent.tsx b/client/src/components/SearchResultsContent.tsx index 7939218..0b0a658 100644 --- a/client/src/components/SearchResultsContent.tsx +++ b/client/src/components/SearchResultsContent.tsx @@ -69,7 +69,7 @@ export default function SearchResultsContent({ } else { return ( - Nothing to see here, select a section! + {/* Nothing to see here, select a section! */} ); } diff --git a/client/src/components/SectionContent.tsx b/client/src/components/SectionContent.tsx index 3906478..2bfc5c4 100644 --- a/client/src/components/SectionContent.tsx +++ b/client/src/components/SectionContent.tsx @@ -299,11 +299,11 @@ const FlexSmall = styled.div` `; interface SectionContentProps { - // relatedSections: Grades[]; section: Grades; instructor: RMPInstructor; courseRating: number | null; - // handleRelatedSectionClick: (search: string, id: number) => void; + relatedSections?: Grades[]; + handleRelatedSectionClick?: (search: string, id: number) => void; } const getDifficultyColor = (difficulty: number): string => { diff --git a/client/src/components/SectionList.tsx b/client/src/components/SectionList.tsx index 6080926..4ab2be2 100644 --- a/client/src/components/SectionList.tsx +++ b/client/src/components/SectionList.tsx @@ -1,7 +1,7 @@ -import { FrownTwoTone, UserOutlined } from "@ant-design/icons"; +import { FrownTwoTone, UserOutlined, LeftOutlined, RightOutlined, DoubleLeftOutlined, DoubleRightOutlined } from "@ant-design/icons"; import type { Grades } from "@utd-grades/db"; import { List, Popover as AntPopover, Spin } from "antd"; -import React, { ReactNode, useEffect, useState } from "react"; +import React, { ReactNode} from "react"; import styled, { css } from "styled-components"; // FIXME (median) // import { getLetterGrade, getLetterGradeColor } from "../utils"; @@ -89,6 +89,42 @@ const IconWrapper = styled.div` margin-right: 8; `; +const PaginationContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px 10px; + font-family: var(--font-family); +`; + +const PaginationButton = styled.button<{ active?: boolean; disabled?: boolean }>` + min-width: 28px; + height: 28px; + padding: 0 8px; + border: 1px solid ${props => props.active ? 'rgb(0, 116, 224)' : '#d9d9d9'}; + background: ${props => props.active ? 'rgb(0, 116, 224)' : props.disabled ? '#f5f5f5' : '#fff'}; + color: ${props => props.active ? '#fff' : props.disabled ? '#bfbfbf' : 'rgba(0, 0, 0, 0.85)'}; + border-radius: 2px; + cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'}; + font-family: var(--font-family); + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + ${props => !props.disabled && !props.active && css` + border-color: rgb(0, 116, 224); + color: rgb(0, 116, 224); + `} + } + + &:focus { + outline: none; + } +`; + // FIXME (median) // const AverageWrapper = styled.div<{ average: number }>` // color: ${(p) => getLetterGradeColor(getLetterGrade(p.average))}; @@ -113,14 +149,29 @@ interface SectionListProps { data: Grades[] | undefined; onClick: (id: number) => void; error: unknown; + page: number; + setPage: React.Dispatch>; } -export default function SectionList({ loading, id, data, onClick, error }: SectionListProps) { - const [page, setPage] = useState(1); +export function SectionList({ loading, id, data, onClick, error, page, setPage }: SectionListProps) { + const pageSize = 5; + const totalPages = data ? Math.ceil(data.length / pageSize) : 0; + + // Calculate which pages to show (max 3 pages) + const getPageNumbers = () => { + if (totalPages <= 3) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } - useEffect(() => { - setPage(1); - }, [data]); + // Always show current page and try to show 1 before and 1 after + if (page === 1) { + return [1, 2, 3]; + } else if (page === totalPages) { + return [totalPages - 2, totalPages - 1, totalPages]; + } else { + return [page - 1, page, page + 1]; + } + }; const popover = ( @@ -155,57 +206,105 @@ export default function SectionList({ loading, id, data, onClick, error }: Secti if (data.length < 1) { return emptyMessage; } else { + const pageNumbers = getPageNumbers(); + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + const currentPageData = data.slice(startIndex, endIndex); + return ( - - itemLayout="vertical" - size="large" - pagination={{ - pageSize: 8, - style: { - marginRight: "10px", - }, - showSizeChanger: false, - current: page, - onChange: (page) => setPage(page), - }} - dataSource={data} - renderItem={(item) => ( - } - child={item.totalStudents.toString()} - key="students-total" - />, - // FIXME (median) - // } - // child={ - // - // {getLetterGrade(item.average)} - // - // } - // key="average" - // />, - ]} - onClick={() => onClick(item.id)} - > - - {item.subject} {item.catalogNumber}.{item.section} - - } - // FIXME (no professor): non null assertion - description={`${item.instructor1!.last}, ${item.instructor1!.first} - ${ - item.semester.season - } ${item.semester.year}`} - /> - + <> + + itemLayout="vertical" + size="large" + dataSource={currentPageData} + renderItem={(item) => ( + } + child={item.totalStudents.toString()} + key="students-total" + />, + // FIXME (median) + // } + // child={ + // + // {getLetterGrade(item.average)} + // + // } + // key="average" + // />, + ]} + onClick={() => onClick(item.id)} + > + + {item.subject} {item.catalogNumber}.{item.section} + + } + // FIXME (no professor): non null assertion + description={`${item.instructor1!.last}, ${item.instructor1!.first} - ${ + item.semester.season + } ${item.semester.year}`} + /> + + )} + /> + {totalPages > 1 && ( + + setPage(1)} + aria-label="First page" + title="First page" + > + + + + setPage(page - 1)} + aria-label="Previous page" + title="Previous page" + > + + + + {pageNumbers.map((pageNum) => ( + setPage(pageNum)} + title={`Page ${pageNum}`} + > + {pageNum} + + ))} + + setPage(page + 1)} + aria-label="Next page" + title="Next page" + > + + + + setPage(totalPages)} + aria-label="Last page" + title="Last page" + > + + + )} - /> + ); } } else if (loading) { @@ -214,7 +313,7 @@ export default function SectionList({ loading, id, data, onClick, error }: Secti itemLayout="vertical" size="large" pagination={{ - pageSize: 8, + pageSize: 5, }} > @@ -227,4 +326,4 @@ export default function SectionList({ loading, id, data, onClick, error }: Secti } else { return emptyMessage; } -} +} \ No newline at end of file