diff --git a/CourseHub b/CourseHub new file mode 160000 index 00000000..d7ccac76 --- /dev/null +++ b/CourseHub @@ -0,0 +1 @@ +Subproject commit d7ccac769a7556b0184e77b583f206cdd8805685 diff --git a/client/src/actions/filebrowser_actions.js b/client/src/actions/filebrowser_actions.js index fa619414..ab445987 100644 --- a/client/src/actions/filebrowser_actions.js +++ b/client/src/actions/filebrowser_actions.js @@ -56,3 +56,8 @@ export const RemoveFileFromFolder = (fileId) => ({ type: "REMOVE_FILE_FROM_FOLDER", payload: fileId, }); + +export const RefreshCurrentFolder = () => ({ + type: "REFRESH_CURRENT_FOLDER", + payload: Date.now(), +}); diff --git a/client/src/api/Folder.js b/client/src/api/Folder.js new file mode 100644 index 00000000..76de50dd --- /dev/null +++ b/client/src/api/Folder.js @@ -0,0 +1,33 @@ +import axios from "axios"; +import serverRoot from "./server"; + +// Create axios instance with baseURL and withCredentials +const API = axios.create({ + baseURL: `${serverRoot}/api`, + withCredentials: true, +}); + +// Attach token to every request if available (from localStorage) +API.interceptors.request.use((req) => { + const user = JSON.parse(localStorage.getItem("profile")); + if (user) req.headers.Authorization = `Bearer ${user.token}`; + return req; +}); + +export const createFolder = async ({ name, course, parentFolder,childType }) => { + const { data } = await API.post("/folder/create", { + name, + course, + parentFolder, + childType + }); + return data; +}; + +export const deleteFolder = async ({ folderId, parentFolderId }) => { + const { data } = await API.delete(`/folder/delete`, { + params: { folderId, parentFolderId }, + }); + return data; +}; + diff --git a/client/src/reducers/filebrowser_reducer.js b/client/src/reducers/filebrowser_reducer.js index 2c77084b..f3527399 100644 --- a/client/src/reducers/filebrowser_reducer.js +++ b/client/src/reducers/filebrowser_reducer.js @@ -81,6 +81,12 @@ const FileBrowserReducer = ( }, }; + case "REFRESH_CURRENT_FOLDER": + return { + ...state, + refreshKey: action.payload, + }; + default: return state; } diff --git a/client/src/screens/browse/components/browsefolder/Delete.svg b/client/src/screens/browse/components/browsefolder/Delete.svg new file mode 100644 index 00000000..036a5745 --- /dev/null +++ b/client/src/screens/browse/components/browsefolder/Delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/screens/browse/components/browsefolder/confirmDialog.jsx b/client/src/screens/browse/components/browsefolder/confirmDialog.jsx new file mode 100644 index 00000000..c75e56d0 --- /dev/null +++ b/client/src/screens/browse/components/browsefolder/confirmDialog.jsx @@ -0,0 +1,96 @@ +import React from "react"; +import cross from "./cross.svg"; + +const styles = { + overlay: { + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.4)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 1000, + }, + dialog: { + backgroundColor: "#fff", + padding: "25px 30px", + borderRadius: "8px", + maxWidth: "400px", + width: "90%", + boxShadow: "0 4px 20px rgba(0, 0, 0, 0.2)", + textAlign: "center", + fontFamily: "sans-serif", + }, + + iconImage: { + width: "80px", + height: "80px", + margin: "1em", + + }, + heading: { + fontSize: "2em", + }, + message: { + fontSize: "1em", + color: "#374151", + margin: "1em", + }, + buttonGroup: { + display: "flex", + justifyContent: "center", + gap: "1em", + }, + deleteBtn: { + display: "flex", + alignItems: "center", + backgroundColor: "#ef4444", + color: "#fff", + border: "none", + padding: "10px 18px", + borderRadius: "5px", + cursor: "pointer", + fontWeight: "bold", + fontSize: "1em", + }, + cancelBtn: { + backgroundColor: "#9ca3af", + color: "#fff", + border: "none", + padding: "10px 18px", + borderRadius: "5px", + cursor: "pointer", + fontWeight: "bold", + fontSize: "1em", + }, +}; + + +const ConfirmDialog = ({ isOpen, onConfirm, onCancel }) => { + if (!isOpen) return null; + + return ( +
+
+ Delete +

