diff --git a/apps/backend/package.json b/apps/backend/package.json index b28fb25e8..d1b443433 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -32,9 +32,11 @@ "dependencies": { "@apollo/server": "^4.10.5", "@apollo/server-plugin-response-cache": "^4.1.3", + "@apollo/utils.keyvadapter": "^3.1.0", "@escape.tech/graphql-armor": "^3.0.1", "@graphql-tools/schema": "^10.0.4", "@graphql-tools/utils": "^10.3.2", + "@keyv/redis": "^3.0.1", "@repo/common": "*", "compression": "^1.7.4", "connect-redis": "^7.1.1", @@ -46,6 +48,7 @@ "graphql-modules": "^2.3.0", "graphql-type-json": "^0.3.2", "helmet": "^7.1.0", + "keyv": "^5.1.0", "lodash": "^4.17.21", "mongodb": "^6.8.0", "mongoose": "^8.5.1", diff --git a/apps/backend/src/bootstrap/loaders/apollo.ts b/apps/backend/src/bootstrap/loaders/apollo.ts index ff8544a66..1f74a230c 100644 --- a/apps/backend/src/bootstrap/loaders/apollo.ts +++ b/apps/backend/src/bootstrap/loaders/apollo.ts @@ -18,16 +18,21 @@ class RedisCache implements KeyValueCache { } async get(key: string) { - return (await this.client.get(this.prefix + key)) ?? undefined; + const value = await this.client.get(this.prefix + key); + + return value ?? undefined; } - async set(key: string, value: string) { - // ttl options are intentionally ignored because we will invalidate cache in update script + async set(key: string, value: string | null) { + if (!value) return; + await this.client.set(this.prefix + key, value); } async delete(key: string) { - return (await this.client.del(this.prefix + key)) === 1; + const success = await this.client.del(this.prefix + key); + + return success === 1; } } @@ -50,7 +55,9 @@ export default async (redis: RedisClientType) => { plugins: [ ...protection.plugins, ApolloServerPluginLandingPageLocalDefault({ includeCookies: true }), - ApolloServerPluginCacheControl({ calculateHttpHeaders: false }), + ApolloServerPluginCacheControl({ + calculateHttpHeaders: false, + }), responseCachePlugin(), ], // TODO(production): Disable introspection in production diff --git a/apps/backend/src/bootstrap/loaders/redis.ts b/apps/backend/src/bootstrap/loaders/redis.ts index 9f4b9e524..7dfa9afd5 100644 --- a/apps/backend/src/bootstrap/loaders/redis.ts +++ b/apps/backend/src/bootstrap/loaders/redis.ts @@ -3,7 +3,11 @@ import { RedisClientType, createClient } from "redis"; import { config } from "../../config"; export default async (): Promise => { - return (await createClient({ + const client = createClient({ url: config.redisUri, - }).connect()) as RedisClientType; + }); + + await client.connect(); + + return client as RedisClientType; }; diff --git a/apps/backend/src/modules/catalog/resolver.ts b/apps/backend/src/modules/catalog/resolver.ts index 639466cef..78bdd3a3d 100644 --- a/apps/backend/src/modules/catalog/resolver.ts +++ b/apps/backend/src/modules/catalog/resolver.ts @@ -3,8 +3,12 @@ import { CatalogModule } from "./generated-types/module-types"; const resolvers: CatalogModule.Resolvers = { Query: { - catalog: (_, { term }, __, info) => - getCatalog(term.year, term.semester, info), + catalog: async (_, { term }, __, info) => { + // const cacheControl = cacheControlFromInfo(info); + // cacheControl.setCacheHint({ maxAge: 300 }); + + return await getCatalog(term.year, term.semester, info); + }, }, }; diff --git a/apps/backend/src/modules/class/controller.ts b/apps/backend/src/modules/class/controller.ts index 083daa7c3..691c60449 100644 --- a/apps/backend/src/modules/class/controller.ts +++ b/apps/backend/src/modules/class/controller.ts @@ -26,17 +26,16 @@ export const getSecondarySections = async ( semester: string, subject: string, courseNumber: string, - classNumber: string + number: string ) => { - return await SectionModel.find({ + const sections = await SectionModel.find({ "class.course.subjectArea.code": subject, "class.course.catalogNumber.formatted": courseNumber, "class.session.term.name": `${year} ${semester}`, - "class.number": classNumber, - "association.primary": false, - }) - .lean() - .then((sections) => sections.map(formatSection)); + "class.number": { $regex: `^(${number[number.length - 1]}|999)` }, + }).lean(); + + return sections.map(formatSection); }; export const getPrimarySection = async ( @@ -51,7 +50,6 @@ export const getPrimarySection = async ( "class.course.catalogNumber.formatted": courseNumber, "class.session.term.name": `${year} ${semester}`, "class.number": classNumber, - "association.primary": true, }).lean(); if (!section) return null; @@ -64,15 +62,13 @@ export const getSection = async ( semester: string, subject: string, courseNumber: string, - classNumber: string, - sectionNumber: string + number: string ) => { const section = await SectionModel.findOne({ "class.course.subjectArea.code": subject, "class.course.catalogNumber.formatted": courseNumber, "class.session.term.name": `${year} ${semester}`, - "class.number": classNumber, - number: sectionNumber, + number: number, }).lean(); if (!section) return null; diff --git a/apps/backend/src/modules/class/resolver.ts b/apps/backend/src/modules/class/resolver.ts index 552a38ce6..c0038a20e 100644 --- a/apps/backend/src/modules/class/resolver.ts +++ b/apps/backend/src/modules/class/resolver.ts @@ -25,16 +25,12 @@ const resolvers: ClassModule.Resolvers = { return _class as unknown as ClassModule.Class; }, - section: async ( - _, - { subject, courseNumber, classNumber, number, year, semester } - ) => { + section: async (_, { subject, courseNumber, number, year, semester }) => { const section = await getSection( year, semester, subject, courseNumber, - classNumber, number ); diff --git a/apps/backend/src/modules/course/controller.ts b/apps/backend/src/modules/course/controller.ts index 8632a0ee0..732d99906 100644 --- a/apps/backend/src/modules/course/controller.ts +++ b/apps/backend/src/modules/course/controller.ts @@ -58,8 +58,6 @@ export const getClassesByCourse = async ( }; export const getAssociatedCourses = async (courses: string[]) => { - console.log(courses); - const queries = courses.map((course) => { const split = course.split(" "); @@ -78,8 +76,6 @@ export const getAssociatedCourses = async (courses: string[]) => { .sort({ fromDate: -1 }) .lean(); - console.log(associatedCourses); - return ( associatedCourses // TODO: Properly filter out duplicates in the query diff --git a/apps/backend/src/modules/schedule/formatter.ts b/apps/backend/src/modules/schedule/formatter.ts index e46ed1427..e4e8e6e8a 100644 --- a/apps/backend/src/modules/schedule/formatter.ts +++ b/apps/backend/src/modules/schedule/formatter.ts @@ -35,8 +35,6 @@ export const formatSchedule = async (schedule: ScheduleType) => { classes, year: schedule.year, semester: schedule.semester, - beginDate: schedule.beginDate, - endDate: schedule.endDate, term: null, events: schedule.events, } as IntermediateSchedule; diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 12b9fe70e..4fa01c633 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -14,6 +14,7 @@ import Enrollment from "@/app/Enrollment"; import Grades from "@/app/Grades"; import Landing from "@/app/Landing"; import Layout from "@/components/Layout"; +import PinsProvider from "@/components/PinsProvider"; const Class = { Enrollment: lazy(() => import("@/components/Class/Enrollment")), @@ -177,7 +178,9 @@ export default function App() { return ( - + + + ); diff --git a/apps/frontend/src/app/Schedules/index.tsx b/apps/frontend/src/app/Schedules/index.tsx index 0ff99b0b6..3ccfe0927 100644 --- a/apps/frontend/src/app/Schedules/index.tsx +++ b/apps/frontend/src/app/Schedules/index.tsx @@ -19,28 +19,28 @@ export default function Schedules() { if (!user) return <>; - if (schedules) { - return ( - - - {schedules?.map((schedule) => ( -
- {schedule.name} -
- ))} -
- ); + if (!schedules) { + return <>; } - return <>; + return ( + + + {schedules?.map((schedule) => ( +
+ {schedule.name} +
+ ))} +
+ ); } diff --git a/apps/frontend/src/components/Class/index.tsx b/apps/frontend/src/components/Class/index.tsx index c3603f56f..b59f1555a 100644 --- a/apps/frontend/src/components/Class/index.tsx +++ b/apps/frontend/src/components/Class/index.tsx @@ -9,6 +9,8 @@ import { CalendarPlus, OpenBook, OpenNewWindow, + Pin, + PinSolid, SidebarCollapse, SidebarExpand, Xmark, @@ -30,7 +32,9 @@ import Capacity from "@/components/Capacity"; import CourseDrawer from "@/components/CourseDrawer"; import Units from "@/components/Units"; import ClassContext from "@/contexts/ClassContext"; +import { ClassPin } from "@/contexts/PinsContext"; import { useReadClass } from "@/hooks/api/classes/useReadClass"; +import usePins from "@/hooks/usePins"; import { IClass, Semester } from "@/lib/api"; import { getExternalLink } from "@/lib/section"; @@ -123,6 +127,8 @@ export default function Class({ onClose, dialog, }: ClassProps) { + const { pins, addPin, removePin } = usePins(); + const location = useLocation(); // TODO: Bookmarks @@ -142,11 +148,33 @@ export default function Class({ const _class = useMemo(() => providedClass ?? data, [data, providedClass]); + const pin = useMemo(() => { + if (!_class) return; + + const { year, semester, subject, courseNumber, number } = _class; + + const id = `${year}-${semester}-${subject}-${courseNumber}-${number}`; + + return { + id, + type: "class", + data: { + year, + semester, + subject, + courseNumber, + number, + }, + } as ClassPin; + }, [year, semester, subject, courseNumber, number]); + + const pinned = useMemo(() => pins.some((p) => p.id === pin?.id), [pins, pin]); + if (loading) { return <>; } - if (!_class) { + if (!_class || !pin) { return <>; } @@ -163,9 +191,7 @@ export default function Class({ )} - + : } - + + (pinned ? removePin(pin) : addPin(pin))} + > + {pinned ? : } + + + @@ -254,7 +290,7 @@ export default function Class({

- {_class.subject} {_class.courseNumber} {_class.number} + {_class.subject} {_class.courseNumber} #{_class.number}

{_class.title || _class.course.title} diff --git a/apps/frontend/src/components/Course/index.tsx b/apps/frontend/src/components/Course/index.tsx index aa6986729..89edfcbfa 100644 --- a/apps/frontend/src/components/Course/index.tsx +++ b/apps/frontend/src/components/Course/index.tsx @@ -1,6 +1,5 @@ import { ReactNode, Suspense, useMemo, useState } from "react"; -import { useQuery } from "@apollo/client"; import * as Tabs from "@radix-ui/react-tabs"; import classNames from "classnames"; import { @@ -8,6 +7,8 @@ import { BookmarkSolid, Expand, GridPlus, + Link as LinkIcon, + ShareIos, Xmark, } from "iconoir-react"; import { Link, NavLink, Outlet, useLocation } from "react-router-dom"; @@ -24,7 +25,8 @@ import { import AverageGrade from "@/components/AverageGrade"; import CourseContext from "@/contexts/CourseContext"; -import { GET_COURSE, GetCourseResponse, ICourse } from "@/lib/api"; +import { useReadCourse } from "@/hooks/api"; +import { ICourse } from "@/lib/api"; import styles from "./Class.module.scss"; import Classes from "./Classes"; @@ -97,16 +99,47 @@ export default function Course({ // TODO: Bookmarks const [bookmarked, setBookmarked] = useState(false); - const { data, loading } = useQuery(GET_COURSE, { - variables: { - subject, - number, - }, + const { data, loading } = useReadCourse(subject as string, number as string, { // Allow course to be provided skip: !!providedCourse, }); - const course = useMemo(() => providedCourse ?? data?.course, [data]); + const course = useMemo(() => providedCourse ?? data, [providedCourse, data]); + + const context = useMemo(() => { + if (!course) return; + + return { + title: `${course.subject} ${course.number}`, + text: course.title, + url: `${window.location.origin}/courses/${course.subject}/${course.number}`, + }; + }, [course]); + + const canShare = useMemo( + () => context && navigator.canShare && navigator.canShare(context), + [context] + ); + + const share = () => { + if (!context) return; + + if (canShare) { + try { + navigator.share(context); + } catch { + // TODO: Handle error + } + + return; + } + + try { + navigator.clipboard.writeText(context.url); + } catch { + // TODO: Handle error + } + }; // TODO: Loading state if (loading) { @@ -125,9 +158,7 @@ export default function Course({

- + : } - +
+ {canShare ? ( + + share()}> + + + + ) : ( + + share()}> + + + + )} {dialog && ( diff --git a/apps/frontend/src/components/Layout/Feedback/Feedback.module.scss b/apps/frontend/src/components/Layout/Feedback/Feedback.module.scss new file mode 100644 index 000000000..12c5a93f5 --- /dev/null +++ b/apps/frontend/src/components/Layout/Feedback/Feedback.module.scss @@ -0,0 +1,29 @@ +.root { + position: fixed; + bottom: 128px; + right: 0; + border-radius: 4px 0 0 4px; + z-index: 979; + overflow: hidden; + pointer-events: none; + + .button { + padding: 0 16px; + border-radius: 4px 0 0 4px; + gap: 16px; + height: 48px; + transform: translateX(calc(100% - 48px)); + pointer-events: auto; + + &:hover, + &:focus { + animation: slideOut 250ms ease-in-out forwards; + } + } +} + +@keyframes slideOut { + to { + transform: translateY(0); + } +} diff --git a/apps/frontend/src/components/Layout/Feedback/index.tsx b/apps/frontend/src/components/Layout/Feedback/index.tsx new file mode 100644 index 000000000..c7e1ebeff --- /dev/null +++ b/apps/frontend/src/components/Layout/Feedback/index.tsx @@ -0,0 +1,22 @@ +import { MessageText } from "iconoir-react"; + +import { Button } from "@repo/theme"; + +import styles from "./Feedback.module.scss"; + +export default function Feedback() { + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/components/Layout/Layout.module.scss b/apps/frontend/src/components/Layout/Layout.module.scss index 3bce3a47e..3c1fe14fc 100644 --- a/apps/frontend/src/components/Layout/Layout.module.scss +++ b/apps/frontend/src/components/Layout/Layout.module.scss @@ -7,33 +7,4 @@ display: flex; flex-direction: column; } - - .feedback { - position: fixed; - bottom: 128px; - right: 0; - border-radius: 4px 0 0 4px; - z-index: 979; - overflow: hidden; - pointer-events: none; - - .button { - padding: 0 16px; - border-radius: 4px 0 0 4px; - gap: 16px; - height: 48px; - transform: translateX(calc(100% - 48px)); - pointer-events: auto; - - @keyframes slideOut { - to { - transform: translateY(0); - } - } - - &:hover, &:focus { - animation: slideOut 250ms ease-in-out forwards; - } - } - } -} \ No newline at end of file +} diff --git a/apps/frontend/src/components/Layout/Pins/Pins.module.scss b/apps/frontend/src/components/Layout/Pins/Pins.module.scss new file mode 100644 index 000000000..66b6415b3 --- /dev/null +++ b/apps/frontend/src/components/Layout/Pins/Pins.module.scss @@ -0,0 +1,26 @@ +.overlay { + background: linear-gradient(to left, rgb(255 255 255 / 50%), transparent); + inset: 0; + position: fixed; + z-index: 988; +} + +.content { + width: 384px; + border-left: 1px solid var(--border-color); + flex-shrink: 0; + position: fixed; + height: 100dvh; + background-color: var(--background-color); + right: 0; + top: 0; + z-index: 989; + + .header { + padding: 12px; + border-bottom: 1px solid var(--border-color); + background-color: var(--foreground-color); + display: flex; + gap: 12px; + } +} diff --git a/apps/frontend/src/components/Layout/Pins/index.tsx b/apps/frontend/src/components/Layout/Pins/index.tsx new file mode 100644 index 000000000..3d69aa0c5 --- /dev/null +++ b/apps/frontend/src/components/Layout/Pins/index.tsx @@ -0,0 +1,49 @@ +import { ReactNode } from "react"; + +import * as Dialog from "@radix-ui/react-dialog"; +import { Xmark, XmarkCircle } from "iconoir-react"; + +import { Button, IconButton } from "@repo/theme"; + +import usePins from "@/hooks/usePins"; + +import styles from "./Pins.module.scss"; + +interface PinsDrawerProps { + children: ReactNode; +} + +// TODO: Popover +export default function PinsDrawer({ children }: PinsDrawerProps) { + const { pins } = usePins(); + + return ( + + {children} + + + +
+ + + + + + +
+
+ {pins.map((pin) => ( +
+ {pin.type} + {JSON.stringify(pin.data)} +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/Layout/index.tsx b/apps/frontend/src/components/Layout/index.tsx index 4625e3d29..b495f97f4 100644 --- a/apps/frontend/src/components/Layout/index.tsx +++ b/apps/frontend/src/components/Layout/index.tsx @@ -1,13 +1,13 @@ import { Suspense } from "react"; -import { MessageText } from "iconoir-react"; import { Outlet } from "react-router"; -import { Boundary, Button, LoadingIndicator } from "@repo/theme"; +import { Boundary, LoadingIndicator } from "@repo/theme"; import Footer from "@/components/Footer"; import NavigationBar from "@/components/NavigationBar"; +import Feedback from "./Feedback"; import styles from "./Layout.module.scss"; interface LayoutProps { @@ -36,20 +36,7 @@ export default function Layout({
{footer &&
} - {feedback && ( -
- -
- )} + {feedback && }
); } diff --git a/apps/frontend/src/components/NavigationBar/index.tsx b/apps/frontend/src/components/NavigationBar/index.tsx index 2c10437c2..d2ba52050 100644 --- a/apps/frontend/src/components/NavigationBar/index.tsx +++ b/apps/frontend/src/components/NavigationBar/index.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { ArrowRight, Menu, User } from "iconoir-react"; +import { ArrowRight, Menu, Pin, User } from "iconoir-react"; import { Link, NavLink } from "react-router-dom"; import { Button, IconButton, MenuItem } from "@repo/theme"; @@ -8,6 +8,7 @@ import useUser from "@/hooks/useUser"; import useWindowDimensions from "@/hooks/useWindowDimensions"; import { signIn, signOut } from "@/lib/api"; +import PinsDrawer from "../Layout/Pins"; import styles from "./NavigationBar.module.scss"; interface NavigationBarProps { @@ -65,6 +66,11 @@ export default function NavigationBar({ invert }: NavigationBarProps) { {user ? user.email : "Sign in"} {user ? : } + + + + + )} diff --git a/apps/frontend/src/components/PinsProvider/index.tsx b/apps/frontend/src/components/PinsProvider/index.tsx new file mode 100644 index 000000000..c95bcf728 --- /dev/null +++ b/apps/frontend/src/components/PinsProvider/index.tsx @@ -0,0 +1,89 @@ +import { ReactNode, useEffect, useState } from "react"; + +import PinsContext, { Pin, PinEventListener } from "@/contexts/PinsContext"; + +const key = "pins"; + +const getPins = () => { + const value = localStorage.getItem(key); + + if (!value) return []; + + try { + return JSON.parse(value) as Pin[]; + } catch { + localStorage.removeItem(key); + + return []; + } +}; + +interface PinsProviderProps { + children: ReactNode; +} + +export default function PinsProvider({ children }: PinsProviderProps) { + const [pins, setPins] = useState(() => getPins()); + + const [pinEventListeners, setPinEventListeners] = useState< + PinEventListener[] + >([]); + + useEffect(() => { + // Listen for changes in other tabs + const handleStorage = (event: StorageEvent) => { + if (event.key !== key) return; + + setPins(getPins()); + }; + + window.addEventListener("storage", handleStorage); + + return () => { + window.removeEventListener("storage", handleStorage); + }; + }, []); + + const addPinEventListener = (listener: PinEventListener) => { + setPinEventListeners((listeners) => [...listeners, listener]); + }; + + const removePinEventListener = (listener: PinEventListener) => { + setPinEventListeners((listeners) => + listeners.filter((l) => l !== listener) + ); + }; + + const addPin = (pin: Pin) => { + // Prevent duplicates + if (pins.some((existingPin) => existingPin.id === pin.id)) return; + + updatePins([...pins, pin]); + }; + + const removePin = (pin: Pin) => { + updatePins(pins.filter((existingPin) => existingPin.id !== pin.id)); + }; + + const updatePins = (pins: Pin[]) => { + setPins(pins); + + const value = JSON.stringify(pins); + localStorage.setItem(key, value); + }; + + return ( + + {children} + + ); +} diff --git a/apps/frontend/src/contexts/PinsContext.ts b/apps/frontend/src/contexts/PinsContext.ts new file mode 100644 index 000000000..169133f9c --- /dev/null +++ b/apps/frontend/src/contexts/PinsContext.ts @@ -0,0 +1,45 @@ +import { Dispatch, createContext } from "react"; + +import { Semester } from "@/lib/api"; + +interface BasePin { + id: string; +} + +export interface ClassPin extends BasePin { + type: "class"; + data: { + year: number; + semester: Semester; + subject: string; + courseNumber: string; + number: string; + }; +} + +export interface CoursePin extends BasePin { + type: "course"; + data: { + subject: string; + number: string; + }; +} + +export type Pin = ClassPin | CoursePin; + +export type PinEvent = "mouseenter" | "mouseleave" | "click"; + +export type PinEventListener = (event: PinEvent, pin: Pin) => void; + +export interface PinsContextType { + pins: Pin[]; + addPin: (pin: Pin) => void; + removePin: (pin: Pin) => void; + pinEventListeners: PinEventListener[]; + addPinEventListener: Dispatch; + removePinEventListener: Dispatch; +} + +const PinsContext = createContext(null); + +export default PinsContext; diff --git a/apps/frontend/src/hooks/api/classes/index.ts b/apps/frontend/src/hooks/api/classes/index.ts new file mode 100644 index 000000000..bcfeae0d6 --- /dev/null +++ b/apps/frontend/src/hooks/api/classes/index.ts @@ -0,0 +1 @@ +export * from "./useReadClass"; diff --git a/apps/frontend/src/hooks/api/courses/index.ts b/apps/frontend/src/hooks/api/courses/index.ts new file mode 100644 index 000000000..15c7d760a --- /dev/null +++ b/apps/frontend/src/hooks/api/courses/index.ts @@ -0,0 +1 @@ +export * from "./useReadCourse"; diff --git a/apps/frontend/src/hooks/api/courses/useReadCourse.ts b/apps/frontend/src/hooks/api/courses/useReadCourse.ts new file mode 100644 index 000000000..4e9d3f95a --- /dev/null +++ b/apps/frontend/src/hooks/api/courses/useReadCourse.ts @@ -0,0 +1,22 @@ +import { QueryHookOptions, useQuery } from "@apollo/client"; + +import { READ_COURSE, ReadCourseResponse } from "@/lib/api"; + +export const useReadCourse = ( + subject: string, + number: string, + options?: Omit, "variables"> +) => { + const query = useQuery(READ_COURSE, { + ...options, + variables: { + subject, + number, + }, + }); + + return { + ...query, + data: query.data?.course, + }; +}; diff --git a/apps/frontend/src/hooks/api/index.ts b/apps/frontend/src/hooks/api/index.ts index b0a50d4eb..2ad8e6af9 100644 --- a/apps/frontend/src/hooks/api/index.ts +++ b/apps/frontend/src/hooks/api/index.ts @@ -1,2 +1,4 @@ export * from "./schedules"; export * from "./terms"; +export * from "./classes"; +export * from "./courses"; diff --git a/apps/frontend/src/hooks/usePins.ts b/apps/frontend/src/hooks/usePins.ts new file mode 100644 index 000000000..d744290cf --- /dev/null +++ b/apps/frontend/src/hooks/usePins.ts @@ -0,0 +1,15 @@ +import { useContext } from "react"; + +import PinsContext from "@/contexts/PinsContext"; + +const usePins = () => { + const pinsContext = useContext(PinsContext); + + if (!pinsContext) { + throw new Error("usePins must be used within a PinsProvider"); + } + + return pinsContext; +}; + +export default usePins; diff --git a/apps/frontend/src/lib/api/courses.ts b/apps/frontend/src/lib/api/courses.ts index 1681b66a3..434680385 100644 --- a/apps/frontend/src/lib/api/courses.ts +++ b/apps/frontend/src/lib/api/courses.ts @@ -27,11 +27,11 @@ export interface ICourse { typicallyOffered: Semester[] | null; } -export interface GetCourseResponse { +export interface ReadCourseResponse { course: ICourse; } -export const GET_COURSE = gql` +export const READ_COURSE = gql` query GetCourse($subject: String!, $number: String!) { course(subject: $subject, number: $number) { subject diff --git a/package-lock.json b/package-lock.json index 96360c785..9eee2938f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,11 @@ "dependencies": { "@apollo/server": "^4.10.5", "@apollo/server-plugin-response-cache": "^4.1.3", + "@apollo/utils.keyvadapter": "^3.1.0", "@escape.tech/graphql-armor": "^3.0.1", "@graphql-tools/schema": "^10.0.4", "@graphql-tools/utils": "^10.3.2", + "@keyv/redis": "^3.0.1", "@repo/common": "*", "compression": "^1.7.4", "connect-redis": "^7.1.1", @@ -42,6 +44,7 @@ "graphql-modules": "^2.3.0", "graphql-type-json": "^0.3.2", "helmet": "^7.1.0", + "keyv": "^5.1.0", "lodash": "^4.17.21", "mongodb": "^6.8.0", "mongoose": "^8.5.1", @@ -70,6 +73,14 @@ "typescript": "^5.5.4" } }, + "apps/backend/node_modules/keyv": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.1.0.tgz", + "integrity": "sha512-FUr1fbKVsj9IZkPkY9reJ80Lp2B3ldtFXH+xK0wvZYzOpwgHV1er3xP4JUhu2cgkV2X3BJQw+hzAbIGqa+hNIA==", + "dependencies": { + "@keyv/serialize": "*" + } + }, "apps/backend/node_modules/mongodb": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.0.tgz", @@ -449,6 +460,44 @@ "node": ">=14" } }, + "node_modules/@apollo/utils.keyvadapter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.keyvadapter/-/utils.keyvadapter-3.1.0.tgz", + "integrity": "sha512-q41MxH2gKwvXL28hUEfQHdSQ0F4u8KrPHUbvN/IkA7vh+KTRj0tG2eWLu42c438jjoV7hUhY1Ru/Dwq8zndEqg==", + "dependencies": { + "@apollo/utils.keyvaluecache": "^3.1.0", + "dataloader": "^2.1.0", + "keyv": "^4.4.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.keyvadapter/node_modules/@apollo/utils.keyvaluecache": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-3.1.0.tgz", + "integrity": "sha512-MM/DKIqpQQbuNG1gNPAlGc45THdWkroTmN8o/J09merFwf/LlZ7+lAfcHFDXIYIknwKmUjJrOMS3OxYbjrz2hA==", + "dependencies": { + "@apollo/utils.logger": "^3.0.0", + "lru-cache": "^10.0.0" + }, + "engines": { + "node": ">=16.14" + } + }, + "node_modules/@apollo/utils.keyvadapter/node_modules/@apollo/utils.logger": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-3.0.0.tgz", + "integrity": "sha512-M8V8JOTH0F2qEi+ktPfw4RL7MvUycDfKp7aEap2eWXfL5SqWHN6jTLbj5f5fj1cceHpyaUSOZlvlaaryaxZAmg==", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.keyvadapter/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, "node_modules/@apollo/utils.keyvaluecache": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", @@ -5252,6 +5301,11 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -5301,6 +5355,48 @@ "integrity": "sha512-gbkePEBupNydxCelHCESvFSFM8XPh1Zs/OAVRW/rKpEqPAl5PbOM90Si8mv9bvnR53uPD2s/FiRxdvSejpRJew==", "dev": true }, + "node_modules/@keyv/redis": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-3.0.1.tgz", + "integrity": "sha512-eyqzomQC76LjUOEkPP8rdR2Fk4eZBSS0Ma47i7CNiQuv8NCw3trZvghx8L5Xruk7XPEj/eRAMrAxP//xQFOPdQ==", + "dependencies": { + "ioredis": "^5.4.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.1.tgz", + "integrity": "sha512-kKXeynfORDGPUEEl2PvTExM2zs+IldC6ZD8jPcfvI351MDNtfMlw9V9s4XZXuJNDK2qR5gbEKxRyoYx3quHUVQ==", + "dependencies": { + "buffer": "^6.0.3" + } + }, + "node_modules/@keyv/serialize/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -7918,7 +8014,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -9262,6 +9357,14 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -11196,6 +11299,29 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ioredis": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", + "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -11814,6 +11940,16 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -15072,6 +15208,25 @@ "@redis/time-series": "1.0.5" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/redux": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", @@ -16083,6 +16238,11 @@ "tslib": "^2.0.3" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/packages/common/src/models/schedule.ts b/packages/common/src/models/schedule.ts index 129fe3326..b233bd2da 100644 --- a/packages/common/src/models/schedule.ts +++ b/packages/common/src/models/schedule.ts @@ -84,14 +84,6 @@ export const scheduleSchema = new Schema( required: true, trim: true, }, - beginDate: { - type: Date, - required: true, - }, - endDate: { - type: Date, - required: true, - }, events: { type: [customEventSchema], required: true, diff --git a/packages/theme/src/components/MenuItem/MenuItem.module.scss b/packages/theme/src/components/MenuItem/MenuItem.module.scss index a968761a0..fe08616b9 100644 --- a/packages/theme/src/components/MenuItem/MenuItem.module.scss +++ b/packages/theme/src/components/MenuItem/MenuItem.module.scss @@ -11,11 +11,14 @@ transition: all 100ms ease-in-out; z-index: 1; - &:global(.active), &:hover, &[data-state="active"] { + &:global(.active), + &:hover, + &[data-state="active"] { color: var(--heading-color); } - &:global(.active)::before, &[data-state="active"]::before { + &:global(.active)::before, + &[data-state="active"]::before { opacity: 1; } @@ -53,4 +56,4 @@ transition: all 100ms ease-in-out; z-index: -1; } -} \ No newline at end of file +} diff --git a/packages/theme/src/components/ThemeProvider/index.tsx b/packages/theme/src/components/ThemeProvider/index.tsx index 6db3c337a..fe477a5fe 100644 --- a/packages/theme/src/components/ThemeProvider/index.tsx +++ b/packages/theme/src/components/ThemeProvider/index.tsx @@ -32,7 +32,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {