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" + } +} diff --git a/backend/middleware/authenticate.js b/backend/middleware/authenticate.js new file mode 100644 index 000000000..16bb6a8d4 --- /dev/null +++ b/backend/middleware/authenticate.js @@ -0,0 +1,18 @@ +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) + req.user = decoded + next() + } catch (err) { + return res.status(401).json({ msg: 'Invalid token' }) + } +} \ No newline at end of file diff --git a/backend/middleware/authorize.js b/backend/middleware/authorize.js new file mode 100644 index 000000000..26440562e --- /dev/null +++ b/backend/middleware/authorize.js @@ -0,0 +1,11 @@ +export default 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/middleware/verifyToken.js b/backend/middleware/verifyToken.js new file mode 100644 index 000000000..307ee015d --- /dev/null +++ b/backend/middleware/verifyToken.js @@ -0,0 +1,28 @@ +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: "No token, authorization denied" }) + } + + const token = authHeader.split(" ")[1] + + try { + 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, + name: user.name, + role: user.role, + } + next() + } catch (err) { + return res.status(401).json({ message: `Invalid token: ${err.message}` }) + } +} diff --git a/backend/models/Cat.js b/backend/models/Cat.js new file mode 100644 index 000000000..0dd467ee1 --- /dev/null +++ b/backend/models/Cat.js @@ -0,0 +1,17 @@ +import mongoose from "mongoose" +import commentSchema from "./Comments.js" + +const catSchema = new mongoose.Schema( + { + name: { type: String, required: true }, + gender: { + type: String, + enum: ["male", "female"], + required: true, + }, + imageUrl: { type: String, required: true }, + location: { type: String, required: true }, + comments: [commentSchema], default: [], + }, { timestamps: true }) + +export default mongoose.models.Cat || mongoose.model("Cat", catSchema) \ No newline at end of file diff --git a/backend/models/Comments.js b/backend/models/Comments.js new file mode 100644 index 000000000..95eed9181 --- /dev/null +++ b/backend/models/Comments.js @@ -0,0 +1,23 @@ +import mongoose from "mongoose" + +const commentSchema = new mongoose.Schema( + { + 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 commentSchema \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 000000000..8dd669b9c --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,12 @@ +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", "user"], default: "user" }, +}, { timestamps: true }) + +const User = mongoose.models.User || mongoose.model("User", UserSchema) + +export default User \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f244..1def31f2a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,15 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcryptjs": "^3.0.3", + "cloudinary": "^1.21.0", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", + "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", "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/adminRoutes.js b/backend/routes/adminRoutes.js new file mode 100644 index 000000000..50ab52318 --- /dev/null +++ b/backend/routes/adminRoutes.js @@ -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 new file mode 100644 index 000000000..e143c1fa9 --- /dev/null +++ b/backend/routes/catRoutes.js @@ -0,0 +1,142 @@ +import express, { } from "express" +import multer from "multer" +import dotenv from "dotenv" +import { CloudinaryStorage } from "multer-storage-cloudinary" +import cloudinary from "cloudinary" +import Cat from "../models/Cat" +import authenticate from "../middleware/authenticate" +import authorize from "../middleware/authorize" + + +dotenv.config() + +// Cloudinary +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: cloudinaryV2, + params: { + folder: "cats", + allowedFormats: ["jpg", "png"], + transformation: [{ + width: 500, + height: 500, + crop: "limit", + effect: "background_removal:fineedges" + }], + }, +}) + +const parser = multer({ storage }) + +const router = express.Router() + +// All cats +router.get("/cats", async (req, res) => { + try { + const cats = await Cat.find().sort({ createdAt: -1 }) + res.json(cats) + + } catch (error) { + res.status(500).json({ error: "Failed to fetch cats" }) + } +}) + +// 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", authenticate, authorize('admin', 'editor'), 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' }) + } +}) + +// Edit +router.put("/cats/:id", authenticate, authorize('admin', 'editor'), 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", authenticate, authorize('admin', 'editor'), 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 new file mode 100644 index 000000000..b936471f5 --- /dev/null +++ b/backend/routes/commentRoutes.js @@ -0,0 +1,77 @@ +import express from "express" +import { verifyToken } from "../middleware/verifyToken.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" }) + + 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(), + createdAt: new Date(), + }) + + 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" }) + } +}) + +// Delete comment +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" }) + 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/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 000000000..42db8ec55 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,133 @@ +import bcrypt from "bcryptjs" +import express from "express" +import dotenv from "dotenv" +import User from "../models/User.js" +import { signJwt } from "../utils/jwt.js" +dotenv.config() + +const router = express.Router() + +// Signup +router.post("/signup", async (req, res) => { + try { + const { name, email, password } = req.body + + 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", + }) + } + + const salt = bcrypt.genSaltSync() + const hashedPassword = bcrypt.hashSync(password, salt) + + const user = new User({ + name: name.trim(), + email: email.toLowerCase(), + password: hashedPassword, + }) + + await user.save() + + const token = signJwt({ sub: user._id, role: user.role, name: user.name }) + + res.status(201).json({ + success: true, + message: "User created", + response: { + id: user._id, + name: user.name, + email: user.email, + role: user.role, + token, + }, + }) + } catch (error) { + 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, + }) + } +}) + +// Login +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body + 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, + }) + } + + const passwordMatches = bcrypt.compareSync(password, user.password) + if (!passwordMatches) { + return res.status(401).json({ + success: false, + 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, + role: user.role, + token, + }, + }) + } catch (error) { + console.error("Login error details:", error) + res.status(500).json({ + success: false, + message: "Something went wrong", + response: error.message, + }) + } +}) + +// Delete +router.delete( + "/users/:id", + 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 070c87518..3ae27ce59 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,50 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +import express from "express" +import cors from "cors" +import dotenv from "dotenv" +import mongoose from "mongoose" -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +import userRouter from "./routes/userRoutes.js" +import catRouter from "./routes/catRoutes.js" +import commentRouter from "./routes/commentRoutes.js" +import adminRouter from "./routes/adminRoutes.js" -const port = process.env.PORT || 8080; -const app = express(); +dotenv.config() -app.use(cors()); -app.use(express.json()); +const app = express() -app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); +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 })) -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); +const mongoUrl = process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project" +mongoose + .connect(mongoUrl, { + useNewUrlParser: true, + useUnifiedTopology: true, + }) + .then(() => console.log("Connected to MongoDB")) + .catch((err) => { + console.error("MongoDB connection error:", err) + process.exit(1) + }) + +app.use("/admin", adminRouter) +app.use("/users", userRouter) +app.use("/", catRouter) +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 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/index.html b/frontend/index.html index 664410b5b..006669622 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,39 @@ - - - - - Technigo React Vite Boiler Plate - - -
- - - + + + + + + + + + Cat Archive Tracking System + + + +
+ + + + \ No newline at end of file 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/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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..f85d59cdd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,102 @@ +import React, { useContext } 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 AdminPanel from "./pages/adminpanel" + +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, logout } = useContext(AuthContext) + + // Cats + const { + cats, + loadCats, + addCat, + editCat, + deleteCat, + } = useCats() + + // Comments + const { createComment, deleteComment } = useComments() + + React.useEffect(() => { + loadCats() + }, [loadCats]) + + const handleLogin = async (email, password) => { + await login(email, password) + } + + const handleSignUpSuccess = async (newUser) => { + await signup(newUser.name, newUser.email, newUser.password) + } return ( <> -