Are you sure?

+

+ Do you want to permanently delete this folder? This action cannot be undone. +

+
+ + +
+
+
+ ); +}; + +export { ConfirmDialog }; diff --git a/client/src/screens/browse/components/browsefolder/cross.svg b/client/src/screens/browse/components/browsefolder/cross.svg new file mode 100644 index 00000000..3f98f5d4 --- /dev/null +++ b/client/src/screens/browse/components/browsefolder/cross.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/screens/browse/components/browsefolder/index.jsx b/client/src/screens/browse/components/browsefolder/index.jsx index cbf8c76a..643fb85c 100644 --- a/client/src/screens/browse/components/browsefolder/index.jsx +++ b/client/src/screens/browse/components/browsefolder/index.jsx @@ -1,15 +1,39 @@ import "./styles.scss"; +import React, { useState } from "react"; import { useDispatch } from "react-redux"; import { ChangeFolder } from "../../../../actions/filebrowser_actions"; +import { deleteFolder } from "../../../../api/Folder"; +import { toast } from "react-toastify"; +import { RefreshCurrentFolder } from "../../../../actions/filebrowser_actions"; +import { ConfirmDialog } from "./confirmDialog"; const BrowseFolder = ({ type = "file", color, path, name, subject, folderData }) => { const dispatch = useDispatch(); + const [showConfirm, setShowConfirm] = useState(false); const onClick = (folderData) => { // return; dispatch(ChangeFolder(folderData)); }; + + const handleDelete = async (e) => { + try { + await deleteFolder({ folderId: folderData._id, parentFolderId: folderData.parent }); + toast.success("Folder deleted successfully!"); + dispatch(RefreshCurrentFolder()); + } catch (err) { + console.log(err); + toast.error("Failed to delete folder."); + } + setShowConfirm(false); + }; + + const cancelDelete = () => { + setShowConfirm(false); + }; + return ( -
onClick(folderData)}> - {/* {type === "folder" ? ( + <> +
onClick(folderData)}> + {/* {type === "folder" ? ( )} */} -
-
-

{""}

-

{name ? name : "Name"}

-
-
-

{subject ? subject.toUpperCase() : "Subject Here"}

+
+
+

{""}

+

{name ? name : "Name"}

+ { + e.stopPropagation(); + setShowConfirm(true); + }} + title="Delete folder" + > +
+
+

+ {subject ? subject.toUpperCase() : "Subject Here"} +

