diff --git a/package-lock.json b/package-lock.json index 3721c7a..a0d1a39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "tailwind-scrollbar": "^4.0.2", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5" @@ -2149,6 +2150,12 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "dev": true + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -2610,6 +2617,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4063,6 +4079,19 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "dev": true, + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4460,6 +4489,21 @@ "node": ">=8" } }, + "node_modules/tailwind-scrollbar": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.2.tgz", + "integrity": "sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==", + "dev": true, + "dependencies": { + "prism-react-renderer": "^2.4.1" + }, + "engines": { + "node": ">=12.13.0" + }, + "peerDependencies": { + "tailwindcss": "4.x" + } + }, "node_modules/tailwindcss": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.6.tgz", diff --git a/package.json b/package.json index bcfc33d..e1709a9 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "tailwind-scrollbar": "^4.0.2", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5" diff --git a/src/App.tsx b/src/App.tsx index becf035..612e5d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,339 +1,40 @@ -import { useState } from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import Catalog from "./pages/Catalog"; import Planner from "./pages/Planner"; import Toolbox from "./components/Toolbox/Toolbox"; import HomePage from "./pages/HomePage"; -import { - CourseEntry, - CourseType, -} from "./types/interfaces/Course.interface.ts"; -import { DragDropContext, DropResult } from "@hello-pangea/dnd"; -import { SemesterType } from "./types/interfaces/Semester.interface.ts"; -import { Filters } from "./types/Filters"; - -function App() { - // Original state from App.tsx - const [toolboxCourses, setToolboxCourses] = useState([]); - const [plannerCourses, setPlannerCourses] = useState([ - { - semesterNumber: 1, - semesterSeason: "fall", - creditsTotal: 0, - courseList: [], - }, - ]); - const [isDragging, setIsDragging] = useState(false); - - const [searchResults, setSearchResults] = useState([]); - - // Moved from SearchBar.tsx - const [searchPrompt, setSearchPrompt] = useState(""); - const [showFilter, setShowFilter] = useState(false); - const [filters, setFilters] = useState({ - Subject: [], - Attributes: [], - Semesters: [], - }); - - // Reorders a simple array - const reorder = ( - list: T[], - startIndex: number, - endIndex: number, - ): T[] => { - const result = [...list]; - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - return result; - }; - - const cloneCourse = (course: CourseEntry): CourseEntry => { - return { - ...course, - name: course.name + "-" + Math.random().toString(36).substring(2, 7), - data: { ...course.data }, - count: 1, - }; - }; - - const onDragStart = () => { - setIsDragging(true); - }; - - const onDragEnd = (result: DropResult) => { - setIsDragging(false); - const { source, destination, draggableId } = result; - if (!destination) return; - - const sInd = source.droppableId; - const dInd = destination.droppableId; - - if (sInd === "toolbox" && dInd === "toolbox") { - setToolboxCourses((prev) => - reorder(prev, source.index, destination.index), - ); - return; - } - - if (dInd === "garbage" && sInd === "toolbox") { - setToolboxCourses((prev) => { - const updated = [...prev]; - updated.splice(source.index, 1); - return updated; - }); - return; - } - - if (sInd === dInd && dInd.startsWith("planner-")) { - const semesterIndex = parseInt(dInd.split("-")[1]); - setPlannerCourses((prev) => - prev.map((semester, idx) => - idx === semesterIndex - 1 - ? { - ...semester, - courseList: reorder( - semester.courseList, - source.index, - destination.index, - ), - } - : semester, - ), - ); - return; - } - - if (sInd.startsWith("planner-") && dInd.startsWith("planner-")) { - const sourceSemesterIndex = parseInt(sInd.split("-")[1]); - const destSemesterIndex = parseInt(dInd.split("-")[1]); - - setPlannerCourses((prev) => { - const updated = [...prev]; - const sourceIdx = sourceSemesterIndex - 1; - const destIdx = destSemesterIndex - 1; - - const sourceSemester = updated[sourceIdx]; - const destSemester = updated[destIdx]; - - if ( - !sourceSemester || - !destSemester || - source.index < 0 || - source.index >= sourceSemester.courseList.length - ) { - console.warn("Invalid source or destination index"); - return prev; - } - - const courseListCopy = [...sourceSemester.courseList]; - const [movedCourse] = courseListCopy.splice(source.index, 1); - - if (!movedCourse) { - console.warn("Failed to extract course from source"); - return prev; - } - - updated[sourceIdx] = { - ...sourceSemester, - courseList: courseListCopy, - creditsTotal: - sourceSemester.creditsTotal - movedCourse.data.credit_max, - }; - - const destListCopy = [...destSemester.courseList]; - destListCopy.splice(destination.index, 0, movedCourse); - - updated[destIdx] = { - ...destSemester, - courseList: destListCopy, - creditsTotal: destSemester.creditsTotal + movedCourse.data.credit_max, - }; - - return updated; - }); - - return; - } - - const fromToolbox = sInd === "toolbox"; - const toPlanner = dInd.startsWith("planner-"); - - if (fromToolbox && toPlanner) { - const courseToClone = toolboxCourses.find((c) => c.name === draggableId); - const semesterIndex = parseInt(dInd.split("-")[1]); - - if (courseToClone) { - const newCourse = cloneCourse(courseToClone); - - setPlannerCourses((prev) => - prev.map((semester, idx) => { - if (idx + 1 === semesterIndex) { - const updatedList = [...semester.courseList]; - updatedList.splice(destination.index, 0, newCourse); - - return { - ...semester, - courseList: updatedList, - creditsTotal: - semester.creditsTotal + courseToClone.data.credit_max, - }; - } - return semester; - }), - ); - - setToolboxCourses((prev) => - prev - .map((c) => - c.name === courseToClone.name ? { ...c, count: c.count - 1 } : c, - ) - .filter((c) => c.count > 0), - ); - } - - return; - } - - // if (sInd.startsWith("planner-") && dInd === "toolbox") { - // const sourceSemesterIndex = parseInt(sInd.split("-")[1]); - - // setPlannerCourses((prev) => { - // const updated = [...prev]; - // const sourceSemester = updated[sourceSemesterIndex - 1]; - // const courseListCopy = [...sourceSemester.courseList]; - // const [removedCourse] = courseListCopy.splice(source.index, 1); - - // if (!removedCourse) return prev; - - // updated[sourceSemesterIndex - 1] = { - // ...sourceSemester, - // courseList: courseListCopy, - // creditsTotal: - // sourceSemester.creditsTotal - removedCourse.data.credit_max, - // }; - - // setToolboxCourses((prevToolbox) => { - // const existing = prevToolbox.find( - // (c) => - // c.data.dept === removedCourse.data.dept && - // c.data.code_num === removedCourse.data.code_num - // ); - - // if (existing) { - // return prevToolbox.map((c) => - // c.name === existing.name ? { ...c, count: c.count + 1 } : c - // ); - // } else { - // return [ - // ...prevToolbox, - // { - // name: removedCourse.data.dept + removedCourse.data.code_num, - // data: removedCourse.data, - // count: 1, - // }, - // ]; - // } - // }); - - // return updated; - // }); - - // return; - // } - - if (sInd.startsWith("planner-") && dInd === "garbage") { - const sourceSemesterIndex = parseInt(sInd.split("-")[1]); - - setPlannerCourses((prev) => { - const updated = [...prev]; - const sourceSemester = updated[sourceSemesterIndex - 1]; - const courseListCopy = [...sourceSemester.courseList]; - const [removedCourse] = courseListCopy.splice(source.index, 1); - - if (!removedCourse) return prev; - - updated[sourceSemesterIndex - 1] = { - ...sourceSemester, - courseList: courseListCopy, - creditsTotal: - sourceSemester.creditsTotal - removedCourse.data.credit_max, - }; - - return updated; - }); - - return; - } - }; +import { DragDropContext } from "@hello-pangea/dnd"; +import { CourseWorkspaceProvider } from "./context/CourseWorkspaceProvider.tsx"; +import { FilterProvider } from "./context/FilterProvider.tsx"; +import { useCourseWorkspace } from "./hooks/useCourseWorkspace.ts"; + +const AppDragDropContext: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + // 2. Get the dnd functions from the hook + const { onDragStart, onDragEnd } = useCourseWorkspace(); + return ( + + {children} + + ); +}; +export default function App() { return ( -
-
-
- + + + - - } - /> - - } - /> - - } - /> + } /> + } /> + } /> - + - -
-
+ + + ); } - -export default App; diff --git a/src/components/Course/CatalogCourse.tsx b/src/components/Course/CatalogCourse.tsx new file mode 100644 index 0000000..315ccf9 --- /dev/null +++ b/src/components/Course/CatalogCourse.tsx @@ -0,0 +1,105 @@ +import React, { useState } from "react"; +import Tag from "./CatalogCourse/Tag"; +import AddButton from "./CatalogCourse/AddButton"; +import { motion } from "framer-motion"; +import { CourseType } from "../../types/interfaces/Course.interface"; +import { useCourseWorkspace } from "../../hooks/useCourseWorkspace"; + +interface CourseProps { + course: CourseType; +} + +const Course: React.FC = ({ course }) => { + const { toolboxCourses, setToolboxCourses } = useCourseWorkspace(); + + const [isOpen, setIsOpen] = useState(false); + const toggleOpen = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (target.id !== "add-button") setIsOpen((open) => !open); + }; + + const toTitleCase = (str: string) => { + return str + .toLowerCase() + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + }; + + const courseDisplay: string = `${course.dept + course.code_num} ${toTitleCase(course.title)}`; + const addCourse = (e: React.MouseEvent) => { + e.stopPropagation(); + + setToolboxCourses((prevCourses) => { + const existingIndex = prevCourses.findIndex( + (c) => c.name === courseDisplay + ); + + if (existingIndex !== -1) { + return prevCourses.map((c, i) => + i === existingIndex ? { ...c, count: c.count + 1 } : c + ); + } else { + return [ + ...prevCourses, + { name: courseDisplay, count: 1, data: course }, + ]; + } + }); + }; + const toolboxCourse = + toolboxCourses[toolboxCourses.findIndex((c) => c.name === courseDisplay)]; + const courseCount = toolboxCourse ? toolboxCourse.count : undefined; + return ( +
+
+

{courseCount}

+
+ +
+
+
+ + {course.dept} + {course.code_num} + +

{toTitleCase(course.title)}

+
+
+ + {course.attr_list?.split(",").map((attr, index) => { + return ; + })} + {course.sem_list?.split(",").map((semester, index) => { + return ; + })} +
+
+
+ +
+
+
+ + {course.desc_text.trim() === "" + ? "Empty Description" + : course.desc_text} + +
+
+ ); +}; + +export default Course; diff --git a/src/components/Course/AddButton.tsx b/src/components/Course/CatalogCourse/AddButton.tsx similarity index 57% rename from src/components/Course/AddButton.tsx rename to src/components/Course/CatalogCourse/AddButton.tsx index 3317048..745f811 100644 --- a/src/components/Course/AddButton.tsx +++ b/src/components/Course/CatalogCourse/AddButton.tsx @@ -6,14 +6,12 @@ interface AddButtonProps { } const AddButton: React.FC = ({ addCourse }) => { return ( - <> - - + ); }; diff --git a/src/components/Course/CatalogCourse/Tag.tsx b/src/components/Course/CatalogCourse/Tag.tsx new file mode 100644 index 0000000..7f9777e --- /dev/null +++ b/src/components/Course/CatalogCourse/Tag.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +interface TagProp { + name: string; + color: string; +} +const Tag: React.FC = ({ name, color }) => { + return ( +
+ {name} +
+ ); +}; + +export default Tag; diff --git a/src/components/Course/Course.tsx b/src/components/Course/Course.tsx deleted file mode 100644 index a878a70..0000000 --- a/src/components/Course/Course.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { useState } from "react"; -import Tag from "./Tag"; -import AddButton from "./AddButton"; -import { motion } from "framer-motion"; -import { - CourseType, - CourseEntry, -} from "../../types/interfaces/Course.interface"; - -interface CourseProps { - course: CourseType; - toolboxCourses: CourseEntry[]; - setToolboxCourses: React.Dispatch>; -} - -const Course: React.FC = ({ - course, - toolboxCourses, - setToolboxCourses, -}) => { - const [isOpen, setIsOpen] = useState(false); - const toggleOpen = (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if (target.id !== "add-button") setIsOpen((open) => !open); - }; - - const toTitleCase = (str: string) => { - return str - .toLowerCase() - .split(" ") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - }; - - const courseDisplay: string = `${course.dept + course.code_num} ${toTitleCase(course.title)}`; - const addCourse = (e: React.MouseEvent) => { - e.stopPropagation(); - - setToolboxCourses((prevCourses) => { - const existingIndex = prevCourses.findIndex( - (c) => c.name === courseDisplay, - ); - - if (existingIndex !== -1) { - return prevCourses.map((c, i) => - i === existingIndex ? { ...c, count: c.count + 1 } : c, - ); - } else { - return [ - ...prevCourses, - { name: courseDisplay, count: 1, data: course }, - ]; - } - }); - }; - const toolboxCourse = - toolboxCourses[toolboxCourses.findIndex((c) => c.name === courseDisplay)]; - const courseCount = toolboxCourse ? toolboxCourse.count : undefined; - return ( - <> -
-
-

{courseCount}

-
-
-
-
- - {course.dept} - {course.code_num} - - - - {course.credit_min !== course.credit_max ? ( - - {course.credit_min}–{course.credit_max} credits - - ) : ( - - {course.credit_max} credits - - )} - - -

{toTitleCase(course.title)}

-
-
- - {course.attr_list?.split(",").map((attr, index) => { - return ; - })} - {course.sem_list?.split(",").map((semester, index) => { - return ; - })} -
-
-
- -
-
-
- - {course.desc_text.trim() === "" - ? "Empty Description" - : course.desc_text} - -
-
- - ); -}; - -export default Course; diff --git a/src/components/Course/PlannerCourse.tsx b/src/components/Course/PlannerCourse.tsx new file mode 100644 index 0000000..a2e9d7d --- /dev/null +++ b/src/components/Course/PlannerCourse.tsx @@ -0,0 +1,154 @@ +import React, { + useState, + useEffect, + useRef, + Dispatch, + SetStateAction, +} from "react"; +import { MdDragIndicator, MdOutlineMoreHoriz } from "react-icons/md"; +import { CourseType } from "../../types/interfaces/Course.interface"; +import { SemesterType } from "../../types/interfaces/Semester.interface"; +import { CourseEntry } from "../../types/interfaces/Course.interface"; +import * as RightClickContextMenu from "@radix-ui/react-context-menu"; +import { usePlannerCourse } from "../../hooks/usePlannerCourseOptions"; +import PlannerOptionsPopup from "../PlannerComponents/PlannerOptionsPopup"; + +interface PlannerCourseProps { + course: CourseType; + isDragging?: boolean; + index: number; + setPlannerCourses: Dispatch>; + setToolboxCourses: Dispatch>; + semesterIndex: number; + count: number; +} + +const PlannerCourse: React.FC = ({ + course, + count, + index, + setPlannerCourses, + setToolboxCourses, + semesterIndex, +}) => { + const [openPopup, setOpenPopup] = useState(false); + const componentRef = useRef(null); + + const togglePopup = (event: React.MouseEvent) => { + event.stopPropagation(); + setOpenPopup((prev) => !prev); + }; + + const handleSelect = (action: () => void) => { + action(); + setOpenPopup(false); + }; + + const { + handleDuplicate, + handleMoveNext, + handleMoveToolbox, + handleDelete, + toTitleCase, + } = usePlannerCourse({ + setPlannerCourses: setPlannerCourses, + setToolboxCourses: setToolboxCourses, + semesterIndex: semesterIndex, + courseIndex: index, + course: course, + name: course.title, + count: count, + }); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + componentRef.current && + !componentRef.current.contains(event.target as Node) + ) { + setOpenPopup(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + /* Right-Click Context Menu */ + return ( + + +
+
+ +
+ + {course.dept} + {course.code_num} + +

{toTitleCase(course.title)}

+
+
+ +
+
+

{course.credit_max}

+
+ +
+ + {openPopup && ( + + )} +
+
+ + + + Duplicate + + + Move to next sem + + + + Move back to toolbox + + + Delete + + + +
+ ); +}; + +export default PlannerCourse; diff --git a/src/components/Course/Tag.tsx b/src/components/Course/Tag.tsx deleted file mode 100644 index f5c848a..0000000 --- a/src/components/Course/Tag.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from "react"; - -interface TagProp { - name: string; - color: string; -} -const Tag: React.FC = ({ name, color }) => { - return ( - <> -
- {name} -
- - ); -}; - -export default Tag; diff --git a/src/components/Course/ToolboxCourse.tsx b/src/components/Course/ToolboxCourse.tsx new file mode 100644 index 0000000..ffe5581 --- /dev/null +++ b/src/components/Course/ToolboxCourse.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { CourseType } from "../../types/interfaces/Course.interface"; +import { MdDragIndicator } from "react-icons/md"; + +interface ToolboxCourseProps { + name: string; + count: number; + index: number; + isDragging: boolean; + course: CourseType; +} + +const ToolboxCourse: React.FC = ({ + name, + count, + isDragging, + course, +}) => { + const toTitleCase = (str: string) => { + return str + .toLowerCase() + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + }; + + if (isDragging) { + return ( +
+
+ +
+ + {course.dept} + {course.code_num} + +

{toTitleCase(course.title)}

+
+
+ +
+
+

{course.credit_max}

+
+
+
+ ); + } + + return ( +
+
+

{count}

+
+ {name} +
+ ); +}; + +export default ToolboxCourse; diff --git a/src/components/Department-Filters.tsx b/src/components/Department-Filters.tsx index d88d008..8bd1242 100644 --- a/src/components/Department-Filters.tsx +++ b/src/components/Department-Filters.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Filters } from "../types/Filters"; +import { useFilterData } from "../hooks/useFilters"; // Department type interface Department { @@ -53,42 +53,41 @@ const departments: Department[] = [ { code: "WRIT", name: "Writing" }, ]; -// DepartmentFilters component -interface DepartmentFiltersProps { - updateFilters: (category: keyof Filters, value: string) => void; -} +const DepartmentFilters: React.FC = () => { + const { toggleFilter } = useFilterData(); -// DepartmentFilters functional component -const DepartmentFilters: React.FC = ({ - updateFilters, -}) => { return ( -
-
- {departments.map((dept) => ( - - ))} -
+
+ {departments.map((dept) => ( + + ))} + +
); }; diff --git a/src/components/DraggableItem.tsx b/src/components/DraggableItem.tsx index 67d1ef9..6ef87eb 100644 --- a/src/components/DraggableItem.tsx +++ b/src/components/DraggableItem.tsx @@ -1,17 +1,17 @@ -import { Draggable } from "@hello-pangea/dnd"; -import PlannerCourse from "./PlannerComponents/PlannerCourse"; -import ToolboxCourse from "./Toolbox/ToolboxCourse"; -import { CourseType } from "../types/interfaces/Course.interface"; import { Dispatch, SetStateAction } from "react"; +import { Draggable } from "@hello-pangea/dnd"; import { SemesterType } from "../types/interfaces/Semester.interface"; -import { CourseEntry } from "../types/interfaces/Course.interface"; +import { CourseType, CourseEntry } from "../types/interfaces/Course.interface"; + +import PlannerCourse from "./Course/PlannerCourse"; +import ToolboxCourse from "./Course/ToolboxCourse"; interface DraggableItemProps { name: string; count: number; index: number; course: CourseType; - location: "toolbox" | "planner"; + location: string; setPlannerCourses: Dispatch> | null; setToolboxCourses: Dispatch> | null; semesterIndex: number | null; @@ -29,39 +29,41 @@ const DraggableItem: React.FC = ({ }) => { return ( - {(provided, snapshot) => ( -
- {location === "planner" && - setPlannerCourses && - setToolboxCourses && - semesterIndex !== null ? ( - - ) : ( - - )} -
- )} + {(provided, snapshot) => { + const style = { + ...provided.draggableProps.style, + cursor: "grab", + }; + + return ( +
+ {location !== "toolbox" ? ( + + ) : ( + + )} +
+ ); + }}
); }; diff --git a/src/components/GarbageBin.tsx b/src/components/GarbageBin.tsx index a4229d4..f1aacd8 100644 --- a/src/components/GarbageBin.tsx +++ b/src/components/GarbageBin.tsx @@ -14,11 +14,11 @@ const GarbageBin: React.FC = ({ isDragging }) => {
{provided.placeholder}
diff --git a/src/components/PlannerComponents/AddSemester.tsx b/src/components/PlannerComponents/AddSemester.tsx index 52f180e..53c56c0 100644 --- a/src/components/PlannerComponents/AddSemester.tsx +++ b/src/components/PlannerComponents/AddSemester.tsx @@ -19,14 +19,12 @@ const AddSemester: React.FC = ({ setPlannerCourses }) => { }; return ( - <> - - + ); }; diff --git a/src/components/PlannerComponents/DeleteSemester.tsx b/src/components/PlannerComponents/DeleteSemester.tsx index 9002fae..ceb4ba7 100644 --- a/src/components/PlannerComponents/DeleteSemester.tsx +++ b/src/components/PlannerComponents/DeleteSemester.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { SemesterType } from "../../types/interfaces/Semester.interface"; interface DeleteSemesterProps { semesterNumber: number; diff --git a/src/components/PlannerComponents/PlannerCourse.tsx b/src/components/PlannerComponents/PlannerCourse.tsx deleted file mode 100644 index 72953d7..0000000 --- a/src/components/PlannerComponents/PlannerCourse.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import React, { - useState, - useEffect, - useRef, - Dispatch, - SetStateAction, -} from "react"; -import { MdDragIndicator, MdOutlineMoreHoriz } from "react-icons/md"; -import { CourseType } from "../../types/interfaces/Course.interface"; -import { SemesterType } from "../../types/interfaces/Semester.interface"; -import { CourseEntry } from "../../types/interfaces/Course.interface"; -import * as RightClickContextMenu from "@radix-ui/react-context-menu"; - -interface PlannerCourseProps { - course: CourseType; - isDragging: boolean; - index: number; - setPlannerCourses: Dispatch>; - setToolboxCourses: Dispatch>; - semesterIndex: number; -} - -const PlannerCourse: React.FC = ({ - course, - index, - setPlannerCourses, - setToolboxCourses, - semesterIndex, -}) => { - const [openPopup, setOpenPopup] = useState(false); - const componentRef = useRef(null); - - const togglePopup = (event: React.MouseEvent) => { - event.stopPropagation(); - setOpenPopup((prev) => !prev); - }; - - const handleClickOutside = (event: MouseEvent) => { - if ( - componentRef.current && - event.target instanceof Node && - !componentRef.current.contains(event.target) - ) { - setOpenPopup(false); - } - }; - - const handleDuplicate = () => { - setPlannerCourses((prev) => - prev.map((semester, i) => - i === semesterIndex - 1 - ? { - ...semester, - creditsTotal: - semester.creditsTotal + - semester.courseList[index].data.credit_max, - courseList: [ - ...semester.courseList.slice(0, index + 1), - { - name: getNextName(semester.courseList[index].name), - count: semester.courseList[index].count, - data: semester.courseList[index].data, - }, - ...semester.courseList.slice(index + 1), - ], - } - : semester, - ), - ); - }; - - const getNextName = (originalName: string): string => { - const [base, tag] = originalName.split("-"); - - if (!tag) { - return `${base}-A`; - } - - const nextTag = incrementTag(tag); - return `${base}-${nextTag}`; - }; - - const incrementTag = (tag: string): string => { - const chars = tag.toUpperCase().split(""); - let carry = true; - - for (let i = chars.length - 1; i >= 0 && carry; i--) { - if (chars[i] === "Z") { - chars[i] = "A"; - } else { - chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1); - carry = false; - } - } - - if (carry) { - chars.unshift("A"); - } - - return chars.join(""); - }; - - const handleMoveNext = () => { - setPlannerCourses((prev) => { - const courseCopy = { - name: prev[semesterIndex - 1].courseList[index].name, - count: prev[semesterIndex - 1].courseList[index].count, - data: prev[semesterIndex - 1].courseList[index].data, - }; - - const updatedPlanner = prev.map((semester, i) => - i === semesterIndex - 1 - ? { - ...semester, - courseList: semester.courseList.filter((_, idx) => idx !== index), - creditsTotal: - semester.creditsTotal - - prev[semesterIndex - 1].courseList[index].data.credit_max, - } - : semester, - ); - - return updatedPlanner.map((semester, i) => - i === semesterIndex - ? { - ...semester, - courseList: [...semester.courseList, courseCopy], - creditsTotal: semester.creditsTotal + courseCopy.data.credit_max, - } - : semester, - ); - }); - }; - - const handleMoveToolbox = () => { - setPlannerCourses((prev) => { - const courseToMove = prev[semesterIndex - 1].courseList[index]; - - const cleanedName = courseToMove.name.split("-")[0]; - - const cleanedCourse = { - ...courseToMove, - name: cleanedName, - }; - - const updatedPlanner = prev.map((semester, i) => - i === semesterIndex - 1 - ? { - ...semester, - courseList: semester.courseList.filter((_, idx) => idx !== index), - creditsTotal: - semester.creditsTotal - courseToMove.data.credit_max, - } - : semester, - ); - - setToolboxCourses((toolboxPrev) => { - const existingIndex = toolboxPrev.findIndex( - (entry) => entry.name === cleanedName, - ); - - if (existingIndex !== -1) { - const updated = [...toolboxPrev]; - updated[existingIndex].count += cleanedCourse.count; - return updated; - } else { - return [...toolboxPrev, cleanedCourse]; - } - }); - - return updatedPlanner; - }); - }; - - const handleDelete = () => { - setPlannerCourses((prev) => - prev.map((semester, i) => - i === semesterIndex - 1 - ? { - ...semester, - courseList: semester.courseList.filter((_, idx) => idx !== index), - creditsTotal: - semester.creditsTotal - - semester.courseList[index].data.credit_max, - } - : semester, - ), - ); - }; - - useEffect(() => { - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); - - const toTitleCase = (str: string) => { - return str - .toLowerCase() - .split(" ") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - }; - - /* Right-Click Context Menu */ - return ( - - -
-
-
-
- -
- - {course.dept} - {course.code_num} - -

{toTitleCase(course.title)}

-
-
-
-
-
-

{course.credit_max}

-
-
-
-
-
- - - - - Duplicate - - - Move to next sem - - - - Move back to toolbox - - - Delete - - - -
- ); -}; - -export default PlannerCourse; diff --git a/src/components/PlannerComponents/PlannerCourseHolder.tsx b/src/components/PlannerComponents/PlannerCourseHolder.tsx index 419e2a3..89b41ec 100644 --- a/src/components/PlannerComponents/PlannerCourseHolder.tsx +++ b/src/components/PlannerComponents/PlannerCourseHolder.tsx @@ -8,21 +8,15 @@ const PlannerCourseHolder: React.FC = ({ isHover, }) => { return ( - <> -
-

- Try dragging a course from your Toolbox -

- - {isHover && ( -
-

Drop it here!

-
- )} -
- +
+ {isHover ? ( +

Drop the course!

+ ) : ( +

Drag a course here to add it to this semester

+ )} +
); }; diff --git a/src/components/PlannerComponents/PlannerOptionsPopup.tsx b/src/components/PlannerComponents/PlannerOptionsPopup.tsx new file mode 100644 index 0000000..782b2e4 --- /dev/null +++ b/src/components/PlannerComponents/PlannerOptionsPopup.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +interface PlannerOptionsPopupProps { + handleSelect: (action: () => void) => void; + handleDuplicate: () => void; + handleMoveNext: () => void; + handleMoveToolbox: () => void; + handleDelete: () => void; +} + +const PlannerOptionsPopup: React.FC = ({ + handleSelect, + ...actions +}) => { + return ( +
+ + +
+ + +
+ ); +}; + +export default PlannerOptionsPopup; diff --git a/src/components/PlannerComponents/RightClickContext.tsx b/src/components/PlannerComponents/RightClickContext.tsx index ea71015..0b6a23b 100644 --- a/src/components/PlannerComponents/RightClickContext.tsx +++ b/src/components/PlannerComponents/RightClickContext.tsx @@ -41,8 +41,8 @@ const PlannerCourse: React.FC = ({ ...semester.courseList.slice(index + 1), ], } - : semester, - ), + : semester + ) ); }; @@ -76,7 +76,7 @@ const PlannerCourse: React.FC = ({ courseList: s.courseList.filter((_, idx) => idx !== index), creditsTotal: s.creditsTotal - courseCopy.data.credit_max, } - : s, + : s ); return updated.map((s, i) => i === semesterIndex @@ -85,7 +85,7 @@ const PlannerCourse: React.FC = ({ courseList: [...s.courseList, courseCopy], creditsTotal: s.creditsTotal + courseCopy.data.credit_max, } - : s, + : s ); }); }; @@ -103,12 +103,12 @@ const PlannerCourse: React.FC = ({ courseList: s.courseList.filter((_, idx) => idx !== index), creditsTotal: s.creditsTotal - courseToMove.data.credit_max, } - : s, + : s ); setToolboxCourses((toolboxPrev) => { const existingIndex = toolboxPrev.findIndex( - (entry) => entry.name === cleanedName, + (entry) => entry.name === cleanedName ); if (existingIndex !== -1) { const updatedToolbox = [...toolboxPrev]; @@ -132,8 +132,8 @@ const PlannerCourse: React.FC = ({ creditsTotal: s.creditsTotal - s.courseList[index].data.credit_max, } - : s, - ), + : s + ) ); }; @@ -175,10 +175,7 @@ const PlannerCourse: React.FC = ({ {/* Context Menu Content */} - + >; - setToolboxCourses: Dispatch>; -} - -const SemesterBlock: React.FC = ({ - semester, - index, - setPlannerCourses, - setToolboxCourses, -}) => { - return ( - <> -
-
-
-
- SEM - - {semester.semesterNumber} - -
-
-
- -
-
- - -
- credits:{" "} - {semester.creditsTotal} -
-
- - {(provided, snapshot) => { - const isEmpty = semester.courseList.length === 0; - const isHovering = snapshot.isDraggingOver; - - return ( -
- {isEmpty && !isHovering && ( - - )} - {isEmpty && isHovering && ( - - )} - {!isEmpty && - semester.courseList.map((course, courseIndex) => ( - - ))} - {provided.placeholder} -
- ); - }} -
-
-
- - ); -}; - -export default SemesterBlock; diff --git a/src/components/PlannerComponents/SemesterBlocks/DesktopSemesterBlock.tsx b/src/components/PlannerComponents/SemesterBlocks/DesktopSemesterBlock.tsx new file mode 100644 index 0000000..b17896a --- /dev/null +++ b/src/components/PlannerComponents/SemesterBlocks/DesktopSemesterBlock.tsx @@ -0,0 +1,70 @@ +import React from "react"; + +import { Droppable } from "@hello-pangea/dnd"; +import PlannerCourseHolder from "../PlannerCourseHolder"; +import DraggableItem from "../../DraggableItem"; + +import { SemesterBlockProps } from "./SemesterBlock"; + +const DesktopSemesterBlock: React.FC = ({ + semester, + index, + setPlannerCourses, + setToolboxCourses, +}) => { + return ( +
+
+ + Semester {semester.semesterNumber} + +
+ +
{semester.creditsTotal} credits
+
+
+ + + {(provided, snapshot) => { + const isEmpty = semester.courseList.length === 0; + const isHovering = snapshot.isDraggingOver; + + return ( +
+ {isEmpty && !isHovering && ( + + )} + {isEmpty && isHovering && } + {!isEmpty && ( + <> + {semester.courseList.map((course, courseIndex) => ( + + ))} + {provided.placeholder} + + )} +
+ ); + }} +
+
+ ); +}; + +export default DesktopSemesterBlock; diff --git a/src/components/PlannerComponents/SemesterBlocks/MobileSemesterBlock.tsx b/src/components/PlannerComponents/SemesterBlocks/MobileSemesterBlock.tsx new file mode 100644 index 0000000..bd59d5d --- /dev/null +++ b/src/components/PlannerComponents/SemesterBlocks/MobileSemesterBlock.tsx @@ -0,0 +1,82 @@ +import React from "react"; + +import { Droppable } from "@hello-pangea/dnd"; +import PlannerCourseHolder from "../PlannerCourseHolder"; +import DraggableItem from "../../DraggableItem"; + +import { SemesterBlockProps } from "./SemesterBlock"; + +const SemesterBlock: React.FC = ({ + semester, + index, + setPlannerCourses, + setToolboxCourses, +}) => { + return ( +
+
+
+
+ SEM + + {semester.semesterNumber} + +
+
+
+ +
+
+ + +
+ credits: {semester.creditsTotal} +
+
+ + {(provided, snapshot) => { + const isEmpty = semester.courseList.length === 0; + const isHovering = snapshot.isDraggingOver; + + return ( +
+ {isEmpty && !isHovering && ( + + )} + {isEmpty && isHovering && ( + + )} + {!isEmpty && ( + <> + {semester.courseList.map((course, courseIndex) => ( + + ))} + {provided.placeholder} + + )} +
+ ); + }} +
+
+
+ ); +}; + +export default SemesterBlock; diff --git a/src/components/PlannerComponents/SemesterBlocks/SemesterBlock.tsx b/src/components/PlannerComponents/SemesterBlocks/SemesterBlock.tsx new file mode 100644 index 0000000..9c3536b --- /dev/null +++ b/src/components/PlannerComponents/SemesterBlocks/SemesterBlock.tsx @@ -0,0 +1,47 @@ +import React, { Dispatch, SetStateAction } from "react"; +import useIsDesktop from "../../../hooks/useIsDesktop"; + +import { SemesterType } from "../../../types/interfaces/Semester.interface"; +import { CourseEntry } from "../../../types/interfaces/Course.interface"; +import MobileSemesterBlock from "./MobileSemesterBlock"; +import DesktopSemesterBlock from "./DesktopSemesterBlock"; + +export interface SemesterBlockProps { + semester: SemesterType; + index: number; + setPlannerCourses: Dispatch>; + setToolboxCourses: Dispatch>; +} + +const SemesterBlock: React.FC = ({ + semester, + index, + setPlannerCourses, + setToolboxCourses, +}) => { + const isDesktop = useIsDesktop(); + + if (isDesktop) { + return ( + + ); + } else { + return ( + + ); + } +}; + +export default SemesterBlock; diff --git a/src/components/SearchBar/ChosenTag.tsx b/src/components/SearchBar/ChosenTag.tsx index f5c9c4e..64c4e09 100644 --- a/src/components/SearchBar/ChosenTag.tsx +++ b/src/components/SearchBar/ChosenTag.tsx @@ -1,21 +1,28 @@ import React from "react"; import { IoClose } from "react-icons/io5"; +import { useFilterData } from "../../hooks/useFilters"; +import { FilterData } from "../../types/Filters"; interface ChosenTagProp { - name: string; - onRemove: (name: string) => void; + filter: FilterData; } -const ChosenTag: React.FC = ({ name, onRemove }) => { +const ChosenTag: React.FC = ({ filter }) => { + const { toggleFilter } = useFilterData(); return ( - <> -
+ {filter.code} +
- + + +
); }; diff --git a/src/components/SearchBar/FilterPanel.tsx b/src/components/SearchBar/FilterPanel.tsx deleted file mode 100644 index 7ba1a7f..0000000 --- a/src/components/SearchBar/FilterPanel.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import api from "../../axios"; -import { Filters } from "../../types/Filters"; -import FilterSection from "./FilterSection"; -import { useEffect, useState } from "react"; - -interface FilterPanelProps { - filters: { [key: string]: string[] }; - updateFilters: (category: keyof Filters, value: string) => void; -} - -const FilterPanel: React.FC = ({ - filters, - updateFilters, -}) => { - const [subjects, setSubjects] = useState([]); - const [attributes, setAttributes] = useState([]); - const [semesters, setSemesters] = useState([]); - - useEffect(() => { - console.log("getting filters"); - - const fetchSubjects = async () => { - const response = await api.get("/course/filter/values/departments"); - const subjects = response.data.map((subject: string, index: number) => { - return { - id: index, - code: subject, - }; - }); - setSubjects(subjects); - }; - - const fetchAttributes = async () => { - const response = await api.get("/course/filter/values/attributes"); - const attributes = response.data.map( - (attribute: string, index: number) => { - return { - id: index, - code: attribute, - }; - }, - ); - setAttributes(attributes); - }; - - const fetchSemesters = async () => { - const response = await api.get("/course/filter/values/semesters"); - const semesters = response.data.map((semester: string, index: number) => { - return { - id: index, - code: semester, - }; - }); - setSemesters(semesters); - }; - - fetchSubjects(); - fetchAttributes(); - fetchSemesters(); - }, []); - - return ( - <> - - - - - ); -}; - -export default FilterPanel; diff --git a/src/components/SearchBar/FilterPanel/FilterPanel.tsx b/src/components/SearchBar/FilterPanel/FilterPanel.tsx new file mode 100644 index 0000000..a27a7e3 --- /dev/null +++ b/src/components/SearchBar/FilterPanel/FilterPanel.tsx @@ -0,0 +1,42 @@ +import { useFilterData } from "../../../hooks/useFilters"; +import FilterSection from "./FilterSection"; + +const FilterPanel: React.FC = () => { + const { selectedFilters, toggleFilter, subjects, attributes, semesters } = + useFilterData(); + + const selectedSubjects = selectedFilters + .filter((f) => f.type === "Subject") + .map((f) => f.code); + const selectedAttributes = selectedFilters + .filter((f) => f.type === "Attributes") + .map((f) => f.code); + const selectedSemesters = selectedFilters + .filter((f) => f.type === "Semesters") + .map((f) => f.code); + + return ( +
+ + + +
+ ); +}; + +export default FilterPanel; diff --git a/src/components/SearchBar/FilterPanel/FilterPanelPopup.tsx b/src/components/SearchBar/FilterPanel/FilterPanelPopup.tsx new file mode 100644 index 0000000..226325a --- /dev/null +++ b/src/components/SearchBar/FilterPanel/FilterPanelPopup.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import FilterPanel from "./FilterPanel"; + +const FilterPanelPopup: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default FilterPanelPopup; diff --git a/src/components/SearchBar/FilterPanel/FilterSection.tsx b/src/components/SearchBar/FilterPanel/FilterSection.tsx new file mode 100644 index 0000000..53dabeb --- /dev/null +++ b/src/components/SearchBar/FilterPanel/FilterSection.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { FilterData } from "../../../types/Filters"; + +interface TagProp { + id: number; + code: string; +} + +interface FilterSectionProps { + sectionName: "Subject" | "Attributes" | "Semesters"; + tags: TagProp[]; + selected: string[]; + toggleFilter: (filter: FilterData) => void; +} + +const FilterSection: React.FC = ({ + sectionName, + tags, + selected, + toggleFilter, +}) => { + return ( +
+

{sectionName}

+
+ {tags.map((tag) => ( + + ))} +
+
+ ); +}; + +export default FilterSection; diff --git a/src/components/SearchBar/FilterSection.tsx b/src/components/SearchBar/FilterSection.tsx deleted file mode 100644 index 16c1e13..0000000 --- a/src/components/SearchBar/FilterSection.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; -import { Filters } from "../../types/Filters"; - -interface TagProp { - id: number; - code: string; -} - -interface FilterSectionProps { - sectionName: keyof Filters; - tags: TagProp[]; - selected: string[]; - updateFilters: (category: keyof Filters, value: string) => void; -} - -const FilterSection: React.FC = ({ - sectionName, - tags, - selected, - updateFilters, -}) => { - return ( -
-

{sectionName}

-
- {tags.map((tag) => ( - - ))} -
-
- ); -}; - -export default FilterSection; diff --git a/src/components/SearchBar/SeachBar.tsx b/src/components/SearchBar/SeachBar.tsx index 5c8103c..b673867 100644 --- a/src/components/SearchBar/SeachBar.tsx +++ b/src/components/SearchBar/SeachBar.tsx @@ -1,195 +1,84 @@ import React, { useState, useEffect, useRef } from "react"; -import { IoClose, IoSearchOutline } from "react-icons/io5"; -import api from "../../axios"; -import FilterPanel from "./FilterPanel"; -import ChosenTag from "./ChosenTag"; -import { Filters } from "../../types/Filters"; -import DepartmentFilters from "../Department-Filters"; -import { CourseType } from "../../types/interfaces/Course.interface.ts"; +import { IoClose, IoSearchOutline, IoFilter } from "react-icons/io5"; +import FilterPanel from "./FilterPanel/FilterPanel.tsx"; +import useIsDesktop from "../../hooks/useIsDesktop.ts"; +import FilterPanelPopup from "./FilterPanel/FilterPanelPopup.tsx"; interface SearchBarProps { - updateSearchResults: (results: CourseType[]) => void; - // New props for state lifted to App.tsx searchPrompt: string; setSearchPrompt: React.Dispatch>; - showFilter: boolean; - setShowFilter: React.Dispatch>; - filters: Filters; - setFilters: React.Dispatch>; } -const SearchBar: React.FC = ({ updateSearchResults }) => { - const [showDeptFilter, setShowDeptFilter] = useState(true); - const [searchPrompt, setSearchPrompt] = useState(""); +const SearchBar: React.FC = ({ + searchPrompt, + setSearchPrompt, +}) => { + const isDesktop = useIsDesktop(); + const componentRef = useRef(null); + const [showFilter, setShowFilter] = useState(false); - const [filters, setFilters] = useState({ - Subject: [], - Attributes: [], - Semesters: [], - }); - const debounceTimeout = useRef | null>(null); - const lastNoResultQuery = useRef(null); + const handleInputChange = (e: React.ChangeEvent) => { + setSearchPrompt(e.target.value); + setShowFilter(false); + }; useEffect(() => { - if (debounceTimeout.current) { - clearTimeout(debounceTimeout.current); - } - - debounceTimeout.current = setTimeout(async () => { - const deptFilters = filters.Subject.join(","); - const attrFilters = filters.Attributes.join(","); - const semFilters = filters.Semesters.join(","); - - if (searchPrompt.length < 3) { - lastNoResultQuery.current = null; - } - + const handleClickOutside = (event: MouseEvent) => { if ( - lastNoResultQuery.current && - searchPrompt.startsWith(lastNoResultQuery.current) + componentRef.current && + !componentRef.current.contains(event.target as Node) ) { - console.log("Skipping redundant no-result query"); - return; - } - - try { - const response = await api.get( - "course/search?searchPrompt=" + - searchPrompt + - "&deptFilters=" + - deptFilters + - "&attrFilters=" + - attrFilters + - "&semFilters=" + - semFilters, - ); - - const data = response.data; - updateSearchResults(data); - - if (data.length === 0 && searchPrompt.length >= 3) { - lastNoResultQuery.current = searchPrompt; - } else { - lastNoResultQuery.current = null; - } - } catch (error) { - console.error("Search error:", error); + setShowFilter(false); } - }, 300); // Change this delay if you want + }; + document.addEventListener("mousedown", handleClickOutside); return () => { - if (debounceTimeout.current) clearTimeout(debounceTimeout.current); + document.removeEventListener("mousedown", handleClickOutside); }; - }, [filters, searchPrompt]); - - const handleSearch = (e: React.KeyboardEvent) => { - (e.currentTarget as HTMLInputElement).blur(); - setShowFilter(false); - }; - - const updateFilters = (category: keyof Filters, value: string) => { - if (showDeptFilter) { - setShowDeptFilter(false); - window.scrollTo(0, 0); - } - - setFilters((prev) => { - if (prev[category].includes(value)) { - const newFilters: Filters = { ...prev }; - newFilters[category] = newFilters[category].filter( - (tag) => tag !== value, - ); - return newFilters; - } - return { - ...prev, - [category]: [...prev[category], value], - }; - }); - }; - - const removeFilter = (value: string) => { - setFilters((prev) => { - const newFilters: Filters = { ...prev }; - for (const category of Object.keys(prev) as (keyof Filters)[]) { - newFilters[category] = newFilters[category].filter( - (tag) => tag !== value, - ); - } - - if ( - newFilters.Subject.length === 0 && - newFilters.Attributes.length === 0 && - newFilters.Semesters.length === 0 - ) { - setShowDeptFilter(true); - setShowFilter(false); - } - return newFilters; - }); - }; + }, []); return ( -
-
-
- - setShowFilter(true)} - onChange={(e) => setSearchPrompt(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === "Done") handleSearch(e); +
+
+ + setShowFilter(false)} + onChange={handleInputChange} + /> + + {showFilter ? ( + { + setShowFilter(false); // Uses prop + setSearchPrompt(""); // Uses prop }} + className="w-5 h-5 hover:cursor-pointer" /> -
- {showFilter && ( - { - setShowFilter(false); - setSearchPrompt(""); + setShowFilter(true); // Uses prop }} + className="w-5 h-5 hover:cursor-pointer" /> )} -
- -
-
- {filters.Subject.map((tag, index) => ( - - ))} - {filters.Attributes.map((tag, index) => ( - - ))} - {filters.Semesters.map((tag, index) => ( - - ))} -
- + {showFilter && isDesktop && }
- {showFilter && ( - - )} - {showDeptFilter && } + {showFilter && !isDesktop && }
); }; diff --git a/src/components/Toolbox/Toolbox.tsx b/src/components/Toolbox/Toolbox.tsx index 9ac68d5..cc11f53 100644 --- a/src/components/Toolbox/Toolbox.tsx +++ b/src/components/Toolbox/Toolbox.tsx @@ -1,87 +1,112 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import useIsDesktop from "../../hooks/useIsDesktop"; import ToolboxButton from "./ToolboxButton"; import NavButton from "./NavButton"; import { Droppable } from "@hello-pangea/dnd"; import { IoIosArrowDown } from "react-icons/io"; -import { CourseEntry } from "../../types/interfaces/Course.interface"; import GarbageBin from "../GarbageBin"; import DraggableItem from "../DraggableItem"; +import { useCourseWorkspace } from "../../hooks/useCourseWorkspace"; -interface ToolboxProps { - courses: CourseEntry[]; - isDragging: boolean; -} - -const Toolbox: React.FC = ({ courses, isDragging }) => { - const [isOpen, setIsOpen] = useState(false); +const Toolbox: React.FC = () => { const isDesktop = useIsDesktop(); + const [isOpen, setIsOpen] = useState(isDesktop); + const [count, setCount] = useState(0); + + const { toolboxCourses, isDragging } = useCourseWorkspace(); + + useEffect(() => { + setCount( + toolboxCourses.reduce((total, course) => total + (course.count || 0), 0) + ); + }, [toolboxCourses]); + + useEffect(() => { + setIsOpen(isDesktop); + }, [isDesktop]); const toggleToolbox = () => { setIsOpen((open) => !open); }; - const toolboxSum = () => { - let sum = 0; - courses.forEach((course) => (sum += course.count)); - return sum; - }; return ( - <> -
+
+ {!isDesktop && !isOpen && ( + )} + +
-
-
-
- -
- - {(provided, snapshot) => { - return ( -
- {courses.map((course, index) => ( - - ))} - {provided.placeholder} -
- ); - }} -
+
+
+

TOOLBOX

+
+ {count > 0 && ( +
+ {count} +
+ )} +
- {!isDesktop && } +
+ + {(provided) => ( +
+ {toolboxCourses.map((course, index) => ( + + ))} + {provided.placeholder} +
+ )} +
- + + {!isDesktop && } +
); }; diff --git a/src/components/Toolbox/ToolboxButton.tsx b/src/components/Toolbox/ToolboxButton.tsx index 3b7dc09..00427ca 100644 --- a/src/components/Toolbox/ToolboxButton.tsx +++ b/src/components/Toolbox/ToolboxButton.tsx @@ -12,22 +12,20 @@ const ToolboxButton: React.FC = ({ count, }) => { return ( - <> - - +

{count}

+
+ + ); }; diff --git a/src/components/Toolbox/ToolboxCourse.tsx b/src/components/Toolbox/ToolboxCourse.tsx deleted file mode 100644 index c21e324..0000000 --- a/src/components/Toolbox/ToolboxCourse.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; - -interface ToolboxCourseProps { - name: string; - count: number; - index: number; - isDragging: boolean; -} - -const ToolboxCourse: React.FC = ({ - name, - count, - isDragging, -}) => { - return ( -
-
-

{count}

-
- {name} -
- ); -}; - -export default ToolboxCourse; diff --git a/src/context/CourseWorkspaceContext.tsx b/src/context/CourseWorkspaceContext.tsx new file mode 100644 index 0000000..716ce0e --- /dev/null +++ b/src/context/CourseWorkspaceContext.tsx @@ -0,0 +1,20 @@ +import React, { createContext } from "react"; +import { CourseEntry } from "../types/interfaces/Course.interface.ts"; +import { SemesterType } from "../types/interfaces/Semester.interface.ts"; +import { OnDragStartResponder, OnDragEndResponder } from "@hello-pangea/dnd"; + +export interface CourseWorkspaceContextType { + plannerCourses: SemesterType[]; + setPlannerCourses: React.Dispatch>; + toolboxCourses: CourseEntry[]; + setToolboxCourses: React.Dispatch>; + isDragging: boolean; + onDragStart: OnDragStartResponder; + onDragEnd: OnDragEndResponder; +} + +const CourseWorkspaceContext = createContext( + null +); + +export default CourseWorkspaceContext; diff --git a/src/context/CourseWorkspaceProvider.tsx b/src/context/CourseWorkspaceProvider.tsx new file mode 100644 index 0000000..4e15334 --- /dev/null +++ b/src/context/CourseWorkspaceProvider.tsx @@ -0,0 +1,45 @@ +import React, { useState, ReactNode } from "react"; +import { CourseEntry } from "../types/interfaces/Course.interface.ts"; +import { SemesterType } from "../types/interfaces/Semester.interface.ts"; +import { useDragAndDrop } from "../hooks/useDragAndDrop.ts"; +import CourseWorkspaceContext from "./CourseWorkspaceContext.tsx"; + +export const CourseWorkspaceProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + // all states + const [toolboxCourses, setToolboxCourses] = useState([]); + const [plannerCourses, setPlannerCourses] = useState([ + { + semesterNumber: 1, + semesterSeason: "fall", + creditsTotal: 0, + courseList: [], + }, + ]); + const [isDragging, setIsDragging] = useState(false); + + const { onDragStart, onDragEnd } = useDragAndDrop({ + toolboxCourses, + setToolboxCourses, + plannerCourses, + setPlannerCourses, + setIsDragging, + }); + + const value = { + plannerCourses, + setPlannerCourses, + toolboxCourses, + setToolboxCourses, + isDragging, + onDragStart, + onDragEnd, + }; + + return ( + + {children} + + ); +}; diff --git a/src/context/FilterContext.tsx b/src/context/FilterContext.tsx new file mode 100644 index 0000000..b5c3ba3 --- /dev/null +++ b/src/context/FilterContext.tsx @@ -0,0 +1,17 @@ +import { createContext } from "react"; +import { FilterData } from "../types/Filters"; + +export interface FilterContextType { + // available filter options + subjects: FilterData[]; + attributes: FilterData[]; + semesters: FilterData[]; + + // selected filter values + selectedFilters: FilterData[]; + toggleFilter: (filter: FilterData) => void; +} + +const FilterContext = createContext(null); + +export default FilterContext; diff --git a/src/context/FilterProvider.tsx b/src/context/FilterProvider.tsx new file mode 100644 index 0000000..c912d5a --- /dev/null +++ b/src/context/FilterProvider.tsx @@ -0,0 +1,72 @@ +import React, { useState, useEffect, ReactNode } from "react"; +import api from "../axios.ts"; +import { FilterData } from "../types/Filters.ts"; +import FilterContext from "./FilterContext.tsx"; + +const formatApiData = (type: string, data: string[]) => { + return data.map((item: string, index: number) => ({ + id: index, + code: item, + type: type as "Subject" | "Attributes" | "Semesters", + })); +}; + +export const FilterProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + // all states + const [subjects, setSubjects] = useState([]); + const [attributes, setAttributes] = useState([]); + const [semesters, setSemesters] = useState([]); + + useEffect(() => { + const fetchAllFilters = async () => { + try { + const [subjectsResponse, attributesResponse, semestersResponse] = + await Promise.all([ + api.get("/course/filter/values/departments"), + api.get("/course/filter/values/attributes"), + api.get("/course/filter/values/semesters"), + ]); + + setSubjects(formatApiData("Subject", subjectsResponse.data)); + setAttributes(formatApiData("Attributes", attributesResponse.data)); + setSemesters(formatApiData("Semesters", semestersResponse.data)); + } catch (error) { + console.error("Failed to fetch filters:", error); + } + }; + + fetchAllFilters(); + }, []); + + // selected filter state + const [selectedFilters, setSelectedFilters] = useState([]); + + const toggleFilter = (filter: FilterData) => { + setSelectedFilters((prevSelected) => { + const exists = prevSelected.find( + (f) => f.code === filter.code && f.type === filter.type + ); + if (exists) { + return prevSelected.filter( + (f) => !(f.code === filter.code && f.type === filter.type) + ); + } else { + return [...prevSelected, filter]; + } + }); + }; + + const value = { + subjects, + attributes, + semesters, + selectedFilters, + toggleFilter, + }; + + return ( + {children} + ); +}; diff --git a/src/hooks/useCourseWorkspace.ts b/src/hooks/useCourseWorkspace.ts new file mode 100644 index 0000000..09bae89 --- /dev/null +++ b/src/hooks/useCourseWorkspace.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import CourseWorkspaceContext from "../context/CourseWorkspaceContext"; + +export const useCourseWorkspace = () => { + const context = useContext(CourseWorkspaceContext); + if (!context) { + throw new Error( + "useCourseWorkspace must be used within a CourseWorkspaceProvider" + ); + } + return context; +}; diff --git a/src/hooks/useDragAndDrop.ts b/src/hooks/useDragAndDrop.ts new file mode 100644 index 0000000..a41524d --- /dev/null +++ b/src/hooks/useDragAndDrop.ts @@ -0,0 +1,241 @@ +import { Dispatch, SetStateAction } from "react"; +import { DropResult } from "@hello-pangea/dnd"; +import { CourseEntry } from "../types/interfaces/Course.interface"; +import { SemesterType } from "../types/interfaces/Semester.interface"; + +// --- Helper Functions --- + +const reorder = (list: T[], startIndex: number, endIndex: number): T[] => { + const result = [...list]; + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + return result; +}; + +const cloneCourse = (course: CourseEntry): CourseEntry => { + return { + ...course, + name: `${course.name}-${Math.random().toString(36).substring(2, 7)}`, + data: { ...course.data }, + count: 1, + }; +}; + +// --- Hook Definition --- + +interface UseDragAndDropProps { + toolboxCourses: CourseEntry[]; + plannerCourses: SemesterType[]; + setToolboxCourses: Dispatch>; + setPlannerCourses: Dispatch>; + setIsDragging: Dispatch>; +} + +export const useDragAndDrop = ({ + toolboxCourses, + plannerCourses, + setToolboxCourses, + setPlannerCourses, + setIsDragging, +}: UseDragAndDropProps) => { + const onDragStart = () => { + document.body.classList.add("no-scroll-during-drag"); + setIsDragging(true); + }; + + const onDragEnd = (result: DropResult) => { + document.body.classList.remove("no-scroll-during-drag"); + setIsDragging(false); + const { source, destination, draggableId } = result; + if (!destination) return; + + const sInd = source.droppableId; + const dInd = destination.droppableId; + + // Reorder in toolbox + if (sInd === "toolbox" && dInd === "toolbox") { + setToolboxCourses((prev) => + reorder(prev, source.index, destination.index) + ); + return; + } + + // Delete from toolbox + if (dInd === "garbage" && sInd === "toolbox") { + setToolboxCourses((prev) => + prev.filter((_, index) => index !== source.index) + ); + return; + } + + // Reorder within the same semester + if (sInd === dInd && dInd.startsWith("planner-")) { + const semesterIndex = parseInt(dInd.split("-")[1]) - 1; + setPlannerCourses((prev) => + prev.map((semester, idx) => + idx === semesterIndex + ? { + ...semester, + courseList: reorder( + semester.courseList, + source.index, + destination.index + ), + } + : semester + ) + ); + return; + } + + // Move between different semesters + if (sInd.startsWith("planner-") && dInd.startsWith("planner-")) { + const sourceSemesterIndex = parseInt(sInd.split("-")[1]) - 1; + const destSemesterIndex = parseInt(dInd.split("-")[1]) - 1; + + setPlannerCourses((prev) => { + const updated = [...prev]; + if ( + sourceSemesterIndex >= updated.length || + destSemesterIndex >= updated.length + ) + return prev; + + const sourceSemester = updated[sourceSemesterIndex]; + const destSemester = updated[destSemesterIndex]; + + const sourceListCopy = [...sourceSemester.courseList]; + const [movedCourse] = sourceListCopy.splice(source.index, 1); + + updated[sourceSemesterIndex] = { + ...sourceSemester, + courseList: sourceListCopy, + creditsTotal: + sourceSemester.creditsTotal - movedCourse.data.credit_max, + }; + + const destListCopy = [...destSemester.courseList]; + destListCopy.splice(destination.index, 0, movedCourse); + + updated[destSemesterIndex] = { + ...destSemester, + courseList: destListCopy, + creditsTotal: destSemester.creditsTotal + movedCourse.data.credit_max, + }; + + return updated; + }); + return; + } + + // Move from toolbox to planner + if (sInd === "toolbox" && dInd.startsWith("planner-")) { + const courseToClone = toolboxCourses.find((c) => c.name === draggableId); + const semesterIndex = parseInt(dInd.split("-")[1]); + + if (courseToClone) { + const newCourse = cloneCourse(courseToClone); + setPlannerCourses((prev) => + prev.map((semester, idx) => { + if (idx + 1 === semesterIndex) { + const updatedList = [...semester.courseList]; + updatedList.splice(destination.index, 0, newCourse); + return { + ...semester, + courseList: updatedList, + creditsTotal: + semester.creditsTotal + courseToClone.data.credit_max, + }; + } + return semester; + }) + ); + setToolboxCourses((prev) => + prev + .map((c) => + c.name === courseToClone.name ? { ...c, count: c.count - 1 } : c + ) + .filter((c) => c.count > 0) + ); + } + return; + } + + // Delete from planner + if (sInd.startsWith("planner-") && dInd === "garbage") { + const sourceSemesterIndex = parseInt(sInd.split("-")[1]) - 1; + setPlannerCourses((prev) => { + const updated = [...prev]; + if (sourceSemesterIndex >= updated.length) return prev; + + const sourceSemester = updated[sourceSemesterIndex]; + const courseListCopy = [...sourceSemester.courseList]; + const [removedCourse] = courseListCopy.splice(source.index, 1); + + if (!removedCourse) return prev; + + updated[sourceSemesterIndex] = { + ...sourceSemester, + courseList: courseListCopy, + creditsTotal: + sourceSemester.creditsTotal - removedCourse.data.credit_max, + }; + return updated; + }); + return; + } + + // Move from planner to toolbox + if (sInd.startsWith("planner-") && dInd === "toolbox") { + const sourceSemesterIndex = parseInt(sInd.split("-")[1]) - 1; + + if (sourceSemesterIndex >= plannerCourses.length) return; + const sourceSemester = plannerCourses[sourceSemesterIndex]; + if (source.index >= sourceSemester.courseList.length) return; + + const courseToMove = sourceSemester.courseList[source.index]; + if (!courseToMove) return; + + setPlannerCourses((prev) => + prev.map((semester, idx) => { + if (idx !== sourceSemesterIndex) return semester; + return { + ...semester, + courseList: semester.courseList.filter( + (_, index) => index !== source.index + ), + creditsTotal: semester.creditsTotal - courseToMove.data.credit_max, + }; + }) + ); + + const cleanedName = courseToMove.name.split("-")[0]; + + setToolboxCourses((prev) => { + const existingIndex = prev.findIndex((c) => c.name === cleanedName); + + const courseForToolbox: CourseEntry = { + ...courseToMove, + name: cleanedName, + }; + + if (existingIndex !== -1) { + const updated = [...prev]; + updated[existingIndex] = { + ...updated[existingIndex], + count: updated[existingIndex].count + courseForToolbox.count, + }; + return updated; + } else { + // It's a new course for the toolbox, add it + const newToolboxList = [...prev]; + newToolboxList.splice(destination.index, 0, courseForToolbox); + return newToolboxList; + } + }); + return; + } + }; + + return { onDragStart, onDragEnd }; +}; diff --git a/src/hooks/useFilters.ts b/src/hooks/useFilters.ts new file mode 100644 index 0000000..3ba2cfc --- /dev/null +++ b/src/hooks/useFilters.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import FilterContext from "../context/FilterContext"; + +export const useFilterData = () => { + const context = useContext(FilterContext); + if (!context) { + throw new Error("useFilterData must be used within a FilterProvider"); + } + return context; +}; diff --git a/src/hooks/usePlannerCourseOptions.ts b/src/hooks/usePlannerCourseOptions.ts new file mode 100644 index 0000000..65c28aa --- /dev/null +++ b/src/hooks/usePlannerCourseOptions.ts @@ -0,0 +1,179 @@ +import { Dispatch, SetStateAction } from "react"; +import { SemesterType } from "../types/interfaces/Semester.interface"; +import { CourseEntry, CourseType } from "../types/interfaces/Course.interface"; + +interface UsePlannerCourseProps { + setPlannerCourses: Dispatch>; + setToolboxCourses: Dispatch>; + semesterIndex: number; // 1-based index + courseIndex: number; // 0-based index + course: CourseType; + name: string; + count: number; +} + +export const usePlannerCourse = ({ + setPlannerCourses, + setToolboxCourses, + semesterIndex, + courseIndex, + course, + name, + count, +}: UsePlannerCourseProps) => { + // --- Helper Functions --- + + const getNextName = (originalName: string): string => { + const [base, tag] = originalName.split("-"); + if (!tag) { + return `${base}-A`; + } + const nextTag = incrementTag(tag); + return `${base}-${nextTag}`; + }; + + const incrementTag = (tag: string): string => { + const chars = tag.toUpperCase().split(""); + let carry = true; + for (let i = chars.length - 1; i >= 0 && carry; i--) { + if (chars[i] === "Z") { + chars[i] = "A"; + } else { + chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1); + carry = false; + } + } + if (carry) { + chars.unshift("A"); + } + return chars.join(""); + }; + + const toTitleCase = (str: string): string => { + if (!str) return ""; + return str + .toLowerCase() + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + }; + + // --- Handler Functions --- + + const handleDuplicate = () => { + setPlannerCourses((prev) => + prev.map((semester, i) => + i === semesterIndex - 1 // 1-based index + ? { + ...semester, + creditsTotal: semester.creditsTotal + course.credit_max, + courseList: [ + ...semester.courseList.slice(0, courseIndex + 1), + { + name: getNextName(name), // Use prop + count: count, // Use prop + data: course, // Use prop + }, + ...semester.courseList.slice(courseIndex + 1), + ], + } + : semester + ) + ); + }; + + const handleMoveNext = () => { + setPlannerCourses((prev) => { + const courseCopy = { name, count, data: course }; + + const updatedPlanner = prev.map((semester, i) => + i === semesterIndex - 1 + ? { + ...semester, + courseList: semester.courseList.filter( + (_, idx) => idx !== courseIndex + ), + creditsTotal: semester.creditsTotal - course.credit_max, + } + : semester + ); + + // Add to next semester + // Note: This will not work if it's the last semester + return updatedPlanner.map((semester, i) => + i === semesterIndex // 1-based index becomes 0-based index of next sem + ? { + ...semester, + courseList: [...semester.courseList, courseCopy], + creditsTotal: semester.creditsTotal + courseCopy.data.credit_max, + } + : semester + ); + }); + }; + + const handleMoveToolbox = () => { + setPlannerCourses((prev) => { + const courseToMove = { name, count, data: course }; + const cleanedName = courseToMove.name.split("-")[0]; + const cleanedCourse = { + ...courseToMove, + name: cleanedName, + }; + + const updatedPlanner = prev.map((semester, i) => + i === semesterIndex - 1 + ? { + ...semester, + courseList: semester.courseList.filter( + (_, idx) => idx !== courseIndex + ), + creditsTotal: + semester.creditsTotal - courseToMove.data.credit_max, + } + : semester + ); + + setToolboxCourses((toolboxPrev) => { + const existingIndex = toolboxPrev.findIndex( + (entry) => entry.name === cleanedName + ); + + if (existingIndex !== -1) { + const updated = [...toolboxPrev]; + updated[existingIndex].count += cleanedCourse.count; + return updated; + } else { + return [...toolboxPrev, cleanedCourse]; + } + }); + + return updatedPlanner; + }); + }; + + const handleDelete = () => { + setPlannerCourses((prev) => + prev.map((semester, i) => + i === semesterIndex - 1 + ? { + ...semester, + courseList: semester.courseList.filter( + (_, idx) => idx !== courseIndex + ), + creditsTotal: semester.creditsTotal - course.credit_max, + } + : semester + ) + ); + }; + + // Return all the functions the component needs + return { + handleDuplicate, + handleMoveNext, + handleMoveToolbox, + handleDelete, + toTitleCase, + }; +}; diff --git a/src/index.css b/src/index.css index d274355..8ac0e4f 100644 --- a/src/index.css +++ b/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@plugin 'tailwind-scrollbar'; @theme { --color-carpipink: oklch(88.46% 0.0441 18.02); @@ -8,9 +9,36 @@ } body { - @apply bg-carpipink; + background-color: var(--color-carpipink); + scrollbar-color: var(--color-darkblue) var(--color-carpipink); + scrollbar-width: thin; } +body.no-scroll-during-drag { + /* This prevents the entire page from scrolling */ + overflow: hidden !important; + + /* This can also help prevent other touch behaviors on some browsers */ + touch-action: none !important; +} + +/* scrollbar for webkit browsers */ +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-track { + background: var(--color-carpipink); +} + +::-webkit-scrollbar-thumb { + background-color: var(--color-darkblue); + border-radius: 9999px; + border: 3px solid var(--color-carpipink); +} + +/* prevent blurriness of images */ img { image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast; diff --git a/src/pages/Catalog.tsx b/src/pages/Catalog.tsx index 224183b..52fb2ea 100644 --- a/src/pages/Catalog.tsx +++ b/src/pages/Catalog.tsx @@ -1,70 +1,157 @@ -import React from "react"; -import Course from "../components/Course/Course"; +import React, { useState, useRef, useEffect } from "react"; +import api from "../axios.ts"; +import Course from "../components/Course/CatalogCourse.tsx"; import SearchBar from "../components/SearchBar/SeachBar"; -import { - CourseType, - CourseEntry, -} from "../types/interfaces/Course.interface.ts"; -import { Filters } from "../types/Filters"; - -interface CatalogProps { - toolboxCourses: CourseEntry[]; - setToolboxCourses: React.Dispatch>; - isDragging: boolean; - // New props added from the moved state - searchResults: CourseType[]; - setSearchResults: React.Dispatch>; - searchPrompt: string; - setSearchPrompt: React.Dispatch>; - showFilter: boolean; - setShowFilter: React.Dispatch>; - filters: Filters; - setFilters: React.Dispatch>; -} - -const Catalog: React.FC = ({ - toolboxCourses, - setToolboxCourses, - isDragging, - searchResults, - setSearchResults, - searchPrompt, - setSearchPrompt, - showFilter, - setShowFilter, - filters, - setFilters, -}) => { +import { CourseType } from "../types/interfaces/Course.interface.ts"; +import DepartmentFilters from "../components/Department-Filters.tsx"; +import ChosenTag from "../components/SearchBar/ChosenTag"; +import { useFilterData } from "../hooks/useFilters.ts"; + +const Catalog: React.FC = () => { + const { selectedFilters } = useFilterData(); + + const [searchResults, setSearchResults] = React.useState([]); + const [searchPrompt, setSearchPrompt] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + + const debounceTimeout = useRef | null>(null); + const lastNoResultQuery = useRef(null); + + useEffect(() => { + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + + if (searchPrompt.length === 0 && selectedFilters.length === 0) { + setHasSearched(false); + setSearchResults([]); + setIsLoading(false); + lastNoResultQuery.current = null; + return; + } + + setIsLoading(true); + setHasSearched(true); + + debounceTimeout.current = setTimeout(async () => { + const deptFilters = selectedFilters + .filter((filter) => filter.type === "Subject") + .map((filter) => filter.code); + const attrFilters = selectedFilters + .filter((filter) => filter.type === "Attributes") + .map((filter) => filter.code); + const semFilters = selectedFilters + .filter((filter) => filter.type === "Semesters") + .map((filter) => filter.code); + + if (searchPrompt.length < 3) { + lastNoResultQuery.current = null; + } + + if ( + lastNoResultQuery.current && + searchPrompt.startsWith(lastNoResultQuery.current) + ) { + console.log("Skipping redundant no-result query"); + return; + } + + try { + const response = await api.get( + "course/search?searchPrompt=" + + searchPrompt + + "&deptFilters=" + + deptFilters + + "&attrFilters=" + + attrFilters + + "&semFilters=" + + semFilters + ); + + const data = response.data; + setSearchResults(data); + setIsLoading(false); + + if (data.length === 0 && searchPrompt.length >= 3) { + lastNoResultQuery.current = searchPrompt; + } else { + lastNoResultQuery.current = null; + } + } catch (error) { + console.error("Search error:", error); + } + }, 300); + + return () => { + if (debounceTimeout.current) clearTimeout(debounceTimeout.current); + }; + }, [selectedFilters, searchPrompt, setSearchResults]); + return ( - <> -
+
+
+

Courses

+ +
+ {selectedFilters.map((filter) => ( + + ))} +
-
- {searchResults?.map((course: CourseType, index: number) => ( - - ))} -
- - +
+ {searchResults.length > 0 ? ( +
+ {searchResults?.map((course: CourseType, index: number) => ( + + ))} + +
+
+ ) : isLoading ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ +
+ {Array.from({ length: 4 }).map((_, j) => ( +
+ ))} +
+
+ +
+
+ )) + ) : hasSearched ? ( +
+

D:

+

+ No courses found for "{searchPrompt}" +

+

+ Try searching for another course.
+ Maybe "CSCI 1100" or "Computer Science I" +

+
+ ) : ( + selectedFilters.length === 0 && + )} +
+
); }; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index f0f0cf0..e9512fd 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -2,62 +2,23 @@ import useIsDesktop from "../hooks/useIsDesktop"; import Catalog from "./Catalog"; import Planner from "./Planner"; -import { CourseEntry, CourseType } from "../types/interfaces/Course.interface"; -import { SemesterType } from "../types/interfaces/Semester.interface"; -import { Filters } from "../types/Filters"; -interface HomePageProps { - isDragging: boolean; - toolboxCourses: CourseEntry[]; - setToolboxCourses: React.Dispatch>; - plannerCourses: SemesterType[]; - setPlannerCourses: React.Dispatch>; - searchResults: CourseType[]; - setSearchResults: React.Dispatch>; - searchPrompt: string; - setSearchPrompt: React.Dispatch>; - showFilter: boolean; - setShowFilter: React.Dispatch>; - filters: Filters; - setFilters: React.Dispatch>; -} - -const HomePage = (props: HomePageProps) => { +const HomePage = () => { const isDesktop = useIsDesktop(); return ( -
- Carpi Logo -
-
-

Courses

- +
+
+ Carpi Logo +
+ +
+
+
{isDesktop && ( -
-

Planner

- +
+
)}
diff --git a/src/pages/Planner.tsx b/src/pages/Planner.tsx index 58bc392..db63202 100644 --- a/src/pages/Planner.tsx +++ b/src/pages/Planner.tsx @@ -1,54 +1,54 @@ -import React, { Dispatch, SetStateAction } from "react"; -import SemesterBlock from "../components/PlannerComponents/SemesterBlock"; -import { SemesterType } from "../types/interfaces/Semester.interface"; -import { CourseEntry } from "../types/interfaces/Course.interface"; +import React from "react"; +import SemesterBlock from "../components/PlannerComponents/SemesterBlocks/SemesterBlock"; import AddSemester from "../components/PlannerComponents/AddSemester"; import DeleteSemester from "../components/PlannerComponents/DeleteSemester"; +import { useCourseWorkspace } from "../hooks/useCourseWorkspace"; -interface PlannerProps { - isDragging: boolean; - plannerCourses: SemesterType[]; - setPlannerCourses: Dispatch>; - setToolboxCourses: Dispatch>; -} +const Planner: React.FC = () => { + const { plannerCourses, setPlannerCourses, setToolboxCourses } = + useCourseWorkspace(); -const Planner: React.FC = ({ - isDragging, - plannerCourses, - setPlannerCourses, - setToolboxCourses, -}) => { const handleDeleteSemester = (semesterNumber: number) => { setPlannerCourses((prev) => prev .filter((s) => s.semesterNumber !== semesterNumber) - .map((s, idx) => ({ ...s, semesterNumber: idx + 1 })), + .map((s, idx) => ({ ...s, semesterNumber: idx + 1 })) ); }; return ( -
- {plannerCourses.map((semester, index) => ( -
- - -
+
+
+ Carpi Logo +
+ +
+
+

Planner

+
- ))} - +
+ {plannerCourses.map((semester, index) => ( +
+ + +
+ ))} +
+
); }; diff --git a/src/types/Filters.ts b/src/types/Filters.ts index bbb4d1a..8dc3e2b 100644 --- a/src/types/Filters.ts +++ b/src/types/Filters.ts @@ -3,3 +3,9 @@ export type Filters = { Attributes: string[]; Semesters: string[]; }; + +export type FilterData = { + id: number; + code: string; + type: "Subject" | "Attributes" | "Semesters"; +};