diff --git a/src/shared/types/Course.ts b/src/shared/types/Course.ts index f11b832a..2fdb42b1 100644 --- a/src/shared/types/Course.ts +++ b/src/shared/types/Course.ts @@ -1,17 +1,7 @@ import { Serialized } from 'chrome-extension-toolkit'; import { capitalize } from '../util/string'; import { CourseSchedule } from './CourseSchedule'; - -/** - * A professor's name, first name, and initial (if applicable) - * Also includes a link to their RateMyProfessor page - */ -export type Instructor = { - fullName: string; - firstName: string; - lastName: string; - middleInitial?: string; -}; +import Instructor from './Instructor'; /** * Whether the class is taught online, in person, or a hybrid of the two @@ -41,6 +31,7 @@ export type Semester = { /** * The internal representation of a course for the extension */ + export class Course { /** Every course has a uniqueId within UT's registrar system corresponding to each course section */ uniqueId: number; @@ -73,40 +64,10 @@ export class Course { /** Which semester is the course from */ semester: Semester; - constructor(course: Serialized) { + constructor(course: Serialized | Course) { Object.assign(this, course); this.schedule = new CourseSchedule(course.schedule); - } - - - - /** - * Get a string representation of the instructors for this course - * @param options - the options for how to format the instructor string - * @returns - */ - getInstructorString(options: InstructorFormatOptions): string { - const { max = 3, format, prefix = '' } = options; - if (!this.instructors.length) { - return `${prefix} TBA`; - } - - const instructors = this.instructors.slice(0, max); - - if (format === 'abbr') { - return prefix + instructors.map(i => `${capitalize(i.firstName[0])}. ${capitalize(i.lastName)}`).join(', '); - } - if (format === 'full_name') { - return prefix + instructors.map(i => capitalize(i.fullName)).join(', '); - } - if (format === 'first_last') { - return prefix + instructors.map(i => `${capitalize(i.firstName)} ${capitalize(i.lastName)}`).join(', '); - } - if (format === 'last') { - return prefix + instructors.map(i => i.lastName).join(', '); - } - - throw new Error(`Invalid Instructor String format: ${format}`); + this.instructors = course.instructors.map(i => new Instructor(i)); } } @@ -117,15 +78,3 @@ export type ScrapedRow = { element: HTMLTableRowElement; course: Course | null; }; - -/** - * Options for how to format the instructor string - */ -type InstructorFormatOptions = { - /** a prefix to add to the string, ex: "with" Mike Scott */ - prefix?: string; - /** The maximum number of instructors to show */ - max?: number; - /** How do you want the names of the professors formatted */ - format: 'abbr' | 'first_last' | 'last' | 'full_name'; -}; diff --git a/src/shared/types/CourseMeeting.ts b/src/shared/types/CourseMeeting.ts index 04872c81..2b20a4db 100644 --- a/src/shared/types/CourseMeeting.ts +++ b/src/shared/types/CourseMeeting.ts @@ -37,7 +37,7 @@ export class CourseMeeting { /** The location that the course is taught */ location?: Location; - constructor(meeting: Serialized) { + constructor(meeting: Serialized | CourseMeeting) { Object.assign(this, meeting); } diff --git a/src/shared/types/CourseSchedule.ts b/src/shared/types/CourseSchedule.ts index 670df761..14b15493 100644 --- a/src/shared/types/CourseSchedule.ts +++ b/src/shared/types/CourseSchedule.ts @@ -7,7 +7,7 @@ import { CourseMeeting, Day, DAY_MAP } from './CourseMeeting'; export class CourseSchedule { meetings: CourseMeeting[] = []; - constructor(courseSchedule?: Serialized) { + constructor(courseSchedule?: Serialized | CourseSchedule) { if (!courseSchedule) { return; } diff --git a/src/shared/types/Instructor.ts b/src/shared/types/Instructor.ts new file mode 100644 index 00000000..3c42a81a --- /dev/null +++ b/src/shared/types/Instructor.ts @@ -0,0 +1,83 @@ +import { Serialized } from 'chrome-extension-toolkit'; +import { capitalize } from '../util/string'; + +/** + * A type representing an instructor for a course (who teaches it) + */ +export default class Instructor { + fullName: string; + firstName: string; + lastName: string; + middleInitial?: string; + + constructor(instructor: Serialized) { + Object.assign(this, { + ...instructor, + }); + } + + /** + * Get the URL to the instructor's directory page on the UT Directory website + * @returns a URL string to the instructor's directory page + */ + getDirectoryUrl(): string { + const name = this.toString({ + format: 'full_name', + case: 'capitalize', + }); + + const url = new URL('https://directory.utexas.edu/index.php'); + url.searchParams.set('q', name); + url.searchParams.set('scope', 'faculty/staff'); + url.searchParams.set('submit', 'Search'); + + return url.toString(); + } + + /** + * Get a string representation of the instructor + * @param options the options for how to format the instructor string + * @returns a string representation of the instructor + */ + toString(options: InstructorFormatOptions): string { + const { firstName, lastName, fullName } = this; + const { format, case: caseType } = options; + + const process = (str: string) => { + if (caseType === 'lowercase') { + return str.toLowerCase(); + } + if (caseType === 'uppercase') { + return str.toUpperCase(); + } + return capitalize(str); + }; + + if (format === 'abbr') { + return `${process(firstName[0])}. ${process(lastName)}`; + } + if (format === 'full_name') { + return process(fullName); + } + if (format === 'first_last') { + return `${process(firstName)} ${process(lastName)}`; + } + if (format === 'last') { + return process(lastName); + } + + throw new Error(`Invalid Instructor String format: ${format}`); + } +} + +/** + * Options for how to format the instructor string + */ +type InstructorFormatOptions = { + /** How do you want the names of the professors formatted */ + format: 'abbr' | 'first_last' | 'last' | 'full_name'; + /** + * What the case of the string should be + */ + case: 'capitalize' | 'lowercase' | 'uppercase'; +}; diff --git a/src/views/components/CourseCatalogMain.tsx b/src/views/components/CourseCatalogMain.tsx index e7594ab9..32096fdb 100644 --- a/src/views/components/CourseCatalogMain.tsx +++ b/src/views/components/CourseCatalogMain.tsx @@ -9,7 +9,7 @@ import ExtensionRoot from './common/ExtensionRoot/ExtensionRoot'; import Icon from './common/Icon/Icon'; import Text from './common/Text/Text'; import AutoLoad from './injected/AutoLoad/AutoLoad'; -import CourseInfoPopup from './injected/CourseInfoPopup/CourseInfoPopup'; +import CoursePopup from './injected/CoursePopup/CoursePopup'; import TableHead from './injected/TableHead'; import TableRow from './injected/TableRow/TableRow'; @@ -69,7 +69,7 @@ export default function CourseCatalogMain({ support }: Props) { /> ); })} - {selectedCourse && } + {selectedCourse && } ); diff --git a/src/views/components/common/Button/Button.module.scss b/src/views/components/common/Button/Button.module.scss index e3e42dc4..90f0f379 100644 --- a/src/views/components/common/Button/Button.module.scss +++ b/src/views/components/common/Button/Button.module.scss @@ -9,8 +9,6 @@ border: none; box-shadow: rgba(0, 0, 0, 0.4) 2px 2px 4px; cursor: pointer; - font-size: 16px; - font-weight: 600; transition: all 0.2s ease-in-out; font-family: 'Inter'; diff --git a/src/views/components/common/Link/Link.tsx b/src/views/components/common/Link/Link.tsx index 8f592233..6a4a9770 100644 --- a/src/views/components/common/Link/Link.tsx +++ b/src/views/components/common/Link/Link.tsx @@ -21,6 +21,7 @@ export default function Link(props: PropsWithChildren) { if (url && !props.onClick) { passedProps.onClick = () => bMessenger.openNewTab({ url }); } + const isDisabled = props.disabled || (!url && !props.onClick); return ( ) { className={classNames( styles.link, { - [styles.disabled]: props.disabled, + [styles.disabled]: isDisabled, }, props.className )} diff --git a/src/views/components/common/Spinner/Spinner.tsx b/src/views/components/common/Spinner/Spinner.tsx index 758a64d7..fad30aa0 100644 --- a/src/views/components/common/Spinner/Spinner.tsx +++ b/src/views/components/common/Spinner/Spinner.tsx @@ -1,14 +1,16 @@ +import classNames from 'classnames'; import React from 'react'; -import { Color } from 'src/views/styles/colors.module.scss'; import styles from './Spinner.module.scss'; type Props = { - color?: Color; + testId?: string; + className?: string; + style?: React.CSSProperties; }; /** * A simple spinner component that can be used to indicate loading. */ -export default function Spinner({ color }: Props) { - return
; +export default function Spinner({ className, testId, style }: Props) { + return
; } diff --git a/src/views/components/common/Text/Text.module.scss b/src/views/components/common/Text/Text.module.scss index 3408a54f..9fc93b7c 100644 --- a/src/views/components/common/Text/Text.module.scss +++ b/src/views/components/common/Text/Text.module.scss @@ -1,3 +1,80 @@ +@import "src/views/styles/base.module.scss"; + .text { font-family: 'Inter', sans-serif; + color: $charcoal; } + + +.light_weight { + font-weight: $light_weight; +} + +.regular_weight { + font-weight: $regular_weight; +} + +.normal_weight { + font-weight: $normal_weight; +} + +.semi_bold_weight { + font-weight: $semi_bold_weight; +} + +.bold_weight { + font-weight: $bold_weight; +} + +.black_weight { + font-weight: $black_weight; +} + +.x_small_size { + font-size: $x_small_size; +} + +.small_size { + font-size: $small_size; +} + +.medium_size { + font-size: $medium_size; +} + +.large_size { + font-size: $large_size; +} + +.x_large_size { + font-size: $x_large_size; +} + +.xx_large_size { + font-size: $xx_large_size; +} + +.x_small_line_height { + line-height: $x_small_line_height; +} + +.small_line_height { + line-height: $small_line_height; +} + +.medium_line_height { + line-height: $medium_line_height; +} + +.large_line_height { + line-height: $large_line_height; +} + +.x_large_line_height { + line-height: $x_large_line_height; +} + +.xx_large_line_height { + line-height: $xx_large_line_height; +} + diff --git a/src/views/components/common/Text/Text.tsx b/src/views/components/common/Text/Text.tsx index ceb0b18d..93d1f5e2 100644 --- a/src/views/components/common/Text/Text.tsx +++ b/src/views/components/common/Text/Text.tsx @@ -19,24 +19,35 @@ export type TextProps = { * A reusable Text component with props that build on top of the design system for the extension */ export default function Text(props: PropsWithChildren) { - const style = props.style || {}; + const style: React.CSSProperties = { + ...props.style, + textAlign: props.align, + color: props.color ? colors[props.color] : undefined, + }; - style.textAlign ??= props.align; - style.color ??= colors?.[props.color ?? 'charcoal']; - style.fontSize ??= fonts?.[`${props.size ?? 'medium'}_size`]; - style.fontWeight ??= fonts?.[`${props.weight ?? 'regular'}_weight`]; - style.lineHeight ??= fonts?.[`${props.size ?? 'medium'}_line_height`]; + const weightClass = `${props.weight ?? 'regular'}_weight`; + const fontSizeClass = `${props.size ?? 'medium'}_size`; + const lineHightClass = `${props.size ?? 'medium'}_line_height`; + + const className = classNames( + styles.text, + props.className, + + styles[weightClass], + styles[fontSizeClass], + styles[lineHightClass] + ); if (props.span) { return ( - + {props.children} ); } return ( -
+
{props.children}
); diff --git a/src/views/components/injected/AutoLoad/AutoLoad.tsx b/src/views/components/injected/AutoLoad/AutoLoad.tsx index 9a1bc136..fd956cd1 100644 --- a/src/views/components/injected/AutoLoad/AutoLoad.tsx +++ b/src/views/components/injected/AutoLoad/AutoLoad.tsx @@ -4,7 +4,11 @@ import { ScrapedRow } from 'src/shared/types/Course'; import useInfiniteScroll from 'src/views/hooks/useInfiniteScroll'; import { CourseCatalogScraper } from 'src/views/lib/CourseCatalogScraper'; import { SiteSupport } from 'src/views/lib/getSiteSupport'; -import { loadNextCourseCatalogPage, AutoLoadStatus } from 'src/views/lib/loadNextCourseCatalogPage'; +import { + loadNextCourseCatalogPage, + AutoLoadStatus, + removePaginationButtons, +} from 'src/views/lib/loadNextCourseCatalogPage'; import Spinner from '../../common/Spinner/Spinner'; import styles from './AutoLoad.module.scss'; @@ -27,9 +31,10 @@ export default function AutoLoad({ addRows }: Props) { setContainer(portalContainer); }, []); - // FOR DEBUGGING useEffect(() => { + removePaginationButtons(document); console.log(`AutoLoad is now ${status}`); + // FOR DEBUGGING }, [status]); // This hook will call the callback when the user scrolls to the bottom of the page. diff --git a/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.module.scss b/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.module.scss deleted file mode 100644 index 8971ba6f..00000000 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.module.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import 'src/views/styles/base.module.scss'; - -.descriptionContainer { - margin: 12px; - padding: 12px; -} diff --git a/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.tsx b/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.tsx deleted file mode 100644 index b2390d4e..00000000 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useEffect } from 'react'; -import { Course } from 'src/shared/types/Course'; -import Text from 'src/views/components/common/Text/Text'; -import { CourseCatalogScraper } from 'src/views/lib/CourseCatalogScraper'; -import { SiteSupport } from 'src/views/lib/getSiteSupport'; -import Card from '../../../common/Card/Card'; -import styles from './CourseInfoDescription.module.scss'; - -type Props = { - course: Course; -}; - -export default function CourseInfoDescription({ course }: Props) { - const [description, setDescription] = React.useState([]); - - useEffect(() => { - fetchDescription(course).then(description => { - setDescription(description); - }); - }, [course]); - - if (!description.length) { - return null; - } - - return ( - - {description.map((paragraph, i) => ( - {paragraph} - ))} - - ); -} - -async function fetchDescription(course: Course): Promise { - if (!course.description?.length) { - const response = await fetch(course.url); - const text = await response.text(); - const doc = new DOMParser().parseFromString(text, 'text/html'); - - const scraper = new CourseCatalogScraper(SiteSupport.COURSE_CATALOG_DETAILS); - course.description = scraper.getDescription(doc); - } - return course.description; -} diff --git a/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.tsx b/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.tsx deleted file mode 100644 index b12245a5..00000000 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React from 'react'; -import { bMessenger } from 'src/shared/messages'; -import { Course } from 'src/shared/types/Course'; -import { Button } from 'src/views/components/common/Button/Button'; -import Card from 'src/views/components/common/Card/Card'; -import Icon from 'src/views/components/common/Icon/Icon'; -import Link from 'src/views/components/common/Link/Link'; -import Text from 'src/views/components/common/Text/Text'; -import styles from './CourseInfoHeader.module.scss'; - -type Props = { - course: Course; - onClose: () => void; -}; - -/** - * This component displays the header of the course info popup. - * It displays the course name, unique id, instructors, and schedule, all formatted nicely. - */ -export default function CourseInfoHeader({ course, onClose }: Props) { - const getBuildingUrl = (building?: string): string | undefined => { - if (!building) return undefined; - return `https://utdirect.utexas.edu/apps/campus/buildings/nlogon/maps/UTM/${building}/`; - }; - - const openRateMyProfessorURL = () => { - const name = course.getInstructorString({ - format: 'first_last', - max: 1, - }); - - const url = new URL('https://www.ratemyprofessors.com/search.jsp'); - url.searchParams.append('queryBy', 'teacherName'); - url.searchParams.append('schoolName', 'university of texas at austin'); - url.searchParams.append('queryoption', 'HEADER'); - url.searchParams.append('query', name); - url.searchParams.append('facetSearch', 'true'); - - bMessenger.openNewTab({ url: url.toString() }); - }; - - const openECISURL = () => { - // TODO: Figure out how to get the ECIS URL, the old one doesn't work anymore - // http://utdirect.utexas.edu/ctl/ecis/results/index.WBX?&s_in_action_sw=S&s_in_search_type_sw=N&s_in_search_name=${prof_name}%2C%20${first_name} - // const name = course.getInstructorString({ - }; - - const openSyllabiURL = () => { - // https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/?year=&semester=&department=${department}&course_number=${number}&course_title=&unique=&instructor_first=&instructor_last=${prof_name}&course_type=In+Residence&search=Search - const { department, number } = course; - - const { firstName, lastName } = course.instructors?.[0] ?? {}; - - const url = new URL('https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/'); - url.searchParams.append('department', department); - url.searchParams.append('course_number', number); - url.searchParams.append('instructor_first', firstName ?? ''); - url.searchParams.append('instructor_last', lastName ?? ''); - url.searchParams.append('course_type', 'In Residence'); - url.searchParams.append('search', 'Search'); - - bMessenger.openNewTab({ url: url.toString() }); - }; - - return ( - - - - {course.courseName} ({course.department} {course.number}) - - #{course.uniqueId} - - - - {course.getInstructorString({ - prefix: 'with ', - format: 'first_last', - })} - - - {course.schedule.meetings.map(meeting => ( - - - {meeting.getDaysString({ - format: 'long', - separator: 'short', - })} - - {' at '} - - {meeting.getTimeString({ - separator: 'to', - capitalize: true, - })} - - {' in '} - - {meeting.location?.building ?? 'TBA'} - - - ))} - - - - - - - - - ); -} diff --git a/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.module.scss b/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.module.scss deleted file mode 100644 index ffc06198..00000000 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -.popup { - border-radius: 12px; - position: relative; - max-width: 50%; - overflow-y: auto; - max-height: 80%; -} diff --git a/src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.module.scss b/src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.module.scss new file mode 100644 index 00000000..1e1ab5da --- /dev/null +++ b/src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.module.scss @@ -0,0 +1,27 @@ +@import 'src/views/styles/base.module.scss'; + +.container { + margin: 20px; + padding: 12px; + + .description { + list-style-type: disc; + margin: 0px; + padding-left: 20px; + + li { + padding: 0px 4px 4px; + .prerequisite { + font-weight: bold; + } + + .onlyOne { + font-style: italic; + } + + .restriction { + color: $speedway_brick; + } + } + } +} diff --git a/src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.tsx b/src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.tsx new file mode 100644 index 00000000..d1e1da46 --- /dev/null +++ b/src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.tsx @@ -0,0 +1,90 @@ +import classNames from 'classnames'; +import React, { useEffect, useState } from 'react'; +import { Course } from 'src/shared/types/Course'; +import Spinner from 'src/views/components/common/Spinner/Spinner'; +import Text from 'src/views/components/common/Text/Text'; +import { CourseCatalogScraper } from 'src/views/lib/CourseCatalogScraper'; +import { SiteSupport } from 'src/views/lib/getSiteSupport'; +import Card from '../../../common/Card/Card'; +import styles from './CourseDescription.module.scss'; + +type Props = { + course: Course; +}; + +enum LoadStatus { + LOADING = 'LOADING', + DONE = 'DONE', + ERROR = 'ERROR', +} + +export default function CourseDescription({ course }: Props) { + const [description, setDescription] = useState([]); + const [status, setStatus] = useState(LoadStatus.LOADING); + + useEffect(() => { + fetchDescription(course) + .then(description => { + setStatus(LoadStatus.DONE); + setDescription(description); + }) + .catch(() => { + setStatus(LoadStatus.ERROR); + }); + }, [course]); + + return ( + + {status === LoadStatus.ERROR && ( + + Please refresh the page and log back in using your UT EID and password + + )} + {status === LoadStatus.LOADING && } + {status === LoadStatus.DONE && ( +
    + {description.map(paragraph => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +interface LineProps { + line: string; +} + +function DescriptionLine({ line }: LineProps) { + const lowerCaseLine = line.toLowerCase(); + + const className = classNames({ + [styles.prerequisite]: lowerCaseLine.includes('prerequisite'), + [styles.onlyOne]: + lowerCaseLine.includes('may be') || + lowerCaseLine.includes('only one') || + lowerCaseLine.includes('may not be'), + [styles.restriction]: lowerCaseLine.includes('restrict'), + }); + + return ( + + {line} + + ); +} + +async function fetchDescription(course: Course): Promise { + if (!course.description?.length) { + const response = await fetch(course.url); + const text = await response.text(); + const doc = new DOMParser().parseFromString(text, 'text/html'); + + const scraper = new CourseCatalogScraper(SiteSupport.COURSE_CATALOG_DETAILS); + course.description = scraper.getDescription(doc); + } + return course.description; +} diff --git a/src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.module.scss b/src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.module.scss new file mode 100644 index 00000000..d1ff0378 --- /dev/null +++ b/src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.module.scss @@ -0,0 +1,17 @@ +@import 'src/views/styles/base.module.scss'; + +.container { + margin: 12px 4px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: none; + + .button { + flex: 1; + } + + .icon { + margin: 4px; + } +} diff --git a/src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.tsx b/src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.tsx new file mode 100644 index 00000000..de54ac80 --- /dev/null +++ b/src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { bMessenger } from 'src/shared/messages'; +import { Course } from 'src/shared/types/Course'; +import { Button } from 'src/views/components/common/Button/Button'; +import Card from 'src/views/components/common/Card/Card'; +import Icon from 'src/views/components/common/Icon/Icon'; +import Text from 'src/views/components/common/Text/Text'; +import styles from './CourseButtons.module.scss'; + +type Props = { + course: Course; +}; + +const { openNewTab } = bMessenger; + +/** + * This component displays the buttons for the course info popup, that allow the user to either + * navigate to other pages that are useful for the course, or to do actions on the current course. + */ +export default function CourseButtons({ course }: Props) { + const openRateMyProfessorURL = () => { + const primaryInstructor = course.instructors?.[0]; + if (!primaryInstructor) return; + + const name = primaryInstructor.toString({ + format: 'first_last', + case: 'capitalize', + }); + + const url = new URL('https://www.ratemyprofessors.com/search.jsp'); + url.searchParams.append('queryBy', 'teacherName'); + url.searchParams.append('schoolName', 'university of texas at austin'); + url.searchParams.append('queryoption', 'HEADER'); + url.searchParams.append('query', name); + url.searchParams.append('facetSearch', 'true'); + + openNewTab({ url: url.toString() }); + }; + + const openSyllabiURL = () => { + const { department, number } = course; + + const { firstName, lastName } = course.instructors?.[0] ?? {}; + + const url = new URL('https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/'); + url.searchParams.append('department', department); + url.searchParams.append('course_number', number); + url.searchParams.append('instructor_first', firstName ?? ''); + url.searchParams.append('instructor_last', lastName ?? ''); + url.searchParams.append('course_type', 'In Residence'); + url.searchParams.append('search', 'Search'); + + openNewTab({ url: url.toString() }); + }; + + return ( + + + + + + + ); +} diff --git a/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.module.scss b/src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.module.scss similarity index 67% rename from src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.module.scss rename to src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.module.scss index 088ed270..ffe975bf 100644 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.module.scss +++ b/src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.module.scss @@ -27,19 +27,4 @@ .instructors { margin-top: 8px; } - - .buttonContainer { - margin: 12px 4px; - display: flex; - align-items: center; - justify-content: center; - - .button { - flex: 1; - } - - .icon { - margin: 4px; - } - } } diff --git a/src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.tsx b/src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.tsx new file mode 100644 index 00000000..be51e270 --- /dev/null +++ b/src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Course } from 'src/shared/types/Course'; +import Card from 'src/views/components/common/Card/Card'; +import Icon from 'src/views/components/common/Icon/Icon'; +import Link from 'src/views/components/common/Link/Link'; +import Text from 'src/views/components/common/Text/Text'; +import CourseButtons from './CourseButtons/CourseButtons'; +import styles from './CourseHeader.module.scss'; + +type Props = { + course: Course; + onClose: () => void; +}; + +/** + * This component displays the header of the course info popup. + * It displays the course name, unique id, instructors, and schedule, all formatted nicely. + */ +export default function CourseHeader({ course, onClose }: Props) { + const getBuildingUrl = (building?: string): string | undefined => { + if (!building) return undefined; + return `https://utdirect.utexas.edu/apps/campus/buildings/nlogon/maps/UTM/${building}/`; + }; + + return ( + + + + {course.courseName} ({course.department} {course.number}) + + #{course.uniqueId} + + + + {`with ${!course.instructors.length ? 'TBA' : ''}`} + {course.instructors.map((instructor, index) => { + const name = instructor.toString({ + format: 'first_last', + case: 'capitalize', + }); + + const url = instructor.getDirectoryUrl(); + const numInstructors = course.instructors.length; + const isLast = course.instructors.length > 1 && index === course.instructors.length - 1; + return ( + <> + {numInstructors > 1 && index === course.instructors.length - 1 ? '& ' : ''} + + {name} + + {numInstructors > 2 && !isLast ? ', ' : ''} + + ); + })} + + + {course.schedule.meetings.map(meeting => ( + + + {meeting.getDaysString({ + format: 'long', + separator: 'short', + })} + + {' at '} + + {meeting.getTimeString({ + separator: 'to', + capitalize: true, + })} + + {' in '} + + {meeting.location?.building ?? 'TBA'} + + + ))} + + + + ); +} diff --git a/src/views/components/injected/CoursePopup/CoursePopup.module.scss b/src/views/components/injected/CoursePopup/CoursePopup.module.scss new file mode 100644 index 00000000..5ab30ceb --- /dev/null +++ b/src/views/components/injected/CoursePopup/CoursePopup.module.scss @@ -0,0 +1,21 @@ +.popup { + border-radius: 12px; + position: relative; + max-width: 50%; + overflow-y: auto; + max-height: 80%; + + // fade in animation + animation: fadeIn 0.2s ease-out; +} + +// fade in animation + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.tsx b/src/views/components/injected/CoursePopup/CoursePopup.tsx similarity index 50% rename from src/views/components/injected/CourseInfoPopup/CourseInfoPopup.tsx rename to src/views/components/injected/CoursePopup/CoursePopup.tsx index 4ccb5488..73d10bf4 100644 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.tsx +++ b/src/views/components/injected/CoursePopup/CoursePopup.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { Course } from 'src/shared/types/Course'; import Popup from '../../common/Popup/Popup'; -import CourseInfoDescription from './CourseInfoDescription/CourseInfoDescription'; -import CourseInfoHeader from './CourseInfoHeader/CourseInfoHeader'; -import styles from './CourseInfoPopup.module.scss'; +import CourseDescription from './CourseDescription/CourseDescription'; +import CourseHeader from './CourseHeader/CourseHeader'; +import styles from './CoursePopup.module.scss'; interface Props { course: Course; @@ -13,12 +13,12 @@ interface Props { /** * The popup that appears when the user clicks on a course for more details. */ -export default function CourseInfoPopup({ course, onClose }: Props) { +export default function CoursePopup({ course, onClose }: Props) { console.log(course); return ( - - + + ); } diff --git a/src/views/components/injected/TableRow/TableRow.module.scss b/src/views/components/injected/TableRow/TableRow.module.scss index 5477054f..0d744dd7 100644 --- a/src/views/components/injected/TableRow/TableRow.module.scss +++ b/src/views/components/injected/TableRow/TableRow.module.scss @@ -1,12 +1,13 @@ @import 'src/views/styles/base.module.scss'; .rowButton { - margin: 2px; + margin: 0px; } .selectedRow { * { background: $burnt_orange !important; color: white !important; + box-shadow: none !important; } } diff --git a/src/views/lib/CourseCatalogScraper.ts b/src/views/lib/CourseCatalogScraper.ts index d4335a47..2fc0f69c 100644 --- a/src/views/lib/CourseCatalogScraper.ts +++ b/src/views/lib/CourseCatalogScraper.ts @@ -1,5 +1,7 @@ -import { Course, Instructor, Status, InstructionMode, ScrapedRow } from 'src/shared/types/Course'; +import { Serialized } from 'chrome-extension-toolkit'; +import { Course, Status, InstructionMode, ScrapedRow } from 'src/shared/types/Course'; import { CourseSchedule } from 'src/shared/types/CourseSchedule'; +import Instructor from 'src/shared/types/Instructor'; import { SiteSupport } from 'src/views/lib/getSiteSupport'; /** @@ -92,7 +94,7 @@ export class CourseCatalogScraper { flags: this.getFlags(row), uniqueId: this.getUniqueId(row), instructionMode: this.getInstructionMode(row), - instructors: this.getInstructors(row), + instructors: this.getInstructors(row) as Instructor[], description: this.getDescription(document), // TODO: get semester from somewhere semester: { @@ -163,12 +165,12 @@ export class CourseCatalogScraper { const [lastName, rest] = fullName.split(',').map(s => s.trim()); const [firstName, middleInitial] = rest.split(' '); - return { + return new Instructor({ fullName, firstName, lastName, middleInitial, - }; + }); }); } diff --git a/src/views/styles/fonts.module.scss b/src/views/styles/fonts.module.scss index d92cb806..9ccd5fb2 100644 --- a/src/views/styles/fonts.module.scss +++ b/src/views/styles/fonts.module.scss @@ -58,5 +58,4 @@ $xx_large_line_height: 52px; large_line_height: $large_line_height; x_large_line_height: $x_large_line_height; xx_large_line_height: $xx_large_line_height; - }