diff --git a/src/background/handler/tabManagementHandler.ts b/src/background/handler/tabManagementHandler.ts index a2a2636d..8bdd081b 100644 --- a/src/background/handler/tabManagementHandler.ts +++ b/src/background/handler/tabManagementHandler.ts @@ -1,13 +1,15 @@ import { MessageHandler } from 'chrome-extension-toolkit'; import TabManagementMessages from 'src/shared/messages/TabManagementMessages'; +import openNewTab from '../util/openNewTab'; const tabManagementHandler: MessageHandler = { getTabId({ sendResponse, sender }) { sendResponse(sender.tab?.id ?? -1); }, - openNewTab({ data, sendResponse }) { + openNewTab({ data, sender, sendResponse }) { const { url } = data; - chrome.tabs.create({ url }).then(sendResponse); + const nextIndex = sender.tab?.index ? sender.tab.index + 1 : undefined; + openNewTab(url, nextIndex).then(sendResponse); }, removeTab({ data, sendResponse }) { const { tabId } = data; diff --git a/src/background/util/openNewTab.ts b/src/background/util/openNewTab.ts new file mode 100644 index 00000000..b5019873 --- /dev/null +++ b/src/background/util/openNewTab.ts @@ -0,0 +1,10 @@ +/** + * This is a helper function that opens a new tab in the current window, and focuses the window + * @param tabIndex - the index of the tab to open the new tab at (optional) + * @returns the tab that was opened + */ +export default async function openNewTab(url: string, tabIndex?: number): Promise { + const tab = await chrome.tabs.create({ url, index: tabIndex, active: true }); + await chrome.windows.update(tab.windowId, { focused: true }); + return tab; +} diff --git a/src/shared/types/Course.ts b/src/shared/types/Course.ts index dc716222..f11b832a 100644 --- a/src/shared/types/Course.ts +++ b/src/shared/types/Course.ts @@ -1,4 +1,5 @@ import { Serialized } from 'chrome-extension-toolkit'; +import { capitalize } from '../util/string'; import { CourseSchedule } from './CourseSchedule'; /** @@ -7,8 +8,8 @@ import { CourseSchedule } from './CourseSchedule'; */ export type Instructor = { fullName: string; - firstName?: string; - lastName?: string; + firstName: string; + lastName: string; middleInitial?: string; }; @@ -77,6 +78,8 @@ export class 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 @@ -85,35 +88,25 @@ export class Course { getInstructorString(options: InstructorFormatOptions): string { const { max = 3, format, prefix = '' } = options; if (!this.instructors.length) { - return `${prefix} Undecided`; + return `${prefix} TBA`; } const instructors = this.instructors.slice(0, max); - switch (format) { - case 'abbr': - return ( - prefix + - instructors - .map(instructor => { - let firstInitial = instructor.firstName?.[0]; - if (firstInitial) { - firstInitial += '. '; - } - return `${firstInitial}${instructor.lastName}`; - }) - .join(', ') - ); - case 'full_name': - return prefix + instructors.map(instructor => instructor.fullName).join(', '); - case 'first_last': - return ( - prefix + instructors.map(instructor => `${instructor.firstName} ${instructor.lastName}`).join(', ') - ); - case 'last': - return prefix + instructors.map(instructor => instructor.lastName).join(', '); - default: - throw new Error(`Invalid Instructor String format: ${format}`); + + 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}`); } } diff --git a/src/shared/util/string.ts b/src/shared/util/string.ts index 94939644..043c0903 100644 --- a/src/shared/util/string.ts +++ b/src/shared/util/string.ts @@ -3,11 +3,22 @@ * @input The string to capitalize. */ export function capitalize(input: string): string { - try { - return input.charAt(0).toUpperCase() + input.substring(1).toLowerCase(); - } catch (err) { - return input; + let capitalized = ''; + + const words = input.split(' '); + for (const word of words) { + if (word.includes('-')) { + const hyphenatedWords = word.split('-'); + for (const hyphenatedWord of hyphenatedWords) { + capitalized += `${capitalizeFirstLetter(hyphenatedWord)}-`; + } + capitalized = capitalized.substring(0, capitalized.length - 1); + } else { + capitalized += capitalizeFirstLetter(word); + } + capitalized += ' '; } + return capitalized; } /** @@ -16,7 +27,7 @@ export function capitalize(input: string): string { * @returns the string with the first letter capitalized */ export function capitalizeFirstLetter(input: string): string { - return input.charAt(0).toUpperCase() + input.slice(1); + return input.charAt(0).toUpperCase() + input.slice(1).toLowerCase(); } /** diff --git a/src/views/components/CourseCatalogMain.tsx b/src/views/components/CourseCatalogMain.tsx index e8f06d20..e7594ab9 100644 --- a/src/views/components/CourseCatalogMain.tsx +++ b/src/views/components/CourseCatalogMain.tsx @@ -54,10 +54,7 @@ export default function CourseCatalogMain({ support }: Props) { return ( - - Plus - - + Plus {rows.map(row => { if (!row.course) { // TODO: handle the course section headers diff --git a/src/views/components/common/Button/Button.module.scss b/src/views/components/common/Button/Button.module.scss index 3a7bbb92..e3e42dc4 100644 --- a/src/views/components/common/Button/Button.module.scss +++ b/src/views/components/common/Button/Button.module.scss @@ -4,12 +4,14 @@ background-color: #000; color: #fff; padding: 10px; - border-radius: 5px; + margin: 10px; + border-radius: 8px; border: none; + box-shadow: rgba(0, 0, 0, 0.4) 2px 2px 4px; cursor: pointer; font-size: 16px; font-weight: 600; - transition: all 0.3s ease; + transition: all 0.2s ease-in-out; font-family: 'Inter'; display: flex; @@ -21,6 +23,10 @@ color: #000; } + &:active { + animation: click_animation 0.2s ease-in-out; + } + &.disabled { cursor: not-allowed !important; opacity: 0.5 !important; @@ -30,10 +36,6 @@ } } - &:active { - animation: click_animation 0.5s ease-in-out; - } - @each $color, $value in ( diff --git a/src/views/components/common/Card/Card.module.scss b/src/views/components/common/Card/Card.module.scss index 0d3f9862..a742204f 100644 --- a/src/views/components/common/Card/Card.module.scss +++ b/src/views/components/common/Card/Card.module.scss @@ -2,7 +2,9 @@ .card { background: $white; - border: 1px solid #c3cee0; + border: 0.5px dotted #c3cee0; box-sizing: border-box; + + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); border-radius: 8px; } diff --git a/src/views/components/injected/AutoLoad/AutoLoad.tsx b/src/views/components/injected/AutoLoad/AutoLoad.tsx index 9f5be0d9..9a1bc136 100644 --- a/src/views/components/injected/AutoLoad/AutoLoad.tsx +++ b/src/views/components/injected/AutoLoad/AutoLoad.tsx @@ -42,7 +42,7 @@ export default function AutoLoad({ addRows }: Props) { } // scrape the courses from the page const ccs = new CourseCatalogScraper(SiteSupport.COURSE_CATALOG_LIST); - const scrapedRows = ccs.scrape(nextRows, true); + const scrapedRows = await ccs.scrape(nextRows, true); // add the scraped courses to the current page addRows(scrapedRows); diff --git a/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.module.scss b/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.module.scss new file mode 100644 index 00000000..8971ba6f --- /dev/null +++ b/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.module.scss @@ -0,0 +1,6 @@ +@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 new file mode 100644 index 00000000..b2390d4e --- /dev/null +++ b/src/views/components/injected/CourseInfoPopup/CourseInfoDescription/CourseInfoDescription.tsx @@ -0,0 +1,45 @@ +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.module.scss b/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.module.scss index 2ac181d0..088ed270 100644 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.module.scss +++ b/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.module.scss @@ -3,14 +3,21 @@ height: auto; color: white; padding: 12px; - margin: 50px 20px; + margin: 20px; align-items: center; + position: relative; justify-content: center; + .close { + position: absolute; + top: 12px; + right: 12px; + cursor: pointer; + } + .title { display: flex; align-items: center; - justify-content: center; .uniqueId { margin-left: 8px; @@ -20,4 +27,19 @@ .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/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.tsx b/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.tsx index dc332d8d..b12245a5 100644 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.tsx +++ b/src/views/components/injected/CourseInfoPopup/CourseInfoHeader/CourseInfoHeader.tsx @@ -1,27 +1,70 @@ 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 }: Props) { +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}) ))} + + + + + + + ); } diff --git a/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.module.scss b/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.module.scss index 03c61052..ffc06198 100644 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.module.scss +++ b/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.module.scss @@ -1,11 +1,7 @@ .popup { border-radius: 12px; position: relative; - - .close { - position: absolute; - top: 12px; - right: 12px; - cursor: pointer; - } + max-width: 50%; + overflow-y: auto; + max-height: 80%; } diff --git a/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.tsx b/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.tsx index 2540c4d2..4ccb5488 100644 --- a/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.tsx +++ b/src/views/components/injected/CourseInfoPopup/CourseInfoPopup.tsx @@ -1,10 +1,7 @@ import React from 'react'; import { Course } from 'src/shared/types/Course'; -import Card from '../../common/Card/Card'; -import Icon from '../../common/Icon/Icon'; -import Link from '../../common/Link/Link'; import Popup from '../../common/Popup/Popup'; -import Text from '../../common/Text/Text'; +import CourseInfoDescription from './CourseInfoDescription/CourseInfoDescription'; import CourseInfoHeader from './CourseInfoHeader/CourseInfoHeader'; import styles from './CourseInfoPopup.module.scss'; @@ -20,8 +17,8 @@ export default function CourseInfoPopup({ 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 dfc30169..5477054f 100644 --- a/src/views/components/injected/TableRow/TableRow.module.scss +++ b/src/views/components/injected/TableRow/TableRow.module.scss @@ -1,5 +1,9 @@ @import 'src/views/styles/base.module.scss'; +.rowButton { + margin: 2px; +} + .selectedRow { * { background: $burnt_orange !important; diff --git a/src/views/components/injected/TableRow/TableRow.tsx b/src/views/components/injected/TableRow/TableRow.tsx index 5f10ef98..bed1adb0 100644 --- a/src/views/components/injected/TableRow/TableRow.tsx +++ b/src/views/components/injected/TableRow/TableRow.tsx @@ -40,7 +40,7 @@ export default function TableRow({ row, isSelected, onClick }: Props): JSX.Eleme } return ReactDOM.createPortal( - , container