diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 00000000..9c185f84 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,215 @@ +{ + "root": true, + "env": { + "browser": true, + "es6": true, + "node": true, + "webextensions": true + }, + "ignorePatterns": [ + "*.html", + "tsconfig.json" + ], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:storybook/recommended", + "airbnb-base", + "airbnb/rules/react", + "airbnb-typescript", + "@unocss", + "prettier", + ], + "plugins": [ + "import", + "jsdoc", + "react-prefer-function-component" + ], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly", + "debugger": true, + "browser": true, + "context": true, + "JSX": true + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.json", + "ecmaVersion": 2022, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true, + "modules": true, + "experimentalObjectRestSpread": true + } + }, + "settings": { + "react": { + "version": "detect" + }, + "jsdoc": { + "mode": "typescript" + }, + "import/parsers": { + "@typescript-eslint/parser": [ + ".ts", + ".tsx" + ] + }, + "import/resolver": { + "typescript": { + "alwaysTryTypes": true, + "project": "./tsconfig.json" + } + } + }, + "rules": { + "prefer-const": [ + "off", + { + "destructuring": "any", + "ignoreReadBeforeAssign": false + } + ], + "no-inner-declarations": "off", + "sort-imports": "off", + "no-case-declarations": "off", + "no-unreachable": "warn", + "no-constant-condition": "error", + "space-before-function-paren": "off", + "no-undef": "off", + "no-return-await": "off", + "@typescript-eslint/return-await": "off", + "@typescript-eslint/no-shadow": [ + "off" + ], + "@typescript-eslint/no-use-before-define": [ + "off" + ], + "class-methods-use-this": "off", + "react-hooks/exhaustive-deps": "warn", + "@typescript-eslint/lines-between-class-members": "off", + "no-param-reassign": [ + "error", + { + "props": false + } + ], + "no-console": "off", + "consistent-return": "off", + "react/destructuring-assignment": "off", + "import/prefer-default-export": "off", + "no-promise-executor-return": "off", + "import/no-cycle": "off", + "import/no-extraneous-dependencies": "off", + "react/jsx-props-no-spreading": "off", + "keyword-spacing": [ + "error", + { + "before": true, + "after": true + } + ], + "no-continue": "off", + "space-before-blocks": [ + "error", + { + "functions": "always", + "keywords": "always", + "classes": "always" + } + ], + "react/jsx-filename-extension": [ + 1, + { + "extensions": [ + ".tsx" + ] + } + ], + "react/no-deprecated": "warn", + "react/prop-types": "off", + "react-prefer-function-component/react-prefer-function-component": [ + "warn", + { + "allowComponentDidCatch": false + } + ], + "react/function-component-definition": "off", + "react/button-has-type": "off", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns-type": "off", + "jsdoc/newline-after-description": "off", + "react/require-default-props": "off", + "jsdoc/require-jsdoc": [ + "warn", + { + "enableFixer": false, + "publicOnly": true, + "checkConstructors": false, + "require": { + "ArrowFunctionExpression": true, + "ClassDeclaration": true, + "ClassExpression": true, + "FunctionExpression": true + }, + "contexts": [ + "MethodDefinition:not([key.name=\"componentDidMount\"]):not([key.name=\"render\"])", + "ArrowFunctionExpression", + "ClassDeclaration", + "ClassExpression", + "ClassProperty:not([key.name=\"state\"]):not([key.name=\"componentDidMount\"])", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEnumDeclaration", + "TSInterfaceDeclaration", + "TSMethodSignature", + "TSModuleDeclaration", + "TSTypeAliasDeclaration" + ] + } + ], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/naming-convention": "off", + "@typescript-eslint/space-before-function-paren": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-empty-interface": "warn", + "import/no-restricted-paths": [ + "error", + { + "zones": [ + { + "target": "./src/background", + "from": "./src/views", + "message": "You cannot import into the `background` directory from the `views` directory (i.e. content script files) because it will break the build!" + }, + { + "target": "./src/views", + "from": "./src/background", + "message": "You cannot import into the `views` directory from the `background` directory (i.e. background script files) because it will break the build!" + }, + { + "target": "./src/shared", + "from": "./", + "except": [ + "./src/shared", + "./node_modules" + ], + "message": "You cannot import into `shared` from an external directory." + } + ] + } + ], + "import/extensions": "off", + "no-restricted-syntax": [ + "error", + "ForInStatement", + "LabeledStatement", + "WithStatement" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a96fccc..5e54add3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4942,12 +4942,8 @@ packages: resolution: {integrity: sha512-2wMrkCj3SSb5hrx9TKs5jZa34QIRkHv9FotbJutAPo7o8hx+XXn56ogzdoUrcFPJZJUx2R2nyOVbSlGMIjtFtw==} dev: true - /@unocss/reset@0.58.5: - resolution: {integrity: sha512-2wMrkCj3SSb5hrx9TKs5jZa34QIRkHv9FotbJutAPo7o8hx+XXn56ogzdoUrcFPJZJUx2R2nyOVbSlGMIjtFtw==} - dev: false - - /@unocss/rule-utils@0.58.4: - resolution: {integrity: sha512-52Jp4I+joGTaDm7ehB/7uZ2kJL+9BZcYRDUVk4IDacDH5W9yxf1F75LzYT8jJVWXD/HIhiS0r9V6qhcBq2OWZw==} + /@unocss/rule-utils@0.58.5: + resolution: {integrity: sha512-w0sGJoeUGwMWLVFLEE9PDiv/fQcQqZnTIIQLYNCjTdqXDRlwTp9ACW0h47x/hAAIXdOtEOOBuTfjGD79GznUmA==} engines: {node: '>=14'} dependencies: '@unocss/core': 0.58.5 diff --git a/public/fonts/material-icons.woff2 b/public/fonts/material-icons.woff2 deleted file mode 100644 index e968e5b2..00000000 Binary files a/public/fonts/material-icons.woff2 and /dev/null differ diff --git a/public/fonts/roboto-100.woff2 b/public/fonts/roboto-100.woff2 deleted file mode 100644 index 85c029e1..00000000 Binary files a/public/fonts/roboto-100.woff2 and /dev/null differ diff --git a/public/fonts/roboto-300.woff2 b/public/fonts/roboto-300.woff2 deleted file mode 100644 index 3e13c5f3..00000000 Binary files a/public/fonts/roboto-300.woff2 and /dev/null differ diff --git a/public/fonts/roboto-400.woff2 b/public/fonts/roboto-400.woff2 deleted file mode 100644 index 0aa90fc1..00000000 Binary files a/public/fonts/roboto-400.woff2 and /dev/null differ diff --git a/public/fonts/roboto-500.woff2 b/public/fonts/roboto-500.woff2 deleted file mode 100644 index 8b1aebb2..00000000 Binary files a/public/fonts/roboto-500.woff2 and /dev/null differ diff --git a/public/fonts/roboto-700.woff2 b/public/fonts/roboto-700.woff2 deleted file mode 100644 index b102004e..00000000 Binary files a/public/fonts/roboto-700.woff2 and /dev/null differ diff --git a/public/fonts/roboto-900.woff2 b/public/fonts/roboto-900.woff2 deleted file mode 100644 index beeec681..00000000 Binary files a/public/fonts/roboto-900.woff2 and /dev/null differ diff --git a/src/stories/components/CalendarCourseCell.stories.tsx b/src/stories/components/CalendarCourseCell.stories.tsx new file mode 100644 index 00000000..d02fd60d --- /dev/null +++ b/src/stories/components/CalendarCourseCell.stories.tsx @@ -0,0 +1,67 @@ +import { Course, Status } from '@shared/types/Course'; +import { CourseMeeting, DAY_MAP } from '@shared/types/CourseMeeting'; +import { CourseSchedule } from '@shared/types/CourseSchedule'; +import Instructor from '@shared/types/Instructor'; +import type { Meta, StoryObj } from '@storybook/react'; +import CalendarCourseCell from '@views/components/common/CalendarCourseCell/CalendarCourseCell'; +import React from 'react'; + +const meta = { + title: 'Components/Common/CalendarCourseCell', + component: CalendarCourseCell, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + course: { control: 'object' }, + meetingIdx: { control: 'number' }, + color: { control: 'color' }, + }, + render: (args: any) => ( +
+ +
+ ), +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + course: new Course({ + uniqueId: 123, + number: '311C', + fullName: "311C - Bevo's Default Course", + courseName: "Bevo's Default Course", + department: 'BVO', + creditHours: 3, + status: Status.WAITLISTED, + instructors: [new Instructor({ firstName: '', lastName: 'Bevo', fullName: 'Bevo' })], + isReserved: false, + url: '', + flags: [], + schedule: new CourseSchedule({ + meetings: [ + new CourseMeeting({ + days: [DAY_MAP.M, DAY_MAP.W, DAY_MAP.F], + startTime: 480, + endTime: 570, + location: { + building: 'UTC', + room: '123', + }, + }), + ], + }), + instructionMode: 'In Person', + semester: { + year: 2024, + season: 'Spring', + }, + }), + meetingIdx: 0, + color: 'red', + }, +}; diff --git a/src/stories/components/CalendarGrid.stories.tsx b/src/stories/components/CalendarGrid.stories.tsx new file mode 100644 index 00000000..24408ca0 --- /dev/null +++ b/src/stories/components/CalendarGrid.stories.tsx @@ -0,0 +1,18 @@ +// Calendar.stories.tsx +import type { Meta, StoryObj } from '@storybook/react'; +import Calendar from '@views/components/common/CalendarGrid/CalendarGrid'; + +const meta = { + title: 'Components/Common/Calendar', + component: Calendar, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: 'centered', + tags: ['autodocs'], + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/views/components/common/Button/Button.module.scss b/src/views/components/common/Button/Button.module.scss deleted file mode 100644 index 90b3ba40..00000000 --- a/src/views/components/common/Button/Button.module.scss +++ /dev/null @@ -1,55 +0,0 @@ -@use 'sass:color'; -@use 'src/views/styles/colors.module.scss'; - -.button { - padding: 0px 16px; - height: 40px; - margin: 10px; - border-radius: 4px; - border: none; - cursor: pointer; - transition: all 0.1s ease-in-out; - font-family: 'Roboto'; - font-size: 18px; - font-style: normal; - font-weight: 500; - line-height: normal; - - display: flex; - align-items: center; - justify-content: center; - - &:hover { - background-color: #fff; - color: #000; - } - - &:active { - transform: scale(0.96); - } - - &.disabled { - cursor: not-allowed !important; - opacity: 0.5 !important; - - &:active { - transform: unset; - } - } - - &.filled { - background-color: var(--color-primary); - color: var(--color-secondary); - } - - &.outline { - background-color: var(--color-secondary); - color: var(--color-primary); - border: 1px solid var(--color-primary); - } - - &.single { - background-color: var(--color-secondary); - color: var(--color-primary); - } -} diff --git a/src/views/components/common/CalendarCourseCell/CalendarCourseCell.tsx b/src/views/components/common/CalendarCourseCell/CalendarCourseCell.tsx new file mode 100644 index 00000000..2b425435 --- /dev/null +++ b/src/views/components/common/CalendarCourseCell/CalendarCourseCell.tsx @@ -0,0 +1,53 @@ +import type { Course } from '@shared/types/Course'; +import { Status } from '@shared/types/Course'; +import type { CourseMeeting } from '@shared/types/CourseMeeting'; +import React from 'react'; + +import ClosedIcon from '~icons/material-symbols/lock'; +import WaitlistIcon from '~icons/material-symbols/timelapse'; +import CancelledIcon from '~icons/material-symbols/warning'; + +import Text from '../Text/Text'; + +export interface CalendarCourseBlockProps { + /** The Course that the meeting is for. */ + course: Course; + /* index into course meeting array to display */ + meetingIdx?: number; + /** The background color for the course. */ + color: string; +} + +const CalendarCourseBlock: React.FC = ({ course, meetingIdx }: CalendarCourseBlockProps) => { + let meeting: CourseMeeting | null = meetingIdx !== undefined ? course.schedule.meetings[meetingIdx] : null; + let rightIcon: React.ReactNode | null = null; + if (course.status === Status.WAITLISTED) { + rightIcon = ; + } else if (course.status === Status.CLOSED) { + rightIcon = ; + } else if (course.status === Status.CANCELLED) { + rightIcon = ; + } + + return ( +
+
+ + {course.department} {course.number} - {course.instructors[0].lastName} + + + {`${meeting.getTimeString({ separator: '–', capitalize: true })}${ + meeting.location ? ` – ${meeting.location.building}` : '' + }`} + +
+ {rightIcon && ( +
+ {rightIcon} +
+ )} +
+ ); +}; + +export default CalendarCourseBlock; diff --git a/src/views/components/common/CalendarGrid/CalendarGrid.module.scss b/src/views/components/common/CalendarGrid/CalendarGrid.module.scss new file mode 100644 index 00000000..a2e208a4 --- /dev/null +++ b/src/views/components/common/CalendarGrid/CalendarGrid.module.scss @@ -0,0 +1,94 @@ +@use 'sass:color'; +@use 'src/views/styles/colors.module.scss'; + +.dayLabelContainer { + display: flex; + flex-direction: row; + height: 13px; + min-width: 40px; + min-height: 13px; + padding-bottom: 15px; + justify-content: center; + align-items: center; + gap: 10px; + flex: 1 0 0; +} + +.calendarGrid { + display: grid; + grid-template-columns: repeat(5, 1fr); + grid-template-rows: repeat(13, 1fr); +} + +.calendarRow { + display: flex; +} + +.calendar { + // display: grid; + // grid-template-columns: auto repeat(5, 1fr); + gap: 10px; +} + +.day { + gap: 5px; + color: colors.$burnt_orange; + text-align: center; + font-size: 14.22px; + font-style: normal; + font-weight: 500; + line-height: normal; +} + +.timeAndGrid { + display: flex; +} + +.timeColumn { + display: flex; + min-height: 573px; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + flex: 1 0 0; + border-radius: var(--border-radius-none, 0px); +} + +.timeBlock { + display: flex; + width: 50px; + height: 40.279px; + min-width: 50px; + max-width: 50px; + min-height: 40px; + flex-direction: column; + align-items: flex-end; + + .timeLabelContainer { + display: flex; + max-height: 20px; + flex-direction: column; + align-items: flex-end; + gap: 17px; + flex: 1 0 0; + align-self: stretch; + border-radius: var(--border-radius-none, 0px); + } + + p { + color: #1A2024; + text-align: left; + height: 6.6px; + align-self: stretch; + margin-top: 0; + margin-bottom: 0; + + /* Type scale/small */ + font-family: "Roboto Flex"; + font-size: 14.22px; + font-style: normal; + font-weight: 500; + line-height: normal; + } +} + diff --git a/src/views/components/common/CalendarGrid/CalendarGrid.tsx b/src/views/components/common/CalendarGrid/CalendarGrid.tsx new file mode 100644 index 00000000..0bc7e546 --- /dev/null +++ b/src/views/components/common/CalendarGrid/CalendarGrid.tsx @@ -0,0 +1,47 @@ +import { DAY_MAP } from '@shared/types/CourseMeeting'; +import React from 'react'; + +import CalendarCell from '../CalendarGridCell/CalendarGridCell'; +import styles from './CalendarGrid.module.scss'; + +const daysOfWeek = Object.values(DAY_MAP).filter(d => d !== 'Saturday' && d !== 'Sunday'); +const hoursOfDay = Array.from({ length: 14 }, (_, index) => index + 8); +const grid = Array.from({ length: 5 }, () => + Array.from({ length: 13 }, (_, columnIndex) => ) +); + +/** + * Grid of CalendarGridCell components forming the user's course schedule calendar view + * @param props + */ +const Calendar: React.FC = props => ( +
+
+ {/* Empty cell in the top-left corner */} +
+ {/* Displaying day labels */} + {daysOfWeek.map(day => ( +
+ {day} +
+ ))} +
+ {/* Displaying the rest of the calendar */} +
+
+ {hoursOfDay.map(hour => ( +
+
+

+ {hour % 12 === 0 ? 12 : hour % 12}:00 {hour < 12 ? 'AM' : 'PM'} +

+
+
+ ))} +
+
{grid.map((row, rowIndex) => row)}
+
+
+); + +export default Calendar; diff --git a/src/views/components/common/CalendarGridCell/CalendarGridCell.module.scss b/src/views/components/common/CalendarGridCell/CalendarGridCell.module.scss new file mode 100644 index 00000000..47479d4d --- /dev/null +++ b/src/views/components/common/CalendarGridCell/CalendarGridCell.module.scss @@ -0,0 +1,18 @@ +.calendarCell { + display: flex; + width: 165px; + height: 52.231px; + min-width: 45px; + min-height: 40px; + flex-direction: column; + justify-content: center; + align-items: flex-start; + border: 1px solid #dadce0; +} + +.hourLine { + width: 165px; + height: 1px; + border-radius: var(--border-radius-none, 0px); + background: rgba(218, 220, 224, 0.25); +} diff --git a/src/views/components/common/CalendarGridCell/CalendarGridCell.tsx b/src/views/components/common/CalendarGridCell/CalendarGridCell.tsx new file mode 100644 index 00000000..347cd734 --- /dev/null +++ b/src/views/components/common/CalendarGridCell/CalendarGridCell.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import styles from './CalendarGridCell.module.scss'; + +/** + * Component representing each 1 hour time block of a calendar + * @param props + */ +const CalendarCell: React.FC = props => ( +
+
+
+); + +export default CalendarCell; 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..c1eedc25 --- /dev/null +++ b/src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.tsx @@ -0,0 +1,91 @@ +import { Course } from '@shared/types/Course'; +import clsx from 'clsx'; +import React, { useEffect, useState } from 'react'; +import Spinner from '@views/components/common/Spinner/Spinner'; +import Text from '@views/components/common/Text/Text'; +import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper'; +import { SiteSupport } from '@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 = clsx({ + [styles.prerequisite]: lowerCaseLine.includes('prerequisite'), + [styles.onlyOne]: + lowerCaseLine.includes('may be') || lowerCaseLine.includes('only one') || lowerCaseLine.includes('may not'), + [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/styles/fonts.module.scss b/src/views/styles/fonts.module.scss index 06485672..40e0cf14 100644 --- a/src/views/styles/fonts.module.scss +++ b/src/views/styles/fonts.module.scss @@ -8,16 +8,6 @@ } } -@each $weight in '100' '300' '400' '500' '700' '900' { - @font-face { - font-family: 'Roboto'; - src: url('@public/fonts/roboto-#{$weight}.woff2') format('woff2'); - font-display: auto; - font-style: normal; - font-weight: #{$weight}; - } -} - @font-face { font-family: 'Roboto Flex'; src: url('@public/fonts/roboto-flex.woff2') format('woff2');