diff --git a/README.md b/README.md index 9a56bfa0..53abbcec 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,69 @@ Assignment 3 - Persistence: Two-tier Web Application with Database, Express server, and CSS template -=== +--- -Due: September 19th, by 11:59 AM. +## Taskly -This assignnment continues where we left off, extending it to use the most popular Node.js server framework (express), -a database (mongodb), and a CSS application framework / template of your choice (Boostrap, Material Design, Semantic UI, Pure etc.) +https://a3-azzhang3.glitch.me -Baseline Requirements ---- +(Late Pass) -Your application is required to implement the following functionalities: -- a `Server`, created using Express (no alternatives will be accepted for this assignment) -- a `Results` functionality which shows all data associated with a logged in user (except passwords) -- a `Form/Entry` functionality which allows users to add, modify, and delete data items (must be all three!) associated with their user name / account. -- Persistent data storage in between server sessions using [mongodb](https://www.mongodb.com/cloud/atlas) (you *must* use mongodb for this assignment). You can use either the [official mongodb node.js library](https://www.npmjs.com/package/mongodb) or use the [Mongoose library](https://www.npmjs.com/package/mongoose), which enables you to define formal schemas for your database. Please be aware that the course staff cannot provide in-depth support for use of Mongoose. -- Use of a [CSS framework or template](https://github.com/troxler/awesome-css-frameworks). -This should do the bulk of your styling/CSS for you and be appropriate to your application. -For example, don't use [NES.css](https://nostalgic-css.github.io/NES.css/) (which is awesome!) unless you're creating a game or some type of retro 80s site. +### Goal +The goal of this application is to provide users with an efficient and intuitive platform for managing their tasks. Users can create, edit, prioritize, and track tasks based on their deadlines, allowing them to stay organized and focused. The application is designed to help users manage their workload by clearly showing upcoming deadlines, highlighting tasks that need attention, and providing a user-friendly interface for managing both ongoing and completed tasks. -Your application is required to demonstrate the use of the following concepts: +### Challenges +- Ensuring that tasks were correctly displayed +- Creating a layout that looks good and functions well, which required substantial work with both the CSS framework and custom styling adjustments. +- Implementing dynamic elements, such as the collapsible task for users to see descriptions. This also ensured that tasks would automatically resize and fit their content. +- Ensuring that tasks with urgent deadlines or those that were past due were visually emphasized without cluttering the interface. -HTML: -- HTML input tags and form fields of various flavors (` + + +
+ + +
+
+ +
+
+ +
+ + + +
+ +
+ +

In Progress

+ + + + + +

Completed

+ + + +
+ +
+ + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 00000000..2b430f13 --- /dev/null +++ b/public/index.html @@ -0,0 +1,102 @@ + + + + Taskly - Login + + + + + + + + + + + + + + + + + +
+ +
+ +
+

Taskly

+

Login

+
+
+ + +
+
+ + +
+ +
+ + +
+ + Sign in with Google + +
+ +
+

Don't have an account?