Welcome to Final Project!

+ + + + } + /> + + } + /> + + }> + + } + /> + } /> + + + {/* FALLBACK */} + } /> + - ); -}; + ) +} + +export default App + +const ToggleWrapper = styled.div` + margin-top: 12px; + text-align: center; +` + +const ToggleBtn = styled.button` + background: none; + border: none; + color: #000; + cursor: pointer; + font-size: 0.95rem; + &:hover { + text-decoration: underline; + } +` \ 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..9c6c2b7ef --- /dev/null +++ b/frontend/src/api/api.js @@ -0,0 +1,38 @@ +export const API_URL = import.meta.env.VITE_API_URL || "https://catsarchivetrackingsystem.onrender.com" + + +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/assets/placeholder.png b/frontend/src/assets/placeholder.png new file mode 100644 index 000000000..2b6d5361d Binary files /dev/null and b/frontend/src/assets/placeholder.png differ diff --git a/frontend/src/assets/placeholder2.png b/frontend/src/assets/placeholder2.png new file mode 100644 index 000000000..f93d74efd Binary files /dev/null and b/frontend/src/assets/placeholder2.png differ diff --git a/frontend/src/assets/placeholderImg.jpg b/frontend/src/assets/placeholderImg.jpg new file mode 100644 index 000000000..dfd89b0f7 Binary files /dev/null and b/frontend/src/assets/placeholderImg.jpg differ diff --git a/frontend/src/components/authenticate.jsx b/frontend/src/components/authenticate.jsx new file mode 100644 index 000000000..2c7b4b9af --- /dev/null +++ b/frontend/src/components/authenticate.jsx @@ -0,0 +1,61 @@ +import { fetchJson } from "../api/api" +import { createContext, useState, useEffect } from "react" +import { 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(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) + } + }, []) + + 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) + } + + 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 new file mode 100644 index 000000000..50317bd6a --- /dev/null +++ b/frontend/src/components/card.jsx @@ -0,0 +1,367 @@ +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 + const [expanded, setExpanded] = useState(false) + const [comments, setComments] = useState([]) + const [loading, setLoading] = useState(false) + const [newText, setNewText] = useState("") + const [error, setError] = useState("") + const [isEditing, setIsEditing] = useState(false) + const [formData, setFormData] = useState(cat) + + // Show comments + 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 + try { + const newComment = await onCreateComment(catId, newText.trim()) + setComments(prevComments => [...prevComments, newComment]) + setNewText("") + } catch (err) { + setError(err.message) + } + } + + // Delete comment + const handleDeleteComment = async (commentId) => { + try { + await onDeleteComment(catId, commentId) + setComments(prevComments => prevComments.filter(comment => comment._id !== commentId)) + } catch (err) { + setError(err.message) + } + } + + // Delete cat + const handleDelete = () => { + if (window.confirm(`Delete "${cat.name}"? This cannot be undone.`)) { + if (typeof onDelete === "function") onDelete(catId) + } + } + + // 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 ( + + {isEditing ? ( + + <> + + + + + Name + + + {/* Gender */} + + + + + {/* Location */} + + + + + + + + 💾 + ✖️ + + + + ) : ( + + <> + + + + + + {cat.name} + + setIsEditing(true)}>✏️ + + 🗑️ + + + + + + {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()} +
+ handleDeleteComment(c._id)}> + 🗑️ + +
+ {c.text} +
+ ))} +
+ + {currentUser && ( +
+ setNewText(e.target.value)} + required + /> + Post + + )} +
+ ) + } + + )} +
+ ) +} + +const CardWrapper = styled.article` + width: 100%; + max-width: 365px; + min-width: 0; + 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; + background: #F5F5F5; + box-shadow: 0px 10px 20px 2px rgba(0, 0, 0, 0.25); + + &:hover { + transform: scale(1.01); + } +` + +const ImgWrapper = styled.div` + 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; + ` + +const Info = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 12px 16px; + ` + +const EditDelete = styled.div` + display: flex; + flex-direction: row; +` + +const Name = styled.h3` + margin: 0 0 8px; + font-size: 1.1rem; + ` + +const Meta = styled.div` + display: flex; + flex-direction: row; + margin: 5px 15px; + gap: 5px; + ` + +const EditMode = styled.div` + display: flex; + flex-direction: row; + gap: 8px; + justify-content: center; + margin-bottom: 8px; + ` + +const OtherBtn = styled.button` + background: #FFFFFF; + border-radius: 50px; + border: none; + font-size: 1rem; + cursor: pointer; + margin-right: 6px; + + &:hover { + transform: scale(1.15); + } +` + +const Tag = styled.span` + background: #d4ded7; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.85rem; + ` + +const ToggleBtn = styled.button` + 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; + ` + +const CommentList = styled.ul` + list-style: none; + padding: 0; + margin: 0 0 12px; + ` + +const CommentItem = styled.li` + margin-bottom: 10px; + background: #f5f5f5; + padding: 6px 8px; + width: 90%; + ` + +const CommentInfo = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; +` + +const Author = styled.span` + color: #3f895c; + 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-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: 6px 12px; + 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 new file mode 100644 index 000000000..889e3dcf3 --- /dev/null +++ b/frontend/src/components/cardlist.jsx @@ -0,0 +1,69 @@ +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) + }) + + // Apply location/gender filters + const filteredCats = validCats.filter((cat) => { + const matchesLocation = + !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.map((cat) => ( + + ))} + + ) +} + +export default CatList + +const Grid = styled.section` + display: grid; + gap: 20px; + + /* Mobile: 1 column (default) */ + grid-template-columns: 1fr; + + /* Tablet: 2 columns */ + @media (min-width: 760px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + /* Desktop: 3 columns */ + @media (min-width: 1120px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + /* Desktop: 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 new file mode 100644 index 000000000..814cb74ce --- /dev/null +++ b/frontend/src/components/catForm.jsx @@ -0,0 +1,254 @@ +import { useState } from "react" +import styled from "styled-components" +import { API_URL } from "../api/api" +import placeholder2 from "../assets/placeholder2.png" + +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(placeholder2) + + 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(placeholder2) + } + } 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 token = localStorage.getItem("token") + const response = await fetch(`${API_URL}/cats`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + 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) + } + + setFormData({ + picture: null, + name: "", + gender: "", + location: "", + }) + setPreviewUrl(placeholder2) + + } catch (error) { + console.error("Submit error:", error) + setErrorMsg("Could not save cat information") + } finally { + setIsSubmitting(false) + } + } + return ( + + +
+ + +
+ + {/* Name */} + + + + + + {/* Gender */} + + + + + + + + + + {/* Location */} + + + + + + + + + + + + + {/* Submit / Feedback */} + {errorMsg &&

{errorMsg}

} + + {isSubmitting ? "Saving…" : "Add Cat"} + +
+
+ ) +} + +export default CatForm + + +const Wrapper = styled.main` + padding: 10px; + border: 1px solid #265237; + border-radius: 24px; + margin-bottom: 50px; + 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` + background: #d4ded7; + border: 1px solid #417354; + border-radius: 16px; + padding: 20px; + ` + +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` + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 3px 0px; + + @media (max-width: 400px) { + flex-direction: column; + gap: 4px; + } +` + +const ImageBox = styled.div` + max-width: 300px; + max-height: 300px; + display: flex; + align-items: center; + 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%; + object-fit: contain; + width: "100%"; + 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; + cursor: pointer; + } +` \ No newline at end of file diff --git a/frontend/src/components/login.jsx b/frontend/src/components/login.jsx new file mode 100644 index 000000000..3f359059b --- /dev/null +++ b/frontend/src/components/login.jsx @@ -0,0 +1,136 @@ +import React, { useState } from "react" +import styled from "styled-components" +import { useNavigate } from "react-router-dom" + +export const LoginForm = ({ login }) => { + const navigate = useNavigate() + + 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 handleSubmit = async (e) => { + e.preventDefault() + + if (!formData.email || !formData.password) { + setErrorMsg("Please fill in both fields") + return + } + + setIsSubmitting(true) + setErrorMsg("") + + try { + await login(formData.email, formData.password) + + navigate("/dashboard") + } catch (err) { + console.error("Login error:", err) + setErrorMsg(err.message) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + +

Log in

+ + {errorMsg && {errorMsg}} + + + + Email + + + + + Password + + + + + + {isSubmitting ? "Logging in…" : "Log In"} + +
+
+
+ ) +} + +export default LoginForm + +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, 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; + border: 2px solid #3f895c; + border-radius: 8px; + padding: 4px; + + &:hover { + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); + cursor: pointer; + } +` + +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 new file mode 100644 index 000000000..c34ce8c29 --- /dev/null +++ b/frontend/src/components/signup.jsx @@ -0,0 +1,143 @@ +import React, { useState } from "react" +import styled from "styled-components" + +export const SignUpForm = ({ onSuccess }) => { + 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 handleSubmit = async (e) => { + e.preventDefault() + + if (!formData.name || !formData.email || !formData.password) { + setErrorMsg("All fields are required") + return + } + + setIsSubmitting(true) + setErrorMsg("") + + try { + await onSuccess(formData) + } catch (err) { + console.error("Signup error:", err) + setErrorMsg(err.message) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + +

Sign up

+ + {errorMsg && {errorMsg}} + + + + Name + + + + + Email + + + + + Password + + + + + + {isSubmitting ? "Creating…" : "Sign up"} + +
+
+
+ ) +} + +export default SignUpForm + +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, 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; + border: 2px solid #3f895c; + border-radius: 8px; + padding: 4px; + + &:hover { + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); + cursor: pointer; + } +` + +const ErrorMsg = styled.p` + color: #c00; + margin-bottom: 12px; +` \ 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..4921bdba2 --- /dev/null +++ b/frontend/src/handling/useComments.js @@ -0,0 +1,25 @@ +import { useCallback } from "react" +import { fetchJson } from "../api/api" + +export const useComments = () => { + + // Add + const createComment = useCallback(async (catId, text) => { + 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) => { + return await fetchJson(`/comments/${catId}/comments/${commentId}`, { // ← fixed path + method: "DELETE", + }) + }, []) + + return { createComment, deleteComment } +} + +export default useComments \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f399..dea7578fc 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,17 @@ -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 { 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..756d409a0 --- /dev/null +++ b/frontend/src/pages/adminpanel.jsx @@ -0,0 +1,171 @@ +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" + +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 (!API_URL) { + setError("API URL not configured") + return + } + const token = localStorage.getItem("token") + fetch(`${API_URL}/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, API_URL]) + + 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(`${API_URL}/admin/users`, { + 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") + } + + setSubmitMessage("User created successfully!") + setFormData({ name: "", email: "", role: "user", password: "" }) + } catch (err) { + setError(err.message) + } finally { + setSubmitting(false) + } + } + + return ( + + navigate(-1)}> + ← Back to Dashboard + + +

