diff --git a/.gitignore b/.gitignore
index 3d70248ba2..f3ec83fcd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,4 +12,6 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
-package-lock.json
\ No newline at end of file
+package-lock.json
+
+dist
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000..d3cb2ac4db
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "postman.settings.dotenv-detection-notification-visibility": false
+}
diff --git a/README.md b/README.md
index 31466b54c2..2964708351 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,34 @@
-# Final Project
+# Final Bootcamp Project - DeskForge
-Replace this readme with your own information about your project.
+The assignment was to create a full-stack React app that solves a clear problem.
-Start by briefly describing the assignment in a sentence or two. Keep it short and to the point.
+I decided to create an app that helps hobbyists, specifically warhammer 40k, to organise their workspaces by uploading a photo of their desk space and getting AI-generated suggestions.
## The problem
-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?
+To create a web app that allows tearget users (warhammer 40k hobbyists) to upload a phot of their desk, and generate suggestions for improvement. Users can must also be able keep track of previous desks through a suggestions history.
+
+Started by sketching out wireframes to udnerstand general structure and functionality.
+
+Built the project feature-by-feature starting with setting up the backend for a particular feature, then building the frontend, dealt with bugs for that specific feature then repeated for the nex feature until the app was built.
+
+Handling the multiple states needed for the upload and generate states for a desk was a challenge that was solved with the help of creating separate components and using global state management.
+
+Routing is handled by react router.
+
+The AI integration was an integral part of the project and that was solved using OpenAI's api as it is very well-documentated and straight-forward to integrate.
+
+The next steps I would like to implement in my own time is to create a new feature where the location of changes is indicated on the image, to migrate it to React Native for mobile, and use Tamagui for the design so it is cohesive across all platforms.
+
+
+## The code
+
+The code is separated into a backend and frontend folder.
+
+The backend folder has the main server file with the ai integration, authorisation, and upload routes in sperate files. Middleware is in a separate folder as is the desk and user monogdb models.
+
+The frontend folder has the main file while components and pages are in 2 seperate folders, as is the latestdesk global state. Pages indicate the sperate pages you can navigate to while components are sections within said pages.
## View it live
-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
+Netfliy link: https://desk-forge.netlify.app/
diff --git a/backend/middlewares/auth.js b/backend/middlewares/auth.js
new file mode 100644
index 0000000000..720e69f1c0
--- /dev/null
+++ b/backend/middlewares/auth.js
@@ -0,0 +1,26 @@
+import jwt from "jsonwebtoken";
+import dotenv from "dotenv";
+dotenv.config();
+
+const JWT_SECRET = process.env.JWT_SECRET;
+
+const authenticate = (req, res, next) => {
+ const authHeader = req.headers.authorization;
+
+ if (!authHeader)
+ return res.status(401).json({ message: "Authorisation header missing" });
+
+ const token = authHeader.split(" ")[1];
+
+ if (!token) return res.status(401).json({ message: "Token missing" });
+
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET);
+ req.user = decoded;
+ next();
+ } catch (error) {
+ res.status(401).json({ message: "Invalid or expired token" });
+ }
+};
+
+export default authenticate;
diff --git a/backend/models/Desk.js b/backend/models/Desk.js
new file mode 100644
index 0000000000..4811067a39
--- /dev/null
+++ b/backend/models/Desk.js
@@ -0,0 +1,32 @@
+import mongoose from "mongoose";
+
+const deskSchema = new mongoose.Schema(
+ {
+ userId: {
+ type: mongoose.Schema.Types.ObjectId,
+ required: true,
+ ref: "User",
+ },
+ imageUrl: {
+ type: String,
+ required: true,
+ },
+ createdAt: {
+ type: Date,
+ default: Date.now,
+ },
+ problems: { type: String, default: "" },
+ suggestions: [
+ {
+ title: { type: String, required: true },
+ description: { type: String, required: true },
+ },
+ ],
+ summary: { type: String, default: "" },
+ },
+ { timestamps: true }
+);
+
+const Desk = mongoose.model("Desk", deskSchema);
+
+export default Desk;
diff --git a/backend/models/User.js b/backend/models/User.js
new file mode 100644
index 0000000000..f3ced8f803
--- /dev/null
+++ b/backend/models/User.js
@@ -0,0 +1,33 @@
+import bcrypt from "bcrypt";
+import mongoose from "mongoose";
+
+const userSchema = new mongoose.Schema(
+ {
+ name: { type: String, required: true, minlength: 3 },
+ email: { type: String, required: true, unique: true, lowercase: true },
+ password: { type: String, required: true, minlength: 6 },
+ lastLogin: { type: Date, default: Date.now },
+ previousLogin: { type: Date },
+ totalAiCalls: { type: Number, default: 0 },
+ },
+ { timestamps: true }
+);
+
+userSchema.pre("save", async function (next) {
+ if (!this.isModified("password")) return next();
+
+ try {
+ const salt = await bcrypt.genSalt(10);
+ this.password = await bcrypt.hash(this.password, salt);
+ next();
+ } catch (error) {
+ next(error);
+ }
+});
+
+userSchema.methods.comparePassword = async function (candidatePassword) {
+ return bcrypt.compare(candidatePassword, this.password);
+};
+
+const User = mongoose.model("User", userSchema);
+export default User;
diff --git a/backend/package.json b/backend/package.json
index 08f29f2448..9c67dce208 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -12,9 +12,16 @@
"@babel/core": "^7.17.9",
"@babel/node": "^7.16.8",
"@babel/preset-env": "^7.16.11",
+ "bcrypt": "^6.0.0",
+ "cloudinary": "^2.7.0",
"cors": "^2.8.5",
- "express": "^4.17.3",
- "mongoose": "^8.4.0",
- "nodemon": "^3.0.1"
+ "dotenv": "^16.5.0",
+ "express": "^4.21.2",
+ "express-list-endpoints": "^7.1.1",
+ "jsonwebtoken": "^9.0.2",
+ "mongoose": "^8.16.0",
+ "multer": "^2.0.1",
+ "nodemon": "^3.0.1",
+ "openai": "^5.9.0"
}
-}
\ No newline at end of file
+}
diff --git a/backend/routes/ai.js b/backend/routes/ai.js
new file mode 100644
index 0000000000..400e9b5fbf
--- /dev/null
+++ b/backend/routes/ai.js
@@ -0,0 +1,145 @@
+import express from "express";
+import Desk from "../models/Desk.js";
+import User from "../models/User.js";
+import authenticate from "../middlewares/auth.js";
+import OpenAI from "openai";
+import dotenv from "dotenv";
+import path from "path";
+
+dotenv.config();
+
+const router = express.Router();
+
+const openai = new OpenAI({
+ apiKey: process.env.OPENAI_API_KEY,
+});
+
+router.post("/desks/:id/generate", authenticate, async (req, res) => {
+ const deskId = req.params.id;
+ const allowedExtensions = [".jpg", ".jpeg", ".png", ".gif"];
+ console.log(
+ "Generate suggestions request for desk ID:",
+ deskId,
+ "by user:",
+ req.user.id
+ );
+
+ try {
+ const desk = await Desk.findOne({ _id: deskId, userId: req.user.id });
+ if (!desk) {
+ console.log("Desk not found for this user:", req.user.id);
+ return res
+ .status(404)
+ .json({ success: false, message: "Desk not found" });
+ }
+ console.log("Desk found:", desk._id, "Image URL:", desk.imageUrl);
+
+ const fileExtension = path
+ .extname(desk.imageUrl.split("?")[0])
+ .toLowerCase();
+
+ if (!allowedExtensions.includes(fileExtension)) {
+ return res.status(400).json({
+ success: false,
+ message:
+ "Cannot generate suggestions: unsupported file type. Please upload JPG, PNG, or GIF.",
+ });
+ }
+
+ const user = await User.findById(req.user.id);
+ if (!user)
+ return res
+ .status(404)
+ .json({ success: false, message: "User not found" });
+
+ console.log(
+ "User fetched:",
+ user._id,
+ "Email:",
+ user.email,
+ "Total AI calls:",
+ user.totalAiCalls || 0
+ );
+
+ if ((user.totalAiCalls || 0) >= 10) {
+ return res.status(403).json({
+ success: false,
+ message:
+ "You’ve reached your AI call limit. Please contact us @ christina.baldwin13@yahoo.com",
+ });
+ }
+
+ const response = await openai.chat.completions.create({
+ model: "gpt-4o-mini",
+ messages: [
+ {
+ role: "user",
+ content: [
+ {
+ type: "text",
+ text: "Use the uploaded image and the below desk problems, if available, to give 5 suggestions to improve my hobby desk setup, specifically for warhammer 40k. Reference specific products if applicable. Reference specific points on the desk where your suggested items would be placed. For each suggestion, provide coordinates using x and y to visually represent where that specific suggestion should be placed assuming x will always be a maximum value of 440px and y being 267px. Reference things on the desk that are not useful and can be removed. Return them as a JSON array of objects, each with a 'title', 'description', 'x' and 'y'. No markdown formatting, no asterisks.",
+ },
+ {
+ type: "text",
+ text: `Desk problems I'd like fixed: ${desk.problems}`,
+ },
+ { type: "image_url", image_url: { url: desk.imageUrl } },
+ ],
+ },
+ ],
+ max_tokens: 500,
+ });
+
+ const aiMessage = response.choices[0]?.message?.content || "";
+
+ console.info("aiMessage: ", aiMessage);
+
+ try {
+ desk.suggestions = JSON.parse(aiMessage);
+ } catch (parseError) {
+ console.error(
+ "Failed to parse AI message:",
+ parseError,
+ "aiMessage:",
+ aiMessage
+ );
+ return res
+ .status(500)
+ .json({ success: false, message: "Invalid AI response format" });
+ }
+
+ const summaryPrompt = `Here are the desk problems ${
+ desk.problems
+ } and here are the ai-generated suggestions ${desk.suggestions
+ .map((s) => `${s.title}: ${s.description}`)
+ .join(
+ "\n"
+ )}. Using these, write a short 1 sentence summary highlighting only the key issues and solutions for my warhammer hobby desk setup`;
+
+ try {
+ const summaryResponse = await openai.chat.completions.create({
+ model: "gpt-4o-mini",
+ messages: [{ role: "user", content: summaryPrompt }],
+ max_tokens: 150,
+ });
+ desk.summary = summaryResponse.choices[0]?.message?.content || "";
+ } catch (summaryError) {
+ console.error("OpenAI summary error:", summaryError);
+ desk.summary = "";
+ }
+
+ user.totalAiCalls = (user.totalAiCalls || 0) + 1;
+ await user.save();
+
+ await desk.save();
+
+ res.status(200).json({ success: true, suggestions: desk.suggestions });
+ } catch (error) {
+ console.error("Generate suggestions error:", error);
+ res
+ .status(500)
+ .json({ success: false, message: "Failed to generate suggestions" });
+ }
+});
+
+export default router;
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
new file mode 100644
index 0000000000..c5423c180c
--- /dev/null
+++ b/backend/routes/auth.js
@@ -0,0 +1,147 @@
+import dotenv from "dotenv";
+dotenv.config();
+
+import express from "express";
+import jwt from "jsonwebtoken";
+
+import User from "../models/User.js";
+import authenticate from "../middlewares/auth.js";
+
+const router = express.Router();
+const JWT_SECRET = process.env.JWT_SECRET;
+const bcrypt = require("bcrypt");
+const MAX_USERS = 20;
+
+async function hashPassword(password) {
+ const saltRounds = 10;
+ const hashed = await bcrypt.hash(password, saltRounds);
+ return hashed;
+}
+
+// signing up as a user
+router.post("/register", async (req, res) => {
+ const { name, email, password } = req.body;
+
+ try {
+ const numberOfUsers = await User.countDocuments();
+ const existingUser = await User.findOne({ email });
+
+ if (numberOfUsers >= MAX_USERS) {
+ return res.status(403).json({
+ success: false,
+ message:
+ "Maximum number of accounts has been reached. Contact us @ christina.baldwin13@yahoo.com",
+ });
+ }
+
+ if (existingUser) {
+ return res
+ .status(400)
+ .json({ success: false, message: "Email already exists." });
+ }
+
+ const newUser = new User({ name, email, password });
+ await newUser.save();
+
+ if (!JWT_SECRET) {
+ console.error("JWT_SECRET is missing or empty!");
+ }
+
+ const token = jwt.sign(
+ { id: newUser._id, user: newUser.email },
+ JWT_SECRET,
+ {
+ expiresIn: "7d",
+ }
+ );
+
+ res.status(201).json({ success: true, message: "User created", token });
+ } catch (error) {
+ console.log(error);
+ res
+ .status(500)
+ .json({ success: false, message: "Error creating user", error });
+ }
+});
+
+// login
+router.post("/login", async (req, res) => {
+ const { email, password } = req.body;
+
+ try {
+ const user = await User.findOne({ email });
+ if (!user) {
+ return res
+ .status(401)
+ .json({ success: false, message: "Invalid email or password" });
+ }
+
+ const isMatch = await user.comparePassword(password);
+ if (!isMatch) {
+ return res
+ .status(401)
+ .json({ success: false, message: "Invalid email or password" });
+ }
+
+ const token = jwt.sign({ id: user._id, user: user.email }, JWT_SECRET, {
+ expiresIn: "7d",
+ });
+
+ user.previousLogin = user.lastLogin;
+ user.lastLogin = new Date();
+
+ await user.save();
+
+ res.status(200).json({
+ success: true,
+ message: "Logged in",
+ token,
+ lastLogin: user.lastLogin,
+ previousLogin: user.previousLogin,
+ });
+ } catch (error) {
+ res
+ .status(500)
+ .json({ success: false, message: error.message || "Login error", error });
+ }
+});
+
+// user info
+router.get("/user", authenticate, async (req, res) => {
+ try {
+ const user = await User.findById(req.user.id).select(
+ "name email lastLogin previousLogin"
+ );
+
+ res.status(200).json({ success: true, user });
+ } catch (error) {
+ res.status(500).json({ success: false, message: "Failed to fetch user" });
+ }
+});
+
+router.patch("/user", authenticate, async (req, res) => {
+ try {
+ const { name, email, password } = req.body;
+ const user = await User.findById(req.user.id);
+
+ if (!user) {
+ return res
+ .status(404)
+ .json({ success: false, message: "User not found" });
+ }
+
+ user.name = name || user.name;
+ user.email = email || user.email;
+ if (password) {
+ user.password = await hashPassword(password);
+ }
+
+ await user.save();
+
+ res.status(200).json({ success: true, user });
+ } catch (error) {
+ res.status(500).json({ success: false, message: "Failed to fetch user" });
+ }
+});
+
+export default router;
diff --git a/backend/routes/upload.js b/backend/routes/upload.js
new file mode 100644
index 0000000000..c4a6ba3d2e
--- /dev/null
+++ b/backend/routes/upload.js
@@ -0,0 +1,180 @@
+import express from "express";
+import Desk from "../models/Desk.js";
+import multer from "multer";
+import authenticate from "../middlewares/auth.js";
+import { v2 as cloudinary } from "cloudinary";
+import fs from "fs/promises";
+import dotenv from "dotenv";
+import path from "path";
+
+dotenv.config();
+
+cloudinary.config(process.env.CLOUDINARY_URL);
+
+const router = express.Router();
+
+const upload = multer({
+ dest: "uploads/",
+ limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB max
+});
+
+router.post("/", authenticate, upload.single("image"), async (req, res) => {
+ try {
+ const file = req.file;
+ const problems = req.body.problems;
+
+ const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
+ const allowedExtensions = [".jpg", ".jpeg", ".png", ".gif"];
+ const MAX_FILE_SIZE_MB = 5;
+
+ if (!file) {
+ return res
+ .status(400)
+ .json({ success: false, message: "No file uploaded" });
+ }
+
+ const fileExtension = path.extname(file.originalname).toLowerCase();
+
+ if (
+ !allowedTypes.includes(file.mimetype) ||
+ !allowedExtensions.includes(fileExtension)
+ ) {
+ await fs.unlink(file.path);
+ return res.status(400).json({
+ success: false,
+ message: "Unsupported file type. Please upload JPG, PNG, or GIF.",
+ });
+ }
+
+ if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
+ await fs.unlink(file.path);
+ return res.status(400).json({
+ success: false,
+ message: `File too large. Please upload a file under ${MAX_FILE_SIZE_MB} MB.`,
+ });
+ }
+
+ const result = await cloudinary.uploader.upload(file.path, {
+ folder: "warhammer-desk-spaces",
+ });
+
+ await fs.unlink(file.path);
+
+ const newDesk = new Desk({
+ userId: req.user.id,
+ imageUrl: result.secure_url,
+ problems: problems,
+ suggestions: [],
+ });
+
+ await newDesk.save();
+
+ res.status(200).json({
+ success: true,
+ message: "Photo uploaded successfully!",
+ url: result.secure_url,
+ id: result.public_id,
+ desk: {
+ _id: newDesk._id,
+ problems: newDesk.problems,
+ suggestions: newDesk.suggestions,
+ },
+ });
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({ success: false, message: "Upload failed" });
+ }
+});
+
+router.get("/latest", authenticate, async (req, res) => {
+ try {
+ const latestDesk = await Desk.findOne({ userId: req.user._id })
+ .sort({ createdAt: -1 })
+ .exec();
+
+ if (!latestDesk) {
+ return res.status(404).json({ success: false, message: "No desk found" });
+ }
+
+ res.status(200).json({ success: true, desk: latestDesk });
+ } catch (error) {
+ console.error(error);
+ res
+ .status(500)
+ .json({ success: false, message: "Failed to fetch latest desk" });
+ }
+});
+
+router.get("/desks", authenticate, async (req, res) => {
+ try {
+ const desks = await Desk.find({ userId: req.user.id })
+ .sort({ createdAt: -1 })
+ .exec();
+
+ res.status(200).json({ success: true, desks });
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({ success: false, message: "Failed to fetch desks" });
+ }
+});
+
+router.delete("/desks/:id", authenticate, async (req, res) => {
+ try {
+ const deskId = req.params.id;
+
+ const desk = await Desk.findOneAndDelete({
+ _id: deskId,
+ userId: req.user.id,
+ });
+
+ if (!desk) {
+ return res
+ .status(404)
+ .json({ success: false, message: "Desk not found" });
+ }
+
+ const publicId = desk.imageUrl.split("/").slice(-2).join("/").split(".")[0];
+
+ await cloudinary.uploader.destroy(publicId);
+
+ res
+ .status(200)
+ .json({ success: true, message: "Desk deleted successfully" });
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({ success: false, message: "Failed to delete desk" });
+ }
+});
+
+router.patch("/desks/:id", authenticate, async (req, res) => {
+ try {
+ const { problems } = req.body;
+ const deskId = req.params.id;
+
+ const desk = await Desk.findOne({
+ _id: deskId,
+ userId: req.user.id,
+ });
+
+ if (!desk) {
+ return res
+ .status(404)
+ .json({ success: false, message: "Desk not found" });
+ }
+
+ if (problems !== undefined) {
+ desk.problems = problems;
+ }
+
+ await desk.save();
+
+ res
+ .status(200)
+ .json({ success: true, message: "Desk updated successfully" });
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({ success: false, message: "Failed to update desk" });
+ }
+});
+
+export default router;
diff --git a/backend/server.js b/backend/server.js
index 070c875189..b12f01d757 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -1,21 +1,34 @@
-import express from "express";
+import dotenv from "dotenv";
+dotenv.config();
+
import cors from "cors";
+import express from "express";
import mongoose from "mongoose";
-const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project";
-mongoose.connect(mongoUrl);
-mongoose.Promise = Promise;
+import authRoutes from "./routes/auth.js";
+import uploadRoutes from "./routes/upload.js";
+import aiRoutes from "./routes/ai.js";
const port = process.env.PORT || 8080;
const app = express();
+// MONGODB CONNECTION //
+const mongoUrl = process.env.MONGO_URL;
+mongoose.connect(mongoUrl);
+mongoose.Promise = Promise;
+
+// MIDDLEWARES //
app.use(cors());
app.use(express.json());
app.get("/", (req, res) => {
- res.send("Hello Technigo!");
+ res.send("Hello DeskForge!");
});
+app.use("/auth", authRoutes);
+app.use("/upload", uploadRoutes);
+app.use("/ai", aiRoutes);
+
// Start the server
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
diff --git a/frontend/index.html b/frontend/index.html
index 664410b5b9..1f17e6f661 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,9 +2,40 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
- Technigo React Vite Boiler Plate
+ DeskForge
diff --git a/frontend/package.json b/frontend/package.json
index 7b2747e949..bf20525063 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,17 +10,27 @@
"preview": "vite preview"
},
"dependencies": {
+ "@heroicons/react": "^2.2.0",
+ "@tailwindcss/vite": "^4.1.10",
+ "motion": "^12.23.12",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^7.6.2",
+ "tailwind": "^4.0.0",
+ "zustand": "^5.0.8"
},
"devDependencies": {
+ "@tailwindcss/postcss": "^4.1.11",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
+ "autoprefixer": "^10.4.21",
"eslint": "^8.45.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.10",
"vite": "^6.3.5"
}
}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000000..f69c5d4119
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png
new file mode 100644
index 0000000000..17cde8ff90
Binary files /dev/null and b/frontend/public/android-chrome-192x192.png differ
diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png
new file mode 100644
index 0000000000..7d4f914ac2
Binary files /dev/null and b/frontend/public/android-chrome-512x512.png differ
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png
new file mode 100644
index 0000000000..e60c48ffc8
Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ
diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png
new file mode 100644
index 0000000000..fdc056b291
Binary files /dev/null and b/frontend/public/favicon-16x16.png differ
diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png
new file mode 100644
index 0000000000..f8dd75ebf3
Binary files /dev/null and b/frontend/public/favicon-32x32.png differ
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
new file mode 100644
index 0000000000..82ba7518e4
Binary files /dev/null and b/frontend/public/favicon.ico differ
diff --git a/frontend/public/hero-img-2.png b/frontend/public/hero-img-2.png
new file mode 100644
index 0000000000..ef6cf82e06
Binary files /dev/null and b/frontend/public/hero-img-2.png differ
diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest
new file mode 100644
index 0000000000..45dc8a2065
--- /dev/null
+++ b/frontend/public/site.webmanifest
@@ -0,0 +1 @@
+{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
\ No newline at end of file
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg
deleted file mode 100644
index e7b8dfb1b2..0000000000
--- a/frontend/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0a24275e6e..1c518204e9 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,8 +1,39 @@
-export const App = () => {
+import { Route, Routes, BrowserRouter } from "react-router-dom";
+import Landing from "./pages/Landing";
+import Dashboard from "./pages/Dashboard";
+import Login from "./pages/Login";
+import Register from "./pages/Register";
+import Suggestions from "./pages/Suggestions";
+import Upload from "./pages/Upload";
+import NotFound from "./pages/NotFound";
+import Layout from "./components/Layout";
+import Settings from "./pages/Settings";
+import PrivateRoute from "./components/PrivateRoute";
+export const App = () => {
return (
- <>
- Welcome to Final Project!
- >
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+
+
+
+
);
};
diff --git a/frontend/src/assets/boiler-plate.svg b/frontend/src/assets/boiler-plate.svg
deleted file mode 100644
index c9252833b4..0000000000
--- a/frontend/src/assets/boiler-plate.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg
deleted file mode 100644
index 6c87de9bb3..0000000000
--- a/frontend/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/technigo-logo.svg b/frontend/src/assets/technigo-logo.svg
deleted file mode 100644
index 3f0da3e572..0000000000
--- a/frontend/src/assets/technigo-logo.svg
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx
new file mode 100644
index 0000000000..fec3ce0b62
--- /dev/null
+++ b/frontend/src/components/Footer.jsx
@@ -0,0 +1,7 @@
+export default function Footer() {
+ return (
+
+ © {new Date().getFullYear()} DeskForge. All rights reserved.
+
+ );
+}
diff --git a/frontend/src/components/LatestDesk.jsx b/frontend/src/components/LatestDesk.jsx
new file mode 100644
index 0000000000..6a1161574f
--- /dev/null
+++ b/frontend/src/components/LatestDesk.jsx
@@ -0,0 +1,226 @@
+import { useState, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { useDeskStore } from "../state/deskStore";
+
+const apiUrl = "https://desk-forge.onrender.com";
+
+const LatestDesk = () => {
+ const { latestDesk, setLatestDesk, clearDesk } = useDeskStore();
+ const [isEditingProblems, setIsEditingProblems] = useState(false);
+ const [newProblems, setNewProblems] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState("");
+
+ const navigate = useNavigate();
+
+ const fetchLatestDesk = async () => {
+ const token = localStorage.getItem("token");
+ try {
+ const response = await fetch(`${apiUrl}/upload/desks`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ if (!response.ok) throw new Error("Failed to fetch desks");
+
+ const data = await response.json();
+
+ if (data.desks && data.desks.length > 0) {
+ setLatestDesk(data.desks[0]);
+ } else {
+ setLatestDesk(null);
+ }
+ } catch (error) {
+ setMessage(error.message);
+ }
+ };
+
+ useEffect(() => {
+ fetchLatestDesk();
+ }, []);
+
+ const handleGenerateSuggestions = async () => {
+ if (!latestDesk) return;
+
+ setLoading(true);
+ setMessage("Generating suggestions, please wait...");
+
+ const token = localStorage.getItem("token");
+
+ try {
+ const response = await fetch(
+ `${apiUrl}/ai/desks/${latestDesk._id}/generate`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ setMessage(data.message || "Failed to generate suggestions");
+ return;
+ }
+
+ setLatestDesk({ ...latestDesk, suggestions: data.suggestions });
+ setMessage("Suggestions generated successfully!");
+ navigate("/suggestions");
+ } catch (error) {
+ console.error("Generation error:", error);
+ setMessage(error.message || "Unexpected error during generation");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!latestDesk) return;
+
+ const confirmDelete = window.confirm(
+ "Are you sure you want to delete this desk?"
+ );
+
+ if (!confirmDelete) return;
+
+ const token = localStorage.getItem("token");
+
+ try {
+ const response = await fetch(`${apiUrl}/upload/desks/${latestDesk._id}`, {
+ method: "DELETE",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to delete file");
+ }
+
+ setMessage("Desk deleted successfully!");
+
+ await fetchLatestDesk();
+ } catch (error) {
+ setMessage(error.message);
+ }
+ };
+
+ const handleProblemsSave = async () => {
+ if (!latestDesk) return;
+
+ const token = localStorage.getItem("token");
+
+ try {
+ const response = await fetch(`${apiUrl}/upload/desks/${latestDesk._id}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ problems: newProblems }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || "Failed to update desk problems");
+ }
+
+ setLatestDesk({ ...latestDesk, problems: newProblems });
+ setIsEditingProblems(false);
+ setNewProblems("");
+ setMessage("");
+ } catch (error) {
+ setMessage(error.message);
+ }
+ };
+
+ if (!latestDesk) return No desk uploaded yet
;
+
+ return (
+
+
+ Use the latest photo of your desk
+
+
+
+
+
+ {isEditingProblems ? (
+
+
setNewProblems(e.target.value)}
+ className="px-4 py-4 border-2 text-dark font-body rounded-[5px] mb-2 focus:outline-none focus:ring-2 focus:ring-accent"
+ />
+
+
+ Save
+
+ setIsEditingProblems(false)}
+ className="px-4 py-2 bg-accent text-dark text-md rounded font-heading cursor-pointer border-light border-4 shadow-[0_0_0_4px_black] drop-shadow-[3px_3px_0_#1b2a2f] transform hover:translate-x-1 hover:translate-y-1 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-accent"
+ >
+ Cancel
+
+
+
+ ) : (
+
+
+ {latestDesk.problems || "No problems listed"}
+
+
setIsEditingProblems(true)}
+ className="px-4 py-2 bg-accent text-dark text-md rounded font-heading cursor-pointer border-light border-4 shadow-[0_0_0_4px_black] drop-shadow-[3px_3px_0_#1b2a2f] transform hover:translate-x-1 hover:translate-y-1 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-accent"
+ >
+ Edit Problems
+
+
+ )}
+
+
+
+ {latestDesk.suggestions?.length > 0
+ ? "Regenerate Suggestions"
+ : "Generate Suggestions"}
+
+
+ Delete
+
+
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+
+ );
+};
+
+export default LatestDesk;
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
new file mode 100644
index 0000000000..4c087ab7a1
--- /dev/null
+++ b/frontend/src/components/Layout.jsx
@@ -0,0 +1,17 @@
+import { Outlet } from "react-router-dom";
+import Nav from "./Nav";
+import Footer from "./Footer";
+
+const Layout = () => {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+export default Layout;
diff --git a/frontend/src/components/Nav.jsx b/frontend/src/components/Nav.jsx
new file mode 100644
index 0000000000..9dc1516924
--- /dev/null
+++ b/frontend/src/components/Nav.jsx
@@ -0,0 +1,64 @@
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+const apiUrl = "https://desk-forge.onrender.com";
+
+const Nav = () => {
+ const [userName, setUserName] = useState("");
+ const [error, setError] = useState("");
+
+ const navigate = useNavigate();
+
+ const handleLogout = () => {
+ localStorage.removeItem("token");
+ navigate("/");
+ };
+
+ useEffect(() => {
+ const fetchUser = async () => {
+ const token = localStorage.getItem("token");
+
+ try {
+ const response = await fetch(`${apiUrl}/auth/user`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || "Failed to fetch user name");
+ }
+
+ setUserName(data.user.name);
+ } catch (error) {
+ setError(error.message);
+ }
+ };
+ fetchUser();
+ }, []);
+
+ return (
+
+
+
+ DF
+
+
+ Hi, {userName || "..."}!
+
+
+
+ LOGOUT
+
+
+ );
+};
+
+export default Nav;
diff --git a/frontend/src/components/PrivateRoute.jsx b/frontend/src/components/PrivateRoute.jsx
new file mode 100644
index 0000000000..42cb7249e9
--- /dev/null
+++ b/frontend/src/components/PrivateRoute.jsx
@@ -0,0 +1,11 @@
+import { Navigate } from "react-router-dom";
+
+export default function PrivateRoute({ children }) {
+ const token = localStorage.getItem("token");
+
+ if (!token) {
+ return ;
+ }
+
+ return children;
+}
diff --git a/frontend/src/components/SideBar.jsx b/frontend/src/components/SideBar.jsx
new file mode 100644
index 0000000000..b5d25526d1
--- /dev/null
+++ b/frontend/src/components/SideBar.jsx
@@ -0,0 +1,47 @@
+import { Link } from "react-router-dom";
+
+const SideBar = () => {
+ return (
+
+
+
+
+ Home
+
+
+
+
+
+ Analyse
+
+
+
+
+
+ Suggestions
+
+
+
+
+
+ Settings
+
+
+
+
+ );
+};
+
+export default SideBar;
diff --git a/frontend/src/components/UploadDesk.jsx b/frontend/src/components/UploadDesk.jsx
new file mode 100644
index 0000000000..4a03d7c050
--- /dev/null
+++ b/frontend/src/components/UploadDesk.jsx
@@ -0,0 +1,208 @@
+import { useState, useEffect, useRef } from "react";
+import { useDeskStore } from "../state/deskStore";
+
+const apiUrl = "https://desk-forge.onrender.com";
+
+const UploadDesk = () => {
+ const setLatestDesk = useDeskStore((state) => state.setLatestDesk);
+ const [file, setFile] = useState(null);
+ const [problems, setProblems] = useState("");
+ const [message, setMessage] = useState("");
+ const [isUploading, setIsUploading] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const fetchLatestDesk = async () => {
+ const token = localStorage.getItem("token");
+ try {
+ const response = await fetch(`${apiUrl}/upload/desks`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ if (!response.ok) throw new Error("Failed to fetch desks");
+
+ const data = await response.json();
+
+ if (data.desks && data.desks.length > 0) {
+ setLatestDesk(data.desks[0]);
+ } else {
+ setLatestDesk(null);
+ }
+ } catch (error) {
+ setMessage(error.message);
+ }
+ };
+
+ useEffect(() => {
+ fetchLatestDesk();
+ }, []);
+
+ const handleFileChange = (e) => {
+ const selectedFile = e.target.files[0];
+ if (!selectedFile) return;
+
+ const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
+ const allowedExtensions = [".jpg", ".jpeg", ".png", ".gif"];
+ const MAX_FILE_SIZE_MB = 5;
+
+ const fileExtension = selectedFile.name
+ .slice(((selectedFile.name.lastIndexOf(".") - 1) >>> 0) + 2)
+ .toLowerCase();
+
+ if (
+ !allowedTypes.includes(selectedFile.type) ||
+ !allowedExtensions.includes(`.${fileExtension}`)
+ ) {
+ setMessage("Only JPG, PNG, and GIF images are supported!");
+ return;
+ }
+
+ if (selectedFile.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
+ alert(
+ `File too large. Please upload a file under ${MAX_FILE_SIZE_MB} MB.`
+ );
+ return;
+ }
+
+ setFile(selectedFile);
+ setMessage("");
+ setProblems("");
+ };
+
+ const handleSelectClick = () => {
+ fileInputRef.current.click();
+ };
+
+ const handleUpload = async () => {
+ if (!file) {
+ setMessage("Please select a file to upload.");
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append("image", file);
+ formData.append("problems", problems);
+ const token = localStorage.getItem("token");
+
+ try {
+ setIsUploading(true);
+
+ const response = await fetch(`${apiUrl}/upload`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: formData,
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ setMessage(data.message || "An unexpected error occurred.");
+ return;
+ }
+
+ // Success
+ setMessage(data.message || "File uploaded successfully!");
+ setProblems("");
+ setFile(null);
+ if (fileInputRef.current) fileInputRef.current.value = null;
+
+ fetchLatestDesk();
+ } catch (error) {
+ setMessage(
+ error.message || "An unexpected error occurred during upload."
+ );
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const handleCancel = () => {
+ if (file) {
+ setFile(null);
+ setMessage("");
+ setProblems("");
+ if (fileInputRef.current) fileInputRef.current.value = null;
+ }
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ const droppedFile = e.dataTransfer.files[0];
+
+ const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
+ if (!allowedTypes.includes(droppedFile.type)) {
+ setMessage("Only JPG, PNG, and GIF images are supported!");
+ return;
+ }
+
+ setFile(droppedFile);
+ setMessage("");
+ };
+
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ };
+
+ return (
+ <>
+
+ Upload a new photo of your desk
+
+
+
+ {file ? file.name : "Add a file or drag here"}
+
+ setProblems(e.target.value)}
+ className="px-4 py-4 border-2 text-dark font-body rounded-[5px] mb-2 focus:outline-none focus:ring-2 focus:ring-accent"
+ />
+
+
+ {isUploading ? "Uploading..." : "Upload"}
+
+
+
+ Cancel
+
+
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+ >
+ );
+};
+
+export default UploadDesk;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e69de29bb2..0c4b9e6f9b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -0,0 +1,19 @@
+@import "tailwindcss";
+
+@theme {
+ --color-primary: theme(colors.teal.900);
+ --color-light: theme(colors.slate.100);
+ --color-dark: theme(colors.slate.900);
+ --color-accent: theme(colors.red.400);
+
+ --font-logo: "Bungee", cursive;
+ --font-body: "Inter", sans-serif;
+ --font-heading: "Share Tech Mono", monospace;
+}
+/*
+ width: 10px;
+ height: 10px;
+ position: absolute;
+ background-color: red;
+ left: 10%;
+ bottom: 15%; */
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
new file mode 100644
index 0000000000..c9cc412a34
--- /dev/null
+++ b/frontend/src/pages/Dashboard.jsx
@@ -0,0 +1,172 @@
+import { useNavigate } from "react-router-dom";
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { useDeskStore } from "../state/deskStore";
+import { motion } from "framer-motion";
+
+import SideBar from "../components/SideBar";
+
+const apiUrl = "https://desk-forge.onrender.com";
+
+const Dashboard = () => {
+ const { latestDesk, setLatestDesk, clearDesk } = useDeskStore();
+ const [lastLogin, setLastLogin] = useState("");
+ const [previousLogin, setPreviousLogin] = useState("");
+
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const token = localStorage.getItem("token");
+ if (!token) {
+ navigate("/login");
+ return;
+ }
+ }, [navigate]);
+
+ useEffect(() => {
+ const fetchUser = async () => {
+ const token = localStorage.getItem("token");
+ try {
+ const res = await fetch(`${apiUrl}/auth/user`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ if (!res.ok) throw new Error("Failed to fetch user");
+ const data = await res.json();
+ console.log(data);
+
+ setLastLogin(data.user.lastLogin || "");
+ setPreviousLogin(data.user.previousLogin || "");
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ fetchUser();
+ }, []);
+
+ const fetchLatestDesk = async () => {
+ const token = localStorage.getItem("token");
+ try {
+ const response = await fetch(`${apiUrl}/upload/desks`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ if (!response.ok) throw new Error("Failed to fetch desks");
+
+ const data = await response.json();
+
+ if (data.desks && data.desks.length > 0) {
+ setLatestDesk(data.desks[0]);
+ } else {
+ clearDesk();
+ }
+ } catch (error) {
+ console.error(error.message);
+ }
+ };
+
+ useEffect(() => {
+ fetchLatestDesk();
+ }, [setLatestDesk, clearDesk]);
+
+ const formatDate = (dateString) => {
+ if (!dateString) return "Never";
+
+ const date = new Date(dateString);
+ const now = new Date();
+
+ const dateOnly = new Date(
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate()
+ );
+ const nowOnly = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+
+ const diffTime = nowOnly - dateOnly;
+ const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) return "Today";
+ if (diffDays === 1) return "Yesterday";
+ return `${diffDays} days ago`;
+ };
+
+ return (
+
+
+
+
+
+
+ Welcome Back!
+
+
+
+ Last logged in:{" "}
+ {formatDate(previousLogin) || formatDate(lastLogin)}
+
+
+ You last uploaded a desk photo {formatDate(latestDesk?.createdAt)}
+ !
+
+
+
+
+ Summary of Your Latest Desk Setup
+
+
+ {latestDesk?.summary
+ ? latestDesk?.summary
+ : "No summary available."}
+
+
+
+
+ Latest Desk Setup
+
+ {latestDesk?.imageUrl ? (
+
+
+
+ ) : (
+
+ No photos yet, upload a photo to see it here!
+
+ )}
+
+
+
+ Analyse Desk
+
+
+ View Suggestions
+
+
+
+
+
+ );
+};
+
+export default Dashboard;
diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx
new file mode 100644
index 0000000000..1c7d296b11
--- /dev/null
+++ b/frontend/src/pages/Landing.jsx
@@ -0,0 +1,64 @@
+import { Link } from "react-router-dom";
+import { motion } from "framer-motion";
+
+const Landing = () => {
+ return (
+
+
+
+
+ LOGIN
+
+
+
+
+ DeskForge
+
+
+
+
+ REGISTER
+
+
+
+
+
+
+
+ Smarter Desks. Smarter Hobbies.
+
+
+
+ Do you get frustrated whenever you sit down to build or paint your
+ miniatures? Do you wish your hobby desk worked better for you?
+ DeskForge can help with that! Providing you with AI-powered feedback
+ to help you optimise your hobby space! All you have to do is upload
+ a photo of your current desk space. Just make an account and give it
+ a go!
+
+
+
+
+
+
+ );
+};
+
+export default Landing;
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
new file mode 100644
index 0000000000..507c243d10
--- /dev/null
+++ b/frontend/src/pages/Login.jsx
@@ -0,0 +1,143 @@
+// TODO: check if an email doesnt exist as an account, id not, give a message that the accoutn doesnt exist and to register
+
+import { useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { motion } from "framer-motion";
+
+const apiUrl = "https://desk-forge.onrender.com";
+
+const Login = () => {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const navigate = useNavigate();
+
+ const handleLogin = async (e) => {
+ e.preventDefault();
+
+ if (!email || !password) {
+ setError("Email and password are required");
+ return;
+ }
+
+ if (!email.includes("@") || !email.includes(".")) {
+ setError("Please enter a valid email");
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch(`${apiUrl}/auth/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || "Login failed");
+ }
+
+ localStorage.setItem("token", data.token);
+
+ navigate("/dashboard");
+ } catch (error) {
+ setError(error.message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ DeskForge
+
+
+
+
+
+ Log in
+
+
+
+
+
+ Don’t have an account?{" "}
+
+ Register here
+
+
+
+
+ ← Back to Home
+
+
+
+
+ );
+};
+
+export default Login;
diff --git a/frontend/src/pages/NotFound.jsx b/frontend/src/pages/NotFound.jsx
new file mode 100644
index 0000000000..6277337383
--- /dev/null
+++ b/frontend/src/pages/NotFound.jsx
@@ -0,0 +1,3 @@
+const NotFound = () => {};
+
+export default NotFound;
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx
new file mode 100644
index 0000000000..67ad31fad4
--- /dev/null
+++ b/frontend/src/pages/Register.jsx
@@ -0,0 +1,307 @@
+import { useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { motion } from "framer-motion";
+
+const apiUrl = "https://desk-forge.onrender.com";
+
+const Register = () => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const navigate = useNavigate();
+
+ const [formData, setFormData] = useState({
+ name: "",
+ email: "",
+ password: "",
+ confirmPassword: "",
+ });
+
+ const [errors, setErrors] = useState({
+ name: "",
+ email: "",
+ password: "",
+ confirmPassword: "",
+ });
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleRegister = async (e) => {
+ e.preventDefault();
+
+ const trimmedName = formData.name.trim();
+ const trimmedEmail = formData.email.trim();
+ const trimmedPassword = formData.password.trim();
+ const trimmedConfirmPassword = formData.confirmPassword.trim();
+
+ if (
+ !trimmedName ||
+ trimmedName.length < 3 ||
+ !trimmedEmail ||
+ !trimmedPassword ||
+ !trimmedConfirmPassword
+ ) {
+ setErrors(() => ({
+ name:
+ !trimmedName || trimmedName.length < 3
+ ? "Name must be at least 3 characters"
+ : "",
+ email: !trimmedEmail ? "Email is required" : "",
+ password: !trimmedPassword ? "Password is required" : "",
+ confirmPassword: !trimmedConfirmPassword
+ ? "Confirm Password is required"
+ : "",
+ }));
+ return;
+ }
+
+ const emailRegex = /^\S+@\S+\.\S+$/;
+ if (!trimmedEmail) {
+ setErrors((prev) => ({
+ ...prev,
+ email: "Email is required.",
+ }));
+ return;
+ }
+
+ if (!emailRegex.test(trimmedEmail)) {
+ setErrors((prev) => ({
+ ...prev,
+ email: "Please enter a valid email address.",
+ }));
+ return;
+ }
+
+ if (!trimmedPassword) {
+ setErrors((prev) => ({
+ ...prev,
+ password: "Password is required.",
+ }));
+ return;
+ }
+
+ if (!trimmedConfirmPassword) {
+ setErrors((prev) => ({
+ ...prev,
+ confirmPassword: "Confirm Password is required.",
+ }));
+ return;
+ }
+
+ if (trimmedPassword.length < 6) {
+ setErrors((prev) => ({
+ ...prev,
+ password: "Password must be at least 6 characters long.",
+ }));
+ return;
+ }
+
+ if (trimmedConfirmPassword.length < 6) {
+ setErrors((prev) => ({
+ ...prev,
+ confirmPassword: "Confirm password must be at least 6 characters long.",
+ }));
+ return;
+ }
+
+ if (trimmedPassword !== trimmedConfirmPassword) {
+ setErrors((prev) => ({
+ ...prev,
+ confirmPassword: "Passwords do not match",
+ }));
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch(`${apiUrl}/auth/register`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: formData.name,
+ email: formData.email,
+ password: formData.password,
+ }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || "Registration failed");
+ }
+
+ alert("Registration successful!");
+
+ setFormData({
+ name: "",
+ email: "",
+ password: "",
+ confirmPassword: "",
+ });
+
+ navigate("/login");
+
+ setErrors({});
+ setError("");
+ } catch (error) {
+ setError(error.message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ DeskForge
+
+
+
+
+
+ Register
+
+
+
+
+ Already have an account?{" "}
+
+ Log in
+
+
+
+ ← Back to Home
+
+
+
+
+ );
+};
+
+export default Register;
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
new file mode 100644
index 0000000000..621eba32fc
--- /dev/null
+++ b/frontend/src/pages/Settings.jsx
@@ -0,0 +1,303 @@
+import { useState, useEffect } from "react";
+import SideBar from "../components/SideBar";
+import { motion } from "framer-motion";
+
+const apiUrl = "https://desk-forge.onrender.com";
+
+const Settings = () => {
+ const [userName, setUserName] = useState("");
+ const [isEditingName, setIsEditingName] = useState(false);
+ const [newName, setNewName] = useState(userName);
+
+ const [userEmail, setUserEmail] = useState("");
+ const [isEditingEmail, setIsEditingEmail] = useState(false);
+ const [newEmail, setNewEmail] = useState("");
+
+ const [isEditingPassword, setIsEditingPassword] = useState(false);
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ const fetchUser = async () => {
+ const token = localStorage.getItem("token");
+
+ try {
+ const response = await fetch(`${apiUrl}/auth/user`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || "Failed to fetch user name");
+ }
+
+ setUserName(data.user.name);
+ setUserEmail(data.user.email);
+ } catch (error) {
+ setError(error.message);
+ }
+ };
+ fetchUser();
+ }, []);
+
+ const handleNameSave = async () => {
+ const token = localStorage.getItem("token");
+
+ try {
+ const response = await fetch(`${apiUrl}/auth/user`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ name: newName }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || "Failed to update user name");
+ }
+
+ setUserName(newName);
+ setIsEditingName(false);
+ setError("");
+ } catch (error) {
+ setError(error.message);
+ }
+ };
+
+ const handleEmailSave = async () => {
+ const token = localStorage.getItem("token");
+
+ try {
+ const response = await fetch(`${apiUrl}/auth/user`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ email: newEmail }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || "Failed to update user email");
+ }
+
+ setUserEmail(newEmail);
+ setIsEditingEmail(false);
+ setError("");
+ } catch (error) {
+ setError(error.message);
+ }
+ };
+
+ const handlePasswordSave = async () => {
+ const token = localStorage.getItem("token");
+
+ if (newPassword !== confirmPassword) {
+ setError("Passwords do not match");
+ return;
+ }
+
+ try {
+ const response = await fetch(`${apiUrl}/auth/user`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ password: newPassword }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || "Failed to update user password");
+ }
+
+ setNewPassword("");
+ setConfirmPassword("");
+ setIsEditingPassword(false);
+ setError("");
+ } catch (error) {
+ setError(error.message);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Settings
+
+
+
+ Name
+
+ {isEditingName ? (
+ <>
+
+
New Name
+
+ setNewName(e.target.value)}
+ className="px-4 py-2 border-2 text-dark font-body rounded-[5px] mb-2 focus:outline-none focus:ring-2 focus:ring-accent"
+ />
+
+
+
+
+ Save
+
+ setIsEditingName(false)}
+ className="px-4 py-2 bg-accent text-dark text-md rounded font-heading cursor-pointer border-light border-4 shadow-[0_0_0_4px_black] drop-shadow-[3px_3px_0_#1b2a2f] transform hover:translate-x-1 hover:translate-y-1 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-accent"
+ >
+ Cancel
+
+
+ >
+ ) : (
+
+
{userName}
+
setIsEditingName(true)}
+ className="px-4 py-2 bg-accent text-dark text-md rounded font-heading cursor-pointer border-light border-4 shadow-[0_0_0_4px_black] drop-shadow-[3px_3px_0_#1b2a2f] transform hover:translate-x-1 hover:translate-y-1 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-accent"
+ >
+ Change Name
+
+
+ )}
+
+
+ Email
+
+ {isEditingEmail ? (
+ <>
+
+
New Email
+
+ setNewEmail(e.target.value)}
+ className="px-4 py-2 border-2 text-dark font-body rounded-[5px] mb-2 focus:outline-none focus:ring-2 focus:ring-accent"
+ />
+
+
+
+
+ Save
+
+ setIsEditingEmail(false)}
+ className="px-4 py-2 bg-accent text-dark text-md rounded font-heading cursor-pointer border-light border-4 shadow-[0_0_0_4px_black] drop-shadow-[3px_3px_0_#1b2a2f] transform hover:translate-x-1 hover:translate-y-1 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-accent"
+ >
+ Cancel
+
+
+ >
+ ) : (
+
+
{userEmail}
+
setIsEditingEmail(true)}
+ className="px-4 py-2 bg-accent text-dark text-md rounded font-heading cursor-pointer border-light border-4 shadow-[0_0_0_4px_black] drop-shadow-[3px_3px_0_#1b2a2f] transform hover:translate-x-1 hover:translate-y-1 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-accent"
+ >
+ Change Email
+
+
+ )}
+
+
+ Password
+
+ {isEditingPassword ? (
+ <>
+
+
New Password
+
+ setNewPassword(e.target.value)}
+ className="px-4 py-2 border-2 text-dark font-body rounded-[5px] mb-2 focus:outline-none focus:ring-2 focus:ring-accent"
+ />
+
+
+
+
+ Confirm New Password
+
+
+ setConfirmPassword(e.target.value)}
+ className="px-4 py-2 border-2 text-dark font-body rounded-[5px] mb-2 focus:outline-none focus:ring-2 focus:ring-accent"
+ />
+
+
+
+
+ Save
+
+ setIsEditingPassword(false)}
+ className="px-4 py-2 bg-accent text-dark text-md rounded font-heading cursor-pointer border-light border-4 shadow-[0_0_0_4px_black] drop-shadow-[3px_3px_0_#1b2a2f] transform hover:translate-x-1 hover:translate-y-1 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-accent"
+ >
+ Cancel
+
+
+ >
+ ) : (
+
+
********
+
setIsEditingPassword(true)}
+ className="px-4 py-2 bg-accent text-dark text-md rounded font-heading cursor-pointer border-light border-4 shadow-[0_0_0_4px_black] drop-shadow-[3px_3px_0_#1b2a2f] transform hover:translate-x-1 hover:translate-y-1 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-accent"
+ >
+ Change Password
+
+
+ )}
+
+
+
+ );
+};
+
+export default Settings;
diff --git a/frontend/src/pages/Suggestions.jsx b/frontend/src/pages/Suggestions.jsx
new file mode 100644
index 0000000000..d2d563b1e8
--- /dev/null
+++ b/frontend/src/pages/Suggestions.jsx
@@ -0,0 +1,250 @@
+import { useState, useRef, useEffect } from "react";
+import SideBar from "../components/SideBar";
+import { useDeskStore } from "../state/deskStore";
+import { motion } from "framer-motion";
+import { Link } from "react-router-dom";
+
+const apiUrl = "https://desk-forge.onrender.com";
+
+const Suggestions = () => {
+ const { latestDesk, setLatestDesk, clearDesk } = useDeskStore();
+ const [olderDesks, setOlderDesks] = useState([]);
+ const [message, setMessage] = useState("");
+ const [visibleOlderSuggestions, setVisibleOlderSuggestions] = useState(false);
+ const [selectedDate, setSelectedDate] = useState("");
+
+ useEffect(() => {
+ const fetchDesks = async () => {
+ const token = localStorage.getItem("token");
+ try {
+ const response = await fetch(`${apiUrl}/upload/desks`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ if (!response.ok) throw new Error("Failed to fetch desks");
+
+ const data = await response.json();
+
+ if (data.desks && data.desks.length > 0) {
+ setLatestDesk(data.desks[0]);
+ setOlderDesks(data.desks.slice(1));
+ } else {
+ clearDesk();
+ }
+ } catch (error) {
+ setMessage(error.message);
+ }
+ };
+
+ fetchDesks();
+ }, [setLatestDesk, clearDesk]);
+
+ const handleViewOlderSuggestions = () => {
+ setVisibleOlderSuggestions(!visibleOlderSuggestions);
+ };
+
+ const formatDate = (dateString) => {
+ return new Date(dateString).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ };
+
+ const dateFilterOptions = Array.from(
+ new Set(
+ olderDesks.map((desk) => {
+ const date = new Date(desk.createdAt);
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
+ 2,
+ "0"
+ )}`;
+ })
+ )
+ ).sort((a, b) => (a < b ? 1 : -1));
+
+ const filteredOlderDesks = selectedDate
+ ? olderDesks.filter((desk) => {
+ const date = new Date(desk.createdAt);
+ const deskDate = `${date.getFullYear()}-${String(
+ date.getMonth() + 1
+ ).padStart(2, "0")}`;
+ return deskDate === selectedDate;
+ })
+ : olderDesks;
+
+ return (
+
+
+
+
+
+
+ Suggestions
+
+
+
+
+ Latest suggestions:
+
+
+ {latestDesk ? (
+
+
+ Latest desk:{" "}
+
+ {formatDate(latestDesk.createdAt)}
+
+
+
+ {/*
*/}
+
+ setMessage(
+ "Preview failed. Try uploading a smaller JPG or PNG image."
+ )
+ }
+ className="max-w-2xs sm:max-w-xs md:max-w-md lg:max-w-lg border-accent border-4 shadow-[0_0_0_4px_black] drop-shadow-[3px_3px_0_#1b2a2f] rounded-lg"
+ />
+
+
+ {latestDesk.suggestions?.length > 0 ? (
+
+
+ {latestDesk.suggestions.map((suggestion, index) => (
+
+ {suggestion.title} :{" "}
+ {suggestion.description}
+
+ ))}
+
+
+ Regenerate Suggestions
+
+
+ ) : (
+
+
+ No suggestions available for this desk yet...
+
+
+ Generate Suggestions
+
+
+ )}
+
+ ) : (
+
No desk uploaded yet...
+ )}
+
+
+
+ {visibleOlderSuggestions
+ ? "Hide older suggestions"
+ : "Load older suggestions"}
+
+
+
+
+ Older suggestions:
+
+
+
+
+ Filter by date:
+
+ setSelectedDate(e.target.value)}
+ className="font-body border px-2 py-1 rounded"
+ >
+ All
+ {dateFilterOptions.map((my) => (
+
+ {new Date(my + "-01").toLocaleString("default", {
+ month: "long",
+ year: "numeric",
+ })}
+
+ ))}
+
+
+
+ {olderDesks && olderDesks.length > 0 ? (
+ filteredOlderDesks.map((desk, deskIndex) => (
+
+
+ Desk from:{" "}
+
+ {formatDate(desk.createdAt)}
+
+
+
+
+
+
+
+ ))
+ ) : (
+
+ No other desks available, upload a new photo and generate
+ suggestions to get started.
+
+ )}
+
+ {message && (
+
{message}
+ )}
+
+
+
+ );
+};
+
+export default Suggestions;
diff --git a/frontend/src/pages/Upload.jsx b/frontend/src/pages/Upload.jsx
new file mode 100644
index 0000000000..5fb38d260f
--- /dev/null
+++ b/frontend/src/pages/Upload.jsx
@@ -0,0 +1,119 @@
+import { useState, useEffect } from "react";
+import SideBar from "../components/SideBar";
+import UploadDesk from "../components/UploadDesk";
+import LatestDesk from "../components/LatestDesk";
+import { LightBulbIcon } from "@heroicons/react/24/outline";
+import { motion } from "framer-motion";
+
+const Upload = () => {
+ const [popupVisible, setPopupVisible] = useState(false);
+
+ const handlePopupVisibility = () => {
+ setPopupVisible((prev) => !prev);
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.key === "Escape") {
+ setPopupVisible(false);
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
+ return (
+
+
+
+
+
+
+ Analyse Desk Setup
+
+
+
+
+ Need help? Click here
+
+
+
setPopupVisible(false)}
+ >
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ >
+
+ Tips & Requirements
+
+
+
+ You can upload a new desk photo OR you can use an existing
+ desk you have uploaded before, this is displayed below
+
+ When taking a photo:
+
+ Ensure good lighting
+ Avoid cluttered backgrounds
+ Fit the whole desk in frame
+
+ When uploading:
+
+
+ Make sure to only upload accepted image formats: JPG, PNG,
+ GIF
+
+ Stay within the max file size of 5MB
+ Write a clear description of the problem
+
+ When generating from an existing desk:
+
+
+ Once a desk has been uploaded it gets saved in the latest
+ desk section
+
+
+ You can then generate suggestions, regenerate suggestions,
+ add new problems, or delete the desk
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Upload;
diff --git a/frontend/src/state/deskStore.js b/frontend/src/state/deskStore.js
new file mode 100644
index 0000000000..255ac8948f
--- /dev/null
+++ b/frontend/src/state/deskStore.js
@@ -0,0 +1,7 @@
+import { create } from "zustand";
+
+export const useDeskStore = create((set) => ({
+ latestDesk: null,
+ setLatestDesk: (desk) => set({ latestDesk: desk }),
+ clearDesk: () => set({ latestDesk: null }),
+}));
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 5a33944a9b..356897e39c 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,7 +1,8 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()],
-})
+ plugins: [react(), tailwindcss()],
+});
diff --git a/package.json b/package.json
deleted file mode 100644
index 680d190772..0000000000
--- a/package.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "name": "project-final-parent",
- "version": "1.0.0",
- "scripts": {
- "postinstall": "npm install --prefix backend"
- }
-}
\ No newline at end of file