+ +
+ + +
+ + diff --git a/public/js/login.js b/public/js/login.js new file mode 100644 index 00000000..c7e85e1c --- /dev/null +++ b/public/js/login.js @@ -0,0 +1,123 @@ +document.addEventListener("DOMContentLoaded", () => { + const loginForm = document.getElementById("loginForm"); + const registerForm = document.getElementById("registerForm"); + const registerSection = document.getElementById("registerSection"); + const googleLoginBtn = document.getElementById("google-login-btn"); + const registerPrompt = document.getElementById("registerPrompt"); + const showRegisterBtn = document.getElementById("showRegister"); + + // Toggle password visibility + // document.querySelectorAll(".toggle-password").forEach((toggle) => { + // toggle.addEventListener("click", () => { + // const input = document.querySelector(toggle.getAttribute("toggle")); + // if (input.type === "password") { + // input.type = "text"; + // toggle.querySelector("i").classList.remove("fa-eye"); + // toggle.querySelector("i").classList.add("fa-eye-slash"); + // } else { + // input.type = "password"; + // toggle.querySelector("i").classList.remove("fa-eye-slash"); + // toggle.querySelector("i").classList.add("fa-eye"); + // } + // }); + // }); + + const darkModeToggle = document.getElementById("darkModeToggle"); + const body = document.body; + + // Check localStorage to set initial mode if previously set + if (localStorage.getItem("darkMode") === "enabled") { + body.classList.add("dark-mode"); + darkModeToggle.checked = true; + } + + // Toggle dark mode on switch click + darkModeToggle.addEventListener("change", function () { + if (darkModeToggle.checked) { + body.classList.add("dark-mode"); + localStorage.setItem("darkMode", "enabled"); // Save preference + } else { + body.classList.remove("dark-mode"); + localStorage.setItem("darkMode", "disabled"); // Save preference + } + }); + + // Handle registration form sliding in + showRegisterBtn.addEventListener("click", (e) => { + e.preventDefault(); + registerSection.style.display = "block"; // Show register form + registerSection.classList.add("slide-in"); // Trigger sliding animation + }); + + // Handle login + loginForm.addEventListener("submit", (e) => { + e.preventDefault(); + const username = document.getElementById("login-username").value; + const password = document.getElementById("login-password").value; + + fetch("/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }) + .then((response) => { + if (response.ok) { + window.location.href = "/home"; // Redirect to home after login + } else { + alert("Invalid username or password"); + } + }) + .catch((error) => console.error("Error:", error)); + }); + + // Handle Google login button + if (googleLoginBtn) { + googleLoginBtn.addEventListener("click", () => { + window.location.href = "/auth/google"; + }); + } else { + console.log("google-login-btn not found on this page."); + } + + // Handle Google login redirect + document.getElementById("google-login-btn").addEventListener("click", () => { + window.location.href = "/auth/google"; + }); + + // Handle registration form sliding in + showRegisterBtn.addEventListener("click", (e) => { + e.preventDefault(); + registerSection.style.display = "block"; // Show register form + + // Add sliding animation + registerSection.classList.add("slide-in"); + }); + + // Handle registration + registerForm.addEventListener("submit", (e) => { + e.preventDefault(); + const username = document.getElementById("reg-username").value; + const password = document.getElementById("reg-password").value; + + fetch("/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }) + .then((response) => + response + .json() + .then((data) => ({ status: response.status, body: data })) + ) + .then(({ status, body }) => { + if (status === 201) { + alert(body.message); + registerForm.reset(); + location.reload(); // Reload the page to go back to login after registration + } else { + alert(body.message); + } + }) + .catch((error) => console.error("Error:", error)); + }); +}); diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 00000000..43bce146 --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,297 @@ +document.addEventListener("DOMContentLoaded", () => { + const taskForm = document.querySelector("#taskForm"); + const taskSection = document.getElementById("taskSection"); + const inProgressTable = document.getElementById("inProgressTable"); + const completedTable = document.getElementById("completedTable"); + const logoutBtn = document.getElementById("logoutBtn"); + + const elemsDatepicker = document.querySelectorAll(".datepicker"); + M.Datepicker.init(elemsDatepicker, { format: "yyyy-mm-dd" }); + + const elemsSelect = document.querySelectorAll("select"); + M.FormSelect.init(elemsSelect); + + const elemsCollapsible = document.querySelectorAll(".collapsible"); + M.Collapsible.init(elemsCollapsible); + + const darkModeToggle = document.getElementById("darkModeToggle"); + const body = document.body; + + // Check localStorage to set previous mode if previously set + if (localStorage.getItem("darkMode") === "enabled") { + body.classList.add("dark-mode"); + darkModeToggle.checked = true; + } + + // Toggle dark mode + darkModeToggle.addEventListener("change", function () { + if (darkModeToggle.checked) { + body.classList.add("dark-mode"); + localStorage.setItem("darkMode", "enabled"); // Save preference + } else { + body.classList.remove("dark-mode"); + localStorage.setItem("darkMode", "disabled"); // Save preference + } + }); + + let tasks = []; + let editingTaskId = null; + + var elems = document.querySelectorAll(".datepicker"); + var instances = M.Datepicker.init(elems, {}); + + var selectElems = document.querySelectorAll("select"); + var selectInstances = M.FormSelect.init(selectElems, {}); + + var elems = document.querySelectorAll(".collapsible"); + var instances = M.Collapsible.init(elems, { + accordion: false, + }); + + // Ensure the task section is shown when the home page loads + taskSection.style.display = "block"; + + // Check if logout button exists + if (logoutBtn) { + logoutBtn.addEventListener("click", (e) => { + e.preventDefault(); + // Clear any session data or cookies here if necessary + fetch("/logout") // Call the logout + .then(() => { + // Refresh the page after logging out + window.location.href = "/"; // Redirect to login + }) + .catch((error) => console.error("Error logging out:", error)); + }); + } + + // Load tasks for the logged-in user + function loadTasks() { + fetch("/tasks") + .then((response) => response.json()) + .then((data) => { + tasks = data; + renderTasks(); + }) + .catch((error) => console.error("Error:", error)); + } + + // Load tasks for the logged-in user + loadTasks(); + + // Function to calculate days left until due date + // function calculateDaysLeft(dueDate) { + // const currentDate = new Date(); // Today's date + // const due = new Date(dueDate); // DueDate + + // // Calculate the difference in milliseconds + // const differenceInTime = due.getTime() - currentDate.getTime(); + + // // Convert to days + // const differenceInDays = Math.ceil(differenceInTime / (1000 * 3600 * 24)); + + // // Return the number of days left, or a past due message + // return differenceInDays >= 0 + // ? differenceInDays > 0 + // ? "${differenceInDays} day(s) left" + // : "Due Today" + // : "Past Due"; + // } + + // Render tasks in the tables + function renderTasks() { + inProgressTable.innerHTML = ""; + completedTable.innerHTML = ""; + + tasks.forEach((task) => { + const taskItem = document.createElement("li"); + + // Calculate the days left for the task + const daysLeft = calculateDaysLeft(task.dueDate); + + // Determine if the task is marked (due today or past due or 1 day left) + const isUrgent = daysLeft <= 1; + + taskItem.innerHTML = ` +
+ + Title: ${task.task} + + Due Date: ${task.dueDate} + Time Left: ${ + daysLeft >= 0 + ? daysLeft > 0 + ? daysLeft + " day(s) left" + : "Due Today" + : "Past Due" + } + ${ + isUrgent && task.status == "In Progress" + ? '' + : "" + } + + Priority: ${ + task.priority + } +
+
+

Description: ${task.description}

+

Status: ${task.status}

+ ${ + task.status === "In Progress" + ? ` + + + + ` + : ` + + + ` + } +
+ `; + + if (task.status === "In Progress") { + inProgressTable.appendChild(taskItem); + } else { + completedTable.appendChild(taskItem); + } + }); + + // Reinitialize the collapsible after dynamically adding items + var elems = document.querySelectorAll(".collapsible"); + M.Collapsible.init(elems); + } + + // Edit a task + window.editTask = (taskId) => { + const taskToEdit = tasks.find((task) => task._id === taskId); + + if (task) { + document.getElementById("task").value = taskToEdit.task; + document.getElementById("description").value = taskToEdit.description; + document.getElementById("date").value = taskToEdit.dueDate; + document.getElementById("priority").value = taskToEdit.priority; + + editingTaskId = taskId; // Track task being edited + taskForm.querySelector('button[type="submit"]').textContent = + "Update Task"; + + M.updateTextFields(); + + const textarea = document.getElementById("description"); + M.textareaAutoResize(textarea); + } + }; + + // Handle task form submission + taskForm.addEventListener("submit", (e) => { + e.preventDefault(); + + const task = document.getElementById("task").value; + const description = document.getElementById("description").value; + const dueDate = document.getElementById("date").value; + const priority = document.getElementById("priority").value; + + if (editingTaskId) { + // Update existing task + fetch(`/edit/${editingTaskId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ task, description, dueDate, priority }), + }) + .then((response) => response.json()) + .then((data) => { + alert(data.message); + loadTasks(); + editingTaskId = null; + taskForm.reset(); + taskForm.querySelector('button[type="submit"]').textContent = + "Submit Task"; + }) + .catch((error) => console.error("Error:", error)); + } else { + // Add new task + fetch("/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ task, description, dueDate, priority }), + }) + .then((response) => response.json()) + .then((data) => { + alert(data.message); + loadTasks(); + taskForm.reset(); + }) + .catch((error) => console.error("Error:", error)); + } + const textarea = document.getElementById("description"); + textarea.value = ""; + M.textareaAutoResize(textarea); + }); + + // Delete a task + window.deleteTask = (taskId) => { + fetch(`/delete/${taskId}`, { method: "DELETE" }) + .then((response) => response.json()) + .then((data) => { + alert(data.message); + loadTasks(); + }) + .catch((error) => console.error("Error:", error)); + }; + + // Mark task as completed + window.completeTask = (taskId) => { + fetch(`/complete/${taskId}`, { method: "PUT" }) + .then((response) => response.json()) + .then((data) => { + alert(data.message); + loadTasks(); + }) + .catch((error) => console.error("Error:", error)); + }; + + // Mark task as "In Progress" + window.markInProgress = (taskId) => { + fetch(`/in-progress/${taskId}`, { method: "PUT" }) + .then((response) => response.json()) + .then((data) => { + alert(data.message); + loadTasks(); + }) + .catch((error) => console.error("Error:", error)); + }; + + // Calculate days left until the due date + function calculateDaysLeft(dueDate) { + const currentDate = new Date(); + const dueDateObj = new Date(dueDate); + const timeDifference = dueDateObj - currentDate; + return Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); + } + + // Show the task management section after login + // function showTaskSection() { + // registerSection.style.display = "none"; + // registerPrompt.style.display = "none"; + // showRegister.style.display = "none"; + // loginForm.style.display = "none"; + // registerForm.style.display = "none"; + // loginText.style.display = "none"; + // registerText.style.display = "none"; + // taskSection.style.display = "block"; + // } + + // Logout + logoutBtn.addEventListener("click", () => { + fetch("/logout") + .then(() => { + alert("Logged out successfully"); + window.location.href = "/"; // Redirect to login + }) + .catch((error) => console.error("Error:", error)); + }); +}); diff --git a/server.js b/server.js new file mode 100644 index 00000000..f5cb5a51 --- /dev/null +++ b/server.js @@ -0,0 +1,341 @@ +const express = require("express"); +const mongoose = require("mongoose"); +const session = require("express-session"); +const bcrypt = require("bcrypt"); +const path = require("path"); +const passport = require("passport"); +const GoogleStrategy = require("passport-google-oauth20").Strategy; + +const app = express(); +const port = 3000; + +// MongoDB connection +mongoose.connect( + "mongodb+srv://azzhang3:hcxrlK2Q8c5yciUf@cluster0.aiqup.mongodb.net/", + { + useNewUrlParser: true, + useUnifiedTopology: true, + } +); + +// User schema +const userSchema = new mongoose.Schema({ + username: { type: String, required: true }, // Username is required + googleId: { type: String, unique: true }, // Google ID will be used if logging in with Google + password: { + type: String, + required: function () { + // Only require password if Google ID is not provided + return !this.googleId; + }, + }, + email: { type: String }, + profilePhoto: { type: String }, +}); + +// Task schema +const taskSchema = new mongoose.Schema({ + task: String, + description: String, + dueDate: String, + creationDate: String, + priority: String, + status: String, + username: String, // Associated with the user +}); + +// Models +const User = mongoose.model("User", userSchema); +const Task = mongoose.model("Task", taskSchema); + +// Middleware +app.use(express.json()); +app.use(express.static(path.join(__dirname, "public"))); + +// Session middleware setup +app.use( + session({ + secret: "your_secret_key", // Replace this with a secure secret key + resave: false, + saveUninitialized: true, + cookie: { secure: false }, // Set secure: true if using HTTPS + }) +); + +// Initialize Passport +app.use(passport.initialize()); +app.use(passport.session()); + +// Passport serialize and deserialize user +passport.serializeUser((user, done) => { + done(null, user.id); +}); + +passport.deserializeUser(async (id, done) => { + try { + const user = await User.findById(id); + done(null, user); + } catch (err) { + done(err, null); + } +}); + +// Passport Google OAuth strategy +passport.use( + new GoogleStrategy( + { + clientID: + "521313058439-ufmg5r2raagp9drd5515lauicgs0j24k.apps.googleusercontent.com", + clientSecret: "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", + callbackURL: "http://localhost:3000/auth/google/callback", + }, + (accessToken, refreshToken, profile, done) => { + // Extract user's email, handle case when emails array is missing or undefined + const email = + profile.emails && profile.emails.length > 0 + ? profile.emails[0].value + : null; + + User.findOne({ googleId: profile.id }).then((existingUser) => { + if (existingUser) { + return done(null, existingUser); + } else { + // If the user doesn't exist, create a new user + new User({ + username: profile.displayName, // Use Google profile name as the username + googleId: profile.id, // Google ID for future logins + profilePhoto: profile.photos[0].value, // Save profile picture (if available) + email: email, // Save email (if available) + }) + .save() + .then((newUser) => { + return done(null, newUser); + }); + } + }); + } + ) +); + +// Middleware to ensure user is authenticated +function isLoggedIn(req, res, next) { + if (req.isAuthenticated()) { + return next(); + } + res.redirect("/"); // If not authenticated, redirect to login page +} + +// Routes +app.get("/", (req, res) => { + res.send("Home Page"); +}); + +// Serve index.html for unauthenticated users (login screen) +app.get("/", (req, res) => { + if (req.isAuthenticated()) { + return res.redirect("/home"); // Redirect to home if already logged in + } + res.sendFile(path.join(__dirname, "public/index.html")); +}); + +// Serve home.html for authenticated users +app.get("/home", isLoggedIn, (req, res) => { + res.sendFile(path.join(__dirname, "public/home.html")); +}); + +// Google Auth Routes for Login/Registration +app.get( + "/auth/google", + passport.authenticate("google", { + scope: ["profile", "email"], + prompt: "select_account", // Force account selection + }) +); + +app.get( + "/auth/google/callback", + passport.authenticate("google", { failureRedirect: "/" }), + (req, res) => { + // Ensure session.username is set for Google login users + req.session.username = req.user.username; // Set username for session + // Successful authentication, redirect to home + res.redirect("/home"); + } +); + +// Serve tasks only if authenticated +app.get("/tasks", isLoggedIn, (req, res) => { + Task.find({ username: req.user.username }) + .then((tasks) => res.json(tasks)) + .catch((err) => res.status(500).json({ error: "An error occurred", err })); +}); + +// Google Auth Routes for Login/Registration +app.get( + "/auth/google", + passport.authenticate("google", { scope: ["profile"] }) +); + +app.get( + "/auth/google/callback", + passport.authenticate("google", { failureRedirect: "/" }), + (req, res) => { + // Successful authentication, redirect to main application + res.redirect("/home.html"); + } +); + +// Registration route +app.post("/register", async (req, res) => { + const { username, password } = req.body; + + try { + // Check if username is already taken + const existingUser = await User.findOne({ username }); + if (existingUser) { + return res.status(400).json({ message: "Username is already taken" }); + } + + // Hash the password before saving + const hashedPassword = await bcrypt.hash(password, 10); + const newUser = new User({ username, password: hashedPassword }); + await newUser.save(); + res.status(201).json({ message: "User created successfully!" }); + } catch (error) { + res.status(500).json({ message: "An error occurred", error }); + } +}); + +// Login route +// app.post("/login", async (req, res) => { +// const { username, password } = req.body; + +// try { +// const user = await User.findOne({ username }); +// if (!user) { +// return res.status(400).json({ message: "Invalid username or password" }); +// } +// const isMatch = await bcrypt.compare(password, user.password); +// if (!isMatch) { +// return res.status(400).json({ message: "Invalid username or password" }); +// } + +// req.session.username = user.username; +// res.json({ message: `Welcome, ${user.username}!` }); +// } catch (error) { +// res.status(500).json({ message: "An error occurred", error }); +// } +// }); +app.post("/login", async (req, res, next) => { + const { username, password } = req.body; + + try { + const user = await User.findOne({ username }); + if (!user) { + return res.status(400).json({ message: "Invalid username or password" }); + } + + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.status(400).json({ message: "Invalid username or password" }); + } + + // Log the user in and redirect + req.login(user, function (err) { + if (err) { + return next(err); + } + return res.redirect("/home"); + }); + } catch (error) { + res.status(500).json({ message: "An error occurred", error }); + } +}); + +// Logout route +app.get("/logout", (req, res, next) => { + req.logout((err) => { + if (err) { + return next(err); // Pass error to the next middleware + } + res.redirect("/"); // Redirect to login page + // req.session.destroy(() => { + // res.clearCookie("connect.sid", { path: "/" }); + // res.redirect("/"); // Redirect to login page + // }); + }); +}); + +// Add a new task +app.post("/submit", isLoggedIn, (req, res) => { + const { task, description, dueDate, priority } = req.body; + const creationDate = new Date().toISOString().split("T")[0]; + + const newTask = new Task({ + task, + description, + dueDate, + creationDate, + priority, + status: "In Progress", + username: req.user.username, + }); + + newTask + .save() + .then(() => res.json({ message: "Task added successfully!" })) + .catch((err) => res.status(500).json({ error: "An error occurred", err })); +}); + +// Edit a task +app.put("/edit/:id", isLoggedIn, (req, res) => { + const { task, description, dueDate, priority } = req.body; + + Task.findOneAndUpdate( + { _id: req.params.id, username: req.user.username }, + { task, description, dueDate, priority, status: "In Progress" }, + { new: true } + ) + .then((updatedTask) => + res.json({ message: "Task updated successfully!", updatedTask }) + ) + .catch((err) => res.status(500).json({ error: "An error occurred", err })); +}); + +// Delete a task +app.delete("/delete/:id", isLoggedIn, (req, res) => { + Task.findOneAndDelete({ _id: req.params.id, username: req.user.username }) + .then(() => res.json({ message: "Task deleted successfully!" })) + .catch((err) => res.status(500).json({ error: "An error occurred", err })); +}); + +// Mark task as complete +app.put("/complete/:id", isLoggedIn, (req, res) => { + Task.findOneAndUpdate( + { _id: req.params.id, username: req.user.username }, + { status: "Completed" }, + { new: true } + ) + .then((updatedTask) => + res.json({ message: "Task marked as complete!", updatedTask }) + ) + .catch((err) => res.status(500).json({ error: "An error occurred", err })); +}); + +// Mark completed task as "In Progress" +app.put("/in-progress/:id", isLoggedIn, (req, res) => { + Task.findOneAndUpdate( + { _id: req.params.id, username: req.user.username }, + { status: "In Progress" }, + { new: true } + ) + .then((updatedTask) => + res.json({ message: "Task marked as In Progress!", updatedTask }) + ) + .catch((err) => res.status(500).json({ error: "An error occurred", err })); +}); + +// Start the server +app.listen(port, () => { + console.log(`Server is running on http://localhost:${port}`); +});