+
-
+ + ); }; diff --git a/client/src/screens/browse/components/browsefolder/styles.scss b/client/src/screens/browse/components/browsefolder/styles.scss index f9232a1a..64db7200 100644 --- a/client/src/screens/browse/components/browsefolder/styles.scss +++ b/client/src/screens/browse/components/browsefolder/styles.scss @@ -1,4 +1,4 @@ -.browse-folder{ +.browse-folder { width: 200px; height: 175px; margin: 10px; @@ -8,45 +8,62 @@ background: url(./folder.svg), none; background-position: center; background-repeat: no-repeat; - &:hover{ + &:hover { transform: translateY(-2px); } - svg{ + svg { position: absolute; z-index: -1; } - .content{ + .content { display: flex; flex-direction: column; justify-content: space-between; height: 100%; - .top{ - .path{ + .top { + position: relative; + .delete { + background: url(./Delete.svg), none; + position: absolute; + top: 5px; + right: 5px; + width: 20px; + height: 20px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease-in-out; + z-index: 3; + } + .path { padding: 30px 50px 0px 15px; font-size: 0.9rem; } - .name{ - font-family: 'Bold'; + .name { + font-family: "Bold"; font-size: 1.2rem; padding: 0px 50px 0px 15px; } - } - .bottom{ + .top:hover .delete { + opacity: 1; + } + .bottom { width: 100%; text-align: right; display: flex; justify-content: right; - .subject{ + .subject { width: 85%; text-align: right; padding: 0px 15px 20px 0px; - font-family: 'Bold'; + font-family: "Bold"; font-size: 1.2rem; - color: rgba(80, 80, 80, 0.5) + color: rgba(80, 80, 80, 0.5); } } } - -} \ No newline at end of file +} diff --git a/client/src/screens/browse/components/collapsible/index.jsx b/client/src/screens/browse/components/collapsible/index.jsx index a97084cb..a4a832c0 100644 --- a/client/src/screens/browse/components/collapsible/index.jsx +++ b/client/src/screens/browse/components/collapsible/index.jsx @@ -88,35 +88,41 @@ const Collapsible = ({ course, color, state }) => { dispatch(ChangeFolder(null)); return currCourse; }; - const triggerGetCourse = () => { const run = async () => { - const t = await getCurrentCourse(course.code); - if (t) { - const yearChildren = Array.isArray(t.children?.[t.children.length - 1]?.children) - ? t.children[t.children.length - 1].children : []; - if (initial) { - dispatch(ChangeCurrentYearData(t.children.length - 1, yearChildren)); - setInitial(false); - } else { - try { - dispatch(ChangeCurrentYearData(t.children.length - 1, yearChildren)); - if (!folderId) { - dispatch(ChangeFolder(t.children?.[t.children.length - 1])); - folderId = null; - } - } catch (error) { - // console.log(error); - dispatch(ChangeCurrentYearData(t.children.length - 1,yearChildren)); - dispatch(ChangeFolder(t.children?.[t.children.length - 1])); - } + try { + const fetched = await getCurrentCourse(course.code); + if (!fetched) { + toast.error("Course data could not be loaded."); + return; + } + + const yearIndex = fetched.children.length - 1; + const yearFolder = fetched.children?.[yearIndex]; + + if (!yearFolder) { + toast.warn("No folders available for this course."); + dispatch(ChangeCurrentYearData(yearIndex, [])); + dispatch(ChangeFolder(null)); + return; } - dispatch(ChangeCurrentCourse(t.children, t.code)); + + const yearChildren = Array.isArray(yearFolder.children) ? yearFolder.children : []; + + dispatch(ChangeCurrentYearData(yearIndex, yearChildren)); + dispatch(ChangeFolder(yearFolder)); + dispatch(ChangeCurrentCourse(fetched.children, fetched.code)); + setInitial(false); + } catch (error) { + console.error( error); + toast.error("Something went wrong while loading the course."); } }; + run(); }; + let courseCode=course.code.replaceAll(" ", "") useEffect(() => { // console.log(currCourseCode); @@ -174,7 +180,13 @@ const Collapsible = ({ course, color, state }) => { {loading && "loading..."} {error && "error"} {notFound && "course not added yet"} - {!loading && !error && !notFound && } + {!loading && + !error && + !notFound && + currCourseCode?.toLowerCase() === + course.code.replaceAll(" ", "").toLowerCase() && ( + + )}
); diff --git a/client/src/screens/browse/components/folder-info/confirmDialog.jsx b/client/src/screens/browse/components/folder-info/confirmDialog.jsx new file mode 100644 index 00000000..3cf37646 --- /dev/null +++ b/client/src/screens/browse/components/folder-info/confirmDialog.jsx @@ -0,0 +1,130 @@ +import React from "react"; + +const styles = { + overlay: { + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.4)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 1000, + }, + dialog: { + backgroundColor: "#fff", + padding: "25px 30px", + borderRadius: "8px", + maxWidth: "400px", + width: "90%", + boxShadow: "0 4px 20px rgba(0, 0, 0, 0.2)", + textAlign: "center", + fontFamily: "sans-serif", + }, + heading: { + fontSize: "2em", + marginBottom: "0.5em", + }, + input: { + padding: "10px", + fontSize: "1em", + width: "100%", + marginBottom: "1.5em", + border: "1px solid #ccc", + borderRadius: "5px", + }, + selectLabel: { + textAlign: "left", + fontWeight: "500", + marginBottom: "0.3em", + display: "block", + }, + select: { + width: "100%", + padding: "10px", + fontSize: "1em", + marginBottom: "1.5em", + border: "1px solid #ccc", + borderRadius: "5px", + }, + buttonGroup: { + display: "flex", + justifyContent: "center", + gap: "1em", + }, + confirmBtn: { + backgroundColor: "#22c55e", + color: "#fff", + border: "none", + padding: "10px 18px", + borderRadius: "5px", + cursor: "pointer", + fontWeight: "bold", + fontSize: "1em", + }, + cancelBtn: { + backgroundColor: "#9ca3af", + color: "#fff", + border: "none", + padding: "10px 18px", + borderRadius: "5px", + cursor: "pointer", + fontWeight: "bold", + fontSize: "1em", + }, +}; + +const ConfirmDialog = ({ + show, + input = false, + inputValue = "", + onInputChange = () => {}, + childType = "", + onChildTypeChange = () => {}, + onCancel, + onConfirm, + confirmText = "Confirm", + cancelText = "Cancel", +}) => { + if (!show) return null; + + return ( +
+
+

Enter Folder Name

+ {input && ( + + )} + +
+ + +
+
+
+ ); +}; + +export { ConfirmDialog }; diff --git a/client/src/screens/browse/components/folder-info/index.jsx b/client/src/screens/browse/components/folder-info/index.jsx index f9a59846..fbc3da2c 100644 --- a/client/src/screens/browse/components/folder-info/index.jsx +++ b/client/src/screens/browse/components/folder-info/index.jsx @@ -4,11 +4,72 @@ import { CopyToClipboard } from "react-copy-to-clipboard"; import clientRoot from "../../../../api/client"; import Share from "../../../share"; import { useState } from "react"; -const FolderInfo = ({ isBR, path, name, canDownload, contributionHandler, folderId, courseCode }) => { +import { createFolder } from "../../../../api/Folder"; +import { getCourse } from "../../../../api/Course"; +import { UpdateCourses } from "../../../../actions/filebrowser_actions"; +import { AddNewCourseLocal } from "../../../../actions/user_actions"; +import { useDispatch } from "react-redux"; +import { RefreshCurrentFolder } from "../../../../actions/filebrowser_actions"; +import {ConfirmDialog} from "./confirmDialog"; + +const FolderInfo = ({ + isBR, + path, + name, + canDownload, + contributionHandler, + folderId, + courseCode, +}) => { + const dispatch = useDispatch(); + const [showConfirm, setShowConfirm] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + const [childType, setChildType] = useState(""); + + const handleShare = () => { const sectionShare = document.getElementById("share"); sectionShare.classList.add("show"); }; + + const handleCreateFolder = () => { + setNewFolderName(""); + setChildType(""); + setShowConfirm(true); + }; + + const handleConfirmCreateFolder = async () => { + const folderName = newFolderName.trim(); + if (!folderName?.trim() || !childType) return; + + if (!courseCode || !folderId) { + toast.error("No course selected."); + return; + } + + try { + const res = await getCourse(courseCode); + if (!res.data?.found) { + toast.error("Course not found. Cannot create folder."); + return; + } + + await createFolder({ + name: folderName.trim(), + course: courseCode, + parentFolder: folderId, + childType: childType, + }); + + toast.success(`Folder "${folderName}" created`); + dispatch(RefreshCurrentFolder()); + } catch (error) { + console.log(error); + toast.error("Failed to create folder."); + } + setShowConfirm(false); + }; + return ( <>
@@ -34,8 +95,7 @@ const FolderInfo = ({ isBR, path, name, canDownload, contributionHandler, folder
- { - canDownload? + {canDownload ? (
{/* */} - +
+ + ) : ( + <> + )} + {isBR && !canDownload && ( +
+
- : <> - } + )} + setNewFolderName(e.target.value)} + childType={childType} + onChildTypeChange={setChildType} + onConfirm={handleConfirmCreateFolder} + onCancel={() => setShowConfirm(false)} + confirmText="Create" + cancelText="Cancel" + /> ); }; diff --git a/client/src/screens/browse/index.jsx b/client/src/screens/browse/index.jsx index d6c861a4..38f56bb2 100644 --- a/client/src/screens/browse/index.jsx +++ b/client/src/screens/browse/index.jsx @@ -24,10 +24,12 @@ import { getCourse } from "../../api/Course"; import { toast } from "react-toastify"; import Share from "../share"; import FileController from "./components/collapsible/components/file-controller"; +import { RefreshCurrentFolder } from "../../actions/filebrowser_actions"; const BrowseScreen = () => { const user = useSelector((state) => state.user); const folderData = useSelector((state) => state.fileBrowser.currentFolder); + const refreshKey = useSelector((state) => state.fileBrowser.refreshKey); const currCourse = useSelector((state) => state.fileBrowser.currentCourse); const currCourseCode = useSelector((state) => state.fileBrowser.currentCourseCode); const currYear = useSelector((state) => state.fileBrowser.currentYear); @@ -43,7 +45,7 @@ const BrowseScreen = () => { const { code, folderId } = useParams(); const fb = useSelector((state) => state.fileBrowser); useEffect(() => { - sessionStorage.removeItem("AllCourses"); + sessionStorage.removeItem("AllCourses"); }, []); useEffect(() => { if (sessionStorage.getItem("AllCourses") !== null) { @@ -132,7 +134,7 @@ const BrowseScreen = () => { // console.log(fb); // console.log(user); // }, [fb, user]); - + // const fetchCourseDataAgain = async (courseCode) => { // try { // const courseCode = currCourseCode ; @@ -147,12 +149,38 @@ const BrowseScreen = () => { // console.error("Error refetching course data:", error); // } // }; - const headerText=folderData?.path ? - folderData.path - : folderData?.childType==="Folder"? "Select a folder..." - : folderData?.childType==="File"? "Select a file..." - :currYear? "Select an year...":"Select a course..." - + + useEffect(() => { + const refreshFolderData = async () => { + if (!folderData?._id || !currCourseCode) return; + + try { + const res = await getCourse(currCourseCode); + if (res.data?.found) { + const updatedFolder = findFolderById(res.data.children, folderData._id); + if (updatedFolder) { + dispatch(ChangeFolder(updatedFolder)); + } + } + } catch (err) { + toast.error("Could not refresh folder view."); + } + }; + + refreshFolderData(); + }, [refreshKey]); + + const findFolderById = (folders, id) => { + for (const folder of folders) { + if (folder._id === id) return folder; + if (folder.children?.length) { + const result = findFolderById(folder.children, id); + if (result) return result; + } + } + return null; + }; + return (
@@ -160,49 +188,61 @@ const BrowseScreen = () => {
+

MY COURSES

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

PREVIOUS COURSES

+ {!(user.user?.isBR && user.user?.previousCourses?.length > 0) + ? "" + : user.user?.previousCourses?.map((course, idx) => { + return ( + + ); + })}
- + {folderData && ( + + )}
- {!folderData ? - headerText - : folderData?.childType === "File" ? ( - - ) : ( + {!folderData ? ( +
Select a course
+ ) : folderData?.childType === "File" ? ( + + ) : folderData?.children?.length === 0 ? ( +
+

No folders available.

+
+ ) : ( folderData?.children.map((folder) => ( )) - )} - + )} - {/* // : folderData?.childType === "File" + {/* // : folderData?.childType === "File" // ? folderData?.children?.map((file) => ( // { ChangeCurrentYearData(idx, currCourse[idx].children) ); dispatch(ChangeFolder(currCourse[idx])); + dispatch(RefreshCurrentFolder()); }} key={idx} > diff --git a/client/src/screens/browse/styles.scss b/client/src/screens/browse/styles.scss index 645f2ec1..7452f95a 100644 --- a/client/src/screens/browse/styles.scss +++ b/client/src/screens/browse/styles.scss @@ -15,6 +15,12 @@ max-width: 300px; border-right: 1px solid rgba(0, 0, 0, 0.33); overflow-y: auto; + .heading{ + padding: 10px 20px; + font-family: 'Bold'; + font-size: 1.2rem; + background-color: #FECF6F; + } } .middle{ flex: 6; @@ -27,6 +33,9 @@ flex-wrap: wrap; // justify-content: space-between; } + .empty-message{ + font-size: 1.2rem; + } &::-webkit-scrollbar{ display: none; } diff --git a/server/index.js b/server/index.js index f6ffe7d1..09dd8d2a 100644 --- a/server/index.js +++ b/server/index.js @@ -29,6 +29,7 @@ import scheduleRoutes from "./modules/schedule/schedule.routes.js"; import snapshotRoutes from "./modules/snapshot/snapshot.routes.js"; import brRoutes from "./modules/br/br.routes.js"; import fileRoutes from "./modules/file/file.routes.js"; +import folderRoutes from "./modules/folder/folder.routes.js"; const app = express(); @@ -62,6 +63,7 @@ app.use("/api/schedule", scheduleRoutes); app.use("/api/snapshot", snapshotRoutes); app.use("/api/br", brRoutes); app.use("/api/files", fileRoutes); +app.use("/api/folder", folderRoutes); app.use( "/homepage", diff --git a/server/modules/auth/auth.controller.js b/server/modules/auth/auth.controller.js index a6075b99..8625230e 100644 --- a/server/modules/auth/auth.controller.js +++ b/server/modules/auth/auth.controller.js @@ -262,6 +262,9 @@ export const redirectHandler = async (req, res, next) => { if (existingUser && !userUpdated) { const courses = await fetchCourses(userFromToken.data.surname); existingUser.courses = courses; + if (br) { + existingUser.previousCourses = await fetchCoursesForBr(userFromToken.data.surname); + } existingUser.semester = calculateSemester(userFromToken.data.surname); await existingUser.save(); const newUpdation = new UserUpdate({ rollNumber: roll }); diff --git a/server/modules/folder/folder.controller.js b/server/modules/folder/folder.controller.js new file mode 100644 index 00000000..2e8d1ab6 --- /dev/null +++ b/server/modules/folder/folder.controller.js @@ -0,0 +1,39 @@ +import { FolderModel } from "../course/course.model.js"; + +async function createFolder(req, res) { + const { name, course, parentFolder, childType } = req.body; + const newFolder = await FolderModel.create({ + name, + course, + childType, + children: [], + }); + + if (parentFolder) { + const parent = await FolderModel.findById(parentFolder); + parent.children.push(newFolder._id); + await parent.save(); + } + + return res.json(newFolder); +} +async function deleteFolder(req, res) { + const { folderId, parentFolderId } = req.query; + + try { + if (parentFolderId) { + await FolderModel.findByIdAndUpdate(parentFolderId, { + $pull: { children: folderId }, + }); + } + const deleted = await FolderModel.findByIdAndDelete(folderId); + if (!deleted) { + return res.status(404).json({ message: "Folder not found" }); + } + + return res.json({ success: true, folderId }); + } catch (err) { + return res.status(500).json({ success: false, error: err.message }); + } +} +export { createFolder, deleteFolder }; diff --git a/server/modules/folder/folder.routes.js b/server/modules/folder/folder.routes.js new file mode 100644 index 00000000..9c51b366 --- /dev/null +++ b/server/modules/folder/folder.routes.js @@ -0,0 +1,11 @@ +import express from "express"; +import { createFolder,deleteFolder } from "./folder.controller.js"; +import isAuthenticated from "../../middleware/isAuthenticated.js"; +import {isBR} from "../../middleware/isBR.js"; // if it's a named export + +const router = express.Router(); + +router.post("/create", isAuthenticated, isBR, createFolder); +router.delete("/delete", isAuthenticated, isBR, deleteFolder); + +export default router;