diff --git a/client/src/App.jsx b/client/src/App.jsx index e094a200..9a2973bf 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -6,6 +6,7 @@ import LoadingPage from "./loading.jsx"; import { BrowserRouter as Router, Routes, Route, useNavigate } from "react-router-dom"; import PrivateRoutes from "./router_utils/PrivateRoutes"; import ProfilePage from "./screens/profile.js"; +import MyQuizzes from "./screens/myquizzes"; import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; @@ -90,6 +91,7 @@ const App = () => { }> } path="dashboard" exact /> } path="profile" exact /> + } path="myquizzes" exact /> } path="browse"> } path=":code"> diff --git a/client/src/api/Quiz.js b/client/src/api/Quiz.js new file mode 100644 index 00000000..9fb89824 --- /dev/null +++ b/client/src/api/Quiz.js @@ -0,0 +1,144 @@ +import { toast } from "react-toastify"; +import server from "./server"; +import { getUser } from "./User"; +import { getCourse } from "./Course"; + +export const createQuizEvent = async (eventName, eventDate, course) => { + try { + const response = await fetch(`${server}/api/quiz/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + eventName, + eventDate, + course, + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || 'Failed to create quiz event'); + } + + return data; + } catch (error) { + throw error; + } +}; +export const getAllQuizEvents = async () => { + try { + const response = await fetch(`${server}/api/quiz/events`, { + method: 'GET', + credentials: 'include' + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || 'Failed to fetch quiz events'); + } + const events = data.data.filter(event => { + const currDate = new Date(); + const eventDate = new Date(event.eventDate); + if (eventDate.getTime() >= currDate.getTime()){ + return true; + } + else{ + deleteQuizEvent(event._id); + return false; + } + }) + return events || []; + } catch (error) { + throw error; + } +}; + +export const getQuizEvents = async () => { + try { + console.log("Getting Quizzes") + const { data: user } = await getUser(); + if(!user) { + throw new Error('User not found'); + } + const response = await fetch(`${server}/api/quiz/events`, { + method: 'GET', + credentials: 'include' + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || 'Failed to fetch quiz events'); + } + if(data.data) { + // const userEvents = data.data.filter(event => + // user?.courses?.some(course => course.code === event.course) + // ); + const userEvents = data.data.filter(event =>{ + if (user?.courses?.some(course => course.code === event.course)){ + const currDate = new Date(); + const eventDate = new Date(event.eventDate); + if(eventDate.getTime() >= currDate.getTime()){ + return true; + } + else{ + deleteQuizEvent(event._id); + return false; + } + } + }) + return userEvents; + } else { + throw new Error(data.message || 'Failed to fetch quiz events data'); + } + } catch (error) { + throw error; + } +}; + +export const modifyQuizEvent = async (eventId, eventData) => { + try { + const response = await fetch(`${server}/api/quiz/modify/${eventId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(eventData) + }); + const data = await response.json(); + if(!response.ok) { + toast.error(data.message || 'Failed to modify quiz event'); + return null; + } + return data; + } + catch (error) { + toast.error(error.message || 'Failed to modify quiz event'); + return null; + } +} +export const deleteQuizEvent = async (eventId) => { + try { + console.log("deleting Quizzes") + const response = await fetch(`${server}/api/quiz/delete/${eventId}`, { + method: 'DELETE', + credentials: 'include' + }); + const data = await response.json(); + if(!response.ok) { + toast.error(data.message || 'Failed to delete quiz event'); + return null; + } + return data; + } + catch (error) { + toast.error(error.message || 'Failed to delete quiz event'); + return null; + } +} diff --git a/client/src/api/Year.js b/client/src/api/Year.js index bf108151..95dcccd4 100644 --- a/client/src/api/Year.js +++ b/client/src/api/Year.js @@ -15,10 +15,11 @@ API.interceptors.request.use((req) => { return req; }); -export const addYear = async ({ name, course }) => { +export const addYear = async ({ name, course, profName }) => { const { data } = await API.post("/year", { name, course, + profName, childType: "Folder", Children: [], }); @@ -31,3 +32,11 @@ export const deleteYear = async ({ folder, courseCode }) => { }); return data; }; + +export const editProfName = async ({ yearId, profName }) => { + const { data } = await API.put("/year/edit-prof", { + yearId, + profName, + }); + return data; +}; diff --git a/client/src/api/profName.js b/client/src/api/profName.js new file mode 100644 index 00000000..85e78631 --- /dev/null +++ b/client/src/api/profName.js @@ -0,0 +1,2 @@ +import axios from "axios"; +import serverRoot from "./server"; diff --git a/client/src/components/container/styles.scss b/client/src/components/container/styles.scss index 92bbdd17..51929b1e 100644 --- a/client/src/components/container/styles.scss +++ b/client/src/components/container/styles.scss @@ -1,5 +1,6 @@ .container { position: relative; + flex: 1; .container-content { max-width: 1400px; diff --git a/client/src/screens/browse/components/folder-info/index.jsx b/client/src/screens/browse/components/folder-info/index.jsx index f4bb11eb..24819085 100644 --- a/client/src/screens/browse/components/folder-info/index.jsx +++ b/client/src/screens/browse/components/folder-info/index.jsx @@ -25,6 +25,7 @@ const FolderInfo = ({ isBR, path, name, + prof, canDownload, contributionHandler, folderId, @@ -243,6 +244,7 @@ const FolderInfo = ({

{path}

{name}

+ {prof && (

Prof. {prof}

)}
{folderId && courseCode && ( <> diff --git a/client/src/screens/browse/components/folder-info/styles.scss b/client/src/screens/browse/components/folder-info/styles.scss index 308fca32..87c051f0 100644 --- a/client/src/screens/browse/components/folder-info/styles.scss +++ b/client/src/screens/browse/components/folder-info/styles.scss @@ -27,6 +27,14 @@ .folder-name { font-family: "Bold"; font-size: 1.5rem; + + } + + .prof-name { + font-family: "Regular"; + font-size: 1rem; + padding-left: 5px; + color: #333; } .folder-actions { @@ -216,4 +224,4 @@ #bottommarginneeded { margin-bottom: 2rem; -} +} \ No newline at end of file diff --git a/client/src/screens/browse/components/quiz-schedule-modal/index.jsx b/client/src/screens/browse/components/quiz-schedule-modal/index.jsx new file mode 100644 index 00000000..9011df04 --- /dev/null +++ b/client/src/screens/browse/components/quiz-schedule-modal/index.jsx @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import { createQuizEvent } from "../../../../api/Quiz"; +import { toast } from "react-toastify"; +import "./styles.scss"; + +const QuizScheduleModal = ({ isOpen, onClose, courseCode }) => { + const [eventName, setEventName] = useState(""); + const [eventDate, setEventDate] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!eventName.trim() || !eventDate) { + toast.error("Please fill in all fields"); + return; + } + + setIsSubmitting(true); + + try { + await createQuizEvent(eventName, eventDate, courseCode); + toast.success("Quiz event scheduled successfully!"); + setEventName(""); + setEventDate(""); + onClose(); + } catch (error) { + toast.error(error.message || "Failed to schedule quiz event"); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (!isSubmitting) { + setEventName(""); + setEventDate(""); + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

Schedule Quiz

+ +
+ +
+
+ + setEventName(e.target.value)} + placeholder="Enter quiz name" + disabled={isSubmitting} + required + /> +
+ +
+ + setEventDate(e.target.value)} + disabled={isSubmitting} + required + /> +
+ +
+ + +
+
+
+
+ ); +}; + +export default QuizScheduleModal; diff --git a/client/src/screens/browse/components/quiz-schedule-modal/styles.scss b/client/src/screens/browse/components/quiz-schedule-modal/styles.scss new file mode 100644 index 00000000..0e877bd1 --- /dev/null +++ b/client/src/screens/browse/components/quiz-schedule-modal/styles.scss @@ -0,0 +1,169 @@ +.quiz-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.35); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.quiz-modal { + background: #fff; + border-radius: 0px; + padding: 36px 32px 32px 32px; + width: 100%; + max-width: 480px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + align-items: flex-start; + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.quiz-modal-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 8px; + + .modal-icon { + width: 40px; + height: 40px; + margin-right: 8px; + display: inline-block; + vertical-align: middle; + } + + h3 { + margin: 0; + font-size: 2rem; + font-weight: 700; + color: #111; + letter-spacing: -1px; + font-family: inherit; + } +} + +.quiz-modal-subtitle { + color: #888; + font-size: 1.15rem; + margin-bottom: 28px; + margin-top: 2px; + font-family: inherit; +} + +.quiz-modal-form { + width: 100%; + font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif; + + .form-group { + margin-bottom: 24px; + + label { + display: block; + margin-bottom: 8px; + font-weight: 700; + color: #111; + font-size: 1.15rem; + letter-spacing: -1px; + } + + input { + width: 100%; + padding: 12px 16px; + border: 1px solid #202020d9; + border-radius: 4px; + font-size: 1.15rem; + font-family: inherit; + color: #222; + margin-bottom: 2px; + + &:focus { + outline: none; + border-color: #fecf6f; + box-shadow: 0 0 0 2px rgba(254, 207, 111, 0.15); + } + } + } + + .form-actions { + display: flex; + gap: 24px; + justify-content: flex-start; + margin-top: 32px; + + .btn { + padding: 20px 0; + width: 220px; + border-radius: 0; + font-weight: 700; + font-size: 1.25rem; + cursor: pointer; + border: none; + letter-spacing: 1px; + transition: background 0.2s, color 0.2s; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .btn-secondary { + background-color: #444; + color: #111; + + &:hover:not(:disabled) { + background-color: #222; + color: #fff; + } + } + + .btn-primary { + background-color: #fecf6f; + color: #111; + + &:hover:not(:disabled) { + background-color: #ffd98c; + } + } + } +} + +// Mobile responsiveness +@media (max-width: 640px) { + .quiz-modal { + margin: 8px; + padding: 16px; + width: calc(100% - 16px); + } + + .quiz-modal-header h3 { + font-size: 1.25rem; + } + + .form-actions { + flex-direction: column; + + .btn { + width: 100%; + } + } +} \ No newline at end of file diff --git a/client/src/screens/browse/components/year-info/confirmDialog.jsx b/client/src/screens/browse/components/year-info/confirmDialog.jsx index 687d5438..8fb66667 100644 --- a/client/src/screens/browse/components/year-info/confirmDialog.jsx +++ b/client/src/screens/browse/components/year-info/confirmDialog.jsx @@ -22,6 +22,8 @@ const ConfirmDialog = ({ show, yearName = "", onYearNameChange = () => {}, + profName= "", + onProfNameChange = () => {}, onCancel, onConfirm, course, @@ -61,6 +63,19 @@ const ConfirmDialog = ({ />
+
+ + onProfNameChange(e.target.value)} + placeholder="Enter professor name" + /> +
diff --git a/client/src/screens/browse/components/year-info/index.jsx b/client/src/screens/browse/components/year-info/index.jsx index 4cb893a3..85949c2c 100644 --- a/client/src/screens/browse/components/year-info/index.jsx +++ b/client/src/screens/browse/components/year-info/index.jsx @@ -20,11 +20,13 @@ const YearInfo = ({ courseCode, course, // years list currYear, + }) => { const dispatch = useDispatch(); const [showConfirm, setShowConfirm] = useState(false); const [showConfirmDel, setShowConfirmDel] = useState(false); const [newYearName, setNewYearName] = useState(""); + const [newprofName, setNewProfName] = useState(""); const [isAddingYear, setIsAddingYear] = useState(false); const user = useSelector((state) => state.user.user); const isReadOnlyCourse = user?.readOnly?.some( @@ -48,6 +50,7 @@ const YearInfo = ({ if (isAddingYear) return; setIsAddingYear(true); const yearName = newYearName.trim(); + const profName = newprofName.trim(); if (!yearName) { setIsAddingYear(false); @@ -75,6 +78,7 @@ const YearInfo = ({ const newYear = await addYear({ name: yearName.trim(), course: courseCode, + profName: profName.trim(), }); course.push(newYear); @@ -179,6 +183,8 @@ const YearInfo = ({ // onInputChange={(e) => setNewFolderName(e.target.value)} yearName={newYearName} onYearNameChange={setNewYearName} + profName={newprofName} + onProfNameChange={setNewProfName} onConfirm={handleConfirmAddYear} onCancel={() => setShowConfirm(false)} confirmText="Create" diff --git a/client/src/screens/browse/index.jsx b/client/src/screens/browse/index.jsx index f8843457..4360d94e 100644 --- a/client/src/screens/browse/index.jsx +++ b/client/src/screens/browse/index.jsx @@ -483,28 +483,23 @@ const BrowseScreen = () => { ) : (

MY COURSES

- {user.user?.courses?.map((course, idx) => { - return ( - - ); - })} - {user.localCourses?.map((course, idx) => { - return ( - - ); - })} + {user.user?.courses?.map((course, idx) => ( + + ))} + {user.localCourses?.map((course, idx) => ( + + ))} {user.user?.readOnly?.length > 0 &&

OTHERS

} - {user.user?.readOnly?.map((course, idx) => ( { )} {user.user?.isBR && user.user?.previousCourses?.length > 0 && - user.user?.previousCourses?.map((course, idx) => { - return ( - - ); - })} + user.user?.previousCourses?.map((course, idx) => ( + + ))}
)} {!isMobile && ( @@ -534,6 +527,7 @@ const BrowseScreen = () => { isBR={user.user.isBR} path={folderData?.path ? folderData.path : HeaderText} name={folderData?.name ? folderData.name : HeaderText} + prof={folderData?.profName ? folderData.profName : ""} canDownload={folderData?.childType === "File"} contributionHandler={contributionHandler} folderId={folderData?._id} diff --git a/client/src/screens/browse/styles.scss b/client/src/screens/browse/styles.scss index d8485cc4..9afbc48d 100644 --- a/client/src/screens/browse/styles.scss +++ b/client/src/screens/browse/styles.scss @@ -17,10 +17,12 @@ height: 1vh; // Extremely thin for very small screens } } + .controller { &::-webkit-scrollbar { display: none; } + display: flex; height: 92vh; // Keep original desktop height @@ -36,11 +38,13 @@ @media (max-width: 480px) { height: 98.8vh; // Compensate for 1.2vh navbar } + .left { flex: 3; max-width: 300px; border-right: 1px solid rgba(0, 0, 0, 0.33); overflow-y: auto; + .heading { padding: 10px 20px; font-family: "Bold"; @@ -48,6 +52,7 @@ background-color: #fecf6f; } } + .middle { flex: 6; border-right: 1px solid rgba(0, 0, 0, 0.33); @@ -59,26 +64,32 @@ flex-wrap: wrap; // justify-content: space-between; } + .empty-message { font-size: 1.2rem; } + &::-webkit-scrollbar { display: none; } } + .right { flex: 1; max-width: 200px; background-color: #000; color: #fff; + .year-content { display: flex; flex-direction: column-reverse; + .year-title { padding: 10px 20px; font-family: "Bold"; font-size: 1.2rem; } + .year { cursor: pointer; padding: 10px 20px; @@ -87,9 +98,11 @@ display: flex; align-items: center; justify-content: space-between; + &.selected { color: #000; background-color: #fff; + .delete { background: url(./components/browsefolder/Delete.svg), none; @@ -109,10 +122,12 @@ border-radius: 2px; } } + &.selected:hover .delete { opacity: 1; } } + &.add-year { border-top: 1px solid #333; margin-top: 10px; @@ -245,11 +260,9 @@ box-shadow: 0 0 0 4px rgba(254, 207, 111, 0.12), 0 4px 12px rgba(254, 207, 111, 0.15); transform: translateY(-2px); // More pronounced lift - background: linear-gradient( - 135deg, - #ffffff 0%, - #fffbf7 100% - ); // Warm focus background + background: linear-gradient(135deg, + #ffffff 0%, + #fffbf7 100%); // Warm focus background } &:hover:not(:disabled) { @@ -502,3 +515,38 @@ margin-bottom: 0; } } + + +.input_profname { + width: 100%; + padding: 10px 12px; + border: 1px solid black; + border-radius: 0; + background-color: #fff; + font-size: 1rem; + color: #333; + margin-top: 6px; + margin-bottom: 12px; + box-sizing: border-box; + transition: border-color 0.2s; + + &:focus { + outline: none; + border-color: #fecf6f; + box-shadow: 0 0 0 2px rgba(254, 207, 111, 0.2); + } + + &::placeholder { + color: black; + font-style: italic; + + } +} + +.label_section { + font-weight: 600; + margin-bottom: 4px; + color: #333; + font-size: 0.95rem; + display: block; +} \ No newline at end of file diff --git a/client/src/screens/dashboard/components/examcard/index.jsx b/client/src/screens/dashboard/components/examcard/index.jsx index 9b8871bf..f2b4a205 100644 --- a/client/src/screens/dashboard/components/examcard/index.jsx +++ b/client/src/screens/dashboard/components/examcard/index.jsx @@ -1,7 +1,7 @@ import "./styles.scss"; -const ExamCard = ({ days, name, color }) => { +const ExamCard = ({ days, name, color, onClick=()=>{} }) => { return ( -
+

{days ? days : 0}

diff --git a/client/src/screens/dashboard/components/quizcard.jsx b/client/src/screens/dashboard/components/quizcard.jsx new file mode 100644 index 00000000..0bd29ad1 --- /dev/null +++ b/client/src/screens/dashboard/components/quizcard.jsx @@ -0,0 +1,19 @@ +import React from "react"; +import "./quizcard.scss"; + +const QuizCard = ({ code, name, date, time, color,children }) => { + return ( + <> +
+
{code}
+
{name}
+
{date}
+
{time}
+ {children} +
+ + + ); +}; + +export default QuizCard; diff --git a/client/src/screens/dashboard/components/quizcard.scss b/client/src/screens/dashboard/components/quizcard.scss new file mode 100644 index 00000000..96eaa8b9 --- /dev/null +++ b/client/src/screens/dashboard/components/quizcard.scss @@ -0,0 +1,53 @@ +.quizcard { + width: 190px; + min-height: 150px; + background-color: #fff; + padding: 10px; + margin: 10px; + transition: 150ms ease; + position: relative; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; +} + +.quizcard-code { + background-color: #000; + color: #fff; + padding: 3px 10px; + //text-align: center; + text-align: left; + font-family: "Bold"; + font-size: 0.9rem; + margin-bottom: 10px; + margin-top: 0; + width: fit-content; +} + +.quizcard-name { + color: #333; + font-family: "Bold"; + font-size: 1.4rem; + text-align: left; + margin-bottom: 10px; + width: 100%; +} + +.quizcard-date { + color: #555; + font-family: "Bold"; + font-size: 1.1rem; + text-align: left; + margin-bottom: 10px; + width: 100%; +} + +.quizcard-day { + color: #555; + font-family: "Bold"; + font-size: 0.95em; + text-align: left; + width: 100%; +} \ No newline at end of file diff --git a/client/src/screens/dashboard/index.jsx b/client/src/screens/dashboard/index.jsx index b9b8f431..c4e3a432 100644 --- a/client/src/screens/dashboard/index.jsx +++ b/client/src/screens/dashboard/index.jsx @@ -7,8 +7,11 @@ import NavBar from "../../components/navbar"; import SubHeading from "../../components/subheading"; import CourseCard from "./components/coursecard"; import ContributionBanner from "./components/contributionbanner"; +import QuizCard from "./components/quizcard"; import Footer from "../../components/footer"; + import FavouriteCard from "./components/favouritecard"; +import { getAllQuizEvents } from "../../api/Quiz"; import { ChangeCurrentCourse, ResetFileBrowserState } from "../../actions/filebrowser_actions"; import { useDispatch, useSelector } from "react-redux"; @@ -16,7 +19,7 @@ import { useNavigate } from "react-router-dom"; import formatName from "../../utils/formatName"; import formatBranch from "../../utils/formatBranch"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { getColors } from "../../utils/colors"; import { LoadCourses } from "../../actions/filebrowser_actions"; import Contributions from "../contributions"; @@ -24,14 +27,17 @@ import { AddNewCourseLocal, ClearLocalCourses } from "../../actions/user_actions import AddCourseModal from "./components/addcoursemodal"; import { AddNewCourseAPI, GetExamDates } from "../../api/User"; import { toast } from "react-toastify"; +import { getQuizEvents } from "../../api/Quiz"; const Dashboard = () => { + const [quizzes, setQuizzes] = useState([]); const dispatch = useDispatch(); const navigate = useNavigate(); const user = useSelector((state) => state.user); const [midSem, setMidSem] = useState(0); const [endSem, setEndSem] = useState(0); + const [quizDate, setquizdate] = useState(0); const contributionHandler = (event) => { const collection = document.getElementsByClassName("contri"); @@ -102,6 +108,53 @@ const Dashboard = () => { } run(); }, []); + // + useEffect(() => { + const fetchQuizzes = async () => { + try { + const all_quizzes = await getAllQuizEvents(); + if (!all_quizzes) { + console.log("Quizzes error fetching"); + return; + } + + console.log(all_quizzes); + const now = Date.now(); + + const user_quizzes = all_quizzes.filter((quiz) =>{ + if(user?.user?.courses.find(course => course.code === quiz.course)){ + if (new Date(quiz.eventDate).getTime() >= now){ + return true; + } + else + return false; + + }} + ) + //user_quizzes.find().sort({eventDate:1}) + setQuizzes(user_quizzes); + + } catch (error) { + console.error("Error fetching quizzes:", error); + } + }; + + fetchQuizzes(); + }, [user]); + useEffect(() => { + if (quizzes.length > 0 && quizzes[0]?.eventDate) { + const now = Date.now(); + + const daysTillQuiz = Math.ceil( + ((new Date(quizzes[0].eventDate).getTime()) - now) / (1000 * 3600 * 24) + ); + console.log("daystill quiz:", daysTillQuiz); + setquizdate(daysTillQuiz); + } + }, [quizzes]); + + + const handleClick = (code) => { let Code = code.replaceAll(" ", ""); @@ -137,12 +190,17 @@ const Dashboard = () => {
- {midSem >= 0 && ( + {/* {midSem >= 0 && ( - )} - {endSem >= 0 && ( - - )} + )} */} + {(quizzes.length && quizDate > 0) && + ({ navigate('/myquizzes')}} />) + } + + {midSem > 0 ? + () : + () + }
@@ -185,6 +243,50 @@ const Dashboard = () => { }} /> */}
+ + {/* MY QUIZZES */} +
+ + +
+ +
+ {quizzes.length > 0 ? ( + [...quizzes] + .sort((a, b) => new Date(a.eventDate) - new Date(b.eventDate)) + .map((quiz, idx) => ( + + )) + ) : ( +
+ No quizzes scheduled +
+ )} +
@@ -212,15 +314,6 @@ const Dashboard = () => { { - // dispatch( - // AddNewCourseLocal({ - // _id: "638f1709897b3c84b7d8d32c", - // name: "Introduction to Engineering Drawing", - // code: "ce101", - // color: "#DBCEFF", - // }) - // ); - // console.log(user); addCourseModalShowHandler(); }} /> diff --git a/client/src/screens/dashboard/styles.scss b/client/src/screens/dashboard/styles.scss index d35ea966..16aaf664 100644 --- a/client/src/screens/dashboard/styles.scss +++ b/client/src/screens/dashboard/styles.scss @@ -24,6 +24,7 @@ align-items: center; justify-content: flex-start; gap: 0; + margin-bottom: 32px; // Add this for spacing below MY COURSES } .fav-container { @@ -32,6 +33,33 @@ padding: 20px 0px; } +.quizzes-header { + display: flex; + justify-content: space-between; + align-items: center; + + .view-all-quizzes-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 8px 16px; + font-family: "Bold"; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } + } +} + .no-fav-graphic { width: 100%; height: 350px; @@ -66,7 +94,7 @@ } // Profile section spacing improvements - .split > div:first-child { + .split>div:first-child { .heading { margin-bottom: 8px; line-height: 1.2; @@ -92,6 +120,19 @@ .coursecard-container { justify-content: space-between; padding: 0 5px; + margin-bottom: 24px; // Optionally, for mobile screens + } + + .quizzes-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + + .view-all-quizzes-btn { + align-self: flex-end; + font-size: 0.8rem; + padding: 6px 12px; + } } } @@ -101,7 +142,7 @@ } // Enhanced profile section spacing for small screens - .split > div:first-child { + .split>div:first-child { .heading { margin-bottom: 6px; line-height: 1.1; @@ -169,4 +210,4 @@ @media screen and (max-width: 768px) { padding: 8px; } -} +} \ No newline at end of file diff --git a/client/src/screens/myquizzes/Delete.svg b/client/src/screens/myquizzes/Delete.svg new file mode 100644 index 00000000..036a5745 --- /dev/null +++ b/client/src/screens/myquizzes/Delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/screens/myquizzes/edit.svg b/client/src/screens/myquizzes/edit.svg new file mode 100644 index 00000000..9d7486d8 --- /dev/null +++ b/client/src/screens/myquizzes/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/screens/myquizzes/index.jsx b/client/src/screens/myquizzes/index.jsx new file mode 100644 index 00000000..d3320c21 --- /dev/null +++ b/client/src/screens/myquizzes/index.jsx @@ -0,0 +1,257 @@ +import "./styles.scss"; +import Container from "../../components/container"; +import Heading from "../../components/heading"; +import Space from "../../components/space"; +import NavBar from "../../components/navbar"; +import SubHeading from "../../components/subheading"; +import Footer from "../../components/footer"; +import QuizCard from "../dashboard/components/quizcard"; +import { useSelector } from "react-redux"; +import { useEffect, useState } from "react"; +import { getColors } from "../../utils/colors"; +import { getQuizEvents, deleteQuizEvent, modifyQuizEvent } from "../../api/Quiz"; +import { toast } from "react-toastify"; +import { useNavigate } from "react-router-dom"; + +// Custom hook to detect mobile view +function useIsMobile() { + const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth <= 768); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + return isMobile; +} + +const MyQuizzes = () => { + const [quizzes, setQuizzes] = useState([]); + const [loading, setLoading] = useState(true); + const [editingQuiz, setEditingQuiz] = useState(null); + const [editForm, setEditForm] = useState({ eventName: "", eventDate: "", course: "" }); + const user = useSelector((state) => state.user); + const navigate = useNavigate(); + const isMobile = useIsMobile(); + + useEffect(() => { + fetchQuizzes(); + }, []); + + const fetchQuizzes = async () => { + try { + setLoading(true); + const data = await getQuizEvents(); + console.log("Quizzes fetched:", data); + setQuizzes(data || []); + } catch (err) { + console.error("Error fetching quizzes:", err); + setQuizzes([]); + toast.error("Failed to fetch quizzes"); + } finally { + setLoading(false); + } + }; + + const handleDeleteQuiz = async (quizId, quizName) => { + if (!user.user?.isBR) { + toast.error("Only Branch Representatives can delete quizzes"); + return; + } + + if (window.confirm(`Are you sure you want to delete "${quizName}"?`)) { + try { + await deleteQuizEvent(quizId); + toast.success("Quiz deleted successfully"); + fetchQuizzes(); // Refresh the list + } catch (error) { + console.error("Error deleting quiz:", error); + toast.error("Failed to delete quiz"); + } + } + }; + + const handleEditQuiz = (quiz) => { + if (!user.user?.isBR) { + toast.error("Only Branch Representatives can edit quizzes"); + return; + } + + setEditingQuiz(quiz._id); + setEditForm({ + eventName: quiz.eventName, + eventDate: quiz.eventDate ? new Date(quiz.eventDate).toISOString().split('T')[0] : "", + course: quiz.course + }); + }; + + const handleSaveEdit = async () => { + if (!editForm.eventName || !editForm.eventDate || !editForm.course) { + toast.error("Please fill in all fields"); + return; + } + + try { + await modifyQuizEvent(editingQuiz, editForm); + toast.success("Quiz updated successfully"); + setEditingQuiz(null); + setEditForm({ eventName: "", eventDate: "", course: "" }); + fetchQuizzes(); // Refresh the list + } catch (error) { + console.error("Error updating quiz:", error); + toast.error("Failed to update quiz"); + } + }; + + const handleCancelEdit = () => { + setEditingQuiz(null); + setEditForm({ eventName: "", eventDate: "", course: "" }); + }; + + if (loading) { + return ( +
+ + + +
+
Loading quizzes...
+
+
+
+
+ ); + } + + return ( +
+ + + +
+ + +
+ + + {quizzes.length > 0 ? ( +
+ {[...quizzes] + .sort((a, b) => new Date(a.eventDate) - new Date(b.eventDate)) + .map((quiz, idx) => ( +
+ + {user.user?.isBR && ( +
+ { + e.stopPropagation(); + handleEditQuiz(quiz); + }} + title="Edit Quiz" + > + { + e.stopPropagation(); + handleDeleteQuiz(quiz._id, quiz.eventName); + }} + title="Delete Quiz" + > +
+ )} +
+
+ ))} +
+ ) : ( +
+
No quizzes scheduled
+
+ {user.user?.isBR + ? "Create quizzes from the dashboard to see them here" + : "Quizzes will appear here when they are scheduled" + } +
+
+ )} + + {/* Edit Modal */} + {editingQuiz && ( +
+
+
+ + +
+
+
+ + setEditForm({ ...editForm, eventName: e.target.value })} + placeholder="Enter quiz name" + /> +
+
+ + +
+
+ + setEditForm({ ...editForm, eventDate: e.target.value })} + /> +
+
+
+ + +
+
+
+ )} +
+
+ ); +}; + +export default MyQuizzes; diff --git a/client/src/screens/myquizzes/styles.scss b/client/src/screens/myquizzes/styles.scss new file mode 100644 index 00000000..1901b7ea --- /dev/null +++ b/client/src/screens/myquizzes/styles.scss @@ -0,0 +1,339 @@ +.myquizzes-header { + text-align: center; + margin-bottom: 20px; +} + +.quizzes-container { + display: flex; + flex-wrap: wrap; + gap: 20px; + justify-content: flex-start; + align-items: flex-start; + + &.mobile { + flex-direction: column; + gap: 15px; + } + + .quiz-card { + position: relative; + z-index: 1; + + &:hover .quiz-actions { + opacity: 1; + } + } + + .quiz-actions { + position: absolute; + top: 10px; + right: 10px; + display: flex; + gap: 8px; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 10; + + .rename-tick { + background: url("./edit.svg"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + width: 20px; + height: 20px; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s ease-in-out; + + &:hover { + opacity: 1; + transform: scale(1.1); + } + } + + .delete { + background: url("./delete.svg"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + width: 20px; + height: 20px; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s ease-in-out; + + &:hover { + opacity: 1; + transform: scale(1.1); + } + } + } +} + + + +.no-quizzes { + text-align: center; + padding: 60px 20px; + + .no-quizzes-text { + font-family: "Bold"; + font-size: 1.5rem; + color: #888; + margin-bottom: 10px; + } + + .no-quizzes-subtext { + font-family: "Regular"; + font-size: 1rem; + color: #666; + max-width: 400px; + margin: 0 auto; + line-height: 1.5; + } +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + + .loading-text { + font-family: "Bold"; + font-size: 1.2rem; + color: #888; + } +} + +// Edit Modal Styles +.edit-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.edit-modal { + background: white; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.edit-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid #e0e0e0; + + .close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s ease; + + &:hover { + background: #f5f5f5; + color: #333; + } + } +} + +.edit-modal-content { + padding: 24px; + + .form-group { + margin-bottom: 20px; + + label { + display: block; + font-family: "Bold"; + font-size: 0.9rem; + color: #333; + margin-bottom: 8px; + } + + input { + width: 100%; + padding: 12px 16px; + border: 1px solid rgba(0, 0, 0, 0.3); + font-size: 1rem; + font-family: "Regular"; + transition: border-color 0.2s ease; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #4f46e5; + } + + &::placeholder { + color: #999; + } + } + } +} + +.edit-modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding: 20px 24px; + border-top: 1px solid #e0e0e0; + + button { + padding: 12px 24px; + font-family: "Bold"; + border: 1px solid rgba(255, 255, 255, 0.33); + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; + min-width: 100px; + + &.cancel-btn { + background: rgba(0, 0, 0, 0.33); + color: black; + + &:hover { + background: rgba(0, 0, 0, 0.33); + color: black; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + } + } + + &.save-btn { + background: #fecf6f; + color: black; + + &:hover { + background: #fecf6f; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + } + + &:active { + transform: translateY(0); + } + } + } +} + +// Mobile Responsive Styles +@media screen and (max-width: 768px) { + .quizzes-container { + gap: 15px; + + .quiz-item { + width: 100%; + + .quiz-actions { + opacity: 1; // Always show on mobile for better UX + position: static; + margin-top: 10px; + justify-content: center; + } + } + } + + .edit-modal { + margin: 10px; + max-height: 95vh; + + .edit-modal-header { + padding: 16px 20px; + } + + .edit-modal-content { + padding: 20px; + } + + .edit-modal-actions { + padding: 16px 20px; + flex-direction: column; + + button { + width: 100%; + } + } + } + + .no-quizzes { + padding: 40px 20px; + + .no-quizzes-text { + font-size: 1.3rem; + } + + .no-quizzes-subtext { + font-size: 0.9rem; + } + } +} + +@media screen and (max-width: 480px) { + .myquizzes-header { + margin-bottom: 15px; + } + + .quizzes-container { + gap: 12px; + } + + .edit-modal { + margin: 5px; + + .edit-modal-header { + padding: 12px 16px; + } + + .edit-modal-content { + padding: 16px; + + .form-group { + margin-bottom: 16px; + + input { + padding: 10px 14px; + font-size: 0.9rem; + } + } + } + + .edit-modal-actions { + padding: 12px 16px; + } + } +} \ No newline at end of file diff --git a/client/yarn.lock b/client/yarn.lock index bbc033e4..a4ff28eb 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -492,6 +492,11 @@ electron-to-chromium@^1.4.251: resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz" integrity sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA== +esbuild-darwin-arm64@0.15.16: + version "0.15.16" + resolved "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.16.tgz" + integrity sha512-fMXaUr5ou0M4WnewBKsspMtX++C1yIa3nJ5R2LSbLCfJT3uFdcRoU/NZjoM4kOMKyOD9Sa/2vlgN8G07K3SJnw== + esbuild@^0.15.9: version "0.15.16" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.15.16.tgz" @@ -561,6 +566,11 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" diff --git a/server/index.js b/server/index.js index e0d8c9eb..740111e0 100644 --- a/server/index.js +++ b/server/index.js @@ -31,8 +31,10 @@ import brRoutes from "./modules/br/br.routes.js"; import fileRoutes from "./modules/file/file.routes.js"; import folderRoutes from "./modules/folder/folder.routes.js"; import yearRoutes from "./modules/year/year.routes.js"; +import quizRoutes from "./modules/quizzes/quizzes.routes.js"; import links from "./links.js"; + const app = express(); const PORT = config.port; @@ -75,6 +77,7 @@ app.use("/api/br", brRoutes); app.use("/api/files", fileRoutes); app.use("/api/folder", folderRoutes); app.use("/api/year", yearRoutes); +app.use("/api/quiz",quizRoutes); app.use( "/homepage", diff --git a/server/modules/course/course.model.js b/server/modules/course/course.model.js index 6564de0f..aba99c05 100644 --- a/server/modules/course/course.model.js +++ b/server/modules/course/course.model.js @@ -3,6 +3,7 @@ import { model, Schema } from "mongoose"; const FolderSchema = Schema({ course: { type: String, required: true }, //id: { type: String, required: true }, + profName: {type:String, }, name: { type: String, required: true }, childType: { type: String, enum: ["File", "Folder"], required: true }, //path: { type: String, required: true }, @@ -28,6 +29,7 @@ const CourseSchema = Schema( code: { type: String, required: true, unique: true }, children: { type: [{ type: Schema.Types.ObjectId, ref: "Folder" }], default: [] }, books: [{ type: String }], + quiz: {} }, { timestamps: true } ); diff --git a/server/modules/quizzes/quizzes.controller.js b/server/modules/quizzes/quizzes.controller.js new file mode 100644 index 00000000..01448fc5 --- /dev/null +++ b/server/modules/quizzes/quizzes.controller.js @@ -0,0 +1,70 @@ +import quizEventModel from "./quizzes.model.js"; + +export const createQuizEvent = async (req, res) => { + try { + const {eventName, eventDate, course,courseName} = req.body; + if(!eventName || !eventDate || !course){ + console.log("All fields are required"); + return res.status(400).json({message: "All fields are required", success: false}); + } + + const newQuizEvent = new quizEventModel({ + eventName, + eventDate, + course, + // courseName + }); + + const savedEvent = await newQuizEvent.save(); + res.status(201).json({ + success: true, + message: "Event Created Successfully", + data: savedEvent, + }); + } catch (error){ + console.log(error); + res.status(500).json({ + success: false, + message: "Error creating quiz event", + error: error.message, + }); + } +} +export const getQuizEvents = async (req,res) =>{ + try { + const quizEvents = await quizEventModel.find().sort({eventDate: 1}); + + res.status(200).json({message: "Quiz events retrieved successfully", success: true, data: quizEvents}); + } catch (error) { + res.status(500).json({ + success: false, + message: "Error retrieving quiz events", + error: error.message, + }); + } +} +export const deleteQuizEvent = async (req,res) => { + try { + const quizEvent = await quizEventModel.findByIdAndDelete(req.params.eventId); + console.log("Deleted Quiz"); + res.status(200).json({message: "Quiz event deleted successfully", success: true, data: quizEvent}); + } catch (error) { + res.status(500).json({ + success: false, + message: "Error deleting quiz event", + error: error.message, + }); + } +} +export const modifyQuizEvent = async (req,res) => { + try { + const quizEvent = await quizEventModel.findByIdAndUpdate(req.params.eventId, req.body, {new: true}); + res.status(200).json({message: "Quiz event modified successfully", success: true, data: quizEvent}); + } catch (error) { + res.status(500).json({ + success: false, + message: "Error modifying quiz event", + error: error.message, + }); + } +} \ No newline at end of file diff --git a/server/modules/quizzes/quizzes.model.js b/server/modules/quizzes/quizzes.model.js new file mode 100644 index 00000000..b2369a77 --- /dev/null +++ b/server/modules/quizzes/quizzes.model.js @@ -0,0 +1,26 @@ +import mongoose from "mongoose"; + +const quizEventSchema = new mongoose.Schema({ + eventName: { + type: String, + required: true, + trim: true + }, + eventDate: { + type: Date, + required: true + }, + course: { + type: String, + required: true, + trim: true + }, + createdAt: { + type: Date, + default: Date.now + } +}); + + +const QuizEvent = mongoose.model('QuizEvent', quizEventSchema); +export default QuizEvent; diff --git a/server/modules/quizzes/quizzes.routes.js b/server/modules/quizzes/quizzes.routes.js new file mode 100644 index 00000000..f33a80a3 --- /dev/null +++ b/server/modules/quizzes/quizzes.routes.js @@ -0,0 +1,15 @@ +import express from "express"; +import { createQuizEvent, getQuizEvents,deleteQuizEvent,modifyQuizEvent } from "./quizzes.controller.js"; +import {isBR } from "../../middleware/isBR.js"; + +const router = express.Router(); + +// Create a new quiz event (BRs only) +router.post('/create', createQuizEvent); + +// Get all quiz events +router.get('/events', getQuizEvents); +router.delete('/delete/:eventId', deleteQuizEvent); +router.put('/modify/:eventId', modifyQuizEvent); + +export default router; diff --git a/server/modules/year/year.controller.js b/server/modules/year/year.controller.js index 8d7c0605..081daf38 100644 --- a/server/modules/year/year.controller.js +++ b/server/modules/year/year.controller.js @@ -3,16 +3,17 @@ import CourseModel from "../course/course.model.js"; import { deleteFile } from "../file/file.controller.js"; async function addYear(req, res) { - const { name, course} = req.body; + const { name, course, profName } = req.body; const newYear = await FolderModel.create({ name, course, + profName, children: [], - childType:"Folder" + childType: "Folder" }); if (course) { - const parent = await CourseModel.findOne({code:course}); + const parent = await CourseModel.findOne({ code: course }); parent.children.push(newYear._id); await parent.save(); } @@ -20,6 +21,25 @@ async function addYear(req, res) { return res.json(newYear); } +async function editProfName(req, res) { + const { yearId, profName } = req.body; + + try { + const year = await FolderModel.findById(yearId); + if (!year) { + return res.status(404).json({ message: "Year not found" }); + } + + // Allow editing professor names regardless of whether they exist or not + year.profName = profName.trim(); + await year.save(); + + return res.json({ message: "Professor name updated successfully", year }); + } catch (error) { + return res.status(500).json({ message: "Failed to update professor name" }); + } +} + async function deleteYear(req, res) { const { folder, courseCode } = req.body; const folderId = folder._id; @@ -27,8 +47,8 @@ async function deleteYear(req, res) { try { if (courseCode) { await CourseModel.findOneAndUpdate( - {code: courseCode}, - {$pull: { children: folderId }} + { code: courseCode }, + { $pull: { children: folderId } } ); } // const deleted = await FolderModel.findByIdAndDelete(folderId); @@ -44,19 +64,19 @@ async function deleteYear(req, res) { } } -async function recursiveDelete(folder){ - if(!folder.children) { +async function recursiveDelete(folder) { + if (!folder.children) { await FolderModel.findByIdAndDelete(folder._id); return; } - if (folder.childType === "Folder"){ - for(const subfolder of folder.children){ + if (folder.childType === "Folder") { + for (const subfolder of folder.children) { await recursiveDelete(subfolder); } await FolderModel.findByIdAndDelete(folder._id); } - else if(folder.childType === "File"){ - for(const file of folder.children){ + else if (folder.childType === "File") { + for (const file of folder.children) { console.log(file); await deleteFile(file); } @@ -64,4 +84,4 @@ async function recursiveDelete(folder){ } } -export {addYear,deleteYear} \ No newline at end of file +export { addYear, deleteYear, editProfName } \ No newline at end of file diff --git a/server/modules/year/year.routes.js b/server/modules/year/year.routes.js index 72dd3358..6a129f37 100644 --- a/server/modules/year/year.routes.js +++ b/server/modules/year/year.routes.js @@ -1,11 +1,12 @@ import express from "express"; -import {addYear,deleteYear} from "./year.controller.js"; +import { addYear, deleteYear, editProfName } from "./year.controller.js"; import isAuthenticated from "../../middleware/isAuthenticated.js"; -import {isBR} from "../../middleware/isBR.js"; +import { isBR } from "../../middleware/isBR.js"; const router = express.Router(); router.post("", isAuthenticated, isBR, addYear); -router.delete("/delete",isAuthenticated,isBR,deleteYear); +router.delete("/delete", isAuthenticated, isBR, deleteYear); +router.put("/edit-prof", isAuthenticated, isBR, editProfName); export default router; diff --git a/server/yarn.lock b/server/yarn.lock index 586fa22f..4f24dbbb 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -2040,6 +2040,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + function-bind@^1.1.1, function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"