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');