diff --git a/README.md b/README.md index 31466b54c2..800e7b2b5a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,37 @@ # Final Project -Replace this readme with your own information about your project. - -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +A full-stack web application where users can discover and create flea markets (“loppis”) nearby. The app allows people to browse events on a map, add their own flea markets with details and images, and interact with the community. ## 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? +We wanted to make it easier for people to find and share local flea markets in a simple and user-friendly way. To achieve this, we built a responsive web app with the following approach: + +- **Planning:** We started by breaking down the project into core features, user registration & login, map integration, creating new flea markets, and browsing events. We also created a design in figma and a flowchart. + +- **Frontend:** Built with React, Tailwind CSS, React Router, Zustand, and Leaflet for interactive maps. We also used libraries for e.g. keen-slider, react focus lock and then lucide for icons. + +- **Backend:** Node.js with Express, MongoDB (Atlas) for data storage, Nominatim API for Open Source Street Map and Cloudinary for image handling. Swagger/OpenAPI for API documentation. Also other libraries like multer to be able to handle formdata submittion with different file formats. + +- **Techniques:** We focused on reusable components, clean state management with hooks, and a mobile-first design. + +- **Next steps:** +With more time, we would like to: +- Allow users to upload a profile picture, edit contact information, and choose dark mode. +- Automatically filter out flea markets with past dates. +- Add more flexible scheduling options when creating an event, e.g. “every Sunday until further notice.” +- Let users click “add to calendar” so their calendar app opens with the flea market details pre-filled. +- Improve the user experience with more transitions and animations. +- Implement a global error handler to manage larger issues such as network failures. + +# Credits + +- [Bianca Van Dijk](https://pixabay.com/users/biancavandijk-9606149/) – images from Pixabay ## 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 +[Live demo on Netlify] +https://runthornet.netlify.app/ + + +[Backend on Render] +https://runthornet-api.onrender.com/ \ No newline at end of file diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js new file mode 100644 index 0000000000..5a8701966e --- /dev/null +++ b/backend/middleware/authMiddleware.js @@ -0,0 +1,22 @@ +import { User } from "../models/User.js" + +export const authenticateUser = async (req, res, next) => { + try { + const accessToken = req.header("Authorization") + const user = await User.findOne({ accessToken: accessToken }) + if (user) { + req.user = user + next() + } else { + res.status(401).json({ + message: "Authentication missing or invalid.", + loggedOut: true + }) + } + } catch (error) { + res.status(500).json({ + message: "Internal server error", + error: error.message + }); + } +} \ No newline at end of file diff --git a/backend/models/Like.js b/backend/models/Like.js new file mode 100644 index 0000000000..37fdb16caa --- /dev/null +++ b/backend/models/Like.js @@ -0,0 +1,23 @@ +import mongoose from "mongoose" + +const likeSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true + }, + loppis: { + type: mongoose.Schema.Types.ObjectId, + ref: "Loppis", + required: true + }, + createdAt: { + type: Date, + default: Date.now + } +}) + +// Ensure that a user can only like a loppis once +likeSchema.index({ user: 1, loppis: 1 }, { unique: true }) + +export const Like = mongoose.model("Like", likeSchema) \ No newline at end of file diff --git a/backend/models/Loppis.js b/backend/models/Loppis.js new file mode 100644 index 0000000000..f290a3ff72 --- /dev/null +++ b/backend/models/Loppis.js @@ -0,0 +1,114 @@ +import mongoose from 'mongoose' + +const loppisSchema = new mongoose.Schema({ + title: { + type: String, + required: true, + minLength: 5, + maxLength: 50 + }, + location: { + address: { + street: String, + city: String, + postalCode: String, + country: { + type: String, + default: 'Sweden' + } + }, + coordinates: { + type: { + type: String, + enum: ['Point'], + default: 'Point' + }, + coordinates: { + type: [Number], // [longitude, latitude] + required: true + }, + }, + }, + dates: [ + { + date: { + type: Date, + required: true + }, + startTime: { + type: String, // HH:MM format + required: true + }, + endTime: { + type: String, // HH:MM format + required: true + } + } + ], + categories: { + type: [String], + required: true, + enum: [ + "Vintage", + "Barn", + "Trädgård", + "Kläder", + "Möbler", + "Böcker", + "Husdjur", + "Elektronik", + "Kök", + "Blandat" + ], + default: "Blandat" + }, + description: { + type: String, + maxLength: 500 + }, + likes: { + type: Number, + default: 0, + min: 0 + }, + images: [{ type: String }], // bara public_id + coverImage: { type: String }, // public_id + createdAt: { + type: Date, + default: Date.now + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } +}) + +// Pre-save hook to automatically geocode address +loppisSchema.pre('save', async function (next) { + if (this.isModified('location.address')) { + + const { street, city, postalCode } = this.location.address + const query = `${street}, ${postalCode} ${city}, Sweden` + + try { + const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1` + const response = await fetch(url) + const data = await response.json() + + if (data.length > 0) { + const { lat, lon } = data[0] + this.location.coordinates = { + type: 'Point', + coordinates: [parseFloat(lon), parseFloat(lat)] + } + } else { + throw new Error('Address not found') + } + } catch (error) { + return next(error) + } + } + next() +}) + +export const Loppis = mongoose.model('Loppis', loppisSchema) \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 0000000000..4cc1cc106b --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,27 @@ +import mongoose from "mongoose" +import crypto from "crypto" + +const userSchema = new mongoose.Schema({ + firstName: { + type: String, + required: true, + }, + lastName: { + type: String, + }, + email: { + type: String, + required: true, + unique: true + }, + password: { + type: String, + required: true + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex") + } +}) + +export const User = mongoose.model("User", userSchema) \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f2448..39a747c412 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,18 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt-nodejs": "^0.0.3", + "cloudinary": "^2.7.0", "cors": "^2.8.5", + "dotenv": "^17.2.1", "express": "^4.17.3", + "express-list-endpoints": "^7.1.1", + "luxon": "^3.7.1", "mongoose": "^8.4.0", - "nodemon": "^3.0.1" + "multer": "^2.0.2", + "nodemon": "^3.0.1", + "sharp": "^0.34.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1" } -} \ No newline at end of file +} diff --git a/backend/routes/geocodeRoutes.js b/backend/routes/geocodeRoutes.js new file mode 100644 index 0000000000..a0eaaf9e52 --- /dev/null +++ b/backend/routes/geocodeRoutes.js @@ -0,0 +1,137 @@ +import express from "express" + +const router = express.Router() + +// Swagger +/** + * @openapi + * tags: + * name: Geocoding + * description: Endpoints for converting between addresses and geographic coordinates using OpenStreetMap Nominatim. + */ + +// Forward geocoding route +/** + * @openapi + * /api/geocode: + * get: + * tags: + * - Geocoding + * summary: Forward geocode an address or place + * description: Uses OpenStreetMap Nominatim to convert an address or place name into coordinates. + * parameters: + * - in: query + * name: q + * required: true + * schema: + * type: string + * description: Search query (address, place name, etc.) + * responses: + * 200: + * description: Geocoding result + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * 400: + * description: Missing query parameter + * 500: + * description: Upstream error + */ +router.get("/", async (req, res) => { + const q = req.query.q + if (!q) return res.status(400).json({ error: "Missing q" }) + + // Build Nominatim URL + const url = new URL("https://nominatim.openstreetmap.org/search") + url.searchParams.set("q", String(q)) + url.searchParams.set("format", "json") + url.searchParams.set("limit", "1") + url.searchParams.set("addressdetails", "1") + url.searchParams.set("countrycodes", "se") + + try { + const r = await fetch(url, { + headers: { + // Required by Nominatim policy: identify your app + contact + "User-Agent": `LoppisApp/1.0 (${process.env.GEOCODER_CONTACT ?? "malinelundgren1991@gmail.com"})`, + "Accept": "application/json", + }, + }) + if (!r.ok) { + return res.status(r.status).json({ error: "Geocoding failed" }) + } + const data = await r.json() + res.json(data) + } catch (e) { + console.error("Geocode proxy error:", e) + res.status(500).json({ error: "Upstream error" }) + } +}) + +// Reverse geocoding route +/** + * @openapi + * /api/geocode/reverse: + * get: + * tags: + * - Geocoding + * summary: Reverse geocode coordinates + * description: Uses OpenStreetMap Nominatim to convert latitude/longitude into a human-readable address. + * parameters: + * - in: query + * name: lat + * required: true + * schema: + * type: number + * description: Latitude + * - in: query + * name: lon + * required: true + * schema: + * type: number + * description: Longitude + * responses: + * 200: + * description: Reverse geocoding result + * content: + * application/json: + * schema: + * type: object + * 400: + * description: Missing coordinates + * 500: + * description: Upstream error + */ +router.get("/reverse", async (req, res) => { + const { lat, lon } = req.query + if (!lat || !lon) return res.status(400).json({ error: "Missing coordinates" }) + + // Nominatim URL + const url = new URL("https://nominatim.openstreetmap.org/reverse") + url.searchParams.set("lat", lat) + url.searchParams.set("lon", lon) + url.searchParams.set("format", "json") + + try { + const r = await fetch(url, { + headers: { + // Required by Nominatim policy: identify your app + contact + "User-Agent": `LoppisApp/1.0 (${process.env.GEOCODER_CONTACT ?? "malinelundgren1991@gmail.com"})`, + "Accept": "application/json", + }, + }) + if (!r.ok) { + return res.status(r.status).json({ error: "Geocoding failed" }) + } + const data = await r.json() + res.json(data) + } catch (e) { + console.error("Geocode proxy error:", e) + res.status(500).json({ error: "Upstream error" }) + } +}) + +export default router \ No newline at end of file diff --git a/backend/routes/loppisRoutes.js b/backend/routes/loppisRoutes.js new file mode 100644 index 0000000000..dac73ead5b --- /dev/null +++ b/backend/routes/loppisRoutes.js @@ -0,0 +1,901 @@ +import 'dotenv/config' +import express from "express" +import mongoose from "mongoose" +import multer from 'multer' + +import { v2 as cloudinary } from 'cloudinary' +import { Loppis } from "../models/Loppis.js" +import { Like } from '../models/Like.js' +import { authenticateUser } from "../middleware/authMiddleware.js" + +const router = express.Router() + +// Multer: store in memory +const upload = multer({ storage: multer.memoryStorage() }) + +// Cloudinary config +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}) + +// swagger comments +/** + * @openapi + * tags: + * name: Loppis + * description: API endpoints for managing loppis ads + * components: + * securitySchemes: + * bearerAuth: + * type: http + * scheme: bearer + * bearerFormat: JWT + * schemas: + * Loppis: + * type: object + * required: + * - title + * - categories + * - dates + * properties: + * _id: + * type: string + * title: + * type: string + * minLength: 5 + * maxLength: 50 + * description: + * type: string + * maxLength: 500 + * categories: + * type: array + * items: + * type: string + * enum: ["Vintage","Barn","Trädgård","Kläder","Möbler","Böcker","Husdjur","Elektronik","Kök","Blandat"] + * likes: + * type: integer + * minimum: 0 + * dates: + * type: array + * items: + * type: object + * required: + * - date + * - startTime + * - endTime + * properties: + * date: + * type: string + * format: date + * startTime: + * type: string + * pattern: "^([01]\\d|2[0-3]):([0-5]\\d)$" + * endTime: + * type: string + * pattern: "^([01]\\d|2[0-3]):([0-5]\\d)$" + * images: + * type: array + * items: + * type: string + * description: Cloudinary public_id + * coverImage: + * type: string + * description: Cloudinary public_id of cover image + * location: + * type: object + * properties: + * address: + * type: object + * properties: + * street: + * type: string + * city: + * type: string + * postalCode: + * type: string + * country: + * type: string + * default: Sweden + * coordinates: + * type: object + * properties: + * type: + * type: string + * enum: ["Point"] + * coordinates: + * type: array + * items: + * type: number + * description: [longitude, latitude] + * createdAt: + * type: string + * format: date-time + * createdBy: + * type: string + * description: MongoDB ObjectId referencing the User + */ + +// get all loppis ads +/** + * @openapi + * /loppis: + * get: + * summary: Get all loppis ads + * tags: [Loppis] + * responses: + * 200: + * description: List of loppis ads + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/Loppis' + */ +router.get("/", async (req, res) => { + const page = req.query.page || 1 + const limit = req.query.limit || 20 + const sortBy = req.query.sort_by || "-createdAt" // sort on most recently added by default + + try { + const { city, date, category } = req.query + const query = {} + if (city) { + query['location.address.city'] = new RegExp(city, "i") + } + if (category) { + query.categories = category + } + if (date) { + const now = new Date() + if (date === 'today') { + query['dates.date'] = { + $gte: new Date(now.setHours(0, 0, 0, 0)), + $lt: new Date(now.setHours(23, 59, 59, 999)) + } + } else if (date === 'tomorrow') { + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + query['dates.date'] = { + $gte: tomorrow.setHours(0, 0, 0, 0), + $lt: tomorrow.setHours(23, 59, 59, 999) + } + } else if (date === 'weekend') { + const saturday = new Date() + saturday.setDate(saturday.getDate() + (6 - saturday.getDay())) + saturday.setHours(0, 0, 0, 0) + + const sunday = new Date(saturday) + sunday.setDate(saturday.getDate() + 1) + sunday.setHours(23, 59, 59, 999) + + query['dates.date'] = { + $gte: saturday, + $lt: sunday + } + } else if (date === 'next_week') { + const nextMonday = new Date() + nextMonday.setDate(nextMonday.getDate() + (9 - nextMonday.getDay())) + nextMonday.setHours(0, 0, 0, 0) + + const nextSunday = new Date(nextMonday) + nextSunday.setDate(nextMonday.getDate() + 5) + nextSunday.setHours(23, 59, 59, 999) + + query['dates.date'] = { + $gte: nextMonday, + $lt: nextSunday + } + } + } + + const totalCount = await Loppis.find(query).countDocuments() + const loppises = await Loppis.find(query).sort(sortBy).skip((page - 1) * limit).limit(limit) + + if (loppises.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No ads found on that query. Try another one." + }) + } + res.status(200).json({ + success: true, + response: { + totalCount: totalCount, + currentPage: page, + limit: limit, + data: loppises, + } + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Server error while fetching ads." + }) + } +}) + +// get popular loppis (most likes) +/** + * @openapi + * /loppis/popular: + * get: + * tags: + * - Loppis + * summary: Get popular loppis (most likes) + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * description: Number of loppis to return (default 10) + * responses: + * 200: + * description: Popular loppis + */ +router.get("/popular", async (req, res) => { + const limit = req.query.limit || 10 // returns the 10 most liked loppis by default + + try { + const popularLoppis = await Loppis.find().sort("-likes").limit(limit) + + if (popularLoppis.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No Loppis found on that query. Try another one." + }) + } + res.status(200).json({ + success: true, + response: popularLoppis + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch popular Loppis." + }) + } +}) + +// get upcoming loppis +/** + * @openapi + * /loppis/upcoming: + * get: + * tags: + * - Loppis + * summary: Get upcoming loppis (sorted by next date) + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * description: Number of loppis to return (default 5) + * responses: + * 200: + * description: Upcoming loppis + */ +router.get("/upcoming", async (req, res) => { + const limit = req.query.limit || 5 // returns the next 5 upcoming loppis by defualt + try { + const today = new Date() + const upcomingLoppis = await Loppis.aggregate([ + // flatten dates array + { $unwind: "$dates" }, + // only keep future dates + { $match: { "dates.date": { $gte: today } } }, + // sort by date ascending + { $sort: { "dates.date": 1 } }, + // group back by loppis, keep the first (=earliest) date + { + $group: { + _id: "$_id", + title: { $first: "$title" }, + location: { $first: "$location" }, + nextDate: { $first: "$dates" } // earliest future date + } + }, + // finally sort loppisar by their next date + { $sort: { "nextDate.date": 1 } } + ]).limit(limit) + + if (upcomingLoppis.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No Loppis found on that query. Try another one." + }) + } + res.status(200).json({ + success: true, + response: upcomingLoppis + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch popular Loppis." + }) + } +}) + +// get the available categories from enums in Loppis model +/** + * @openapi + * /loppis/categories: + * get: + * tags: + * - Loppis + * summary: Get available categories + * responses: + * 200: + * description: List of categories + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * response: + * type: array + * items: + * type: string + */ +router.get("/categories", async (req, res) => { + + try { + const categories = Loppis.schema.path("categories").caster.enumValues + + if (!categories || categories.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No categories found." + }) + } + + res.status(200).json({ + success: true, + response: categories, + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Server error while fetching categories." + }) + } +}) + +// get one loppis by id +/** + * @openapi + * /loppis/{id}: + * get: + * summary: Get a single loppis ad by ID + * tags: [Loppis] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: MongoDB ObjectId of the loppis ad + * responses: + * 200: + * description: Loppis ad found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Loppis' + * 400: + * description: Invalid ID format + * 404: + * description: Loppis not found + */ +router.get("/:id", async (req, res) => { + const { id } = req.params + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + sucess: false, + response: null, + message: "Invalid ID format." + }) + } + + const loppis = await Loppis.findById(id) + if (!loppis) { + return res.status(404).json({ + success: false, + response: null, + message: "Loppis not found!" + }) + } + res.status(200).json({ + success: true, + response: loppis + }) + } catch (error) { + return res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch loppis." + }) + } +}) + +// Like a loppis - only autheticated users +/** + * @openapi + * /loppis/{id}/like: + * patch: + * summary: Like or unlike a loppis ad (authenticated users only) + * tags: [Loppis] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: MongoDB ObjectId of the loppis ad + * responses: + * 200: + * description: Loppis liked/unliked successfully + * 400: + * description: Invalid ID format + * 404: + * description: Loppis not found + * 500: + * description: Server error + */ +router.patch("/:id/like", authenticateUser, async (req, res) => { + const { id } = req.params + let action = '' + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid ID format." + }) + } + + const loppis = await Loppis.findById(id) + if (!loppis) { + return res.status(404).json({ + success: false, + response: null, + message: "Loppis not found!" + }) + } + + // check if user has already liked this loppis or not + const existingLike = await Like.findOne({ user: req.user._id, loppis: id }) + if (!existingLike) { + action = 'liked' + // create a like entry + await new Like({ user: req.user._id, loppis: id }).save() + // increse loppis likes by one + await Loppis.findByIdAndUpdate(id, { $inc: { likes: 1 } }, { new: true, runValidators: true }) + } else { + action = 'unliked' + // remove like entry + await Like.findByIdAndDelete(existingLike.id) + // decrese loppis likes by one + await Loppis.findByIdAndUpdate(id, { $inc: { likes: -1 } }, { new: true, runValidators: true }) + } + + res.status(200).json({ + success: true, + response: { + data: loppis, + action: action + }, + message: `Loppis ${action} successfully!` + }) + + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to like or unlike loppis." + }) + } +}) + +// add a loppis ad +/** + * @openapi + * /loppis: + * post: + * summary: Create a new loppis ad (authenticated users only) + * tags: [Loppis] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - data + * properties: + * data: + * type: string + * description: JSON string with loppis ad fields (title, description, categories, dates, location) + * images: + * type: array + * items: + * type: string + * format: binary + * description: Array of image files (max 6) + * responses: + * 201: + * description: Loppis ad created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Loppis' + * 400: + * description: Missing data or invalid input + * 500: + * description: Server error + */ +router.post('/', authenticateUser, upload.array('images', 6), async (req, res) => { + const user = req.user + + try { + + if (!req.body?.data) { + return res.status(400).json({ success: false, message: 'Missing "data" field in form-data' }) + } + + const payload = JSON.parse(req.body.data) + + const uploads = (req.files || []).map(file => new Promise((resolve, reject) => { + const stream = cloudinary.uploader.upload_stream( + { + folder: 'loppis', // save original in a folder + resource_type: 'image', + }, + (err, result) => { + if (err) return reject(err) + // save *public_id* to generate all variants at delivery + resolve(result.public_id) + } + ) + stream.end(file.buffer) + })) + + const publicIds = await Promise.all(uploads) // [ "loppis/abc123", ... ] + payload.images = publicIds + payload.coverImage = publicIds[0] || null + + // spara i DB med mongoose + const doc = await Loppis.create(payload) + return res.status(201).json({ success: true, response: doc }) + + } catch (err) { + res.status(500).json({ success: false, message: err.message || 'Server error' }) + } +}) + +// Edit loppis ad +// Geocode helper +const geocodeAddress = async ({ street, postalCode, city }) => { + const q = `${street}, ${postalCode} ${city}, Sweden` + const res = await fetch(`http://localhost:8080/api/geocode?q=${encodeURIComponent(q)}`) + if (!res.ok) throw new Error(`Geocode failed: ${res.status}`) + const arr = await res.json() + if (!Array.isArray(arr) || arr.length === 0) return null + const { lat, lon } = arr[0] + return { lat: parseFloat(lat), lon: parseFloat(lon) } +} +// update loppis ad incl. images via multipart/form-data === +/** + * @openapi + * /loppis/{id}: + * patch: + * summary: Update a loppis ad (authenticated users only) + * tags: [Loppis] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: MongoDB ObjectId of the loppis ad + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * data: + * type: string + * description: JSON string with fields to update + * images: + * type: array + * items: + * type: string + * format: binary + * description: Array of new images to upload (max 6) + * responses: + * 200: + * description: Loppis ad updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Loppis' + * 400: + * description: Invalid ID format or no changes provided + * 404: + * description: Loppis not found + * 422: + * description: Could not geocode updated address + * 500: + * description: Server error + */ +router.patch('/:id', authenticateUser, upload.array('images', 6), async (req, res) => { + const { id } = req.params + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ success: false, response: null, message: 'Invalid ID format.' }) + } + + try { + const existing = await Loppis.findById(id) + if (!existing) { + return res.status(404).json({ success: false, response: null, message: 'Loppis not found!' }) + } + + // 1) Parsa "data" (kommer som string i multipart) + let body = req.body || {} + if (body && typeof body === 'object' && body.data) { + body = typeof body.data === 'string' ? JSON.parse(body.data) : body.data + } + + // 2) Ladda upp NYA filer (behåll ordningen) + const uploadBufferToCloudinary = (buf) => + new Promise((resolve, reject) => { + const stream = cloudinary.uploader.upload_stream( + { folder: 'loppis', resource_type: 'image' }, + (err, result) => (err ? reject(err) : resolve(result.public_id)) + ) + stream.end(buf) + }) + + let uploadedNew = [] + if (req.files && req.files.length > 0) { + uploadedNew = await Promise.all(req.files.map(f => uploadBufferToCloudinary(f.buffer))) + // uploadedNew[0] motsvarar {type:'new', index:0} i order + } + + // 3) Bygg slutlig bildlista från 'order' (NYTT kontrakt) + // Fallback: om 'order' saknas, kör "gammal" väg (images/keep + remove). + let finalImages = null + let coverImage = null + const order = Array.isArray(body.order) ? body.order : null + const coverIndex = Number.isInteger(body.coverIndex) ? body.coverIndex : 0 + + if (order) { + const prev = Array.isArray(existing.images) ? existing.images : [] + const uploadedMap = uploadedNew.reduce((acc, pid, idx) => (acc[idx] = pid, acc), {}) + const removedSetFromBody = new Set(Array.isArray(body.removedExistingPublicIds) ? body.removedExistingPublicIds : []) + + // Återskapa lista enligt 'order' + const tmp = [] + for (const token of order) { + if (token?.type === 'existing' && token.publicId && prev.includes(token.publicId)) { + // hoppa över om den explicit markerats för borttag + if (!removedSetFromBody.has(token.publicId)) tmp.push(token.publicId) + } else if (token?.type === 'new' && Number.isInteger(token.index)) { + const pid = uploadedMap[token.index] + if (pid) tmp.push(pid) + } + } + finalImages = tmp + coverImage = finalImages[coverIndex] || null + + // Räkna ut vad som ska raderas i Cloudinary: + // a) allt som användaren markerat i removedExistingPublicIds + // b) PLUS allt som fanns i prev men inte längre finns i finalImages + const finalSet = new Set(finalImages) + const implicitRemoved = (prev || []).filter(pid => !finalSet.has(pid)) + const toDelete = new Set([ + ...removedSetFromBody, + ...implicitRemoved, + ]) + // säkerhet: radera inte om den faktiskt finns kvar i final + const reallyDelete = [...toDelete].filter(pid => !finalSet.has(pid)) + + if (reallyDelete.length) { + await Promise.all( + reallyDelete.map(pid => + cloudinary.uploader.destroy(pid).catch(() => null) // swallow failures + ) + ) + } + + } else { + // ---- Fallback till din tidigare logik (om en gammal klient skulle anropa) ---- + const imageMode = (body.imageMode || 'append').toLowerCase() + const keepListRaw = Array.isArray(body.images) ? body.images : null + const removeList = new Set(Array.isArray(body.removeImages) ? body.removeImages : []) + + let next = existing.images ? [...existing.images] : [] + if (imageMode === 'replace') { + next = [...uploadedNew] + } else { + if (keepListRaw) { + const keepSet = new Set(next) + next = keepListRaw.filter(pid => keepSet.has(pid)) + } + if (removeList.size > 0) { + next = next.filter(pid => !removeList.has(pid)) + } + if (uploadedNew.length > 0) { + next = [...next, ...uploadedNew] + } + } + + finalImages = next + if (body.coverImage && finalImages.includes(body.coverImage)) { + coverImage = body.coverImage + } else if (!existing.coverImage || !finalImages.includes(existing.coverImage)) { + coverImage = finalImages[0] || null + } else { + coverImage = existing.coverImage + } + + if (removeList.size > 0) { + await Promise.all( + [...removeList].map(pid => + cloudinary.uploader.destroy(pid).catch(() => null) + ) + ) + } + } + + // 4) Fält-uppdateringar (samma som innan) + const addrIn = body.location?.address || {} + const oldAddr = existing.location?.address || {} + + const streetChanged = addrIn.street?.trim() !== undefined && addrIn.street.trim() !== (oldAddr.street || '') + const cityChanged = addrIn.city?.trim() !== undefined && addrIn.city.trim() !== (oldAddr.city || '') + const postalCodeChanged = addrIn.postalCode?.trim() !== undefined && addrIn.postalCode.trim() !== (oldAddr.postalCode || '') + const addressChanged = streetChanged || cityChanged || postalCodeChanged + + const $set = {} + if (body.title !== undefined) $set.title = body.title + if (body.description !== undefined) $set.description = body.description + if (body.categories !== undefined) $set.categories = body.categories + if (body.dates !== undefined) $set.dates = body.dates + + if (addrIn.street !== undefined) $set['location.address.street'] = addrIn.street + if (addrIn.city !== undefined) $set['location.address.city'] = addrIn.city + if (addrIn.postalCode !== undefined) $set['location.address.postalCode'] = addrIn.postalCode + + // Spara bilder (om vi byggt en ny lista) + if (finalImages) { + $set.images = finalImages + $set.coverImage = coverImage + } + + if (addressChanged) { + const geo = await geocodeAddress({ + street: $set['location.address.street'] ?? oldAddr.street, + city: $set['location.address.city'] ?? oldAddr.city, + postalCode: $set['location.address.postalCode'] ?? oldAddr.postalCode, + }) + if (!geo) { + return res.status(422).json({ + success: false, + response: null, + message: 'Kunde inte geokoda den nya adressen. Kontrollera stavning/postnummer.', + }) + } + $set['location.coordinates'] = { + type: 'Point', + coordinates: [geo.lon, geo.lat], // [lng, lat] + } + } + + if (Object.keys($set).length === 0) { + return res.status(400).json({ + success: false, + response: null, + message: 'No changes provided. Skickade du några fält i "data" eller filer i "images[]"?', + }) + } + + const updated = await Loppis.findByIdAndUpdate(id, { $set }, { + new: true, + runValidators: true, + validateModifiedOnly: true, + }) + + return res.status(200).json({ success: true, response: updated, message: 'Loppis updated successfully!' }) + } catch (err) { + return res.status(500).json({ success: false, response: null, message: 'Failed to update loppis ad.' }) + } +}) + +// Delete loppis ad +/** + * @openapi + * /loppis/{id}: + * delete: + * summary: Delete a loppis ad (authenticated users only) + * tags: [Loppis] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: MongoDB ObjectId of the loppis ad + * responses: + * 200: + * description: Loppis deleted successfully + * 400: + * description: Invalid ID format + * 404: + * description: Loppis not found + * 500: + * description: Server error + */ +router.delete("/:id", authenticateUser, async (req, res) => { + const { id } = req.params + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid ID format." + }) + } + + try { + const deletedLoppis = await Loppis.findByIdAndDelete(id) + + if (!deletedLoppis) { + return res.status(404).json({ + success: false, + response: null, + message: "Loppis not found!" + }) + } + + res.status(200).json({ + success: true, + response: deletedLoppis, + message: "Loppis deleted successfully!" + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to delete loppis ad." + }) + } +}) + +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 0000000000..eb0a1ec7a9 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,397 @@ +import express from "express" +import bcrypt from "bcrypt-nodejs" +import mongoose from 'mongoose' +import { User } from "../models/User.js" +import { Loppis } from "../models/Loppis.js" +import { Like } from '../models/Like.js' +import { authenticateUser } from "../middleware/authMiddleware.js" + +const router = express.Router() + +// Swagger +/** + * @openapi + * tags: + * name: Users + * description: User management & authentication + */ + +// register a new user +/** + * @openapi + * /users/register: + * post: + * summary: Register a new user + * tags: [Users] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - firstName + * - email + * - password + * properties: + * firstName: + * type: string + * example: Alice + * lastName: + * type: string + * example: Andersson + * email: + * type: string + * format: email + * example: alice@example.com + * password: + * type: string + * format: password + * example: mySecret123 + * responses: + * 200: + * description: User created successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * response: + * type: object + * properties: + * id: + * type: string + * firstName: + * type: string + * accessToken: + * type: string + * 409: + * description: User already exists + */ +router.post('/register', async (req, res) => { + try { + const { firstName, lastName, email, password } = req.body + // validate input + if (!firstName || !email || !password) { + return res.status(400).json({ + success: false, + message: "Name, email and password are required", + }) + } + + + const MIN_PASSWORD = 8 + if (String(password).length < MIN_PASSWORD) { + return res.status(400).json({ + success: false, + message: `Password must be at least ${MIN_PASSWORD} characters long.` + }) + } + + // validate if email already exists + const normalizedEmail = String(email).toLowerCase().trim() + const existingUser = await User.findOne({ email: normalizedEmail }) + if (existingUser) { + return res.status(409).json({ + success: false, + message: "User already exists", + }) + } + // create a new user + const salt = bcrypt.genSaltSync() + const user = new User({ firstName, lastName, email: email.toLowerCase(), password: bcrypt.hashSync(password, salt) }) + await user.save() + + res.status(200).json({ + success: true, + message: "User created successfully!", + response: { + id: user._id, + firstName: user.firstName, + accessToken: user.accessToken + } + }) + + } catch (error) { + return res.status(400).json({ + success: false, + response: error, + message: "Failed to create user." + }) + } +}) + +// login existing user +/** + * @openapi + * /users/login: + * post: + * summary: Login user and return access token + * tags: [Users] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - password + * properties: + * email: + * type: string + * format: email + * example: alice@example.com + * password: + * type: string + * format: password + * example: mySecret123 + * responses: + * 200: + * description: Login successful + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * response: + * type: object + * properties: + * id: + * type: string + * firstName: + * type: string + * accessToken: + * type: string + * 401: + * description: Invalid credentials + * 404: + * description: User not found + */ +router.post('/login', async (req, res) => { + try { + const { email, password } = req.body + // validate input + if (!email || !password) { + return res.status(400).json({ + success: false, + message: "Email and password are required", + }) + } + // validate if user exists + const user = await User.findOne({ email: email.toLowerCase() }) + if (!user) { + return res.status(404).json({ + success: false, + message: "User not found", + }) + } + // validate password + if (user && bcrypt.compareSync(password, user.password)) { + res.status(200).json({ + success: true, + message: "Log in successful!", + response: { + id: user._id, + firstName: user.firstName, + accessToken: user.accessToken + } + }) + } else { + res.status(401).json({ + success: false, + message: "Invalid credentials", + }) + } + } catch (error) { + res.status(500).json({ + success: false, + message: "Failed to log in", + error, + }) + } +}) + +// list loppis created by (authenticated) user +/** + * @openapi + * /users/{id}/loppis: + * get: + * summary: Get all loppis ads created by a user + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: User ID + * responses: + * 200: + * description: List of loppis ads created by the user + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * response: + * type: array + * items: + * $ref: '#/components/schemas/Loppis' + * 401: + * description: Unauthorized + * 500: + * description: Server error + */ +router.get("/:id/loppis", authenticateUser, async (req, res) => { + const userId = req.user._id.toString() + + try { + if (!mongoose.Types.ObjectId.isValid(userId)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid userId" + }) + } + + const loppises = await Loppis.find({ createdBy: userId }) + + return res.status(200).json({ + success: true, + response: loppises + }) + } catch (error) { + return res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch loppis ads for user." + }) + } +}) + +// list loppis liked by (authenticated) user +/** + * @openapi + * /users/{id}/likes: + * get: + * summary: Get all loppis ads liked by a user + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: User ID + * - name: page + * in: query + * schema: + * type: integer + * default: 1 + * - name: limit + * in: query + * schema: + * type: integer + * default: 10 + * responses: + * 200: + * description: List of liked loppis ads + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * response: + * type: object + * properties: + * totalCount: + * type: integer + * currentPage: + * type: integer + * limit: + * type: integer + * data: + * type: array + * items: + * $ref: '#/components/schemas/Loppis' + * message: + * type: string + * 401: + * description: Unauthorized + * 404: + * description: No liked loppis found + * 500: + * description: Server error + */ +router.get("/:id/likes", authenticateUser, async (req, res) => { + const user = req.user + const page = req.query.page || 1 + const limit = req.query.limit || 10 + + try { + if (!mongoose.Types.ObjectId.isValid(user._id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid user ID format." + }) + } + + const userLikes = await Like.find({ user: user }) + if (!userLikes || userLikes.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No likes found for this user." + }) + } + + const likedLoppis = await Loppis.find({ _id: { $in: userLikes.map(like => like.loppis) } }) + .sort("-createdAt").skip((page - 1) * limit).limit(limit) + + if (likedLoppis.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No liked loppis found for this user." + }) + } + + res.status(200).json({ + success: true, + response: { + totalCount: likedLoppis.length, + currentPage: page, + limit: limit, + data: likedLoppis, + }, + message: "Successfully fetched liked loppis ads." + }) + + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch liked loppis ads." + }) + } +}) + +export default router \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 070c875189..101769a64c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,39 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +import 'dotenv/config' +import express from "express" +import cors from "cors" +import mongoose from "mongoose" +import listEndpoints from "express-list-endpoints" +import loppisRoutes from "./routes/loppisRoutes.js" +import userRoutes from './routes/userRoutes.js' +import geocodeRoutes from './routes/geocodeRoutes.js' +import { swaggerDocs } from "./swagger.js" -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project" +mongoose.connect(mongoUrl) -const port = process.env.PORT || 8080; -const app = express(); +const port = process.env.PORT || 8080 +const app = express() -app.use(cors()); -app.use(express.json()); +app.use(cors()) +app.use(express.json()) +// endpoint for documentation of the API app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); + const endpoints = listEndpoints(app) + res.json({ + message: "Welcome to the LoppisApp API.", + documentation: "API documentation available at '/api-docs'.", + endpoints: endpoints + }) +}) + +// end point routes +app.use("/users", userRoutes) +app.use("/loppis", loppisRoutes) +app.use("/api/geocode", geocodeRoutes) // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); -}); + swaggerDocs(app) // enable Swagger +}) diff --git a/backend/swagger.js b/backend/swagger.js new file mode 100644 index 0000000000..ff1913c94a --- /dev/null +++ b/backend/swagger.js @@ -0,0 +1,44 @@ +import swaggerJsdoc from "swagger-jsdoc"; +import swaggerUi from "swagger-ui-express"; + +const options = { + definition: { + openapi: "3.0.0", + info: { + title: "Loppis API", + version: "1.0.0", + description: "API documentation for LoppisApp project", + }, + servers: [ + { + url: 'http://localhost:8080/', + description: 'Local development server', + }, + { + url: 'https://runthornet-api.onrender.com', + description: 'Production server (Render)', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + apis: ['./routes/*.js', './models/*.js'] // look for docs inside your route files +}; + +const swaggerSpec = swaggerJsdoc(options); + +export const swaggerDocs = (app) => { + app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +} \ No newline at end of file diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000000..5b7d186fe1 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1 @@ +VITE_API_URL='https://runthornet-api.onrender.com' \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 664410b5b9..708ee6f52d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,66 @@ - - - - - - Technigo React Vite Boiler Plate - - -
- - - + + + + + + + + + + + + + + + + Runt Hörnet + + + +
+ + + + \ No newline at end of file diff --git a/frontend/netlify.toml b/frontend/netlify.toml new file mode 100644 index 0000000000..8d0d2c3e72 --- /dev/null +++ b/frontend/netlify.toml @@ -0,0 +1,5 @@ +# Redirect all routes to index.html for React Router +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 7b2747e949..7359d264d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,14 +4,29 @@ "version": "1.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "vite build", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, "dependencies": { + "@tailwindcss/vite": "^4.1.11", + "date-fns": "^4.1.0", + "dotenv": "^17.2.1", + "hamburger-react": "^2.5.2", + "keen-slider": "^6.8.6", + "leaflet": "^1.9.4", + "lucide-react": "^0.536.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-dropzone": "^14.3.8", + "react-focus-lock": "^2.13.6", + "react-leaflet": "^4.2.1", + "react-leaflet-cluster": "^3.1.0", + "react-responsive": "^10.0.1", + "react-router-dom": "^7.7.1", + "tailwindcss": "^4.1.11", + "zustand": "^5.0.7" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 0000000000..6ea109f7ca 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..98d9e85ed5 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/preview.png b/frontend/public/preview.png new file mode 100644 index 0000000000..9034bedaef Binary files /dev/null and b/frontend/public/preview.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6e..6c18ab2602 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,52 @@ -export const App = () => { +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import Layout from './pages/Layout' +import Home from './pages/Home' +import Search from './pages/Search' +import LoppisInfo from './pages/LoppisInfo' +import Profile from './pages/Profile' +import SignUp from './pages/SignUp' +import AddLoppis from './pages/AddLoppis' +import NotFound from './pages/NotFound' +import AboutUs from './pages/AboutUs' +import ContactUs from './pages/ContactUs' +import ProtectedPage from './pages/ProtectedPage' +export const App = () => { return ( - <> -

Welcome to Final Project!

- - ); -}; + + + } > + } title='Startsida' /> + } title='Söksida' /> + } title='Loppissida' /> + + + + } + title='Profilsida' /> + + + + } /> + } /> + + + + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/frontend/src/assets/botanical-1.jpg b/frontend/src/assets/botanical-1.jpg new file mode 100644 index 0000000000..a08262cad2 Binary files /dev/null and b/frontend/src/assets/botanical-1.jpg differ diff --git a/frontend/src/assets/botanical-2.jpg b/frontend/src/assets/botanical-2.jpg new file mode 100644 index 0000000000..988cd35e68 Binary files /dev/null and b/frontend/src/assets/botanical-2.jpg differ diff --git a/frontend/src/assets/botanical-3.jpg b/frontend/src/assets/botanical-3.jpg new file mode 100644 index 0000000000..2176bc77de Binary files /dev/null and b/frontend/src/assets/botanical-3.jpg differ diff --git a/frontend/src/assets/botanical-4.jpg b/frontend/src/assets/botanical-4.jpg new file mode 100644 index 0000000000..2b952553dc Binary files /dev/null and b/frontend/src/assets/botanical-4.jpg differ diff --git a/frontend/src/assets/botanical.jpg b/frontend/src/assets/botanical.jpg new file mode 100644 index 0000000000..3c97f5656f Binary files /dev/null and b/frontend/src/assets/botanical.jpg differ diff --git a/frontend/src/assets/circle.jpg b/frontend/src/assets/circle.jpg new file mode 100644 index 0000000000..e75819916d Binary files /dev/null and b/frontend/src/assets/circle.jpg differ diff --git a/frontend/src/assets/default-profile.png b/frontend/src/assets/default-profile.png new file mode 100644 index 0000000000..9afed7b402 Binary files /dev/null and b/frontend/src/assets/default-profile.png differ diff --git a/frontend/src/assets/drawing.jpg b/frontend/src/assets/drawing.jpg new file mode 100644 index 0000000000..c13e12855b Binary files /dev/null and b/frontend/src/assets/drawing.jpg differ diff --git a/frontend/src/assets/lines.jpg b/frontend/src/assets/lines.jpg new file mode 100644 index 0000000000..6cf25de7a8 Binary files /dev/null and b/frontend/src/assets/lines.jpg differ diff --git a/frontend/src/assets/logo-1.png b/frontend/src/assets/logo-1.png new file mode 100644 index 0000000000..060169e1bf Binary files /dev/null and b/frontend/src/assets/logo-1.png differ diff --git a/frontend/src/assets/loppis-placeholder-image.png b/frontend/src/assets/loppis-placeholder-image.png new file mode 100644 index 0000000000..be2d19c7f8 Binary files /dev/null and b/frontend/src/assets/loppis-placeholder-image.png differ diff --git a/frontend/src/assets/monstera-1.jpg b/frontend/src/assets/monstera-1.jpg new file mode 100644 index 0000000000..464eac54e5 Binary files /dev/null and b/frontend/src/assets/monstera-1.jpg differ diff --git a/frontend/src/assets/monstera.jpg b/frontend/src/assets/monstera.jpg new file mode 100644 index 0000000000..c3c970c682 Binary files /dev/null and b/frontend/src/assets/monstera.jpg differ diff --git a/frontend/src/assets/seeds-1.jpg b/frontend/src/assets/seeds-1.jpg new file mode 100644 index 0000000000..7465ccfdf2 Binary files /dev/null and b/frontend/src/assets/seeds-1.jpg differ diff --git a/frontend/src/assets/seeds.jpg b/frontend/src/assets/seeds.jpg new file mode 100644 index 0000000000..15a851277d Binary files /dev/null and b/frontend/src/assets/seeds.jpg differ diff --git a/frontend/src/components/AriaLiveRegion.jsx b/frontend/src/components/AriaLiveRegion.jsx new file mode 100644 index 0000000000..0c319cf9ab --- /dev/null +++ b/frontend/src/components/AriaLiveRegion.jsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react" +import { useLocation } from "react-router-dom" + +const AriaLiveRegion = () => { + const { pathname } = useLocation() + const [message, setMessage] = useState("") + + useEffect(() => { + // Customize messages per route + switch (pathname) { + case '/': + setMessage("Startsida laddad") + break + case "/search": + setMessage("Söksida laddad") + break + case "/loppis/:loppisId": + setMessage("Loppissida laddad") + break + case "/profile": + setMessage("Profilsida laddad") + break + default: + setMessage("Sidan har laddats") + } + + // Clear message after screen reader reads it + const timeout = setTimeout(() => setMessage(""), 1000) + return () => clearTimeout(timeout) + }, [pathname]) + + return ( + + ) +} + +export default AriaLiveRegion \ No newline at end of file diff --git a/frontend/src/components/Button.jsx b/frontend/src/components/Button.jsx new file mode 100644 index 0000000000..892dbb4405 --- /dev/null +++ b/frontend/src/components/Button.jsx @@ -0,0 +1,28 @@ +const Button = ({ text, icon: Icon, type, onClick, active, ariaLabel, classNames }) => { + const baseStyles = 'flex justify-center items-center gap-2 rounded-full shadow-md hover:shadow-lg font-medium transition cursor-pointer' + + const colorStyles = // this can me changed later + type === 'submit' || active + ? 'bg-button text-button-text hover:bg-button-hover hover:text-button-text-hover' + : 'bg-white border-2 border-button text-button-text hover:bg-button-hover hover:text-button-text-hover' + + const sizeStyles = // fixed circle size if no text (only icon) + !text + ? "w-10 h-10" + : "py-2 px-4" + + return ( + + ) +} + +export default Button + diff --git a/frontend/src/components/CardLink.jsx b/frontend/src/components/CardLink.jsx new file mode 100644 index 0000000000..81ed6dabe6 --- /dev/null +++ b/frontend/src/components/CardLink.jsx @@ -0,0 +1,21 @@ +import { Link } from "react-router-dom" + +const CardLink = ({ to, icon: Icon, iconColor, label, className = "" }) => { + return ( + + + + +

{label}

+ + ) +} + +export default CardLink diff --git a/frontend/src/components/CarouselCard.jsx b/frontend/src/components/CarouselCard.jsx new file mode 100644 index 0000000000..668c3b1a41 --- /dev/null +++ b/frontend/src/components/CarouselCard.jsx @@ -0,0 +1,63 @@ +import { Link } from 'react-router' +import { format } from 'date-fns' +import { sv } from 'date-fns/locale' +import LikeButton from './LikeButton' +import useAuthStore from '../stores/useAuthStore' +import useModalStore from '../stores/useModalStore' +import { IMG } from '../utils/imageVariants' +import useLikesStore from '../stores/useLikesStore' + +const CarouselCard = ({ loppis, index, total }) => { + const { user, token } = useAuthStore() + const { openLoginModal } = useModalStore() + const { likedLoppisIds, toggleLike } = useLikesStore() + + // check if loppis is liked by current user + const isLiked = likedLoppisIds?.includes(loppis._id) + // Hämta publicId för omslagsbild: + const id = loppis.coverImage ?? loppis.images?.[0] ?? null + // format date + const dateString = `${format(loppis.dates[0].date, 'EEE d MMM', { locale: sv })}` + + // handle click on like button + const likeLoppis = async (e) => { + e.stopPropagation() // förhindra att kortet klickas på + if (!user || !token) { + openLoginModal('Du måste vara inloggad för att gilla en loppis!') + return + } + toggleLike(loppis._id, user.id, token) + } + + return ( +
+
+ + {IMG.card(id) ? ( + {loppis.title} + ) : ( +
+ Ingen bild +
+ )} + + +
+
+

{loppis.title}

+

{loppis.location.address.city} • {dateString}

+
+
+ ) +} + +export default CarouselCard \ No newline at end of file diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000000..aa8041fa5b --- /dev/null +++ b/frontend/src/components/ConfirmDialog.jsx @@ -0,0 +1,60 @@ +import { Loader, X } from 'lucide-react' + +const ConfirmDialog = ({ + open, + title = 'Bekräfta', + message, + confirmText = 'Ja', + cancelText = 'Nej', + onConfirm, + onCancel, + loading = false, +}) => { + if (!open) return null + + return ( +
+ {/* backdrop */} +
+ {/* panel */} +
+
+

{title}

+ +
+ +
+

{message}

+
+ +
+ + +
+
+
+ ) +} + +export default ConfirmDialog diff --git a/frontend/src/components/Details.jsx b/frontend/src/components/Details.jsx new file mode 100644 index 0000000000..df5838a5a4 --- /dev/null +++ b/frontend/src/components/Details.jsx @@ -0,0 +1,13 @@ +const Details = ({ icon: Icon, text }) => { + + return ( +
+ + + {text} + +
+ ) +} + +export default Details \ No newline at end of file diff --git a/frontend/src/components/Divider.jsx b/frontend/src/components/Divider.jsx new file mode 100644 index 0000000000..0d89b4ce65 --- /dev/null +++ b/frontend/src/components/Divider.jsx @@ -0,0 +1,5 @@ +const Divider = () => ( +
+) + +export default Divider \ No newline at end of file diff --git a/frontend/src/components/ErrorMessage.jsx b/frontend/src/components/ErrorMessage.jsx new file mode 100644 index 0000000000..d682982bd7 --- /dev/null +++ b/frontend/src/components/ErrorMessage.jsx @@ -0,0 +1,26 @@ +import { AlertCircle } from "lucide-react" + +const VARIANTS = { + error: "bg-red-50 border-red-200 text-red-800", + warning: "bg-amber-50 border-amber-200 text-amber-800", + info: "bg-sky-50 border-sky-200 text-sky-800", +} + +const ICON_CLASSES = "w-4 h-4 shrink-0 mt-0.5" + +const ErrorMessage = ({ children, variant = "error", id, className = "" }) => { + const styles = VARIANTS[variant] || VARIANTS.error + return ( + + ) +} + +export default ErrorMessage diff --git a/frontend/src/components/FieldError.jsx b/frontend/src/components/FieldError.jsx new file mode 100644 index 0000000000..1eee60ad73 --- /dev/null +++ b/frontend/src/components/FieldError.jsx @@ -0,0 +1,18 @@ + +const FieldError = ({ id, show = true, className = '', children }) => { + // rendera inget om vi inte uttryckligen ska visa, eller om det saknas text + if (!show || !children) return null + + return ( + + ) +} + +export default FieldError \ No newline at end of file diff --git a/frontend/src/components/FilterOption.jsx b/frontend/src/components/FilterOption.jsx new file mode 100644 index 0000000000..a3243cbc04 --- /dev/null +++ b/frontend/src/components/FilterOption.jsx @@ -0,0 +1,28 @@ +const FilterOption = ({ type = 'checkbox', name, value, label, checked, onChange, id }) => { + const inputId = id || `${name}-${String(value).replace(/\s+/g, "-")}` + return ( + + ) +} + +export default FilterOption + diff --git a/frontend/src/components/FilterTag.jsx b/frontend/src/components/FilterTag.jsx new file mode 100644 index 0000000000..b9170cbabb --- /dev/null +++ b/frontend/src/components/FilterTag.jsx @@ -0,0 +1,18 @@ +import { X } from 'lucide-react' + +const FilterTag = ({ text, onClick }) => { + return ( + + ) +} + +export default FilterTag diff --git a/frontend/src/components/Input.jsx b/frontend/src/components/Input.jsx new file mode 100644 index 0000000000..d8c95f3732 --- /dev/null +++ b/frontend/src/components/Input.jsx @@ -0,0 +1,35 @@ +const Input = ({ type, id, label, placeholder, value, onChange, required, showLabel, ...rest }) => { + // Show fake placeholder only for date/time inputs and when value is empty + const showFakePlaceholder = + (type === "date" || type === "time") && !value + + return ( +
+ + + + + {/* Fake placeholder for mobile and tablet */} + {showFakePlaceholder && ( + + {placeholder} + + )} +
+ ) +} + +export default Input \ No newline at end of file diff --git a/frontend/src/components/LikeButton.jsx b/frontend/src/components/LikeButton.jsx new file mode 100644 index 0000000000..cec925269e --- /dev/null +++ b/frontend/src/components/LikeButton.jsx @@ -0,0 +1,29 @@ +import { Heart } from 'lucide-react' + +const LikeButton = ({ isLiked, onLike, className = '' }) => { + return ( + + ) +} + +export default LikeButton \ No newline at end of file diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 0000000000..e56e295c65 --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -0,0 +1,73 @@ +import { useState } from "react" +import Input from "./Input" +import Button from "./Button" +import ErrorMessage from "./ErrorMessage" +import FieldError from "./FieldError" + +const LoginForm = ({ onSubmit, isLoading, error, fieldErrors = {} }) => { + const [formData, setFormData] = useState({ + email: "", + password: "", + }) + + const [touched, setTouched] = useState({ email: false, password: false }) + + const handleSubmit = (event) => { + event.preventDefault() + setTouched({ email: true, password: true }) + onSubmit(formData.email, formData.password) + } + + return ( +
+ {error && {error}} + +
+ setFormData({ ...formData, email: e.target.value })} + value={formData.email} + showLabel={true} + required={true} + onBlur={() => setTouched((t) => ({ ...t, email: true }))} + aria-invalid={touched.email && !!fieldErrors.email} + aria-describedby={touched.email && fieldErrors.email ? "login-email-error" : undefined} + /> + {fieldErrors.email} +
+ +
+ setFormData({ ...formData, password: e.target.value })} + onBlur={() => setTouched((t) => ({ ...t, password: true }))} + value={formData.password} + showLabel={true} + required + aria-invalid={touched.password && !!fieldErrors.password} + aria-describedby={touched.password && fieldErrors.password ? "login-password-error" : undefined} + /> + + {fieldErrors.password} + +
+ + +
+ )} + + {isUpdating && ( +
+ +
+ )} +
+ + {/* Body */} +
+
+ +

+ {loppis.title} +

+ + +
+ +
+ {shownCats.map((category, i) => )} + {isMap && loppis.categories?.length > 2 && ( + +{loppis.categories.length - 2} + )} +
+ +
+
+
+
+
+ + ) +} + +export default LoppisCard diff --git a/frontend/src/components/LoppisForm.jsx b/frontend/src/components/LoppisForm.jsx new file mode 100644 index 0000000000..dbe75db7b2 --- /dev/null +++ b/frontend/src/components/LoppisForm.jsx @@ -0,0 +1,645 @@ + +import { useState, useEffect, useMemo } from 'react' +import { ChevronDown, ChevronUp, Camera, Trash2, Loader2 } from 'lucide-react' +import Input from './Input' +import Button from './Button' +import SmallMap from './SmallMap' +import PhotoDropzone from './PhotoDropzone' +import FilterTag from './FilterTag' +import ErrorMessage from './ErrorMessage' +import FieldError from './FieldError' +import { errorMessage } from '../utils/errorMessage' +import { IMG } from '../utils/imageVariants' +import { geocodeCity } from '../services/geocodingApi' +import { getLoppisCategories } from '../services/loppisApi' + + + +const normalizeDateInput = (val) => { + if (!val) return '' + try { + // ISO eller Date → yyyy-mm-dd + const d = new Date(val) + if (Number.isNaN(d.getTime())) return String(val).slice(0, 10) + return d.toISOString().slice(0, 10) + } catch { + return String(val).slice(0, 10) + } +} + +const toInitialState = (initialValues) => { + // fallback till tomma värden + const iv = initialValues || {} + const addr = iv.location?.address || {} + + return { + formData: { + title: iv.title || '', + street: addr.street || '', + city: addr.city || '', + postalCode: addr.postalCode || '', + description: iv.description || '', + imageUrl: iv.imageUrl || '', + categories: Array.isArray(iv.categories) ? iv.categories : [], + }, + selectedCategories: Array.isArray(iv.categories) ? iv.categories : [], + dates: Array.isArray(iv.dates) && iv.dates.length + ? iv.dates.map(d => ({ + date: normalizeDateInput(d.date), + startTime: d.startTime || '', + endTime: d.endTime || '', + })) + : [{ date: '', startTime: '', endTime: '' }], + coordinates: Array.isArray(iv.location?.coordinates?.coordinates) + ? [ + // backend: [lon, lat] → vi vill [lat, lon] + iv.location.coordinates.coordinates[1], + iv.location.coordinates.coordinates[0], + ] + : null, + } +} + +const LoppisForm = ({ + initialValues, + submitLabel = 'Spara', + title = 'Redigera Loppis', + onSubmit, // (payload) => Promise + onCancel, // () => void +}) => { + const [categories, setCategories] = useState([]) + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [serverError, setServerError] = useState('') // ← global form error (API, etc) + const [categoriesError, setCategoriesError] = useState('')// ← fetch categories errors + const [geoLoading, setGeoLoading] = useState(false) + const [geoError, setGeoError] = useState('') + + // Stabil "init-nyckel" baserad på innehållet (inte referensen) + const initKey = useMemo(() => JSON.stringify(initialValues ?? {}), [initialValues]) + + // initiera state en gång från initialValues (innehåll) + const init = useMemo(() => toInitialState(initialValues), [initKey]) + + const [formData, setFormData] = useState(init.formData) + const [selectedCategories, setSelectedCategories] = useState(init.selectedCategories) + const [dates, setDates] = useState(init.dates) + const [coordinates, setCoordinates] = useState(init.coordinates) + + // 🔁 Media från PhotoDropzone (en ENDA lista + removed) + const [media, setMedia] = useState({ items: [], removedExistingPublicIds: [] }) + + // --- validation state --- + const [touched, setTouched] = useState({ + title: false, street: false, postalCode: false, city: false, + }) + // datesTouched speglar dates.length + const [datesTouched, setDatesTouched] = useState(dates.map(() => ({ + date: false, startTime: false, endTime: false, + }))) + + // re-init om initialValues ändras + useEffect(() => { + const next = toInitialState(initialValues) + setFormData(next.formData) + setSelectedCategories(next.selectedCategories) + setDates(next.dates) + setCoordinates(next.coordinates) + setTouched({ title: false, street: false, postalCode: false, city: false }) + setDatesTouched(next.dates.map(() => ({ date: false, startTime: false, endTime: false }))) + setServerError('') + setGeoError('') + }, [initKey]) + + + const validateField = (key, val) => { + const v = String(val || '').trim() + switch (key) { + case 'title': + if (!v) return 'Rubrik är obligatorisk.' + if (v.length < 3) return 'Rubriken måste vara minst 3 tecken.' + return '' + case 'street': + if (!v) return 'Gatuadress är obligatorisk.' + return '' + case 'postalCode': { + if (!v) return 'Postnummer är obligatoriskt.' + const ok = /^[0-9]{3}\s?[0-9]{2}$/.test(v) // 12345 eller 123 45 + return ok ? '' : 'Ange ett giltigt postnummer (t.ex. 123 45).' + } + case 'city': + if (!v) return 'Stad är obligatorisk.' + return '' + default: + return '' + } + } + + const validateDateRow = (row) => { + const errs = { date: '', startTime: '', endTime: '', range: '' } + if (!row.date) errs.date = 'Datum är obligatoriskt.' + if (!row.startTime) errs.startTime = 'Starttid är obligatorisk.' + if (!row.endTime) errs.endTime = 'Sluttid är obligatorisk.' + // Om alla finns, kontrollera att sluttid > starttid + if (row.startTime && row.endTime) { + const a = row.startTime + const b = row.endTime + if (a >= b) errs.range = 'Sluttid måste vara efter starttid.' + } + return errs + } + + + const fieldErrors = { + title: validateField('title', formData.title), + street: validateField('street', formData.street), + postalCode: validateField('postalCode', formData.postalCode), + city: validateField('city', formData.city), + } + const dateErrors = dates.map(validateDateRow) + + const hasAnyErrors = () => { + if (Object.values(fieldErrors).some(Boolean)) return true + for (const r of dateErrors) { + if (r.date || r.startTime || r.endTime || r.range) return true + } + return false + } + + // --- handlers --- + const handleChange = (key) => (e) => { + const val = e.target.value + setFormData(prev => ({ ...prev, [key]: val })) + if (key in touched && touched[key]) { + // live-uppdatera fel + // (fieldErrors beräknas om via render; ingen extra setState behövs här) + } + } + + const handleBlur = (key) => () => setTouched(prev => ({ ...prev, [key]: true })) + + + + const handleCategoryChange = (e) => { + const { value, checked } = e.target + if (checked) { + setSelectedCategories(prev => [...prev, value]) + setFormData(prev => ({ ...prev, categories: [...prev.categories, value] })) + } else { + setSelectedCategories(prev => prev.filter(cat => cat !== value)) + setFormData(prev => ({ ...prev, categories: prev.categories.filter(cat => cat !== value) })) + } + } + + const removeCategory = (category) => { + setSelectedCategories(prev => prev.filter(cat => cat !== category)) + setFormData(prev => ({ ...prev, categories: prev.categories.filter(cat => cat !== category) })) + } + + const toggleDropdown = () => setIsDropdownOpen(v => !v) + + useEffect(() => { + const fetchCategories = async () => { + try { + setCategoriesError('') + const cats = await getLoppisCategories() + setCategories(cats || []) + } catch (err) { + setCategories([]) + setCategoriesError(errorMessage(err) || 'Kunde inte hämta kategorier just nu.') + } + } + fetchCategories() + }, []) + + const fetchCoordinates = async () => { + setCoordinates(null) + setGeoError('') + if (!formData.street || !formData.city) { + setGeoError('Fyll i gatuadress och stad först.') + return + } + const address = `${formData.street}, ${formData.postalCode} ${formData.city}, Sweden` + try { + setGeoLoading(true) + const { lat, lon } = await geocodeCity(address) + if (!lat || !lon) throw new Error('Kunde inte hitta koordinater för adressen.') + setCoordinates([lat, lon]) + } catch (err) { + setCoordinates(null) + setGeoError(errorMessage(err) || 'Kunde inte hämta koordinater.') + } finally { + setGeoLoading(false) + } + } + + + + + // Cover + initialbilder till dropzone + const orderedPublicIds = useMemo(() => { + const ids = Array.isArray(initialValues?.images) ? initialValues.images : [] + const cover = initialValues?.coverImage + return cover && ids.includes(cover) ? [cover, ...ids.filter(id => id !== cover)] : ids + }, [initKey]) + + const initialFilesForDropzone = useMemo( + () => orderedPublicIds.map(pid => ({ url: IMG.thumb(pid), publicId: pid })), + [orderedPublicIds] + ) + + + + const handleSubmit = async (e) => { + e.preventDefault() + setServerError('') + + setTouched({ title: true, street: true, postalCode: true, city: true }) + setDatesTouched(dates.map(() => ({ date: true, startTime: true, endTime: true }))) + + if (hasAnyErrors()) return + + try { + setSubmitting(true) + + const payload = { + title: formData.title, + dates, + location: { + address: { + street: formData.street, + city: formData.city, + postalCode: formData.postalCode, + } + }, + categories: formData.categories, + description: formData.description, + } + + const order = [] + const newFiles = [] + let newIdx = 0 + for (const it of media.items) { + if (it.kind === 'existing') { + order.push({ type: 'existing', publicId: it.publicId }) + } else { + order.push({ type: 'new', index: newIdx }) + newFiles.push(it.file) + newIdx++ + } + } + + const fd = new FormData() + fd.append('data', JSON.stringify({ + ...payload, + order, + removedExistingPublicIds: media.removedExistingPublicIds, + coverIndex: 0, + })) + for (const f of newFiles) fd.append('images', f) + + await onSubmit?.(fd) + } catch (err) { + setServerError(errorMessage(err) || 'Något gick fel när annonsen skulle sparas.') + } finally { + setSubmitting(false) + } + } + + const touchDate = (i, key) => { + setDatesTouched(prev => { + const next = [...prev] + next[i] = { ...next[i], [key]: true } + return next + }) + } + + return ( +
+

{title}

+ + {/* Global/server error */} + {serverError ? {serverError} : null} + +
+ +
+ + + {/* VÄNSTER KOLUMN på large */} +
+ + {/* photo-dropzone */} +
+ Bilder +
+ + + {submitting && ( +
+ +
+ )} +
+
+ + {/* Beskrivning */} +
+ Beskrivning + +
+ + + {fieldErrors.title} + +
+ + +
+ +