From 93c0a1f8c2cae219a831562e686798844d24770e Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 17 Feb 2026 15:52:16 +0100 Subject: [PATCH 01/33] Started with Adding-component --- backend/package.json | 10 +- backend/server.js | 65 ++++++++--- frontend/index.html | 3 + frontend/package.json | 4 +- frontend/src/App.jsx | 27 ++++- frontend/src/components/add.jsx | 162 +++++++++++++++++++++++++++ frontend/src/main.jsx | 10 +- frontend/src/styling/GlobalStyles.js | 18 +++ frontend/src/styling/Theme.js | 0 package.json | 12 +- 10 files changed, 284 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/add.jsx create mode 100644 frontend/src/styling/GlobalStyles.js create mode 100644 frontend/src/styling/Theme.js diff --git a/backend/package.json b/backend/package.json index 08f29f244..3fa136ff2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,13 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "cloudinary": "^2.9.0", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", + "dotenv": "^17.3.1", + "express": "^4.22.1", + "mongoose": "^8.23.0", + "multer": "^2.0.2", + "multer-storage-cloudinary": "^4.0.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/server.js b/backend/server.js index 070c87518..fd31f137c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,59 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +import express from "express" +import cors from "cors" +import mongoose from "mongoose" +import dotenv from 'dotenv' +import cloudinaryFramework from 'cloudinary' +import multer from 'multer' +import { CloudinaryStorage } from 'multer-storage-cloudinary' -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +dotenv.config() -const port = process.env.PORT || 8080; -const app = express(); +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project" +mongoose.connect(mongoUrl) +mongoose.Promise = Promise -app.use(cors()); -app.use(express.json()); +const port = process.env.PORT || 8080 +const app = express() + +app.use(cors()) +app.use(express.json()) + +const cloudinary = cloudinaryFramework.v2; +cloudinary.config({ + cloud_name: 'dzbwzwskg', // this needs to be whatever you get from cloudinary + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET +}) + +const storage = new CloudinaryStorage({ + cloudinary, + params: { + folder: 'pets', + allowedFormats: ['jpg', 'png'], + transformation: [{ width: 500, height: 500, crop: 'limit' }], + }, +}) +const parser = multer({ storage }) app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); + res.send("Hello Technigo!") +}) + +const Pet = mongoose.model('Pet', { + name: String, + imageUrl: String +}) + +app.post('/cats', parser.single('image'), async (req, res) => { + try { + const pet = await new Pet({ name: req.body.filename, imageUrl: req.file.path }).save() + res.json(pet) + } catch (err) { + res.status(400).json({ errors: err.errors }) + } +}) // Start the server app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); + console.log(`Server running on http://localhost:${port}`) +}) \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 664410b5b..862b29bda 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,9 @@ + + + Technigo React Vite Boiler Plate diff --git a/frontend/package.json b/frontend/package.json index 7b2747e94..4c27853bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "express": "^5.2.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "styled-components": "^6.3.9" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..bc34b8d33 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,29 @@ +// import React, { useState, useRef } from 'react' +import { GlobalStyle } from "./styling/GlobalStyles" +import { CatCardForm } from './components/add' + +const API_URL = 'http://localhost:8080/cats' + export const App = () => { + // const fileInput = useRef() + // const [name, setName] = useState('') + + // const handleFormSubmit = (e) => { + // e.preventDefault() + // const formData = new FormData() + // formData.append('image', fileInput.current.files[0]) + // formData.append('name', name) + + // fetch(API_URL, { method: 'POST', body: formData }) + // .then((res) => res.json()) + // .then((json) => { + // console.log(json) + // }) + // } return ( <> -

Welcome to Final Project!

+ + - ); -}; +)} \ No newline at end of file diff --git a/frontend/src/components/add.jsx b/frontend/src/components/add.jsx new file mode 100644 index 000000000..7a64ec147 --- /dev/null +++ b/frontend/src/components/add.jsx @@ -0,0 +1,162 @@ +import { useState } from "react" +import styled from "styled-components" + +const API_URL = 'http://localhost:8080' + +export const CatCardForm = ({ onSuccess }) => { + const [formData, setFormData] = useState({ + picture: null, // will hold a File object + name: "", + gender: "", + location: "", + }) + + const [errorMsg, setErrorMsg] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [previewUrl, setPreviewUrl] = useState("") + + const handleChange = (e) => { + const { name, value, files } = e.target + + if (name === "picture" && files && files[0]) { + const file = files[0] + setFormData((prev) => ({ ...prev, picture: file })) + // optional preview + setPreviewUrl(URL.createObjectURL(file)) + } else { + setFormData((prev) => ({ ...prev, [name]: value })) + } + } + + const handleSubmit = async (e) => { + e.preventDefault() + + // Basic validation + if (!formData.picture || !formData.name || !formData.gender || !formData.location) { + setErrorMsg("All fields are required") + return + } + + setIsSubmitting(true) + setErrorMsg("") + + try { + const payload = new FormData() + payload.append("picture", formData.picture) + payload.append("name", formData.name) + payload.append("gender", formData.gender) + payload.append("location", formData.location) + + const response = await fetch(`${API_URL}/cats`, { + method: "POST", + body: payload, // multipart/form‑data is handled automatically + }) + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})) + const msg = errBody.message || `Status ${response.status}` + throw new Error(msg) + } + + const newCat = await response.json() + if (typeof onSuccess === "function") { + onSuccess(newCat) + } + } catch (error) { + console.error("Submit error:", error) + setErrorMsg("Could not save cat information") + } finally { + setIsSubmitting(false) + } + } + + return ( + +
+ {/* Picture */} +
+ + + + {previewUrl && ( + Cat preview + )} +
+ + {/* Name */} +
+ + + +
+ + {/* Gender */} +
+ + + +
+ + {/* Location */} +
+ + + +
+ + {/* Submit / Feedback */} + {errorMsg &&

{errorMsg}

} + +
+
+ ) +} + +const FormWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border: black solid 2px; + border-radius: 15px; +` \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f399..0864f7335 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,10 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { App } from "./App.jsx"; -import "./index.css"; +import React from "react" +import ReactDOM from "react-dom/client" +import { App } from "./App.jsx" +import "./index.css" ReactDOM.createRoot(document.getElementById("root")).render( -); +) \ No newline at end of file diff --git a/frontend/src/styling/GlobalStyles.js b/frontend/src/styling/GlobalStyles.js new file mode 100644 index 000000000..f88712f0b --- /dev/null +++ b/frontend/src/styling/GlobalStyles.js @@ -0,0 +1,18 @@ +import { createGlobalStyle } from "styled-components" + +export const GlobalStyle = createGlobalStyle` + * { + margin: 0; + padding: 0; + font-family: "roboto"; + } + + body { + display: flex; + flex-direction: column; + margin: auto; + width: 70%; + max-width: 500px; + padding: 30px; +} +` \ No newline at end of file diff --git a/frontend/src/styling/Theme.js b/frontend/src/styling/Theme.js new file mode 100644 index 000000000..e69de29bb diff --git a/package.json b/package.json index 680d19077..98d4511e7 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,15 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "dependencies": { + "cloudinary": "^1.41.3", + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "mongoose": "^9.2.1", + "multer": "^2.0.2", + "multer-storage-cloudinary": "^4.0.0", + "react": "^19.2.4" } -} \ No newline at end of file +} From 3cb9a5c45ca14b305d07a5abe4bb4025d33f43c8 Mon Sep 17 00:00:00 2001 From: Julia Date: Fri, 20 Feb 2026 11:14:42 +0100 Subject: [PATCH 02/33] Adds card and cardlist --- backend/data.json | 8 ++ backend/models/Cat.js | 17 +++ backend/routes/catRoutes.js | 69 +++++++++ backend/routes/userRoutes.js | 0 backend/server.js | 63 ++------- frontend/src/App.jsx | 55 +++++--- frontend/src/assets/placeholderImg.jpg | Bin 0 -> 54349 bytes frontend/src/components/add.jsx | 162 ---------------------- frontend/src/components/card.jsx | 73 ++++++++++ frontend/src/components/cardlist.jsx | 35 +++++ frontend/src/components/catForm.jsx | 185 +++++++++++++++++++++++++ frontend/src/styling/Theme.js | 5 + 12 files changed, 441 insertions(+), 231 deletions(-) create mode 100644 backend/data.json create mode 100644 backend/models/Cat.js create mode 100644 backend/routes/catRoutes.js create mode 100644 backend/routes/userRoutes.js create mode 100644 frontend/src/assets/placeholderImg.jpg delete mode 100644 frontend/src/components/add.jsx create mode 100644 frontend/src/components/card.jsx create mode 100644 frontend/src/components/cardlist.jsx create mode 100644 frontend/src/components/catForm.jsx diff --git a/backend/data.json b/backend/data.json new file mode 100644 index 000000000..fe827f975 --- /dev/null +++ b/backend/data.json @@ -0,0 +1,8 @@ +[ + { + "picture": null, + "name": "Test", + "gender": "male", + "location": "outside" + } +] \ No newline at end of file diff --git a/backend/models/Cat.js b/backend/models/Cat.js new file mode 100644 index 000000000..454b8b9cc --- /dev/null +++ b/backend/models/Cat.js @@ -0,0 +1,17 @@ +import mongoose from "mongoose" + +const catSchema = new mongoose.Schema( + { + name: { type: String, required: true }, + gender: { + type: String, + enum: ["male", "female", "other"], + required: true, + }, + imageUrl: { type: String, required: true }, + location: { type: String, required: true }, + }) + +const Cat = mongoose.models.Cat || mongoose.model("Cat", catSchema) + +export default Cat \ No newline at end of file diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js new file mode 100644 index 000000000..136797425 --- /dev/null +++ b/backend/routes/catRoutes.js @@ -0,0 +1,69 @@ +import express, { text } from "express" +import multer from "multer" +import dotenv from "dotenv" +import { CloudinaryStorage } from "multer-storage-cloudinary" +import cloudinaryFramework from "cloudinary" +import Cat from "../models/Cat" + +dotenv.config() + +const cloudinary = cloudinaryFramework.v2 +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}) + +const storage = new CloudinaryStorage({ + cloudinary, + params: { + folder: "cats", + allowedFormats: ["jpg", "png"], + transformation: [{ width: 500, height: 500, crop: "limit" }], + }, +}) + +const parser = multer({ storage }) + +const router = express.Router() + +// All cats +router.get("/cats", async (req, res) => { + try { + const cats = await Cat.find().sort({ createdAt: "desc" }) + res.json(cats) + + } catch (error) { + res.status(500).json({ error: "Failed to fetch cats" }) + } +}) + +// Post +router.post('/cats', parser.single('picture'), async (req, res) => { + try { + const { filename, gender, location } = req.body + if (!req.file) { + return res.status(400).json({ message: "Image file missing" }) + } + if (!filename || !gender || !location) { + return res.status(400).json({ message: "All fields are required" }) + } + + const cat = await new Cat({ + name: filename, + imageUrl: req.file.path, + gender, + location, + }).save() + + res.status(201).json(cat) + } catch (err) { + console.error('Save error:', err) + if (err.name === 'ValidationError') { + return res.status(400).json({ message: err.message, errors: err.errors }) + } + res.status(500).json({ message: err.message || 'Server error' }) + } +}) + +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..e69de29bb diff --git a/backend/server.js b/backend/server.js index fd31f137c..6e7bb88e0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,59 +1,26 @@ import express from "express" import cors from "cors" import mongoose from "mongoose" -import dotenv from 'dotenv' -import cloudinaryFramework from 'cloudinary' -import multer from 'multer' -import { CloudinaryStorage } from 'multer-storage-cloudinary' +import dotenv from "dotenv" +import catRouter from "./routes/catRoutes.js" dotenv.config() -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project" -mongoose.connect(mongoUrl) -mongoose.Promise = Promise +mongoose + .connect(process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project", { + useNewUrlParser: true, + useUnifiedTopology: true, + }) + .then(() => console.log("Connected to MongoDB")) + .catch((e) => { + console.error("MongoDB connection error:", e) + process.exit(1) + }) -const port = process.env.PORT || 8080 const app = express() - app.use(cors()) app.use(express.json()) +app.use("/", catRouter) -const cloudinary = cloudinaryFramework.v2; -cloudinary.config({ - cloud_name: 'dzbwzwskg', // this needs to be whatever you get from cloudinary - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET -}) - -const storage = new CloudinaryStorage({ - cloudinary, - params: { - folder: 'pets', - allowedFormats: ['jpg', 'png'], - transformation: [{ width: 500, height: 500, crop: 'limit' }], - }, -}) -const parser = multer({ storage }) - -app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) - -const Pet = mongoose.model('Pet', { - name: String, - imageUrl: String -}) - -app.post('/cats', parser.single('image'), async (req, res) => { - try { - const pet = await new Pet({ name: req.body.filename, imageUrl: req.file.path }).save() - res.json(pet) - } catch (err) { - res.status(400).json({ errors: err.errors }) - } -}) - -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -}) \ No newline at end of file +const PORT = process.env.PORT || 8080 +app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`)) \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bc34b8d33..17da276f5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,29 +1,42 @@ -// import React, { useState, useRef } from 'react' +import React from "react"; +import styled from "styled-components"; import { GlobalStyle } from "./styling/GlobalStyles" -import { CatCardForm } from './components/add' +import { CatForm } from "./components/catForm"; +import { CatList } from "./components/cardlist"; -const API_URL = 'http://localhost:8080/cats' +export const API_URL = "http://localhost:8080"; export const App = () => { - // const fileInput = useRef() - // const [name, setName] = useState('') - - // const handleFormSubmit = (e) => { - // e.preventDefault() - // const formData = new FormData() - // formData.append('image', fileInput.current.files[0]) - // formData.append('name', name) - - // fetch(API_URL, { method: 'POST', body: formData }) - // .then((res) => res.json()) - // .then((json) => { - // console.log(json) - // }) - // } + const [cats, setCats] = React.useState([]) + const handleNewCat = (newCat) => { + setCats((prev) => [newCat, ...prev]) + } return ( <> - - + + +
Cat. Archive. Tracking. System.
+ + All Cats + +
-)} \ No newline at end of file + ) +} + +const PageWrapper = styled.main` + max-width: 960px; + margin: 0 auto; + padding: 20px; +` + +const Header = styled.h1` + text-align: center; + margin-bottom: 30px; +` + +const SectionTitle = styled.h2` + margin-top: 40px; + margin-bottom: 10px; +` \ No newline at end of file diff --git a/frontend/src/assets/placeholderImg.jpg b/frontend/src/assets/placeholderImg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dfd89b0f72e2578b4b52ae208ae3d1ff440c4195 GIT binary patch literal 54349 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/add.jsx b/frontend/src/components/add.jsx deleted file mode 100644 index 7a64ec147..000000000 --- a/frontend/src/components/add.jsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useState } from "react" -import styled from "styled-components" - -const API_URL = 'http://localhost:8080' - -export const CatCardForm = ({ onSuccess }) => { - const [formData, setFormData] = useState({ - picture: null, // will hold a File object - name: "", - gender: "", - location: "", - }) - - const [errorMsg, setErrorMsg] = useState("") - const [isSubmitting, setIsSubmitting] = useState(false) - const [previewUrl, setPreviewUrl] = useState("") - - const handleChange = (e) => { - const { name, value, files } = e.target - - if (name === "picture" && files && files[0]) { - const file = files[0] - setFormData((prev) => ({ ...prev, picture: file })) - // optional preview - setPreviewUrl(URL.createObjectURL(file)) - } else { - setFormData((prev) => ({ ...prev, [name]: value })) - } - } - - const handleSubmit = async (e) => { - e.preventDefault() - - // Basic validation - if (!formData.picture || !formData.name || !formData.gender || !formData.location) { - setErrorMsg("All fields are required") - return - } - - setIsSubmitting(true) - setErrorMsg("") - - try { - const payload = new FormData() - payload.append("picture", formData.picture) - payload.append("name", formData.name) - payload.append("gender", formData.gender) - payload.append("location", formData.location) - - const response = await fetch(`${API_URL}/cats`, { - method: "POST", - body: payload, // multipart/form‑data is handled automatically - }) - - if (!response.ok) { - const errBody = await response.json().catch(() => ({})) - const msg = errBody.message || `Status ${response.status}` - throw new Error(msg) - } - - const newCat = await response.json() - if (typeof onSuccess === "function") { - onSuccess(newCat) - } - } catch (error) { - console.error("Submit error:", error) - setErrorMsg("Could not save cat information") - } finally { - setIsSubmitting(false) - } - } - - return ( - -
- {/* Picture */} -
- - - - {previewUrl && ( - Cat preview - )} -
- - {/* Name */} -
- - - -
- - {/* Gender */} -
- - - -
- - {/* Location */} -
- - - -
- - {/* Submit / Feedback */} - {errorMsg &&

{errorMsg}

} - -
-
- ) -} - -const FormWrapper = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - border: black solid 2px; - border-radius: 15px; -` \ No newline at end of file diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx new file mode 100644 index 000000000..55a886452 --- /dev/null +++ b/frontend/src/components/card.jsx @@ -0,0 +1,73 @@ +import styled from "styled-components" + +export const CatCard = ({ cat }) => { + const { name, imageUrl, gender, location, createdAt } = cat + + return ( + + + + + + + {name} + + {gender} + {location} + + + + ) +} + + +const CardContainer = styled.article` + border: 1px solid black; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; +` + +const ImgWrapper = styled.div` + width: 100%; + height: 200px; + display: flex; + align-items: center; + justify-content: center; +` + +const CatImg = styled.img` + max-width: 100%; + max-height: 100%; + object-fit: cover; +` + +const Info = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 16px; +` + +const Name = styled.h3` + margin: 0 0 8px; + font-size: 20px; + color: black; +` + +const Tags = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: baseline; +` + +const Tag = styled.span` + border: 1px solid black; + color: black; + padding: 2px 6px; + border-radius: 4px; + font-size: 15px; +` \ No newline at end of file diff --git a/frontend/src/components/cardlist.jsx b/frontend/src/components/cardlist.jsx new file mode 100644 index 000000000..d55efc699 --- /dev/null +++ b/frontend/src/components/cardlist.jsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react" +import styled from "styled-components" +import { CatCard } from "./card" +import { API_URL } from "../App" + +export const CatList = () => { + const [cats, setCats] = useState([]) + + useEffect(() => { + const fetchCats = async () => { + try { + const res = await fetch(`${API_URL}/cats`) + const data = await res.json() + setCats(data) + } catch (error) { + console.error("Failed to load cats:", error) + } + } + fetchCats() + }, []) + + return ( + + {cats.map((cat) => ( + + ))} + + ) +} + +const Grid = styled.section` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; +` \ No newline at end of file diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx new file mode 100644 index 000000000..a2ae84f58 --- /dev/null +++ b/frontend/src/components/catForm.jsx @@ -0,0 +1,185 @@ +import { useState } from "react" +import styled from "styled-components" +import { API_URL } from "../App" +import placeholderImg from "../assets/placeholderImg.jpg" + +export const CatForm = ({ onSuccess }) => { + const [formData, setFormData] = useState({ + picture: null, + name: "", + gender: "", + location: "", + }) + + const [errorMsg, setErrorMsg] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [previewUrl, setPreviewUrl] = useState(placeholderImg) + + const handleChange = (e) => { + const { name, value, files } = e.target + + if (name === "picture") { + if (files && files[0]) { + const file = files[0] + setFormData((prev) => ({ ...prev, picture: file })) + setPreviewUrl(URL.createObjectURL(file)) + } else { + setFormData((prev) => ({ ...prev, picture: null })) + setPreviewUrl(placeholderImg) + } + } else { + setFormData((prev) => ({ ...prev, [name]: value })) + } + } + + const handleSubmit = async (e) => { + e.preventDefault() + + // Basic validation + if (!formData.picture || !formData.name || !formData.gender || !formData.location) { + setErrorMsg("All fields are required") + return + } + + setIsSubmitting(true) + setErrorMsg("") + + try { + const payload = new FormData() + payload.append("picture", formData.picture) + payload.append("filename", formData.name) + payload.append("gender", formData.gender) + payload.append("location", formData.location) + + const response = await fetch(`${API_URL}/cats`, { + method: "POST", + body: payload, + }) + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})) + const msg = errBody.message || `Status ${response.status}` + throw new Error(msg) + } + + const newCat = await response.json(); + if (typeof onSuccess === "function") { + onSuccess(newCat) + } + + } catch (error) { + console.error("Submit error:", error) + setErrorMsg("Could not save cat information") + } finally { + setIsSubmitting(false) + } + } + + return ( + + +
+ + + {previewUrl && ( + + )} + + +
+ + {/* Name */} +
+ + +
+ + {/* Gender */} +
+ + +
+ + {/* Location */} +
+ + + +
+ + {/* Submit / Feedback */} + {errorMsg &&

{errorMsg}

} + +
+
+ ) +} + +const FormWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + border: black solid 2px; + border-radius: 15px; +` + +const StyledForm = styled.form` + max-width: 400px; + margin: 10px; +` + +const ImageBox = styled.div` + width: 300px; + height: 300px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +` + +const PreviewImg = styled.img` + max-width: 100%; + max-height: 100%; + object-fit: contain; + width: "100%"; + margin-top: 8; + border-radius: 4; +` \ No newline at end of file diff --git a/frontend/src/styling/Theme.js b/frontend/src/styling/Theme.js index e69de29bb..7edd4755a 100644 --- a/frontend/src/styling/Theme.js +++ b/frontend/src/styling/Theme.js @@ -0,0 +1,5 @@ +// export const theme = { +// colors: { +// backgroundColor: "#eeeced" +// } +// } \ No newline at end of file From 0879ec33762d113066cfa0f9cfdef59e08e25af1 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 25 Feb 2026 16:10:37 +0100 Subject: [PATCH 03/33] Adds the name after login --- backend/routes/catRoutes.js | 10 +- backend/routes/userRoutes.js | 96 +++++++++++++++ backend/server.js | 16 +-- frontend/src/App.jsx | 184 ++++++++++++++++++++++++---- frontend/src/components/card.jsx | 2 +- frontend/src/components/catForm.jsx | 6 +- frontend/src/components/login.jsx | 96 +++++++++++++++ frontend/src/components/signup.jsx | 148 ++++++++++++++++++++++ frontend/src/main.jsx | 7 +- frontend/src/pages/dashboard.jsx | 55 +++++++++ frontend/src/pages/start.jsx | 8 ++ package.json | 4 +- 12 files changed, 591 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/login.jsx create mode 100644 frontend/src/components/signup.jsx create mode 100644 frontend/src/pages/dashboard.jsx create mode 100644 frontend/src/pages/start.jsx diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index 136797425..c38e421dd 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -1,4 +1,4 @@ -import express, { text } from "express" +import express, { } from "express" import multer from "multer" import dotenv from "dotenv" import { CloudinaryStorage } from "multer-storage-cloudinary" @@ -39,9 +39,11 @@ router.get("/cats", async (req, res) => { }) // Post -router.post('/cats', parser.single('picture'), async (req, res) => { +router.post("/cats", parser.single('picture'), async (req, res) => { try { + const { filename, gender, location } = req.body + if (!req.file) { return res.status(400).json({ message: "Image file missing" }) } @@ -49,6 +51,10 @@ router.post('/cats', parser.single('picture'), async (req, res) => { return res.status(400).json({ message: "All fields are required" }) } + // const transformedUrl = cloudinary.url(req.file.public_id, { + // effect: "background_removal", + // }) + const cat = await new Cat({ name: filename, imageUrl: req.file.path, diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index e69de29bb..dfbb9042d 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -0,0 +1,96 @@ +import bcrypt from "bcrypt" +import crypto from "crypto" +import express from "express" +import mongoose from "mongoose" + +const router = express.Router() + +// User schema +const UserSchema = new mongoose.Schema({ + name: { type: String, required: true }, + 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) + + +// New User +router.post('/users/signup', async (req, res) => { + try { + const { name, email, password } = req.body + + const existingUser = await User.findOne({ + email: email.toLowerCase() + }) + + if (existingUser) { + return res.status(400).json({ + success: false, + message: "User with this email already exists" + }) + } + + const salt = bcrypt.genSaltSync() + const hashedPassword = bcrypt.hashSync(password, salt) + const user = new User({ name, email, password: hashedPassword }) + + await user.save() + + res.status(200).json({ + success: true, + message: "User created successfully", + response: { + name: user.name, + email: user.email, + id: user._id, + accessToken: user.accessToken, + }, + }) + } catch (error) { + res.status(400).json({ + success: false, + message: 'Could not create user', + response: error, + }) + } +}) + +// Log In +router.post('/users/login', async (req, res) => { + try { + const { email, password } = req.body + const user = await User.findOne({ email: email.toLowerCase() }) + + if (user && bcrypt.compareSync(password, user.password)) { + res.json({ + success: true, + message: "Logged in successfully", + response: { + id: user._id, + name: user.name, + email: user.email, + accessToken: user.accessToken + }, + }) + } else { + res.status(401).json({ + success: false, + message: "Wrong e-mail or password", + response: null, + }) + } + } catch (error) { + res.status(500).json({ + success: false, + message: "Something went wrong", + response: error + }) + } +}) + +export default router \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 6e7bb88e0..a7efac311 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,11 +1,18 @@ -import express from "express" import cors from "cors" -import mongoose from "mongoose" import dotenv from "dotenv" +import express from "express" +import mongoose from "mongoose" import catRouter from "./routes/catRoutes.js" +import userRouter from "./routes/userRoutes.js" dotenv.config() +const app = express() +app.use(cors()) +app.use(express.json()) +app.use("/", catRouter) +app.use("/", userRouter) + mongoose .connect(process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project", { useNewUrlParser: true, @@ -17,10 +24,5 @@ mongoose process.exit(1) }) -const app = express() -app.use(cors()) -app.use(express.json()) -app.use("/", catRouter) - const PORT = process.env.PORT || 8080 app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`)) \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 17da276f5..4e97c36ac 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,42 +1,176 @@ -import React from "react"; -import styled from "styled-components"; +import { useState, useCallback } from "react" +import { Routes, Route, Navigate, useNavigate } from "react-router-dom" import { GlobalStyle } from "./styling/GlobalStyles" -import { CatForm } from "./components/catForm"; -import { CatList } from "./components/cardlist"; +import { LoginForm } from "./components/login" +import { SignUpForm } from "./components/signup" +import ProtectedRoute from "./pages/start" +import { Dashboard } from "./pages/dashboard" +import styled from "styled-components" -export const API_URL = "http://localhost:8080"; +export const API_URL = "http://localhost:8080" export const App = () => { - const [cats, setCats] = React.useState([]) - const handleNewCat = (newCat) => { - setCats((prev) => [newCat, ...prev]) + const navigate = useNavigate() + const [cats, setCats] = useState([]) + const [user, setUser] = useState(null) + const [authMode, setAuthMode] = useState("login") + + const authHeaders = useCallback( + () => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("token")}`, + }), + [] + ) + + const login = async (email, password) => { + const res = await fetch(`${API_URL}/users/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }) + + if (!res.ok) throw new Error("Login failed") + + const { response } = await res.json() + localStorage.setItem("user", JSON.stringify(response)) + if (response.accessToken) localStorage.setItem("token", response.accessToken) + if (response.id) localStorage.setItem("userId", response.id) + setUser(response) + + + navigate("/dashboard") + } + + const handleSignUpSuccess = (newUser) => { + localStorage.setItem("token", newUser.accessToken) + localStorage.setItem("user", JSON.stringify(newUser)) + setUser(newUser) + navigate("/dashboard") + } + + const handleLogout = () => { + setUser(null); + localStorage.removeItem("user") + localStorage.removeItem("token") + localStorage.removeItem("userId") + navigate("/login") + } + + const toggleAuthMode = () => + setAuthMode((prev) => (prev === "login" ? "signup" : "login")) + + const loadCats = async () => { + try { + const res = await fetch(`${API_URL}/cats`, { + headers: authHeaders(), + }) + if (!res.ok) throw new Error(`Status ${res.status}`) + + const raw = await res.json() + const formatted = raw.map((item) => ({ + id: item._id, + name: item.name, + imageUrl: item.imageUrl, + gender: item.gender, + location: item.location, + userId: item.userId, + })) + setCats(formatted) + } catch (e) { + console.error("Failed to load cats:", e) + } + } + + const handleNewCat = (newCatFromForm) => { + const formatted = { + id: newCatFromForm._id, + name: newCatFromForm.name, + imageUrl: newCatFromForm.imageUrl, + gender: newCatFromForm.gender, + location: newCatFromForm.location, + userId: newCatFromForm.userId, + } + setCats((prev) => [formatted, ...prev]) } return ( <> - -
Cat. Archive. Tracking. System.
- - All Cats - -
+ + + {authMode === "login" ? ( + + ) : ( + + )} + + {authMode === "login" ? ( + + Don’t have an account? Sign up + + ) : ( + + Already have an account? Log in + + )} + + + } + /> + + + } + /> + + }> + + } + /> + + } /> + ) } -const PageWrapper = styled.main` - max-width: 960px; - margin: 0 auto; - padding: 20px; +const ToggleWrapper = styled.div` + margin-top: 12px; + text-align: center; ` -const Header = styled.h1` - text-align: center; - margin-bottom: 30px; +const ToggleBtn = styled.button` + background: none; + border: none; + color: #0066cc; + cursor: pointer; + font-size: 0.95rem; + &:hover { + text-decoration: underline; + } ` -const SectionTitle = styled.h2` - margin-top: 40px; - margin-bottom: 10px; -` \ No newline at end of file +export default App \ No newline at end of file diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index 55a886452..b58eecba3 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -32,7 +32,7 @@ const CardContainer = styled.article` const ImgWrapper = styled.div` width: 100%; - height: 200px; + max-height: 200px; display: flex; align-items: center; justify-content: center; diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index a2ae84f58..471013f4d 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -156,7 +156,7 @@ export const CatForm = ({ onSuccess }) => { const FormWrapper = styled.div` display: flex; flex-direction: column; - align-items: center; + align-items: center; //?? border: black solid 2px; border-radius: 15px; ` @@ -167,8 +167,8 @@ const StyledForm = styled.form` ` const ImageBox = styled.div` - width: 300px; - height: 300px; + max-width: 300px; + max-height: 300px; display: flex; align-items: center; justify-content: center; diff --git a/frontend/src/components/login.jsx b/frontend/src/components/login.jsx new file mode 100644 index 000000000..8a4adce59 --- /dev/null +++ b/frontend/src/components/login.jsx @@ -0,0 +1,96 @@ +import { useState } from "react" +import styled from "styled-components" + +export const LoginForm = ({ login }) => { + const [formData, setFormData] = useState({ + email: "", + password: "", + }) + + const [error, setError] = useState("") + + const handleSubmit = async (e) => { + e.preventDefault() + + if (!formData.email || !formData.password) { + setError("Please fill in both fields") + return + } + try { + await login(formData.email, formData.password) + setFormData({ name: "", email: "", password: "" }) + } catch { + setError("Invalid email or password") + } + } + + + const handleChange = (e) => { + const { name, value } = e.target + + setFormData((prevFormData) => ({ ...prevFormData, [name]: value })) + } + + return ( + +

Log in

+ + + + Email + + + + Password + + + + + {error &&

{error}

} + + Log In +
+ ) +} + +export default LoginForm + +const FormWrapper = styled.form` +background: #f2f0f0; +border: 1px solid black; +box-shadow: 10px 10px 0 black; +padding: 20px; +margin-bottom: 50px; +` + +const StyledDiv = styled.div` + display: flex; + flex-direction: column; + margin: 5px 0px; +` + +const Styledlabel = styled.label` + display: flex; + flex-direction: column; +` + +const StyledBtn = styled.button` + background-color: white; + border: 2px solid #c9c8c8; + padding: 4px; + + &:hover { + border: 2px solid black; + cursor: pointer; + } +` \ No newline at end of file diff --git a/frontend/src/components/signup.jsx b/frontend/src/components/signup.jsx new file mode 100644 index 000000000..1b2c640ba --- /dev/null +++ b/frontend/src/components/signup.jsx @@ -0,0 +1,148 @@ +import React, { useState } from "react" +import styled from "styled-components" +import { API_URL } from "../App" +import { useNavigate } from "react-router-dom" + +export const SignUpForm = ({ onSuccess, setUser }) => { + const navigate = useNavigate() + const [formData, setFormData] = useState({ + name: "", + email: "", + password: "", + }) + + const [errorMsg, setErrorMsg] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleChange = (e) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + } + + const handleSignUp = async (e) => { + e.preventDefault() + + if (!formData.name || !formData.email || !formData.password) { + setErrorMsg("Name, email and password are required") + return + } + + setIsSubmitting(true) + setErrorMsg("") + + try { + const response = await fetch(`${API_URL}/users/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }) + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})) + const msg = errBody.message || `Status ${response.status}` + throw new Error(msg) + } + + const payload = await response.json() + const user = payload.response || payload + + if (user.accessToken) { + localStorage.setItem("token", user.accessToken) + } + localStorage.setItem("user", JSON.stringify(user)) + + if (typeof setUser === "function") setUser(user) + if (typeof onSuccess === "function") onSuccess(user) + navigate("/dashboard") + } catch (error) { + console.error("Sign‑up error:", error); + setErrorMsg(error.message || "Could not create account") + } finally { + setIsSubmitting(false) + } + } + + return ( + +

Sign up

+ + {errorMsg && {errorMsg}} + + + + Name + + + + + Email + + + + + Password + + + + + + {isSubmitting ? "Creating…" : "Sign up"} + +
+ ) +} + +export default SignUpForm + +const FormWrapper = styled.form` + background: #f2f0f0; + border: 1px solid black; + box-shadow: 10px 10px 0 black; + padding: 20px; + margin-bottom: 50px; +` + +const StyledDiv = styled.div` + display: flex; + flex-direction: column; + margin: 5px 0; +` + +const StyledLabel = styled.label` + display: flex; + flex-direction: column; + margin-bottom: 12px; +` + +const StyledBtn = styled.button` + background-color: white; + border: 2px solid #c9c8c8; + padding: 4px; + &:hover { + border: 2px solid black; + cursor: pointer; + } +` + +const ErrorMsg = styled.p` + color: #c00; + margin-bottom: 12px; +` \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 0864f7335..7de868e29 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,13 @@ import React from "react" import ReactDOM from "react-dom/client" -import { App } from "./App.jsx" +import { BrowserRouter } from "react-router-dom" +import App from "./App" import "./index.css" ReactDOM.createRoot(document.getElementById("root")).render( - + + + ) \ No newline at end of file diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx new file mode 100644 index 000000000..1248e98a7 --- /dev/null +++ b/frontend/src/pages/dashboard.jsx @@ -0,0 +1,55 @@ +import React, { useEffect } from "react" +import { CatForm } from "../components/catForm" +import { CatList } from "../components/cardlist" +import styled from "styled-components" + +export const Dashboard = ({ + cats, + setCats, + handleNewCat, + loadCats, + logout, + user, +}) => { + useEffect(() => { + loadCats() + }, []) + + return ( + +
Cat. Archive. Tracking. System.
+ {user && ( + + Welcome, {user.name}! + + + )} + + All Cats + +
+ ) +} + + +const PageWrapper = styled.main` + max-width: 960px; + margin: 0 auto; + padding: 20px; +` + +const Header = styled.h1` + text-align: center; + margin-bottom: 30px; +` + +const SectionTitle = styled.h2` + margin-top: 40px; + margin-bottom: 10px; +` + +const UserBar = styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 20px; +` \ No newline at end of file diff --git a/frontend/src/pages/start.jsx b/frontend/src/pages/start.jsx new file mode 100644 index 000000000..3458828e8 --- /dev/null +++ b/frontend/src/pages/start.jsx @@ -0,0 +1,8 @@ +import { Navigate, Outlet } from "react-router-dom" + +export const ProtectedRoute = () => { + const token = localStorage.getItem("token") + return token ? : +} + +export default ProtectedRoute \ No newline at end of file diff --git a/package.json b/package.json index 98d4511e7..49de104f7 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "postinstall": "npm install --prefix backend" }, "dependencies": { + "bcrypt": "^6.0.0", "cloudinary": "^1.41.3", "cors": "^2.8.6", "dotenv": "^17.3.1", @@ -12,6 +13,7 @@ "mongoose": "^9.2.1", "multer": "^2.0.2", "multer-storage-cloudinary": "^4.0.0", - "react": "^19.2.4" + "react": "^19.2.4", + "react-router-dom": "^7.13.1" } } From f8ff27471f6e4f32875d43075f2e37e7ea6b3647 Mon Sep 17 00:00:00 2001 From: Julia Date: Sun, 1 Mar 2026 12:24:37 +0100 Subject: [PATCH 04/33] Tries to add comments --- backend/models/Comments.js | 14 ++ backend/models/auth.js | 21 +++ backend/routes/catRoutes.js | 5 +- backend/routes/commentRoutes.js | 55 ++++++++ backend/routes/userRoutes.js | 4 +- backend/server.js | 6 +- frontend/src/App.jsx | 35 ++--- frontend/src/api.js | 20 +++ frontend/src/components/card.jsx | 193 +++++++++++++++++++++++---- frontend/src/components/cardlist.jsx | 33 +---- frontend/src/components/catForm.jsx | 7 +- frontend/src/components/signup.jsx | 6 +- frontend/src/pages/dashboard.jsx | 2 +- package.json | 1 + 14 files changed, 308 insertions(+), 94 deletions(-) create mode 100644 backend/models/Comments.js create mode 100644 backend/models/auth.js create mode 100644 backend/routes/commentRoutes.js create mode 100644 frontend/src/api.js diff --git a/backend/models/Comments.js b/backend/models/Comments.js new file mode 100644 index 000000000..ff5a0fe33 --- /dev/null +++ b/backend/models/Comments.js @@ -0,0 +1,14 @@ +import mongoose from "mongoose" + +const commentSchema = new mongoose.Schema( + { + catId: { type: mongoose.Schema.Types.ObjectId, ref: "Cat", required: true }, + userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + userName: { type: String, required: true }, + text: { type: String, required: true, minlength: 1 }, + }, + { timestamps: true } +) + +export default mongoose.models.Comment || + mongoose.model("Comment", commentSchema) \ No newline at end of file diff --git a/backend/models/auth.js b/backend/models/auth.js new file mode 100644 index 000000000..adc095017 --- /dev/null +++ b/backend/models/auth.js @@ -0,0 +1,21 @@ +import jwt from "jsonwebtoken" + +export const verifyToken = (req, res, next) => { + const authHeader = req.headers.authorization + if (!authHeader) { + return res.status(401).json({ message: "Missing Authorization header" }) + } + + const token = authHeader.split(" ")[1] + try { + const payload = jwt.verify(token, process.env.JWT_SECRET) + req.user = { + id: payload.id, + name: payload.name, + email: payload.email, + } + next() + } catch (err) { + return res.status(401).json({ message: "Invalid token" }) + } +} \ No newline at end of file diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index c38e421dd..6c9429e78 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -7,6 +7,7 @@ import Cat from "../models/Cat" dotenv.config() +// Cloudinary const cloudinary = cloudinaryFramework.v2 cloudinary.config({ cloud_name: process.env.CLOUDINARY_CLOUD_NAME, @@ -51,10 +52,6 @@ router.post("/cats", parser.single('picture'), async (req, res) => { return res.status(400).json({ message: "All fields are required" }) } - // const transformedUrl = cloudinary.url(req.file.public_id, { - // effect: "background_removal", - // }) - const cat = await new Cat({ name: filename, imageUrl: req.file.path, diff --git a/backend/routes/commentRoutes.js b/backend/routes/commentRoutes.js new file mode 100644 index 000000000..d8d0d5327 --- /dev/null +++ b/backend/routes/commentRoutes.js @@ -0,0 +1,55 @@ +import express from "express" +import { verifyToken } from "../models/auth.js" +import Cat from "../models/Cat.js" + +const router = express.Router() + +// Comments +router.get("/:catId/comments", async (req, res) => { + const { catId } = req.params + try { + const cat = await Cat.findById(catId).select("comments") + if (!cat) return res.status(404).json({ message: "Cat not found" }) + + // Sort newest first (optional) + const sorted = cat.comments.sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + ) + res.json(sorted) + } catch (err) { + console.error("Get comments error:", err) + res.status(500).json({ message: err.message || "Server error" }) + } +}) + +// Post comment +router.post("/:catId/comments", verifyToken, async (req, res) => { + const { catId } = req.params + const { text } = req.body + + if (!text || !text.trim()) { + return res.status(400).json({ message: "Comment text cannot be empty" }) + } + + try { + const cat = await Cat.findById(catId) + if (!cat) return res.status(404).json({ message: "Cat not found" }) + + // New comment + cat.comments.push({ + userId: req.user.id, + userName: req.user.name, + text: text.trim(), + }) + + await cat.save() + + const newComment = cat.comments[cat.comments.length - 1] + res.status(201).json(newComment) + } catch (err) { + console.error("Create comment error:", err) + res.status(500).json({ message: err.message || "Server error" }) + } +}) + +export default router \ No newline at end of file diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index dfbb9042d..4b3b0f610 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -20,7 +20,7 @@ export const User = mongoose.model('User', UserSchema) // New User -router.post('/users/signup', async (req, res) => { +router.post('/signup', async (req, res) => { try { const { name, email, password } = req.body @@ -61,7 +61,7 @@ router.post('/users/signup', async (req, res) => { }) // Log In -router.post('/users/login', async (req, res) => { +router.post('/login', async (req, res) => { try { const { email, password } = req.body const user = await User.findOne({ email: email.toLowerCase() }) diff --git a/backend/server.js b/backend/server.js index a7efac311..298af9305 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,6 +3,7 @@ import dotenv from "dotenv" import express from "express" import mongoose from "mongoose" import catRouter from "./routes/catRoutes.js" +import commentRouter from "./routes/commentRoutes.js" import userRouter from "./routes/userRoutes.js" dotenv.config() @@ -10,8 +11,9 @@ dotenv.config() const app = express() app.use(cors()) app.use(express.json()) -app.use("/", catRouter) -app.use("/", userRouter) +app.use("/cats", catRouter) +app.use("/cats", commentRouter) +app.use("/users", userRouter) mongoose .connect(process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project", { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4e97c36ac..d50260346 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,30 +1,21 @@ -import { useState, useCallback } from "react" +import { useState, useCallback, useEffect } from "react" import { Routes, Route, Navigate, useNavigate } from "react-router-dom" import { GlobalStyle } from "./styling/GlobalStyles" import { LoginForm } from "./components/login" import { SignUpForm } from "./components/signup" import ProtectedRoute from "./pages/start" import { Dashboard } from "./pages/dashboard" +import { API_URL, fetchJson } from "./api" import styled from "styled-components" -export const API_URL = "http://localhost:8080" - export const App = () => { const navigate = useNavigate() const [cats, setCats] = useState([]) const [user, setUser] = useState(null) const [authMode, setAuthMode] = useState("login") - const authHeaders = useCallback( - () => ({ - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem("token")}`, - }), - [] - ) - const login = async (email, password) => { - const res = await fetch(`${API_URL}/users/login`, { + const res = await fetch(`${API_URL}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), @@ -62,26 +53,18 @@ export const App = () => { const loadCats = async () => { try { - const res = await fetch(`${API_URL}/cats`, { - headers: authHeaders(), + const data = await fetchJson(`${API_URL}/cats`, { }) - if (!res.ok) throw new Error(`Status ${res.status}`) - - const raw = await res.json() - const formatted = raw.map((item) => ({ - id: item._id, - name: item.name, - imageUrl: item.imageUrl, - gender: item.gender, - location: item.location, - userId: item.userId, - })) - setCats(formatted) + setCats(data) } catch (e) { console.error("Failed to load cats:", e) } } + useEffect(() => { + loadCats() + }, []) + const handleNewCat = (newCatFromForm) => { const formatted = { id: newCatFromForm._id, diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 000000000..b60f9df16 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,20 @@ +export const API_URL = "http://localhost:8080"; + +export const fetchJson = async (API_URL, options = {}) => { + const res = await fetch(API_URL, { + ...options, + headers: { + ...(options.headers || {}), + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }) + + if (!res.ok) { + const errBody = await res.json().catch(() => ({})) + const msg = errBody.message || `Status ${res.status}` + throw new Error(msg) + } + + return await res.json() +} \ No newline at end of file diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index b58eecba3..0f62fff6a 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -1,38 +1,119 @@ +import React, { useState, useEffect } from "react" import styled from "styled-components" +import { API_URL, fetchJson } from "../api" -export const CatCard = ({ cat }) => { - const { name, imageUrl, gender, location, createdAt } = cat +export const CatCard = ({ cat, currentUser }) => { + const catId = cat._id + const [expanded, setExpanded] = useState(false) + const [comments, setComments] = useState([]) + const [loading, setLoading] = useState(false) + const [newText, setNewText] = useState("") + const [error, setError] = useState("") + + const loadComments = async () => { + setLoading(true) + try { + const data = await fetchJson(`${API_URL}/cats/${catId}/comments`) + setComments(data) + } catch (e) { + console.error(e) + setError("Failed to load comments") + } finally { + setLoading(false) + } + } + + // Show comments + const toggleExpand = () => { + setExpanded((prev) => !prev) + if (!expanded && comments.length === 0) { + loadComments() + } + } + + // New comment + const handleSubmit = async (e) => { + e.preventDefault() + if (!newText.trim()) return + + try { + const created = await fetchJson(`${API_URL}/cats/${catId}/comments`, { + method: "POST", + body: JSON.stringify({ text: newText.trim() }), + }) + setComments((prev) => [created, ...prev]) + setNewText("") + } catch (e) { + console.error(e) + setError(e.message || "Could not post comment") + } + } return ( - + - + - {name} - - {gender} - {location} - + {cat.name} + + {cat.gender} + {cat.location} + - + + {/* Expand / collapse button */} + + {expanded ? "▲ Hide comments" : "▼ Show comments"} + + {expanded && ( + + {loading &&

Loading comments…

} + {error && {error}} + + + {comments.map((c) => ( + + {c.userName} + {new Date(c.createdAt).toLocaleString()} + {c.text} + + ))} + + + {currentUser && ( +
+ setNewText(e.target.value)} + required + /> + Post + + )} +
+ )} + ) } - -const CardContainer = styled.article` - border: 1px solid black; +const CardWrapper = styled.article` + width: 280px; + border: 1px solid #ddd; border-radius: 8px; overflow: hidden; + background: #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); display: flex; flex-direction: column; - align-items: center; ` const ImgWrapper = styled.div` width: 100%; - max-height: 200px; + height: 200px; + background: #f5f5f5; display: flex; align-items: center; justify-content: center; @@ -45,29 +126,89 @@ const CatImg = styled.img` ` const Info = styled.div` - display: flex; - flex-direction: column; - align-items: center; padding: 12px 16px; ` const Name = styled.h3` margin: 0 0 8px; - font-size: 20px; - color: black; + font-size: 1.1rem; ` -const Tags = styled.div` +const Meta = styled.div` display: flex; - flex-wrap: wrap; gap: 6px; - align-items: baseline; ` const Tag = styled.span` - border: 1px solid black; - color: black; + background: #e0f0ff; + color: #0066cc; padding: 2px 6px; border-radius: 4px; - font-size: 15px; + font-size: 0.85rem; +` + +const ToggleBtn = styled.button` + background: none; + border: none; + color: #0066cc; + padding: 8px; + cursor: pointer; + font-size: 0.9rem; + text-align: left; + &:hover { + text-decoration: underline; + } +` + +const ExpandedSection = styled.div` + padding: 12px 16px; + border-top: 1px solid #eee; +` + +const CommentList = styled.ul` + list-style: none; + padding: 0; + margin: 0 0 12px; +` + +const CommentItem = styled.li` + margin-bottom: 10px; + background: #f9f9f9; + padding: 6px 8px; + border-radius: 4px; +` + +const Author = styled.span` + font-weight: bold; + margin-right: 6px; +` + +const Timestamp = styled.span` + color: #666; + font-size: 0.8rem; +` + +const Text = styled.p` + margin: 4px 0 0; +` + +const CommentInput = styled.textarea` + width: 100%; + min-height: 60px; + resize: vertical; + margin-bottom: 6px; + padding: 6px; +` + +const CommentBtn = styled.button` + background: #0077cc; + color: #fff; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; +` + +const ErrorMsg = styled.p` + color: #c00; ` \ No newline at end of file diff --git a/frontend/src/components/cardlist.jsx b/frontend/src/components/cardlist.jsx index d55efc699..f5834ef3d 100644 --- a/frontend/src/components/cardlist.jsx +++ b/frontend/src/components/cardlist.jsx @@ -1,32 +1,13 @@ -import { useEffect, useState } from "react" import styled from "styled-components" import { CatCard } from "./card" -import { API_URL } from "../App" -export const CatList = () => { - const [cats, setCats] = useState([]) - - useEffect(() => { - const fetchCats = async () => { - try { - const res = await fetch(`${API_URL}/cats`) - const data = await res.json() - setCats(data) - } catch (error) { - console.error("Failed to load cats:", error) - } - } - fetchCats() - }, []) - - return ( - - {cats.map((cat) => ( - - ))} - - ) -} +export const CatList = ({ externalCats, currentUser }) => ( + + {externalCats.map((cat) => ( + + ))} + +) const Grid = styled.section` display: grid; diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index 471013f4d..8e549a219 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -1,6 +1,6 @@ import { useState } from "react" import styled from "styled-components" -import { API_URL } from "../App" +import { API_URL, fetchJson } from "../api" import placeholderImg from "../assets/placeholderImg.jpg" export const CatForm = ({ onSuccess }) => { @@ -51,7 +51,7 @@ export const CatForm = ({ onSuccess }) => { payload.append("gender", formData.gender) payload.append("location", formData.location) - const response = await fetch(`${API_URL}/cats`, { + const response = await fetchJson(`${API_URL}/cats`, { method: "POST", body: payload, }) @@ -62,7 +62,7 @@ export const CatForm = ({ onSuccess }) => { throw new Error(msg) } - const newCat = await response.json(); + const newCat = await response.json() if (typeof onSuccess === "function") { onSuccess(newCat) } @@ -106,7 +106,6 @@ export const CatForm = ({ onSuccess }) => { name="name" value={formData.name} onChange={handleChange} - // placeholder="Enter cats name" /> diff --git a/frontend/src/components/signup.jsx b/frontend/src/components/signup.jsx index 1b2c640ba..bb49c5671 100644 --- a/frontend/src/components/signup.jsx +++ b/frontend/src/components/signup.jsx @@ -1,6 +1,6 @@ import React, { useState } from "react" import styled from "styled-components" -import { API_URL } from "../App" +import { API_URL, fetchJson } from "../api" import { useNavigate } from "react-router-dom" export const SignUpForm = ({ onSuccess, setUser }) => { @@ -31,7 +31,7 @@ export const SignUpForm = ({ onSuccess, setUser }) => { setErrorMsg("") try { - const response = await fetch(`${API_URL}/users/signup`, { + const payload = await fetchJson(`${API_URL}/users/signup`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData), @@ -43,7 +43,7 @@ export const SignUpForm = ({ onSuccess, setUser }) => { throw new Error(msg) } - const payload = await response.json() + // const payload = await response.json() const user = payload.response || payload if (user.accessToken) { diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index 1248e98a7..d6cb2a2c6 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -26,7 +26,7 @@ export const Dashboard = ({ )} All Cats - + ) } diff --git a/package.json b/package.json index 49de104f7..ad23d426c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", "mongoose": "^9.2.1", "multer": "^2.0.2", "multer-storage-cloudinary": "^4.0.0", From d7bad30c1278bb11fba5da0b705380dd11545117 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 4 Mar 2026 10:14:25 +0100 Subject: [PATCH 05/33] Adds delete --- backend/models/Cat.js | 4 +- backend/models/Comments.js | 21 +++++-- backend/models/auth.js | 3 + backend/routes/catRoutes.js | 31 +++++++++- backend/routes/commentRoutes.js | 5 +- backend/routes/userRoutes.js | 11 ++-- backend/server.js | 19 +++--- frontend/src/App.jsx | 47 +++++++++++--- frontend/src/api.js | 15 ++--- frontend/src/components/card.jsx | 19 +++++- frontend/src/components/cardlist.jsx | 8 ++- frontend/src/components/catForm.jsx | 6 +- frontend/src/components/login.jsx | 93 +++++++++++++++++----------- frontend/src/components/signup.jsx | 31 ++++------ frontend/src/pages/dashboard.jsx | 6 +- frontend/src/styling/GlobalStyles.js | 2 + frontend/src/styling/Theme.js | 2 +- 17 files changed, 222 insertions(+), 101 deletions(-) diff --git a/backend/models/Cat.js b/backend/models/Cat.js index 454b8b9cc..ca1e989fe 100644 --- a/backend/models/Cat.js +++ b/backend/models/Cat.js @@ -1,4 +1,5 @@ import mongoose from "mongoose" +import commentSchema from "./Comments" const catSchema = new mongoose.Schema( { @@ -10,7 +11,8 @@ const catSchema = new mongoose.Schema( }, imageUrl: { type: String, required: true }, location: { type: String, required: true }, - }) + comments: [commentSchema], + }, { timestamps: true }) const Cat = mongoose.models.Cat || mongoose.model("Cat", catSchema) diff --git a/backend/models/Comments.js b/backend/models/Comments.js index ff5a0fe33..95eed9181 100644 --- a/backend/models/Comments.js +++ b/backend/models/Comments.js @@ -2,13 +2,22 @@ import mongoose from "mongoose" const commentSchema = new mongoose.Schema( { - catId: { type: mongoose.Schema.Types.ObjectId, ref: "Cat", required: true }, - userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, - userName: { type: String, required: true }, - text: { type: String, required: true, minlength: 1 }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + userName: { + type: String, + required: true, + }, + text: { + type: String, + required: true, + minlength: 1, + }, }, { timestamps: true } ) -export default mongoose.models.Comment || - mongoose.model("Comment", commentSchema) \ No newline at end of file +export default commentSchema \ No newline at end of file diff --git a/backend/models/auth.js b/backend/models/auth.js index adc095017..c78209a92 100644 --- a/backend/models/auth.js +++ b/backend/models/auth.js @@ -1,4 +1,7 @@ import jwt from "jsonwebtoken" +import dotenv from "dotenv" + +dotenv.config export const verifyToken = (req, res, next) => { const authHeader = req.headers.authorization diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index 6c9429e78..64fd88d09 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -31,7 +31,7 @@ const router = express.Router() // All cats router.get("/cats", async (req, res) => { try { - const cats = await Cat.find().sort({ createdAt: "desc" }) + const cats = await Cat.find().sort({ createdAt: -1 }) res.json(cats) } catch (error) { @@ -69,4 +69,33 @@ router.post("/cats", parser.single('picture'), async (req, res) => { } }) +router.delete("/cats/:id", async (req, res) => { + const id = req.params.id + try { + const cat = await Cat.findById(id) + + if (!cat) { + return res.status(404).json({ + success: false, + response: [], + message: "Cat not found" + }) + } + + await Cat.findByIdAndDelete(id) + + res.status(200).json({ + success: true, + response: id, + message: "Cat deleted successfully" + }) + } catch (error) { + res.status(500).json({ + success: false, + response: null, + message: error, + }) + } +}) + export default router \ No newline at end of file diff --git a/backend/routes/commentRoutes.js b/backend/routes/commentRoutes.js index d8d0d5327..1e0ae6fe8 100644 --- a/backend/routes/commentRoutes.js +++ b/backend/routes/commentRoutes.js @@ -5,13 +5,12 @@ import Cat from "../models/Cat.js" const router = express.Router() // Comments -router.get("/:catId/comments", async (req, res) => { +router.get("/cats/:catId/comments", async (req, res) => { const { catId } = req.params try { const cat = await Cat.findById(catId).select("comments") if (!cat) return res.status(404).json({ message: "Cat not found" }) - // Sort newest first (optional) const sorted = cat.comments.sort( (a, b) => b.createdAt.getTime() - a.createdAt.getTime() ) @@ -23,7 +22,7 @@ router.get("/:catId/comments", async (req, res) => { }) // Post comment -router.post("/:catId/comments", verifyToken, async (req, res) => { +router.post("/cats/:catId/comments", verifyToken, async (req, res) => { const { catId } = req.params const { text } = req.body diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index 4b3b0f610..bb74ebdb2 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -2,6 +2,9 @@ import bcrypt from "bcrypt" import crypto from "crypto" import express from "express" import mongoose from "mongoose" +import dotenv from "dotenv" + +dotenv.config const router = express.Router() @@ -10,7 +13,7 @@ const UserSchema = new mongoose.Schema({ name: { type: String, required: true }, email: { type: String, required: true, unique: true }, password: { type: String, required: true }, - accessToken: { + token: { type: String, default: () => crypto.randomBytes(128).toString("hex"), }, @@ -48,7 +51,7 @@ router.post('/signup', async (req, res) => { name: user.name, email: user.email, id: user._id, - accessToken: user.accessToken, + token: user.token, }, }) } catch (error) { @@ -61,7 +64,7 @@ router.post('/signup', async (req, res) => { }) // Log In -router.post('/login', async (req, res) => { +router.post("/login", async (req, res) => { try { const { email, password } = req.body const user = await User.findOne({ email: email.toLowerCase() }) @@ -74,7 +77,7 @@ router.post('/login', async (req, res) => { id: user._id, name: user.name, email: user.email, - accessToken: user.accessToken + token: user.token }, }) } else { diff --git a/backend/server.js b/backend/server.js index 298af9305..13392544a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2,19 +2,12 @@ import cors from "cors" import dotenv from "dotenv" import express from "express" import mongoose from "mongoose" +import userRouter from "./routes/userRoutes.js" import catRouter from "./routes/catRoutes.js" import commentRouter from "./routes/commentRoutes.js" -import userRouter from "./routes/userRoutes.js" dotenv.config() -const app = express() -app.use(cors()) -app.use(express.json()) -app.use("/cats", catRouter) -app.use("/cats", commentRouter) -app.use("/users", userRouter) - mongoose .connect(process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project", { useNewUrlParser: true, @@ -26,5 +19,15 @@ mongoose process.exit(1) }) +const app = express() +app.use(cors()) +app.use(express.json({ limit: "10mb" })) +app.use(express.urlencoded({ limit: "10mb", extended: true })) + +app.use("/users", userRouter) +app.use("/", catRouter) +app.use("/", commentRouter) +app.use("*", (req, res) => res.status(404).json({ message: "Not Found" })) + const PORT = process.env.PORT || 8080 app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`)) \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d50260346..8fa5f2544 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,7 +15,7 @@ export const App = () => { const [authMode, setAuthMode] = useState("login") const login = async (email, password) => { - const res = await fetch(`${API_URL}/login`, { + const res = await fetch(`${API_URL}/users/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), @@ -24,27 +24,46 @@ export const App = () => { if (!res.ok) throw new Error("Login failed") const { response } = await res.json() + console.log("login response:", response) + + if (response.token) { + localStorage.setItem("token", response.token) + } else { + console.warn("No token returned from login", response) + } + localStorage.setItem("user", JSON.stringify(response)) - if (response.accessToken) localStorage.setItem("token", response.accessToken) - if (response.id) localStorage.setItem("userId", response.id) setUser(response) - navigate("/dashboard") } + useEffect(() => { + const storedUser = localStorage.getItem("user") + if (storedUser) { + try { + setUser(JSON.parse(storedUser)) + } catch (e) { + console.warn("Corrupt user data in localStorage", e) + localStorage.removeItem("user") + } + } + }, []) + + const handleSignUpSuccess = (newUser) => { - localStorage.setItem("token", newUser.accessToken) + if (newUser.token) { + localStorage.setItem("token", newUser.token) + } localStorage.setItem("user", JSON.stringify(newUser)) setUser(newUser) navigate("/dashboard") } const handleLogout = () => { - setUser(null); localStorage.removeItem("user") localStorage.removeItem("token") - localStorage.removeItem("userId") + setUser(null) navigate("/login") } @@ -65,6 +84,7 @@ export const App = () => { loadCats() }, []) + const handleNewCat = (newCatFromForm) => { const formatted = { id: newCatFromForm._id, @@ -77,6 +97,15 @@ export const App = () => { setCats((prev) => [formatted, ...prev]) } + const deleteCat = async (id) => { + try { + await fetchJson(`${API_URL}/cats/${id}`, { method: "DELETE" }) + setCats((prev) => prev.filter((c) => c._id !== id)) + } catch (err) { + console.error("Delete cat error:", err) + } + } + return ( <> @@ -130,6 +159,7 @@ export const App = () => { loadCats={loadCats} logout={handleLogout} user={user} + onDelete={deleteCat} /> } /> @@ -140,6 +170,7 @@ export const App = () => { ) } +// Styling const ToggleWrapper = styled.div` margin-top: 12px; text-align: center; @@ -148,7 +179,7 @@ const ToggleWrapper = styled.div` const ToggleBtn = styled.button` background: none; border: none; - color: #0066cc; + color: #fcfcfc; cursor: pointer; font-size: 0.95rem; &:hover { diff --git a/frontend/src/api.js b/frontend/src/api.js index b60f9df16..cb12ed7d3 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,18 +1,19 @@ -export const API_URL = "http://localhost:8080"; +export const API_URL = "http://localhost:8080" -export const fetchJson = async (API_URL, options = {}) => { - const res = await fetch(API_URL, { +export const fetchJson = async (url, options = {}) => { + const token = localStorage.getItem("token") + const res = await fetch(url, { ...options, headers: { - ...(options.headers || {}), "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem("token")}`, + ...(options.headers || {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }) if (!res.ok) { - const errBody = await res.json().catch(() => ({})) - const msg = errBody.message || `Status ${res.status}` + const err = await res.json().catch(() => ({})) + const msg = err.message || `Status ${res.status}` throw new Error(msg) } diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index 0f62fff6a..d1979a14e 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect } from "react" +import React, { useState } from "react" import styled from "styled-components" import { API_URL, fetchJson } from "../api" -export const CatCard = ({ cat, currentUser }) => { +export const CatCard = ({ cat, currentUser, onDelete }) => { const catId = cat._id const [expanded, setExpanded] = useState(false) const [comments, setComments] = useState([]) @@ -37,8 +37,13 @@ export const CatCard = ({ cat, currentUser }) => { if (!newText.trim()) return try { + const token = localStorage.getItem("token") const created = await fetchJson(`${API_URL}/cats/${catId}/comments`, { method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, body: JSON.stringify({ text: newText.trim() }), }) setComments((prev) => [created, ...prev]) @@ -49,6 +54,13 @@ export const CatCard = ({ cat, currentUser }) => { } } + // Delete + const handleDelete = () => { + if (window.confirm(`Delete "${cat.name}"? This cannot be undone.`)) { + if (typeof onDelete === "function") onDelete(catId) + } + } + return ( @@ -62,6 +74,9 @@ export const CatCard = ({ cat, currentUser }) => { {cat.location} + {/* Expand / collapse button */} diff --git a/frontend/src/components/cardlist.jsx b/frontend/src/components/cardlist.jsx index f5834ef3d..4a1cfcf5d 100644 --- a/frontend/src/components/cardlist.jsx +++ b/frontend/src/components/cardlist.jsx @@ -1,10 +1,14 @@ import styled from "styled-components" import { CatCard } from "./card" -export const CatList = ({ externalCats, currentUser }) => ( +export const CatList = ({ externalCats, currentUser, onDelete }) => ( {externalCats.map((cat) => ( - + ))} ) diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index 8e549a219..5021b76b3 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -1,6 +1,6 @@ import { useState } from "react" import styled from "styled-components" -import { API_URL, fetchJson } from "../api" +import { API_URL } from "../api" import placeholderImg from "../assets/placeholderImg.jpg" export const CatForm = ({ onSuccess }) => { @@ -51,8 +51,10 @@ export const CatForm = ({ onSuccess }) => { payload.append("gender", formData.gender) payload.append("location", formData.location) - const response = await fetchJson(`${API_URL}/cats`, { + const token = localStorage.getItem("token") + const response = await fetch(`${API_URL}/cats`, { method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, body: payload, }) diff --git a/frontend/src/components/login.jsx b/frontend/src/components/login.jsx index 8a4adce59..7a6616196 100644 --- a/frontend/src/components/login.jsx +++ b/frontend/src/components/login.jsx @@ -1,6 +1,9 @@ import { useState } from "react" import styled from "styled-components" +// import { theme } from "../styling/Theme" +// + export const LoginForm = ({ login }) => { const [formData, setFormData] = useState({ email: "", @@ -25,6 +28,7 @@ export const LoginForm = ({ login }) => { } + const handleChange = (e) => { const { name, value } = e.target @@ -32,45 +36,55 @@ export const LoginForm = ({ login }) => { } return ( - -

Log in

- - - - Email - - - - Password - - - - - {error &&

{error}

} - - Log In -
+ + +

Log in

+ + + + Email + + + + Password + + + + + {error &&

{error}

} + + Log In +
+
) } export default LoginForm +const Wrapper = styled.div` + padding: 10px; + border: 1px solid #265237; + border-radius: 24px; + box-shadow: 0 0 0.25rem 0.5rem rgba(0, 0, 0, .15); + margin-bottom: 50px; + background-color: #2B5C3F; +` + const FormWrapper = styled.form` -background: #f2f0f0; -border: 1px solid black; -box-shadow: 10px 10px 0 black; -padding: 20px; -margin-bottom: 50px; + background: #47785A; + border: 1px solid #417354; + border-radius: 16px; + padding: 20px; ` const StyledDiv = styled.div` @@ -84,13 +98,18 @@ const Styledlabel = styled.label` flex-direction: column; ` +const StyledInput = styled.input` + /* background-color: #d4f9e4; */ +` + const StyledBtn = styled.button` - background-color: white; - border: 2px solid #c9c8c8; - padding: 4px; + background-color: #b0cebd; + border: 2px solid #3f895c; + border-radius: 8px; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; &:hover { - border: 2px solid black; + border: 2px solid #142d1e; cursor: pointer; } ` \ No newline at end of file diff --git a/frontend/src/components/signup.jsx b/frontend/src/components/signup.jsx index bb49c5671..2572ac88a 100644 --- a/frontend/src/components/signup.jsx +++ b/frontend/src/components/signup.jsx @@ -1,9 +1,9 @@ import React, { useState } from "react" import styled from "styled-components" -import { API_URL, fetchJson } from "../api" +import { API_URL } from "../api" import { useNavigate } from "react-router-dom" -export const SignUpForm = ({ onSuccess, setUser }) => { +export const SignUpForm = ({ setUser }) => { const navigate = useNavigate() const [formData, setFormData] = useState({ name: "", @@ -31,34 +31,29 @@ export const SignUpForm = ({ onSuccess, setUser }) => { setErrorMsg("") try { - const payload = await fetchJson(`${API_URL}/users/signup`, { + const res = await fetch(`${API_URL}/users/signup`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData), }) - if (!response.ok) { - const errBody = await response.json().catch(() => ({})) - const msg = errBody.message || `Status ${response.status}` - throw new Error(msg) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.message || `Status ${res.status}`) } - // const payload = await response.json() + const payload = await res.json() const user = payload.response || payload - if (user.accessToken) { - localStorage.setItem("token", user.accessToken) - } + // Store token & user + if (user.token) localStorage.setItem("token", user.token) localStorage.setItem("user", JSON.stringify(user)) - if (typeof setUser === "function") setUser(user) - if (typeof onSuccess === "function") onSuccess(user) + setUser(user); navigate("/dashboard") - } catch (error) { - console.error("Sign‑up error:", error); - setErrorMsg(error.message || "Could not create account") - } finally { - setIsSubmitting(false) + } catch (err) { + console.error(err) + setErrorMsg(err.message) } } diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index d6cb2a2c6..f1e67e11b 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -10,6 +10,7 @@ export const Dashboard = ({ loadCats, logout, user, + onDelete, }) => { useEffect(() => { loadCats() @@ -26,7 +27,10 @@ export const Dashboard = ({ )} All Cats - + ) } diff --git a/frontend/src/styling/GlobalStyles.js b/frontend/src/styling/GlobalStyles.js index f88712f0b..510ac2b6f 100644 --- a/frontend/src/styling/GlobalStyles.js +++ b/frontend/src/styling/GlobalStyles.js @@ -14,5 +14,7 @@ export const GlobalStyle = createGlobalStyle` width: 70%; max-width: 500px; padding: 30px; + + background-color: #3d3a37; } ` \ No newline at end of file diff --git a/frontend/src/styling/Theme.js b/frontend/src/styling/Theme.js index 7edd4755a..9c21d5d8d 100644 --- a/frontend/src/styling/Theme.js +++ b/frontend/src/styling/Theme.js @@ -1,5 +1,5 @@ // export const theme = { // colors: { -// backgroundColor: "#eeeced" +// backgroundColor: "#1F4831" // } // } \ No newline at end of file From 8006655f0e88a10336104a77fe5d8414393e01e3 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 4 Mar 2026 14:41:34 +0100 Subject: [PATCH 06/33] Adds delete comment --- backend/models/auth.js | 19 +++++++--- backend/routes/catRoutes.js | 12 ++++++ backend/routes/commentRoutes.js | 24 ++++++++++++ frontend/src/App.jsx | 33 +++++++++++++++- frontend/src/components/card.jsx | 57 +++++++++------------------- frontend/src/components/cardlist.jsx | 6 ++- frontend/src/pages/dashboard.jsx | 6 ++- 7 files changed, 108 insertions(+), 49 deletions(-) diff --git a/backend/models/auth.js b/backend/models/auth.js index c78209a92..886527330 100644 --- a/backend/models/auth.js +++ b/backend/models/auth.js @@ -1,9 +1,10 @@ import jwt from "jsonwebtoken" import dotenv from "dotenv" +import { User } from "../routes/userRoutes" dotenv.config -export const verifyToken = (req, res, next) => { +export const verifyToken = async (req, res, next) => { const authHeader = req.headers.authorization if (!authHeader) { return res.status(401).json({ message: "Missing Authorization header" }) @@ -11,14 +12,20 @@ export const verifyToken = (req, res, next) => { const token = authHeader.split(" ")[1] try { - const payload = jwt.verify(token, process.env.JWT_SECRET) + const existingUser = await User.findOne({ + token: token + }) + + if (!existingUser) { + throw new Error("No user found") + } + req.user = { - id: payload.id, - name: payload.name, - email: payload.email, + id: existingUser.id, + name: existingUser.name, } next() } catch (err) { - return res.status(401).json({ message: "Invalid token" }) + return res.status(401).json({ message: `Invalid token: ${err}` }) } } \ No newline at end of file diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index 64fd88d09..030be2eaf 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -39,6 +39,18 @@ router.get("/cats", async (req, res) => { } }) +// One cat +router.get("/cats/:id", async (req, res) => { + const id = req.params.id + try { + const cat = await Cat.findById(id) + res.json(cat) + + } catch (error) { + res.status(500).json({ error: "Failed to fetch cat" }) + } +}) + // Post router.post("/cats", parser.single('picture'), async (req, res) => { try { diff --git a/backend/routes/commentRoutes.js b/backend/routes/commentRoutes.js index 1e0ae6fe8..a083426e2 100644 --- a/backend/routes/commentRoutes.js +++ b/backend/routes/commentRoutes.js @@ -51,4 +51,28 @@ router.post("/cats/:catId/comments", verifyToken, async (req, res) => { } }) +// Delete comment +router.delete("/cats/:catId/comments/:commentId", verifyToken, async (req, res) => { + const { catId, commentId } = req.params + const cat = await Cat.findById(catId) + if (!cat) return res.status(404).json({ message: "Cat not found" }) + try { + cat.comments.pull({ _id: commentId }) + + await cat.save() + + res.status(200).json({ + success: true, + response: commentId, + message: "Comment deleted successfully" + }) + } catch (error) { + res.status(500).json({ + success: false, + response: null, + message: error, + }) + } +}) + export default router \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8fa5f2544..61cdacc1f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -84,7 +84,7 @@ export const App = () => { loadCats() }, []) - + // Cats const handleNewCat = (newCatFromForm) => { const formatted = { id: newCatFromForm._id, @@ -106,6 +106,35 @@ export const App = () => { } } + // Comments + const createComment = async (catId, newText) => { + try { + const token = localStorage.getItem("token") + await fetchJson(`${API_URL}/cats/${catId}/comments`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ text: newText.trim() }), + }) + loadCats() + } catch (e) { + console.error(e) + setError(e.message || "Could not post comment") + } + } + + + const deleteComment = async (catId, commentId) => { + try { + await fetchJson(`${API_URL}/cats/${catId}/comments/${commentId}`, { method: "DELETE" }) + loadCats() + } catch (err) { + console.error("Delete comment error:", err) + } + } + return ( <> @@ -159,7 +188,9 @@ export const App = () => { loadCats={loadCats} logout={handleLogout} user={user} + onCreateComment={createComment} onDelete={deleteCat} + onDeleteComment={deleteComment} /> } /> diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index d1979a14e..307a5cf1f 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -2,59 +2,34 @@ import React, { useState } from "react" import styled from "styled-components" import { API_URL, fetchJson } from "../api" -export const CatCard = ({ cat, currentUser, onDelete }) => { +export const CatCard = ({ cat, currentUser, onCreateComment, onDelete, onDeleteComment }) => { const catId = cat._id const [expanded, setExpanded] = useState(false) const [comments, setComments] = useState([]) const [loading, setLoading] = useState(false) const [newText, setNewText] = useState("") const [error, setError] = useState("") - - const loadComments = async () => { - setLoading(true) - try { - const data = await fetchJson(`${API_URL}/cats/${catId}/comments`) - setComments(data) - } catch (e) { - console.error(e) - setError("Failed to load comments") - } finally { - setLoading(false) - } - } + const [isEditing, setIsEditing] = useState(false) // Show comments const toggleExpand = () => { setExpanded((prev) => !prev) - if (!expanded && comments.length === 0) { - loadComments() - } } // New comment - const handleSubmit = async (e) => { + const handleCreateComment = async (e) => { e.preventDefault() if (!newText.trim()) return + if (typeof onCreateComment === "function") onCreateComment(catId, newText.trim()) + setNewText("") + } - try { - const token = localStorage.getItem("token") - const created = await fetchJson(`${API_URL}/cats/${catId}/comments`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ text: newText.trim() }), - }) - setComments((prev) => [created, ...prev]) - setNewText("") - } catch (e) { - console.error(e) - setError(e.message || "Could not post comment") - } + // Delete comment + const handleDeleteComment = (catId, commentId) => { + if (typeof onDelete === "function") onDeleteComment(catId, commentId) } - // Delete + // Delete cat const handleDelete = () => { if (window.confirm(`Delete "${cat.name}"? This cannot be undone.`)) { if (typeof onDelete === "function") onDelete(catId) @@ -88,17 +63,20 @@ export const CatCard = ({ cat, currentUser, onDelete }) => { {error && {error}} - {comments.map((c) => ( + {cat.comments.map((c) => ( {c.userName} {new Date(c.createdAt).toLocaleString()} + {c.text} ))} {currentUser && ( -
+ { )} - )} -
+ ) + } + ) } diff --git a/frontend/src/components/cardlist.jsx b/frontend/src/components/cardlist.jsx index 4a1cfcf5d..3fcc6ed30 100644 --- a/frontend/src/components/cardlist.jsx +++ b/frontend/src/components/cardlist.jsx @@ -1,14 +1,16 @@ import styled from "styled-components" import { CatCard } from "./card" -export const CatList = ({ externalCats, currentUser, onDelete }) => ( +export const CatList = ({ externalCats, currentUser, onCreateComment, onDelete, onDeleteComment }) => ( {externalCats.map((cat) => ( + onCreateComment={onCreateComment} + onDelete={onDelete} + onDeleteComment={onDeleteComment} /> ))} ) diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index f1e67e11b..b29298b1a 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -10,7 +10,9 @@ export const Dashboard = ({ loadCats, logout, user, + onCreateComment, onDelete, + onDeleteComment, }) => { useEffect(() => { loadCats() @@ -30,7 +32,9 @@ export const Dashboard = ({ + onCreateComment={onCreateComment} + onDelete={onDelete} + onDeleteComment={onDeleteComment} /> ) } From 39bf2006bcc6e6c7b4106b4c06cf10aa04a31d83 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 4 Mar 2026 16:25:11 +0100 Subject: [PATCH 07/33] Adds edit mode --- backend/routes/catRoutes.js | 22 ++ frontend/src/App.jsx | 17 ++ frontend/src/components/card.jsx | 361 +++++++++++++++++---------- frontend/src/components/cardlist.jsx | 3 +- frontend/src/pages/dashboard.jsx | 2 + 5 files changed, 275 insertions(+), 130 deletions(-) diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index 030be2eaf..2fb367332 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -4,6 +4,7 @@ import dotenv from "dotenv" import { CloudinaryStorage } from "multer-storage-cloudinary" import cloudinaryFramework from "cloudinary" import Cat from "../models/Cat" +import { verifyToken } from "../models/auth" dotenv.config() @@ -81,6 +82,27 @@ router.post("/cats", parser.single('picture'), async (req, res) => { } }) +// Edit +router.put("/cats/:id", verifyToken, async (req, res) => { + try { + + const editedCat = req.body + + const cat = await Cat.findById(editedCat._id) + + cat.name = editedCat.name + cat.gender = editedCat.gender + cat.location = editedCat.location + + await cat.save() + res.json(cat) + + } catch (error) { + res.status(500).json({ error: "Failed to edit cat" }) + } +}) + +// Delete router.delete("/cats/:id", async (req, res) => { const id = req.params.id try { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 61cdacc1f..dfb340466 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -97,6 +97,22 @@ export const App = () => { setCats((prev) => [formatted, ...prev]) } + const onEdit = async (catId, cat) => { + try { + const token = localStorage.getItem("token") + await fetchJson(`${API_URL}/cats/${catId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(cat), + }) + loadCats() + } catch (e) { console.log(e) } + } + + const deleteCat = async (id) => { try { await fetchJson(`${API_URL}/cats/${id}`, { method: "DELETE" }) @@ -186,6 +202,7 @@ export const App = () => { setCats={setCats} handleNewCat={handleNewCat} loadCats={loadCats} + onEdit={onEdit} logout={handleLogout} user={user} onCreateComment={createComment} diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index 307a5cf1f..b1ff55e88 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -2,7 +2,7 @@ import React, { useState } from "react" import styled from "styled-components" import { API_URL, fetchJson } from "../api" -export const CatCard = ({ cat, currentUser, onCreateComment, onDelete, onDeleteComment }) => { +export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, onDeleteComment }) => { const catId = cat._id const [expanded, setExpanded] = useState(false) const [comments, setComments] = useState([]) @@ -10,6 +10,7 @@ export const CatCard = ({ cat, currentUser, onCreateComment, onDelete, onDeleteC const [newText, setNewText] = useState("") const [error, setError] = useState("") const [isEditing, setIsEditing] = useState(false) + const [formData, setFormData] = useState(cat) // Show comments const toggleExpand = () => { @@ -36,173 +37,275 @@ export const CatCard = ({ cat, currentUser, onCreateComment, onDelete, onDeleteC } } + // Edit + const handleSave = async () => { + await onEdit(catId, formData) + setIsEditing(false) + } + + const handleCancel = () => { + setIsEditing(false) + } + + const handleNameChange = (e) => { + setFormData({ ...formData, name: e.target.value.trim() }) + } + + const handleGenderChange = (e) => { + setFormData({ ...formData, gender: e.target.value }) + } + + const handleLocationChange = (e) => { + setFormData({ ...formData, location: e.target.value }) + } + return ( - - - - - - {cat.name} - - {cat.gender} - {cat.location} - - - - - {/* Expand / collapse button */} - - {expanded ? "▲ Hide comments" : "▼ Show comments"} - - {expanded && ( - - {loading &&

Loading comments…

} - {error && {error}} - - - {cat.comments.map((c) => ( - - {c.userName} - {new Date(c.createdAt).toLocaleString()} - - {c.text} - - ))} - - - {currentUser && ( -
- setNewText(e.target.value)} - required - /> - Post - - )} -
- ) - } + {isEditing ? ( + + <> + + + +
+ + +
+ {/* Gender */} +
+ + +
+ {/* Location */} +
+ + + +
+ + + 💾 + ✖️ + + + + ) : ( + + <> + + + + + + {cat.name} + setIsEditing(true)}>✏️ + + + {cat.gender} + {cat.location} + + + + + {/* Expand / collapse button */} + + {expanded ? "▲ Hide comments" : "▼ Show comments"} + + {expanded && ( + + {loading &&

Loading comments…

} + {error && {error}} + + + {cat.comments.map((c) => ( + + {c.userName} + {new Date(c.createdAt).toLocaleString()} + + {c.text} + + ))} + + + {currentUser && ( +
+ setNewText(e.target.value)} + required + /> + Post + + )} +
+ ) + } + + )}
) } const CardWrapper = styled.article` - width: 280px; - border: 1px solid #ddd; - border-radius: 8px; - overflow: hidden; - background: #fff; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); - display: flex; - flex-direction: column; -` + width: 280px; + border: 1px solid #ddd; + border-radius: 8px; + overflow: hidden; + background: #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + ` const ImgWrapper = styled.div` - width: 100%; - height: 200px; - background: #f5f5f5; - display: flex; - align-items: center; - justify-content: center; -` + width: 100%; + height: 200px; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + ` const CatImg = styled.img` - max-width: 100%; - max-height: 100%; - object-fit: cover; -` + max-width: 100%; + max-height: 100%; + object-fit: cover; + ` const Info = styled.div` - padding: 12px 16px; -` + padding: 12px 16px; + ` const Name = styled.h3` - margin: 0 0 8px; - font-size: 1.1rem; -` + margin: 0 0 8px; + font-size: 1.1rem; + ` const Meta = styled.div` - display: flex; - gap: 6px; -` + display: flex; + gap: 6px; + ` -const Tag = styled.span` - background: #e0f0ff; - color: #0066cc; - padding: 2px 6px; - border-radius: 4px; - font-size: 0.85rem; -` +const EditMode = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + margin-left: 3px; + ` -const ToggleBtn = styled.button` - background: none; +const OtherBtn = styled.button` + background: #FFFFFF; + border-radius: 50px; border: none; - color: #0066cc; - padding: 8px; + font-size: 1rem; cursor: pointer; - font-size: 0.9rem; - text-align: left; + margin-right: 6px; + &:hover { - text-decoration: underline; + transform: scale(1.15); } ` +const Tag = styled.span` + background: #e0f0ff; + color: #0066cc; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.85rem; + ` + +const ToggleBtn = styled.button` + background: none; + border: none; + color: #0066cc; + padding: 8px; + cursor: pointer; + font-size: 0.9rem; + text-align: left; + &:hover { + text-decoration: underline; + } + ` + const ExpandedSection = styled.div` - padding: 12px 16px; - border-top: 1px solid #eee; -` + padding: 12px 16px; + border-top: 1px solid #eee; + ` const CommentList = styled.ul` - list-style: none; - padding: 0; - margin: 0 0 12px; -` + list-style: none; + padding: 0; + margin: 0 0 12px; + ` const CommentItem = styled.li` - margin-bottom: 10px; - background: #f9f9f9; - padding: 6px 8px; - border-radius: 4px; -` + margin-bottom: 10px; + background: #f9f9f9; + padding: 6px 8px; + border-radius: 4px; + ` const Author = styled.span` - font-weight: bold; - margin-right: 6px; -` + font-weight: bold; + margin-right: 6px; + ` const Timestamp = styled.span` - color: #666; - font-size: 0.8rem; -` + color: #666; + font-size: 0.8rem; + ` const Text = styled.p` - margin: 4px 0 0; -` + margin: 4px 0 0; + ` const CommentInput = styled.textarea` - width: 100%; - min-height: 60px; - resize: vertical; - margin-bottom: 6px; - padding: 6px; -` + width: 100%; + min-height: 60px; + resize: vertical; + margin-bottom: 6px; + padding: 6px; + ` const CommentBtn = styled.button` - background: #0077cc; - color: #fff; - border: none; - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; -` + background: #0077cc; + color: #fff; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + ` const ErrorMsg = styled.p` - color: #c00; -` \ No newline at end of file + color: #c00; + ` \ No newline at end of file diff --git a/frontend/src/components/cardlist.jsx b/frontend/src/components/cardlist.jsx index 3fcc6ed30..8dc0426bf 100644 --- a/frontend/src/components/cardlist.jsx +++ b/frontend/src/components/cardlist.jsx @@ -1,7 +1,7 @@ import styled from "styled-components" import { CatCard } from "./card" -export const CatList = ({ externalCats, currentUser, onCreateComment, onDelete, onDeleteComment }) => ( +export const CatList = ({ externalCats, currentUser, onEdit, onCreateComment, onDelete, onDeleteComment }) => ( {externalCats.map((cat) => ( ))} diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index b29298b1a..997219518 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -11,6 +11,7 @@ export const Dashboard = ({ logout, user, onCreateComment, + onEdit, onDelete, onDeleteComment, }) => { @@ -33,6 +34,7 @@ export const Dashboard = ({ externalCats={cats} currentUser={user} onCreateComment={onCreateComment} + onEdit={onEdit} onDelete={onDelete} onDeleteComment={onDeleteComment} /> From a0dd1c0cc99cd24fa1dc72db99a36c3666717f51 Mon Sep 17 00:00:00 2001 From: Julia Date: Thu, 5 Mar 2026 12:53:05 +0100 Subject: [PATCH 08/33] Adds styling --- backend/routes/catRoutes.js | 6 +- frontend/src/App.jsx | 2 +- frontend/src/assets/placeholder.png | Bin 0 -> 19735 bytes frontend/src/components/card.jsx | 221 +++++++++++++++------------ frontend/src/components/catForm.jsx | 118 +++++++++----- frontend/src/components/login.jsx | 32 ++-- frontend/src/components/signup.jsx | 117 +++++++------- frontend/src/pages/dashboard.jsx | 20 ++- frontend/src/styling/GlobalStyles.js | 2 +- 9 files changed, 308 insertions(+), 210 deletions(-) create mode 100644 frontend/src/assets/placeholder.png diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index 2fb367332..040f9b37b 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -21,7 +21,11 @@ const storage = new CloudinaryStorage({ params: { folder: "cats", allowedFormats: ["jpg", "png"], - transformation: [{ width: 500, height: 500, crop: "limit" }], + transformation: [{ + width: 500, + height: 500, + crop: "limit", + }], }, }) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index dfb340466..a0fc98636 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -227,7 +227,7 @@ const ToggleWrapper = styled.div` const ToggleBtn = styled.button` background: none; border: none; - color: #fcfcfc; + color: #000000; cursor: pointer; font-size: 0.95rem; &:hover { diff --git a/frontend/src/assets/placeholder.png b/frontend/src/assets/placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..2b6d5361d1772364c3c9a265013b914188fd7868 GIT binary patch literal 19735 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 V7g7hQtsIKPO$}Z3!fTc<{tJbV7f1jA literal 0 HcmV?d00001 diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index b1ff55e88..e888e55fc 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -101,9 +101,11 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o value={formData.location} onChange={handleLocationChange} > - + + + @@ -122,16 +124,18 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o {cat.name} - setIsEditing(true)}>✏️ - - - {cat.gender} - {cat.location} - + + setIsEditing(true)}>✏️ + + 🗑️ + + - + + + {cat.gender} + {cat.location} + {/* Expand / collapse button */} @@ -145,11 +149,15 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o {cat.comments.map((c) => ( - {c.userName} - {new Date(c.createdAt).toLocaleString()} - + +
+ {c.userName} + {new Date(c.createdAt).toLocaleString()} +
+ handleDeleteComment(catId, c._id)}> + 🗑️ + +
{c.text}
))} @@ -176,44 +184,58 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o } const CardWrapper = styled.article` - width: 280px; - border: 1px solid #ddd; - border-radius: 8px; - overflow: hidden; - background: #fff; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); - display: flex; - flex-direction: column; - ` + max-width: 365px; + border: 2px solid #3f895c; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + /* margin: 3px 0px; */ + ` const ImgWrapper = styled.div` - width: 100%; - height: 200px; - background: #f5f5f5; - display: flex; - align-items: center; - justify-content: center; - ` + width: 100%; + height: 200px; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + ` const CatImg = styled.img` - max-width: 100%; - max-height: 100%; - object-fit: cover; - ` + max-width: 100%; + max-height: 100%; + object-fit: cover; + ` const Info = styled.div` - padding: 12px 16px; - ` + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 12px 16px; + ` + +// const Select = styled.select` +// height: 40px; +// ` + +const EditDelete = styled.div` + display: flex; + flex-direction: row; +` const Name = styled.h3` - margin: 0 0 8px; - font-size: 1.1rem; - ` + margin: 0 0 8px; + font-size: 1.1rem; + ` const Meta = styled.div` - display: flex; - gap: 6px; - ` + display: flex; + flex-direction: row; + margin: 5px 15px; + gap: 5px; + ` const EditMode = styled.div` display: flex; @@ -237,75 +259,86 @@ const OtherBtn = styled.button` ` const Tag = styled.span` - background: #e0f0ff; - color: #0066cc; - padding: 2px 6px; - border-radius: 4px; - font-size: 0.85rem; - ` + background: #d4ded7; + /* color: #000000; */ + padding: 2px 6px; + border-radius: 4px; + font-size: 0.85rem; + ` const ToggleBtn = styled.button` - background: none; - border: none; - color: #0066cc; - padding: 8px; - cursor: pointer; - font-size: 0.9rem; - text-align: left; - &:hover { - text-decoration: underline; + background: none; + border: none; + color: #000000; + padding: 8px; + cursor: pointer; + font-size: 0.9rem; + text-align: left; + + &:hover { + text-decoration: underline; } - ` + ` const ExpandedSection = styled.div` - padding: 12px 16px; - border-top: 1px solid #eee; - ` + padding: 12px 16px; + border-top: 1px solid #eee; + ` const CommentList = styled.ul` - list-style: none; - padding: 0; - margin: 0 0 12px; - ` + list-style: none; + padding: 0; + margin: 0 0 12px; + ` const CommentItem = styled.li` - margin-bottom: 10px; - background: #f9f9f9; - padding: 6px 8px; - border-radius: 4px; - ` + margin-bottom: 10px; + background: #f9f9f9; + padding: 6px 8px; + width: 90%; + ` + +const CommentInfo = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; +` const Author = styled.span` - font-weight: bold; - margin-right: 6px; - ` + color: #3f895c; + font-weight: bold; + margin-right: 6px; + ` const Timestamp = styled.span` - color: #666; - font-size: 0.8rem; - ` + color: #666; + font-size: 0.8rem; + ` const Text = styled.p` - margin: 4px 0 0; - ` + margin: 4px 0 0; + ` const CommentInput = styled.textarea` - width: 100%; - min-height: 60px; - resize: vertical; - margin-bottom: 6px; - padding: 6px; - ` + width: 92%; + min-height: 60px; + resize: vertical; + margin-bottom: 6px; + padding: 6px; + ` const CommentBtn = styled.button` - background: #0077cc; - color: #fff; - border: none; - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; - ` + background-color: #b0cebd; + border: 2px solid #3f895c; + border-radius: 8px; + padding: 6px 12px; + + &:hover { + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; + cursor: pointer; + } +` const ErrorMsg = styled.p` - color: #c00; - ` \ No newline at end of file + color: #c00; + ` \ No newline at end of file diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index 5021b76b3..da51fcf28 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -1,7 +1,7 @@ import { useState } from "react" import styled from "styled-components" import { API_URL } from "../api" -import placeholderImg from "../assets/placeholderImg.jpg" +import placeholder from "../assets/placeholder.png" export const CatForm = ({ onSuccess }) => { const [formData, setFormData] = useState({ @@ -13,7 +13,7 @@ export const CatForm = ({ onSuccess }) => { const [errorMsg, setErrorMsg] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) - const [previewUrl, setPreviewUrl] = useState(placeholderImg) + const [previewUrl, setPreviewUrl] = useState(placeholder) const handleChange = (e) => { const { name, value, files } = e.target @@ -25,7 +25,7 @@ export const CatForm = ({ onSuccess }) => { setPreviewUrl(URL.createObjectURL(file)) } else { setFormData((prev) => ({ ...prev, picture: null })) - setPreviewUrl(placeholderImg) + setPreviewUrl(placeholder) } } else { setFormData((prev) => ({ ...prev, [name]: value })) @@ -69,6 +69,9 @@ export const CatForm = ({ onSuccess }) => { onSuccess(newCat) } + setFormData(setFormData) + setPreviewUrl(placeholder) + } catch (error) { console.error("Submit error:", error) setErrorMsg("Could not save cat information") @@ -78,10 +81,10 @@ export const CatForm = ({ onSuccess }) => { } return ( - - + +
- + {previewUrl && ( { /> )} - + + +
{/* Name */} -
+ - -
+ {/* Gender */} -
+ - -
+ + {/* Location */} -
+ - -
+ + {/* Submit / Feedback */} {errorMsg &&

{errorMsg}

} - -
-
+ + + ) } -const FormWrapper = styled.div` - display: flex; - flex-direction: column; - align-items: center; //?? - border: black solid 2px; - border-radius: 15px; +const Wrapper = styled.main` + padding: 10px; + border: 1px solid #265237; + border-radius: 24px; + /* box-shadow: 0 0 0.25rem 0.5rem rgba(0, 0, 0, .15); */ + margin-bottom: 50px; + background-color: #2B5C3F; + max-width: 345px; +` + +const FormWrapper = styled.form` + background: #d4ded7; + border: 1px solid #417354; + border-radius: 16px; + padding: 20px; + ` + +const StyledName = styled.input` + width: 70%; +` + +const StyledSelect = styled.select` + width: 50%; + text-align: center; ` -const StyledForm = styled.form` - max-width: 400px; - margin: 10px; +const StyledRow = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 3px 0px; ` const ImageBox = styled.div` @@ -183,4 +213,16 @@ const PreviewImg = styled.img` width: "100%"; margin-top: 8; border-radius: 4; +` + +const StyledBtn = styled.button` + background-color: #b0cebd; + border: 2px solid #3f895c; + border-radius: 8px; + padding: 4px; + + &:hover { + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; + cursor: pointer; + } ` \ No newline at end of file diff --git a/frontend/src/components/login.jsx b/frontend/src/components/login.jsx index 7a6616196..bb7036fb6 100644 --- a/frontend/src/components/login.jsx +++ b/frontend/src/components/login.jsx @@ -36,29 +36,29 @@ export const LoginForm = ({ login }) => { } return ( - +

Log in

- + Email - - - + + Password - - + {error &&

{error}

} @@ -71,7 +71,7 @@ export const LoginForm = ({ login }) => { export default LoginForm -const Wrapper = styled.div` +const Wrapper = styled.main` padding: 10px; border: 1px solid #265237; border-radius: 24px; @@ -81,7 +81,7 @@ const Wrapper = styled.div` ` const FormWrapper = styled.form` - background: #47785A; + background: #d4ded7; border: 1px solid #417354; border-radius: 16px; padding: 20px; @@ -93,23 +93,19 @@ const StyledDiv = styled.div` margin: 5px 0px; ` -const Styledlabel = styled.label` +const StyledLabel = styled.label` display: flex; flex-direction: column; ` -const StyledInput = styled.input` - /* background-color: #d4f9e4; */ -` - const StyledBtn = styled.button` background-color: #b0cebd; border: 2px solid #3f895c; border-radius: 8px; - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; - - &:hover { - border: 2px solid #142d1e; + padding: 4px; + + &:hover { + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; cursor: pointer; } ` \ No newline at end of file diff --git a/frontend/src/components/signup.jsx b/frontend/src/components/signup.jsx index 2572ac88a..70ef056be 100644 --- a/frontend/src/components/signup.jsx +++ b/frontend/src/components/signup.jsx @@ -58,81 +58,92 @@ export const SignUpForm = ({ setUser }) => { } return ( - -

Sign up

- - {errorMsg && {errorMsg}} - - - - Name - - - - - Email - - - - - Password - - - - - - {isSubmitting ? "Creating…" : "Sign up"} - -
+ + +

Sign up

+ + {errorMsg && {errorMsg}} + + + + Name + + + + + Email + + + + + Password + + + + + + {isSubmitting ? "Creating…" : "Sign up"} + +
+
) } export default SignUpForm +const Wrapper = styled.main` + padding: 10px; + border: 1px solid #265237; + border-radius: 24px; + box-shadow: 0 0 0.25rem 0.5rem rgba(0, 0, 0, .15); + margin-bottom: 50px; + background-color: #2B5C3F; +` + const FormWrapper = styled.form` - background: #f2f0f0; - border: 1px solid black; - box-shadow: 10px 10px 0 black; + background: #d4ded7; + border: 1px solid #417354; + border-radius: 16px; padding: 20px; - margin-bottom: 50px; ` const StyledDiv = styled.div` display: flex; flex-direction: column; - margin: 5px 0; + margin: 5px 0px; ` const StyledLabel = styled.label` display: flex; flex-direction: column; - margin-bottom: 12px; ` const StyledBtn = styled.button` - background-color: white; - border: 2px solid #c9c8c8; + background-color: #b0cebd; + border: 2px solid #3f895c; + border-radius: 8px; padding: 4px; + &:hover { - border: 2px solid black; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; cursor: pointer; } ` diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index 997219518..214b0d618 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -20,12 +20,12 @@ export const Dashboard = ({ }, []) return ( - -
Cat. Archive. Tracking. System.
+ +
Cat. Archive. Tracking. System.
{user && ( Welcome, {user.name}! - + Logout )} @@ -43,7 +43,7 @@ export const Dashboard = ({ const PageWrapper = styled.main` - max-width: 960px; + max-width: 345px; margin: 0 auto; padding: 20px; ` @@ -62,4 +62,16 @@ const UserBar = styled.div` display: flex; justify-content: space-between; margin-bottom: 20px; +` + +const StyledBtn = styled.button` + background-color: #b0cebd; + border: 2px solid #3f895c; + border-radius: 8px; + padding: 4px; + + &:hover { + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; + cursor: pointer; + } ` \ No newline at end of file diff --git a/frontend/src/styling/GlobalStyles.js b/frontend/src/styling/GlobalStyles.js index 510ac2b6f..22860398b 100644 --- a/frontend/src/styling/GlobalStyles.js +++ b/frontend/src/styling/GlobalStyles.js @@ -15,6 +15,6 @@ export const GlobalStyle = createGlobalStyle` max-width: 500px; padding: 30px; - background-color: #3d3a37; + /* background-color: #3d3a37; */ } ` \ No newline at end of file From 184b16929982badf95f1de3a2e30cd633221f679 Mon Sep 17 00:00:00 2001 From: Julia Date: Thu, 5 Mar 2026 16:05:28 +0100 Subject: [PATCH 09/33] adds responsiveness --- frontend/src/assets/placeholder2.png | Bin 0 -> 47650 bytes frontend/src/components/card.jsx | 9 +++---- frontend/src/components/cardlist.jsx | 19 ++++++++++++++- frontend/src/components/catForm.jsx | 16 +++++++------ frontend/src/pages/dashboard.jsx | 34 +++++++++++++++++---------- frontend/src/styling/GlobalStyles.js | 4 +--- 6 files changed, 52 insertions(+), 30 deletions(-) create mode 100644 frontend/src/assets/placeholder2.png diff --git a/frontend/src/assets/placeholder2.png b/frontend/src/assets/placeholder2.png new file mode 100644 index 0000000000000000000000000000000000000000..f93d74efd97839f3a493dbadd1c9404f097c2e49 GIT binary patch literal 47650 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 Wr<@DQWPyt9S;U#b(>}L1{rvxd0q2qc literal 0 HcmV?d00001 diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index e888e55fc..ee0432e48 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -185,14 +185,15 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o const CardWrapper = styled.article` max-width: 365px; + min-width: 345px; border: 2px solid #3f895c; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); display: flex; flex-direction: column; - /* margin: 3px 0px; */ - ` + background: white; +` const ImgWrapper = styled.div` width: 100%; @@ -216,10 +217,6 @@ const Info = styled.div` padding: 12px 16px; ` -// const Select = styled.select` -// height: 40px; -// ` - const EditDelete = styled.div` display: flex; flex-direction: row; diff --git a/frontend/src/components/cardlist.jsx b/frontend/src/components/cardlist.jsx index 8dc0426bf..1dc988a7d 100644 --- a/frontend/src/components/cardlist.jsx +++ b/frontend/src/components/cardlist.jsx @@ -18,6 +18,23 @@ export const CatList = ({ externalCats, currentUser, onEdit, onCreateComment, on const Grid = styled.section` display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; + + /* Mobile – 1 column (default) */ + grid-template-columns: 1fr; + + /* Tablet – ≥ 600 px wide → 2 columns */ + @media (min-width: 760px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + /* Desktop 1400 */ + @media (min-width: 1120px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + /* Desktop – ≥ 1024 px wide → 4 columns */ + @media (min-width: 1470px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } ` \ No newline at end of file diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index da51fcf28..28247b37c 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -1,7 +1,7 @@ import { useState } from "react" import styled from "styled-components" import { API_URL } from "../api" -import placeholder from "../assets/placeholder.png" +import placeholder2 from "../assets/placeholder2.png" export const CatForm = ({ onSuccess }) => { const [formData, setFormData] = useState({ @@ -13,7 +13,7 @@ export const CatForm = ({ onSuccess }) => { const [errorMsg, setErrorMsg] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) - const [previewUrl, setPreviewUrl] = useState(placeholder) + const [previewUrl, setPreviewUrl] = useState(placeholder2) const handleChange = (e) => { const { name, value, files } = e.target @@ -25,7 +25,7 @@ export const CatForm = ({ onSuccess }) => { setPreviewUrl(URL.createObjectURL(file)) } else { setFormData((prev) => ({ ...prev, picture: null })) - setPreviewUrl(placeholder) + setPreviewUrl(placeholder2) } } else { setFormData((prev) => ({ ...prev, [name]: value })) @@ -70,7 +70,7 @@ export const CatForm = ({ onSuccess }) => { } setFormData(setFormData) - setPreviewUrl(placeholder) + setPreviewUrl(placeholder2) } catch (error) { console.error("Submit error:", error) @@ -204,22 +204,24 @@ const ImageBox = styled.div` align-items: center; justify-content: center; overflow: hidden; -` + margin-bottom: 15px; + ` const PreviewImg = styled.img` max-width: 100%; max-height: 100%; object-fit: contain; width: "100%"; - margin-top: 8; border-radius: 4; -` + ` const StyledBtn = styled.button` background-color: #b0cebd; border: 2px solid #3f895c; border-radius: 8px; padding: 4px; + display: block; + margin-left: auto; &:hover { box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index 214b0d618..d3a75172f 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -21,14 +21,16 @@ export const Dashboard = ({ return ( -
Cat. Archive. Tracking. System.
- {user && ( - - Welcome, {user.name}! - Logout - - )} - + +
Cat. Archive. Tracking. System.
+ {user && ( + + Welcome, {user.name}! + Logout + + )} + +
All Cats Date: Thu, 5 Mar 2026 17:31:29 +0100 Subject: [PATCH 10/33] A little styling --- backend/models/Cat.js | 2 +- frontend/src/components/card.jsx | 3 +-- frontend/src/components/cardlist.jsx | 8 ++++---- frontend/src/components/catForm.jsx | 8 ++++++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/backend/models/Cat.js b/backend/models/Cat.js index ca1e989fe..f86d99a60 100644 --- a/backend/models/Cat.js +++ b/backend/models/Cat.js @@ -6,7 +6,7 @@ const catSchema = new mongoose.Schema( name: { type: String, required: true }, gender: { type: String, - enum: ["male", "female", "other"], + enum: ["male", "female"], required: true, }, imageUrl: { type: String, required: true }, diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index ee0432e48..51872efde 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -88,7 +88,6 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o > - {/* Location */} @@ -192,7 +191,7 @@ const CardWrapper = styled.article` box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); display: flex; flex-direction: column; - background: white; + background: #F5F5F5; ` const ImgWrapper = styled.div` diff --git a/frontend/src/components/cardlist.jsx b/frontend/src/components/cardlist.jsx index 1dc988a7d..ca7fcfc28 100644 --- a/frontend/src/components/cardlist.jsx +++ b/frontend/src/components/cardlist.jsx @@ -20,20 +20,20 @@ const Grid = styled.section` display: grid; gap: 20px; - /* Mobile – 1 column (default) */ + /* Mobile: 1 column (default) */ grid-template-columns: 1fr; - /* Tablet – ≥ 600 px wide → 2 columns */ + /* Tablet: 2 columns */ @media (min-width: 760px) { grid-template-columns: repeat(2, minmax(0, 1fr)); } - /* Desktop 1400 */ + /* Desktop: 3 columns */ @media (min-width: 1120px) { grid-template-columns: repeat(3, minmax(0, 1fr)); } - /* Desktop – ≥ 1024 px wide → 4 columns */ + /* Desktop: 4 columns */ @media (min-width: 1470px) { grid-template-columns: repeat(4, minmax(0, 1fr)); } diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index 28247b37c..fd2899ffb 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -69,7 +69,12 @@ export const CatForm = ({ onSuccess }) => { onSuccess(newCat) } - setFormData(setFormData) + setFormData({ + picture: null, + name: "", + gender: "", + location: "", + }) setPreviewUrl(placeholder2) } catch (error) { @@ -131,7 +136,6 @@ export const CatForm = ({ onSuccess }) => { - From 5f6ca5dc7e01ba733afa7ff1faba04db73c183c6 Mon Sep 17 00:00:00 2001 From: Julia Date: Thu, 5 Mar 2026 17:56:00 +0100 Subject: [PATCH 11/33] Adds cats successfully --- backend/data.json | 8 -------- frontend/src/components/card.jsx | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) delete mode 100644 backend/data.json diff --git a/backend/data.json b/backend/data.json deleted file mode 100644 index fe827f975..000000000 --- a/backend/data.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "picture": null, - "name": "Test", - "gender": "male", - "location": "outside" - } -] \ No newline at end of file diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index 51872efde..d794a7aee 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -146,7 +146,7 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o {error && {error}} - {cat.comments.map((c) => ( + {(cat.comments ?? []).map((c) => (
@@ -289,7 +289,7 @@ const CommentList = styled.ul` const CommentItem = styled.li` margin-bottom: 10px; - background: #f9f9f9; + background: #f5f5f5; padding: 6px 8px; width: 90%; ` From 332183dcaf2cf9c99c2393923c1008a149617486 Mon Sep 17 00:00:00 2001 From: Julia Date: Thu, 5 Mar 2026 18:14:06 +0100 Subject: [PATCH 12/33] Adds filters --- frontend/src/components/cardlist.jsx | 49 ++++++++++++++++++-------- frontend/src/pages/dashboard.jsx | 52 +++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/cardlist.jsx b/frontend/src/components/cardlist.jsx index ca7fcfc28..e0f4fd663 100644 --- a/frontend/src/components/cardlist.jsx +++ b/frontend/src/components/cardlist.jsx @@ -1,20 +1,34 @@ import styled from "styled-components" import { CatCard } from "./card" -export const CatList = ({ externalCats, currentUser, onEdit, onCreateComment, onDelete, onDeleteComment }) => ( - - {externalCats.map((cat) => ( - - ))} - -) +export const CatList = ({ externalCats, currentUser, onEdit, locationFilter = "", genderFilter = "", onCreateComment, onDelete, onDeleteComment }) => { + + const filteredCats = externalCats.filter(cat => { + const matchesLocation = + locationFilter === "" || cat.location === locationFilter + const matchesGender = + genderFilter === "" || cat.gender === genderFilter + return matchesLocation && matchesGender + }) + + return ( + + {filteredCats.length === 0 ? ( + No cats match the selected filters + ) : filteredCats.map(cat => ( + + ))} + + ) +} const Grid = styled.section` display: grid; @@ -37,4 +51,11 @@ const Grid = styled.section` @media (min-width: 1470px) { grid-template-columns: repeat(4, minmax(0, 1fr)); } +` + +const EmptyMessage = styled.p` + grid-column: 1 / -1; + text-align: center; + color: #555; + font-style: italic; ` \ No newline at end of file diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index d3a75172f..edc7c8fbf 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react" +import React, { useEffect, useState } from "react" import { CatForm } from "../components/catForm" import { CatList } from "../components/cardlist" import styled from "styled-components" @@ -15,6 +15,10 @@ export const Dashboard = ({ onDelete, onDeleteComment, }) => { + + const [locationFilter, setLocationFilter] = useState("") + const [genderFilter, setGenderFilter] = useState("") + useEffect(() => { loadCats() }, []) @@ -32,9 +36,38 @@ export const Dashboard = ({ All Cats + + + + + Date: Thu, 5 Mar 2026 20:40:55 +0100 Subject: [PATCH 13/33] Adds remove background --- backend/routes/catRoutes.js | 1 + frontend/src/components/card.jsx | 27 +++++--- frontend/src/components/catForm.jsx | 9 +-- frontend/src/components/login.jsx | 69 ++++++++++--------- frontend/src/components/signup.jsx | 101 +++++++++++++++------------- frontend/src/pages/dashboard.jsx | 81 ++++++++++++---------- 6 files changed, 160 insertions(+), 128 deletions(-) diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index 040f9b37b..b333b663e 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -25,6 +25,7 @@ const storage = new CloudinaryStorage({ width: 500, height: 500, crop: "limit", + effect: "background_removal:fineedges" }], }, }) diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index d794a7aee..02597af0e 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -67,8 +67,8 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o -
- + + Name -
+ {/* Gender */} -
+ -
+ {/* Location */} -
+ -
+ 💾 @@ -192,6 +192,11 @@ const CardWrapper = styled.article` display: flex; flex-direction: column; background: #F5F5F5; + box-shadow: 0px 10px 20px 2px rgba(0, 0, 0, 0.25); + + &:hover { + transform: scale(1.01); + } ` const ImgWrapper = styled.div` @@ -235,10 +240,10 @@ const Meta = styled.div` const EditMode = styled.div` display: flex; - flex-direction: column; + flex-direction: row; gap: 8px; - align-items: center; - margin-left: 3px; + justify-content: center; + margin-bottom: 8px; ` const OtherBtn = styled.button` @@ -330,7 +335,7 @@ const CommentBtn = styled.button` padding: 6px 12px; &:hover { - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 6px 12px; cursor: pointer; } ` diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index fd2899ffb..a7ebb8f79 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -98,7 +98,7 @@ export const CatForm = ({ onSuccess }) => { /> )} - +
{ onChange={handleChange} aria-label="picture" /> - +
{/* Name */} @@ -176,6 +176,7 @@ const Wrapper = styled.main` margin-bottom: 50px; background-color: #2B5C3F; max-width: 345px; + box-shadow: 0px 10px 20px 2px rgba(0, 0, 0, 0.25); ` const FormWrapper = styled.form` @@ -186,11 +187,11 @@ const FormWrapper = styled.form` ` const StyledName = styled.input` - width: 70%; + width: 68.5%; ` const StyledSelect = styled.select` - width: 50%; + width: 70%; text-align: center; ` diff --git a/frontend/src/components/login.jsx b/frontend/src/components/login.jsx index bb7036fb6..4eb480cd3 100644 --- a/frontend/src/components/login.jsx +++ b/frontend/src/components/login.jsx @@ -36,42 +36,51 @@ export const LoginForm = ({ login }) => { } return ( - - -

Log in

- - - - Email - - - - Password - - - - - {error &&

{error}

} - - Log In -
-
+ + + +

Log in

+ + + + Email + + + + Password + + + + + {error &&

{error}

} + + Log In +
+
+
) } export default LoginForm +const StyledBody = styled.body` + display: flex; + flex-direction: column; + align-items: center; +` + const Wrapper = styled.main` + width: 320px; padding: 10px; border: 1px solid #265237; border-radius: 24px; diff --git a/frontend/src/components/signup.jsx b/frontend/src/components/signup.jsx index 70ef056be..ab5e95c17 100644 --- a/frontend/src/components/signup.jsx +++ b/frontend/src/components/signup.jsx @@ -58,58 +58,67 @@ export const SignUpForm = ({ setUser }) => { } return ( - - -

Sign up

- - {errorMsg && {errorMsg}} - - - - Name - - - - - Email - - - - - Password - - - - - - {isSubmitting ? "Creating…" : "Sign up"} - -
-
+ + + +

Sign up

+ + {errorMsg && {errorMsg}} + + + + Name + + + + + Email + + + + + Password + + + + + + {isSubmitting ? "Creating…" : "Sign up"} + +
+
+
) } export default SignUpForm +const StyledBody = styled.body` + display: flex; + flex-direction: column; + align-items: center; +` + const Wrapper = styled.main` + width: 320px; padding: 10px; border: 1px solid #265237; border-radius: 24px; diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index edc7c8fbf..49b3ef28a 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -35,43 +35,45 @@ export const Dashboard = ({ )} - All Cats - - - - - - + + All Cats + + + + + + +
) } @@ -117,6 +119,11 @@ const StyledBtn = styled.button` } ` +const FilterPart = styled.div` + display: flex; + flex-direction: column; + ` + const FiltersWrapper = styled.div` display: flex; gap: 1rem; From 2fea2452880889dc17289135b8188006a58fa71d Mon Sep 17 00:00:00 2001 From: Julia Date: Sat, 7 Mar 2026 23:21:16 +0100 Subject: [PATCH 14/33] Makes login work *again* --- backend/middleware/authenticate.js | 18 ++++ backend/middleware/authorize.js | 11 +++ backend/models/Cat.js | 8 +- backend/models/User.js | 14 +++ backend/models/auth.js | 26 ++--- backend/package.json | 2 +- backend/parseBoolean.js | 10 ++ backend/routes/catRoutes.js | 9 +- backend/routes/userRoutes.js | 100 +++++++++++-------- backend/server.js | 9 +- backend/utils/jwt.js | 15 +++ frontend/src/App.jsx | 3 +- frontend/src/components/authenticate.jsx | 38 +++++++ frontend/src/components/card.jsx | 2 +- frontend/src/components/catForm.jsx | 2 +- frontend/src/components/login.jsx | 121 ++++++++++++++--------- frontend/src/components/signup.jsx | 7 +- frontend/src/main.jsx | 6 +- frontend/src/pages/adminpanel.jsx | 16 +++ package.json | 1 + 20 files changed, 300 insertions(+), 118 deletions(-) create mode 100644 backend/middleware/authenticate.js create mode 100644 backend/middleware/authorize.js create mode 100644 backend/models/User.js create mode 100644 backend/parseBoolean.js create mode 100644 backend/utils/jwt.js create mode 100644 frontend/src/components/authenticate.jsx create mode 100644 frontend/src/pages/adminpanel.jsx diff --git a/backend/middleware/authenticate.js b/backend/middleware/authenticate.js new file mode 100644 index 000000000..c1e2dc3b5 --- /dev/null +++ b/backend/middleware/authenticate.js @@ -0,0 +1,18 @@ +import jwt from "jsonwebtoken" + +function authenticate(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) + return res.status(401).json({ msg: 'Missing token' }) + + const token = authHeader.split(' ')[1] + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + req.user = decoded + next() + } catch (err) { + return res.status(401).json({ msg: 'Invalid token' }) + } +} + +export default authenticate \ No newline at end of file diff --git a/backend/middleware/authorize.js b/backend/middleware/authorize.js new file mode 100644 index 000000000..f23d50b33 --- /dev/null +++ b/backend/middleware/authorize.js @@ -0,0 +1,11 @@ +export function authorize(...allowedRoles) { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ msg: "Unauthenticated" }) + } + if (!allowedRoles.includes(req.user.role)) { + return res.status(403).json({ msg: "Insufficient rights" }) + } + next() + } +} \ No newline at end of file diff --git a/backend/models/Cat.js b/backend/models/Cat.js index f86d99a60..0dd467ee1 100644 --- a/backend/models/Cat.js +++ b/backend/models/Cat.js @@ -1,5 +1,5 @@ import mongoose from "mongoose" -import commentSchema from "./Comments" +import commentSchema from "./Comments.js" const catSchema = new mongoose.Schema( { @@ -11,9 +11,7 @@ const catSchema = new mongoose.Schema( }, imageUrl: { type: String, required: true }, location: { type: String, required: true }, - comments: [commentSchema], + comments: [commentSchema], default: [], }, { timestamps: true }) -const Cat = mongoose.models.Cat || mongoose.model("Cat", catSchema) - -export default Cat \ No newline at end of file +export default mongoose.models.Cat || mongoose.model("Cat", catSchema) \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 000000000..6a7316db3 --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,14 @@ +import mongoose from "mongoose" + +const UserSchema = new mongoose.Schema({ + name: { type: String, required: true }, + email: { type: String, required: true, unique: true }, + password: { type: String, required: true }, + role: { type: String, enum: ['admin', 'editor', 'viewer'], default: 'viewer' }, + token: { + 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/models/auth.js b/backend/models/auth.js index 886527330..affff57e5 100644 --- a/backend/models/auth.js +++ b/backend/models/auth.js @@ -1,31 +1,25 @@ -import jwt from "jsonwebtoken" -import dotenv from "dotenv" -import { User } from "../routes/userRoutes" - -dotenv.config +import { authenticate } from "../middleware/authenticate.js" +import { User } from "../models/User.js" export const verifyToken = async (req, res, next) => { const authHeader = req.headers.authorization - if (!authHeader) { + if (!authHeader?.startsWith("Bearer ")) { return res.status(401).json({ message: "Missing Authorization header" }) } const token = authHeader.split(" ")[1] try { - const existingUser = await User.findOne({ - token: token - }) - - if (!existingUser) { - throw new Error("No user found") - } + const decoded = authenticate(token) + const user = await User.findById(decoded.sub) + if (!user) throw new Error("User not found") req.user = { - id: existingUser.id, - name: existingUser.name, + id: user._id, + name: user.name, + role: user.role, } next() } catch (err) { - return res.status(401).json({ message: `Invalid token: ${err}` }) + return res.status(401).json({ message: `Invalid token: ${err.message}` }) } } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 3fa136ff2..84aa50025 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,4 +21,4 @@ "multer-storage-cloudinary": "^4.0.0", "nodemon": "^3.0.1" } -} +} \ No newline at end of file diff --git a/backend/parseBoolean.js b/backend/parseBoolean.js new file mode 100644 index 000000000..59bcca8e1 --- /dev/null +++ b/backend/parseBoolean.js @@ -0,0 +1,10 @@ +export const parseBoolean = (value) => { + if (typeof value === "boolean") return value + if (typeof value === "string") { + const lowered = value.toLowerCase().trim() + if (["true", "1", "yes", "y"].includes(lowered)) return true + if (["false", "0", "no", "n"].includes(lowered)) return false + } + + return false +} \ No newline at end of file diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index b333b663e..f69e94940 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -5,6 +5,9 @@ import { CloudinaryStorage } from "multer-storage-cloudinary" import cloudinaryFramework from "cloudinary" import Cat from "../models/Cat" import { verifyToken } from "../models/auth" +import authenticate from "../middleware/authenticate" +import { authorize } from "../middleware/authorize" + dotenv.config() @@ -58,7 +61,7 @@ router.get("/cats/:id", async (req, res) => { }) // Post -router.post("/cats", parser.single('picture'), async (req, res) => { +router.post("/cats", authenticate, authorize('admin', 'editor'), parser.single('picture'), async (req, res) => { try { const { filename, gender, location } = req.body @@ -88,7 +91,7 @@ router.post("/cats", parser.single('picture'), async (req, res) => { }) // Edit -router.put("/cats/:id", verifyToken, async (req, res) => { +router.put("/cats/:id", authenticate, authorize('admin', 'editor'), verifyToken, async (req, res) => { try { const editedCat = req.body @@ -108,7 +111,7 @@ router.put("/cats/:id", verifyToken, async (req, res) => { }) // Delete -router.delete("/cats/:id", async (req, res) => { +router.delete("/cats/:id", authenticate, authorize('admin', 'editor'), async (req, res) => { const id = req.params.id try { const cat = await Cat.findById(id) diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index bb74ebdb2..c2799c013 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -1,27 +1,15 @@ import bcrypt from "bcrypt" -import crypto from "crypto" import express from "express" -import mongoose from "mongoose" import dotenv from "dotenv" +import { User } from "../models/User" +import authenticate from "../middleware/authenticate" +import { authorize } from "../middleware/authorize" +import { signJwt } from "../utils/jwt.js" -dotenv.config +dotenv.config() const router = express.Router() -// User schema -const UserSchema = new mongoose.Schema({ - name: { type: String, required: true }, - email: { type: String, required: true, unique: true }, - password: { type: String, required: true }, - token: { - type: String, - default: () => crypto.randomBytes(128).toString("hex"), - }, -}) - -export const User = mongoose.model('User', UserSchema) - - // New User router.post('/signup', async (req, res) => { try { @@ -44,15 +32,12 @@ router.post('/signup', async (req, res) => { await user.save() - res.status(200).json({ + const token = signJwt({ sub: user._id, role: user.role, name: user.name }) + + res.status(201).json({ success: true, - message: "User created successfully", - response: { - name: user.name, - email: user.email, - id: user._id, - token: user.token, - }, + message: "User created", + response: { id: user._id, name: user.name, email: user.email, token }, }) } catch (error) { res.status(400).json({ @@ -66,34 +51,67 @@ router.post('/signup', async (req, res) => { // Log In router.post("/login", async (req, res) => { try { - const { email, password } = req.body - const user = await User.findOne({ email: email.toLowerCase() }) + const { email, password } = req.body; - if (user && bcrypt.compareSync(password, user.password)) { - res.json({ - success: true, - message: "Logged in successfully", - response: { - id: user._id, - name: user.name, - email: user.email, - token: user.token - }, + const user = await User.findOne({ email: email.toLowerCase() }) + if (!user) { + return res.status(401).json({ + success: false, + message: "Wrong e‑mail or password", + response: null, }) - } else { - res.status(401).json({ + } + + const passwordMatches = bcrypt.compareSync(password, user.password) + if (!passwordMatches) { + return res.status(401).json({ success: false, - message: "Wrong e-mail or password", + message: "Wrong e‑mail or password", response: null, }) } + + const token = signJwt({ sub: user._id, role: user.role, name: user.name }) + + res.json({ + success: true, + message: "Logged in successfully", + response: { + id: user._id, + name: user.name, + email: user.email, + token, + }, + }) } catch (error) { + console.error("Login error:", error); res.status(500).json({ success: false, message: "Something went wrong", - response: error + response: error.message, }) } }) +// Delete +router.delete( + "/users/:id", + authenticate, + authorize("admin"), + + async (req, res) => { + try { + const { id } = req.params + const deleted = await User.findByIdAndDelete(id) + if (!deleted) { + return res.status(404).json({ success: false, message: "User not found" }) + } + res.json({ success: true, message: "User removed" }) + } catch (err) { + console.error("Delete user error:", err) + res.status(500).json({ success: false, message: err.message }) + } + } +) + export default router \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 13392544a..226ce3cd7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -19,8 +19,15 @@ mongoose process.exit(1) }) +const corsOptions = { + origin: "http://localhost:5173", + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true +} + const app = express() -app.use(cors()) +app.use(cors({ origin: true, credentials: true })) app.use(express.json({ limit: "10mb" })) app.use(express.urlencoded({ limit: "10mb", extended: true })) diff --git a/backend/utils/jwt.js b/backend/utils/jwt.js new file mode 100644 index 000000000..c92259560 --- /dev/null +++ b/backend/utils/jwt.js @@ -0,0 +1,15 @@ +import jwt from "jsonwebtoken" + +export const signJwt = (payload) => { + if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET is not defined in .env") + } + return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: "7d" }) +} + +export const verifyJwt = (token) => { + if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET is not defined in .env") + } + return jwt.verify(token, process.env.JWT_SECRET) +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a0fc98636..11e340c98 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import ProtectedRoute from "./pages/start" import { Dashboard } from "./pages/dashboard" import { API_URL, fetchJson } from "./api" import styled from "styled-components" +import AdminPanel from "./pages/adminpanel" export const App = () => { const navigate = useNavigate() @@ -24,7 +25,6 @@ export const App = () => { if (!res.ok) throw new Error("Login failed") const { response } = await res.json() - console.log("login response:", response) if (response.token) { localStorage.setItem("token", response.token) @@ -212,6 +212,7 @@ export const App = () => { } /> + } /> } /> diff --git a/frontend/src/components/authenticate.jsx b/frontend/src/components/authenticate.jsx new file mode 100644 index 000000000..daabedbf1 --- /dev/null +++ b/frontend/src/components/authenticate.jsx @@ -0,0 +1,38 @@ +import React, { createContext, useState, useEffect } from "react" +import * as jwtDecode from "jwt-decode" + +export const AuthContext = createContext(null) + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null) + + const syncUserFromToken = (token) => { + try { + const decoded = jwtDecode.default(token); + setUser({ id: decoded.sub, role: decoded.role, name: decoded.name }) + } catch (_) { + setUser(null) + } + } + + useEffect(() => { + const token = localStorage.getItem("token") + if (token) syncUserFromToken(token) + }, []) + + const login = (token) => { + localStorage.setItem("token", token) + syncUserFromToken(token) + } + + const logout = () => { + localStorage.removeItem("token") + setUser(null) + } + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index 02597af0e..f23361adb 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -184,7 +184,7 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o const CardWrapper = styled.article` max-width: 365px; - min-width: 345px; + min-width: 320px; border: 2px solid #3f895c; border-radius: 8px; overflow: hidden; diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index a7ebb8f79..ee66cfa5a 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -172,7 +172,6 @@ const Wrapper = styled.main` padding: 10px; border: 1px solid #265237; border-radius: 24px; - /* box-shadow: 0 0 0.25rem 0.5rem rgba(0, 0, 0, .15); */ margin-bottom: 50px; background-color: #2B5C3F; max-width: 345px; @@ -192,6 +191,7 @@ const StyledName = styled.input` const StyledSelect = styled.select` width: 70%; + height: 25px; text-align: center; ` diff --git a/frontend/src/components/login.jsx b/frontend/src/components/login.jsx index 4eb480cd3..d4dbbf97a 100644 --- a/frontend/src/components/login.jsx +++ b/frontend/src/components/login.jsx @@ -1,120 +1,149 @@ -import { useState } from "react" -import styled from "styled-components" +// src/components/LoginForm.jsx +import { useState, useContext } from "react"; +import styled from "styled-components"; +import { useNavigate } from "react-router-dom"; +import { AuthContext } from "../components/authenticate"; -// import { theme } from "../styling/Theme" -// +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8080"; -export const LoginForm = ({ login }) => { - const [formData, setFormData] = useState({ - email: "", - password: "", - }) +export const LoginForm = () => { + const navigate = useNavigate(); + const { login: storeToken } = useContext(AuthContext); // rename to avoid confusion + const [formData, setFormData] = useState({ email: "", password: "" }); + const [error, setError] = useState(""); - const [error, setError] = useState("") + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; const handleSubmit = async (e) => { - e.preventDefault() + e.preventDefault(); + // ---- basic client‑side validation ---- if (!formData.email || !formData.password) { - setError("Please fill in both fields") - return + setError("Please fill in both fields"); + return; } + try { - await login(formData.email, formData.password) - setFormData({ name: "", email: "", password: "" }) - } catch { - setError("Invalid email or password") + // ---- 1️⃣ POST to the back‑end ---- + const response = await fetch(`${API_URL}/users/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + // ---- 2️⃣ handle non‑200 responses ---- + if (!response.ok) { + // Try to read the JSON error payload; fall back to status text + const errPayload = await response.json().catch(() => ({})); + const msg = errPayload.message || `Status ${response.status}`; + throw new Error(msg); + } + + // ---- 3️⃣ extract the JWT ---- + const payload = await response.json(); // { response: { token, … } } + const token = payload.response?.token; + if (!token) throw new Error("Server did not return a token"); + + // ---- 4️⃣ store token in context ---- + storeToken(token); // updates AuthContext & localStorage + + // ---- 5️⃣ navigate to the protected page ---- + navigate("/dashboard"); + } catch (err) { + console.error("Login error:", err); + setError(err.message); } - } - - - - const handleChange = (e) => { - const { name, value } = e.target - - setFormData((prevFormData) => ({ ...prevFormData, [name]: value })) - } + }; return ( - +

Log in

- + Email + Password - {error &&

{error}

} + {error &&

{error}

} Log In
- ) -} + ); +}; -export default LoginForm +export default LoginForm; -const StyledBody = styled.body` +/* ------------------------------------------------- + Styled components (unchanged – they render
/
) +------------------------------------------------- */ +const StyledBody = styled.div` display: flex; flex-direction: column; align-items: center; -` +`; const Wrapper = styled.main` width: 320px; padding: 10px; border: 1px solid #265237; border-radius: 24px; - box-shadow: 0 0 0.25rem 0.5rem rgba(0, 0, 0, .15); + box-shadow: 0 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15); margin-bottom: 50px; - background-color: #2B5C3F; -` + background-color: #2b5c3f; +`; const FormWrapper = styled.form` background: #d4ded7; border: 1px solid #417354; border-radius: 16px; padding: 20px; -` +`; const StyledDiv = styled.div` display: flex; flex-direction: column; - margin: 5px 0px; -` + margin: 5px 0; +`; const StyledLabel = styled.label` display: flex; flex-direction: column; -` +`; const StyledBtn = styled.button` background-color: #b0cebd; border: 2px solid #3f895c; border-radius: 8px; padding: 4px; - + &:hover { - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); cursor: pointer; } -` \ No newline at end of file +`; \ No newline at end of file diff --git a/frontend/src/components/signup.jsx b/frontend/src/components/signup.jsx index ab5e95c17..b37271af4 100644 --- a/frontend/src/components/signup.jsx +++ b/frontend/src/components/signup.jsx @@ -2,8 +2,11 @@ import React, { useState } from "react" import styled from "styled-components" import { API_URL } from "../api" import { useNavigate } from "react-router-dom" +import { useContext } from "react" +import { AuthContext } from "./authenticate" export const SignUpForm = ({ setUser }) => { + const { login } = useContext(AuthContext) const navigate = useNavigate() const [formData, setFormData] = useState({ name: "", @@ -46,7 +49,9 @@ export const SignUpForm = ({ setUser }) => { const user = payload.response || payload // Store token & user - if (user.token) localStorage.setItem("token", user.token) + if (user.token) { + login(user.token) + } localStorage.setItem("token", user.token) localStorage.setItem("user", JSON.stringify(user)) setUser(user); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 7de868e29..f6c876b42 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,11 +3,15 @@ import ReactDOM from "react-dom/client" import { BrowserRouter } from "react-router-dom" import App from "./App" import "./index.css" +import { AuthProvider } from "./components/authenticate"; + ReactDOM.createRoot(document.getElementById("root")).render( - + + + ) \ No newline at end of file diff --git a/frontend/src/pages/adminpanel.jsx b/frontend/src/pages/adminpanel.jsx new file mode 100644 index 000000000..c01dec239 --- /dev/null +++ b/frontend/src/pages/adminpanel.jsx @@ -0,0 +1,16 @@ +import { useContext } from "react" +import { AuthContext } from "../components/authenticate" + +export default function AdminPanel() { + const { user } = useContext(AuthContext) + + if (!user) return

Please log in.

+ if (user.role !== "admin") return

Access denied – admin only

+ + return ( +
+

Admin Dashboard

+ {/* admin‑only UI */} +
+ ) +} \ No newline at end of file diff --git a/package.json b/package.json index ad23d426c..7030e6e6a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", + "jwt-decode": "^4.0.0", "mongoose": "^9.2.1", "multer": "^2.0.2", "multer-storage-cloudinary": "^4.0.0", From 3cb63248cd311e184031205457995c7d52877b7a Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 00:08:02 +0100 Subject: [PATCH 15/33] Adds levels off users --- backend/middleware/authenticate.js | 8 +- backend/middleware/authorize.js | 2 +- backend/models/User.js | 12 +- backend/models/auth.js | 5 +- backend/package.json | 3 +- backend/routes/adminRoutes | 86 +++++++++ backend/routes/catRoutes.js | 6 +- backend/routes/commentRoutes.js | 2 +- backend/routes/userRoutes.js | 64 ++++--- backend/server.js | 54 +++--- frontend/src/App.jsx | 211 ++++------------------- frontend/src/api.js | 21 --- frontend/src/api/api.js | 38 ++++ frontend/src/components/authenticate.jsx | 19 +- frontend/src/components/card.jsx | 1 - frontend/src/components/cardlist.jsx | 44 +++-- frontend/src/components/catForm.jsx | 8 +- frontend/src/components/login.jsx | 105 +++++------ frontend/src/components/signup.jsx | 63 ++----- frontend/src/handling/useAuth.js | 64 +++++++ frontend/src/handling/useCats.js | 47 +++++ frontend/src/handling/useComments.js | 24 +++ frontend/src/pages/adminpanel.jsx | 163 ++++++++++++++++- frontend/src/pages/dashboard.jsx | 117 +++++++------ frontend/src/pages/start.jsx | 2 +- package.json | 4 + 26 files changed, 722 insertions(+), 451 deletions(-) create mode 100644 backend/routes/adminRoutes delete mode 100644 frontend/src/api.js create mode 100644 frontend/src/api/api.js create mode 100644 frontend/src/handling/useAuth.js create mode 100644 frontend/src/handling/useCats.js create mode 100644 frontend/src/handling/useComments.js diff --git a/backend/middleware/authenticate.js b/backend/middleware/authenticate.js index c1e2dc3b5..3514aabc9 100644 --- a/backend/middleware/authenticate.js +++ b/backend/middleware/authenticate.js @@ -1,7 +1,7 @@ import jwt from "jsonwebtoken" -function authenticate(req, res, next) { - const authHeader = req.headers.authorization; +export default function authenticate(req, res, next) { + const authHeader = req.headers.authorization if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ msg: 'Missing token' }) @@ -13,6 +13,4 @@ function authenticate(req, res, next) { } catch (err) { return res.status(401).json({ msg: 'Invalid token' }) } -} - -export default authenticate \ No newline at end of file +} \ No newline at end of file diff --git a/backend/middleware/authorize.js b/backend/middleware/authorize.js index f23d50b33..26440562e 100644 --- a/backend/middleware/authorize.js +++ b/backend/middleware/authorize.js @@ -1,4 +1,4 @@ -export function authorize(...allowedRoles) { +export default function authorize(...allowedRoles) { return (req, res, next) => { if (!req.user) { return res.status(401).json({ msg: "Unauthenticated" }) diff --git a/backend/models/User.js b/backend/models/User.js index 6a7316db3..2a625e54d 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -4,11 +4,9 @@ const UserSchema = new mongoose.Schema({ name: { type: String, required: true }, email: { type: String, required: true, unique: true }, password: { type: String, required: true }, - role: { type: String, enum: ['admin', 'editor', 'viewer'], default: 'viewer' }, - token: { - type: String, - default: () => crypto.randomBytes(128).toString("hex"), - }, -}) + role: { type: String, enum: ["admin", "editor", "viewer"], default: "viewer" }, +}, { timestamps: true }) -export const User = mongoose.model('User', UserSchema) \ No newline at end of file +const User = mongoose.models.User || mongoose.model("User", UserSchema) + +export default User \ No newline at end of file diff --git a/backend/models/auth.js b/backend/models/auth.js index affff57e5..83a52ee00 100644 --- a/backend/models/auth.js +++ b/backend/models/auth.js @@ -1,5 +1,6 @@ -import { authenticate } from "../middleware/authenticate.js" -import { User } from "../models/User.js" +import authenticate from "../middleware/authenticate.js" +import authorize from "../middleware/authorize.js" +import User from "../models/User.js" export const verifyToken = async (req, res, next) => { const authHeader = req.headers.authorization diff --git a/backend/package.json b/backend/package.json index 84aa50025..c58c87acb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcryptjs": "^3.0.3", "cloudinary": "^2.9.0", "cors": "^2.8.5", "dotenv": "^17.3.1", @@ -21,4 +22,4 @@ "multer-storage-cloudinary": "^4.0.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/routes/adminRoutes b/backend/routes/adminRoutes new file mode 100644 index 000000000..50ab52318 --- /dev/null +++ b/backend/routes/adminRoutes @@ -0,0 +1,86 @@ +import express from "express" +import authenticate from "../middleware/authenticate.js" +import authorize from "../middleware/authorize.js" +import User from "../models/User.js" +import bcrypt from "bcryptjs" + +const adminRouter = express.Router() + +adminRouter.get( + "/", + authenticate, + authorize("admin"), + async (req, res) => { + try { + const stats = { + totalUsers: await User.countDocuments(), + totalAdmins: await User.countDocuments({ role: "admin" }), + } + res.status(200).json({ success: true, data: stats }) + } catch (error) { + console.error("Admin stats error:", error) + res.status(500).json({ success: false, message: error.message }) + } + } +) + +// Create User +adminRouter.post( + "/users", + authenticate, + authorize("admin"), + async (req, res) => { + try { + const { name, email, role, password } = req.body + + // Validate required fields + if (!name || !email || !password) { + return res.status(400).json({ + success: false, + message: "Name, email, and password are required" + }) + } + + // Check if user already exists + const existingUser = await User.findOne({ email }) + if (existingUser) { + return res.status(400).json({ + success: false, + message: "User with this email already exists" + }) + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10) + + // Create new user + const newUser = new User({ + name, + email, + role: role || "user", + password: hashedPassword + }) + + await newUser.save() + + res.status(201).json({ + success: true, + message: "User created successfully", + data: { + id: newUser._id, + name: newUser.name, + email: newUser.email, + role: newUser.role + } + }) + } catch (error) { + console.error("User creation error:", error) + res.status(500).json({ + success: false, + message: error.message + }) + } + } +) + +export default adminRouter \ No newline at end of file diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index f69e94940..05eae589c 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -6,7 +6,7 @@ import cloudinaryFramework from "cloudinary" import Cat from "../models/Cat" import { verifyToken } from "../models/auth" import authenticate from "../middleware/authenticate" -import { authorize } from "../middleware/authorize" +import authorize from "../middleware/authorize" dotenv.config() @@ -50,7 +50,7 @@ router.get("/cats", async (req, res) => { // One cat router.get("/cats/:id", async (req, res) => { - const id = req.params.id + const id = req.params._id try { const cat = await Cat.findById(id) res.json(cat) @@ -112,7 +112,7 @@ router.put("/cats/:id", authenticate, authorize('admin', 'editor'), verifyToken, // Delete router.delete("/cats/:id", authenticate, authorize('admin', 'editor'), async (req, res) => { - const id = req.params.id + const id = req.params._id try { const cat = await Cat.findById(id) diff --git a/backend/routes/commentRoutes.js b/backend/routes/commentRoutes.js index a083426e2..db80344a5 100644 --- a/backend/routes/commentRoutes.js +++ b/backend/routes/commentRoutes.js @@ -36,7 +36,7 @@ router.post("/cats/:catId/comments", verifyToken, async (req, res) => { // New comment cat.comments.push({ - userId: req.user.id, + userId: req.user._id, userName: req.user.name, text: text.trim(), }) diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index c2799c013..366f712f2 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -1,34 +1,40 @@ import bcrypt from "bcrypt" import express from "express" import dotenv from "dotenv" -import { User } from "../models/User" -import authenticate from "../middleware/authenticate" -import { authorize } from "../middleware/authorize" +import User from "../models/User.js" import { signJwt } from "../utils/jwt.js" - dotenv.config() const router = express.Router() -// New User -router.post('/signup', async (req, res) => { +// Signup +router.post("/signup", async (req, res) => { try { - const { name, email, password } = req.body + const { name, email, password } = req.body; - const existingUser = await User.findOne({ - email: email.toLowerCase() - }) + if (!name?.trim() || !email?.trim() || !password) { + return res.status(400).json({ + success: false, + message: "Name, e‑mail and password are required", + }) + } + const existingUser = await User.findOne({ email: email.toLowerCase() }); if (existingUser) { return res.status(400).json({ success: false, - message: "User with this email already exists" + message: "User with this email already exists", }) } const salt = bcrypt.genSaltSync() const hashedPassword = bcrypt.hashSync(password, salt) - const user = new User({ name, email, password: hashedPassword }) + + const user = new User({ + name: name.trim(), + email: email.toLowerCase(), + password: hashedPassword, + }) await user.save() @@ -37,22 +43,35 @@ router.post('/signup', async (req, res) => { res.status(201).json({ success: true, message: "User created", - response: { id: user._id, name: user.name, email: user.email, token }, + response: { + id: user._id, + name: user.name, + email: user.email, + role: user.role, + token, + }, }) } catch (error) { - res.status(400).json({ + console.error("Signup error:", error) + if (error.name === "ValidationError") { + return res.status(400).json({ + success: false, + message: "Invalid user data", + response: error.errors, + }) + } + res.status(500).json({ success: false, - message: 'Could not create user', - response: error, + message: "Could not create user", + response: error.message, }) } }) -// Log In +// Login router.post("/login", async (req, res) => { try { - const { email, password } = req.body; - + const { email, password } = req.body const user = await User.findOne({ email: email.toLowerCase() }) if (!user) { return res.status(401).json({ @@ -72,7 +91,6 @@ router.post("/login", async (req, res) => { } const token = signJwt({ sub: user._id, role: user.role, name: user.name }) - res.json({ success: true, message: "Logged in successfully", @@ -80,11 +98,12 @@ router.post("/login", async (req, res) => { id: user._id, name: user.name, email: user.email, + role: user.role, token, }, }) } catch (error) { - console.error("Login error:", error); + console.error("Login error details:", error) res.status(500).json({ success: false, message: "Something went wrong", @@ -96,9 +115,6 @@ router.post("/login", async (req, res) => { // Delete router.delete( "/users/:id", - authenticate, - authorize("admin"), - async (req, res) => { try { const { id } = req.params diff --git a/backend/server.js b/backend/server.js index 226ce3cd7..a3b053631 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,40 +1,50 @@ +import express from "express" import cors from "cors" import dotenv from "dotenv" -import express from "express" import mongoose from "mongoose" -import userRouter from "./routes/userRoutes.js" -import catRouter from "./routes/catRoutes.js" -import commentRouter from "./routes/commentRoutes.js" dotenv.config() +const app = express() + +app.use( + cors({ + origin: true, + credentials: true, + allowedHeaders: ["Content-Type", "Authorization"], + }) +) +app.use(express.json({ limit: "10mb" })) +app.use(express.urlencoded({ limit: "10mb", extended: true })) + +const mongoUri = process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project" mongoose - .connect(process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project", { + .connect(mongoUri, { useNewUrlParser: true, useUnifiedTopology: true, }) - .then(() => console.log("Connected to MongoDB")) - .catch((e) => { - console.error("MongoDB connection error:", e) + .then(() => console.log("✅ Connected to MongoDB")) + .catch((err) => { + console.error("MongoDB connection error:", err) process.exit(1) }) -const corsOptions = { - origin: "http://localhost:5173", - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization"], - credentials: true -} - -const app = express() -app.use(cors({ origin: true, credentials: true })) -app.use(express.json({ limit: "10mb" })) -app.use(express.urlencoded({ limit: "10mb", extended: true })) +import userRouter from "./routes/userRoutes.js" +import catRouter from "./routes/catRoutes.js" +import commentRouter from "./routes/commentRoutes.js" +import adminRouter from "./routes/adminRoutes" +app.use("/admin", adminRouter) app.use("/users", userRouter) app.use("/", catRouter) -app.use("/", commentRouter) -app.use("*", (req, res) => res.status(404).json({ message: "Not Found" })) +app.use("/comments", commentRouter) +app.all("*", (req, res) => { + res.status(404).json({ message: "Not Found" }) +}) + +// Server start const PORT = process.env.PORT || 8080 -app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`)) \ No newline at end of file +app.listen(PORT, () => { + console.log(`Server listening on http://localhost:${PORT}`) +}) \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 11e340c98..fbebd3415 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,154 +1,43 @@ -import { useState, useCallback, useEffect } from "react" -import { Routes, Route, Navigate, useNavigate } from "react-router-dom" +import React from "react" +import { Routes, Route, Navigate } from "react-router-dom" import { GlobalStyle } from "./styling/GlobalStyles" import { LoginForm } from "./components/login" import { SignUpForm } from "./components/signup" import ProtectedRoute from "./pages/start" import { Dashboard } from "./pages/dashboard" -import { API_URL, fetchJson } from "./api" -import styled from "styled-components" import AdminPanel from "./pages/adminpanel" -export const App = () => { - const navigate = useNavigate() - const [cats, setCats] = useState([]) - const [user, setUser] = useState(null) - const [authMode, setAuthMode] = useState("login") - - const login = async (email, password) => { - const res = await fetch(`${API_URL}/users/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }) - - if (!res.ok) throw new Error("Login failed") - - const { response } = await res.json() - - if (response.token) { - localStorage.setItem("token", response.token) - } else { - console.warn("No token returned from login", response) - } - - localStorage.setItem("user", JSON.stringify(response)) - setUser(response) - - navigate("/dashboard") - } - - useEffect(() => { - const storedUser = localStorage.getItem("user") - if (storedUser) { - try { - setUser(JSON.parse(storedUser)) - } catch (e) { - console.warn("Corrupt user data in localStorage", e) - localStorage.removeItem("user") - } - } - }, []) - +import useAuth from "./handling/useAuth" +import { useCats } from "./handling/useCats" +import { useComments } from "./handling/useComments" - const handleSignUpSuccess = (newUser) => { - if (newUser.token) { - localStorage.setItem("token", newUser.token) - } - localStorage.setItem("user", JSON.stringify(newUser)) - setUser(newUser) - navigate("/dashboard") - } - - const handleLogout = () => { - localStorage.removeItem("user") - localStorage.removeItem("token") - setUser(null) - navigate("/login") - } - - const toggleAuthMode = () => - setAuthMode((prev) => (prev === "login" ? "signup" : "login")) - - const loadCats = async () => { - try { - const data = await fetchJson(`${API_URL}/cats`, { - }) - setCats(data) - } catch (e) { - console.error("Failed to load cats:", e) - } - } +import styled from "styled-components" - useEffect(() => { - loadCats() - }, []) +export const App = () => { + const { user, login, signup, logout } = useAuth() // Cats - const handleNewCat = (newCatFromForm) => { - const formatted = { - id: newCatFromForm._id, - name: newCatFromForm.name, - imageUrl: newCatFromForm.imageUrl, - gender: newCatFromForm.gender, - location: newCatFromForm.location, - userId: newCatFromForm.userId, - } - setCats((prev) => [formatted, ...prev]) - } - - const onEdit = async (catId, cat) => { - try { - const token = localStorage.getItem("token") - await fetchJson(`${API_URL}/cats/${catId}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(cat), - }) - loadCats() - } catch (e) { console.log(e) } - } + const { + cats, + loadCats, + addCat, + editCat, + deleteCat, + } = useCats() + // Comments + const { createComment, deleteComment } = useComments() - const deleteCat = async (id) => { - try { - await fetchJson(`${API_URL}/cats/${id}`, { method: "DELETE" }) - setCats((prev) => prev.filter((c) => c._id !== id)) - } catch (err) { - console.error("Delete cat error:", err) - } - } + React.useEffect(() => { + loadCats() + }, [loadCats]) - // Comments - const createComment = async (catId, newText) => { - try { - const token = localStorage.getItem("token") - await fetchJson(`${API_URL}/cats/${catId}/comments`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ text: newText.trim() }), - }) - loadCats() - } catch (e) { - console.error(e) - setError(e.message || "Could not post comment") - } + const handleLogin = async (email, password) => { + await login(email, password) } - - const deleteComment = async (catId, commentId) => { - try { - await fetchJson(`${API_URL}/cats/${catId}/comments/${commentId}`, { method: "DELETE" }) - loadCats() - } catch (err) { - console.error("Delete comment error:", err) - } + const handleSignUpSuccess = async (newUser) => { + await signup(newUser.name, newUser.email, newUser.password) } return ( @@ -158,39 +47,13 @@ export const App = () => { - {authMode === "login" ? ( - - ) : ( - - )} - - {authMode === "login" ? ( - - Don’t have an account? Sign up - - ) : ( - - Already have an account? Log in - - )} - - + } /> - } + element={} /> }> @@ -199,27 +62,29 @@ export const App = () => { element={ } /> + } /> - } /> + + {/* FALLBACK */} } /> ) } -// Styling +export default App + const ToggleWrapper = styled.div` margin-top: 12px; text-align: center; @@ -228,12 +93,10 @@ const ToggleWrapper = styled.div` const ToggleBtn = styled.button` background: none; border: none; - color: #000000; + color: #000; cursor: pointer; font-size: 0.95rem; &:hover { text-decoration: underline; } -` - -export default App \ No newline at end of file +` \ No newline at end of file diff --git a/frontend/src/api.js b/frontend/src/api.js deleted file mode 100644 index cb12ed7d3..000000000 --- a/frontend/src/api.js +++ /dev/null @@ -1,21 +0,0 @@ -export const API_URL = "http://localhost:8080" - -export const fetchJson = async (url, options = {}) => { - const token = localStorage.getItem("token") - const res = await fetch(url, { - ...options, - headers: { - "Content-Type": "application/json", - ...(options.headers || {}), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }) - - if (!res.ok) { - const err = await res.json().catch(() => ({})) - const msg = err.message || `Status ${res.status}` - throw new Error(msg) - } - - return await res.json() -} \ No newline at end of file diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js new file mode 100644 index 000000000..4be7a3ffb --- /dev/null +++ b/frontend/src/api/api.js @@ -0,0 +1,38 @@ +export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8080" + + +export const fetchJson = async (endpoint, options = {}) => { + const token = localStorage.getItem("token") + const headers = { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + } + + const res = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers: { + ...headers, + ...options.headers, + }, + }) + + const contentType = res.headers.get("content-type") || "" + if (!contentType.includes("application/json")) { + const text = await res.text() + throw new Error( + `Expected JSON but got ${contentType}. Response body:\n${text.slice( + 0, + 200 + )}…` + ) + } + + if (!res.ok) { + const errPayload = await res.json().catch(() => ({})) + const msg = errPayload.message || `Status ${res.status}` + throw new Error(msg) + } + + return res.json() +} \ No newline at end of file diff --git a/frontend/src/components/authenticate.jsx b/frontend/src/components/authenticate.jsx index daabedbf1..186b7851e 100644 --- a/frontend/src/components/authenticate.jsx +++ b/frontend/src/components/authenticate.jsx @@ -1,5 +1,5 @@ -import React, { createContext, useState, useEffect } from "react" -import * as jwtDecode from "jwt-decode" +import { createContext, useState, useEffect } from "react" +import { jwtDecode } from "jwt-decode" export const AuthContext = createContext(null) @@ -8,16 +8,23 @@ export const AuthProvider = ({ children }) => { const syncUserFromToken = (token) => { try { - const decoded = jwtDecode.default(token); - setUser({ id: decoded.sub, role: decoded.role, name: decoded.name }) - } catch (_) { + const decoded = jwtDecode(token) + setUser({ + id: decoded.sub, + role: decoded.role, + name: decoded.name, + }) + } catch (error) { + console.error("Invalid token:", error) setUser(null) } } useEffect(() => { const token = localStorage.getItem("token") - if (token) syncUserFromToken(token) + if (token) { + syncUserFromToken(token) + } }, []) const login = (token) => { diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index f23361adb..f6de3ea49 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -1,6 +1,5 @@ import React, { useState } from "react" import styled from "styled-components" -import { API_URL, fetchJson } from "../api" export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, onDeleteComment }) => { const catId = cat._id diff --git a/frontend/src/components/cardlist.jsx b/frontend/src/components/cardlist.jsx index e0f4fd663..889e3dcf3 100644 --- a/frontend/src/components/cardlist.jsx +++ b/frontend/src/components/cardlist.jsx @@ -1,28 +1,41 @@ import styled from "styled-components" import { CatCard } from "./card" +export const CatList = ({ + externalCats, + currentUser, + locationFilter, + genderFilter, + onCreateComment, + onEdit, + onDelete, + onDeleteComment, +}) => { + const validCats = (externalCats || []).filter((cat) => { + return cat && (cat._id || cat.id) + }) -export const CatList = ({ externalCats, currentUser, onEdit, locationFilter = "", genderFilter = "", onCreateComment, onDelete, onDeleteComment }) => { - - const filteredCats = externalCats.filter(cat => { + // Apply location/gender filters + const filteredCats = validCats.filter((cat) => { const matchesLocation = - locationFilter === "" || cat.location === locationFilter - const matchesGender = - genderFilter === "" || cat.gender === genderFilter + !locationFilter || cat.location === locationFilter + const matchesGender = !genderFilter || cat.gender === genderFilter return matchesLocation && matchesGender }) + if (filteredCats.length === 0) { + return

No cats found matching your filters.

+ } + return ( - {filteredCats.length === 0 ? ( - No cats match the selected filters - ) : filteredCats.map(cat => ( + {filteredCats.map((cat) => ( ))} @@ -30,6 +43,8 @@ export const CatList = ({ externalCats, currentUser, onEdit, locationFilter = "" ) } +export default CatList + const Grid = styled.section` display: grid; gap: 20px; @@ -51,11 +66,4 @@ const Grid = styled.section` @media (min-width: 1470px) { grid-template-columns: repeat(4, minmax(0, 1fr)); } -` - -const EmptyMessage = styled.p` - grid-column: 1 / -1; - text-align: center; - color: #555; - font-style: italic; ` \ No newline at end of file diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index ee66cfa5a..4706a6785 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -1,6 +1,6 @@ import { useState } from "react" import styled from "styled-components" -import { API_URL } from "../api" +import { API_URL } from "../api/api" import placeholder2 from "../assets/placeholder2.png" export const CatForm = ({ onSuccess }) => { @@ -84,12 +84,11 @@ export const CatForm = ({ onSuccess }) => { setIsSubmitting(false) } } - return (
- + {previewUrl && ( { ) } +export default CatForm + + const Wrapper = styled.main` padding: 10px; border: 1px solid #265237; diff --git a/frontend/src/components/login.jsx b/frontend/src/components/login.jsx index d4dbbf97a..e8304ee8a 100644 --- a/frontend/src/components/login.jsx +++ b/frontend/src/components/login.jsx @@ -1,62 +1,45 @@ -// src/components/LoginForm.jsx -import { useState, useContext } from "react"; -import styled from "styled-components"; -import { useNavigate } from "react-router-dom"; -import { AuthContext } from "../components/authenticate"; +import React, { useState } from "react" +import styled from "styled-components" +import { useNavigate } from "react-router-dom" -const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8080"; +export const LoginForm = ({ login }) => { + const navigate = useNavigate() -export const LoginForm = () => { - const navigate = useNavigate(); - const { login: storeToken } = useContext(AuthContext); // rename to avoid confusion - const [formData, setFormData] = useState({ email: "", password: "" }); - const [error, setError] = useState(""); + const [formData, setFormData] = useState({ + email: "", + password: "", + }) + + const [errorMsg, setErrorMsg] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) const handleChange = (e) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - }; + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + } const handleSubmit = async (e) => { - e.preventDefault(); + e.preventDefault() - // ---- basic client‑side validation ---- if (!formData.email || !formData.password) { - setError("Please fill in both fields"); - return; + setErrorMsg("Please fill in both fields") + return } + setIsSubmitting(true) + setErrorMsg("") + try { - // ---- 1️⃣ POST to the back‑end ---- - const response = await fetch(`${API_URL}/users/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - - // ---- 2️⃣ handle non‑200 responses ---- - if (!response.ok) { - // Try to read the JSON error payload; fall back to status text - const errPayload = await response.json().catch(() => ({})); - const msg = errPayload.message || `Status ${response.status}`; - throw new Error(msg); - } - - // ---- 3️⃣ extract the JWT ---- - const payload = await response.json(); // { response: { token, … } } - const token = payload.response?.token; - if (!token) throw new Error("Server did not return a token"); - - // ---- 4️⃣ store token in context ---- - storeToken(token); // updates AuthContext & localStorage - - // ---- 5️⃣ navigate to the protected page ---- - navigate("/dashboard"); + await login(formData.email, formData.password) + + navigate("/dashboard") } catch (err) { - console.error("Login error:", err); - setError(err.message); + console.error("Login error:", err) + setErrorMsg(err.message) + } finally { + setIsSubmitting(false) } - }; + } return ( @@ -64,6 +47,8 @@ export const LoginForm = () => {

Log in

+ {errorMsg && {errorMsg}} + Email @@ -88,25 +73,22 @@ export const LoginForm = () => { - {error &&

{error}

} - - Log In + + {isSubmitting ? "Logging in…" : "Log In"} +
); }; -export default LoginForm; +export default LoginForm -/* ------------------------------------------------- - Styled components (unchanged – they render
/
) -------------------------------------------------- */ const StyledBody = styled.div` display: flex; flex-direction: column; align-items: center; -`; +` const Wrapper = styled.main` width: 320px; @@ -116,25 +98,25 @@ const Wrapper = styled.main` box-shadow: 0 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15); margin-bottom: 50px; background-color: #2b5c3f; -`; +` const FormWrapper = styled.form` background: #d4ded7; border: 1px solid #417354; border-radius: 16px; padding: 20px; -`; +` const StyledDiv = styled.div` display: flex; flex-direction: column; margin: 5px 0; -`; +` const StyledLabel = styled.label` display: flex; flex-direction: column; -`; +` const StyledBtn = styled.button` background-color: #b0cebd; @@ -146,4 +128,9 @@ const StyledBtn = styled.button` box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); cursor: pointer; } -`; \ No newline at end of file +` + +const ErrorMsg = styled.p` + color: #c00; + margin-bottom: 12px; +` \ No newline at end of file diff --git a/frontend/src/components/signup.jsx b/frontend/src/components/signup.jsx index b37271af4..a4349d6ee 100644 --- a/frontend/src/components/signup.jsx +++ b/frontend/src/components/signup.jsx @@ -1,13 +1,7 @@ import React, { useState } from "react" import styled from "styled-components" -import { API_URL } from "../api" -import { useNavigate } from "react-router-dom" -import { useContext } from "react" -import { AuthContext } from "./authenticate" - -export const SignUpForm = ({ setUser }) => { - const { login } = useContext(AuthContext) - const navigate = useNavigate() + +export const SignUpForm = ({ onSuccess }) => { const [formData, setFormData] = useState({ name: "", email: "", @@ -22,11 +16,11 @@ export const SignUpForm = ({ setUser }) => { setFormData((prev) => ({ ...prev, [name]: value })) } - const handleSignUp = async (e) => { + const handleSubmit = async (e) => { e.preventDefault() if (!formData.name || !formData.email || !formData.password) { - setErrorMsg("Name, email and password are required") + setErrorMsg("All fields are required") return } @@ -34,43 +28,24 @@ export const SignUpForm = ({ setUser }) => { setErrorMsg("") try { - const res = await fetch(`${API_URL}/users/signup`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }) - - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.message || `Status ${res.status}`) - } - - const payload = await res.json() - const user = payload.response || payload - - // Store token & user - if (user.token) { - login(user.token) - } localStorage.setItem("token", user.token) - localStorage.setItem("user", JSON.stringify(user)) - - setUser(user); - navigate("/dashboard") + await onSuccess(formData) } catch (err) { - console.error(err) + console.error("Signup error:", err) setErrorMsg(err.message) + } finally { + setIsSubmitting(false) } } return ( - - + +

Sign up

{errorMsg && {errorMsg}} - + Name {
- ) -} + ); +}; export default SignUpForm -const StyledBody = styled.body` +const StyledBody = styled.div` display: flex; flex-direction: column; align-items: center; @@ -127,9 +102,9 @@ const Wrapper = styled.main` padding: 10px; border: 1px solid #265237; border-radius: 24px; - box-shadow: 0 0 0.25rem 0.5rem rgba(0, 0, 0, .15); + box-shadow: 0 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15); margin-bottom: 50px; - background-color: #2B5C3F; + background-color: #2b5c3f; ` const FormWrapper = styled.form` @@ -142,7 +117,7 @@ const FormWrapper = styled.form` const StyledDiv = styled.div` display: flex; flex-direction: column; - margin: 5px 0px; + margin: 5px 0; ` const StyledLabel = styled.label` @@ -155,9 +130,9 @@ const StyledBtn = styled.button` border: 2px solid #3f895c; border-radius: 8px; padding: 4px; - + &:hover { - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); cursor: pointer; } ` diff --git a/frontend/src/handling/useAuth.js b/frontend/src/handling/useAuth.js new file mode 100644 index 000000000..c17259edc --- /dev/null +++ b/frontend/src/handling/useAuth.js @@ -0,0 +1,64 @@ +import { useState, useCallback, useEffect } from "react" +import { + fetchJson +} from "../api/api" + +export const useAuth = () => { + const [user, setUser] = useState(null) + + useEffect(() => { + const stored = localStorage.getItem("user") + if (stored) { + try { + setUser(JSON.parse(stored)) + } catch (_) { + localStorage.removeItem("user") + } + } + }, []) + + const storeSession = useCallback((session) => { + const { token, ...rest } = session + console.log("Storing user session:", rest) + if (token) localStorage.setItem("token", token) + localStorage.setItem("user", JSON.stringify(rest)) + setUser(rest) + }, []) + + // Login + const login = useCallback( + async (email, password) => { + const response = await fetchJson("/users/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }) + storeSession(response.response) + const storedUser = localStorage.getItem("user") + if (storedUser) { + setUser(JSON.parse(storedUser)) + } + }, + [storeSession] + ) + + // Signup + const signup = useCallback(async (name, email, password) => { + const { response } = await fetchJson("/users/signup", { + method: "POST", + body: JSON.stringify({ name, email, password }), + }) + storeSession(response) + }, [storeSession]) + + + // Logout + const logout = useCallback(() => { + localStorage.removeItem("token") + localStorage.removeItem("user") + setUser(null) + }, []) + + return { user, login, signup, logout } +} + +export default useAuth \ No newline at end of file diff --git a/frontend/src/handling/useCats.js b/frontend/src/handling/useCats.js new file mode 100644 index 000000000..181e9951a --- /dev/null +++ b/frontend/src/handling/useCats.js @@ -0,0 +1,47 @@ +import { useState, useCallback } from "react" +import { fetchJson } from "../api/api" + +export const useCats = () => { + const [cats, setCats] = useState([]) + + const loadCats = useCallback(async () => { + const data = await fetchJson("/cats") + setCats(data) + }, []) + + const addCat = useCallback((catFromServer) => { + if (!catFromServer || !catFromServer._id) { + console.error("Invalid cat data received:", catFromServer) + return + } + const formatted = { + id: catFromServer._id, + _id: catFromServer._id, + name: catFromServer.name, + imageUrl: catFromServer.imageUrl, + gender: catFromServer.gender, + location: catFromServer.location, + userId: catFromServer.userId, + } + setCats((prev) => [formatted, ...prev]) + }, []) + + // Edit + const editCat = useCallback(async (catId, updates) => { + await fetchJson(`/cats/${catId}`, { + method: "PUT", + body: JSON.stringify(updates), + }) + await loadCats() + }, [loadCats]) + + // Delete + const deleteCat = useCallback(async (catId) => { + await fetchJson(`/cats/${catId}`, { method: "DELETE" }) + setCats((prev) => prev.filter((c) => c._id !== catId)) + }, []) + + return { cats, loadCats, addCat, editCat, deleteCat } +} + +export default useCats \ No newline at end of file diff --git a/frontend/src/handling/useComments.js b/frontend/src/handling/useComments.js new file mode 100644 index 000000000..93f5a7a8a --- /dev/null +++ b/frontend/src/handling/useComments.js @@ -0,0 +1,24 @@ +import { useCallback } from "react" +import { fetchJson } from "../api/api" + +export const useComments = () => { + + // Add + const createComment = useCallback(async (catId, text) => { + await fetchJson(`/cats/${catId}/comments`, { + method: "POST", + body: JSON.stringify({ text: text.trim() }), + }) + }, []) + + // Delete + const deleteComment = useCallback(async (catId, commentId) => { + await fetchJson(`/cats/${catId}/comments/${commentId}`, { + method: "DELETE", + }) + }, []) + + return { createComment, deleteComment } +} + +export default useComments \ No newline at end of file diff --git a/frontend/src/pages/adminpanel.jsx b/frontend/src/pages/adminpanel.jsx index c01dec239..36e22f9f0 100644 --- a/frontend/src/pages/adminpanel.jsx +++ b/frontend/src/pages/adminpanel.jsx @@ -1,16 +1,167 @@ -import { useContext } from "react" +import { useContext, useEffect, useState } from "react" +import { useNavigate } from "react-router-dom" +import styled from "styled-components" import { AuthContext } from "../components/authenticate" +const apiUrl = import.meta.env.VITE_API_URL + export default function AdminPanel() { + const navigate = useNavigate() const { user } = useContext(AuthContext) + const [stats, setStats] = useState(null) + const [error, setError] = useState("") + const [formData, setFormData] = useState({ + name: "", + email: "", + role: "user", + password: "" + }) + const [submitting, setSubmitting] = useState(false) + const [submitMessage, setSubmitMessage] = useState("") + + useEffect(() => { + if (!user) return + if (!apiUrl) { + setError("API URL not configured") + return + } + const token = localStorage.getItem("token") + fetch(`${apiUrl}/admin`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + .then((res) => { + if (!res.ok) throw new Error(`Status ${res.status}`) + return res.json() + }) + .then((data) => setStats(data.data)) + .catch((err) => setError(err.message)) + }, [user, apiUrl]) + + if (!user) return

Loading…

+ if (user.role !== "admin") return

Access denied, admin only.

+ if (error && !submitMessage) return

Error: {error}

+ if (!stats) return

Loading admin stats…

+ + const handleChange = (e) => { + setFormData({ ...formData, [e.target.name]: e.target.value }) + } + + const handleSubmit = async (e) => { + e.preventDefault() + setSubmitting(true) + setSubmitMessage("") + setError("") + + const token = localStorage.getItem("token") + try { + const response = await fetch(`${apiUrl}/users/signup`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(formData), + }) + + const result = await response.json() + + if (!response.ok) { + throw new Error(result.message || "Failed to create user") + } - if (!user) return

Please log in.

- if (user.role !== "admin") return

Access denied – admin only

+ setSubmitMessage("User created successfully!") + setFormData({ name: "", email: "", role: "user", password: "" }) + } catch (err) { + setError(err.message) + } finally { + setSubmitting(false) + } + } return ( -
+ + +

Admin Dashboard

- {/* admin‑only UI */} -
+

Welcome, {user.name}

+ +
+

Create New User

+ + {submitMessage &&

{submitMessage}

} + +
+ + + + + + +
+
+ ) +} + +const PanelWrapper = styled.div` + padding: 20px; + background: #f5f5f5; + border: 1px solid #ccc; + border-radius: 8px; + max-width: 800px; + margin: 0 auto; +` + +const inputStyle = { + padding: '10px', + borderRadius: '4px', + border: '1px solid #ccc', + fontSize: '14px', } \ No newline at end of file diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index 49b3ef28a..21b5c89b4 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -1,69 +1,76 @@ -import React, { useEffect, useState } from "react" -import { CatForm } from "../components/catForm" -import { CatList } from "../components/cardlist" +import { useContext, useState } from "react" +import { Link } from "react-router-dom" +import { AuthContext } from "../components/authenticate" import styled from "styled-components" +import CatForm from "../components/catForm" +import CatList from "../components/cardlist" export const Dashboard = ({ cats, - setCats, - handleNewCat, - loadCats, - logout, - user, - onCreateComment, + onNewCat, onEdit, onDelete, + onCreateComment, onDeleteComment, }) => { + const { user, logout } = useContext(AuthContext) + // Filter state const [locationFilter, setLocationFilter] = useState("") const [genderFilter, setGenderFilter] = useState("") - useEffect(() => { - loadCats() - }, []) - return ( - + -
Cat. Archive. Tracking. System.
+
Cat Archive Tracking System
+ {user && ( - Welcome, {user.name}! + Welcome, {user.name}! Logout )} - + + {/* Admin button - only shown for admins */} + {user?.role === "admin" && ( + + Go to Admin Panel + + )} + +
+ All Cats + + onDeleteComment={onDeleteComment} + />
) } +export default Dashboard const PageWrapper = styled.main` display: flex; @@ -86,25 +95,24 @@ const PageWrapper = styled.main` ` const TopPart = styled.div` - display: flex; - flex-direction: column; - align-items: center; - ` + width: 100%; + max-width: 800px; + padding: 20px; +` const Header = styled.h1` - text-align: center; margin-bottom: 30px; ` -const SectionTitle = styled.h2` - margin-top: 40px; - margin-bottom: 10px; -` - const UserBar = styled.div` + margin-bottom: 20px; display: flex; - flex-direction: column; - margin-bottom: 30px; + align-items: center; + gap: 12px; +` + +const Greeting = styled.span` + margin-right: 12px; ` const StyledBtn = styled.button` @@ -112,6 +120,8 @@ const StyledBtn = styled.button` border: 2px solid #3f895c; border-radius: 8px; padding: 4px; + display: block; + margin-left: auto; &:hover { box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 4px; @@ -119,24 +129,27 @@ const StyledBtn = styled.button` } ` -const FilterPart = styled.div` - display: flex; - flex-direction: column; - ` +const AdminLink = styled(Link)` + margin-top: 20px; + display: inline-block; +` + +const FilterPart = styled.section` + width: 100%; + max-width: 800px; + padding: 20px; +` + +const SectionTitle = styled.h2` + margin-bottom: 15px; +` const FiltersWrapper = styled.div` display: flex; - gap: 1rem; - margin-bottom: 1rem; - - label { - font-size: 0.9rem; - display: flex; - flex-direction: column; - } + gap: 20px; + margin-bottom: 20px; +` - select { - margin-top: 0.2rem; - padding: 0.2rem 0.4rem; - } +const StyledSelected = styled.select` + margin-left: 5px; ` \ No newline at end of file diff --git a/frontend/src/pages/start.jsx b/frontend/src/pages/start.jsx index 3458828e8..d64cb07a7 100644 --- a/frontend/src/pages/start.jsx +++ b/frontend/src/pages/start.jsx @@ -1,4 +1,4 @@ -import { Navigate, Outlet } from "react-router-dom" +import { Navigate, Outlet } from "react-router-dom"; export const ProtectedRoute = () => { const token = localStorage.getItem("token") diff --git a/package.json b/package.json index 7030e6e6a..abe202837 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ }, "dependencies": { "bcrypt": "^6.0.0", + "bcryptjs": "^3.0.3", "cloudinary": "^1.41.3", "cors": "^2.8.6", "dotenv": "^17.3.1", @@ -17,5 +18,8 @@ "multer-storage-cloudinary": "^4.0.0", "react": "^19.2.4", "react-router-dom": "^7.13.1" + }, + "devDependencies": { + "nodemon": "^3.1.14" } } From 37a9d42b727109367f06f815efa0670793388c64 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 14:01:57 +0100 Subject: [PATCH 16/33] Now admin can create new admins --- backend/middleware/authenticate.js | 2 + .../auth.js => middleware/verifyToken.js} | 14 ++++--- backend/models/User.js | 2 +- backend/routes/catRoutes.js | 7 ++-- backend/routes/commentRoutes.js | 13 +++---- backend/server.js | 2 +- frontend/src/components/card.jsx | 37 +++++++++++++++---- frontend/src/components/login.jsx | 4 +- frontend/src/components/signup.jsx | 4 +- frontend/src/handling/useAuth.js | 1 - frontend/src/handling/useComments.js | 5 ++- frontend/src/pages/adminpanel.jsx | 2 +- frontend/src/pages/dashboard.jsx | 10 ++++- frontend/src/pages/start.jsx | 2 +- 14 files changed, 68 insertions(+), 37 deletions(-) rename backend/{models/auth.js => middleware/verifyToken.js} (67%) diff --git a/backend/middleware/authenticate.js b/backend/middleware/authenticate.js index 3514aabc9..5ad8a5606 100644 --- a/backend/middleware/authenticate.js +++ b/backend/middleware/authenticate.js @@ -2,9 +2,11 @@ import jwt from "jsonwebtoken" export default function authenticate(req, res, next) { const authHeader = req.headers.authorization + if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ msg: 'Missing token' }) + const token = authHeader.split(' ')[1] try { const decoded = jwt.verify(token, process.env.JWT_SECRET); diff --git a/backend/models/auth.js b/backend/middleware/verifyToken.js similarity index 67% rename from backend/models/auth.js rename to backend/middleware/verifyToken.js index 83a52ee00..307ee015d 100644 --- a/backend/models/auth.js +++ b/backend/middleware/verifyToken.js @@ -1,21 +1,23 @@ -import authenticate from "../middleware/authenticate.js" -import authorize from "../middleware/authorize.js" +import jwt from "jsonwebtoken" import User from "../models/User.js" export const verifyToken = async (req, res, next) => { const authHeader = req.headers.authorization + if (!authHeader?.startsWith("Bearer ")) { - return res.status(401).json({ message: "Missing Authorization header" }) + return res.status(401).json({ message: "No token, authorization denied" }) } const token = authHeader.split(" ")[1] + try { - const decoded = authenticate(token) + const decoded = jwt.verify(token, process.env.JWT_SECRET) + const user = await User.findById(decoded.sub) if (!user) throw new Error("User not found") req.user = { - id: user._id, + _id: user._id, name: user.name, role: user.role, } @@ -23,4 +25,4 @@ export const verifyToken = async (req, res, next) => { } catch (err) { return res.status(401).json({ message: `Invalid token: ${err.message}` }) } -} \ No newline at end of file +} diff --git a/backend/models/User.js b/backend/models/User.js index 2a625e54d..8dd669b9c 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -4,7 +4,7 @@ const UserSchema = new mongoose.Schema({ name: { type: String, required: true }, email: { type: String, required: true, unique: true }, password: { type: String, required: true }, - role: { type: String, enum: ["admin", "editor", "viewer"], default: "viewer" }, + role: { type: String, enum: ["admin", "user"], default: "user" }, }, { timestamps: true }) const User = mongoose.models.User || mongoose.model("User", UserSchema) diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index 05eae589c..86e5080cf 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -4,7 +4,6 @@ import dotenv from "dotenv" import { CloudinaryStorage } from "multer-storage-cloudinary" import cloudinaryFramework from "cloudinary" import Cat from "../models/Cat" -import { verifyToken } from "../models/auth" import authenticate from "../middleware/authenticate" import authorize from "../middleware/authorize" @@ -50,7 +49,7 @@ router.get("/cats", async (req, res) => { // One cat router.get("/cats/:id", async (req, res) => { - const id = req.params._id + const id = req.params.id try { const cat = await Cat.findById(id) res.json(cat) @@ -91,7 +90,7 @@ router.post("/cats", authenticate, authorize('admin', 'editor'), parser.single(' }) // Edit -router.put("/cats/:id", authenticate, authorize('admin', 'editor'), verifyToken, async (req, res) => { +router.put("/cats/:id", authenticate, authorize('admin', 'editor'), async (req, res) => { try { const editedCat = req.body @@ -112,7 +111,7 @@ router.put("/cats/:id", authenticate, authorize('admin', 'editor'), verifyToken, // Delete router.delete("/cats/:id", authenticate, authorize('admin', 'editor'), async (req, res) => { - const id = req.params._id + const id = req.params.id try { const cat = await Cat.findById(id) diff --git a/backend/routes/commentRoutes.js b/backend/routes/commentRoutes.js index db80344a5..b936471f5 100644 --- a/backend/routes/commentRoutes.js +++ b/backend/routes/commentRoutes.js @@ -1,19 +1,17 @@ import express from "express" -import { verifyToken } from "../models/auth.js" +import { verifyToken } from "../middleware/verifyToken.js" import Cat from "../models/Cat.js" const router = express.Router() // Comments -router.get("/cats/:catId/comments", async (req, res) => { +router.get("/:catId/comments", async (req, res) => { const { catId } = req.params try { const cat = await Cat.findById(catId).select("comments") if (!cat) return res.status(404).json({ message: "Cat not found" }) - const sorted = cat.comments.sort( - (a, b) => b.createdAt.getTime() - a.createdAt.getTime() - ) + const sorted = cat.comments.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) res.json(sorted) } catch (err) { console.error("Get comments error:", err) @@ -22,7 +20,7 @@ router.get("/cats/:catId/comments", async (req, res) => { }) // Post comment -router.post("/cats/:catId/comments", verifyToken, async (req, res) => { +router.post("/:catId/comments", verifyToken, async (req, res) => { const { catId } = req.params const { text } = req.body @@ -39,6 +37,7 @@ router.post("/cats/:catId/comments", verifyToken, async (req, res) => { userId: req.user._id, userName: req.user.name, text: text.trim(), + createdAt: new Date(), }) await cat.save() @@ -52,7 +51,7 @@ router.post("/cats/:catId/comments", verifyToken, async (req, res) => { }) // Delete comment -router.delete("/cats/:catId/comments/:commentId", verifyToken, async (req, res) => { +router.delete("/:catId/comments/:commentId", verifyToken, async (req, res) => { const { catId, commentId } = req.params const cat = await Cat.findById(catId) if (!cat) return res.status(404).json({ message: "Cat not found" }) diff --git a/backend/server.js b/backend/server.js index a3b053631..59aff9b9d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -23,7 +23,7 @@ mongoose useNewUrlParser: true, useUnifiedTopology: true, }) - .then(() => console.log("✅ Connected to MongoDB")) + .then(() => console.log("Connected to MongoDB")) .catch((err) => { console.error("MongoDB connection error:", err) process.exit(1) diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index f6de3ea49..829ae3b2a 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -1,5 +1,6 @@ import React, { useState } from "react" import styled from "styled-components" +import { fetchJson } from "../api/api" export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, onDeleteComment }) => { const catId = cat._id @@ -12,21 +13,43 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o const [formData, setFormData] = useState(cat) // Show comments - const toggleExpand = () => { + const toggleExpand = async () => { + if (!expanded) { + setLoading(true) + try { + const response = await fetchJson(`/comments/${catId}/comments`) + setComments(response) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } setExpanded((prev) => !prev) } + // New comment const handleCreateComment = async (e) => { e.preventDefault() if (!newText.trim()) return - if (typeof onCreateComment === "function") onCreateComment(catId, newText.trim()) - setNewText("") + try { + const newComment = await onCreateComment(catId, newText.trim()) + setComments(prevComments => [...prevComments, newComment]) + setNewText("") + } catch (err) { + setError(err.message) + } } // Delete comment - const handleDeleteComment = (catId, commentId) => { - if (typeof onDelete === "function") onDeleteComment(catId, commentId) + const handleDeleteComment = async (commentId) => { + try { + await onDeleteComment(catId, commentId) + setComments(prevComments => prevComments.filter(comment => comment._id !== commentId)) + } catch (err) { + setError(err.message) + } } // Delete cat @@ -145,14 +168,14 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o {error && {error}} - {(cat.comments ?? []).map((c) => ( + {comments.map((c) => (
{c.userName} {new Date(c.createdAt).toLocaleString()}
- handleDeleteComment(catId, c._id)}> + handleDeleteComment(c._id)}> 🗑️
diff --git a/frontend/src/components/login.jsx b/frontend/src/components/login.jsx index e8304ee8a..3f359059b 100644 --- a/frontend/src/components/login.jsx +++ b/frontend/src/components/login.jsx @@ -79,8 +79,8 @@ export const LoginForm = ({ login }) => { - ); -}; + ) +} export default LoginForm diff --git a/frontend/src/components/signup.jsx b/frontend/src/components/signup.jsx index a4349d6ee..c34ce8c29 100644 --- a/frontend/src/components/signup.jsx +++ b/frontend/src/components/signup.jsx @@ -86,8 +86,8 @@ export const SignUpForm = ({ onSuccess }) => { - ); -}; + ) +} export default SignUpForm diff --git a/frontend/src/handling/useAuth.js b/frontend/src/handling/useAuth.js index c17259edc..58d755344 100644 --- a/frontend/src/handling/useAuth.js +++ b/frontend/src/handling/useAuth.js @@ -19,7 +19,6 @@ export const useAuth = () => { const storeSession = useCallback((session) => { const { token, ...rest } = session - console.log("Storing user session:", rest) if (token) localStorage.setItem("token", token) localStorage.setItem("user", JSON.stringify(rest)) setUser(rest) diff --git a/frontend/src/handling/useComments.js b/frontend/src/handling/useComments.js index 93f5a7a8a..4921bdba2 100644 --- a/frontend/src/handling/useComments.js +++ b/frontend/src/handling/useComments.js @@ -5,15 +5,16 @@ export const useComments = () => { // Add const createComment = useCallback(async (catId, text) => { - await fetchJson(`/cats/${catId}/comments`, { + const newComment = await fetchJson(`/comments/${catId}/comments`, { method: "POST", body: JSON.stringify({ text: text.trim() }), }) + return newComment // ← return it so CatCard can use it }, []) // Delete const deleteComment = useCallback(async (catId, commentId) => { - await fetchJson(`/cats/${catId}/comments/${commentId}`, { + return await fetchJson(`/comments/${catId}/comments/${commentId}`, { // ← fixed path method: "DELETE", }) }, []) diff --git a/frontend/src/pages/adminpanel.jsx b/frontend/src/pages/adminpanel.jsx index 36e22f9f0..c024cffa3 100644 --- a/frontend/src/pages/adminpanel.jsx +++ b/frontend/src/pages/adminpanel.jsx @@ -54,7 +54,7 @@ export default function AdminPanel() { const token = localStorage.getItem("token") try { - const response = await fetch(`${apiUrl}/users/signup`, { + const response = await fetch(`${apiUrl}/admin/users`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index 21b5c89b4..4f31236e2 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -1,5 +1,5 @@ import { useContext, useState } from "react" -import { Link } from "react-router-dom" +import { Link, useNavigate } from "react-router-dom" import { AuthContext } from "../components/authenticate" import styled from "styled-components" import CatForm from "../components/catForm" @@ -14,6 +14,12 @@ export const Dashboard = ({ onDeleteComment, }) => { const { user, logout } = useContext(AuthContext) + const navigate = useNavigate() + + const handleLogout = () => { + logout() + navigate("/login") + } // Filter state const [locationFilter, setLocationFilter] = useState("") @@ -27,7 +33,7 @@ export const Dashboard = ({ {user && ( Welcome, {user.name}! - Logout + Logout )} diff --git a/frontend/src/pages/start.jsx b/frontend/src/pages/start.jsx index d64cb07a7..3458828e8 100644 --- a/frontend/src/pages/start.jsx +++ b/frontend/src/pages/start.jsx @@ -1,4 +1,4 @@ -import { Navigate, Outlet } from "react-router-dom"; +import { Navigate, Outlet } from "react-router-dom" export const ProtectedRoute = () => { const token = localStorage.getItem("token") From c4482be490114502d1f9e1d48ffcc2bac1ee9176 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 14:50:37 +0100 Subject: [PATCH 17/33] Adds the final responsiveness --- frontend/src/components/card.jsx | 6 ++-- frontend/src/components/catForm.jsx | 55 +++++++++++++++++++---------- frontend/src/pages/adminpanel.jsx | 31 +++++++++------- frontend/src/pages/dashboard.jsx | 49 ++++++++++++++----------- 4 files changed, 86 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/card.jsx b/frontend/src/components/card.jsx index 829ae3b2a..50317bd6a 100644 --- a/frontend/src/components/card.jsx +++ b/frontend/src/components/card.jsx @@ -205,8 +205,9 @@ export const CatCard = ({ cat, currentUser, onEdit, onCreateComment, onDelete, o } const CardWrapper = styled.article` + width: 100%; max-width: 365px; - min-width: 320px; + min-width: 0; border: 2px solid #3f895c; border-radius: 8px; overflow: hidden; @@ -283,7 +284,6 @@ const OtherBtn = styled.button` const Tag = styled.span` background: #d4ded7; - /* color: #000000; */ padding: 2px 6px; border-radius: 4px; font-size: 0.85rem; @@ -343,7 +343,7 @@ const Text = styled.p` ` const CommentInput = styled.textarea` - width: 92%; + width: 100%; min-height: 60px; resize: vertical; margin-bottom: 6px; diff --git a/frontend/src/components/catForm.jsx b/frontend/src/components/catForm.jsx index 4706a6785..814cb74ce 100644 --- a/frontend/src/components/catForm.jsx +++ b/frontend/src/components/catForm.jsx @@ -88,25 +88,18 @@ export const CatForm = ({ onSuccess }) => {
- - - {previewUrl && ( - - )} - -
- -
+ +
{/* Name */} @@ -178,6 +171,8 @@ const Wrapper = styled.main` background-color: #2B5C3F; max-width: 345px; box-shadow: 0px 10px 20px 2px rgba(0, 0, 0, 0.25); + box-sizing: border-box; + width: 100%; ` const FormWrapper = styled.form` @@ -189,12 +184,20 @@ const FormWrapper = styled.form` const StyledName = styled.input` width: 68.5%; + + @media (max-width: 400px) { + width: 100%; + } ` const StyledSelect = styled.select` width: 70%; height: 25px; text-align: center; + + @media (max-width: 400px) { + width: 100%; + } ` const StyledRow = styled.div` @@ -202,6 +205,11 @@ const StyledRow = styled.div` flex-direction: row; justify-content: space-between; margin: 3px 0px; + + @media (max-width: 400px) { + flex-direction: column; + gap: 4px; + } ` const ImageBox = styled.div` @@ -212,8 +220,17 @@ const ImageBox = styled.div` justify-content: center; overflow: hidden; margin-bottom: 15px; + cursor: pointer; + + &:hover { + opacity: 0.85; + } ` +const HiddenInput = styled.input` + display: none; +` + const PreviewImg = styled.img` max-width: 100%; max-height: 100%; diff --git a/frontend/src/pages/adminpanel.jsx b/frontend/src/pages/adminpanel.jsx index c024cffa3..cd1928049 100644 --- a/frontend/src/pages/adminpanel.jsx +++ b/frontend/src/pages/adminpanel.jsx @@ -80,9 +80,9 @@ export default function AdminPanel() { return ( - +

Admin Dashboard

Welcome, {user.name}

@@ -130,20 +130,12 @@ export default function AdminPanel() { style={inputStyle} /> - +
@@ -164,4 +156,17 @@ const inputStyle = { borderRadius: '4px', border: '1px solid #ccc', fontSize: '14px', -} \ No newline at end of file +} + +const StyledBtn = styled.button` + background-color: #b0cebd; + border: 2px solid #3f895c; + border-radius: 8px; + padding: 8px; + margin-bottom: 10px; + + &:hover { + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 8px; + cursor: pointer; + } +` \ No newline at end of file diff --git a/frontend/src/pages/dashboard.jsx b/frontend/src/pages/dashboard.jsx index 4f31236e2..3a72ee513 100644 --- a/frontend/src/pages/dashboard.jsx +++ b/frontend/src/pages/dashboard.jsx @@ -33,26 +33,24 @@ export const Dashboard = ({ {user && ( Welcome, {user.name}! + {user.role === "admin" && ( + + Admin Panel + + )} Logout )} - {/* Admin button - only shown for admins */} - {user?.role === "admin" && ( - - Go to Admin Panel - - )} - All Cats - + + - + + Date: Wed, 11 Mar 2026 14:58:59 +0100 Subject: [PATCH 18/33] pin cloudinary to 1.x to satisfy multer-storage-cloudinary peer dependency --- backend/backend/package.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 backend/backend/package.json diff --git a/backend/backend/package.json b/backend/backend/package.json new file mode 100644 index 000000000..09d13ed92 --- /dev/null +++ b/backend/backend/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "cloudinary": "^1.41.3" + } +} From 70ec8a6c290c43aa478f9b82ede4b69c583ab498 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 15:08:34 +0100 Subject: [PATCH 19/33] Changes cloudinary for deployment --- backend/middleware/authenticate.js | 2 +- backend/package.json | 2 +- backend/routes/catRoutes.js | 8 ++++---- backend/routes/userRoutes.js | 4 ++-- frontend/src/main.jsx | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/middleware/authenticate.js b/backend/middleware/authenticate.js index 5ad8a5606..16bb6a8d4 100644 --- a/backend/middleware/authenticate.js +++ b/backend/middleware/authenticate.js @@ -9,7 +9,7 @@ export default function authenticate(req, res, next) { const token = authHeader.split(' ')[1] try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); + const decoded = jwt.verify(token, process.env.JWT_SECRET) req.user = decoded next() } catch (err) { diff --git a/backend/package.json b/backend/package.json index c58c87acb..79a1ce296 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,7 @@ "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", "bcryptjs": "^3.0.3", - "cloudinary": "^2.9.0", + "cloudinary": "^1.21.0", "cors": "^2.8.5", "dotenv": "^17.3.1", "express": "^4.22.1", diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js index 86e5080cf..e143c1fa9 100644 --- a/backend/routes/catRoutes.js +++ b/backend/routes/catRoutes.js @@ -2,7 +2,7 @@ import express, { } from "express" import multer from "multer" import dotenv from "dotenv" import { CloudinaryStorage } from "multer-storage-cloudinary" -import cloudinaryFramework from "cloudinary" +import cloudinary from "cloudinary" import Cat from "../models/Cat" import authenticate from "../middleware/authenticate" import authorize from "../middleware/authorize" @@ -11,15 +11,15 @@ import authorize from "../middleware/authorize" dotenv.config() // Cloudinary -const cloudinary = cloudinaryFramework.v2 -cloudinary.config({ +const { v2: cloudinaryV2 } = cloudinary +cloudinaryV2.config({ cloud_name: process.env.CLOUDINARY_CLOUD_NAME, api_key: process.env.CLOUDINARY_API_KEY, api_secret: process.env.CLOUDINARY_API_SECRET, }) const storage = new CloudinaryStorage({ - cloudinary, + cloudinary: cloudinaryV2, params: { folder: "cats", allowedFormats: ["jpg", "png"], diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index 366f712f2..f06d20e34 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -10,7 +10,7 @@ const router = express.Router() // Signup router.post("/signup", async (req, res) => { try { - const { name, email, password } = req.body; + const { name, email, password } = req.body if (!name?.trim() || !email?.trim() || !password) { return res.status(400).json({ @@ -19,7 +19,7 @@ router.post("/signup", async (req, res) => { }) } - const existingUser = await User.findOne({ email: email.toLowerCase() }); + const existingUser = await User.findOne({ email: email.toLowerCase() }) if (existingUser) { return res.status(400).json({ success: false, diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index f6c876b42..dea7578fc 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client" import { BrowserRouter } from "react-router-dom" import App from "./App" import "./index.css" -import { AuthProvider } from "./components/authenticate"; +import { AuthProvider } from "./components/authenticate" ReactDOM.createRoot(document.getElementById("root")).render( From 7543d2a1998e0170dc0859bec401653eddf6c6d9 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 15:15:09 +0100 Subject: [PATCH 20/33] Fixing deployment --- frontend/public/_redirects | 1 + 1 file changed, 1 insertion(+) create mode 100644 frontend/public/_redirects diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 000000000..3e05d2db4 --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file From a9ca8c2ea3c70d330a501a27cf17aa1451d95fa2 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 16:04:09 +0100 Subject: [PATCH 21/33] More troubleshooting deployment --- backend/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/server.js b/backend/server.js index 59aff9b9d..fd6d8ff6b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -32,7 +32,7 @@ mongoose import userRouter from "./routes/userRoutes.js" import catRouter from "./routes/catRoutes.js" import commentRouter from "./routes/commentRoutes.js" -import adminRouter from "./routes/adminRoutes" +import adminRouter from "./routes/adminRoutes.js" app.use("/admin", adminRouter) app.use("/users", userRouter) From 5e6c058de78ffedaee2c0c812755cfaa7b7e023e Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 16:23:49 +0100 Subject: [PATCH 22/33] More deploytrouble --- backend/routes/userRoutes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index f06d20e34..42db8ec55 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -1,4 +1,4 @@ -import bcrypt from "bcrypt" +import bcrypt from "bcryptjs" import express from "express" import dotenv from "dotenv" import User from "../models/User.js" From 7783d0a6e5e26f2b06e7b784fbb763c2781c3f70 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 16:41:29 +0100 Subject: [PATCH 23/33] Add jsonwebtoken dependency --- backend/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/package.json b/backend/package.json index 79a1ce296..1def31f2a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "cors": "^2.8.5", "dotenv": "^17.3.1", "express": "^4.22.1", + "jsonwebtoken": "^9.0.3", "mongoose": "^8.23.0", "multer": "^2.0.2", "multer-storage-cloudinary": "^4.0.0", From 8145466cf030c82dd6a7f3445c2dabe331245013 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 16:46:48 +0100 Subject: [PATCH 24/33] Fix adminname --- backend/routes/{adminRoutes => adminRoutes.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/routes/{adminRoutes => adminRoutes.js} (100%) diff --git a/backend/routes/adminRoutes b/backend/routes/adminRoutes.js similarity index 100% rename from backend/routes/adminRoutes rename to backend/routes/adminRoutes.js From d0c02003093db32a2db3bde3fbad90812c59fea9 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 16:54:27 +0100 Subject: [PATCH 25/33] Adds renderlink hardcoded --- frontend/src/api/api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 4be7a3ffb..887a608f4 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -1,4 +1,4 @@ -export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8080" +export const API_URL = import.meta.env.VITE_API_URL || "https://api.render.com/deploy/srv-d6onvbqa214c73bfjus0?key=kG1oUUp7PgU" export const fetchJson = async (endpoint, options = {}) => { From d5e35289c49fdae06c6509115483939450f8846e Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 17:03:53 +0100 Subject: [PATCH 26/33] Adds right render --- frontend/src/api/api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 887a608f4..9c6c2b7ef 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -1,4 +1,4 @@ -export const API_URL = import.meta.env.VITE_API_URL || "https://api.render.com/deploy/srv-d6onvbqa214c73bfjus0?key=kG1oUUp7PgU" +export const API_URL = import.meta.env.VITE_API_URL || "https://catsarchivetrackingsystem.onrender.com" export const fetchJson = async (endpoint, options = {}) => { From fa704577a7e87c2d448c65c7cdd1773925961ca2 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 18:13:41 +0100 Subject: [PATCH 27/33] Fixes spellingerror --- backend/server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/server.js b/backend/server.js index fd6d8ff6b..2cac3e3dc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -17,9 +17,9 @@ app.use( app.use(express.json({ limit: "10mb" })) app.use(express.urlencoded({ limit: "10mb", extended: true })) -const mongoUri = process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project" +const MONGO_URL = process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project" mongoose - .connect(mongoUri, { + .connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true, }) From 0253f6910336c443e31315b5a70aab89a087a22d Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 18:18:15 +0100 Subject: [PATCH 28/33] Fixed another spellingerror --- backend/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/server.js b/backend/server.js index 2cac3e3dc..597b4fd63 100644 --- a/backend/server.js +++ b/backend/server.js @@ -17,7 +17,7 @@ app.use( app.use(express.json({ limit: "10mb" })) app.use(express.urlencoded({ limit: "10mb", extended: true })) -const MONGO_URL = process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project" +const mongoUrl = process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project" mongoose .connect(mongoUrl, { useNewUrlParser: true, From bb9a448e4023dfb828730537f3f382e28f756dd9 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 18:33:56 +0100 Subject: [PATCH 29/33] Moved the imports --- backend/server.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/server.js b/backend/server.js index 597b4fd63..3ae27ce59 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,6 +3,11 @@ import cors from "cors" import dotenv from "dotenv" import mongoose from "mongoose" +import userRouter from "./routes/userRoutes.js" +import catRouter from "./routes/catRoutes.js" +import commentRouter from "./routes/commentRoutes.js" +import adminRouter from "./routes/adminRoutes.js" + dotenv.config() const app = express() @@ -29,11 +34,6 @@ mongoose process.exit(1) }) -import userRouter from "./routes/userRoutes.js" -import catRouter from "./routes/catRoutes.js" -import commentRouter from "./routes/commentRoutes.js" -import adminRouter from "./routes/adminRoutes.js" - app.use("/admin", adminRouter) app.use("/users", userRouter) app.use("/", catRouter) From 3a73a927422e7a77160d659e68b8161c9c910d9d Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 19:47:20 +0100 Subject: [PATCH 30/33] Removes one handler --- frontend/index.html | 51 +++++++++++++------ frontend/src/App.jsx | 4 +- frontend/src/components/authenticate.jsx | 19 ++++++- frontend/src/handling/useAuth.js | 63 ------------------------ 4 files changed, 56 insertions(+), 81 deletions(-) delete mode 100644 frontend/src/handling/useAuth.js diff --git a/frontend/index.html b/frontend/index.html index 862b29bda..006669622 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,16 +1,39 @@ - - - - - - - - Technigo React Vite Boiler Plate - - -
- - - + + + + + + + + + Cat Archive Tracking System + + + +
+ + + + \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fbebd3415..cc107cd0c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,14 +7,14 @@ import ProtectedRoute from "./pages/start" import { Dashboard } from "./pages/dashboard" import AdminPanel from "./pages/adminpanel" -import useAuth from "./handling/useAuth" +import { AuthContext } from "./components/authenticate" import { useCats } from "./handling/useCats" import { useComments } from "./handling/useComments" import styled from "styled-components" export const App = () => { - const { user, login, signup, logout } = useAuth() + const { user, login, logout } = useContext(AuthContext) // Cats const { diff --git a/frontend/src/components/authenticate.jsx b/frontend/src/components/authenticate.jsx index 186b7851e..7a6f5f403 100644 --- a/frontend/src/components/authenticate.jsx +++ b/frontend/src/components/authenticate.jsx @@ -27,7 +27,22 @@ export const AuthProvider = ({ children }) => { } }, []) - const login = (token) => { + const login = async (email, password) => { + const data = await fetchJson("/users/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }) + const token = data.response.token + localStorage.setItem("token", token) + syncUserFromToken(token) + } + + const signup = async (name, email, password) => { + const data = await fetchJson("/users/signup", { + method: "POST", + body: JSON.stringify({ name, email, password }), + }) + const token = data.response.token localStorage.setItem("token", token) syncUserFromToken(token) } @@ -38,7 +53,7 @@ export const AuthProvider = ({ children }) => { } return ( - + {children} ) diff --git a/frontend/src/handling/useAuth.js b/frontend/src/handling/useAuth.js deleted file mode 100644 index 58d755344..000000000 --- a/frontend/src/handling/useAuth.js +++ /dev/null @@ -1,63 +0,0 @@ -import { useState, useCallback, useEffect } from "react" -import { - fetchJson -} from "../api/api" - -export const useAuth = () => { - const [user, setUser] = useState(null) - - useEffect(() => { - const stored = localStorage.getItem("user") - if (stored) { - try { - setUser(JSON.parse(stored)) - } catch (_) { - localStorage.removeItem("user") - } - } - }, []) - - const storeSession = useCallback((session) => { - const { token, ...rest } = session - if (token) localStorage.setItem("token", token) - localStorage.setItem("user", JSON.stringify(rest)) - setUser(rest) - }, []) - - // Login - const login = useCallback( - async (email, password) => { - const response = await fetchJson("/users/login", { - method: "POST", - body: JSON.stringify({ email, password }), - }) - storeSession(response.response) - const storedUser = localStorage.getItem("user") - if (storedUser) { - setUser(JSON.parse(storedUser)) - } - }, - [storeSession] - ) - - // Signup - const signup = useCallback(async (name, email, password) => { - const { response } = await fetchJson("/users/signup", { - method: "POST", - body: JSON.stringify({ name, email, password }), - }) - storeSession(response) - }, [storeSession]) - - - // Logout - const logout = useCallback(() => { - localStorage.removeItem("token") - localStorage.removeItem("user") - setUser(null) - }, []) - - return { user, login, signup, logout } -} - -export default useAuth \ No newline at end of file From 498420cc36e3a33f461784a9361dccfb200ca17f Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 19:49:58 +0100 Subject: [PATCH 31/33] Added useContext to app --- frontend/src/App.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cc107cd0c..f85d59cdd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useContext } from "react" import { Routes, Route, Navigate } from "react-router-dom" import { GlobalStyle } from "./styling/GlobalStyles" import { LoginForm } from "./components/login" From c79e3632362675b2108d35fb6ad7da31cbdffc10 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 19:51:23 +0100 Subject: [PATCH 32/33] Adds fetchJson to auth --- frontend/src/components/authenticate.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/authenticate.jsx b/frontend/src/components/authenticate.jsx index 7a6f5f403..2c7b4b9af 100644 --- a/frontend/src/components/authenticate.jsx +++ b/frontend/src/components/authenticate.jsx @@ -1,3 +1,4 @@ +import { fetchJson } from "../api/api" import { createContext, useState, useEffect } from "react" import { jwtDecode } from "jwt-decode" From 4939b30513172670b1623e7545288c410879bda4 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 11 Mar 2026 19:55:42 +0100 Subject: [PATCH 33/33] Changes url in auth --- frontend/src/pages/adminpanel.jsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/adminpanel.jsx b/frontend/src/pages/adminpanel.jsx index cd1928049..756d409a0 100644 --- a/frontend/src/pages/adminpanel.jsx +++ b/frontend/src/pages/adminpanel.jsx @@ -1,10 +1,9 @@ +import { API_URL } from "../api/api" import { useContext, useEffect, useState } from "react" import { useNavigate } from "react-router-dom" import styled from "styled-components" import { AuthContext } from "../components/authenticate" -const apiUrl = import.meta.env.VITE_API_URL - export default function AdminPanel() { const navigate = useNavigate() const { user } = useContext(AuthContext) @@ -21,12 +20,12 @@ export default function AdminPanel() { useEffect(() => { if (!user) return - if (!apiUrl) { + if (!API_URL) { setError("API URL not configured") return } const token = localStorage.getItem("token") - fetch(`${apiUrl}/admin`, { + fetch(`${API_URL}/admin`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then((res) => { @@ -35,7 +34,7 @@ export default function AdminPanel() { }) .then((data) => setStats(data.data)) .catch((err) => setError(err.message)) - }, [user, apiUrl]) + }, [user, API_URL]) if (!user) return

Loading…

if (user.role !== "admin") return

Access denied, admin only.

@@ -54,7 +53,7 @@ export default function AdminPanel() { const token = localStorage.getItem("token") try { - const response = await fetch(`${apiUrl}/admin/users`, { + const response = await fetch(`${API_URL}/admin/users`, { method: "POST", headers: { "Content-Type": "application/json",