diff --git a/package.json b/package.json index 4e0ca5cd..6c883fdd 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,13 @@ "private": true, "homepage": "https://gt-scheduler.org/", "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", "@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-fontawesome": "^0.2.0", + "@mui/material": "^5.15.1", "@sentry/react": "^6.12.0", "@sentry/tracing": "^6.12.0", "@types/react-map-gl": "^6.1.3", diff --git a/src/components/AppDataLoader/index.tsx b/src/components/AppDataLoader/index.tsx index 302d9ec1..ce7402d6 100644 --- a/src/components/AppDataLoader/index.tsx +++ b/src/components/AppDataLoader/index.tsx @@ -1,5 +1,6 @@ import produce, { Immutable, Draft, original, castDraft } from 'immer'; import React, { useCallback, useMemo } from 'react'; +import useLocalStorageState from 'use-local-storage-state'; import { ScheduleContextValue, @@ -293,6 +294,10 @@ function ContextProvider({ currentVersion, }); + const [adjustedCredits, setAdjustedCredits] = useLocalStorageState< + Record + >('adjustedCredits', { defaultValue: {} }); + // Memoize the context values so that they are stable const scheduleContextValue = useMemo( () => [ @@ -301,6 +306,7 @@ function ContextProvider({ oscar, currentVersion, allVersionNames, + adjustedCredits, ...castDraft(scheduleVersion.schedule), }, { @@ -312,6 +318,7 @@ function ContextProvider({ deleteVersion, renameVersion, cloneVersion, + setAdjustedCredits, }, ], [ @@ -319,6 +326,7 @@ function ContextProvider({ oscar, currentVersion, allVersionNames, + adjustedCredits, scheduleVersion.schedule, setTerm, patchSchedule, @@ -328,6 +336,7 @@ function ContextProvider({ deleteVersion, renameVersion, cloneVersion, + setAdjustedCredits, ] ); diff --git a/src/components/Course/index.tsx b/src/components/Course/index.tsx index cbefc376..b8f67064 100644 --- a/src/components/Course/index.tsx +++ b/src/components/Course/index.tsx @@ -1,16 +1,19 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faAngleDown, faAngleUp, faShareAlt, faPalette, + faPencilAlt, faPlus, faTrash, } from '@fortawesome/free-solid-svg-icons'; +import { Tooltip as ReactTooltip } from 'react-tooltip'; import { classes, getContentClassName } from '../../utils/misc'; import Cancellable from '../../utils/cancellable'; -import { ActionRow, Instructor, Palette, Prerequisite } from '..'; +import { ActionRow, CreditSlider, Instructor, Palette, Prerequisite } from '..'; import { ScheduleContext } from '../../contexts'; import { Course as CourseBean, Section } from '../../data/beans'; import { CourseGpa, CrawlerPrerequisites } from '../../types'; @@ -33,10 +36,18 @@ export default function Course({ const [prereqOpen, setPrereqOpen] = useState(false); const [paletteShown, setPaletteShown] = useState(false); const [gpaMap, setGpaMap] = useState(null); + const [creditSliderShown, setCreditSliderShown] = useState(false); const isSearching = Boolean(onAddCourse); const [ - { oscar, desiredCourses, pinnedCrns, excludedCrns, colorMap }, - { patchSchedule }, + { + oscar, + desiredCourses, + pinnedCrns, + excludedCrns, + colorMap, + adjustedCredits, + }, + { patchSchedule, setAdjustedCredits }, ] = useContext(ScheduleContext); useEffect(() => { @@ -132,21 +143,45 @@ export default function Course({ const prereqControl = ( nextPrereqOpen: boolean, + nextCreditSliderShown: boolean, nextExpanded: boolean ): void => { setPrereqOpen(nextPrereqOpen); + setCreditSliderShown(nextCreditSliderShown); setExpanded(nextExpanded); }; const prereqAction = { icon: faShareAlt, styling: { transform: 'rotate(90deg)' }, onClick: (): void => { - prereqControl(true, !prereqOpen ? true : !expanded); + prereqControl( + true, + false, + !prereqOpen || creditSliderShown ? true : !expanded + ); }, tooltip: 'View Prerequisites', id: `${course.id}-prerequisites`, }; + const sliderControl = ( + nextPrereqOpen: boolean, + nextCreditSliderShown: boolean, + nextExpanded: boolean + ): void => { + setPrereqOpen(nextPrereqOpen); + setCreditSliderShown(nextCreditSliderShown); + setExpanded(nextExpanded); + }; + const handleSliderChange = (newValue: number): void => { + const newAdjustedCredits = { + ...adjustedCredits, + [`${course.id}-${course.term}`]: newValue, + }; + setAdjustedCredits(newAdjustedCredits); + course.adjustedCredits = newValue; + }; + const pinnedSections = course.sections.filter((section) => pinnedCrns.includes(section.crn) ); @@ -154,6 +189,9 @@ export default function Course({ (credits, section) => credits + section.credits, 0 ); + const adjustableCredits = pinnedSections.some( + (section) => section.adjustableCredits + ); return (
prereqControl(false, !expanded), + onClick: (): void => prereqControl(false, false, !expanded), }, prereqAction, { @@ -207,7 +245,44 @@ export default function Course({ : 'N/A'} {totalCredits > 0 && ( - {totalCredits} Credits +
+ + {adjustableCredits + ? adjustedCredits[`${course.id}-${course.term}`] ?? 1 + : totalCredits}{' '} + Credits + + {adjustableCredits && ( +
+ { + sliderControl( + false, + true, + prereqOpen || !creditSliderShown ? true : !expanded + ); + }} + /> + + Adjust Credits + +
+ )} +
)}
)} @@ -222,7 +297,7 @@ export default function Course({ /> )} - {expanded && !prereqOpen && ( + {expanded && !prereqOpen && !creditSliderShown && (
{includedInstructors.map((name) => { let instructorGpa: number | undefined = 0; @@ -264,9 +339,22 @@ export default function Course({ )}
)} - {expanded && prereqOpen && prereqs !== null && ( + {expanded && !creditSliderShown && prereqOpen && prereqs !== null && ( )} + {expanded && creditSliderShown && !prereqOpen && ( +
+ { + handleSliderChange(newValue as number); + }} + /> +
+ )} ); } diff --git a/src/components/Course/stylesheet.scss b/src/components/Course/stylesheet.scss index 273c0eb1..31bc4f1d 100644 --- a/src/components/Course/stylesheet.scss +++ b/src/components/Course/stylesheet.scss @@ -22,6 +22,22 @@ } } + .adjustable-credits { + z-index: 0; + opacity: 0.6; + cursor: pointer; + + &:hover { + opacity: 1; + } + + .pencil-icon { + padding: 0px 0px 0px 6px; + outline: none; + border: none; + } + } + .palette { position: absolute; top: 0; diff --git a/src/components/CreditSlider/index.tsx b/src/components/CreditSlider/index.tsx new file mode 100644 index 00000000..33b7dbdd --- /dev/null +++ b/src/components/CreditSlider/index.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Slider from '@mui/material/Slider'; + +import './stylesheet.scss'; + +export type CreditSliderProps = { + value: number; + onChange: (event: Event, newValue: number | number[]) => void; +}; + +export default function CreditSlider({ + value, + onChange, +}: CreditSliderProps): React.ReactElement { + return ( + + ); +} diff --git a/src/components/CreditSlider/stylesheet.scss b/src/components/CreditSlider/stylesheet.scss new file mode 100644 index 00000000..98589b91 --- /dev/null +++ b/src/components/CreditSlider/stylesheet.scss @@ -0,0 +1,21 @@ +.slider { + color: white !important; + + .MuiSlider-mark { + background-color: transparent; + } + + .MuiSlider-thumb { + width: 15px; + height: 15px; + + &:hover { + box-shadow: 0px 0px 0px 8px rgba(255, 255, 255, 0.35); + } + } + + .MuiSlider-valueLabel { + background-color: #222; + opacity: 0.9; + } +} \ No newline at end of file diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 7485588f..48cd8503 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -27,7 +27,14 @@ export default function Header({ captureRef, }: HeaderProps): React.ReactElement { const [ - { term, oscar, pinnedCrns, allVersionNames, currentVersion }, + { + term, + oscar, + pinnedCrns, + allVersionNames, + currentVersion, + adjustedCredits, + }, { setTerm, setCurrentVersion, @@ -40,11 +47,25 @@ export default function Header({ const terms = useContext(TermsContext); const totalCredits = useMemo(() => { + const adjustedCourses = new Set(); return pinnedCrns.reduce((credits, crn) => { const crnSection = oscar.findSection(crn); - return credits + (crnSection != null ? crnSection.credits : 0); + if ( + crnSection !== undefined && + crnSection.adjustableCredits && + !adjustedCourses.has(`${crnSection.course.id}-${term}`) + ) { + adjustedCourses.add(`${crnSection.course.id}-${term}`); + return ( + credits + (adjustedCredits[`${crnSection.course.id}-${term}`] ?? 1) + ); + } + if (!crnSection?.adjustableCredits) { + return credits + (crnSection != null ? crnSection.credits : 0); + } + return credits; }, 0); - }, [pinnedCrns, oscar]); + }, [pinnedCrns, oscar, adjustedCredits, term]); const headerActionBarProps = useHeaderActionBarProps(captureRef); diff --git a/src/components/HeaderDisplay/stylesheet.scss b/src/components/HeaderDisplay/stylesheet.scss index 775598cd..03938ab6 100644 --- a/src/components/HeaderDisplay/stylesheet.scss +++ b/src/components/HeaderDisplay/stylesheet.scss @@ -27,6 +27,7 @@ padding: 12px; display: flex; align-items: center; + min-width: 115px; } } diff --git a/src/components/index.ts b/src/components/index.ts index 47d8b7ab..88e6242b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -27,3 +27,4 @@ export { default as EventBlocks } from './EventBlocks'; export { default as Attribution } from './Attribution'; export { default as Event } from './Event'; export { default as CourseNavMenu } from './CourseNavMenu'; +export { default as CreditSlider } from './CreditSlider'; diff --git a/src/contexts/schedule.ts b/src/contexts/schedule.ts index 12b0a8d5..6ff1b534 100644 --- a/src/contexts/schedule.ts +++ b/src/contexts/schedule.ts @@ -10,6 +10,7 @@ type ExtraData = { term: string; currentVersion: string; allVersionNames: { id: string; name: string }[]; + adjustedCredits: Record; // `oscar` is included below as a separate type }; @@ -28,6 +29,7 @@ export type ScheduleContextSetters = { deleteVersion: (id: string) => void; renameVersion: (id: string, newName: string) => void; cloneVersion: (id: string, newName: string) => void; + setAdjustedCredits: (next: Record) => void; }; export type ScheduleContextValue = [ ScheduleContextData, @@ -39,6 +41,7 @@ export const ScheduleContext = React.createContext([ currentVersion: '', allVersionNames: [], oscar: EMPTY_OSCAR, + adjustedCredits: {}, ...defaultSchedule, }, { @@ -106,5 +109,13 @@ export const ScheduleContext = React.createContext([ }, }); }, + setAdjustedCredits: (next: Record): void => { + throw new ErrorWithFields({ + message: 'empty ScheduleContext.setAdjustedCredits value being used', + fields: { + next, + }, + }); + }, }, ]); diff --git a/src/data/beans/Course.ts b/src/data/beans/Course.ts index bf173364..68f96708 100644 --- a/src/data/beans/Course.ts +++ b/src/data/beans/Course.ts @@ -52,6 +52,8 @@ export default class Course { sections: Section[]; + adjustedCredits: number | undefined; + prereqs: CrawlerPrerequisites | undefined; hasLab: boolean; @@ -107,6 +109,11 @@ export default class Course { } } ); + this.adjustedCredits = this.sections.some( + (section) => section.adjustableCredits + ) + ? 0 + : undefined; this.prereqs = prereqs; const onlyLectures = this.sections.filter( diff --git a/src/data/beans/Section.ts b/src/data/beans/Section.ts index b7f8ea02..48691af8 100644 --- a/src/data/beans/Section.ts +++ b/src/data/beans/Section.ts @@ -46,6 +46,8 @@ export default class Section { scheduleType: string; + adjustableCredits: boolean; + campus: string; deliveryMode: string | undefined; @@ -87,6 +89,7 @@ export default class Section { this.seating = [[], 0]; this.credits = credits; this.scheduleType = oscar.scheduleTypes[scheduleTypeIndex] ?? 'unknown'; + this.adjustableCredits = this.scheduleType.includes('Directed Study'); this.campus = oscar.campuses[campusIndex] ?? 'unknown'; const attributes = attributeIndices .map((attributeIndex) => oscar.attributes[attributeIndex])