diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5fdf13c2..f76668f7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,8 @@ { - "recommendations": ["dbaeumer.vscode-eslint", "nrwl.angular-console", "esbenp.prettier-vscode"] + "recommendations": [ + "dbaeumer.vscode-eslint", + "nrwl.angular-console", + "esbenp.prettier-vscode", + "prisma.prisma" + ] } diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 7a4b155c..887ca986 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,6 +7,7 @@ import { getFCEs } from "~/controllers/fces"; import { getInstructors } from "~/controllers/instructors"; import { getGeneds } from "~/controllers/geneds"; import { getSchedules } from "~/controllers/schedules"; +import { getSyllabi, getAllSyllabi } from "~/controllers/syllabi"; const app = express(); const port = process.env.PORT || 3000; @@ -33,6 +34,9 @@ app.route("/schedules").get(getSchedules); app.route("/geneds").get(getGeneds); app.route("/geneds").post(isUser, getGeneds); +app.route("/syllabi").get(getSyllabi) +app.route("/syllabi/all").get(getAllSyllabi); + // the next parameter is needed! // eslint-disable-next-line @typescript-eslint/no-unused-vars const errorHandler: ErrorRequestHandler = (err, req, res, next) => { diff --git a/apps/backend/src/controllers/syllabi.ts b/apps/backend/src/controllers/syllabi.ts new file mode 100644 index 00000000..a8526cfd --- /dev/null +++ b/apps/backend/src/controllers/syllabi.ts @@ -0,0 +1,119 @@ +import { RequestHandler } from "express"; +import { cleanID, PrismaReturn, SingleOrArray, singleToArray } from "~/util"; +import db from "@cmucourses/db"; + +export interface Syllabus { + id: string; + season: string; + year: number; + number: string; + url: string | null; +} + +export interface GetSyllabi { + params: unknown; + resBody: Syllabus[]; + reqBody: unknown; + query: { number?: string | string[] }; +} + +export const getSyllabi: RequestHandler< + GetSyllabi["params"], + GetSyllabi["resBody"], + GetSyllabi["reqBody"], + GetSyllabi["query"] +> = async (req, res, next) => { + console.log(req.query.number); + const numbers = singleToArray(req.query.number).map((n) => n); + + console.log("Fetching syllabi for numbers:", numbers); + + try { + const syllabi = await db.syllabi.findMany({ + where: { + number: { in: numbers }, + }, + select: { + id: true, + season: true, + year: true, + number: true, + section: true, + url: true, + }, + }); + + console.log(`Found ${syllabi.length} syllabi`); + console.log("Syllabi:", syllabi); + res.json(syllabi); + } catch (e) { + console.error("Error fetching syllabi:", e); + res.json([]); + } +}; + +// TODO: use a better caching system +const allSyllabiEntry = { + allSyllabi: [] as Syllabus[], + lastCached: new Date(1970), +}; + +const getAllSyllabiDbQuery = { + select: { + id: true, + season: true, + year: true, + number: true, + url: true, + }, +}; + +export interface GetAllSyllabi { + params: unknown; + resBody: Syllabus[]; + reqBody: unknown; + query: { number?: string | string[] }; +} + +export const getAllSyllabi: RequestHandler< + GetAllSyllabi["params"], + GetAllSyllabi["resBody"], + GetAllSyllabi["reqBody"], + GetAllSyllabi["query"] +> = async (req, res, next) => { + const filter: Record = {}; + + // // Add filtering logic if needed + // if (req.query.number) { + // filter.number = Array.isArray(req.query.number) + // ? { in: req.query.number } + // : req.query.number; + // } + + if (new Date().valueOf() - allSyllabiEntry.lastCached.valueOf() > 1000 * 60 * 60 * 24) { + try { + const syllabiFromDB = await db.syllabi.findMany({ + where: filter, + select: getAllSyllabiDbQuery.select, + }); + + const syllabi: Syllabus[] = syllabiFromDB.map((s) => ({ + id: s.id, + season: s.season ?? "", + year: s.year ?? 0, + number: s.number ?? "", + url: s.url ?? null, + })); + + allSyllabiEntry.lastCached = new Date(); + allSyllabiEntry.allSyllabi = syllabi; + + res.json(syllabi); + } catch (e) { + console.error("Error fetching all syllabi:", e); + res.json([] as Syllabus[]); + } + } else { + res.json(allSyllabiEntry.allSyllabi); + } +}; \ No newline at end of file diff --git a/apps/backend/src/util.ts b/apps/backend/src/util.ts index baea521e..072156f7 100644 --- a/apps/backend/src/util.ts +++ b/apps/backend/src/util.ts @@ -5,6 +5,10 @@ export const standardizeID = (id: string): string => { return id; }; +export const cleanID = (id: string): string => { + return id.replace("-", ""); +} + export type SingleOrArray = T | T[]; export function singleToArray(param: SingleOrArray): T[] { diff --git a/apps/frontend/src/app/api/syllabi.ts b/apps/frontend/src/app/api/syllabi.ts new file mode 100644 index 00000000..d5cad18a --- /dev/null +++ b/apps/frontend/src/app/api/syllabi.ts @@ -0,0 +1,144 @@ +import axios from "axios"; +import { useQuery, useQueries, keepPreviousData } from "@tanstack/react-query"; +import { create, windowScheduler, keyResolver } from "@yornaath/batshit"; +import { STALE_TIME } from "~/app/constants"; +import { Syllabus } from "~/app/types"; + +export type FetchAllSyllabiResult = Syllabus[]; + +// const fetchSyllabi = async (numbers: string[]): Promise => { +// try { +// const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/syllabi`; +// console.log("the ", numbers); +// const params = new URLSearchParams( +// numbers.map((number) => ["number", number]) +// ); + +// const response = await axios.get(url, { +// headers: { +// "Content-Type": "application/json", +// }, +// params, +// }); + +// return response.data; +// } catch (error) { +// console.error("Error fetching syllabi:", error); +// return []; +// } +// }; + +const fetchSyllabi = async (numbers: string[]): Promise => { + try { + const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/syllabi`; + + console.log("Original numbers (with types):", numbers.map(n => `${n} (${typeof n})`)); + + // Ensure numbers are always strings and padded to 5 digits + const paddedNumbers = numbers.map(num => String(num).padStart(5, '0')); + + console.log("Padded numbers:", paddedNumbers); + + const params = new URLSearchParams( + paddedNumbers.map((number) => ["number", number]) + ); + + const response = await axios.get(url, { + headers: { + "Content-Type": "application/json", + }, + params, + }); + + return response.data; + } catch (error) { + console.error("Error fetching syllabi:", error); + return []; + } +}; + +export const useFetchSyllabi = (numbers: string[]) => { + return useQuery({ + queryKey: ["syllabi", numbers], + queryFn: () => fetchSyllabi(numbers), + staleTime: STALE_TIME, + enabled: numbers.length > 0, + }); +}; + +const fetchSyllabusBatcher = create({ + fetcher: async (syllabusNumbers: string[]): Promise => { + try { + const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/syllabi`; + const params = new URLSearchParams( + syllabusNumbers.map((number) => ["number", number]) + ); + + const response = await axios.get(url, { + headers: { + "Content-Type": "application/json", + }, + params, + }); + + return response.data; + } catch (error) { + console.error("Error in syllabus batcher:", error); + return []; + } + }, + resolver: keyResolver("number"), + scheduler: windowScheduler(10), +}); + +export const useFetchSyllabus = (number: string) => { + return useQuery({ + queryKey: ["syllabus", { number }], + queryFn: () => fetchSyllabusBatcher.fetch(number), + staleTime: STALE_TIME, + enabled: !!number, + }); +}; + +export const useFetchMultipleSyllabi = (numbers: string[]) => { + return useQueries({ + queries: numbers.map((number) => ({ + queryKey: ["syllabus", { number }], + queryFn: () => fetchSyllabusBatcher.fetch(number), + staleTime: STALE_TIME, + enabled: !!number, + })), + combine: (result) => { + return result.reduce((acc, { data }) => { + if (data) acc.push(data); + return acc; + }, [] as Syllabus[]); + }, + }); +}; + +const fetchAllSyllabi = async (): Promise => { + try { + const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/syllabi/all`; + + const response = await axios.get(url, { + headers: { + "Content-Type": "application/json", + }, + }); + + return response.data; + } catch (error) { + console.error("Error fetching all syllabi:", error); + return []; + } +}; + +export const useFetchAllSyllabi = () => { + return useQuery({ + queryKey: ["allSyllabi"], + queryFn: fetchAllSyllabi, + staleTime: STALE_TIME, + retry: false, + }); +}; \ No newline at end of file diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx new file mode 100644 index 00000000..a14e64fc --- /dev/null +++ b/apps/frontend/src/app/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/apps/frontend/src/app/types.ts b/apps/frontend/src/app/types.ts index c86059ee..f67970a1 100644 --- a/apps/frontend/src/app/types.ts +++ b/apps/frontend/src/app/types.ts @@ -32,6 +32,14 @@ export interface Course { fces?: FCE[]; } +export interface Syllabus { + season: string; + year: number; + number: string; + section: string; + url: string; +} + export interface Time { days: number[]; begin: string; diff --git a/apps/frontend/src/app/utils.tsx b/apps/frontend/src/app/utils.tsx index 690bc635..a97239a6 100644 --- a/apps/frontend/src/app/utils.tsx +++ b/apps/frontend/src/app/utils.tsx @@ -1,6 +1,6 @@ import reactStringReplace from "react-string-replace"; import Link from "~/components/Link"; -import { FCE, Schedule, Session, Time } from "./types"; +import { FCE, Schedule, Session, Time, Syllabus } from "./types"; import { AggregateFCEsOptions, filterFCEs } from "./fce"; import { DEPARTMENT_MAP_NAME, @@ -24,6 +24,10 @@ export const standardizeIdsInString = (str: string) => { return str.replaceAll(courseIdRegex, standardizeId); }; +export const cleanID = (id: string): string => { + return id.replace("-", ""); +} + export const sessionToString = (sessionInfo: Session | FCE | Schedule) => { if (!sessionInfo) return ""; @@ -283,3 +287,38 @@ export const getCalendarColorLight = (color: string) => { const index = CALENDAR_COLORS.indexOf(color); return CALENDAR_COLORS_LIGHT[index]; }; + +export const getCanvasLinkFromDownloadLink = (downloadLink: string): string => { + if (!downloadLink || downloadLink === "#") return "#"; + try { + const fileIdMatch = downloadLink.match(/\/files\/(\d+)/); + const verifierMatch = downloadLink.match(/verifier=([^&]+)/); + + if (fileIdMatch && verifierMatch) { + const fileId = fileIdMatch[1]; + const verifier = verifierMatch[1]; + return `https://canvas.cmu.edu/files/${fileId}/?verifier=${verifier}`; + } + return downloadLink; + } catch (e) { + console.error("Error parsing download link:", e); + return downloadLink; + } +}; + +export const groupSyllabiByNumber = (syllabi: Syllabus[]) => { + return syllabi.reduce((groups, syllabus) => { + const num = syllabus.number?.trim().toLowerCase() || ''; + if (!groups[num]) groups[num] = []; + groups[num].push(syllabus); + return groups; + }, {} as Record); +}; + +export const sortSyllabi = (syllabi: Syllabus[]) => { + return [...syllabi].sort((a, b) => { + if (a.year !== b.year) return b.year - a.year; + const seasonOrder: Record = { "spring": 1, "fall": 0 }; + return (seasonOrder[b.season.toLowerCase()] || 0) - (seasonOrder[a.season.toLowerCase()] || 0); + }); +}; diff --git a/apps/frontend/src/components/CourseCard.tsx b/apps/frontend/src/components/CourseCard.tsx index 403d4b51..c0775981 100644 --- a/apps/frontend/src/components/CourseCard.tsx +++ b/apps/frontend/src/components/CourseCard.tsx @@ -6,6 +6,7 @@ import { filterSessions, injectLinks, sessionToShortString, + cleanID, } from "~/app/utils"; import { useAppSelector } from "~/app/hooks"; import BookmarkButton from "./BookmarkButton"; @@ -16,6 +17,7 @@ import { CourseSchedulesDetail } from "./CourseSchedulesDetail"; import { useFetchCourseInfo } from "~/app/api/course"; import { useFetchFCEInfoByCourse } from "~/app/api/fce"; import { useAuth } from "@clerk/nextjs"; +import { useFetchSyllabus } from "~/app/api/syllabi"; interface Props { courseID: string; @@ -35,12 +37,19 @@ const CourseCard = ({ useFetchCourseInfo(courseID); const { isPending: isFCEInfoPending, data: { fces } = {} } = useFetchFCEInfoByCourse(courseID); + const cleanedCourseID = cleanID(courseID); + const { isPending: isSyllabiPending, data: syllabi } = + useFetchSyllabus(cleanedCourseID); const options = useAppSelector((state) => state.user.fceAggregation); - if (isCourseInfoPending || isFCEInfoPending || !info) { + if (isCourseInfoPending || isFCEInfoPending /*|| isSyllabusPending*/ || !info) { return <>; } + const courseSyllabus = syllabi + ? (cleanID(syllabi.number || "") === cleanedCourseID ? syllabi : undefined) + : undefined; + const sortedSchedules = filterSessions(info.schedules || []).sort( compareSessions ); @@ -112,6 +121,18 @@ const CourseCard = ({ {injectLinks(courseListToString(info.crosslisted))} +
+
Syllabus
+
+ {courseSyllabus ? ( + + View All Syllabi + + ) : ( + "None" + )} +
+
)} diff --git a/apps/frontend/src/components/CourseDetail.tsx b/apps/frontend/src/components/CourseDetail.tsx index 315e4807..f7a4a230 100644 --- a/apps/frontend/src/components/CourseDetail.tsx +++ b/apps/frontend/src/components/CourseDetail.tsx @@ -6,6 +6,7 @@ import { SchedulesCard } from "./SchedulesCard"; import { FCECard } from "./FCECard"; import { useFetchCourseInfo, useFetchCourseRequisites } from "~/app/api/course"; import ReqTreeCard from "./ReqTreeCard"; +import SyllabusCard from "./SyllabusCard"; type Props = { courseID: string; @@ -20,6 +21,11 @@ const CourseDetail = ({ courseID }: Props) => { return
Loading...
; } + const parts = courseID.split("-"); + const department = parts[0] || ""; + const courseNumber = parts[1] || ""; + const fullNumber = department + courseNumber; + return (
@@ -39,8 +45,10 @@ const CourseDetail = ({ courseID }: Props) => { postreqs={requisites.postreqs} /> )} + +
); }; -export default CourseDetail; +export default CourseDetail; \ No newline at end of file diff --git a/apps/frontend/src/components/SideNav.tsx b/apps/frontend/src/components/SideNav.tsx index 7d3cce46..d8dcc01b 100644 --- a/apps/frontend/src/components/SideNav.tsx +++ b/apps/frontend/src/components/SideNav.tsx @@ -1,6 +1,7 @@ import { ChatBubbleBottomCenterTextIcon, ClockIcon, + AcademicCapIcon, MagnifyingGlassIcon, StarIcon, UserCircleIcon, @@ -81,6 +82,12 @@ export const SideNav = ({ activePage }) => { link="/instructors" active={activePage === "instructors"} /> + { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { course: courseQuery } = router.query; + const courseFilter = typeof courseQuery === 'string' ? courseQuery.trim().toLowerCase() : null; + const page = useAppSelector((state) => state.filters.page); + const { data: syllabi } = useFetchAllSyllabi(); + const filteredSyllabi = useMemo(() => { + if (!syllabi || syllabi.length === 0) return []; + + if (courseFilter) { + return syllabi.filter(s => + (s.number || "").trim().toLowerCase() === courseFilter + ); + } + + return syllabi; + }, [syllabi, courseFilter]); + const groupedSyllabi = useMemo(() => { + return groupSyllabiByNumber(filteredSyllabi); + }, [filteredSyllabi]); + const courseNumbers = useMemo(() => { + return Object.keys(groupedSyllabi).sort(); + }, [groupedSyllabi]); + const pagedCourseNumbers = useMemo(() => { + const startIndex = (page - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + + return courseNumbers.slice(startIndex, endIndex); + }, [courseNumbers, page]); + + useEffect(() => { + if (courseFilter && courseNumbers.length > 0) { + const courseIndex = courseNumbers.indexOf(courseFilter); + if (courseIndex >= 0) { + const correctPage = Math.floor(courseIndex / ITEMS_PER_PAGE) + 1; + if (page !== correctPage) { + dispatch(filtersSlice.actions.setPage(correctPage)); + } + } + } + }, [courseFilter, courseNumbers, page, dispatch]); + + if (!syllabi || syllabi.length === 0) { + return ( +
+

No syllabi found!

+

The API returned no syllabi data.

+
+ ); + } + if (filteredSyllabi.length === 0) { + return ( +
+

No syllabi found for course "{courseFilter}"

+

Try searching for a different course number.

+ + View all syllabi + +
+ ); + } + + if (courseFilter) { + return ( +
+
+

Syllabi for {standardizeId(courseFilter)}

+ + View all syllabi + +
+ +
+ ); + } + + return ( +
+ {pagedCourseNumbers.map((number) => ( + + ))} +
+ ); + }; \ No newline at end of file diff --git a/apps/frontend/src/components/SyllabiSearchList.tsx b/apps/frontend/src/components/SyllabiSearchList.tsx new file mode 100644 index 00000000..2db2d7d7 --- /dev/null +++ b/apps/frontend/src/components/SyllabiSearchList.tsx @@ -0,0 +1,63 @@ +import React, { useMemo, useEffect } from "react"; +import { useAppDispatch, useAppSelector } from "~/app/hooks"; +import { useFetchAllSyllabi } from "~/app/api/syllabi"; +import Loading from "./Loading"; +import { Pagination } from "./Pagination"; +import { filtersSlice } from "~/app/filters"; +import { useRouter } from "next/router"; +import { SyllabiPage, ITEMS_PER_PAGE } from "~/components/SyllabiDetail"; + +const SyllabiSearchList: React.FC = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const curPage = useAppSelector((state) => state.filters.page); + const { isPending, data: syllabi } = useFetchAllSyllabi(); + const { course: courseQuery } = router.query; + const courseFilter = typeof courseQuery === 'string' ? courseQuery.trim().toLowerCase() : null; + const totalCourses = useMemo(() => { + if (!syllabi) return 0; + + if (courseFilter) { + const hasCourse = syllabi.some(s => + (s.number || "").trim().toLowerCase() === courseFilter + ); + return hasCourse ? 1 : 0; + } + + const uniqueCourses = new Set( + syllabi.map(s => (s.number || "").trim().toLowerCase()) + ); + return uniqueCourses.size; + }, [syllabi, courseFilter]); + const totalPages = useMemo(() => { + if (courseFilter) return 1; + return Math.ceil(totalCourses / ITEMS_PER_PAGE); + }, [totalCourses, courseFilter]); + + const handlePageClick = (page: number) => { + void dispatch(filtersSlice.actions.setPage(page + 1)); + }; + + return ( +
+ {isPending ? ( + + ) : ( + <> + + {totalPages > 1 && !courseFilter && ( +
+ +
+ )} + + )} +
+ ); +}; + +export default SyllabiSearchList; \ No newline at end of file diff --git a/apps/frontend/src/components/SyllabusCard.tsx b/apps/frontend/src/components/SyllabusCard.tsx new file mode 100644 index 00000000..327426a1 --- /dev/null +++ b/apps/frontend/src/components/SyllabusCard.tsx @@ -0,0 +1,189 @@ +import React from "react"; +import { Card } from "./Card"; +import { useFetchSyllabi } from "~/app/api/syllabi"; +import { Syllabus } from "~/app/types"; +import { + ColumnDef, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { getTable } from "~/components/GetTable"; + +interface SyllabusCardProps { + number: string; +} + +// Helper function to convert download link to canvas link +const getCanvasLinkFromDownloadLink = (downloadLink: string): string => { + if (!downloadLink || downloadLink === "#") return "#"; + + try { + // Extract the file ID and verifier from the download link + const fileIdMatch = downloadLink.match(/\/files\/(\d+)/); + const verifierMatch = downloadLink.match(/verifier=([^&]+)/); + + if (fileIdMatch && verifierMatch) { + const fileId = fileIdMatch[1]; + const verifier = verifierMatch[1]; + + // Construct the canvas link + return `https://canvas.cmu.edu/files/${fileId}/?verifier=${verifier}`; + } + + // If we can't parse the link, return the original + return downloadLink; + } catch (e) { + console.error("Error parsing download link:", e); + return downloadLink; + } +}; + +// Define type for rows in our syllabus table +type SyllabusRow = { + semester: string; + section: string; + canvasLink: { + url: string; + text: string; + }; + downloadLink: { + url: string; + text: string; + }; +}; + +// Define column configurations +const columns: ColumnDef[] = [ + { + header: "Semester", + accessorKey: "semester", + }, + { + header: "Section", + accessorKey: "section", + }, + { + header: "View Syllabus on Canvas", + accessorKey: "canvasLink", + cell: (info) => { + const link = info.getValue() as { url: string; text: string }; + return ( + + {link.text} + + ); + }, + }, + { + header: "Download", + accessorKey: "downloadLink", + cell: (info) => { + const link = info.getValue() as { url: string; text: string }; + return ( + + {link.text} + + ); + }, + }, +]; + +const SyllabusDataTable = ({ data }: { data: SyllabusRow[] }) => { + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + }); + + return getTable(table); +}; + +const SyllabusCard: React.FC = ({ number }) => { + const trimmedNumber = number.trim().toLowerCase(); + const { data: syllabi, isLoading, error } = useFetchSyllabi([trimmedNumber]); + + const courseSyllabi = !syllabi ? [] : syllabi.filter((syllabus: Syllabus) => + (syllabus.number || "").trim().toLowerCase() === trimmedNumber + ); + + if (isLoading) { + return ( + + Syllabi +
Loading syllabi...
+
+ ); + } + + if (error) { + return ( + + Syllabi +
Error loading syllabi. Please try again later.
+
+ ); + } + + if (courseSyllabi.length === 0) { + return ( + + Syllabi +
No syllabus available for this course.
+
+ ); + } + + const sortedSyllabi = [...courseSyllabi].sort((a, b) => { + if (a.year !== b.year) return b.year - a.year; + + // Use Record to tell TypeScript this object can be indexed with any string + const seasonOrder: Record = { "spring": 1, "fall": 0 }; + + return (seasonOrder[b.season.toLowerCase()] || 0) - (seasonOrder[a.season.toLowerCase()] || 0); + }); + + // Convert syllabi data to table row format + const tableData: SyllabusRow[] = sortedSyllabi.map(syllabus => { + const courseCode = trimmedNumber.toUpperCase(); + const seasonPrefix = syllabus.season.toLowerCase().startsWith('f') ? 'F' : 'S'; + const yearCode = syllabus.year.toString().slice(-2); + const displayCode = `${seasonPrefix}${yearCode}-${courseCode}-${syllabus.section || "1"}`; + + const downloadLink = syllabus.url || "#"; + const canvasLink = getCanvasLinkFromDownloadLink(downloadLink); + + return { + semester: `${syllabus.season} ${syllabus.year}`, + section: syllabus.section || "1", + canvasLink: { + url: canvasLink, + text: `${displayCode} (Canvas Link)` + }, + downloadLink: { + url: downloadLink, + text: `${displayCode} (Download Link)` + } + }; + }); + + return ( + + Syllabi +
+ +
+
+ ); +}; + +export default SyllabusCard; diff --git a/apps/frontend/src/components/SyllabusCourseCard.tsx b/apps/frontend/src/components/SyllabusCourseCard.tsx new file mode 100644 index 00000000..f5474639 --- /dev/null +++ b/apps/frontend/src/components/SyllabusCourseCard.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { useFetchCourseInfo } from "~/app/api/course"; +import { standardizeId, getCanvasLinkFromDownloadLink, groupSyllabiByNumber, sortSyllabi } from "~/app/utils"; +import { Card } from "./Card"; +import Link from "next/link"; +import { Syllabus } from "~/app/types"; +import { + ColumnDef, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { getTable } from "~/components/GetTable"; + +type SyllabusRow = { + semester: string; + section: string; + canvasLink: { + url: string; + text: string; + }; + downloadLink: { + url: string; + text: string; + }; +}; + +const syllabusColumns: ColumnDef[] = [ + { + header: "Semester", + accessorKey: "semester", + }, + { + header: "Section", + accessorKey: "section", + }, + { + header: "View Syllabus on Canvas", + accessorKey: "canvasLink", + cell: (info) => { + const link = info.getValue() as { url: string; text: string }; + return ( + + {link.text} + + ); + }, + }, + { + header: "Download", + accessorKey: "downloadLink", + cell: (info) => { + const link = info.getValue() as { url: string; text: string }; + return ( + + {link.text} + + ); + }, + }, + ]; + +const SyllabusDataTable = ({ data }: { data: SyllabusRow[] }) => { + const table = useReactTable({ + columns: syllabusColumns, + data, + getCoreRowModel: getCoreRowModel(), + }); + + + return getTable(table); + }; + +const convertSyllabiToTableData = (syllabi: Syllabus[], courseNumber: string): SyllabusRow[] => { + return syllabi.map(syllabus => { + const courseCode = courseNumber.toUpperCase(); + const seasonPrefix = syllabus.season.toLowerCase().startsWith('f') ? 'F' : 'S'; + const yearCode = syllabus.year.toString().slice(-2); + const displayCode = `${seasonPrefix}${yearCode}-${courseCode}-${syllabus.section || "1"}`; + + const downloadLink = syllabus.url || "#"; + const canvasLink = getCanvasLinkFromDownloadLink(downloadLink); + + return { + semester: `${syllabus.season} ${syllabus.year}`, + section: syllabus.section || "1", + canvasLink: { + url: canvasLink, + text: `${displayCode} (Canvas Link)` + }, + downloadLink: { + url: downloadLink, + text: `${displayCode} (Download Link)` + } + }; + }); + }; + +export const SyllabusCourseCard = ({ number, syllabi }: { number: string; syllabi: Syllabus[] }) => { + const sortedSyllabi = sortSyllabi(syllabi); + const tableData = convertSyllabiToTableData(sortedSyllabi, number); + + const formattedCourseNumber = standardizeId(number); + const { data: courseInfo } = useFetchCourseInfo(formattedCourseNumber); + + const courseName = courseInfo?.name || syllabi[0]?.name || ""; + + return ( + + + + {formattedCourseNumber}: {courseName} + + +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/apps/frontend/src/pages/syllabi.tsx b/apps/frontend/src/pages/syllabi.tsx new file mode 100644 index 00000000..2ff99b3b --- /dev/null +++ b/apps/frontend/src/pages/syllabi.tsx @@ -0,0 +1,26 @@ +import type { NextPage } from "next"; +import Topbar from "~/components/Topbar"; +import React from "react"; +import { Page } from "~/components/Page"; +import Aggregate from "~/components/Aggregate"; +import SyllabiSearch from "~/components/SyllabiSearchList"; + +const SyllabiPage: NextPage = () => { + return ( + + + + } + content={ + <> + + + } + activePage="syllabi" + /> + ); +}; + +export default SyllabiPage; \ No newline at end of file diff --git a/packages/db/schema.prisma b/packages/db/schema.prisma index 83bd7229..de0d47e8 100644 --- a/packages/db/schema.prisma +++ b/packages/db/schema.prisma @@ -133,3 +133,14 @@ model geneds { school String tags String[] } + +model syllabi { + id String @id @default(auto()) @map("_id") @db.ObjectId + season String + year Int + number String + section String + url String? + + @@index([year, number, section, season]) +}