diff --git a/README.md b/README.md index dfa05e177..f32541403 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,70 @@ # Project Auth API -Replace this readme with your own information about your project. +## Description +This project, "Project Authorization Fullstack," is a full-stack application that demonstrates a user authentication system with image upload functionality. The backend is built with Express.js and integrates MongoDB for data persistence, while the frontend is developed using React. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +## Backend - Project Auth Backend + +### Features +- User authentication (login, registration, and logout). +- Image upload with Cloudinary integration. +- CRUD operations for ads. +- User and ads management. + +### Technologies Used +- Node.js, Express.js +- Mongoose for MongoDB integration. +- bcrypt for password hashing. +- JWT for maintaining user sessions. +- multer and Cloudinary for image uploads. + +### Installation +1. Clone the repository. +2. Navigate to the backend directory. +3. Run `npm install` to install dependencies. +4. Create a `.env` file and configure your environment variables (e.g., MongoDB URI, JWT secret, Cloudinary details). + +### Usage +- Use `npm start` to run the server. +- Use `npm run dev` for development mode with hot reload. + +## Frontend + +### Features +- User interface for login, registration, and ad management. +- Responsive design using styled-components. +- State management with Zustand. + +### Technologies Used +- React +- React Router for routing. +- Styled-components for styling. +- Zustand for state management. + +### Installation +1. Clone the repository. +2. Navigate to the frontend directory. +3. Run `npm install` to install dependencies. + +### Usage +- Use `npm run dev` to start the development server. +- Use `npm run build` to create a production build. + +## Common + +### Installation +- Root directory contains common dependencies and post-install scripts. +- Run `npm install` at the root to set up both frontend and backend. + +### Scripts +- `postinstall`: Automatically set up the backend upon installing the root dependencies. ## 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? +If I had more time I would work on the styling and I would create a feature to change/edit a post. Probably I would add the possibility to save a post of someone else and display it under a collection. ## 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. +Backend project deployed at: https://project-authorization-fullstack.onrender.com + +Frontend project deployed at: https://fullstack-auth-project.netlify.app/ diff --git a/backend/config/cloudinaryConfig.js b/backend/config/cloudinaryConfig.js new file mode 100644 index 000000000..9e2eda871 --- /dev/null +++ b/backend/config/cloudinaryConfig.js @@ -0,0 +1,13 @@ +import cloudinaryFramework from 'cloudinary'; +import dotenv from 'dotenv'; + +dotenv.config(); + +// Correct the usage here +cloudinaryFramework.v2.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET +}); + +export default cloudinaryFramework.v2; diff --git a/backend/config/db.js b/backend/config/db.js new file mode 100644 index 000000000..704f67c86 --- /dev/null +++ b/backend/config/db.js @@ -0,0 +1,24 @@ +import mongoose from "mongoose"; +import dotenv from "dotenv"; + +// Load environment variables from the .env file +dotenv.config(); + +// Define an asynchronous function 'connectDB' to connect to the MongoDB database +export const connectDB = async () => { + try { + // Attempt to connect to the MongoDB database using the URL from the environment variables + // Mongoose Method: mongoose.connect() + // Description: This line of code serves the crucial purpose of connecting the Node.js application to the MongoDB database specified by the URL provided in the environment variable MONGO_URL. Once this connection is established, the application can perform various database operations, such as querying and modifying data in the MongoDB database. It's a critical step in setting up the database connection for the application to work with MongoDB. + const conn = await mongoose.connect(process.env.MONGO_URL); + + // If the connection is successful, log a message indicating that the MongoDB is connected + console.log(`Mongo DB Connected: ${conn.connection.host}`); + } catch (error) { + // If an error occurs during the connection attempt, log the error message + console.log(error); + + // Exit the Node.js process with an exit code of 1 to indicate an error + process.exit(1); + } + }; \ No newline at end of file diff --git a/backend/controllers/adController.js b/backend/controllers/adController.js new file mode 100644 index 000000000..16e745ecd --- /dev/null +++ b/backend/controllers/adController.js @@ -0,0 +1,170 @@ +import { AdModel } from "../models/AdModel"; +//asyncHandler: We use asyncHandler to simplify error handling in asynchronous code. It helps us avoid writing repetitive try-catch blocks by automatically catching errors and passing them to our error handling middleware. This makes our code cleaner and more readable, reducing the risk of unhandled exceptions that could crash the server. +import asyncHandler from "express-async-handler"; +// We need to import the userModel to check for the famous accesstoken +import { UserModel } from "../models/UserModel"; +// Import cloudinary configuration +import cloudinary from "../config/cloudinaryConfig"; + +// desciption: Get Ads +// route: /getAllAds +// access: Private +export const getAllAdsController = asyncHandler(async (req, res) => { + const userStorage = req.user; + const allAds = await AdModel.find().populate("user", "username"); + res.status(200).json(allAds); +}); + + +// desciption: Get Ads +// route: /getAds +// access: Private +export const getAdsController = asyncHandler(async (req, res) => { + const userStorage = req.user; + const ads = await AdModel.find({ user: userStorage }) + .sort("-createdAt") + .populate("user", "username"); // Populate the user field + + res.json(ads); +}); + +// desciption: POST Ad +// route: /add +// access: Private +export const createAdController = asyncHandler(async (req, res) => { + try { + console.log("Request body:", req.body); // Log the entire request body + console.log("req.file", req.file); + const { brand, model } = req.body; + const accessToken = req.header("Authorization"); + const userFromStorage = await UserModel.findOne({ accessToken }); + + if (!userFromStorage) { + return res.status(401).json({ message: "Unauthorized: User not found." }); + } + + if (!req.file) { + return res.status(400).json({ message: "No image file provided." }); + } + + let imageUrl, imageId; + try { + // Upload the image file to Cloudinary + const result = await cloudinary.uploader.upload(req.file.path); + imageUrl = result.url; + imageId = result.public_id; // or use req.file.filename for filename + + } catch (uploadError) { + console.error('Cloudinary Upload Error:', uploadError); + return res.status(500).json({ message: "Error uploading image to Cloudinary.", error: uploadError }); + } + + // Define and save new AD + const newAd = new AdModel({ + brand, + model, + image: imageUrl, + imageId: imageId, + user: userFromStorage, + }); + + const savedAd = await newAd.save(); + res.json(savedAd); + } catch (error) { + console.error(error); // Log the detailed error + res.status(500).json({ message: "Internal server error", error }); + } +}); + +// desciption: PUT/PATCH a specific AD +// route: /update/:id +// access: Private +export const updateAdController = asyncHandler(async (req, res) => { + const { id } = req.params; + const updateData = req.body; // This contains the fields to be updated + + // Optionally, if you're updating the image, handle the image file upload and get the new image URL and ID + if (req.file) { + try { + // Upload the new image file to Cloudinary + const result = await cloudinary.uploader.upload(req.file.path); + updateData.image = result.url; + updateData.imageId = result.public_id; + } catch (uploadError) { + console.error('Cloudinary Upload Error:', uploadError); + return res.status(500).json({ message: "Error uploading new image to Cloudinary.", error: uploadError }); + } + } + + // Make sure to check that the user making the update is the owner of the ad + const userFromStorage = await UserModel.findOne({ accessToken: req.header("Authorization") }); + if (!userFromStorage) { + return res.status(401).json({ message: "Unauthorized: User not found." }); + } + + // Update the ad with the new data + AdModel.findByIdAndUpdate(id, updateData, { new: true }) // {new: true} will return the updated document + .then((updatedAd) => { + if (!updatedAd) { + return res.status(404).json({ message: "Ad not found." }); + } + res.json(updatedAd); + }) + .catch((err) => res.status(500).json({ message: "Error updating ad.", error: err })); +}); + + +// desciption: DELETE all ads +// route: /deleteAll +// access: Private +export const deleteAllAdsController = asyncHandler(async (req, res) => { + const accessToken = req.header("Authorization"); + + const userFromStorage = await UserModel.findOne({ accessToken }); + if (!userFromStorage) { + return res.status(401).json({ message: "Unauthorized: User not found." }); + } + + // Find all ads for the user + const ads = await AdModel.find({ user: userFromStorage }); + + // Iterate over all ads and delete associated images from Cloudinary + for (const ad of ads) { + await cloudinary.uploader.destroy(ad.imageId); + } + + // After all images are deleted, delete the ads from the database + const result = await AdModel.deleteMany({ user: userFromStorage }); + res.json({ + message: "All ads and associated images deleted", + deletedCount: result.deletedCount, + }); +}); + +// desciption: DELETE AD by its ID +// route: /delete/:id +// access: Private +export const deleteSpecificAdController = asyncHandler(async (req, res) => { + const { id } = req.params; + + const ad = await AdModel.findById(id); + if (!ad) { + return res.status(404).json({ message: "Ad not found" }); + } + + try { + // Delete the image from Cloudinary using the imageId + await cloudinary.uploader.destroy(ad.imageId); + + // Then delete the ad from the database + const result = await AdModel.findByIdAndDelete(id); + res.json({ + message: "Ad and associated image deleted successfully", + deletedAd: result, + }); + } catch (err) { + console.error('Error during ad deletion:', err); + res.status(500).json({ message: "Failed to delete ad and/or image", error: err }); + } +}); + diff --git a/backend/controllers/userController.js b/backend/controllers/userController.js new file mode 100644 index 000000000..a12311c39 --- /dev/null +++ b/backend/controllers/userController.js @@ -0,0 +1,92 @@ +import { UserModel } from "../models/UserModel"; +import asyncHandler from "express-async-handler"; +import bcrypt from "bcrypt"; + +export const showAllUsersController = asyncHandler(async (req, res) => { + const users = await UserModel.find(); + res.status(200).json(users); +}); + +//Set up a route to handle user registration (Sign-up) +export const registerUserController = asyncHandler(async (req, res) => { + + const { username, password, email } = req.body; //defines what to request from the body + + try { + if (!username || !email || !password) { + res.status(400); + throw new Error("Please fill in all fields"); //error message shown on the server side + } + + const existingUser = await UserModel.findOne({ + $or: [{ username }, { email }], + }); + if (existingUser) { + res.status(400); + throw new Error( + `User with ${existingUser.username === username ? "username" : "email" + } already exists` + ); + } + + const salt = bcrypt.genSaltSync(10); //Add extra layers of security + + const hashedPassword = bcrypt.hashSync(password, salt); + + //create a new user instance with the hashed password + const newUser = new UserModel({ + username, + email, + password: hashedPassword, //passes the variable with the encrypted password + }); + + await newUser.save(); //Mongoose method to save the new user instance to the database + + // Respond with a success message, user details, and the JWT token + res.status(201).json({ + success: true, + response: { + username: newUser.username, + email: newUser.email, + id: newUser._id, + accessToken: newUser.accessToken, + }, + }); + } catch (err) { + // Handle any errors that occur during the registration process + res.status(500).json({ success: false, response: err.message }); + } +}); + +//Set up a route for logging in +export const loginUserController = asyncHandler(async (req, res) => { + const { username, password } = req.body; + + try { + const user = await UserModel.findOne({ username }); + if (!user) { + return res + .status(401) + .json({ success: false, response: "User not found" }); + } + + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res + .status(401) + .json({ success: false, response: "Incorrect Password" }); + + } res.status(200).json({ + success: true, + response: { + username: user.username, + id: user._id, + accessToken: user.accessToken, + } + }); + } catch (err) { + // Handle any errors that occur during the login process + res.status(500).json({ success: false, response: err.message }); + } +}); + diff --git a/backend/middlewares/authenticateUser.js b/backend/middlewares/authenticateUser.js new file mode 100644 index 000000000..6bdc9e42f --- /dev/null +++ b/backend/middlewares/authenticateUser.js @@ -0,0 +1,22 @@ +//Checks and verifies requests, responses and next +import { UserModel } from "../models/UserModel"; + +export const authenticateUser = async (req, res, next) => { + //retrieve the access token from the request header (why is it sometimes header and not body?) + const accessToken = req.header("Authorization"); + console.log("Access Token:", accessToken); + try { + const user = await UserModel.findOne({ accessToken: accessToken }); + console.log("Retrieved User:", user); + if (user) { + req.user = user; // Store the user in the request for later use + next(); //Continue to the next middleware route + } else { + //if user not found show message + res.status(401).json({ success: false, response: "Please log in" }); + } + } catch (e) { + // handle erros that occur during the db query or authentication + res.status(500).json({ success: false, response: e.message }); + } +}; \ No newline at end of file diff --git a/backend/middlewares/imageUpload.js b/backend/middlewares/imageUpload.js new file mode 100644 index 000000000..ecd37c3d4 --- /dev/null +++ b/backend/middlewares/imageUpload.js @@ -0,0 +1,16 @@ +import multer from "multer"; +import { CloudinaryStorage } from "multer-storage-cloudinary"; +import cloudinary from "../config/cloudinaryConfig.js"; + +const storage = new CloudinaryStorage({ + cloudinary, + params: { + folder: 'sneakers', + allowedFormats: ['jpg', 'png'], + transformation: [{ width: 500, height: 500, crop: 'limit' }], + }, +}) + +const parser = multer({ storage }); + +export default parser; diff --git a/backend/middlewares/validateInputData.js b/backend/middlewares/validateInputData.js new file mode 100644 index 000000000..8656ed5d9 --- /dev/null +++ b/backend/middlewares/validateInputData.js @@ -0,0 +1,26 @@ +import { AdModel } from "../models/AdModel"; + +// Middleware function for validating ad data +export const validateInputData = (req, res, next) => { + const { brand, imageUrl, size, model, price } = req.body; + + // Validate the incoming data against the schema + const newAd = new AdModel({ + brand, + imageUrl, + size, + model, + price, + user: req.user._id, // Assuming you have the user ID in the request object after authentication + }); + + // Validate the ad data synchronously + const validationError = newAd.validateSync(); + + if (validationError) { + return res.status(400).json({ error: validationError.message }); // Send validation error + } + + req.newAd = newAd; // Attach validated ad object to the request + next(); // Move to the next middleware or route handler +}; \ No newline at end of file diff --git a/backend/models/AdModel.js b/backend/models/AdModel.js new file mode 100644 index 000000000..a0133f606 --- /dev/null +++ b/backend/models/AdModel.js @@ -0,0 +1,42 @@ +import mongoose from "mongoose"; + +// Import the Schema class from the Mongoose library +// Destructures the Schema class from the Mongoose library, allowing us to create a schema. +const { Schema } = mongoose; + +// Create a new Mongoose schema named 'adSchema' +// Creates a new Mongoose schema named adSchema that defines the structure of a document in the MongoDB collection. It includes fields like brand, createdAt, and done, specifying their data types, validation rules, and default values. +export const adSchema = new Schema( + { + brand: { + type: String, + required: true, + minlength: 2, + }, + model: { + type: String, + required: true, + }, + image: { + type: String, // Store URL for the image + required: true + }, + imageId: { + type: String, // Store the unique identifier for the image + required: true + }, + // Define the relaitonship between the user and his/her ad -- 1:1 relationship with the user or 1 usar can have many ads + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + }, + { + timestamps: true, + } +); + +// Create a Mongoose model named 'AdModel' based on the 'adSchema' for the 'ads' collection +// This model is used to interact with the "ads" collection in the MongoDB database. It allows you to perform CRUD operations on documents in that collection and provides methods for data validation based on the schema. +export const AdModel = mongoose.model("Ad", adSchema); + diff --git a/backend/models/UserModel.js b/backend/models/UserModel.js new file mode 100644 index 000000000..9f709ac5c --- /dev/null +++ b/backend/models/UserModel.js @@ -0,0 +1,36 @@ +import mongoose from "mongoose"; +import crypto from "crypto"; + +const { Schema } = mongoose; //destructures the Schema class from Mongoose to create a Schema + +//Below Schema defines the structure of the user document in the MongoDB collection +export const userSchema = new Schema( + { + username: { + type: String, + required: true, + unique: true, + minlength: 2, + }, + password: { + type: String, + required: true, + minlength: 6, + }, + email: { + type: String, + required: true, + unique: true, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex"), + }, + }, + // Add timestamp to tell when the user object is created + { + timestamps: true, //always outside of the initial object you create in the schema + } +); + +export const UserModel = mongoose.model("User", userSchema); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 8de5c4ce0..5939d3d37 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,20 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^5.1.1", + "body-parser": "^1.20.2", + "cloudinary": "^1.41.1", "cors": "^2.8.5", + "crypto": "^1.0.1", + "dotenv": "^16.3.1", "express": "^4.17.3", - "mongoose": "^8.0.0", + "express-async-handler": "^1.2.0", + "express-list-endpoints": "^6.0.0", + "jsonwebtoken": "^9.0.2", + "mongodb": "^6.3.0", + "mongoose": "^8.0.3", + "multer": "^1.4.5-lts.1", + "multer-storage-cloudinary": "^4.0.0", "nodemon": "^3.0.1" } } diff --git a/backend/routes/adRoutes.js b/backend/routes/adRoutes.js new file mode 100644 index 000000000..4d22d2188 --- /dev/null +++ b/backend/routes/adRoutes.js @@ -0,0 +1,39 @@ +// Import the necessary modules and functions +import express from "express"; +import { authenticateUser } from "../middlewares/authenticateUser"; // Import middleware for user authentication +//import { validateInputData } from "../middlewares/validateInputData"; +import parser from "../middlewares/imageUpload"; // Import the parser middleware for image upload + +import { + getAllAdsController, + getAdsController, + updateAdController, + deleteAllAdsController, + deleteSpecificAdController, + createAdController, +} from "../controllers/adController"; // Import controller functions for ads + +// Create an instance of the Express router +const router = express.Router(); + +// Define a route for handling GET requests to retrieve all ads +router.get("/getAllAds", getAllAdsController); // When a GET request is made to /get, authenticate the user using middleware and then execute the getAdsController function + +// Define a route for handling GET requests to retrieve all ads +router.get("/getAds", authenticateUser, getAdsController); // When a GET request is made to /get, authenticate the user using middleware and then execute the getAdsController function + +// Define a route for handling PUT requests to update a specific ad by ID +router.put("/update/:id", updateAdController); // When a PUT request is made to /update/:id, execute the updateAdController function + +// Define a route for handling DELETE requests to delete all ads +router.delete("/deleteAll", deleteAllAdsController); // When a DELETE request is made to /deleteAll, execute the deleteAllAdsController function + +// Define a route for handling DELETE requests to delete a specific ads by ID +router.delete("/delete/:id", deleteSpecificAdController); // When a DELETE request is made to /delete/:id, execute the deleteSpecificAdController function + +// Define a route for handling POST requests to add a new AD +router.post("/createAd", authenticateUser, parser.single('image'), createAdController); + +// Export the router for use in the main application +export default router; + diff --git a/backend/routes/imageRoute.js b/backend/routes/imageRoute.js new file mode 100644 index 000000000..cf243daaa --- /dev/null +++ b/backend/routes/imageRoute.js @@ -0,0 +1,44 @@ +import { ImageModel } from "../models/ImageModel"; +import express from "express"; +//import { authenticateUser } from "../middlewares/authenticateUser"; + +// Create an instance of the Express router +const router = express.Router(); + +//GET +router.get("/getimage", (req, res) => { + try { + ImageModel.find({}).then(data => { + res.json(data) + }).catch(error => { + res.status(408).json({error}) + }) + } catch (error) { + res.json({ error }) + } +}) + + +router.post("/imageupload", async (req, res) => { + console.log("Request body:", req.body); // Debugging + + try { + const { imageData } = req.body; + + if (!imageData) { + return res.status(400).json({ error: 'Required data missing.' }); + } + + const newImage = await ImageModel.create({ imageData }); + await newImage.save(); + res.status(201).json({ message: "Image successfully uploaded" }); + } catch (error) { + console.error(error); // Log the detailed error + res.status(409).json({ message: error.message }); + } +}); + + + +// Export the router for use in the main application +export default router; \ No newline at end of file diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 000000000..d457a5100 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,31 @@ +import express from "express"; + +import { + showAllUsersController, + registerUserController, + loginUserController, +} from "../controllers/userController"; + +// Create an instance of the Express router +const router = express.Router(); + +// SHOW USERS: show all users +router.get( + "/users", + showAllUsersController +); + +// REGISTER ROUTE: Handle user registration +router.post( + "/register", + registerUserController +); + +// LOGIN ROUTE: Handle user login +router.post( + "/login", + loginUserController +); + +// Export the router for use in the main application +export default router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 2d7ae8aa1..293a4ceef 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,27 +1,27 @@ import express from "express"; import cors from "cors"; -import mongoose from "mongoose"; +import dotenv from "dotenv"; +import listEndpoints from 'express-list-endpoints'; +import { connectDB } from "./config/db"; +import userRoutes from "./routes/userRoutes"; +import adRoutes from "./routes/adRoutes"; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/project-mongo"; -mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }); -mongoose.Promise = Promise; - -// Defines the port the app will run on. Defaults to 8080, but can be overridden -// when starting the server. Example command to overwrite PORT env variable value: -// PORT=9000 npm start -const port = process.env.PORT || 8080; +dotenv.config(); const app = express(); +const port = process.env.PORT; + +app.use(cors()); // Enable CORS (Cross-Origin Resource Sharing) +app.use(express.json()); // Parse incoming JSON data +app.use(express.urlencoded({ extended: false })); // Parse URL-encoded data -// Add middlewares to enable cors and json body parsing -app.use(cors()); -app.use(express.json()); +app.use(adRoutes); +app.use(userRoutes); -// Start defining your routes here app.get("/", (req, res) => { - res.send("Hello Technigo!"); + res.json(listEndpoints(app)); }); -// Start the server +connectDB(); app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); + console.log(`Server running on http://localhost:${port}`); }); diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 4dcb43901..88cbbaf2d 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -2,19 +2,20 @@ module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:react/jsx-runtime', - 'plugin:react-hooks/recommended', + "eslint:recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, - settings: { react: { version: '18.2' } }, - plugins: ['react-refresh'], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + settings: { react: { version: "18.2" } }, + plugins: ["react-refresh"], rules: { - 'react-refresh/only-export-components': [ - 'warn', + "react/prop-types": "off", + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], }, -} +}; \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 0c589eccd..0b6fa57bd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - Vite + React + Share Your Sneakers
diff --git a/frontend/package.json b/frontend/package.json index e9c95b79f..e2b86fe58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,10 @@ }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.1", + "styled-components": "^6.1.1", + "zustand": "^4.4.7" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/public/icons/sneaker-favicon.svg b/frontend/public/icons/sneaker-favicon.svg new file mode 100644 index 000000000..a7571a87a --- /dev/null +++ b/frontend/public/icons/sneaker-favicon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/icons/sneaks-logo.png b/frontend/public/icons/sneaks-logo.png new file mode 100644 index 000000000..b50cbd0f0 Binary files /dev/null and b/frontend/public/icons/sneaks-logo.png differ diff --git a/frontend/public/photos/ad-1.jpg b/frontend/public/photos/ad-1.jpg new file mode 100644 index 000000000..e4c8b701c Binary files /dev/null and b/frontend/public/photos/ad-1.jpg differ diff --git a/frontend/public/photos/ad-10.jpg b/frontend/public/photos/ad-10.jpg new file mode 100644 index 000000000..43bfb2cfb Binary files /dev/null and b/frontend/public/photos/ad-10.jpg differ diff --git a/frontend/public/photos/ad-2.jpg b/frontend/public/photos/ad-2.jpg new file mode 100644 index 000000000..44a3ebc27 Binary files /dev/null and b/frontend/public/photos/ad-2.jpg differ diff --git a/frontend/public/photos/ad-3.jpg b/frontend/public/photos/ad-3.jpg new file mode 100644 index 000000000..e9577568e Binary files /dev/null and b/frontend/public/photos/ad-3.jpg differ diff --git a/frontend/public/photos/ad-4.jpg b/frontend/public/photos/ad-4.jpg new file mode 100644 index 000000000..8dc4405c8 Binary files /dev/null and b/frontend/public/photos/ad-4.jpg differ diff --git a/frontend/public/photos/ad-5.jpg b/frontend/public/photos/ad-5.jpg new file mode 100644 index 000000000..d63496d83 Binary files /dev/null and b/frontend/public/photos/ad-5.jpg differ diff --git a/frontend/public/photos/ad-6.jpg b/frontend/public/photos/ad-6.jpg new file mode 100644 index 000000000..7f67afe2f Binary files /dev/null and b/frontend/public/photos/ad-6.jpg differ diff --git a/frontend/public/photos/ad-7.jpg b/frontend/public/photos/ad-7.jpg new file mode 100644 index 000000000..e6897c6c8 Binary files /dev/null and b/frontend/public/photos/ad-7.jpg differ diff --git a/frontend/public/photos/ad-8.jpg b/frontend/public/photos/ad-8.jpg new file mode 100644 index 000000000..880bc110d Binary files /dev/null and b/frontend/public/photos/ad-8.jpg differ diff --git a/frontend/public/photos/ad-9.jpg b/frontend/public/photos/ad-9.jpg new file mode 100644 index 000000000..f4be81834 Binary files /dev/null and b/frontend/public/photos/ad-9.jpg differ diff --git a/frontend/public/photos/mike-von-NnLj_jd6p7k-unsplash.jpg b/frontend/public/photos/mike-von-NnLj_jd6p7k-unsplash.jpg new file mode 100644 index 000000000..15224e02e Binary files /dev/null and b/frontend/public/photos/mike-von-NnLj_jd6p7k-unsplash.jpg differ diff --git a/frontend/public/photos/sneaker-1.jpg b/frontend/public/photos/sneaker-1.jpg new file mode 100644 index 000000000..57b39a801 Binary files /dev/null and b/frontend/public/photos/sneaker-1.jpg differ diff --git a/frontend/public/photos/sneaker-2.jpg b/frontend/public/photos/sneaker-2.jpg new file mode 100644 index 000000000..165376a52 Binary files /dev/null and b/frontend/public/photos/sneaker-2.jpg differ diff --git a/frontend/public/photos/sneaker-3.jpg b/frontend/public/photos/sneaker-3.jpg new file mode 100644 index 000000000..c0c1be834 Binary files /dev/null and b/frontend/public/photos/sneaker-3.jpg differ diff --git a/frontend/public/photos/sneaker-4.jpg b/frontend/public/photos/sneaker-4.jpg new file mode 100644 index 000000000..1688ef67c Binary files /dev/null and b/frontend/public/photos/sneaker-4.jpg differ diff --git a/frontend/public/photos/sneaker-5.jpg b/frontend/public/photos/sneaker-5.jpg new file mode 100644 index 000000000..8fb258d67 Binary files /dev/null and b/frontend/public/photos/sneaker-5.jpg differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1091d4310..13d9aa10f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,3 +1,16 @@ +import { BrowserRouter, Routes } from "react-router-dom"; +import { routes } from "./routes/routes"; +import "./index.css"; + export const App = () => { - return
Find me in src/app.jsx!
; -}; + return ( + <> + +
+ {/* {routes} */} + {routes} +
+
+ + ); +}; \ No newline at end of file diff --git a/frontend/src/assets/upload.png b/frontend/src/assets/upload.png new file mode 100644 index 000000000..0dcdf70d8 Binary files /dev/null and b/frontend/src/assets/upload.png differ diff --git a/frontend/src/components/AdCard.jsx b/frontend/src/components/AdCard.jsx new file mode 100644 index 000000000..c31eea9d4 --- /dev/null +++ b/frontend/src/components/AdCard.jsx @@ -0,0 +1,11 @@ +export const AdCard = ({ ad }) => { + + return ( +
+ {`${ad.brand} +

{ad.brand} - {ad.model}

+

Posted by: {ad.user.username || 'Unknown'}

+
+ ); + }; + \ No newline at end of file diff --git a/frontend/src/components/AdsList.jsx b/frontend/src/components/AdsList.jsx new file mode 100644 index 000000000..10a11ea49 --- /dev/null +++ b/frontend/src/components/AdsList.jsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; +import { adStore } from "../stores/adStore"; +import { AdCard } from "../components/AdCard"; +import { Button } from "./reusableComponents/Button"; + +export const AdsList = ({ showUserAdsOnly = false }) => { + const [isLoading, setIsLoading] = useState(false); + const { ads, getAllAds, fetchAds, deleteAllAds, deleteAdById } = adStore(); + + useEffect(() => { + const fetchAdsData = async () => { + setIsLoading(true); + if (showUserAdsOnly) { + await fetchAds(); // Fetch user-specific ads + } else { + await getAllAds(); // Fetch all ads + } + setIsLoading(false); + }; + + fetchAdsData(); + }, [getAllAds, fetchAds, showUserAdsOnly]); + + return ( +
+ {showUserAdsOnly && ( +
+ ))} + {isLoading &&

Loading...

} + + ); +}; diff --git a/frontend/src/components/CreateAd.jsx b/frontend/src/components/CreateAd.jsx new file mode 100644 index 000000000..2f4d45496 --- /dev/null +++ b/frontend/src/components/CreateAd.jsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { adStore } from "../stores/adStore"; + +export const CreateAd = () => { + const [brand, setBrand] = useState(''); + const [model, setModel] = useState(''); + const [image, setImage] = useState(null); + + const { createAd } = adStore(); + + const handleSubmit = async (e) => { + e.preventDefault(); + + // Basic validation + if (!brand || !model || !image) { + alert("All fields are required"); + return; + } + + await createAd({ brand, model }, image); + + // Reset form fields after submission + setBrand(''); + setModel(''); + setImage(null); + }; + + return ( +
+
+ + setBrand(e.target.value)} /> +
+
+ + setModel(e.target.value)} /> +
+
+ + { + setImage(e.target.files[0]); + console.log(e.target.files[0]); // Log the file object here + }} + />
+ +
+ ); +}; + + diff --git a/frontend/src/components/ImageSwapper.jsx b/frontend/src/components/ImageSwapper.jsx new file mode 100644 index 000000000..83a786c25 --- /dev/null +++ b/frontend/src/components/ImageSwapper.jsx @@ -0,0 +1,73 @@ +import { useState, useEffect } from "react"; +import styled from "styled-components"; + +const ImageWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; // Center vertically + text-align: center; // Center the text elements, like h1 + width: 100%; // Take full width of the container + margin-bottom: 50px; + + h1 { + margin-top: 30px; + } + + .image-wrapper { + width: 100%; // Take full width for the image container + display: flex; + justify-content: center; // Center the image horizontally within the container + } + + img { + max-width: 90%; + height: auto; + margin: auto; // Center the image within the .image-wrapper + border-radius: 10px; + } + + @media (min-width: 768px) { + h1 { + margin-top: 80px; + margin-bottom: 40px; + } + + img { + max-width: 80%; // Limit the size on larger screens + } + } +`; + + +export const ImageSwapper = () => { + const [currentImage, setCurrentImage] = useState(0); + const images = [ + "/photos/sneaker-1.jpg", + "/photos/sneaker-2.jpg", + "/photos/sneaker-3.jpg", + "/photos/sneaker-4.jpg", + "/photos/sneaker-5.jpg" + ]; + + useEffect(() => { + const interval = setInterval(() => { + setCurrentImage((prevImage) => (prevImage + 1) % images.length); + }, 5000); + + return () => clearInterval(interval); + }, [images.length]); + + return ( + <> + +

Ready to share your sneakers?

+
+ {`Image +
+
+ + ); +}; + + diff --git a/frontend/src/components/reusableComponents/BackButton.jsx b/frontend/src/components/reusableComponents/BackButton.jsx new file mode 100644 index 000000000..581deee2a --- /dev/null +++ b/frontend/src/components/reusableComponents/BackButton.jsx @@ -0,0 +1,12 @@ +// This component represents a "Go Back" button to navigate back to the list of movies. It's a reusable component. +import { Link } from 'react-router-dom'; + +export function BackButton({ redirectTo }) { + return ( +
+ + Go Back + +
+ ); +} diff --git a/frontend/src/components/reusableComponents/Button.jsx b/frontend/src/components/reusableComponents/Button.jsx new file mode 100644 index 000000000..e6c4c7c7a --- /dev/null +++ b/frontend/src/components/reusableComponents/Button.jsx @@ -0,0 +1,24 @@ +//import './button.css' + +export const Button = ({ icon, label, link, className, onClick, ariaLabel }) => { + const handleClick = () => { + window.open(link, '_blank'); + }; + + return ( + + ); + }; + \ No newline at end of file diff --git a/frontend/src/components/reusableComponents/ErrorMessage.css b/frontend/src/components/reusableComponents/ErrorMessage.css new file mode 100644 index 000000000..f1972f0d7 --- /dev/null +++ b/frontend/src/components/reusableComponents/ErrorMessage.css @@ -0,0 +1,10 @@ +.error-message { + color: red; + background-color: #ffecec; + border: 1px solid red; + padding: 10px; + margin: 10px 0; + border-radius: 5px; + font-size: 0.9rem; + } + \ No newline at end of file diff --git a/frontend/src/components/reusableComponents/ErrorMessage.jsx b/frontend/src/components/reusableComponents/ErrorMessage.jsx new file mode 100644 index 000000000..f51fb000c --- /dev/null +++ b/frontend/src/components/reusableComponents/ErrorMessage.jsx @@ -0,0 +1,10 @@ +import './ErrorMessage.css'; + +export const ErrorMessage = ({ message }) => { + return ( +
+ {message} +
+ ); +}; + diff --git a/frontend/src/components/reusableComponents/Heading.jsx b/frontend/src/components/reusableComponents/Heading.jsx new file mode 100644 index 000000000..7e15e3a83 --- /dev/null +++ b/frontend/src/components/reusableComponents/Heading.jsx @@ -0,0 +1,28 @@ +// Heading component definition +export const Heading = ({ level, text, className, onClick, style }) => { + const Tag = `h${level}`; + return ( + + {text} + + ); +}; + +// Define default props +Heading.defaultProps = { + level: 1, // Default to h1 + className: '', + onClick: () => {}, // Default to a no-operation function + style: {}, // Default to an empty style object +}; + +// Example Usage +/* + console.log("Heading clicked")} + style={{ cursor: 'pointer' }} +/> +*/ diff --git a/frontend/src/components/reusableComponents/Image.jsx b/frontend/src/components/reusableComponents/Image.jsx new file mode 100644 index 000000000..5733e912f --- /dev/null +++ b/frontend/src/components/reusableComponents/Image.jsx @@ -0,0 +1,9 @@ +//import "./image.css"; + +export const Image = ({ sectionClassName, elementClassName, link, ImageAltText }) => { + return ( +
+ {ImageAltText} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/reusableComponents/Navigation/Burger.jsx b/frontend/src/components/reusableComponents/Navigation/Burger.jsx new file mode 100644 index 000000000..8d12a872a --- /dev/null +++ b/frontend/src/components/reusableComponents/Navigation/Burger.jsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import styled from "styled-components"; +import { RightNav } from "./RightNav"; + +const StyledBurger = styled.div` + width: 2rem; + height: 2rem; + position: fixed; + top: 20px; + right: 25px; + z-index: 20; + display: none; + + @media (max-width: 768px) { + display: flex; + justify-content: space-around; + flex-flow: column nowrap; + } + + div { + width: 2rem; + height: 0.25rem; + background-color: ${({ open }) => open ? "#ccc" : "#333"}; + border-radius: 10px; + transform-origin: 1px; + transition: all 0.3 linear; + + &:nth-child(1) { + transform: ${({ open }) => open ? "rotate(45deg)" : "rotate(0)"}; + } + + &:nth-child(2) { + transform: ${({ open }) => open ? "translateX(100%)" : "translateX(0)"}; + opacity: ${({ open }) => open ? 0 : 1}; + } + + &:nth-child(3) { + transform: ${({ open }) => open ? "rotate(-45deg)" : "rotate(0)"}; + } + } +` + +export const Burger = ({ isLoggedIn, toggleDarkMode, handleLogout }) => { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(!open)}> +
+
+
+ + + + ); +}; diff --git a/frontend/src/components/reusableComponents/Navigation/NavBar.jsx b/frontend/src/components/reusableComponents/Navigation/NavBar.jsx new file mode 100644 index 000000000..abd0ebc55 --- /dev/null +++ b/frontend/src/components/reusableComponents/Navigation/NavBar.jsx @@ -0,0 +1,30 @@ +import styled from "styled-components"; +import { Burger } from "./Burger"; + +const Nav = styled.nav` + width: 100%; + height: 55px; + padding: 20px 20px; + display: flex; + justify-content: space-between; + + .logo { + padding: 15px 20px; + } + + .logo img { + width: 150px; + } +` + + +export const Navbar = ({ isLoggedIn, toggleDarkMode, handleLogout }) => { + return ( + + ); +}; diff --git a/frontend/src/components/reusableComponents/Navigation/RightNav.jsx b/frontend/src/components/reusableComponents/Navigation/RightNav.jsx new file mode 100644 index 000000000..8fd32834a --- /dev/null +++ b/frontend/src/components/reusableComponents/Navigation/RightNav.jsx @@ -0,0 +1,73 @@ +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +// Styling for RouterLink +const StyledRouterLink = styled(RouterLink)` + text-decoration: none; + font-weight: 600; + color: inherit; // Inherit color from parent +`; + +const Ul = styled.ul` + list-style: none; + display: flex; + flex-flow: row nowrap; + + li { + padding: 18px 20px; + } + + li a { + font-size: 20px; + text-decoration: none; + font-weight: 600; + color: inherit; // Inherit color from parent + } + + @media (max-width: 768px) { + flex-flow: column nowrap; + background-color: #FFF; // Menu Background Color + position: fixed; + transform: ${({ open }) => open ? 'translateX(0)' : 'translateX(100%)'}; + top: 0; + right: 0; + height: 100vh; + width: 300px; + padding-top: 3.5rem; + transition: transform 0.3s ease-in-out; + + li { + color: #333; // Text Color + } + + li a { + font-size: 20px; + } + } +`; + +export const RightNav = ({ open, isLoggedIn, handleLogout }) => { + return ( +
    + {!isLoggedIn ? ( + <> +
  • + Login +
  • +
  • + Signup +
  • + + ) : ( + <> +
  • + Your Ads +
  • +
  • + Sign Out +
  • + + )} +
+ ); +}; diff --git a/frontend/src/components/reusableComponents/Paragraph.jsx b/frontend/src/components/reusableComponents/Paragraph.jsx new file mode 100644 index 000000000..1320878c6 --- /dev/null +++ b/frontend/src/components/reusableComponents/Paragraph.jsx @@ -0,0 +1,7 @@ +//import './typography.css' + +export const Paragraph = ({ text, className }) => { + return ( +

{text}

+ ) +} \ No newline at end of file diff --git a/frontend/src/components/reusableComponents/SuccessMessage.css b/frontend/src/components/reusableComponents/SuccessMessage.css new file mode 100644 index 000000000..4b02c73de --- /dev/null +++ b/frontend/src/components/reusableComponents/SuccessMessage.css @@ -0,0 +1,9 @@ +.success-message { + color: green; + background-color: #ecffec; /* Light green background */ + border: 1px solid green; + padding: 10px; + margin: 10px 0; + border-radius: 5px; + font-size: 0.9rem; +} diff --git a/frontend/src/components/reusableComponents/SuccessMessage.jsx b/frontend/src/components/reusableComponents/SuccessMessage.jsx new file mode 100644 index 000000000..f86109975 --- /dev/null +++ b/frontend/src/components/reusableComponents/SuccessMessage.jsx @@ -0,0 +1,9 @@ +import './SuccessMessage.css'; + +export const SuccessMessage = ({ message }) => { + return ( +
+ {message} +
+ ); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e560a674..3991cdcd2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,9 +5,226 @@ sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + + /* Light mode colors */ + --background-color: #ffffff; + /* off-white */ + --text-color: #333333; + /* off-black */ + --heading-color: #212121; + /* slightly darker for contrast */ + + /* Dark mode colors (can be toggled) */ + --background-color-dark: #333333; + /* off-black */ + --text-color-dark: #f5f5f5; + /* off-white */ + --heading-color-dark: #ffffff; + /* pure white for contrast */ + width: 100%; + height: 100%; } code { font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} + + +body { + margin: 0; + min-height: 100vh; + width: 100%; +} + +main { + display: flex; + justify-content: center; + align-items: flex-start; + margin: 0; + background-color: var(--background-color); + color: var(--text-color); + font-family: 'Arial', sans-serif; + transition: background-color 0.3s ease, color 0.3s ease; + padding-top: 30px; +} + + +h1, +h2, +h3 { + color: var(--heading-color); + font-family: 'Helvetica Neue', sans-serif; + margin-top: 0; +} + +/* Base styles for mobile devices */ +h1 { + font-size: 1.8em; + /* Smaller size for mobile */ +} + +h2 { + font-size: 1.6em; +} + +h3 { + font-size: 1.4em; +} + +p { + font-size: 0.9em; + line-height: 1.4; + /* Slightly closer lines for mobile */ +} + +/* TOGGLE MODE */ +.toggle-dark-mode { + cursor: pointer; + padding: 10px 15px; + background-color: var(--background-color); + border: none; + border-radius: 5px; + color: var(--text-color); + font-size: 1em; + transition: background-color 0.3s ease, color 0.3s ease; +} + +.toggle-dark-mode:hover { + background-color: var(--heading-color); + color: var(--background-color); +} + + +/* BUTTON STYLES */ +.button, +button { + padding: 5px 15px; + background-color: #333; + color: #fff; + border: none; + border-radius: 5px; + cursor: pointer; + text-align: center; + text-decoration: none; + display: inline-block; + transition: background-color 0.3s ease, color 0.3s ease; + font-size: 16px; + /* Increased font size */ + margin: 15px 0; +} + +.button:hover, +button:hover { + background-color: #555; +} + +/* Style for back button */ +.back-button { + background-color: transparent; + color: #333; + border: none; + cursor: pointer; + padding: 5px 0; + border-radius: 5px; + /* Rounded corners */ + transition: background-color 0.3s ease; + text-decoration: none; + /* Remove underline */ + font-size: 0.9em; + /* Reduce font size */ +} + +.back-button:hover { + background-color: #eee; + text-decoration: none; + /* Ensure no underline on hover */ +} + + +/* INPUT FIELDS */ +input[type="text"], +input[type="password"], +input[type="file"] { + width: 100%; + padding: 10px; + margin: 10px 0; + border-radius: 5px; + border: 1px solid #ddd; + box-sizing: border-box; +} + + +/* Ad Card */ +.ad-card { + border: 1px solid #ddd; + padding: 10px; + margin: 10px; +} + +.ad-card img { + width: 100%; + height: 200px; + object-fit: cover; +} + +/* LANDING PAGE */ + +header { + margin: 0; +} + +/* LOGIN PAGE */ +.login { + margin: 0px 20px; + padding: 20px; + width: auto; +} + +.login h2 { + margin-top: 30px; +} + +/* REGISTER PAGE */ +.register { + margin: 0px 20px; + padding: 20px; + width: auto; +} + +.register h2 { + margin-top: 30px; +} + +/* HOME PAGE */ +.share-sneakers { + cursor: pointer; + margin-top: 50px; +} + +/* MEDIA QUERIES */ + +/* TABLET AND DESKTOP */ +@media (min-width: 768px) { + h1 { + font-size: 2.5em; + } + + h2 { + font-size: 2em; + } + + h3 { + font-size: 1.5em; + } + + p { + font-size: 1em; + line-height: 1.6; + } + + .back-button { + font-size: 1em; + /* Adjust font size for larger screens if needed */ + } } \ No newline at end of file diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 000000000..2de38646e --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { userStore } from "../stores/userStore"; +import { useNavigate } from "react-router-dom"; +import { CreateAd } from "../components/CreateAd"; +import { ErrorMessage } from '../components/reusableComponents/ErrorMessage'; +import { SuccessMessage } from '../components/reusableComponents/SuccessMessage'; +import { AdsList } from "../components/AdsList"; +import { Navbar } from "../components/reusableComponents/Navigation/NavBar"; +import { Heading } from "../components/reusableComponents/Heading"; +import styled from "styled-components"; + +const HomePage = styled.section` + display: flex; + flex-direction: column; + width: 90%; + margin: auto; + align-items: center; +`; + + +export const Home = () => { + const [showCreateAd, setShowCreateAd] = useState(false); + + // Access the 'handleLogout' function from the 'userStore'. + const storeHandleLogout = userStore((state) => state.handleLogout); + + // Handle error & success messages + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + // Use the 'useNavigate' hook to programmatically navigate between routes. + const navigate = useNavigate(); + + // Get 'isLoggedIn' and 'accessToken' from the 'userStore'. + const { isLoggedIn } = userStore(); + + // useEffect hook to check user authentication status. + useEffect(() => { + if (!isLoggedIn) { + // If the user is not logged in, show an alert and navigate to the login route. + setError("no permission - here"); + navigate("/"); // You can change this to the login route + } + }, [isLoggedIn, navigate]); + + // Function to handle the click event of the logout button. + const onLogoutClick = () => { + storeHandleLogout(); // Call the 'handleLogout' function from 'userStore'. + // Additional logic after logout can be added here. + setSuccess("Log out successful"); + navigate("/"); // You can change this to the login route + }; + + // Function to toggle the CreateAd component + const toggleCreateAd = () => { + setShowCreateAd(!showCreateAd); + }; + + + return ( + + + + + {error && } + {success && } + + {showCreateAd && } + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx new file mode 100644 index 000000000..fe620464e --- /dev/null +++ b/frontend/src/pages/Landing.jsx @@ -0,0 +1,23 @@ +import { Navbar } from "../components/reusableComponents/Navigation/NavBar" +import { ImageSwapper } from "../components/ImageSwapper" +import styled from "styled-components"; + +const LandingPage = styled.section` + display: flex; + flex-direction: column; + width: 90%; + margin: auto; // Center horizontally + align-items: center; // Center children horizontally within LandingPage +`; + + +export const Landing = () => { + return ( + <> + + + + + + ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 000000000..605a3d5b4 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,95 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { ErrorMessage } from '../components/reusableComponents/ErrorMessage'; +import { userStore } from "../stores/userStore"; +import { BackButton } from "../components/reusableComponents/BackButton"; +import { Button } from "../components/reusableComponents/Button"; + +export const Login = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const { errorMessage, setErrorMessage, handleLogin, isLoggedIn } = userStore((state) => ({ + errorMessage: state.errorMessage, + setErrorMessage: state.setErrorMessage, + handleLogin: state.handleLogin, + isLoggedIn: state.isLoggedIn + })); + + useEffect(() => { + if (isLoggedIn) { + navigate("/home"); + } + }, [isLoggedIn, navigate]); + + useEffect(() => { + return () => { + setErrorMessage(''); + }; + }, [setErrorMessage]); + + const onLoginClick = async () => { + setErrorMessage(""); + if (!username || !password) { + setErrorMessage("Please enter both username and password"); + return; + } + + setIsLoading(true); + try { + await handleLogin(username, password); + } catch (error) { + console.error("Login error:", error); + setErrorMessage("An error occurred during login"); + } finally { + setIsLoading(false); + } + }; + + const text = { + heading: "Login", + loremIpsum: "Please enter username and password" + }; + + return ( + <> +
+ +

{text.heading}

+ {errorMessage && } + {isLoading ? ( +
Loading...
+ ) : ( + <> +

{text.loremIpsum}

+
+ setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> +
+ + )} +
+ + ); +}; diff --git a/frontend/src/pages/NotFound.jsx b/frontend/src/pages/NotFound.jsx new file mode 100644 index 000000000..4890e6e03 --- /dev/null +++ b/frontend/src/pages/NotFound.jsx @@ -0,0 +1,4 @@ + +export const NotFound = () => { + return
NotFound
; +}; \ No newline at end of file diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx new file mode 100644 index 000000000..879ba8c69 --- /dev/null +++ b/frontend/src/pages/Register.jsx @@ -0,0 +1,112 @@ +import { userStore } from "../stores/userStore"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { BackButton } from "../components/reusableComponents/BackButton"; +import { ErrorMessage } from '../components/reusableComponents/ErrorMessage'; +import { Button } from "../components/reusableComponents/Button"; + +export const Register = () => { + // States + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + // Initialize the navigate function + const navigate = useNavigate(); + + // Function to handle the click event of the signup button + const storeHandleSignup = userStore((state) => state.handleSignup); + + // Function for basic email validation + const isValidEmail = (email) => { + return /\S+@\S+\.\S+/.test(email); + }; + + // Combined function for handling the signup click event + const onSignupClick = async () => { + setError(""); // Clear previous error + + // Validate email + if (!isValidEmail(email)) { + setError("Please enter a valid email address"); + return; + } + + // Validate username length + if (username.length < 5) { + setError("Username must be at least 5 characters long"); + return; + } + + // Validate password length + if (password.length < 8) { + setError("Password must be at least 8 characters long"); + return; + } + + try { + const signupResponse = await storeHandleSignup(username, password, email); + if (signupResponse && signupResponse.success) { + navigate("/home"); // Navigate to home after successful signup + } else { + setError("Signup failed. Please try again."); + } + } catch (error) { + console.error("Signup error:", error); + setError("An error occurred during signup"); + } + }; + + // Text + const text = { + heading: "Sign Up", + intro: "Get ready to share your sneakers with the world", + }; + + return ( + <> +
+ +

{text.heading}

+

{text.intro}

+ {error && } +
+ { + setError(""); // Clear error when user starts typing + setEmail(e.target.value); + }} + /> + { + setError(""); // Clear error when user starts typing + setUsername(e.target.value); + }} + /> + { + setError(""); // Clear error when user starts typing + setPassword(e.target.value); + }} + /> +
+
+ + ); +}; diff --git a/frontend/src/pages/YourAds.jsx b/frontend/src/pages/YourAds.jsx new file mode 100644 index 000000000..2e29f9cd1 --- /dev/null +++ b/frontend/src/pages/YourAds.jsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { adStore } from "../stores/adStore"; +import { AdsList } from "../components/AdsList" +import { CreateAd } from "../components/CreateAd"; +import { BackButton } from "../components/reusableComponents/BackButton"; +import { Heading } from "../components/reusableComponents/Heading"; + +export const YourAds = () => { + const { ads } = adStore(); + const [hasAds, setHasAds] = useState(false); + + useEffect(() => { + setHasAds(ads.length > 0); + }, [ads]); + + return ( + <> +
+ + {hasAds && ( + <> + + + + )} + + +
+ + ); +}; diff --git a/frontend/src/routes/routes.jsx b/frontend/src/routes/routes.jsx new file mode 100644 index 000000000..a0779d5b6 --- /dev/null +++ b/frontend/src/routes/routes.jsx @@ -0,0 +1,18 @@ +import { Route } from "react-router-dom"; +import { Home } from "../pages/Home"; +import { Landing } from "../pages/Landing"; +import { Login } from "../pages/Login"; +import { Register } from "../pages/Register"; +import { NotFound } from "../pages/NotFound"; +import { YourAds } from "../pages/YourAds"; + +export const routes = ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + +); \ No newline at end of file diff --git a/frontend/src/stores/adStore.jsx b/frontend/src/stores/adStore.jsx new file mode 100644 index 000000000..9d0c4062d --- /dev/null +++ b/frontend/src/stores/adStore.jsx @@ -0,0 +1,184 @@ +// Import the necessary module for state management +import { create } from "zustand"; +// Import the userStore to access user-related data +import { userStore } from "./userStore"; + +// Get the backend API URL from the environment variable +const apiEnv = import.meta.env.VITE_BACKEND_API; +console.log(apiEnv); + +// Create and export a Zustand store for managing ads +export const adStore = create((set) => ({ + // Initialize the ad state with an empty array + ads: [], + // Initialize the userId state by accessing it from the userStore + userId: userStore.userId, + + // Define an action to add an AD to the state + addAd: (ad) => set((state) => ({ ads: [...state.ads, ad] })), + + // Define an action to set the ads state to a new array of ads + setads: (ads) => set({ ads }), + + // New action to delete all ads + deleteAllAds: async () => { + try { + // Send a DELETE request to the backend API to delete all ads + const response = await fetch(`${apiEnv}/deleteAll`, { + method: "DELETE", + headers: { + Authorization: localStorage.getItem("accessToken"), + }, + }); + // Check if the request was successful + if (response.ok) { + // Clear the ads in the state + set({ ads: [] }); + } else { + console.error("Failed to delete ads"); + } + } catch (error) { + console.error(error); + } + }, + + // New action to fetch all ads in the DB + getAllAds: async () => { + try { + // Send a GET request to the backend API to fetch all ads + const response = await fetch(`${apiEnv}/getAllAds`, { + method: "GET", + }); + // Check if the request was successful + if (response.ok) { + // Parse the response data and set it as the ads state + const data = await response.json(); + set({ ads: data }); + } else { + console.error("Failed to fetch ads"); + } + } catch (error) { + console.error(error); + } + }, + + // New action to fetch ads + fetchAds: async () => { + try { + // Send a GET request to the backend API to fetch ads + const response = await fetch(`${apiEnv}/getAds`, { + method: "GET", + headers: { + Authorization: localStorage.getItem("accessToken"), + }, + }); + // Check if the request was successful + if (response.ok) { + // Parse the response data and set it as the ads state + const data = await response.json(); + set({ ads: data }); + } else { + console.error("Failed to fetch ads"); + } + } catch (error) { + console.error(error); + } + }, + + // New action to add an ad to the server and then to the store + createAd: async (newAdData, imageFile) => { + console.log("imageFile:", imageFile); // Log the imageFile here + try { + const formData = new FormData(); + formData.append('brand', newAdData.brand); + formData.append('model', newAdData.model); + formData.append('image', imageFile); + + // Send the request to create a new ad with form data + const response = await fetch(`${apiEnv}/createAd`, { + method: "POST", + headers: { + Authorization: localStorage.getItem("accessToken"), + }, + body: formData, + }); + + const newAd = await response.json(); + console.log("Server response for new ad:", newAd); + + if (response.ok) { + set((state) => { + const updatedAds = [...state.ads, newAd]; + console.log("Updated ads state:", updatedAds); // Log updated state here + return { ads: updatedAds }; + }); + } else { + console.error("Failed to create ad"); + } + } catch (error) { + console.error(error); + } + }, + + + // New action to update the boolean isAvailable value in the store + handleEdit: async (id, updatedAdData, imageFile) => { + try { + const formData = new FormData(); + formData.append('brand', updatedAdData.brand); + formData.append('model', updatedAdData.model); + if (imageFile) { + formData.append('image', imageFile); + } + + // Send a PUT request with form data + const response = await fetch(`${apiEnv}/update/${id}`, { + method: "PUT", + headers: { + Authorization: localStorage.getItem("accessToken"), + }, + body: formData, + }); + // Parse the updated ad data + const updatedAd = await response.json(); + // Check if the request was successful + if (response.ok) { + // Update the ad in the ads state + set((state) => ({ + ads: state.ads.map((ad) => + ad._id === id ? { ...ad, ...updatedAd } : ad + ), + })); + } else { + console.error("Failed to update the ad"); + } + } catch (error) { + console.error(error); + } + }, + + // New action to delete a specific ad by its ID + deleteAdById: async (id) => { + try { + // Send a DELETE request to the backend API to delete an ad by its ID + const response = await fetch(`${apiEnv}/delete/${id}`, { + method: "DELETE", + headers: { + Authorization: localStorage.getItem("accessToken"), + }, + }); + + // Check if the request was successful + if (response.ok) { + // Remove the ad from the ads state + set((state) => ({ + ads: state.ads.filter((ad) => ad._id !== id), + })); + } else { + console.error("Failed to delete ad"); + } + } catch (error) { + console.error("Error deleting ad:", error); + } + }, +})); diff --git a/frontend/src/stores/userStore.jsx b/frontend/src/stores/userStore.jsx new file mode 100644 index 000000000..3853da896 --- /dev/null +++ b/frontend/src/stores/userStore.jsx @@ -0,0 +1,97 @@ +import { create } from "zustand"; + +const apiEnv = import.meta.env.VITE_BACKEND_API; + +export const userStore = create((set) => ({ + // State variables and their setter methods + username: "", + setUsername: (username) => set({ username }), + email: "", + setEmail: (email) => set({ email }), + password: "", + setPassword: (password) => set({ password }), + accessToken: null, + setAccessToken: (token) => set({ accessToken: token }), + isLoggedIn: false, + setIsLoggedIn: (isLoggedIn) => set({ isLoggedIn }), + errorMessage: '', + setErrorMessage: (message) => set({ errorMessage: message }), + successMessage: '', + setSuccessMessage: (message) => set({ successMessage: message }), + + handleSignup: async (username, password, email) => { + if (!username || !password || !email) { + set({ errorMessage: "Please enter username, email and password" }); + return; + } + + try { + const response = await fetch(`${apiEnv}/register`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ email, username, password }) + }); + + const data = await response.json(); + if (data.success) { + set({ username, isLoggedIn: true }); // Assuming the user is logged in upon signup + set({ successMessage: "Signup successful!" }); + console.log("Signing up with:", username); + return { success: true }; // Return a success indicator + } else { + set({ errorMessage: data.response || "Signup failed" }); + return { success: false }; // Return a failure indicator + } + } catch (error) { + console.error("Signup error:", error); + set({ errorMessage: "An error occurred during signup" }); + return { success: false }; // Return a failure indicator + } + }, + + // LOGIN + handleLogin: async (username, password) => { + if (!username || !password) { + set({ errorMessage: "Please enter both username and password" }); + return; + } + + try { + const response = await fetch(`${apiEnv}/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username, password }), + }); + + const data = await response.json(); + if (data.success) { + set({ + username, + accessToken: data.response.accessToken, + isLoggedIn: true, + }); // Update the state with username and accessToken + // Redirect or update UI + localStorage.setItem("accessToken", data.response.accessToken); + set({ successMessage: "Login successful!" }); + } else { + // Display error message from server + set({ errorMessage: data.response || "Login failed" }); + } + } catch (error) { + console.error("Login error:", error); + set({ errorMessage: "An error occurred during login" }); + } + }, + handleLogout: () => { + // Clear user information and set isLoggedIn to false + set({ username: "", accessToken: null, isLoggedIn: false, errorMessage: '', successMessage: '' }); + localStorage.removeItem("accessToken"); + // Additional logout logic if needed + }, +})); + +//This store serves as a centralized place to manage user-related state and actions in the application, providing methods to handle user authentication, login, signup, and logout. \ No newline at end of file diff --git a/netlify.toml b/netlify.toml index 95443a1f3..8eff73c1d 100644 --- a/netlify.toml +++ b/netlify.toml @@ -2,5 +2,10 @@ # how it should build the JavaScript assets to deploy from. [build] base = "frontend/" - publish = "build/" - command = "npm run build" + publish = "dist/" + command = "vite build" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 \ No newline at end of file diff --git a/package.json b/package.json index d774b8cc3..326108a94 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,9 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "dependencies": { + "express-list-endpoints": "^6.0.0", + "multer": "^1.4.5-lts.1" } }