feat: enable TS strict mode (#168)
* feat: enable TS strict mode * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: colors bug with default * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: text type errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors - add definite assignment assertion * fix: strict TS errors - add definite assignment assertion * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix(ESLint): error on no-explicit-any * fix: type annotations for any types * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors (and remove packages) * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * feat: enable React.StrictMode * fix: strict TS errors (done!) * fix: build error * fix: replace no-explicit-any assertions * refactor: cleanup * refactor: more cleanup * style: prettier --------- Co-authored-by: Lukas Zenick <lukas@utexas.edu> Co-authored-by: Razboy20 <razboy20@gmail.com>
This commit is contained in:
@@ -9,6 +9,7 @@ module.exports = {
|
|||||||
ignorePatterns: ['*.html', 'tsconfig.json'],
|
ignorePatterns: ['*.html', 'tsconfig.json'],
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
'plugin:react-hooks/recommended',
|
'plugin:react-hooks/recommended',
|
||||||
'plugin:storybook/recommended',
|
'plugin:storybook/recommended',
|
||||||
@@ -164,7 +165,7 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
'@typescript-eslint/no-unused-vars': 'warn',
|
'@typescript-eslint/no-unused-vars': 'warn',
|
||||||
'@typescript-eslint/naming-convention': 'off',
|
'@typescript-eslint/naming-convention': 'off',
|
||||||
'@typescript-eslint/space-before-function-paren': 'off',
|
'@typescript-eslint/space-before-function-paren': 'off',
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ This extension, UT Registration Plus (UTRP), tries to streamline most of the unn
|
|||||||
- Run `pnpm dev`
|
- Run `pnpm dev`
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Injected content such as extension content on UT pages is not properly styled, and are missing class stylings. When developing for these pages, use `pnpm build -w` to build and watch for changes. This will ensure you are seeing an accurate representation of the extension.
|
> Injected content such as extension content on UT pages is not properly styled, and are missing class stylings. When developing for these pages, use `NODE_ENV='development' pnpm run dev build --mode development -w` to build and watch for changes. This will ensure you are seeing an accurate representation of the extension.
|
||||||
|
|
||||||
### Production Builds
|
### Production Builds
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"coverage": "vitest run --coverage",
|
"coverage": "vitest run --coverage",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"devtools": "react-devtools",
|
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"nanoid": "^5.0.6",
|
"nanoid": "^5.0.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-devtools-core": "^5.0.0",
|
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"sass": "^1.71.1",
|
"sass": "^1.71.1",
|
||||||
"sql.js": "1.10.2"
|
"sql.js": "1.10.2"
|
||||||
@@ -102,9 +100,8 @@
|
|||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"react-dev-utils": "^12.0.1",
|
"react-dev-utils": "^12.0.1",
|
||||||
"react-devtools": "^5.0.0",
|
|
||||||
"storybook": "^7.6.17",
|
"storybook": "^7.6.17",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.4.3",
|
||||||
"unocss": "^0.58.6",
|
"unocss": "^0.58.6",
|
||||||
"unocss-preset-primitives": "0.0.2-beta.0",
|
"unocss-preset-primitives": "0.0.2-beta.0",
|
||||||
"unplugin-icons": "^0.18.5",
|
"unplugin-icons": "^0.18.5",
|
||||||
|
|||||||
1146
pnpm-lock.yaml
generated
1146
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,19 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
const manifest = chrome.runtime.getManifest();
|
const manifest = chrome.runtime.getManifest();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles editing the storage for a specific area.
|
||||||
|
*
|
||||||
|
* @param {string} areaName - The name of the storage area.
|
||||||
|
* @returns {Function} - A function that accepts changes and sets them in the storage.
|
||||||
|
*/
|
||||||
|
const handleEditStorage = (areaName: 'local' | 'sync' | 'session') => (changes: Record<string, unknown>) => {
|
||||||
|
chrome.storage[areaName].set(changes);
|
||||||
|
};
|
||||||
|
|
||||||
interface JSONEditorProps {
|
interface JSONEditorProps {
|
||||||
data: any;
|
data: unknown;
|
||||||
onChange: (updates: any) => void;
|
onChange: ReturnType<typeof handleEditStorage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function JSONEditor(props: JSONEditorProps) {
|
function JSONEditor(props: JSONEditorProps) {
|
||||||
@@ -64,9 +74,9 @@ function JSONEditor(props: JSONEditorProps) {
|
|||||||
// ));
|
// ));
|
||||||
|
|
||||||
function DevDashboard() {
|
function DevDashboard() {
|
||||||
const [localStorage, setLocalStorage] = React.useState<any>({});
|
const [localStorage, setLocalStorage] = React.useState<Record<string, unknown>>({});
|
||||||
const [syncStorage, setSyncStorage] = React.useState<any>({});
|
const [syncStorage, setSyncStorage] = React.useState<Record<string, unknown>>({});
|
||||||
const [sessionStorage, setSessionStorage] = React.useState<any>({});
|
const [sessionStorage, setSessionStorage] = React.useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onVisibilityChange = () => {
|
const onVisibilityChange = () => {
|
||||||
@@ -95,7 +105,8 @@ function DevDashboard() {
|
|||||||
// listen for changes to the chrome storage to update the local storage state displayed in the dashboard
|
// listen for changes to the chrome storage to update the local storage state displayed in the dashboard
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChanged = (changes: chrome.storage.StorageChange, areaName: chrome.storage.AreaName) => {
|
const onChanged = (changes: chrome.storage.StorageChange, areaName: chrome.storage.AreaName) => {
|
||||||
let copy = {};
|
let copy: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (areaName === 'local') {
|
if (areaName === 'local') {
|
||||||
copy = { ...localStorage };
|
copy = { ...localStorage };
|
||||||
} else if (areaName === 'sync') {
|
} else if (areaName === 'sync') {
|
||||||
@@ -104,8 +115,8 @@ function DevDashboard() {
|
|||||||
copy = { ...sessionStorage };
|
copy = { ...sessionStorage };
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.keys(changes).forEach(key => {
|
Object.keys(changes).forEach((key: string) => {
|
||||||
copy[key] = changes[key].newValue;
|
copy[key] = changes[key as keyof typeof changes].newValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (areaName === 'local') {
|
if (areaName === 'local') {
|
||||||
@@ -126,10 +137,6 @@ function DevDashboard() {
|
|||||||
};
|
};
|
||||||
}, [localStorage, syncStorage, sessionStorage]);
|
}, [localStorage, syncStorage, sessionStorage]);
|
||||||
|
|
||||||
const handleEditStorage = (areaName: string) => (changes: Record<string, any>) => {
|
|
||||||
chrome.storage[areaName].set(changes);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>
|
<h1>
|
||||||
@@ -147,4 +154,4 @@ function DevDashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(<DevDashboard />);
|
createRoot(document.getElementById('root')!).render(<DevDashboard />);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
// this is a custom wrapper around react-devtools
|
|
||||||
// that changes it so that we only send messages to the devtools when the current tab is active;
|
|
||||||
import { connectToDevTools } from 'react-devtools-core';
|
|
||||||
|
|
||||||
// connect to the devtools server
|
|
||||||
let ws = new WebSocket('ws://localhost:8097');
|
|
||||||
|
|
||||||
connectToDevTools({
|
|
||||||
websocket: ws,
|
|
||||||
});
|
|
||||||
|
|
||||||
// when the tab's visibile state changes, we connect or disconnect from the devtools
|
|
||||||
const onVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
ws = new WebSocket('ws://localhost:8097');
|
|
||||||
connectToDevTools({
|
|
||||||
websocket: ws,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
||||||
@@ -44,7 +44,7 @@ messageListener.listen();
|
|||||||
|
|
||||||
UserScheduleStore.listen('schedules', async schedules => {
|
UserScheduleStore.listen('schedules', async schedules => {
|
||||||
const index = await UserScheduleStore.get('activeIndex');
|
const index = await UserScheduleStore.get('activeIndex');
|
||||||
const numCourses = schedules[index]?.courses?.length;
|
const numCourses = schedules.newValue[index]?.courses?.length;
|
||||||
if (!numCourses) return;
|
if (!numCourses) return;
|
||||||
|
|
||||||
updateBadgeText(numCourses);
|
updateBadgeText(numCourses);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const CESHandler: MessageHandler<CESMessage> = {
|
|||||||
const instructorFirstAndLastName = [instructorFirstName, instructorLastName];
|
const instructorFirstAndLastName = [instructorFirstName, instructorLastName];
|
||||||
chrome.scripting.executeScript({
|
chrome.scripting.executeScript({
|
||||||
target: { tabId: tab.id },
|
target: { tabId: tab.id },
|
||||||
func: (...instructorFirstAndLastName: String[]) => {
|
func: (...instructorFirstAndLastName: string[]) => {
|
||||||
const inputElement = document.getElementById(
|
const inputElement = document.getElementById(
|
||||||
'ctl00_ContentPlaceHolder1_ViewList_tbxValue'
|
'ctl00_ContentPlaceHolder1_ViewList_tbxValue'
|
||||||
) as HTMLInputElement | null;
|
) as HTMLInputElement | null;
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
|
import type { TabWithId } from '@background/util/openNewTab';
|
||||||
import openNewTab from '@background/util/openNewTab';
|
import openNewTab from '@background/util/openNewTab';
|
||||||
import { tabs } from '@shared/messages';
|
import { tabs } from '@shared/messages';
|
||||||
import type { CalendarBackgroundMessages } from '@shared/messages/CalendarMessages';
|
import type { CalendarBackgroundMessages } from '@shared/messages/CalendarMessages';
|
||||||
import type { MessageHandler } from 'chrome-extension-toolkit';
|
import type { MessageHandler } from 'chrome-extension-toolkit';
|
||||||
|
|
||||||
const getAllTabInfos = async () => {
|
const getAllTabInfos = async () => {
|
||||||
const openTabs = await chrome.tabs.query({});
|
const openTabs = (await chrome.tabs.query({})).filter((tab): tab is TabWithId => tab.id !== undefined);
|
||||||
const results = await Promise.allSettled(openTabs.map(tab => tabs.getTabInfo(undefined, tab.id)));
|
const results = await Promise.allSettled(openTabs.map(tab => tabs.getTabInfo(undefined, tab.id)));
|
||||||
|
|
||||||
|
type TabInfo = PromiseFulfilledResult<Awaited<ReturnType<typeof tabs.getTabInfo>>>;
|
||||||
return results
|
return results
|
||||||
.map((result, index) => ({ result, index }))
|
.map((result, index) => ({ result, index }))
|
||||||
.filter(({ result }) => result.status === 'fulfilled')
|
.filter((el): el is { result: TabInfo; index: number } => el.result.status === 'fulfilled')
|
||||||
.map(({ result, index }) => {
|
.map(({ result, index }) => ({
|
||||||
if (result.status !== 'fulfilled') throw new Error('Will never happen, typescript dumb');
|
|
||||||
return {
|
|
||||||
...result.value,
|
...result.value,
|
||||||
tab: openTabs[index],
|
tab: openTabs[index]!,
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarBackgroundHandler: MessageHandler<CalendarBackgroundMessages> = {
|
const calendarBackgroundHandler: MessageHandler<CalendarBackgroundMessages> = {
|
||||||
@@ -25,17 +25,21 @@ const calendarBackgroundHandler: MessageHandler<CalendarBackgroundMessages> = {
|
|||||||
|
|
||||||
const allTabs = await getAllTabInfos();
|
const allTabs = await getAllTabInfos();
|
||||||
|
|
||||||
const openCalendarTabInfo = allTabs.find(tab => tab.url.startsWith(calendarUrl));
|
const openCalendarTabInfo = allTabs.find(tab => tab.url?.startsWith(calendarUrl));
|
||||||
|
|
||||||
if (openCalendarTabInfo !== undefined) {
|
if (openCalendarTabInfo !== undefined) {
|
||||||
chrome.tabs.update(openCalendarTabInfo.tab.id, { active: true });
|
const tabid = openCalendarTabInfo.tab.id;
|
||||||
if (uniqueId !== undefined) await tabs.openCoursePopup({ uniqueId }, openCalendarTabInfo.tab.id);
|
|
||||||
|
chrome.tabs.update(tabid, { active: true });
|
||||||
|
if (uniqueId !== undefined) await tabs.openCoursePopup({ uniqueId }, tabid);
|
||||||
|
|
||||||
sendResponse(openCalendarTabInfo.tab);
|
sendResponse(openCalendarTabInfo.tab);
|
||||||
} else {
|
} else {
|
||||||
const urlParams = new URLSearchParams();
|
const urlParams = new URLSearchParams();
|
||||||
if (uniqueId !== undefined) urlParams.set('uniqueId', uniqueId.toString());
|
if (uniqueId !== undefined) urlParams.set('uniqueId', uniqueId.toString());
|
||||||
const url = `${calendarUrl}?${urlParams.toString()}`.replace(/\?$/, '');
|
const url = `${calendarUrl}?${urlParams.toString()}`.replace(/\?$/, '');
|
||||||
const tab = await openNewTab(url);
|
const tab = await openNewTab(url);
|
||||||
|
|
||||||
sendResponse(tab);
|
sendResponse(tab);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ export default async function renameSchedule(scheduleId: string, newName: string
|
|||||||
if (scheduleIndex === -1) {
|
if (scheduleIndex === -1) {
|
||||||
return `Schedule ${scheduleId} does not exist`;
|
return `Schedule ${scheduleId} does not exist`;
|
||||||
}
|
}
|
||||||
// if (schedules.find(schedule => schedule.name === newName)) {
|
|
||||||
// return `Schedule ${newName} already exists`;
|
|
||||||
// }
|
|
||||||
|
|
||||||
schedules[scheduleIndex].name = newName;
|
schedules[scheduleIndex]!.name = newName;
|
||||||
// schedules[scheduleIndex].updatedAt = Date.now();
|
// schedules[scheduleIndex].updatedAt = Date.now();
|
||||||
|
|
||||||
await UserScheduleStore.set('schedules', schedules);
|
await UserScheduleStore.set('schedules', schedules);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export default async function switchSchedule(scheduleId: string): Promise<void>
|
|||||||
if (scheduleIndex === -1) {
|
if (scheduleIndex === -1) {
|
||||||
throw new Error(`Schedule ${scheduleId} does not exist`);
|
throw new Error(`Schedule ${scheduleId} does not exist`);
|
||||||
}
|
}
|
||||||
schedules[scheduleIndex].updatedAt = Date.now();
|
|
||||||
|
schedules[scheduleIndex]!.updatedAt = Date.now();
|
||||||
|
|
||||||
await UserScheduleStore.set('activeIndex', scheduleIndex);
|
await UserScheduleStore.set('activeIndex', scheduleIndex);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function openDebugTab() {
|
|||||||
DevStore.get('wasDebugTabVisible'),
|
DevStore.get('wasDebugTabVisible'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isAlreadyOpen = await (await chrome.tabs.query({})).some(tab => tab.id === debugTabId);
|
const isAlreadyOpen = (await chrome.tabs.query({})).some(tab => tab.id === debugTabId);
|
||||||
if (isAlreadyOpen) return;
|
if (isAlreadyOpen) return;
|
||||||
|
|
||||||
const tab = await chrome.tabs.create({
|
const tab = await chrome.tabs.create({
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
export type TabWithId = Omit<chrome.tabs.Tab, 'id'> & { id: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is a helper function that opens a new tab in the current window, and focuses the window
|
* 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)
|
* @param tabIndex - the index of the tab to open the new tab at (optional)
|
||||||
* @returns the tab that was opened
|
* @returns the tab that was opened
|
||||||
*/
|
*/
|
||||||
export default async function openNewTab(url: string, tabIndex?: number): Promise<chrome.tabs.Tab> {
|
export default async function openNewTab(url: string, tabIndex?: number): Promise<TabWithId> {
|
||||||
const tab = await chrome.tabs.create({ url, index: tabIndex, active: true });
|
const tab = (await chrome.tabs.create({ url, index: tabIndex, active: true })) as TabWithId;
|
||||||
await chrome.windows.update(tab.windowId, { focused: true });
|
await chrome.windows.update(tab.windowId, { focused: true });
|
||||||
return tab;
|
return tab;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import CalendarMain from './CalendarMain';
|
import CalendarMain from './CalendarMain';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(<CalendarMain />);
|
createRoot(document.getElementById('root')!).render(<CalendarMain />);
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(<App />);
|
createRoot(document.getElementById('root')!).render(<App />);
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ import PopupMain from '@views/components/PopupMain';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(<PopupMain />);
|
createRoot(document.getElementById('root')!).render(<PopupMain />);
|
||||||
|
|||||||
@@ -43,40 +43,40 @@ export type Semester = {
|
|||||||
*/
|
*/
|
||||||
export class Course {
|
export class Course {
|
||||||
/** Every course has a uniqueId within UT's registrar system corresponding to each course section */
|
/** Every course has a uniqueId within UT's registrar system corresponding to each course section */
|
||||||
uniqueId: number;
|
uniqueId!: number;
|
||||||
/** This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H */
|
/** This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H */
|
||||||
number: string;
|
number!: string;
|
||||||
/** The full name of the course, i.e. CS 314 Data Structures and Algorithms */
|
/** The full name of the course, i.e. CS 314 Data Structures and Algorithms */
|
||||||
fullName: string;
|
fullName!: string;
|
||||||
/** Just the english name for a course, without the number and department */
|
/** Just the english name for a course, without the number and department */
|
||||||
courseName: string;
|
courseName!: string;
|
||||||
/** The unique identifier for which department that a course belongs to, i.e. CS, MAL, etc. */
|
/** The unique identifier for which department that a course belongs to, i.e. CS, MAL, etc. */
|
||||||
department: string;
|
department!: string;
|
||||||
|
|
||||||
/** The number of credits that a course is worth */
|
/** The number of credits that a course is worth */
|
||||||
creditHours: number;
|
creditHours!: number;
|
||||||
/** Is the course open, closed, waitlisted, or cancelled? */
|
/** Is the course open, closed, waitlisted, or cancelled? */
|
||||||
status: StatusType;
|
status!: StatusType;
|
||||||
/** all the people that are teaching this course, and some metadata about their names */
|
/** all the people that are teaching this course, and some metadata about their names */
|
||||||
instructors: Instructor[];
|
instructors: Instructor[];
|
||||||
/** Some courses at UT are reserved for certain groups of people or people within a certain major, which makes it difficult for people outside of that group to register for the course. */
|
/** Some courses at UT are reserved for certain groups of people or people within a certain major, which makes it difficult for people outside of that group to register for the course. */
|
||||||
isReserved: boolean;
|
isReserved!: boolean;
|
||||||
/** The description of the course as an array of "lines". This will include important information as well as a short summary of the topics covered */
|
/** The description of the course as an array of "lines". This will include important information as well as a short summary of the topics covered */
|
||||||
description?: string[];
|
description?: string[];
|
||||||
/** The schedule for the course, which includes the days of the week that the course is taught, the time that the course is taught, and the location that the course is taught */
|
/** The schedule for the course, which includes the days of the week that the course is taught, the time that the course is taught, and the location that the course is taught */
|
||||||
schedule: CourseSchedule;
|
schedule: CourseSchedule;
|
||||||
/** the link to the course details page for this course */
|
/** the link to the course details page for this course */
|
||||||
url: string;
|
url!: string;
|
||||||
/** the link to the registration page for this course, for easy access when registering */
|
/** the link to the registration page for this course, for easy access when registering */
|
||||||
registerURL?: string;
|
registerURL?: string;
|
||||||
/** At UT, some courses have certain "flags" which aid in graduation */
|
/** At UT, some courses have certain "flags" which aid in graduation */
|
||||||
flags: string[];
|
flags!: string[];
|
||||||
/** How is the class being taught (online, hybrid, in person, etc) */
|
/** How is the class being taught (online, hybrid, in person, etc) */
|
||||||
instructionMode: InstructionMode;
|
instructionMode!: InstructionMode;
|
||||||
/** Which semester is the course from */
|
/** Which semester is the course from */
|
||||||
semester: Semester;
|
semester!: Semester;
|
||||||
/** Unix timestamp of when the course was last scraped */
|
/** Unix timestamp of when the course was last scraped */
|
||||||
scrapedAt: number;
|
scrapedAt!: number;
|
||||||
/** The colors of the course when displayed */
|
/** The colors of the course when displayed */
|
||||||
colors: CourseColors;
|
colors: CourseColors;
|
||||||
|
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ export type Location = {
|
|||||||
*/
|
*/
|
||||||
export class CourseMeeting {
|
export class CourseMeeting {
|
||||||
/** The day of the week that the course is taught */
|
/** The day of the week that the course is taught */
|
||||||
days: Day[];
|
days!: Day[];
|
||||||
/** NOTE: Times starting and after 12 PM have an additional 720 minutes (12 hrs) added to them
|
/** NOTE: Times starting and after 12 PM have an additional 720 minutes (12 hrs) added to them
|
||||||
* The start time of the course, in minutes since midnight
|
* The start time of the course, in minutes since midnight
|
||||||
* */
|
* */
|
||||||
startTime: number;
|
startTime!: number;
|
||||||
/** NOTE: Times starting and after 12 PM have an additional 720 minutes (12 hrs) added to them
|
/** NOTE: Times starting and after 12 PM have an additional 720 minutes (12 hrs) added to them
|
||||||
* The end time of the course, in minutes since midnight
|
* The end time of the course, in minutes since midnight
|
||||||
* */
|
* */
|
||||||
endTime: number;
|
endTime!: number;
|
||||||
/** The location that the course is taught */
|
/** The location that the course is taught */
|
||||||
location?: Location;
|
location?: Location;
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class CourseSchedule {
|
|||||||
if (char === 'S' && nextChar === 'U') {
|
if (char === 'S' && nextChar === 'U') {
|
||||||
day += nextChar;
|
day += nextChar;
|
||||||
}
|
}
|
||||||
return DAY_MAP[day];
|
return DAY_MAP[day as keyof typeof DAY_MAP];
|
||||||
})
|
})
|
||||||
.filter(Boolean) as Day[];
|
.filter(Boolean) as Day[];
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export class CourseSchedule {
|
|||||||
.split('-')
|
.split('-')
|
||||||
.map(time => {
|
.map(time => {
|
||||||
const [rawHour, rest] = time.split(':');
|
const [rawHour, rest] = time.split(':');
|
||||||
const [rawMinute, ampm] = rest.split(' ');
|
const [rawMinute, ampm] = rest?.split(' ') ?? ['', ''];
|
||||||
const hour = (rawHour === '12' ? 0 : Number(rawHour)) + (ampm === 'pm' ? 12 : 0);
|
const hour = (rawHour === '12' ? 0 : Number(rawHour)) + (ampm === 'pm' ? 12 : 0);
|
||||||
const minute = Number(rawMinute);
|
const minute = Number(rawMinute);
|
||||||
|
|
||||||
@@ -56,17 +56,27 @@ export class CourseSchedule {
|
|||||||
|
|
||||||
const location = locLine.split(' ').filter(Boolean);
|
const location = locLine.split(' ').filter(Boolean);
|
||||||
|
|
||||||
|
if (startTime === undefined || endTime === undefined) {
|
||||||
|
throw new Error('Failed to parse time');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime >= endTime) {
|
||||||
|
throw new Error('Start time must be before end time');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location === undefined) {
|
||||||
|
throw new Error('Failed to parse location');
|
||||||
|
}
|
||||||
|
|
||||||
return new CourseMeeting({
|
return new CourseMeeting({
|
||||||
days,
|
days,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
location: location.length
|
location: {
|
||||||
? {
|
building: location[0] ?? '',
|
||||||
building: location[0],
|
room: location[1] ?? '',
|
||||||
room: location[1],
|
},
|
||||||
}
|
} satisfies Serialized<CourseMeeting>);
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to parse schedule: ${dayLine} ${timeLine} ${locLine}`);
|
throw new Error(`Failed to parse schedule: ${dayLine} ${timeLine} ${locLine}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import type { Serialized } from 'chrome-extension-toolkit';
|
|||||||
* A type representing an instructor for a course (who teaches it)
|
* A type representing an instructor for a course (who teaches it)
|
||||||
*/
|
*/
|
||||||
export default class Instructor {
|
export default class Instructor {
|
||||||
fullName: string;
|
fullName?: string;
|
||||||
firstName: string;
|
firstName?: string;
|
||||||
lastName: string;
|
lastName?: string;
|
||||||
middleInitial?: string;
|
middleInitial?: string;
|
||||||
|
|
||||||
constructor(instructor: Serialized<Instructor>) {
|
constructor(instructor: Serialized<Instructor>) {
|
||||||
@@ -53,16 +53,16 @@ export default class Instructor {
|
|||||||
return capitalize(str);
|
return capitalize(str);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (format === 'abbr') {
|
if (format === 'abbr' && firstName && lastName && firstName[0]) {
|
||||||
return `${process(firstName[0])}. ${process(lastName)}`;
|
return `${process(firstName[0])}. ${process(lastName)}`;
|
||||||
}
|
}
|
||||||
if (format === 'full_name') {
|
if (format === 'full_name' && fullName) {
|
||||||
return process(fullName);
|
return process(fullName);
|
||||||
}
|
}
|
||||||
if (format === 'first_last') {
|
if (format === 'first_last' && firstName && lastName) {
|
||||||
return `${process(firstName)} ${process(lastName)}`;
|
return `${process(firstName)} ${process(lastName)}`;
|
||||||
}
|
}
|
||||||
if (format === 'last') {
|
if (format === 'last' && lastName) {
|
||||||
return process(lastName);
|
return process(lastName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const extendedColors = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type NestedKeys<T> = {
|
type NestedKeys<T> = {
|
||||||
[K in keyof T]: T[K] extends Record<string, any> ? `${string & K}-${string & keyof T[K]}` : never;
|
[K in keyof T]: T[K] extends Record<string, unknown> ? `${string & K}-${string & keyof T[K]}` : never;
|
||||||
}[keyof T];
|
}[keyof T];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +56,7 @@ export type ThemeColor = NestedKeys<typeof colors>;
|
|||||||
export type TWColorway = {
|
export type TWColorway = {
|
||||||
[K in keyof typeof theme.colors]: (typeof theme.colors)[K] extends Record<string, unknown> ? K : never;
|
[K in keyof typeof theme.colors]: (typeof theme.colors)[K] extends Record<string, unknown> ? K : never;
|
||||||
}[keyof typeof theme.colors];
|
}[keyof typeof theme.colors];
|
||||||
|
export type TWIndex = keyof (typeof theme.colors)[TWColorway];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the colors for a course.
|
* Represents the colors for a course.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { theme } from 'unocss/preset-mini';
|
|||||||
import type { HexColor, Lab, RGB, sRGB } from '../types/Color';
|
import type { HexColor, Lab, RGB, sRGB } from '../types/Color';
|
||||||
import { isHexColor } from '../types/Color';
|
import { isHexColor } from '../types/Color';
|
||||||
import type { Course } from '../types/Course';
|
import type { Course } from '../types/Course';
|
||||||
import type { CourseColors, TWColorway } from '../types/ThemeColors';
|
import type { CourseColors, TWColorway, TWIndex } from '../types/ThemeColors';
|
||||||
import { colorwayIndexes } from '../types/ThemeColors';
|
import { colorwayIndexes } from '../types/ThemeColors';
|
||||||
import type { UserSchedule } from '../types/UserSchedule';
|
import type { UserSchedule } from '../types/UserSchedule';
|
||||||
|
|
||||||
@@ -14,18 +14,21 @@ import type { UserSchedule } from '../types/UserSchedule';
|
|||||||
* @param hex - The hexadecimal color value.
|
* @param hex - The hexadecimal color value.
|
||||||
* @returns An array containing the RGB values.
|
* @returns An array containing the RGB values.
|
||||||
*/
|
*/
|
||||||
export function hexToRGB(hex: HexColor): RGB {
|
export function hexToRGB(hex: HexColor): RGB | undefined {
|
||||||
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
||||||
let shorthandRegex: RegExp = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
let shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||||
const parsedHex: string = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
|
const parsedHex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
|
||||||
|
|
||||||
let result: RegExpExecArray = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(parsedHex);
|
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(parsedHex);
|
||||||
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null;
|
|
||||||
|
if (!result || !(result.length > 3)) return undefined;
|
||||||
|
|
||||||
|
return [parseInt(result[1]!, 16), parseInt(result[2]!, 16), parseInt(result[3]!, 16)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useableColorways = Object.keys(theme.colors)
|
export const useableColorways = Object.keys(theme.colors)
|
||||||
// check that the color is a colorway (is an object)
|
// check that the color is a colorway (is an object)
|
||||||
.filter(color => typeof theme.colors[color] === 'object')
|
.filter(color => typeof theme.colors[color as keyof typeof theme.colors] === 'object')
|
||||||
.slice(0, 17) as TWColorway[];
|
.slice(0, 17) as TWColorway[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,12 +36,16 @@ export const useableColorways = Object.keys(theme.colors)
|
|||||||
* @param bgColor the hex color of the background
|
* @param bgColor the hex color of the background
|
||||||
*/
|
*/
|
||||||
export function pickFontColor(bgColor: HexColor): 'text-white' | 'text-black' | 'text-theme-black' {
|
export function pickFontColor(bgColor: HexColor): 'text-white' | 'text-black' | 'text-theme-black' {
|
||||||
const coefficients = [0.2126729, 0.7151522, 0.072175];
|
const coefficients = [0.2126729, 0.7151522, 0.072175] as const;
|
||||||
|
|
||||||
const flipYs = 0.342; // based on APCA™ 0.98G middle contrast BG color
|
const flipYs = 0.342; // based on APCA™ 0.98G middle contrast BG color
|
||||||
|
|
||||||
const trc = 2.4; // 2.4 exponent for emulating actual monitor perception
|
const trc = 2.4; // 2.4 exponent for emulating actual monitor perception
|
||||||
let Ys = hexToRGB(bgColor).reduce((acc, c, i) => acc + (c / 255.0) ** trc * coefficients[i], 0);
|
const rgb = hexToRGB(bgColor);
|
||||||
|
if (!rgb) throw new Error('bgColor: Invalid hex.');
|
||||||
|
|
||||||
|
// coefficients and rgb are both 3 elements long, so this is safe
|
||||||
|
let Ys = rgb.reduce((acc, c, i) => acc + (c / 255.0) ** trc * coefficients[i]!, 0);
|
||||||
|
|
||||||
if (Ys < flipYs) {
|
if (Ys < flipYs) {
|
||||||
return 'text-white';
|
return 'text-white';
|
||||||
@@ -54,13 +61,13 @@ export function pickFontColor(bgColor: HexColor): 'text-white' | 'text-black' |
|
|||||||
export function getCourseColors(colorway: TWColorway, index?: number, offset: number = 300): CourseColors {
|
export function getCourseColors(colorway: TWColorway, index?: number, offset: number = 300): CourseColors {
|
||||||
if (index === undefined) {
|
if (index === undefined) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
index = colorway in colorwayIndexes ? colorwayIndexes[colorway] : 500;
|
index = colorway in colorwayIndexes ? colorwayIndexes[colorway as keyof typeof colorwayIndexes] : 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
primaryColor: theme.colors[colorway][index],
|
primaryColor: theme.colors[colorway][index as TWIndex] as HexColor,
|
||||||
secondaryColor: theme.colors[colorway][index + offset],
|
secondaryColor: theme.colors[colorway][(index + offset) as TWIndex] as HexColor,
|
||||||
} satisfies CourseColors;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +94,12 @@ export function getColorwayFromColor(color: HexColor): TWColorway {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distance = oklabDistance(rgbToOKlab(hexToRGB(shadeColor)), rgbToOKlab(hexToRGB(color)));
|
const shadeColorRGB = hexToRGB(shadeColor);
|
||||||
|
if (!shadeColorRGB) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = oklabDistance(rgbToOKlab(shadeColorRGB), rgbToOKlab(shadeColorRGB));
|
||||||
if (distance < closestDistance) {
|
if (distance < closestDistance) {
|
||||||
closestDistance = distance;
|
closestDistance = distance;
|
||||||
closestColor = shade;
|
closestColor = shade;
|
||||||
@@ -148,7 +160,8 @@ export function getUnusedColor(
|
|||||||
|
|
||||||
if (sameDepartment.length > 0) {
|
if (sameDepartment.length > 0) {
|
||||||
// check to see if any adjacent colorways are available
|
// check to see if any adjacent colorways are available
|
||||||
const centerCourse = sameDepartment[Math.floor(Math.random() * sameDepartment.length)];
|
const centerCourse = sameDepartment[Math.floor(Math.random() * sameDepartment.length)]!;
|
||||||
|
|
||||||
let nextColorway = getNextColorway(centerCourse.colorway);
|
let nextColorway = getNextColorway(centerCourse.colorway);
|
||||||
let prevColorway = getPreviousColorway(centerCourse.colorway);
|
let prevColorway = getPreviousColorway(centerCourse.colorway);
|
||||||
|
|
||||||
@@ -175,11 +188,18 @@ export function getUnusedColor(
|
|||||||
|
|
||||||
if (shortenedColorways.size > 0) {
|
if (shortenedColorways.size > 0) {
|
||||||
// TODO: make this go by 3's to leave future spaces open
|
// TODO: make this go by 3's to leave future spaces open
|
||||||
const randomColorway = Array.from(shortenedColorways)[Math.floor(Math.random() * shortenedColorways.size)];
|
const randomColorway = Array.from(shortenedColorways)[Math.floor(Math.random() * shortenedColorways.size)]!;
|
||||||
|
|
||||||
return getCourseColors(randomColorway, index, offset);
|
return getCourseColors(randomColorway, index, offset);
|
||||||
}
|
}
|
||||||
// no colorways are at least 2 indexes away from any used colors, just get a random colorway
|
// no colorways are at least 2 indexes away from any used colors, just get a random colorway
|
||||||
const randomColorway = Array.from(availableColorways)[Math.floor(Math.random() * availableColorways.size)];
|
const randomColorway: TWColorway | undefined =
|
||||||
|
Array.from(availableColorways)[Math.floor(Math.random() * availableColorways.size)];
|
||||||
|
|
||||||
|
if (!randomColorway) {
|
||||||
|
throw new Error('randomColorway is undefined');
|
||||||
|
}
|
||||||
|
|
||||||
return getCourseColors(randomColorway, index, offset);
|
return getCourseColors(randomColorway, index, offset);
|
||||||
}
|
}
|
||||||
// TODO: get just a random color idk
|
// TODO: get just a random color idk
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import CancelledIcon from '~icons/material-symbols/warning';
|
|||||||
/**
|
/**
|
||||||
* Get Icon component based on status
|
* Get Icon component based on status
|
||||||
* @param props.status status
|
* @param props.status status
|
||||||
* @returns React.ReactElement - the icon component
|
* @returns the icon component
|
||||||
*/
|
*/
|
||||||
export function StatusIcon(props: SVGProps<SVGSVGElement> & { status: StatusType }): React.ReactElement {
|
export function StatusIcon(props: SVGProps<SVGSVGElement> & { status: StatusType }): JSX.Element | null {
|
||||||
const { status, ...rest } = props;
|
const { status, ...rest } = props;
|
||||||
|
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
@@ -23,5 +23,6 @@ export function StatusIcon(props: SVGProps<SVGSVGElement> & { status: StatusType
|
|||||||
case Status.CANCELLED:
|
case Status.CANCELLED:
|
||||||
return <CancelledIcon {...rest} />;
|
return <CancelledIcon {...rest} />;
|
||||||
default:
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { hexToRGB } from './colors';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Flattened colors object.
|
* Flattened colors object.
|
||||||
* @type {Record<ThemeColor, string>}
|
|
||||||
*/
|
*/
|
||||||
export const colorsFlattened = Object.entries(colors).reduce(
|
export const colorsFlattened: Record<ThemeColor, string> = Object.entries(colors).reduce(
|
||||||
(acc, [prefix, group]) => {
|
(acc: Record<ThemeColor, string>, [prefix, group]) => {
|
||||||
for (const [name, hex] of Object.entries(group)) {
|
for (const [name, hex] of Object.entries(group)) {
|
||||||
acc[`${prefix}-${name}`] = hex;
|
acc[`${prefix}-${name}` as ThemeColor] = hex;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
@@ -19,9 +18,8 @@ export const colorsFlattened = Object.entries(colors).reduce(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the flattened RGB values of the colors.
|
* Represents the flattened RGB values of the colors.
|
||||||
* @type {Record<ThemeColor, ReturnType<typeof hexToRgb>>}
|
|
||||||
*/
|
*/
|
||||||
const colorsFlattenedRgb = Object.fromEntries(
|
const colorsFlattenedRgb: Record<ThemeColor, ReturnType<typeof hexToRGB>> = Object.fromEntries(
|
||||||
Object.entries(colorsFlattened).map(([name, hex]) => [name, hexToRGB(hex as HexColor)])
|
Object.entries(colorsFlattened).map(([name, hex]) => [name, hexToRGB(hex as HexColor)])
|
||||||
) as Record<ThemeColor, ReturnType<typeof hexToRGB>>;
|
) as Record<ThemeColor, ReturnType<typeof hexToRGB>>;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { UserSchedule } from '@shared/types/UserSchedule';
|
|||||||
import { generateRandomId } from '@shared/util/random';
|
import { generateRandomId } from '@shared/util/random';
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import List from '@views/components/common/List/List';
|
import List from '@views/components/common/List/List';
|
||||||
|
import type { ScheduleDropdownProps } from '@views/components/common/ScheduleDropdown/ScheduleDropdown';
|
||||||
import ScheduleDropdown from '@views/components/common/ScheduleDropdown/ScheduleDropdown';
|
import ScheduleDropdown from '@views/components/common/ScheduleDropdown/ScheduleDropdown';
|
||||||
import ScheduleListItem from '@views/components/common/ScheduleListItem/ScheduleListItem';
|
import ScheduleListItem from '@views/components/common/ScheduleListItem/ScheduleListItem';
|
||||||
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
||||||
@@ -49,7 +50,7 @@ const meta: Meta<typeof ScheduleDropdown> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
render: (args: any) => {
|
render: (args: ScheduleDropdownProps) => {
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
const [activeSchedule, schedules] = useSchedules();
|
const [activeSchedule, schedules] = useSchedules();
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const generateCourses = (count: number): Course[] => {
|
|||||||
status: Status.WAITLISTED,
|
status: Status.WAITLISTED,
|
||||||
uniqueId: 12345 + i, // Make uniqueId different for each course
|
uniqueId: 12345 + i, // Make uniqueId different for each course
|
||||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20242/12345/',
|
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20242/12345/',
|
||||||
colors: tailwindColorways[i],
|
colors: tailwindColorways[i]!,
|
||||||
});
|
});
|
||||||
|
|
||||||
courses.push(course);
|
courses.push(course);
|
||||||
@@ -86,16 +86,16 @@ const meta = {
|
|||||||
argTypes: {
|
argTypes: {
|
||||||
gap: { control: 'number' },
|
gap: { control: 'number' },
|
||||||
},
|
},
|
||||||
} satisfies Meta<typeof List>;
|
} satisfies Meta<typeof List<Course>>;
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<Meta<typeof List<Course>>>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
draggables: exampleCourses,
|
draggables: exampleCourses,
|
||||||
children: generateCourseBlocks,
|
children: generateCourseBlocks,
|
||||||
itemKey: (item: Course) => item.uniqueId,
|
itemKey: item => item.uniqueId,
|
||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
render: args => (
|
render: args => (
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const PromptDialogWithButton = ({ children, ...args }: PromptDialogProps) => {
|
|||||||
const handleClose = () => setIsOpen(false);
|
const handleClose = () => setIsOpen(false);
|
||||||
const { title, content } = args;
|
const { title, content } = args;
|
||||||
|
|
||||||
const childrenWithHandleClose: React.ReactElement[] = children.map(child => {
|
const childrenWithHandleClose: React.ReactElement[] = (children ?? []).map(child => {
|
||||||
if (child.type === Button) {
|
if (child.type === Button) {
|
||||||
return React.cloneElement(child, { onClick: () => handleClose() } as React.HTMLAttributes<HTMLElement>);
|
return React.cloneElement(child, { onClick: () => handleClose() } as React.HTMLAttributes<HTMLElement>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,12 +87,12 @@ export const Default: Story = {
|
|||||||
courses: [
|
courses: [
|
||||||
{
|
{
|
||||||
colors: getCourseColors('pink', 200),
|
colors: getCourseColors('pink', 200),
|
||||||
courseDeptAndInstr: `${exampleGovCourse.department} ${exampleGovCourse.number} – ${exampleGovCourse.instructors[0].lastName}`,
|
courseDeptAndInstr: `${exampleGovCourse.department} ${exampleGovCourse.number} – ${exampleGovCourse.instructors[0]!.lastName}`,
|
||||||
status: exampleGovCourse.status,
|
status: exampleGovCourse.status,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
colors: getCourseColors('slate', 500),
|
colors: getCourseColors('slate', 500),
|
||||||
courseDeptAndInstr: `${examplePsyCourse.department} ${examplePsyCourse.number} – ${examplePsyCourse.instructors[0].lastName}`,
|
courseDeptAndInstr: `${examplePsyCourse.department} ${examplePsyCourse.number} – ${examplePsyCourse.instructors[0]!.lastName}`,
|
||||||
status: examplePsyCourse.status,
|
status: examplePsyCourse.status,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const meta = {
|
|||||||
argTypes: {
|
argTypes: {
|
||||||
course: { control: 'object' },
|
course: { control: 'object' },
|
||||||
meetingIdx: { control: 'number' },
|
meetingIdx: { control: 'number' },
|
||||||
rightIcon: { control: 'object' },
|
|
||||||
},
|
},
|
||||||
} satisfies Meta<typeof CalendarCourse>;
|
} satisfies Meta<typeof CalendarCourse>;
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Status } from '@shared/types/Course';
|
import { Status } from '@shared/types/Course';
|
||||||
import { getCourseColors } from '@shared/util/colors';
|
import { getCourseColors } from '@shared/util/colors';
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import type { CalendarCourseCellProps } from '@views/components/calendar/CalendarCourseCell/CalendarCourseCell';
|
||||||
import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell/CalendarCourseCell';
|
import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell/CalendarCourseCell';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ const meta = {
|
|||||||
timeAndLocation: { control: { type: 'text' } },
|
timeAndLocation: { control: { type: 'text' } },
|
||||||
colors: { control: { type: 'object' } },
|
colors: { control: { type: 'object' } },
|
||||||
},
|
},
|
||||||
render: (args: any) => (
|
render: (args: CalendarCourseCellProps) => (
|
||||||
<div className='w-45'>
|
<div className='w-45'>
|
||||||
<CalendarCourseCell {...args} />
|
<CalendarCourseCell {...args} />
|
||||||
</div>
|
</div>
|
||||||
@@ -29,7 +30,7 @@ const meta = {
|
|||||||
courseDeptAndInstr: ExampleCourse.department,
|
courseDeptAndInstr: ExampleCourse.department,
|
||||||
className: ExampleCourse.number,
|
className: ExampleCourse.number,
|
||||||
status: ExampleCourse.status,
|
status: ExampleCourse.status,
|
||||||
timeAndLocation: ExampleCourse.schedule.meetings[0].getTimeString({ separator: '-' }),
|
timeAndLocation: ExampleCourse.schedule.meetings[0]!.getTimeString({ separator: '-' }),
|
||||||
|
|
||||||
colors: getCourseColors('emerald', 500),
|
colors: getCourseColors('emerald', 500),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface Props {
|
|||||||
/**
|
/**
|
||||||
* This is the top level react component orchestrating the course catalog page.
|
* This is the top level react component orchestrating the course catalog page.
|
||||||
*/
|
*/
|
||||||
export default function CourseCatalogMain({ support }: Props): JSX.Element {
|
export default function CourseCatalogMain({ support }: Props): JSX.Element | null {
|
||||||
const [rows, setRows] = React.useState<ScrapedRow[]>([]);
|
const [rows, setRows] = React.useState<ScrapedRow[]>([]);
|
||||||
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
|
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
@@ -53,8 +53,6 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element {
|
|||||||
setSelectedCourse(course);
|
setSelectedCourse(course);
|
||||||
};
|
};
|
||||||
|
|
||||||
// useKeyPress('Escape', handleClearSelectedCourse);
|
|
||||||
|
|
||||||
const [activeSchedule] = useSchedules();
|
const [activeSchedule] = useSchedules();
|
||||||
|
|
||||||
if (!activeSchedule) {
|
if (!activeSchedule) {
|
||||||
@@ -78,7 +76,7 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<CourseCatalogInjectedPopup
|
<CourseCatalogInjectedPopup
|
||||||
course={selectedCourse}
|
course={selectedCourse!} // always defined when showPopup is true
|
||||||
show={showPopup}
|
show={showPopup}
|
||||||
onClose={() => setShowPopup(false)}
|
onClose={() => setShowPopup(false)}
|
||||||
afterLeave={() => setSelectedCourse(null)}
|
afterLeave={() => setSelectedCourse(null)}
|
||||||
|
|||||||
@@ -20,16 +20,20 @@ import TeamLinks from '../TeamLinks';
|
|||||||
export default function Calendar(): JSX.Element {
|
export default function Calendar(): JSX.Element {
|
||||||
const calendarRef = useRef<HTMLDivElement>(null);
|
const calendarRef = useRef<HTMLDivElement>(null);
|
||||||
const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
|
const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
|
||||||
|
|
||||||
const [course, setCourse] = useState<Course | null>((): Course | null => {
|
const [course, setCourse] = useState<Course | null>((): Course | null => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const uniqueIdRaw = urlParams.get('uniqueId');
|
const uniqueIdRaw = urlParams.get('uniqueId');
|
||||||
if (uniqueIdRaw === null) return null;
|
if (uniqueIdRaw === null) return null;
|
||||||
|
|
||||||
const uniqueId = Number(uniqueIdRaw);
|
const uniqueId = Number(uniqueIdRaw);
|
||||||
const course = activeSchedule.courses.find(course => course.uniqueId === uniqueId);
|
const course = activeSchedule.courses.find(course => course.uniqueId === uniqueId);
|
||||||
if (course === undefined) return null;
|
if (course === undefined) return null;
|
||||||
|
|
||||||
urlParams.delete('uniqueId');
|
urlParams.delete('uniqueId');
|
||||||
const newUrl = `${window.location.pathname}?${urlParams}`.replace(/\?$/, '');
|
const newUrl = `${window.location.pathname}?${urlParams}`.replace(/\?$/, '');
|
||||||
window.history.replaceState({}, '', newUrl);
|
window.history.replaceState({}, '', newUrl);
|
||||||
|
|
||||||
return course;
|
return course;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,16 +45,20 @@ export default function Calendar(): JSX.Element {
|
|||||||
async openCoursePopup({ data, sendResponse }) {
|
async openCoursePopup({ data, sendResponse }) {
|
||||||
const course = activeSchedule.courses.find(course => course.uniqueId === data.uniqueId);
|
const course = activeSchedule.courses.find(course => course.uniqueId === data.uniqueId);
|
||||||
if (course === undefined) return;
|
if (course === undefined) return;
|
||||||
|
|
||||||
setCourse(course);
|
setCourse(course);
|
||||||
setShowPopup(true);
|
setShowPopup(true);
|
||||||
sendResponse(await chrome.tabs.getCurrent());
|
|
||||||
|
const currentTab = await chrome.tabs.getCurrent();
|
||||||
|
if (currentTab === undefined) return;
|
||||||
|
sendResponse(currentTab);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
listener.listen();
|
listener.listen();
|
||||||
|
|
||||||
return () => listener.unlisten();
|
return () => listener.unlisten();
|
||||||
}, [activeSchedule.courses]);
|
}, [activeSchedule]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (course) setShowPopup(true);
|
if (course) setShowPopup(true);
|
||||||
@@ -85,7 +93,7 @@ export default function Calendar(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CourseCatalogInjectedPopup
|
<CourseCatalogInjectedPopup
|
||||||
course={course}
|
course={course!} // always defined when showPopup is true
|
||||||
onClose={() => setShowPopup(false)}
|
onClose={() => setShowPopup(false)}
|
||||||
open={showPopup}
|
open={showPopup}
|
||||||
afterLeave={() => setCourse(null)}
|
afterLeave={() => setCourse(null)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Course } from '@shared/types/Course';
|
import type { Course } from '@shared/types/Course';
|
||||||
import type { CourseMeeting } from '@shared/types/CourseMeeting';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import styles from './CalendarCourseMeeting.module.scss';
|
import styles from './CalendarCourseMeeting.module.scss';
|
||||||
@@ -12,28 +11,27 @@ export interface CalendarCourseMeetingProps {
|
|||||||
course: Course;
|
course: Course;
|
||||||
/* index into course meeting array to display */
|
/* index into course meeting array to display */
|
||||||
meetingIdx?: number;
|
meetingIdx?: number;
|
||||||
/** The icon to display on the right side of the course. This is optional. */
|
|
||||||
rightIcon?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `CalendarCourseMeeting` is a functional component that displays a course meeting.
|
* `CalendarCourseMeeting` is a functional component that displays a course meeting.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* <CalendarCourseMeeting course={course} meeting={meeting} rightIcon={<Icon />} />
|
* <CalendarCourseMeeting course={course} meeting={meeting} />
|
||||||
*/
|
*/
|
||||||
export default function CalendarCourseMeeting({
|
export default function CalendarCourseMeeting({ course, meetingIdx }: CalendarCourseMeetingProps): JSX.Element | null {
|
||||||
course,
|
let meeting = meetingIdx !== undefined ? course.schedule.meetings[meetingIdx] : undefined;
|
||||||
meetingIdx,
|
|
||||||
rightIcon,
|
if (!meeting) {
|
||||||
}: CalendarCourseMeetingProps): JSX.Element {
|
return null;
|
||||||
let meeting: CourseMeeting | null = meetingIdx !== undefined ? course.schedule.meetings[meetingIdx] : null;
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.component}>
|
<div className={styles.component}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles['course-detail']}>
|
<div className={styles['course-detail']}>
|
||||||
<div className={styles.course}>
|
<div className={styles.course}>
|
||||||
{course.department} {course.number} - {course.instructors[0].lastName}
|
{course.department} {course.number} - {course.instructors[0]?.lastName}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles['time-and-location']}>
|
<div className={styles['time-and-location']}>
|
||||||
{`${meeting.getTimeString({ separator: '-', capitalize: true })}${
|
{`${meeting.getTimeString({ separator: '-', capitalize: true })}${
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ThemeColor, TWIndex } from '@shared/types/ThemeColors';
|
||||||
import { getThemeColorHexByName } from '@shared/util/themeColors';
|
import { getThemeColorHexByName } from '@shared/util/themeColors';
|
||||||
import Divider from '@views/components/common/Divider/Divider';
|
import Divider from '@views/components/common/Divider/Divider';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -30,16 +31,19 @@ const baseColors = [
|
|||||||
'fuchsia',
|
'fuchsia',
|
||||||
'pink',
|
'pink',
|
||||||
'rose',
|
'rose',
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const BaseColorNum = 500;
|
const BaseColorNum: TWIndex = 500;
|
||||||
const StartingShadeIndex = 200;
|
const StartingShadeIndex: TWIndex = 200;
|
||||||
const ShadeIncrement = 100;
|
const ShadeIncrement = 100;
|
||||||
|
|
||||||
const colorPatchColors = new Map<string, string[]>(
|
const colorPatchColors = new Map<string, string[]>(
|
||||||
baseColors.map((baseColor: string) => [
|
baseColors.map(baseColor => [
|
||||||
theme.colors[baseColor][BaseColorNum],
|
theme.colors[baseColor][BaseColorNum],
|
||||||
Array.from({ length: 6 }, (_, index) => theme.colors[baseColor][StartingShadeIndex + ShadeIncrement * index]),
|
Array.from(
|
||||||
|
{ length: 6 },
|
||||||
|
(_, index) => theme.colors[baseColor][(StartingShadeIndex + ShadeIncrement * index) as TWIndex]
|
||||||
|
),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -51,7 +55,7 @@ const hexCodeToBaseColor = new Map<string, string>(
|
|||||||
* Props for the CourseCellColorPicker component.
|
* Props for the CourseCellColorPicker component.
|
||||||
*/
|
*/
|
||||||
export interface CourseCellColorPickerProps {
|
export interface CourseCellColorPickerProps {
|
||||||
setSelectedColor: React.Dispatch<React.SetStateAction<string | null>>;
|
setSelectedColor: React.Dispatch<React.SetStateAction<ThemeColor | null>>;
|
||||||
isInvertColorsToggled: boolean;
|
isInvertColorsToggled: boolean;
|
||||||
setIsInvertColorsToggled: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsInvertColorsToggled: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
@@ -89,7 +93,7 @@ export default function CourseCellColorPicker({
|
|||||||
const [hexCode, setHexCode] = React.useState<string>(
|
const [hexCode, setHexCode] = React.useState<string>(
|
||||||
getThemeColorHexByName('ut-gray').slice(1).toLocaleLowerCase()
|
getThemeColorHexByName('ut-gray').slice(1).toLocaleLowerCase()
|
||||||
);
|
);
|
||||||
const hexCodeWithHash = `#${hexCode}`;
|
const hexCodeWithHash = `#${hexCode}` as ThemeColor;
|
||||||
const selectedBaseColor = hexCodeToBaseColor.get(hexCodeWithHash);
|
const selectedBaseColor = hexCodeToBaseColor.get(hexCodeWithHash);
|
||||||
|
|
||||||
const handleSelectColorPatch = (baseColor: string) => {
|
const handleSelectColorPatch = (baseColor: string) => {
|
||||||
@@ -124,7 +128,7 @@ export default function CourseCellColorPicker({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{hexCodeToBaseColor.has(hexCodeWithHash) && (
|
{selectedBaseColor && (
|
||||||
<>
|
<>
|
||||||
<Divider orientation='horizontal' size='100%' className='my-1' />
|
<Divider orientation='horizontal' size='100%' className='my-1' />
|
||||||
<div className='grid grid-cols-6 gap-1'>
|
<div className='grid grid-cols-6 gap-1'>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function CalendarHour({ hour }: { hour: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeGridRow(row: number, cols: number): JSX.Element {
|
function makeGridRow(row: number, cols: number): JSX.Element {
|
||||||
const hour = hoursOfDay[row];
|
const hour = hoursOfDay[row]!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -83,14 +83,17 @@ interface AccountForCourseConflictsProps {
|
|||||||
// TODO: Deal with react strict mode (wacky movements)
|
// TODO: Deal with react strict mode (wacky movements)
|
||||||
function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseConflictsProps): JSX.Element[] {
|
function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseConflictsProps): JSX.Element[] {
|
||||||
// Groups by dayIndex to identify overlaps
|
// Groups by dayIndex to identify overlaps
|
||||||
const days = courseCells.reduce((acc, cell: CalendarGridCourse) => {
|
const days = courseCells.reduce(
|
||||||
|
(acc, cell: CalendarGridCourse) => {
|
||||||
const { dayIndex } = cell.calendarGridPoint;
|
const { dayIndex } = cell.calendarGridPoint;
|
||||||
if (!acc[dayIndex]) {
|
if (acc[dayIndex] === undefined) {
|
||||||
acc[dayIndex] = [];
|
acc[dayIndex] = [];
|
||||||
}
|
}
|
||||||
acc[dayIndex].push(cell);
|
acc[dayIndex]!.push(cell);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
},
|
||||||
|
{} as Record<number, CalendarGridCourse[]>
|
||||||
|
);
|
||||||
|
|
||||||
// Check for overlaps within each day and adjust gridColumnIndex and totalColumns
|
// Check for overlaps within each day and adjust gridColumnIndex and totalColumns
|
||||||
Object.values(days).forEach((dayCells: CalendarGridCourse[]) => {
|
Object.values(days).forEach((dayCells: CalendarGridCourse[]) => {
|
||||||
@@ -121,7 +124,7 @@ function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseC
|
|||||||
});
|
});
|
||||||
|
|
||||||
return courseCells.map((block, i) => {
|
return courseCells.map((block, i) => {
|
||||||
const { courseDeptAndInstr, timeAndLocation, status } = courseCells[i].componentProps;
|
const { courseDeptAndInstr, timeAndLocation, status } = courseCells[i]!.componentProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -129,8 +132,8 @@ function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseC
|
|||||||
style={{
|
style={{
|
||||||
gridColumn: `${block.calendarGridPoint.dayIndex + 3}`,
|
gridColumn: `${block.calendarGridPoint.dayIndex + 3}`,
|
||||||
gridRow: `${block.calendarGridPoint.startIndex} / ${block.calendarGridPoint.endIndex}`,
|
gridRow: `${block.calendarGridPoint.startIndex} / ${block.calendarGridPoint.endIndex}`,
|
||||||
width: `calc(100% / ${block.totalColumns})`,
|
width: `calc(100% / ${block.totalColumns ?? 1})`,
|
||||||
marginLeft: `calc(100% * ${(block.gridColumnStart - 1) / block.totalColumns})`,
|
marginLeft: `calc(100% * ${((block.gridColumnStart ?? 0) - 1) / (block.totalColumns ?? 1)})`,
|
||||||
padding: '0px 10px 4px 0px',
|
padding: '0px 10px 4px 0px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
|
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
|
import type { Serialized } from 'chrome-extension-toolkit';
|
||||||
import { toPng } from 'html-to-image';
|
import { toPng } from 'html-to-image';
|
||||||
|
|
||||||
export const CAL_MAP = {
|
export const CAL_MAP = {
|
||||||
@@ -13,9 +15,9 @@ export const CAL_MAP = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the schedule from the UserScheduleStore based on the active index.
|
* Retrieves the schedule from the UserScheduleStore based on the active index.
|
||||||
* @returns {Promise<any>} A promise that resolves to the retrieved schedule.
|
* @returns A promise that resolves to the retrieved schedule.
|
||||||
*/
|
*/
|
||||||
const getSchedule = async () => {
|
const getSchedule = async (): Promise<Serialized<UserSchedule> | undefined> => {
|
||||||
const schedules = await UserScheduleStore.get('schedules');
|
const schedules = await UserScheduleStore.get('schedules');
|
||||||
const activeIndex = await UserScheduleStore.get('activeIndex');
|
const activeIndex = await UserScheduleStore.get('activeIndex');
|
||||||
const schedule = schedules[activeIndex];
|
const schedule = schedules[activeIndex];
|
||||||
@@ -61,6 +63,10 @@ export const saveAsCal = async () => {
|
|||||||
|
|
||||||
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
|
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new Error('No schedule found');
|
||||||
|
}
|
||||||
|
|
||||||
schedule.courses.forEach(course => {
|
schedule.courses.forEach(course => {
|
||||||
course.schedule.meetings.forEach(meeting => {
|
course.schedule.meetings.forEach(meeting => {
|
||||||
const { startTime, endTime, days, location } = meeting;
|
const { startTime, endTime, days, location } = meeting;
|
||||||
@@ -85,7 +91,7 @@ export const saveAsCal = async () => {
|
|||||||
icsString += `DTEND:${endDate}\n`;
|
icsString += `DTEND:${endDate}\n`;
|
||||||
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays}\n`;
|
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays}\n`;
|
||||||
icsString += `SUMMARY:${course.fullName}\n`;
|
icsString += `SUMMARY:${course.fullName}\n`;
|
||||||
icsString += `LOCATION:${location.building} ${location.room}\n`;
|
icsString += `LOCATION:${location?.building ?? ''} ${location?.room ?? ''}\n`;
|
||||||
icsString += `END:VEVENT\n`;
|
icsString += `END:VEVENT\n`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function Button({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={disabled ? undefined : onClick}
|
onClick={disabled ? undefined : onClick}
|
||||||
>
|
>
|
||||||
{icon && <Icon className='h-6 w-6' />}
|
{Icon && <Icon className='h-6 w-6' />}
|
||||||
{!isIconOnly && (
|
{!isIconOnly && (
|
||||||
<Text variant='h4' className='translate-y-0.08'>
|
<Text variant='h4' className='translate-y-0.08'>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import styles from './Card.module.scss';
|
|||||||
export type Props = {
|
export type Props = {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: (...args) => void;
|
onClick?: (...args: unknown[]) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function Chip({ label }: React.PropsWithChildren<Props>): JSX.Element {
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: '#FFD600',
|
backgroundColor: '#FFD600',
|
||||||
}}
|
}}
|
||||||
title={Object.keys(flagMap).find(key => flagMap[key] === label)}
|
title={Object.entries(flagMap).find(([full, short]) => short === label)![0]}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import React, { Fragment } from 'react';
|
|||||||
|
|
||||||
import ExtensionRoot from '../ExtensionRoot/ExtensionRoot';
|
import ExtensionRoot from '../ExtensionRoot/ExtensionRoot';
|
||||||
|
|
||||||
interface _DialogProps {
|
export interface _DialogProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: JSX.Element;
|
title?: JSX.Element;
|
||||||
description?: JSX.Element;
|
description?: JSX.Element;
|
||||||
@@ -21,7 +21,7 @@ export type DialogProps = _DialogProps & Omit<TransitionRootProps<typeof HDialog
|
|||||||
* A reusable popup component that can be used to display content on the page
|
* A reusable popup component that can be used to display content on the page
|
||||||
*/
|
*/
|
||||||
export default function Dialog(props: PropsWithChildren<DialogProps>): JSX.Element {
|
export default function Dialog(props: PropsWithChildren<DialogProps>): JSX.Element {
|
||||||
const { children, className, open, onTransitionEnd, ...rest } = props;
|
const { children, className, open, ...rest } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition show={open} as={HDialog} {...rest}>
|
<Transition show={open} as={HDialog} {...rest}>
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ export default function ExtensionRoot(props: React.PropsWithChildren<Props>): JS
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
<div className={clsx(styles.extensionRoot, props.className)} data-testid={props.testId}>
|
<div className={clsx(styles.extensionRoot, props.className)} data-testid={props.testId}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
.link {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disabled {
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
text-decoration: none !important;
|
|
||||||
color: #999999 !important;
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ import clsx from 'clsx';
|
|||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import styles from './Link.module.scss';
|
|
||||||
|
|
||||||
type Props = TextProps<'a'> & {
|
type Props = TextProps<'a'> & {
|
||||||
href?: string;
|
href?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -19,7 +17,10 @@ export default function Link(props: PropsWithChildren<Props>): JSX.Element {
|
|||||||
let { className, href, ...passedProps } = props;
|
let { className, href, ...passedProps } = props;
|
||||||
|
|
||||||
if (href && !props.onClick) {
|
if (href && !props.onClick) {
|
||||||
passedProps.onClick = () => background.openNewTab({ url: href });
|
passedProps.onClick = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
background.openNewTab({ url: href });
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const isDisabled = props.disabled || (!href && !props.onClick);
|
const isDisabled = props.disabled || (!href && !props.onClick);
|
||||||
|
|
||||||
@@ -28,11 +29,13 @@ export default function Link(props: PropsWithChildren<Props>): JSX.Element {
|
|||||||
color='bluebonnet'
|
color='bluebonnet'
|
||||||
{...passedProps}
|
{...passedProps}
|
||||||
as='a'
|
as='a'
|
||||||
|
aria-disabled={isDisabled}
|
||||||
|
href={!isDisabled ? href : undefined}
|
||||||
tabIndex={isDisabled ? -1 : 0}
|
tabIndex={isDisabled ? -1 : 0}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
styles.link,
|
|
||||||
{
|
{
|
||||||
[styles.disabled]: isDisabled,
|
'underline cursor-pointer': !isDisabled,
|
||||||
|
'cursor-not-allowed color-ut-gray': isDisabled,
|
||||||
},
|
},
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ function reorder<T>(list: T[], startIndex: number, endIndex: number) {
|
|||||||
const listCopy = [...list];
|
const listCopy = [...list];
|
||||||
|
|
||||||
const [removed] = listCopy.splice(startIndex, 1);
|
const [removed] = listCopy.splice(startIndex, 1);
|
||||||
|
if (removed) {
|
||||||
listCopy.splice(endIndex, 0, removed);
|
listCopy.splice(endIndex, 0, removed);
|
||||||
|
}
|
||||||
|
|
||||||
return listCopy;
|
return listCopy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ function List<T>({ draggables, itemKey, children, onReordered, gap }: ListProps<
|
|||||||
// check if the draggables content has *actually* changed
|
// check if the draggables content has *actually* changed
|
||||||
if (
|
if (
|
||||||
draggables.length === items.length &&
|
draggables.length === items.length &&
|
||||||
draggables.every((element, index) => itemKey(element) === items[index].id)
|
draggables.every((element, index) => itemKey(element) === items[index]?.id)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -86,12 +89,12 @@ function List<T>({ draggables, itemKey, children, onReordered, gap }: ListProps<
|
|||||||
}, [draggables, itemKey, items]);
|
}, [draggables, itemKey, items]);
|
||||||
|
|
||||||
const onDragEnd: OnDragEndResponder = useCallback(
|
const onDragEnd: OnDragEndResponder = useCallback(
|
||||||
result => {
|
({ destination, source }) => {
|
||||||
if (!result.destination) return;
|
if (!destination) return;
|
||||||
if (result.source.index === result.destination.index) return;
|
if (source.index === destination.index) return;
|
||||||
|
|
||||||
// will reorder in place
|
// will reorder in place
|
||||||
const reordered = reorder(items, result.source.index, result.destination.index);
|
const reordered = reorder(items, source.index, destination.index);
|
||||||
|
|
||||||
setItems(reordered);
|
setItems(reordered);
|
||||||
onReordered(reordered.map(item => item.content));
|
onReordered(reordered.map(item => item.content));
|
||||||
@@ -125,7 +128,7 @@ function List<T>({ draggables, itemKey, children, onReordered, gap }: ListProps<
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ExtensionRoot>
|
<ExtensionRoot>
|
||||||
{transformFunction(items[rubric.source.index].content, provided.dragHandleProps)}
|
{transformFunction(items[rubric.source.index]!.content, provided.dragHandleProps!)}
|
||||||
</ExtensionRoot>
|
</ExtensionRoot>
|
||||||
</Item>
|
</Item>
|
||||||
);
|
);
|
||||||
@@ -135,17 +138,22 @@ function List<T>({ draggables, itemKey, children, onReordered, gap }: ListProps<
|
|||||||
<div {...provided.droppableProps} ref={provided.innerRef} style={{ marginBottom: `-${gap}px` }}>
|
<div {...provided.droppableProps} ref={provided.innerRef} style={{ marginBottom: `-${gap}px` }}>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<Draggable key={item.id} draggableId={item.id.toString()} index={index}>
|
<Draggable key={item.id} draggableId={item.id.toString()} index={index}>
|
||||||
{draggableProvided => (
|
{({ innerRef, draggableProps, dragHandleProps }) => (
|
||||||
<div
|
<div
|
||||||
ref={draggableProvided.innerRef}
|
ref={innerRef}
|
||||||
{...draggableProvided.draggableProps}
|
{...draggableProps}
|
||||||
style={{
|
style={{
|
||||||
...draggableProvided.draggableProps.style,
|
...draggableProps.style,
|
||||||
// if last item, don't add margin
|
// if last item, don't add margin
|
||||||
marginBottom: `${gap}px`,
|
marginBottom: `${gap}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{transformFunction(item.content, draggableProvided.dragHandleProps)}
|
{
|
||||||
|
transformFunction(
|
||||||
|
item.content,
|
||||||
|
dragHandleProps!
|
||||||
|
) /* always exists; only doesn't when "isDragDisabled" is set */
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
|
||||||
import { background } from '@shared/messages';
|
import { background } from '@shared/messages';
|
||||||
import type { Course } from '@shared/types/Course';
|
import type { Course } from '@shared/types/Course';
|
||||||
import { Status } from '@shared/types/Course';
|
import { Status } from '@shared/types/Course';
|
||||||
@@ -17,7 +18,7 @@ export interface PopupCourseBlockProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
course: Course;
|
course: Course;
|
||||||
colors: CourseColors;
|
colors: CourseColors;
|
||||||
dragHandleProps?: any;
|
dragHandleProps?: DraggableProvidedDragHandleProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import DropdownArrowUp from '~icons/material-symbols/arrow-drop-up';
|
|||||||
/**
|
/**
|
||||||
* Props for the Dropdown component.
|
* Props for the Dropdown component.
|
||||||
*/
|
*/
|
||||||
export type Props = {
|
export type ScheduleDropdownProps = {
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
@@ -17,7 +17,7 @@ export type Props = {
|
|||||||
/**
|
/**
|
||||||
* This is a reusable dropdown component that can be used to toggle the visiblity of information
|
* This is a reusable dropdown component that can be used to toggle the visiblity of information
|
||||||
*/
|
*/
|
||||||
export default function ScheduleDropdown(props: Props) {
|
export default function ScheduleDropdown(props: ScheduleDropdownProps) {
|
||||||
const [activeSchedule] = useSchedules();
|
const [activeSchedule] = useSchedules();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
|
|||||||
<div className='group flex flex-1 items-center overflow-x-hidden'>
|
<div className='group flex flex-1 items-center overflow-x-hidden'>
|
||||||
<div
|
<div
|
||||||
className='flex flex-grow items-center gap-1.5 overflow-x-hidden'
|
className='flex flex-grow items-center gap-1.5 overflow-x-hidden'
|
||||||
onClick={(...e) => !isEditing && onClick(...e)}
|
onClick={(...e) => !isEditing && onClick?.(...e)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ type OurProps<TTag extends ReactTag> = {
|
|||||||
ref?: React.ForwardedRef<React.ElementRef<TTag>>;
|
ref?: React.ForwardedRef<React.ElementRef<TTag>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AsProps<TTag extends ReactTag, TOverrides = {}> = CleanProps<TTag, keyof TOverrides> & OurProps<TTag> & TOverrides;
|
type AsProps<TTag extends ReactTag, TOverrides = object> = CleanProps<TTag, keyof TOverrides> &
|
||||||
|
OurProps<TTag> &
|
||||||
|
TOverrides;
|
||||||
|
|
||||||
const variants = ['mini', 'small', 'p', 'h4', 'h3-course', 'h3', 'h2-course', 'h2', 'h1-course', 'h1'] as const;
|
const variants = ['mini', 'small', 'p', 'h4', 'h3-course', 'h3', 'h2-course', 'h2', 'h1-course', 'h1'] as const;
|
||||||
|
|
||||||
@@ -25,7 +27,8 @@ type Variant = (typeof variants)[number];
|
|||||||
/**
|
/**
|
||||||
* Props for the Text component.
|
* Props for the Text component.
|
||||||
*/
|
*/
|
||||||
export type TextProps<TTag extends ElementType = 'span'> = PropsOf<TTag>['className'] extends string
|
export type TextProps<TTag extends ElementType = 'span'> =
|
||||||
|
NonNullable<PropsOf<TTag>['className']> extends string
|
||||||
? AsProps<
|
? AsProps<
|
||||||
TTag,
|
TTag,
|
||||||
{
|
{
|
||||||
@@ -47,4 +50,4 @@ function Text<TTag extends ElementType = 'span'>(
|
|||||||
return <Comp className={mergedClassName} {...rest} ref={ref} />;
|
return <Comp className={mergedClassName} {...rest} ref={ref} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.forwardRef(Text) as typeof Text;
|
export default React.forwardRef(Text) as <TTag extends ElementType = 'span'>(props: TextProps<TTag>) => JSX.Element;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type Props = {
|
|||||||
* This component is responsible for loading the next page of courses when the user scrolls to the bottom of the page.
|
* This component is responsible for loading the next page of courses when the user scrolls to the bottom of the page.
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export default function AutoLoad({ addRows }: Props): JSX.Element {
|
export default function AutoLoad({ addRows }: Props): JSX.Element | null {
|
||||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||||
const [status, setStatus] = useState<AutoLoadStatusType>(AutoLoadStatus.IDLE);
|
const [status, setStatus] = useState<AutoLoadStatusType>(AutoLoadStatus.IDLE);
|
||||||
|
|
||||||
|
|||||||
@@ -53,40 +53,41 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
const [status, setStatus] = React.useState<DataStatusType>(DataStatus.LOADING);
|
const [status, setStatus] = React.useState<DataStatusType>(DataStatus.LOADING);
|
||||||
const ref = React.useRef<HighchartsReact.RefObject>(null);
|
const ref = React.useRef<HighchartsReact.RefObject>(null);
|
||||||
|
|
||||||
const chartData = React.useMemo(() => {
|
// const chartData = React.useMemo(() => {
|
||||||
if (status === DataStatus.FOUND && distributions[semester]) {
|
// if (status === DataStatus.FOUND && distributions[semester]) {
|
||||||
return Object.entries(distributions[semester]).map(([grade, count]) => ({
|
// return Object.entries(distributions[semester]).map(([grade, count]) => ({
|
||||||
y: count,
|
// y: count,
|
||||||
color: GRADE_COLORS[grade as LetterGrade],
|
// color: GRADE_COLORS[grade as LetterGrade],
|
||||||
}));
|
// }));
|
||||||
}
|
// }
|
||||||
return Array(12).fill(0);
|
// return Array(12).fill(0);
|
||||||
}, [distributions, semester, status]);
|
// }, [distributions, semester, status]);
|
||||||
|
// const chartData: unknown[] = [];
|
||||||
|
|
||||||
React.useEffect(() => {
|
// React.useEffect(() => {
|
||||||
const fetchInitialData = async () => {
|
// const fetchInitialData = async () => {
|
||||||
try {
|
// try {
|
||||||
const [aggregateDist, semesters] = await queryAggregateDistribution(course);
|
// const [aggregateDist, semesters] = await queryAggregateDistribution(course);
|
||||||
const initialDistributions: Record<string, Distribution> = { Aggregate: aggregateDist };
|
// const initialDistributions: Record<string, Distribution> = { Aggregate: aggregateDist };
|
||||||
const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester));
|
// const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester));
|
||||||
const semesterDistributions = await Promise.all(semesterPromises);
|
// const semesterDistributions = await Promise.all(semesterPromises);
|
||||||
semesters.forEach((semester, i) => {
|
// semesters.forEach((semester, i) => {
|
||||||
initialDistributions[`${semester.season} ${semester.year}`] = semesterDistributions[i];
|
// initialDistributions[`${semester.season} ${semester.year}`] = semesterDistributions[i];
|
||||||
});
|
// });
|
||||||
setDistributions(initialDistributions);
|
// setDistributions(initialDistributions);
|
||||||
setStatus(DataStatus.FOUND);
|
// setStatus(DataStatus.FOUND);
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
console.error(e);
|
// console.error(e);
|
||||||
if (e instanceof NoDataError) {
|
// if (e instanceof NoDataError) {
|
||||||
setStatus(DataStatus.NOT_FOUND);
|
// setStatus(DataStatus.NOT_FOUND);
|
||||||
} else {
|
// } else {
|
||||||
setStatus(DataStatus.ERROR);
|
// setStatus(DataStatus.ERROR);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
fetchInitialData();
|
// fetchInitialData();
|
||||||
}, [course]);
|
// }, [course]);
|
||||||
|
|
||||||
const handleSelectSemester = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleSelectSemester = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
setSemester(event.target.value);
|
setSemester(event.target.value);
|
||||||
@@ -129,7 +130,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
{
|
{
|
||||||
type: 'column',
|
type: 'column',
|
||||||
name: 'Grades',
|
name: 'Grades',
|
||||||
data: chartData,
|
// data: chartData,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -156,7 +157,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
<>
|
<>
|
||||||
<div className='w-full flex items-center justify-center gap-[12px]'>
|
<div className='w-full flex items-center justify-center gap-[12px]'>
|
||||||
<Text variant='p'>Grade distribution for {`${course.department} ${course.number}`}</Text>
|
<Text variant='p'>Grade distribution for {`${course.department} ${course.number}`}</Text>
|
||||||
<select
|
{/* <select
|
||||||
className='border border rounded-[4px] border-solid px-[12px] py-[8px]'
|
className='border border rounded-[4px] border-solid px-[12px] py-[8px]'
|
||||||
onChange={handleSelectSemester}
|
onChange={handleSelectSemester}
|
||||||
>
|
>
|
||||||
@@ -180,7 +181,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
{semester}
|
{semester}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select> */}
|
||||||
</div>
|
</div>
|
||||||
<HighchartsReact ref={ref} highcharts={Highcharts} options={chartOptions} />
|
<HighchartsReact ref={ref} highcharts={Highcharts} options={chartOptions} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
|||||||
const formattedUniqueId = uniqueId.toString().padStart(5, '0');
|
const formattedUniqueId = uniqueId.toString().padStart(5, '0');
|
||||||
|
|
||||||
const getInstructorFullName = (instructor: Instructor) => {
|
const getInstructorFullName = (instructor: Instructor) => {
|
||||||
const { firstName, lastName } = instructor;
|
const { firstName = '', lastName = '' } = instructor;
|
||||||
if (firstName === '') return capitalizeString(lastName);
|
if (firstName === '') return capitalizeString(lastName);
|
||||||
return `${capitalizeString(firstName)} ${capitalizeString(lastName)}`;
|
return `${capitalizeString(firstName)} ${capitalizeString(lastName)}`;
|
||||||
};
|
};
|
||||||
@@ -76,7 +76,7 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
|||||||
|
|
||||||
const handleOpenCES = async () => {
|
const handleOpenCES = async () => {
|
||||||
const openTabs = instructors.map(instructor => {
|
const openTabs = instructors.map(instructor => {
|
||||||
let { firstName, lastName } = instructor;
|
let { firstName = '', lastName = '' } = instructor;
|
||||||
firstName = capitalizeString(firstName);
|
firstName = capitalizeString(firstName);
|
||||||
lastName = capitalizeString(lastName);
|
lastName = capitalizeString(lastName);
|
||||||
return openCESPage({ instructorFirstName: firstName, instructorLastName: lastName });
|
return openCESPage({ instructorFirstName: firstName, instructorLastName: lastName });
|
||||||
@@ -134,9 +134,12 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
|||||||
.flatMap((el, i) => (i === 0 ? [el] : [', ', el]))}
|
.flatMap((el, i) => (i === 0 ? [el] : [', ', el]))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<div className='flex gap-1'>
|
<div className='flex items-center gap-1'>
|
||||||
{flags.map(flag => (
|
{flags.map((flag: string) => (
|
||||||
<Chip key={flagMap[flag]} label={flagMap[flag]} />
|
<Chip
|
||||||
|
key={flagMap[flag as keyof typeof flagMap]}
|
||||||
|
label={flagMap[flag as keyof typeof flagMap]}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +148,11 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
|||||||
const daysString = meeting.getDaysString({ format: 'long', separator: 'long' });
|
const daysString = meeting.getDaysString({ format: 'long', separator: 'long' });
|
||||||
const timeString = meeting.getTimeString({ separator: ' to ', capitalize: false });
|
const timeString = meeting.getTimeString({ separator: ' to ', capitalize: false });
|
||||||
return (
|
return (
|
||||||
<Text key={daysString + timeString + meeting.location.building} variant='h4' as='p'>
|
<Text
|
||||||
|
key={daysString + timeString + (meeting.location?.building ?? '')}
|
||||||
|
variant='h4'
|
||||||
|
as='p'
|
||||||
|
>
|
||||||
{daysString} {timeString}
|
{daysString} {timeString}
|
||||||
{meeting.location && (
|
{meeting.location && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const RECRUIT_FROM_DEPARTMENTS = ['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD'];
|
|||||||
* This adds a new column to the course catalog table header.
|
* This adds a new column to the course catalog table header.
|
||||||
* @returns a react portal to the new column or null if the column has not been created yet.
|
* @returns a react portal to the new column or null if the column has not been created yet.
|
||||||
*/
|
*/
|
||||||
export default function RecruitmentBanner(): JSX.Element {
|
export default function RecruitmentBanner(): JSX.Element | null {
|
||||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import styles from './TableRow.module.scss';
|
|||||||
interface Props {
|
interface Props {
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
row: ScrapedRow;
|
row: ScrapedRow;
|
||||||
onClick: (...args: any[]) => any;
|
onClick: (...args: unknown[]) => unknown;
|
||||||
activeSchedule?: UserSchedule;
|
activeSchedule?: UserSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
|||||||
const { element, course } = row;
|
const { element, course } = row;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
element.classList.add(styles.row);
|
element.classList.add(styles.row!);
|
||||||
element.classList.add('group');
|
element.classList.add('group');
|
||||||
const portalContainer = document.createElement('td');
|
const portalContainer = document.createElement('td');
|
||||||
// portalContainer.style.textAlign = 'right';
|
// portalContainer.style.textAlign = 'right';
|
||||||
@@ -39,12 +39,12 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
portalContainer.remove();
|
portalContainer.remove();
|
||||||
element.classList.remove(styles.row);
|
element.classList.remove(styles.row!);
|
||||||
};
|
};
|
||||||
}, [element]);
|
}, [element]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
element.classList[isSelected ? 'add' : 'remove'](styles.selectedRow);
|
element.classList[isSelected ? 'add' : 'remove'](styles.selectedRow!);
|
||||||
}, [isSelected, element.classList]);
|
}, [isSelected, element.classList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,10 +52,10 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
|||||||
|
|
||||||
const isInSchedule = activeSchedule.containsCourse(course);
|
const isInSchedule = activeSchedule.containsCourse(course);
|
||||||
|
|
||||||
element.classList[isInSchedule ? 'add' : 'remove'](styles.inActiveSchedule);
|
element.classList[isInSchedule ? 'add' : 'remove'](styles.inActiveSchedule!);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element.classList.remove(styles.inActiveSchedule);
|
element.classList.remove(styles.inActiveSchedule!);
|
||||||
};
|
};
|
||||||
}, [activeSchedule, course, element.classList]);
|
}, [activeSchedule, course, element.classList]);
|
||||||
|
|
||||||
@@ -72,11 +72,11 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflict);
|
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflict!);
|
||||||
setConflicts(conflicts);
|
setConflicts(conflicts);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element.classList.remove(styles.isConflict);
|
element.classList.remove(styles.isConflict!);
|
||||||
setConflicts([]);
|
setConflicts([]);
|
||||||
};
|
};
|
||||||
}, [activeSchedule, course, element.classList]);
|
}, [activeSchedule, course, element.classList]);
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ export default function TableSubheading({ row }: Props): JSX.Element | null {
|
|||||||
const { element } = row;
|
const { element } = row;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
element.classList.add(styles.subheader);
|
element.classList.add(styles.subheader!);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element.classList.remove(styles.subheader);
|
element.classList.remove(styles.subheader!);
|
||||||
};
|
};
|
||||||
}, [element]);
|
}, [element]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { HexColor } from '@shared/types/Color';
|
|||||||
import type { Course, StatusType } from '@shared/types/Course';
|
import type { Course, StatusType } from '@shared/types/Course';
|
||||||
import type { CourseMeeting } from '@shared/types/CourseMeeting';
|
import type { CourseMeeting } from '@shared/types/CourseMeeting';
|
||||||
import { colors } from '@shared/types/ThemeColors';
|
import { colors } from '@shared/types/ThemeColors';
|
||||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import type { CalendarCourseCellProps } from '@views/components/calendar/CalendarCourseCell/CalendarCourseCell';
|
import type { CalendarCourseCellProps } from '@views/components/calendar/CalendarCourseCell/CalendarCourseCell';
|
||||||
|
|
||||||
import useSchedules from './useSchedules';
|
import useSchedules from './useSchedules';
|
||||||
@@ -13,6 +13,8 @@ const dayToNumber = {
|
|||||||
Wednesday: 2,
|
Wednesday: 2,
|
||||||
Thursday: 3,
|
Thursday: 3,
|
||||||
Friday: 4,
|
Friday: 4,
|
||||||
|
Saturday: 5,
|
||||||
|
Sunday: 6,
|
||||||
} as const satisfies Record<string, number>;
|
} as const satisfies Record<string, number>;
|
||||||
|
|
||||||
interface CalendarGridPoint {
|
interface CalendarGridPoint {
|
||||||
@@ -42,7 +44,7 @@ export interface CalendarGridCourse {
|
|||||||
*/
|
*/
|
||||||
export interface FlattenedCourseSchedule {
|
export interface FlattenedCourseSchedule {
|
||||||
courseCells: CalendarGridCourse[];
|
courseCells: CalendarGridCourse[];
|
||||||
activeSchedule?: UserSchedule;
|
activeSchedule: UserSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,46 +61,22 @@ export const convertMinutesToIndex = (minutes: number): number => Math.floor((mi
|
|||||||
export function useFlattenedCourseSchedule(): FlattenedCourseSchedule {
|
export function useFlattenedCourseSchedule(): FlattenedCourseSchedule {
|
||||||
const [activeSchedule] = useSchedules();
|
const [activeSchedule] = useSchedules();
|
||||||
|
|
||||||
if (!activeSchedule) {
|
const processedCourses = activeSchedule.courses
|
||||||
return {
|
.flatMap(course => {
|
||||||
courseCells: [] as CalendarGridCourse[],
|
|
||||||
activeSchedule: new UserSchedule({
|
|
||||||
courses: [],
|
|
||||||
id: 'error',
|
|
||||||
name: 'Something may have went wrong',
|
|
||||||
hours: 0,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
}),
|
|
||||||
} satisfies FlattenedCourseSchedule;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSchedule.courses.length === 0) {
|
|
||||||
return {
|
|
||||||
courseCells: [] as CalendarGridCourse[],
|
|
||||||
activeSchedule,
|
|
||||||
} satisfies FlattenedCourseSchedule;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { courses, name, hours } = activeSchedule;
|
|
||||||
|
|
||||||
const processedCourses = courses
|
|
||||||
.flatMap((course: Course) => {
|
|
||||||
const { status, courseDeptAndInstr, meetings } = extractCourseInfo(course);
|
const { status, courseDeptAndInstr, meetings } = extractCourseInfo(course);
|
||||||
|
|
||||||
if (meetings.length === 0) {
|
if (meetings.length === 0) {
|
||||||
return processAsyncCourses({ courseDeptAndInstr, status, course });
|
return processAsyncCourses({ courseDeptAndInstr, status, course });
|
||||||
}
|
}
|
||||||
|
|
||||||
return meetings.flatMap((meeting: CourseMeeting) =>
|
return meetings.flatMap(meeting => processInPersonMeetings(meeting, courseDeptAndInstr, status, course));
|
||||||
processInPersonMeetings(meeting, { courseDeptAndInstr, status, course })
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.sort(sortCourses);
|
.sort(sortCourses);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
courseCells: processedCourses as CalendarGridCourse[],
|
courseCells: processedCourses,
|
||||||
activeSchedule: { name, courses, hours } as UserSchedule,
|
activeSchedule,
|
||||||
} satisfies FlattenedCourseSchedule;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,7 +87,7 @@ function extractCourseInfo(course: Course) {
|
|||||||
status,
|
status,
|
||||||
schedule: { meetings },
|
schedule: { meetings },
|
||||||
} = course;
|
} = course;
|
||||||
const courseDeptAndInstr = `${course.department} ${course.number} – ${course.instructors[0].lastName}`;
|
const courseDeptAndInstr = `${course.department} ${course.number} – ${course.instructors[0]?.lastName}`;
|
||||||
|
|
||||||
return { status, courseDeptAndInstr, meetings, course };
|
return { status, courseDeptAndInstr, meetings, course };
|
||||||
}
|
}
|
||||||
@@ -149,12 +127,17 @@ function processAsyncCourses({
|
|||||||
/**
|
/**
|
||||||
* Function to process each in-person class into its distinct meeting objects for calendar grid
|
* Function to process each in-person class into its distinct meeting objects for calendar grid
|
||||||
*/
|
*/
|
||||||
function processInPersonMeetings(meeting: CourseMeeting, { courseDeptAndInstr, status, course }) {
|
function processInPersonMeetings(
|
||||||
|
meeting: CourseMeeting,
|
||||||
|
courseDeptAndInstr: string,
|
||||||
|
status: StatusType,
|
||||||
|
course: Course
|
||||||
|
) {
|
||||||
const { days, startTime, endTime, location } = meeting;
|
const { days, startTime, endTime, location } = meeting;
|
||||||
const midnightIndex = 1440;
|
const midnightIndex = 1440;
|
||||||
const normalizingTimeFactor = 720;
|
const normalizingTimeFactor = 720;
|
||||||
const time = meeting.getTimeString({ separator: '-', capitalize: true });
|
const time = meeting.getTimeString({ separator: '-', capitalize: true });
|
||||||
const timeAndLocation = `${time} - ${location ? location.building : 'WB'}`;
|
const timeAndLocation = `${time}${location ? ` - ${location.building}` : ''}`;
|
||||||
const normalizedStartTime = startTime >= midnightIndex ? startTime - normalizingTimeFactor : startTime;
|
const normalizedStartTime = startTime >= midnightIndex ? startTime - normalizingTimeFactor : startTime;
|
||||||
const normalizedEndTime = endTime >= midnightIndex ? endTime - normalizingTimeFactor : endTime;
|
const normalizedEndTime = endTime >= midnightIndex ? endTime - normalizingTimeFactor : endTime;
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { useEffect } from 'react';
|
|||||||
* @param key the key to listen for
|
* @param key the key to listen for
|
||||||
* @param callback the callback to call when the key is pressed
|
* @param callback the callback to call when the key is pressed
|
||||||
*/
|
*/
|
||||||
export function useKeyPress(key: string, callback: (...args: any[]) => void): void {
|
export function useKeyPress(key: string, callback: (event: KeyboardEvent) => void): void {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === key) {
|
if (event.key === key) {
|
||||||
callback();
|
callback(event);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
|||||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
let schedulesCache = [];
|
let schedulesCache: UserSchedule[] = [];
|
||||||
let activeIndexCache = -1;
|
let activeIndexCache = -1;
|
||||||
let initialLoad = true;
|
let initialLoad = true;
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export class CourseCatalogScraper {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return names.map(fullName => {
|
return names.map(fullName => {
|
||||||
const [lastName, rest] = fullName.split(',').map(s => s.trim());
|
const [lastName, rest = ''] = fullName.split(',').map(s => s.trim());
|
||||||
const [firstName, middleInitial] = rest.split(' ');
|
const [firstName, middleInitial] = rest.split(' ');
|
||||||
|
|
||||||
return new Instructor({
|
return new Instructor({
|
||||||
@@ -334,13 +334,11 @@ export class CourseCatalogScraper {
|
|||||||
const schedule = new CourseSchedule();
|
const schedule = new CourseSchedule();
|
||||||
|
|
||||||
for (let i = 0; i < dayLines.length; i += 1) {
|
for (let i = 0; i < dayLines.length; i += 1) {
|
||||||
schedule.meetings.push(
|
const dayText = dayLines[i]?.textContent || '';
|
||||||
CourseSchedule.parse(
|
const hourText = hourLines[i]?.textContent || '';
|
||||||
dayLines[i].textContent || '',
|
const locationText = locLines[i]?.textContent || '';
|
||||||
hourLines[i].textContent || '',
|
|
||||||
locLines[i].textContent || ''
|
schedule.meetings.push(CourseSchedule.parse(dayText, hourText, locationText));
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return schedule;
|
return schedule;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function queryAggregateDistribution(course: Course): Promise<[Distr
|
|||||||
|
|
||||||
let row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
let row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
||||||
res.columns.forEach((col, i) => {
|
res.columns.forEach((col, i) => {
|
||||||
row[res.columns[i]] = res.values[0][i];
|
row[col as keyof CourseSQLRow] = res.values[0]![i]! as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
const distribution: Distribution = {
|
const distribution: Distribution = {
|
||||||
@@ -48,6 +48,9 @@ export async function queryAggregateDistribution(course: Course): Promise<[Distr
|
|||||||
|
|
||||||
rawSemesters.forEach((sem: string) => {
|
rawSemesters.forEach((sem: string) => {
|
||||||
const [season, year] = sem.split(' ');
|
const [season, year] = sem.split(' ');
|
||||||
|
if (!season || !year) {
|
||||||
|
throw new Error('Season is undefined');
|
||||||
|
}
|
||||||
semesters.push({ year: parseInt(year, 10), season: season as Semester['season'] });
|
semesters.push({ year: parseInt(year, 10), season: season as Semester['season'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ export async function querySemesterDistribution(course: Course, semester: Semest
|
|||||||
|
|
||||||
let row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
let row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
||||||
res.columns.forEach((col, i) => {
|
res.columns.forEach((col, i) => {
|
||||||
row[res.columns[i]] = res.values[0][i];
|
row[col as keyof CourseSQLRow] = res.values[0]![i]! as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
const distribution: Distribution = {
|
const distribution: Distribution = {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
"types": ["vite/client", "unplugin-icons/types/react", "node"],
|
"types": ["vite/client", "unplugin-icons/types/react", "node"],
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"isolatedModules": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"src/*": ["src/*"],
|
"src/*": ["src/*"],
|
||||||
"@assets/*": ["src/assets/*"],
|
"@assets/*": ["src/assets/*"],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react-swc';
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import UnoCSS from 'unocss/vite';
|
import UnoCSS from 'unocss/vite';
|
||||||
import Icons from 'unplugin-icons/vite';
|
import Icons from 'unplugin-icons/vite';
|
||||||
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
|
import type { Plugin, ResolvedConfig, Rollup, ViteDevServer } from 'vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import inspect from 'vite-plugin-inspect';
|
import inspect from 'vite-plugin-inspect';
|
||||||
|
|
||||||
@@ -26,9 +26,11 @@ window.$RefreshSig$ = () => (type) => type
|
|||||||
window.__vite_plugin_react_preamble_installed__ = true
|
window.__vite_plugin_react_preamble_installed__ = true
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const isOutputChunk = (input: Rollup.OutputAsset | Rollup.OutputChunk): input is Rollup.OutputChunk => 'code' in input;
|
||||||
|
|
||||||
const renameFile = (source: string, destination: string): Plugin => {
|
const renameFile = (source: string, destination: string): Plugin => {
|
||||||
if (typeof source !== 'string' || typeof destination !== 'string') {
|
if (typeof source !== 'string' || typeof destination !== 'string') {
|
||||||
return;
|
throw new Error('Invalid arguments for renameFile');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -36,25 +38,30 @@ const renameFile = (source: string, destination: string): Plugin => {
|
|||||||
apply: 'build',
|
apply: 'build',
|
||||||
enforce: 'post',
|
enforce: 'post',
|
||||||
generateBundle(options, bundle) {
|
generateBundle(options, bundle) {
|
||||||
if (!bundle[source]) return;
|
const file = bundle[source];
|
||||||
bundle[source].fileName = destination;
|
if (!file) return;
|
||||||
|
file.fileName = destination;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const fixManifestOptionsPage = () => ({
|
const fixManifestOptionsPage = (): Plugin => ({
|
||||||
name: 'fix-manifest-options-page',
|
name: 'fix-manifest-options-page',
|
||||||
apply: 'build' as const,
|
apply: 'build',
|
||||||
enforce: 'post' as const,
|
enforce: 'post',
|
||||||
generateBundle(_, bundle) {
|
generateBundle(_, bundle) {
|
||||||
for (const fileName of Object.keys(bundle)) {
|
for (const fileName of Object.keys(bundle)) {
|
||||||
if (fileName.startsWith('assets/crx-manifest')) {
|
if (fileName.startsWith('assets/crx-manifest')) {
|
||||||
const chunk = bundle[fileName];
|
const chunk = bundle[fileName];
|
||||||
|
if (!chunk) continue;
|
||||||
|
|
||||||
|
if (isOutputChunk(chunk)) {
|
||||||
chunk.code = chunk.code.replace(
|
chunk.code = chunk.code.replace(
|
||||||
/"options_page":"src\/pages\/options\/index.html"/,
|
/"options_page":"src\/pages\/options\/index.html"/,
|
||||||
`"options_page":"options.html"`
|
`"options_page":"options.html"`
|
||||||
);
|
);
|
||||||
break;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -126,6 +133,7 @@ export default defineConfig({
|
|||||||
return code;
|
return code;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
renameFile('src/pages/debug/index.html', 'debug.html'),
|
||||||
renameFile('src/pages/options/index.html', 'options.html'),
|
renameFile('src/pages/options/index.html', 'options.html'),
|
||||||
renameFile('src/pages/calendar/index.html', 'calendar.html'),
|
renameFile('src/pages/calendar/index.html', 'calendar.html'),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user