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 ( + + ); +} 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 +

+
+ 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" + /> +
+ + +
+
+ ) : ( +
+

+ {latestDesk.problems || "No problems listed"} +

+ +
+ )} + +
+ + +
+ +
+ {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 ( + <> +