Admin Dashboard

+

Welcome, {user.name}

+ +
+

Create New User

+ + {submitMessage &&

{submitMessage}

} + +
+ + + + + + + {submitting ? "Creating..." : "Create User"} + +
+
+
+ ) +} + +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', +} + +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 new file mode 100644 index 000000000..3a72ee513 --- /dev/null +++ b/frontend/src/pages/dashboard.jsx @@ -0,0 +1,170 @@ +import { useContext, useState } from "react" +import { Link, useNavigate } 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, + onNewCat, + onEdit, + onDelete, + onCreateComment, + onDeleteComment, +}) => { + const { user, logout } = useContext(AuthContext) + const navigate = useNavigate() + + const handleLogout = () => { + logout() + navigate("/login") + } + + // Filter state + const [locationFilter, setLocationFilter] = useState("") + const [genderFilter, setGenderFilter] = useState("") + + return ( + + +
Cat Archive Tracking System
+ + {user && ( + + Welcome, {user.name}! + {user.role === "admin" && ( + + Admin Panel + + )} + Logout + + )} + + +
+ + + All Cats + + + Location: + + + + + Gender: + + + + + + +
+ ) +} + +export default Dashboard + +const PageWrapper = styled.main` + display: flex; + flex-direction: column; + align-items: center; +` + +const TopPart = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + max-width: 800px; + padding: 20px; + box-sizing: border-box; +` + +const Header = styled.h1` + margin-bottom: 30px; + font-size: clamp(1.2rem, 5vw, 2rem); +` + +const UserBar = styled.div` + margin-bottom: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +` + +const Greeting = styled.span` + margin-right: 12px; +` + +const StyledBtn = styled.button` + background-color: #b0cebd; + border: 2px solid #3f895c; + border-radius: 8px; + padding: 8px; + display: block; + + &:hover { + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); padding: 8px; + cursor: pointer; + } +` + +const AdminLink = styled(Link)` + margin-top: 20px; + display: inline-block; +` + +const FilterPart = styled.section` + width: 100%; + max-width: 800px; + padding: 20px; + box-sizing: border-box; +` + +const SectionTitle = styled.h2` + margin-bottom: 15px; +` + +const FiltersWrapper = styled.div` + display: flex; + gap: 20px; + margin-bottom: 20px; + flex-wrap: wrap; +` + +const StyledLabel = styled.label` + display: flex; + flex-direction: column; +` \ 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/frontend/src/styling/GlobalStyles.js b/frontend/src/styling/GlobalStyles.js new file mode 100644 index 000000000..3d46f27b9 --- /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; + padding: 30px; + + background-color: #e4ede2; +} +` \ 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..9c21d5d8d --- /dev/null +++ b/frontend/src/styling/Theme.js @@ -0,0 +1,5 @@ +// export const theme = { +// colors: { +// backgroundColor: "#1F4831" +// } +// } \ No newline at end of file diff --git a/package.json b/package.json index 680d19077..abe202837 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,23 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "dependencies": { + "bcrypt": "^6.0.0", + "bcryptjs": "^3.0.3", + "cloudinary": "^1.41.3", + "cors": "^2.8.6", + "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", + "react": "^19.2.4", + "react-router-dom": "^7.13.1" + }, + "devDependencies": { + "nodemon": "^3.1.14" } -} \ No newline at end of file +}