diff --git a/backend/.babelrc b/.babelrc
similarity index 100%
rename from backend/.babelrc
rename to .babelrc
diff --git a/.gitignore b/.gitignore
index 3d70248ba2..0936ed3415 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,15 +1,35 @@
-node_modules
-.DS_Store
+# Node modules
+node_modules/
+backend/node_modules/
+frontend/node_modules/
+
+# Logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
+backend/.env
+frontend/.env
-build
+# Build outputs
+frontend/dist/
+frontend/build/
+build/
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
+# OS files
+.DS_Store
+Thumbs.db
+
+# IDE configs
+.vscode/
+.idea/
-package-lock.json
\ No newline at end of file
+# Package lock
+package-lock.json
diff --git a/README.md b/README.md
index 31466b54c2..32f7a38cd8 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,58 @@
-# Final Project
+Final Project
-Replace this readme with your own information about your project.
+#This is a full-stack Task Management App where users can create, update, and manage tasks, projects, and groups with file uploads, authentication, and a responsive UI.
-Start by briefly describing the assignment in a sentence or two. Keep it short and to the point.
+# The problem
-## The problem
+#A lot of people including myself have task managing problems this app solves it with a vast array of methods. It also supports collaberation.
-Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next?
+#The assignment was to build a complete full-stack application. I chose to create a task and project management system that supports collaboration, authentication, and file handling.
-## View it live
+#I planned the project around scalability and user experience, using:
-Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about.
\ No newline at end of file
+#Frontend: React (Vite) with Zustand for state management and Material-UI for accessible, responsive components.
+
+Backend: Node.js + Express with MongoDB, GridFS for file storage, Multer for uploads, and JWT for authentication.
+
+Deployment: Netlify (frontend) and Render (backend).
+
+If I had more time, I’d add real-time updates (WebSockets), richer collaboration tools, and more advanced analytics dashboards.
+
+## View live
+https://project-final-darius-1.onrender.com
+
+Frontend: https://project-final-darius.netlify.app/
+
+Key Highlights:
+
+Frontend: React (Vite), Zustand for state management with persistence, Material-UI for responsive and accessible UI, Axios for API communication.
+
+Backend: Node.js + Express with a RESTful API, MongoDB for storage, GridFS for file handling, Multer for uploads, and JWT for authentication.
+
+Core Features:
+
+Full task CRUD (create, update, delete, completion tracking).
+
+Project management with automatic completion calculation.
+
+Group management (create, join, leave, assign projects).
+
+File uploads stored in MongoDB GridFS.
+
+Filtering, search, and pagination support.
+
+UX & Accessibility: Dark/light mode toggle, responsive layout, ARIA roles, modals, snackbars, and real-time state-driven updates.
+
+Deployment: Frontend on Netlify, backend on Render.
+
+What it demonstrates:
+
+Proficiency in advanced state management (Zustand + persistence).
+
+Secure authentication with JWT and protected API routes.
+
+Scalable backend architecture with modular routes and file storage.
+
+Strong focus on UX and accessibility through Material-UI and responsive design.
+
+Readiness for future enhancements like real-time collaboration.
diff --git a/backend/db/db.js b/backend/db/db.js
new file mode 100644
index 0000000000..fb86065218
--- /dev/null
+++ b/backend/db/db.js
@@ -0,0 +1,20 @@
+import mongoose from "mongoose";
+
+export const connectDB = async () => {
+ const mongoUrl = process.env.MONGO_URI || "mongodb://localhost:27017/task-manager";
+
+ try {
+ await mongoose.connect(mongoUrl, {
+ useNewUrlParser: true,
+
+ });
+ console.log("✅ MongoDB connected:", mongoUrl);
+ } catch (err) {
+ console.error("❌ MongoDB connection error:", err);
+ process.exit(1); // exit process if DB connection fails
+ }
+
+ mongoose.connection.on("disconnected", () => {
+ console.warn("⚠️ MongoDB disconnected!");
+ });
+};
diff --git a/backend/middleware/authmiddleware.js b/backend/middleware/authmiddleware.js
new file mode 100644
index 0000000000..4887d73b02
--- /dev/null
+++ b/backend/middleware/authmiddleware.js
@@ -0,0 +1,36 @@
+import jwt from "jsonwebtoken";
+
+export const authMiddleware = (req, res, next) => {
+ try {
+ const authHeader = req.headers.authorization;
+ if (!authHeader) {
+ console.warn("[Auth] No Authorization header");
+ return res.status(401).json({ error: "No token provided" });
+ }
+
+ const token = authHeader.split(" ")[1];
+ if (!token) {
+ console.warn("[Auth] No token after Bearer");
+ return res.status(401).json({ error: "No token provided" });
+ }
+
+ const decoded = jwt.verify(token, process.env.JWT_SECRET || "supersecret123");
+
+ if (!decoded) {
+ console.warn("[Auth] Token could not be decoded");
+ return res.status(401).json({ error: "Invalid token" });
+ }
+
+ // Ensure req.user has id and groupId
+ req.user = {
+ id: decoded.id,
+ groupId: decoded.groupId,
+ };
+
+
+ next();
+ } catch (err) {
+ console.error("[Auth] Error verifying token:", err.message);
+ res.status(401).json({ error: "Unauthorized" });
+ }
+};
diff --git a/backend/middleware/upload-task.js b/backend/middleware/upload-task.js
new file mode 100644
index 0000000000..5ea9ffd8eb
--- /dev/null
+++ b/backend/middleware/upload-task.js
@@ -0,0 +1,26 @@
+import multer from "multer";
+import path from "path";
+
+// Store uploads in /uploads folder
+const storage = multer.diskStorage({
+ destination: (req, file, cb) => {
+ cb(null, "uploads/");
+ },
+ filename: (req, file, cb) => {
+ cb(null, Date.now() + path.extname(file.originalname)); // unique name
+ },
+});
+
+export const upload = multer({ storage });
+
+export const detectFileType = (mimetype) => {
+ if (mimetype.startsWith("image/")) return "image";
+ if (mimetype === "application/pdf") return "pdf";
+ if (
+ mimetype === "application/msword" ||
+ mimetype.includes("officedocument")
+ )
+ return "doc";
+ if (mimetype.startsWith("text/")) return "text";
+ return "other";
+};
\ No newline at end of file
diff --git a/backend/model/files.js b/backend/model/files.js
new file mode 100644
index 0000000000..bbb7a9d7cc
--- /dev/null
+++ b/backend/model/files.js
@@ -0,0 +1,17 @@
+import mongoose from "mongoose";
+const fileSchema = new mongoose.Schema({
+ name: String, // original file name
+ filename: String, // GridFS stored filename (with timestamp prefix)
+ url: String, // `/tasks/files/:filename` for frontend
+ contentType: String, // MIME type (e.g., text/plain, application/pdf)
+ size: Number, // file size in bytes
+ type: {
+ type: String,
+ enum: ["image", "pdf", "doc", "text", "other"],
+ default: "other"
+ },
+ folder: { type: String, default: "root" },
+});
+
+
+export default mongoose.model("File", fileSchema);
diff --git a/backend/model/groups.js b/backend/model/groups.js
new file mode 100644
index 0000000000..6962f1d29f
--- /dev/null
+++ b/backend/model/groups.js
@@ -0,0 +1,10 @@
+import mongoose from "mongoose";
+
+const groupSchema = new mongoose.Schema({
+ name: { type: String, required: true },
+ members: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }],
+ currentProject: { type: String, default: null },
+ createdAt: { type: Date, default: Date.now },
+});
+
+export default mongoose.model("Group", groupSchema);
diff --git a/backend/model/tasks.js b/backend/model/tasks.js
new file mode 100644
index 0000000000..74e8b1b4e5
--- /dev/null
+++ b/backend/model/tasks.js
@@ -0,0 +1,31 @@
+import mongoose from "mongoose";
+
+const fileSchema = new mongoose.Schema({
+ name: String, // original file name
+ filename: String, // GridFS stored filename (with timestamp prefix)
+ url: String, // `/tasks/files/:filename` for frontend
+ contentType: String, // MIME type (e.g., text/plain, application/pdf)
+ size: Number, // file size in bytes
+ type: {
+ type: String,
+ enum: ["image", "pdf", "doc", "text", "other"],
+ default: "other"
+ },
+ folder: { type: String, default: "root" },
+});
+
+
+const taskSchema = new mongoose.Schema({
+ title: { type: String, required: true, trim: true },
+ description: { type: String },
+category: { type: String, enum: ["Work","Home","Health","Errands","Leisure","Other",""], default: "Other" } ,
+priority: { type: String, enum: ["low", "medium", "high"], default: "medium" },
+ dueDate: { type: Date },
+ completed: { type: Boolean, default: false },
+ files: [fileSchema], // structured files
+ createdBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
+ group: { type: mongoose.Schema.Types.ObjectId, ref: "Group", required: true },
+ createdAt: { type: Date, default: Date.now },
+});
+
+export const Task = mongoose.model("Task", taskSchema);
diff --git a/backend/model/users.js b/backend/model/users.js
new file mode 100644
index 0000000000..d31051ce57
--- /dev/null
+++ b/backend/model/users.js
@@ -0,0 +1,11 @@
+import mongoose from "mongoose";
+
+const userSchema = new mongoose.Schema({
+ username: { type: String, required: true, unique: true, trim: true },
+email: { type: String, lowercase: true } ,// remove required
+ passwordHash: { type: String, required: true },
+ group: { type: mongoose.Schema.Types.ObjectId, ref: "Group" },
+ createdAt: { type: Date, default: Date.now }
+});
+
+export default mongoose.model("User", userSchema);
diff --git a/backend/package.json b/backend/package.json
index 08f29f2448..2e22da1ad8 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,10 +1,11 @@
{
"name": "project-final-backend",
"version": "1.0.0",
+ "type": "module",
"description": "Server part of final project",
"scripts": {
- "start": "babel-node server.js",
- "dev": "nodemon server.js --exec babel-node"
+ "start": "babel-node backend/server.js",
+ "dev": "nodemon backend/server.js --exec babel-node"
},
"author": "",
"license": "ISC",
@@ -12,9 +13,13 @@
"@babel/core": "^7.17.9",
"@babel/node": "^7.16.8",
"@babel/preset-env": "^7.16.11",
+ "backend": "file:..",
+ "bcrypt": "^6.0.0",
"cors": "^2.8.5",
"express": "^4.17.3",
+ "jsonwebtoken": "^9.0.2",
"mongoose": "^8.4.0",
+ "multer": "^2.0.2",
"nodemon": "^3.0.1"
}
-}
\ No newline at end of file
+}
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
new file mode 100644
index 0000000000..0950184dba
--- /dev/null
+++ b/backend/routes/auth.js
@@ -0,0 +1,112 @@
+import express from "express";
+import bcrypt from "bcrypt";
+import jwt from "jsonwebtoken";
+import User from "../model/users.js";
+import Group from "../model/groups.js";
+import { authMiddleware } from "../middleware/authmiddleware.js";
+
+const router = express.Router();
+const JWT_SECRET = process.env.JWT_SECRET || "supersecret123";
+
+// --- Register ---
+router.post("/register", async (req, res) => {
+ console.log("REQ.BODY:", req.body);
+
+ try {
+ const { username = "", password } = req.body;
+
+ // Validate input
+ if (!username || !password) {
+ return res.status(400).json({ error: "Username and password are required" });
+ }
+
+ // Check if username already exists
+ const existingUser = await User.findOne({ username });
+ if (existingUser) {
+ return res.status(409).json({ error: "Username already taken" });
+ }
+
+ // Hash password
+ const salt = await bcrypt.genSalt(10);
+ const passwordHash = await bcrypt.hash(password, salt);
+
+ // Find or create default group
+ let defaultGroup = await Group.findOne({ name: "Default Group" });
+ if (!defaultGroup) {
+ defaultGroup = await Group.create({ name: "Default Group" });
+ }
+
+ // Create user
+ const user = await User.create({
+ username,
+ passwordHash,
+ group: defaultGroup._id,
+
+ });
+
+ res.status(201).json({ message: "User registered successfully", userId: user._id });
+ } catch (err) {
+ console.error("Register error:", err);
+ res.status(500).json({ error: "Internal server error" });
+ }
+});
+
+// --- Login ---
+router.post("/login", async (req, res) => {
+ try {
+ const { username, password } = req.body;
+
+ if (!password || (!username)) {
+ return res.status(400).json({ error: "Provide username and password" });
+ }
+
+ // Find user by username o
+ const user = await User.findOne({ username }).populate("group");
+
+ if (!user) return res.status(401).json({ error: "User not found" });
+
+ // Compare password
+ const isMatch = await bcrypt.compare(password, user.passwordHash);
+
+
+ if (!isMatch) return res.status(401).json({ error: "Invalid password" });
+
+
+ // Generate JWT
+ const token = jwt.sign(
+ { id: user._id,
+ groupId: user.group._id
+ },
+ JWT_SECRET,
+ { expiresIn: "7d" }
+ );
+
+ res.json({ token, user: { id: user._id, username: user.username, group: user.group } });
+ } catch (err) {
+ console.error("Login error:", err);
+ res.status(500).json({ error: "Internal server error" });
+ }
+});
+router.get("/me", authMiddleware, async (req, res) => {
+ try {
+ // Fetch user data from database using the ID from the JWT
+ const user = await User.findById(req.user.id).populate("group").select("-passwordHash");
+ if (!user) {
+ return res.status(404).json({ error: "User not found" });
+ }
+
+ // Return user data in the expected format
+ res.json({
+ user: {
+ id: user._id,
+ username: user.username,
+ group: user.group ? { id: user.group._id, name: user.group.name } : null,
+ },
+ });
+ } catch (err) {
+ console.error("Get user error:", err);
+ res.status(500).json({ error: "Internal server error" });
+ }
+});
+
+export default router;
diff --git a/backend/routes/group-routes.js b/backend/routes/group-routes.js
new file mode 100644
index 0000000000..81b7d55993
--- /dev/null
+++ b/backend/routes/group-routes.js
@@ -0,0 +1,156 @@
+import express from "express";
+import Group from "../model/groups.js";
+import { authMiddleware } from "../middleware/authmiddleware.js";
+
+const router = express.Router();
+
+/**
+ * CREATE a new group
+ * POST /groups
+ * Body: { name, currentProject (optional) }
+ */
+router.post("/", authMiddleware, async (req, res) => {
+ try {
+ const { name, currentProject } = req.body;
+ if (!name) return res.status(400).json({ error: "Group name is required" });
+
+ const existing = await Group.findOne({ name });
+ if (existing) return res.status(409).json({ error: "Group name already exists" });
+
+ const group = await Group.create({
+ name,
+ currentProject: currentProject || null,
+ members: [req.user.id], // creator automatically joins
+ });
+
+ res.status(201).json(group);
+ } catch (err) {
+ console.error("Create group error:", err);
+ res.status(500).json({ error: "Internal server error" });
+ }
+});
+
+// DELETE /groups/:id
+router.delete("/:id", authMiddleware, async (req, res) => {
+ try {
+ const group = await Group.findById(req.params.id);
+ if (!group) return res.status(404).json({ error: "Group not found" });
+
+ // Optional: Check if user is group creator
+ if (group.members[0].toString() !== req.user.id) {
+ return res.status(403).json({ error: "Only group creator can delete" });
+ }
+
+ await group.deleteOne();
+ res.json({ message: "Group deleted successfully" });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+/**
+ * GET all groups
+ * GET /groups
+ */
+router.get("/", authMiddleware, async (req, res) => {
+
+ try {
+ const groups = await Group.find().populate("members", "username");
+
+ console.log("Fetched groups:", JSON.stringify(groups, null, 2));
+
+ res.json(groups);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+/**
+ * JOIN a group
+ * PUT /groups/:id/join
+ */
+router.put("/:id/join", authMiddleware, async (req, res) => {
+ try {
+ const group = await Group.findById(req.params.id);
+ if (!group) return res.status(404).json({ error: "Group not found" });
+
+ if (!group.members.includes(req.user.id)) {
+ group.members.push(req.user.id);
+ await group.save();
+ }
+
+ res.json(group);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+/**
+ * LEAVE a group
+ * PUT /groups/:id/leave
+ */
+router.put("/:id/leave", authMiddleware, async (req, res) => {
+ try {
+ const group = await Group.findById(req.params.id);
+ if (!group) return res.status(404).json({ error: "Group not found" });
+
+ group.members = group.members.filter((id) => id.toString() !== req.user.id);
+ await group.save();
+
+ res.json({ message: "Left group successfully", group });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+/**
+ * SET current project for group
+ * PUT /groups/:id/project
+ * Body: { projectName }
+ */
+router.put("/:id/project", authMiddleware, async (req, res) => {
+ try {
+ const { projectName } = req.body;
+ if (!projectName) return res.status(400).json({ error: "Project name required" });
+
+ const group = await Group.findById(req.params.id);
+ if (!group) return res.status(404).json({ error: "Group not found" });
+
+ group.currentProject = projectName;
+ await group.save();
+
+ res.json(group);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+/**
+ * GET single group by ID
+ * GET /groups/:id
+ */
+router.get("/:id", authMiddleware, async (req, res) => {
+ try {
+ const group = await Group.findById(req.params.id).populate("members", "username");
+ if (!group) return res.status(404).json({ error: "Group not found" });
+ res.json(group);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+router.delete("/:id/project", authMiddleware, async (req, res) => {
+ try {
+ const group = await Group.findById(req.params.id);
+ if (!group) return res.status(404).json({ error: "Group not found" });
+
+ group.currentProject = null;
+ await group.save();
+
+ res.json({ message: "Project removed", group });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+export default router;
diff --git a/backend/routes/taskroutes.js b/backend/routes/taskroutes.js
new file mode 100644
index 0000000000..d5390bcbeb
--- /dev/null
+++ b/backend/routes/taskroutes.js
@@ -0,0 +1,196 @@
+import express from "express";
+import mongoose from "mongoose";
+import multer from "multer";
+import { Task } from "../model/tasks.js";
+import Group from "../model/groups.js";
+import { authMiddleware } from "../middleware/authmiddleware.js";
+
+const router = express.Router();
+
+// ------------------ MONGODB GRIDFS SETUP ------------------
+const mongoURI = process.env.MONGO_URI;
+const conn = mongoose.createConnection(mongoURI, {});
+
+let gfs;
+conn.once("open", () => {
+ gfs = new mongoose.mongo.GridFSBucket(conn.db, { bucketName: "uploads" });
+ console.log("✅ GridFS initialized");
+});
+
+// ------------------ MULTER MEMORY STORAGE ------------------
+const upload = multer({ storage: multer.memoryStorage() });
+
+// ------------------ HELPERS ------------------
+const waitForGFS = () =>
+ new Promise((resolve) => {
+ if (gfs) return resolve();
+ conn.once("open", () => {
+ gfs = new mongoose.mongo.GridFSBucket(conn.db, { bucketName: "uploads" });
+ console.log("✅ GridFS initialized");
+ resolve();
+ });
+ });
+
+const saveFileToGridFS = async (file) => {
+ await waitForGFS();
+ return new Promise((resolve, reject) => {
+ try {
+ const filename = `${Date.now()}-${file.originalname}`;
+ const uploadStream = gfs.openUploadStream(filename, { contentType: file.mimetype });
+ uploadStream.end(file.buffer);
+
+ uploadStream.on("finish", () =>
+ resolve({
+ name: file.originalname,
+ filename,
+ url: `/tasks/files/${filename}`,
+ contentType: file.mimetype,
+ size: file.size,
+ folder: "root",
+ })
+ );
+
+ uploadStream.on("error", reject);
+ } catch (err) {
+ reject(err);
+ }
+ });
+};
+
+// ------------------ ROUTES ------------------
+
+// --- Serve files from GridFS ---
+router.get("/files/:filename", async (req, res) => {
+ try {
+ await waitForGFS();
+ const { filename } = req.params;
+
+ const files = await gfs.find({ filename }).toArray();
+ if (!files.length) return res.status(404).json({ error: "File not found" });
+
+ const file = files[0];
+ res.set({
+ "Content-Type": file.contentType || "application/octet-stream",
+ "Content-Disposition": `inline; filename="${file.filename}"`,
+ });
+
+ gfs.openDownloadStreamByName(filename).pipe(res).on("error", (err) => {
+ console.error("❌ Error streaming file:", err);
+ if (!res.headersSent) res.status(500).json({ error: "Error streaming file" });
+ else res.destroy(err);
+ });
+ } catch (err) {
+
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- Create task ---
+router.post("/", authMiddleware, upload.array("files", 5), async (req, res) => {
+ try {
+ const { task: title, category, projectId, dueDate, description } = req.body;
+
+ const files = await Promise.all((req.files || []).map(saveFileToGridFS));
+
+ const safeCategory = category?.trim() || "Other";
+ const groupId = req.user.groupId || (await Group.findOne({ name: "Default Group" }))._id;
+
+ const task = await Task.create({
+ title,
+ description,
+ category: safeCategory,
+ dueDate,
+ files,
+ createdBy: req.user.id,
+ group: groupId,
+ });
+
+ res.status(201).json(task);
+ } catch (err) {
+
+ res.status(400).json({ error: err.message });
+ }
+});
+
+
+
+// --- Upload file to existing task ---
+router.post("/:taskId/files", authMiddleware, upload.single("file"), async (req, res) => {
+ try {
+
+ const task = await Task.findById(req.params.taskId);
+ if (!task) return res.status(404).json({ error: "Task not found" });
+
+ const fileMeta = await saveFileToGridFS(req.file);
+ task.files.push(fileMeta);
+ await task.save();
+
+ res.json({ message: "File uploaded", task });
+ } catch (err) {
+
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- Get all tasks ---
+router.get("/", authMiddleware, async (req, res) => {
+ try {
+ const tasks = await Task.find({ group: req.user.groupId })
+ .populate("createdBy", "username")
+ .sort({ createdAt: -1 });
+
+ res.json(tasks);
+ } catch (err) {
+
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- Get single task ---
+router.get("/:id", authMiddleware, async (req, res) => {
+ try {
+ const task = await Task.findById(req.params.id);
+ if (!task) return res.status(404).json({ error: "Task not found" });
+
+ res.json(task);
+ } catch (err) {
+
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- Update task ---
+router.put("/:id", authMiddleware, upload.array("files", 5), async (req, res) => {
+ try {
+ const { title, description, priority, dueDate, completed, folder } = req.body;
+ const newFiles = await Promise.all((req.files || []).map(saveFileToGridFS));
+
+ const task = await Task.findByIdAndUpdate(
+ req.params.id,
+ { $set: { title, description, priority, dueDate, completed }, $push: { files: { $each: newFiles } } },
+ { new: true }
+ );
+
+ if (!task) return res.status(404).json({ error: "Task not found" });
+
+ res.json(task);
+ } catch (err) {
+
+ res.status(400).json({ error: err.message });
+ }
+});
+
+// --- Delete task ---
+router.delete("/:id", authMiddleware, async (req, res) => {
+ try {
+ const task = await Task.findByIdAndDelete(req.params.id);
+ if (!task) return res.status(404).json({ error: "Task not found" });
+
+ res.json({ message: "Task deleted" });
+ } catch (err) {
+
+ res.status(500).json({ error: err.message });
+ }
+});
+
+export default router;
diff --git a/backend/server.js b/backend/server.js
index 070c875189..7ed87a9f82 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -1,22 +1,75 @@
import express from "express";
import cors from "cors";
-import mongoose from "mongoose";
+import dotenv from "dotenv";
+import multer from "multer";
-const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project";
-mongoose.connect(mongoUrl);
-mongoose.Promise = Promise;
+import { connectDB } from "./db/db.js";
+import authRoutes from "./routes/auth.js";
+import taskRoutes from "./routes/taskroutes.js";
+import groupRoutes from "./routes/group-routes.js";
+import { authMiddleware } from "./middleware/authmiddleware.js";
+
+dotenv.config();
-const port = process.env.PORT || 8080;
const app = express();
+const port = process.env.PORT || 8080;
+
+// --- Database ---
+connectDB();
-app.use(cors());
+// --- CORS Setup ---
+const allowedOrigins = [
+ "https://project-final-darius.netlify.app",
+ "https://project-final-darius-1.onrender.com"
+];
+
+app.use(cors({
+ origin: (origin, callback) => {
+ console.log("[CORS] Request origin:", origin);
+ if (!origin || allowedOrigins.includes(origin)) {
+ return callback(null, true);
+ }
+ callback(new Error("CORS not allowed"));
+ },
+ credentials: true,
+}));
+
+// --- Middleware ---
app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+
+// --- Request Logger ---
-app.get("/", (req, res) => {
+
+// --- Auth Logger ---
+app.use("/tasks", authMiddleware, (req, res, next) => {
+ console.log("[Auth] req.user set:", req.user);
+ next();
+});
+
+// --- Routes ---
+app.use("/auth", authRoutes);
+app.use("/tasks", taskRoutes);
+app.use("/groups", groupRoutes);
+
+// --- Root Route ---
+app.get("/", (_, res) => {
+ console.log("[Root] Accessed /");
res.send("Hello Technigo!");
});
-// Start the server
+// --- Start Server ---
app.listen(port, () => {
- console.log(`Server running on http://localhost:${port}`);
+ console.log(`Server running at http://localhost:${port}`);
+ console.log("NODE_ENV:", process.env.NODE_ENV);
+ console.log("MONGO_URI:", process.env.MONGO_URI);
+});
+
+// --- Multer Setup Example for Logging ---
+const upload = multer({ storage: multer.memoryStorage() });
+
+app.post("/debug-upload", upload.single("file"), (req, res) => {
+ console.log("[Upload] req.file:", req.file);
+ console.log("[Upload] req.body:", req.body);
+ res.json({ message: "Upload debug complete" });
});
diff --git a/backend/wake.js b/backend/wake.js
new file mode 100644
index 0000000000..847caa83ea
--- /dev/null
+++ b/backend/wake.js
@@ -0,0 +1,8 @@
+import mongoose from "mongoose";
+import dotenv from "dotenv";
+
+dotenv.config();
+
+mongoose.connect(process.env.MONGO_URI)
+ .then(() => console.log("✅ Cluster awake!"))
+ .catch(err => console.error(err));
diff --git a/frontend/index.html b/frontend/index.html
index 664410b5b9..705d22b70d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,12 +2,19 @@
-
+
+
- Technigo React Vite Boiler Plate
+ Todo
-
+
diff --git a/frontend/package.json b/frontend/package.json
index 7b2747e949..8f53fc5f7b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,8 +10,17 @@
"preview": "vite preview"
},
"dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/icons-material": "^7.3.1",
+ "@mui/material": "^7.3.1",
+ "@mui/x-date-pickers": "^8.10.2",
+ "axios": "^1.11.0",
+ "date-fns": "^4.1.0",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^7.8.2",
+ "zustand": "^5.0.8"
},
"devDependencies": {
"@types/react": "^18.2.15",
diff --git a/frontend/public/_redirects b/frontend/public/_redirects
new file mode 100644
index 0000000000..ad37e2c2c9
--- /dev/null
+++ b/frontend/public/_redirects
@@ -0,0 +1 @@
+/* /index.html 200
diff --git a/frontend/public/photos/7247856.webp b/frontend/public/photos/7247856.webp
new file mode 100644
index 0000000000..0f41ea17b7
Binary files /dev/null and b/frontend/public/photos/7247856.webp differ
diff --git a/frontend/public/photos/stars-night-galaxy-4k-3840x2160.webp b/frontend/public/photos/stars-night-galaxy-4k-3840x2160.webp
new file mode 100644
index 0000000000..6c1b3f8adb
Binary files /dev/null and b/frontend/public/photos/stars-night-galaxy-4k-3840x2160.webp differ
diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt
new file mode 100644
index 0000000000..542c6194a7
--- /dev/null
+++ b/frontend/public/robots.txt
@@ -0,0 +1,3 @@
+User-agent: *
+Allow: /
+Sitemap: https://project-final-darius.netlify.app/sitemap.xml
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0a24275e6e..685d21a1a6 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,8 +1,275 @@
+import React, { useState, useMemo, useCallback } from 'react';
+import {
+ BrowserRouter,
+ Routes,
+ Route,
+ Navigate
+} from "react-router-dom";
+import { useTaskStore } from './store/useTaskStore';
+import { Header } from './Header';
+import { SubmitTask } from './SubmitTask';
+import { DisplayTasks } from './DisplayTasks';
+import { Footer } from './Footer';
+import RegisterForm from './registration.jsx';
+import LoginForm from './login.jsx';
+import GroupsManagement from './groups-mngnt.jsx';
+import {
+ Box,
+ Button,
+ Typography,
+ Paper,
+ Container,
+ CssBaseline,
+ createTheme,
+ ThemeProvider,
+} from '@mui/material';
+
+/* ------------------ LOGIN PAGE (externalized later) ------------------ */
+const LoginPage = ({ theme, onLogin }) => {
+ const [showRegisterModal, setShowRegisterModal] = useState(false);
+
+ return (
+
+
+
+
+ Welcome to To-Do App
+
+
+
+
+
+
+ {showRegisterModal && (
+ setShowRegisterModal(false)}
+ >
+ e.stopPropagation()}
+ >
+
+
+
+
+ )}
+
+
+ );
+};
+
+/* ------------------ APP (with inline MainApp) ------------------ */
export const App = () => {
+ const [darkMode, setDarkMode] = useState(false);
+ const tasks = useTaskStore((s) => s.tasks);
+
+ const [user, setUser] = useState(() => {
+ const token = localStorage.getItem('token');
+ return token ? { token } : null;
+ });
+
+ const [showGroups, setShowGroups] = useState(false);
+
+ const handleLogin = (token) => {
+ localStorage.setItem('token', token);
+ setUser({ ...user, token });
+ };
+
+ const handleLogout = () => {
+ localStorage.removeItem('token');
+ setUser(null);
+ };
+
+ const toggleDarkMode = useCallback(() => setDarkMode((prev) => !prev), []);
+
+ const theme = useMemo(
+ () =>
+ createTheme({
+ palette: {
+ mode: darkMode ? 'dark' : 'light',
+ primary: {
+ main: darkMode ? '#9c27b0' : '#1976d2',
+ contrastText: '#fff',
+ },
+ background: {
+ default: darkMode ? '#000' : '#fff',
+ paper: darkMode ? '#1a1a1a' : '#f5f5f5',
+ },
+ },
+ }),
+ [darkMode]
+ );
+
+ /* ------------------ MAIN APP ------------------ */
+ const MainApp = () => (
+
+
+
+ setShowGroups(true)}
+ />
+
+
+
+
+
+
+
+
+
+ To Do List
+
+ Total Tasks: {tasks.length}
+
+ Uncompleted Tasks: {tasks.filter((t) => !t.completed).length}
+
+
+
+
+
+
+
+
+
+
+
+ {showGroups && (
+ setShowGroups(false)} />
+ )}
+
+
+
+ );
+ /* ------------------ ROUTING ------------------ */
return (
- <>
- Welcome to Final Project!
- >
+
+
+
+ ) : (
+
+ )}
+ />
+
+ ) : (
+
+ )}
+ />
+ }
+ />
+
+
);
};
diff --git a/frontend/src/DisplayTasks.jsx b/frontend/src/DisplayTasks.jsx
new file mode 100644
index 0000000000..5ea5db3dc4
--- /dev/null
+++ b/frontend/src/DisplayTasks.jsx
@@ -0,0 +1,411 @@
+import React, { useState, useMemo } from 'react';
+import { useTaskStore } from './store/useTaskStore';
+import {
+ Box,
+ Typography,
+ Divider,
+ Chip,
+ Paper,
+ Button,
+ Checkbox,
+ Select,
+ MenuItem,
+ useTheme,
+ FormControl,
+ InputLabel,
+} from '@mui/material';
+
+export const DisplayTasks = () => {
+ const [show, setShow] = useState('all');
+ const [sortBy, setSortBy] = useState('created-desc');
+ const {
+ tasks: rawTasks,
+ projects,
+ toggleTaskCompletion,
+ deleteTask,
+ completeAllTasks,
+ deleteProject,
+ } = useTaskStore();
+
+ const theme = useTheme();
+ const isDarkMode = theme.palette.mode === 'dark';
+
+ /* ---------- Filtering ---------- */
+ const filteredTasks = useMemo(() => {
+ switch (show) {
+ case 'completed':
+ return rawTasks.filter((t) => t.completed);
+ case 'uncompleted':
+ return rawTasks.filter((t) => !t.completed);
+ default:
+ return rawTasks;
+ }
+ }, [rawTasks, show]);
+
+ /* ---------- Sorting ---------- */
+ const tasks = useMemo(() => {
+ return [...filteredTasks].sort((a, b) => {
+ const compareDates = (d1, d2) => (d1 || '').localeCompare(d2 || '');
+ switch (sortBy) {
+ case 'due-asc':
+ return compareDates(a.dueDate, b.dueDate);
+ case 'due-desc':
+ return compareDates(b.dueDate, a.dueDate);
+ case 'created-asc':
+ return a.createdAt.localeCompare(b.createdAt);
+ case 'created-desc':
+ return b.createdAt.localeCompare(a.createdAt);
+ case 'category':
+ return (a.category || '').localeCompare(b.category || '');
+ default:
+ return 0;
+ }
+ });
+ }, [filteredTasks, sortBy]);
+
+ const uncompletedCount = tasks.filter((t) => !t.completed).length;
+
+ /* ---------- Render: Empty State ---------- */
+ if (!tasks.length) {
+ return (
+
+ No tasks to display.
+
+ );
+ }
+
+ /* ---------- Helpers ---------- */
+ const renderDueDate = (task) => {
+ if (!task.dueDate) return null;
+ const due = new Date(task.dueDate);
+ const hoursLeft = (due.getTime() - Date.now()) / 36e5;
+ const dueSoon = hoursLeft > 0 && hoursLeft <= 24 && !task.completed;
+
+ return (
+
+ Due: {due.toLocaleDateString()} {due.toLocaleTimeString()}
+
+ );
+ };
+
+ /* ---------- Render ---------- */
+ return (
+
+ {/* Header */}
+
+
+ Tasks ({tasks.length}) • Uncompleted ({uncompletedCount})
+
+
+ {/* Filters */}
+
+
+ Filter
+
+
+
+
+ Sort by
+
+
+
+
+
+
+
+
+
+ {/* Task List */}
+
+ {tasks.map((task) => {
+ const project = projects.find((p) => p.id === task.projectId);
+ const overdue = task.dueDate && new Date(task.dueDate) < new Date() && !task.completed;
+ const statusLabel = overdue ? 'Overdue' : task.completed ? 'Completed' : 'Incomplete';
+ const statusKind = overdue ? 'error' : task.completed ? 'success' : 'warning';
+
+ return (
+ -
+
+ {/* Task Row */}
+
+
+ toggleTaskCompletion(task.id)}
+ inputProps={{ 'aria-labelledby': `task-label-${task.id}` }}
+ />
+
+ {task.title || task.task || 'Untitled Task'}
+
+
+
+
+
+
+ {/* Status */}
+
+ Status:
+ {statusKind === 'warning' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Category */}
+
+ Category: {task.category || 'None'}
+
+
+ {/* Project */}
+
+ Project:
+ {project ? `${project.name} (${project.completed ? 'Done' : 'Ongoing'})` : 'None'}
+ {project && (
+
+ )}
+
+
+ {/* Dates */}
+
+ Created: {new Date(task.createdAt).toLocaleString()}
+
+
+ {renderDueDate(task)}
+{/* Files */}
+{/* Files */}
+{task.files?.length > 0 && (
+
+
+ Files:
+
+
+ {task.files.map((file, idx) => {
+ const fileName = file.name || 'Unknown';
+ const fileSizeKB = file.size ? Math.round(file.size / 1024) : null;
+ const handleViewFile = async (file) => {
+ console.log("[File] File object:", file);
+ const fileUrl = file.url || file.path;
+ if (!fileUrl) return alert("File URL not available");
+
+ try {
+ const token = localStorage.getItem("token");
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
+
+ const fetchUrl = fileUrl.startsWith("/")
+ ? `https://project-final-darius-1.onrender.com${fileUrl}`
+ : file.url;
+
+ console.log("[File] Fetching:", fetchUrl, "with headers:", headers);
+
+ const res = await fetch(fetchUrl, { headers });
+
+ console.log("[File] Response status:", res.status, res.statusText);
+ console.log("[File] Response headers:", Object.fromEntries(res.headers.entries()));
+ if (!res.ok) throw new Error(`Failed to fetch file: ${res.status} ${res.statusText}`);
+
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const newWin = window.open(url, "_blank");
+ if (!newWin) alert("Popup blocked! Please allow popups to view the file.");
+ } catch (err) {
+ console.error("[File] Error fetching file:", err);
+ alert(err.message);
+ }
+ };
+
+ return (
+ -
+
+
+ );
+ })}
+
+
+)}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+//Fixed warning Chip contrast for dark mode by using a darker orange background and white text to meet WCAG standards.
+
+// Added responsive text wrapping and ellipsis for Filter and Sort dropdowns to prevent overflow.
+
+// Updated header typography to wrap on small screens and prevent overflow (minWidth: 0, overflowWrap: 'break-word').
+
+// Highlighted due-soon tasks using theme’s error.main color instead of inline red.
+
+// Added accessibility improvements: aria-labelledby on checkboxes, role="region" and aria-label for task list.
+
+// Ensured layout is responsive and compatible with both dark and light modes.
\ No newline at end of file
diff --git a/frontend/src/Footer.jsx b/frontend/src/Footer.jsx
new file mode 100644
index 0000000000..dc7adcd77b
--- /dev/null
+++ b/frontend/src/Footer.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { AppBar, Toolbar, Typography, useTheme } from '@mui/material';
+
+export const Footer = () => {
+ const theme = useTheme();
+ const isDark = theme.palette.mode === 'dark';
+ const currentYear = new Date().getFullYear();
+
+ return (
+
+
+
+ © {currentYear} Task Manager by Darius Olsson Carter — All rights reserved.
+
+
+
+ );
+};
diff --git a/frontend/src/Header.jsx b/frontend/src/Header.jsx
new file mode 100644
index 0000000000..efdb048978
--- /dev/null
+++ b/frontend/src/Header.jsx
@@ -0,0 +1,40 @@
+import React from "react";
+import { AppBar, Toolbar, Typography, Button, Box, useTheme } from "@mui/material";
+
+export const Header = ({ onLogout, onOpenGroups }) => {
+ const theme = useTheme();
+
+ const buttonStyle = {
+ bgcolor: theme.palette.background.paper,
+ color: theme.palette.text.primary,
+ "&:hover": {
+ bgcolor: theme.palette.mode === "dark"
+ ? theme.palette.background.default
+ : "#e0e0e0",
+ },
+ ml: 1,
+ };
+
+ return (
+
+
+
+ Task Manager
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/LoginPage.jsx b/frontend/src/LoginPage.jsx
new file mode 100644
index 0000000000..9de47d7b51
--- /dev/null
+++ b/frontend/src/LoginPage.jsx
@@ -0,0 +1,103 @@
+import React, { useState } from "react";
+import {
+ Box,
+ Typography,
+ Button,
+ CssBaseline,
+ ThemeProvider,
+} from "@mui/material";
+import LoginForm from "./login.jsx";
+import RegisterForm from "./registration.jsx";
+
+export default function LoginPage({ theme, onLogin }) {
+ const [showRegisterModal, setShowRegisterModal] = useState(false);
+
+ return (
+
+
+
+
+ Welcome to To-Do App
+
+
+ {/* Login form */}
+
+
+ {/* Register button */}
+
+
+ {/* Register modal */}
+ {showRegisterModal && (
+ setShowRegisterModal(false)}
+ >
+ e.stopPropagation()}
+ >
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/SubmitTask.jsx b/frontend/src/SubmitTask.jsx
new file mode 100644
index 0000000000..b2240a6228
--- /dev/null
+++ b/frontend/src/SubmitTask.jsx
@@ -0,0 +1,261 @@
+import React, { useState, useCallback } from 'react';
+import { useTaskStore } from './store/useTaskStore';
+import { useUserStore } from './store/useUserStore';
+import {
+ Box,
+ Button,
+ TextField,
+ Select,
+ MenuItem,
+ FormControl,
+ InputLabel,
+ useTheme,
+ Stack,
+ Typography
+} from '@mui/material';
+import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
+import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
+import { DatePicker } from '@mui/x-date-pickers/DatePicker';
+
+export const SubmitTask = () => {
+ const theme = useTheme();
+ const { addTask, projects, addProject } = useTaskStore();
+
+ // ---------------- Form State ----------------
+ const [input, setInput] = useState('');
+ const [category, setCategory] = useState('');
+ const [projectId, setProjectId] = useState('');
+ const [dueDate, setDueDate] = useState(null);
+ const [newProject, setNewProject] = useState('');
+ const [fileObjects, setFileObjects] = useState([]);
+ const [folders, setFolders] = useState([]);
+
+ // ---------------- Handlers ----------------
+ const handleAddProject = useCallback(() => {
+ const name = newProject.trim();
+ if (!name) return;
+ const id = Date.now();
+ addProject({ id, name, completed: false });
+ setProjectId(id);
+ setNewProject('');
+ }, [newProject, addProject]);
+
+ // File input handler - only updates fileObjects and folders
+ const handleFileInput = useCallback((e) => {
+ const files = Array.from(e.target.files);
+ if (!files.length) return;
+
+ setFileObjects(prev => [...prev, ...files]);
+ const uniqueFolders = files.map(f => f.webkitRelativePath ? f.webkitRelativePath.split('/')[0] : 'root');
+ setFolders(prev => [...new Set([...prev, ...uniqueFolders])]);
+
+ // Reset input value so same file can be picked again
+ e.target.value = null;
+ }, []);
+
+const handleSubmit = useCallback(async (e) => {
+ e.preventDefault();
+ if (!input.trim()) return alert('Please enter a task');
+
+ try {
+ const token = useUserStore.getState().token || localStorage.getItem("token");
+ const formData = new FormData();
+
+ formData.append('task', input.trim());
+ formData.append('category', category);
+ // Don't send group — backend will set it from req.user.groupId
+ if (dueDate) formData.append('dueDate', dueDate.toISOString());
+
+ fileObjects.forEach(file => {
+ console.log("Appending file to FormData:", file.name, file.size, file.type);
+ formData.append('files', file, file.name);
+ });
+
+ // Debug FormData
+ console.log("FormData contents:");
+ for (const [key, value] of formData.entries()) {
+ console.log(key, value instanceof File ? `${value.name} (${value.size} bytes)` : value);
+ }
+
+ const res = await fetch('https://project-final-darius-1.onrender.com/tasks', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ body: formData,
+ });
+
+ const backendTask = await res.json();
+ console.log("Backend response:", backendTask);
+
+ if (backendTask.error) return alert(`Error: ${backendTask.error}`);
+
+ addTask({
+ id: backendTask._id || Date.now(),
+ title: backendTask.title || input.trim(),
+ category: backendTask.category || category,
+ projectId: projectId || null, // UI only
+ files: backendTask.files?.map(f => ({
+ name: f.name,
+ path: f.url,
+ type: f.contentType,
+ size: f.size
+ })) || [],
+ folders: backendTask.folders || folders,
+ completed: backendTask.completed ?? false,
+ createdAt: backendTask.createdAt || new Date().toISOString(),
+ dueDate: backendTask.dueDate ? new Date(backendTask.dueDate) : dueDate,
+ });
+
+ // Reset form
+ setInput('');
+ setCategory('');
+ setProjectId('');
+ setDueDate(null);
+ setFileObjects([]);
+ setFolders([]);
+ setNewProject('');
+
+ } catch (err) {
+ console.error("Error submitting task:", err);
+ alert(err.message);
+ }
+}, [input, category, projectId, dueDate, fileObjects, folders, addTask]);
+
+
+ // ---------------- UI ----------------
+ return (
+
+
+ To Do List
+
+
+
+
+ );
+};
diff --git a/frontend/src/Tasks.jsx b/frontend/src/Tasks.jsx
new file mode 100644
index 0000000000..ff759d471b
--- /dev/null
+++ b/frontend/src/Tasks.jsx
@@ -0,0 +1,21 @@
+export const Tasks = () => {
+
+
+return (
+ <>
+ To Do List
+
+
+ - Build a To-Do List App
+ - Deploy the App
+
+ Total tasks: 3
+ >
+);
+
+
+
+
+
+
+}
\ No newline at end of file
diff --git a/frontend/src/groups-mngnt.jsx b/frontend/src/groups-mngnt.jsx
new file mode 100644
index 0000000000..e46615136c
--- /dev/null
+++ b/frontend/src/groups-mngnt.jsx
@@ -0,0 +1,342 @@
+ import React, { useEffect, useState, useMemo } from "react";
+import {
+ AppBar,
+ Toolbar,
+ Typography,
+ Box,
+ Button,
+ TextField,
+ Paper,
+ IconButton,
+ Snackbar,
+ Alert,
+ Pagination,
+ List,
+ ListItem,
+ ListItemText,
+ Divider,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ ListItemIcon,
+ MenuItem,
+} from "@mui/material";
+import DeleteIcon from "@mui/icons-material/Delete";
+import AddIcon from "@mui/icons-material/Add";
+import GroupIcon from "@mui/icons-material/Group";
+import PersonIcon from "@mui/icons-material/Person";
+import { useGroupStore } from "./store/useGroupStore";
+import { useUserStore } from "./store/useUserStore";
+import { useTaskStore } from "./store/useTaskStore";
+
+const GroupsManagement = () => {
+ const { user, loadUserFromStorage } = useUserStore();
+ const userId = user?.id;
+
+ const { groups, fetchGroups, createGroup, deleteGroup, joinGroup, leaveGroup, setProject, removeProject } =
+ useGroupStore();
+ const { projects } = useTaskStore();
+
+ const [newGroup, setNewGroup] = useState("");
+ const [search, setSearch] = useState("");
+ const [projectInputs, setProjectInputs] = useState({});
+ const [page, setPage] = useState(1);
+ const [snackbar, setSnackbar] = useState({ open: false, message: "", severity: "success" });
+ const [membersDialog, setMembersDialog] = useState({ open: false, members: [] });
+
+ const itemsPerPage = 5;
+
+ useEffect(() => {
+ loadUserFromStorage()
+ .then(() => fetchGroups())
+ .catch(() => openSnackbar("Failed to load user data or groups", "error"));
+ }, [loadUserFromStorage, fetchGroups]);
+
+ const filteredGroups = useMemo(
+ () => groups.filter((g) => g.name.toLowerCase().includes(search.toLowerCase())),
+ [groups, search]
+ );
+
+ const openSnackbar = (message, severity = "success") =>
+ setSnackbar({ open: true, message, severity });
+
+ const handleCreate = async () => {
+ if (!newGroup.trim()) return;
+ try {
+ await createGroup(newGroup.trim());
+ openSnackbar("Group created successfully");
+ setNewGroup("");
+ } catch {
+ openSnackbar("Error creating group", "error");
+ }
+ };
+
+ const handleDelete = async (id) => {
+ try {
+ await deleteGroup(id);
+ openSnackbar("Group deleted", "info");
+ } catch {
+ openSnackbar("Error deleting group", "error");
+ }
+ };
+
+ const handleJoinLeave = async (group, isMember) => {
+ try {
+ isMember ? await leaveGroup(group._id) : await joinGroup(group._id);
+ openSnackbar(isMember ? "Left group" : "Joined group");
+ } catch {
+ openSnackbar("Action failed", "error");
+ }
+ };
+
+ const handleSetProject = async (groupId) => {
+ const projectId = projectInputs[groupId];
+ if (!projectId) return;
+
+ const project = projects.find((p) => p.id === projectId);
+ if (!project) return;
+
+ try {
+ await setProject(groupId, project.name);
+ openSnackbar("Project assigned");
+ setProjectInputs((prev) => ({ ...prev, [groupId]: "" }));
+ } catch {
+ openSnackbar("Failed to assign project", "error");
+ }
+ };
+
+ const handleRemoveProject = async (groupId) => {
+ try {
+ await removeProject(groupId);
+ openSnackbar("Project removed");
+ } catch {
+ openSnackbar("Failed to remove project", "error");
+ }
+ };
+
+ const openMembersDialog = (members) => setMembersDialog({ open: true, members });
+ const closeMembersDialog = () => setMembersDialog({ open: false, members: [] });
+
+ return (
+
+ {/* Top Bar */}
+
+
+
+
+ Groups Management
+
+
+ setSearch(e.target.value)}
+ sx={{
+ bgcolor: "white",
+ borderRadius: 1,
+ flexShrink: 1,
+ width: { xs: "100%", sm: "auto" },
+ "& .MuiInputBase-input::placeholder": { color: "black", opacity: 1 },
+ }}
+ />
+
+
+
+
+ {/* Main Content */}
+
+ {/* Create Group */}
+
+ setNewGroup(e.target.value)}
+ fullWidth
+ sx={{
+ flex: "1 1 240px",
+ minWidth: { xs: "100%", sm: 240 },
+ }}
+ />
+ }
+ onClick={handleCreate}
+ >
+ Create
+
+
+
+ {/* Group List */}
+
+ {filteredGroups
+ .slice((page - 1) * itemsPerPage, page * itemsPerPage)
+ .map((group) => {
+ const isMember = group.members?.some((m) => m._id === userId);
+ const currentProjectName = group.currentProject || "None";
+
+ return (
+
+
+
+
+ Members: {group.members?.length || 0}
+
+
+ {/* Project Controls */}
+
+
+ Project: {currentProjectName}
+
+
+
+ setProjectInputs((prev) => ({
+ ...prev,
+ [group._id]: e.target.value,
+ }))
+ }
+ sx={{
+ flex: { xs: "1 1 100%", sm: "1 1 140px" },
+ minWidth: { xs: "100%", sm: 140 },
+ }}
+ >
+
+ {projects.map((p) => (
+
+ ))}
+
+
+
+
+
+
+
+ }
+ />
+
+ {/* Action Buttons */}
+
+
+
+
+
+ handleDelete(group._id)}
+ sx={{ width: { xs: "100%", sm: "auto" } }}
+ >
+
+
+
+
+
+
+ );
+ })}
+
+
+ {/* Pagination */}
+ setPage(val)}
+ sx={{ mt: 2 }}
+ />
+
+
+ {/* Snackbar */}
+ setSnackbar((prev) => ({ ...prev, open: false }))}
+ >
+ {snackbar.message}
+
+
+ {/* Members Dialog */}
+
+
+ );
+};
+
+export default GroupsManagement;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e69de29bb2..8ff52fc378 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -0,0 +1,41 @@
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+}
+.dark-background,
+.light-background {
+ background-size: 75%; /* Adjusts visual size */
+ background-position: center;
+ background-repeat: no-repeat;
+ background-attachment: fixed;
+ height: 100vh;
+}
+
+
+/* -------------- MOBILE 0-600px -------------- */
+@media (max-width: 600px) {
+ .display-tasks { padding: 16px; }
+ .filter-actions { flex-direction: column; align-items: stretch; }
+ .filter-actions > * { width: 100%; }
+ .task-item { margin-bottom: 12px; }
+ .task-card { padding: 12px; }
+ .task-top { flex-direction: column; align-items: flex-start; }
+ .task-delete { width: 100%; margin-top: 6px; }
+}
+
+/* -------------- SMALL TABLET 601-900px -------------- */
+@media (min-width: 601px) and (max-width: 900px) {
+ .display-tasks { max-width: 90%; }
+ .filter-actions { flex-wrap: nowrap; }
+ .filter-actions > * { flex: 0 0 auto; }
+ .task-delete { width: auto; }
+}
+
+/* -------------- MEDIUM 901-1200px -------------- */
+@media (min-width: 901px) and (max-width: 1200px) {
+ .display-tasks { max-width: 80%; }
+}
+
+/* -------------- DESKTOP ≥1201px -------------- */
+@media (min-width: 1201px) {
+ .display-tasks { max-width: 600px; }
+}
\ No newline at end of file
diff --git a/frontend/src/login.jsx b/frontend/src/login.jsx
new file mode 100644
index 0000000000..2e4da688e7
--- /dev/null
+++ b/frontend/src/login.jsx
@@ -0,0 +1,107 @@
+import React, { useState } from 'react';
+import {
+ TextField,
+ Button,
+ Box,
+ Typography,
+ Paper,
+} from '@mui/material';
+
+const LoginForm = ({ onLogin }) => {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+
+ const handleLogin = async (e) => {
+ e.preventDefault();
+ setError('');
+
+ try {
+ const res = await fetch('https://project-final-darius-1.onrender.com/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.message || 'Login failed');
+ localStorage.setItem('token', data.token);
+ onLogin(data.token);
+ } catch (err) {
+ setError(err.message);
+ }
+ };
+
+ return (
+
+
+ Login
+
+
+ setUsername(e.target.value)}
+ required
+ autoComplete="username"
+ fullWidth
+ />
+
+ setPassword(e.target.value)}
+ required
+ autoComplete="current-password"
+ fullWidth
+ />
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+};
+
+export default LoginForm;
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 51294f3998..1b8ffe9baa 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,10 +1,12 @@
-import React from "react";
-import ReactDOM from "react-dom/client";
-import { App } from "./App.jsx";
-import "./index.css";
+import React from 'react'
+import ReactDOM from 'react-dom/client'
-ReactDOM.createRoot(document.getElementById("root")).render(
+import { App } from './App.jsx'
+
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
-);
+)
diff --git a/frontend/src/registration.jsx b/frontend/src/registration.jsx
new file mode 100644
index 0000000000..eef705d8cc
--- /dev/null
+++ b/frontend/src/registration.jsx
@@ -0,0 +1,103 @@
+import { useState } from 'react';
+
+const RegisterForm = () => {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [message, setMessage] = useState('');
+
+ const handleRegister = async (e) => {
+ e.preventDefault();
+ console.log("Submitting:", username, password);
+
+ setMessage('');
+
+ try {
+ const res = await fetch('https://project-final-darius-1.onrender.com/auth/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await res.json();
+ console.log("Response:", data);
+ if (!res.ok) throw new Error(data.message || 'Registration failed');
+ setMessage('🎉 Registered successfully!');
+ } catch (err) {
+ setMessage(`❌ ${err.message}`);
+ }
+ };
+
+ return (
+
+ );
+};
+
+
+export default RegisterForm;
diff --git a/frontend/src/store/useGroupStore.jsx b/frontend/src/store/useGroupStore.jsx
new file mode 100644
index 0000000000..b065093626
--- /dev/null
+++ b/frontend/src/store/useGroupStore.jsx
@@ -0,0 +1,121 @@
+import { create } from "zustand";
+import axios from "axios";
+
+const API_BASE = "https://project-final-darius-1.onrender.com";
+
+// Helper to get auth headers
+const getAuthHeaders = () => {
+ const token = localStorage.getItem("token"); // JWT stored in localStorage
+ return token ? { Authorization: `Bearer ${token}` } : {};
+};
+
+export const useGroupStore = create((set, get) => ({
+ groups: [],
+
+ fetchGroups: async () => {
+ try {
+ const { data } = await axios.get(`${API_BASE}/groups`, {
+ headers: getAuthHeaders(),
+ withCredentials: true, // keep this if your backend also sets cookies
+ });
+
+ set({ groups: Array.isArray(data) ? data : [] });
+ } catch (err) {
+ console.error("Failed to fetch groups:", err.response?.data || err);
+ set({ groups: [] });
+ throw err;
+ }
+ },
+
+ createGroup: async (name) => {
+ if (!name?.trim()) return;
+ try {
+ const { data } = await axios.post(
+ `${API_BASE}/groups`,
+ { name: name.trim() },
+ { headers: getAuthHeaders(), withCredentials: true }
+ );
+ set((state) => ({ groups: [...state.groups, data || { name, members: [] }] }));
+ } catch (err) {
+
+ throw err;
+ }
+ },
+
+ joinGroup: async (id) => {
+ try {
+ await axios.put(`${API_BASE}/groups/${id}/join`, {}, {
+ headers: getAuthHeaders(),
+ withCredentials: true
+ });
+ await get().fetchGroups();
+ } catch (err) {
+
+ throw err;
+ }
+ },
+
+ leaveGroup: async (id) => {
+ try {
+ await axios.put(`${API_BASE}/groups/${id}/leave`, {}, {
+ headers: getAuthHeaders(),
+ withCredentials: true
+ });
+ await get().fetchGroups();
+ } catch (err) {
+
+ throw err;
+ }
+ },
+
+ deleteGroup: async (id) => {
+ try {
+ await axios.delete(`${API_BASE}/groups/${id}`, {
+ headers: getAuthHeaders(),
+ withCredentials: true
+ });
+ set((state) => ({ groups: state.groups.filter((g) => g._id !== id) }));
+ } catch (err) {
+ console.error("Failed to delete group:", err.response?.data || err);
+ throw err;
+ }
+ },
+
+ setProject: async (id, projectName) => {
+ try {
+ await axios.put(`${API_BASE}/groups/${id}/project`, { projectName }, {
+ headers: getAuthHeaders(),
+ withCredentials: true
+ });
+ await get().fetchGroups();
+ } catch (err) {
+
+ throw err;
+ }
+ },setGroupProject: async (groupId, projectName) => {
+ try {
+ await axios.put(`${API_BASE}/groups/${groupId}/project`, { projectName }, { withCredentials: true });
+ set((state) => ({
+ groups: state.groups.map((g) =>
+ g._id === groupId ? { ...g, currentProject: projectName } : g
+ ),
+ }));
+ } catch (err) {
+
+ throw err;
+ }
+},
+
+removeProject: async (id) => {
+ try {
+ await axios.delete(`${API_BASE}/groups/${id}/project`, {
+ headers: getAuthHeaders(),
+ withCredentials: true
+ });
+ await get().fetchGroups();
+ } catch (err) {
+
+ throw err;
+ }
+},
+}));
diff --git a/frontend/src/store/useTaskStore.jsx b/frontend/src/store/useTaskStore.jsx
new file mode 100644
index 0000000000..964d3b2247
--- /dev/null
+++ b/frontend/src/store/useTaskStore.jsx
@@ -0,0 +1,121 @@
+import { create } from 'zustand';
+
+const load = (key) => {
+ try {
+ return JSON.parse(localStorage.getItem(key)) || [];
+ } catch {
+ return [];
+ }
+};
+
+const save = (key, data) => localStorage.setItem(key, JSON.stringify(data));
+
+export const useTaskStore = create((set, get) => ({
+
+ /* ---------- STATE ---------- */
+ tasks: load('tasks'),
+ projects: load('projects'),
+
+ /* ---------- TASK ACTIONS ---------- */
+ addTask: (task) => {
+ const newTask = {
+ id: Date.now(),
+ title: task.title || task.task || 'Untitled Task',
+ category: task.category || '',
+ projectId: task.projectId || null,
+ files: task.files || [],
+ folders: task.folders || [],
+ completed: false,
+ createdAt: new Date().toISOString(),
+ dueDate: task.dueDate || null,
+ };
+
+ const tasks = [...get().tasks, newTask];
+ save('tasks', tasks);
+ set({ tasks });
+
+ if (newTask.projectId) get().updateProjectCompletion(newTask.projectId);
+ },
+
+ deleteTask: (id) => {
+ const tasks = get().tasks.filter(t => t.id !== id);
+ save('tasks', tasks);
+ set({ tasks });
+ },
+
+ toggleTaskCompletion: (id) => {
+ const tasks = get().tasks.map(t =>
+ t.id === id ? { ...t, completed: !t.completed } : t
+ );
+ save('tasks', tasks);
+ set({ tasks });
+
+ const task = tasks.find(t => t.id === id);
+ if (task?.projectId) get().updateProjectCompletion(task.projectId);
+ },
+
+ completeAllTasks: () => {
+ const tasks = get().tasks.map(t => ({ ...t, completed: true }));
+ save('tasks', tasks);
+ set({ tasks });
+
+ [...new Set(tasks.map(t => t.projectId).filter(Boolean))]
+ .forEach(pid => get().updateProjectCompletion(pid));
+ },
+
+ /* ---------- TASK QUERIES ---------- */
+ filterTasks: (status = 'all', afterDate = null) => {
+ let filtered = get().tasks;
+
+ if (status === 'completed') filtered = filtered.filter(t => t.completed);
+ if (status === 'uncompleted') filtered = filtered.filter(t => !t.completed);
+
+ if (afterDate) {
+ const after = new Date(afterDate);
+ filtered = filtered.filter(t => new Date(t.createdAt) > after);
+ }
+
+ return filtered;
+ },
+
+ /* ---------- PROJECT ACTIONS ---------- */
+ addProject: (project) => {
+ const newProject = {
+ id: project.id || Date.now(),
+ name: project.name || 'Untitled Project',
+ completed: project.completed || false,
+ };
+ const projects = [...get().projects, newProject];
+ save('projects', projects);
+ set({ projects });
+ },
+
+ deleteProject: (projectId) => {
+ const projects = get().projects.filter(p => p.id !== projectId);
+ save('projects', projects);
+ set({ projects });
+
+ const tasks = get().tasks.map(t =>
+ t.projectId === projectId ? { ...t, projectId: null } : t
+ );
+ save('tasks', tasks);
+ set({ tasks });
+ },
+
+ updateProjectCompletion: (projectId) => {
+ if (!projectId) return;
+ const tasks = get().tasks.filter(t => t.projectId === projectId);
+ const allComplete = tasks.length > 0 && tasks.every(t => t.completed);
+
+ const projects = get().projects.map(p =>
+ p.id === projectId ? { ...p, completed: allComplete } : p
+ );
+ save('projects', projects);
+ set({ projects });
+ },
+
+ /* ---------- HELPERS ---------- */
+ isOverdue: (task) =>
+ !!task.dueDate && !task.completed && new Date(task.dueDate) < new Date(),
+
+}));
diff --git a/frontend/src/store/useUserStore.jsx b/frontend/src/store/useUserStore.jsx
new file mode 100644
index 0000000000..216980a721
--- /dev/null
+++ b/frontend/src/store/useUserStore.jsx
@@ -0,0 +1,43 @@
+import { create } from "zustand";
+import axios from "axios";
+
+
+const API_BASE = "https://project-final-darius-1.onrender.com";
+
+export const useUserStore = create((set, get) => ({
+ user: null, // { id, username, group }
+ token: null,
+
+ // --- Set user and persist token ---
+ setUser: (user, token) => {
+ if (token) {
+ localStorage.setItem("token", token);
+ axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
+ }
+ set({ user, token });
+ },
+
+ // --- Clear user and token ---
+ clearUser: () => {
+ const { setUser } = get(); // get other actions
+ localStorage.removeItem("token");
+ axios.defaults.headers.common["Authorization"] = "";
+ setUser(null, null); // call the setUser action properly
+ },
+
+ // --- Load user from localStorage ---
+ loadUserFromStorage: async () => {
+ const token = localStorage.getItem("token");
+ if (!token) return;
+
+ try {
+ axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
+ const { data } = await axios.get(`${API_BASE}/auth/me`);
+ set({ user: data.user, token });
+ } catch (err) {
+ console.error("Failed to load user from storage:", err);
+ const { setUser } = get();
+ setUser(null, null); // call properly
+ }
+ },
+}));
diff --git a/package.json b/package.json
index 680d190772..90c83d8a0f 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,27 @@
{
- "name": "project-final-parent",
+ "name": "backend",
"version": "1.0.0",
+ "type": "module",
+ "main": "server.js",
"scripts": {
- "postinstall": "npm install --prefix backend"
+ "start": "node backend/server.js",
+ "dev": "nodemon backend/server.js --exec babel-node"
+ },
+ "dependencies": {
+ "bcrypt": "^6.0.0",
+ "cors": "^2.8.5",
+ "dotenv": "^16.3.1",
+ "express": "^4.18.2",
+ "gridfs-stream": "^1.1.1",
+ "jsonwebtoken": "^9.0.0",
+ "mongoose": "^8.18.0",
+ "multer-gridfs-storage": "^5.0.2"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.28.3",
+ "@babel/node": "^7.28.0",
+ "@babel/preset-env": "^7.28.3",
+ "@vitejs/plugin-react": "^5.0.1",
+ "nodemon": "^3.1.7"
}
-}
\ No newline at end of file
+}