diff --git a/backend/src/controllers/products.ts b/backend/src/controllers/products.ts index 3c00ae0..7fc32f1 100644 --- a/backend/src/controllers/products.ts +++ b/backend/src/controllers/products.ts @@ -6,7 +6,7 @@ import mongoose from "mongoose"; import { bucket } from "src/config/firebase"; // Import Firebase bucket import { getStorage, ref, getDownloadURL } from "firebase/storage"; import { v4 as uuidv4 } from "uuid"; // For unique filenames -import { initializeApp } from "firebase/app"; +import { getApp, getApps, initializeApp } from "firebase/app"; import { firebaseConfig } from "src/config/firebaseConfig"; import multer from "multer"; @@ -69,30 +69,30 @@ export const addProduct = [ upload, async (req: AuthenticatedRequest, res: Response) => { try { - const { name, price, description } = req.body; + const { name, price, description, year, category, condition} = req.body; if (!req.user) return res.status(404).json({ message: "User not found" }); const userId = req.user._id; const userEmail = req.user.userEmail; - if (!name || !price || !userEmail) { - return res.status(400).json({ message: "Name, price, and userEmail are required." }); + if (!name || !price || !userEmail || !year || !category || !condition) { + return res.status(400).json({ message: "Name, price, userEmail, year, category, condition, are required." }); } const images: string[] = []; if (req.files && Array.isArray(req.files)) { - const app = initializeApp(firebaseConfig); + const app = getApps().length ? getApp() : initializeApp(firebaseConfig); const storage = getStorage(app); - for (const file of req.files as Express.Multer.File[]) { - const fileName = `${uuidv4()}-${file.originalname}`; - const firebaseFile = bucket.file(fileName); - - await firebaseFile.save(file.buffer, { - metadata: { contentType: file.mimetype }, - }); - - const imageUrl = await getDownloadURL(ref(storage, fileName)); - images.push(imageUrl); - } + const urls = await Promise.all( + (req.files as Express.Multer.File[]).map(async (file) => { + const fileName = `${uuidv4()}-${file.originalname}`; + const firebaseFile = bucket.file(fileName); + await firebaseFile.save(file.buffer, { + metadata: { contentType: file.mimetype }, + }); + return await getDownloadURL(ref(storage, fileName)); + }), + ); + images.push(...urls); } const newProduct = new ProductModel({ @@ -101,6 +101,9 @@ export const addProduct = [ description, userEmail, images, + year, + category, + condition, timeCreated: new Date(), timeUpdated: new Date(), }); @@ -171,15 +174,16 @@ export const updateProductById = [ let existing = req.body.existingImages || []; if (!Array.isArray(existing)) existing = [existing]; - const newUrls: string[] = []; - const app = initializeApp(firebaseConfig); + const app = getApps().length ? getApp() : initializeApp(firebaseConfig); const storage = getStorage(app); - for (const file of req.files as Express.Multer.File[]) { - const name = `${uuidv4()}-${file.originalname}`; - const bucketFile = bucket.file(name); - await bucketFile.save(file.buffer, { metadata: { contentType: file.mimetype } }); - newUrls.push(await getDownloadURL(ref(storage, name))); - } + const newUrls = await Promise.all( + (req.files as Express.Multer.File[]).map(async (file) => { + const name = `${uuidv4()}-${file.originalname}`; + const bucketFile = bucket.file(name); + await bucketFile.save(file.buffer, { metadata: { contentType: file.mimetype } }); + return await getDownloadURL(ref(storage, name)); + }), + ); const finalImages = [...existing, ...newUrls]; @@ -189,6 +193,9 @@ export const updateProductById = [ name: req.body.name, price: req.body.price, description: req.body.description, + year: req.body.year, + category: req.body.category, + condition: req.body.condition, images: finalImages, timeUpdated: new Date(), }, diff --git a/backend/src/models/product.ts b/backend/src/models/product.ts index ca7386a..973f962 100644 --- a/backend/src/models/product.ts +++ b/backend/src/models/product.ts @@ -12,6 +12,18 @@ const productSchema = new Schema({ description: { type: String, }, + year: { + type: Number, + required: true, + }, + category: { + type: String, + required: true, + }, + condition: { + type: String, + required: true, + }, timeCreated: { type: Date, required: true, diff --git a/frontend/public/bg-light-white-trident.png b/frontend/public/bg-light-white-trident.png new file mode 100644 index 0000000..2056df2 Binary files /dev/null and b/frontend/public/bg-light-white-trident.png differ diff --git a/frontend/src/components/Product.tsx b/frontend/src/components/Product.tsx index 4a5b4b5..a7b6bc2 100644 --- a/frontend/src/components/Product.tsx +++ b/frontend/src/components/Product.tsx @@ -10,6 +10,10 @@ interface Props { productImages: string[]; productName: string; productPrice: number; + productYear: number; + productCategory: string; + productCondition: string; + productLocation: string; isSaved?: boolean; onSaveToggle?: (productId: string, newSavedStatus: boolean) => void; } diff --git a/frontend/src/pages/AddProduct.tsx b/frontend/src/pages/AddProduct.tsx index bf68a9b..1daa9c3 100644 --- a/frontend/src/pages/AddProduct.tsx +++ b/frontend/src/pages/AddProduct.tsx @@ -6,74 +6,132 @@ import { FirebaseContext } from "src/utils/FirebaseProvider"; export function AddProduct() { const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB + const MAX_IMAGE_DIMENSION = 1600; + const JPEG_QUALITY = 0.82; const productName = useRef(null); const productPrice = useRef(null); const productDescription = useRef(null); + const productYear = useRef(null); + const productCategory = useRef(null); + const productCondition = useRef(null); const productImages = useRef(null); + + const currentYear = new Date().getFullYear(); + const years = Array.from({ length: currentYear - 1950 }, (_, i) => currentYear - i); + + + const categories = [ + 'Electronics', + 'School Supplies', + 'Dorm Essentials', + 'Furniture', + 'Clothes', + 'Miscellaneous']; + + const conditions = ["New", "Used"]; + const { user } = useContext(FirebaseContext); const [error, setError] = useState(false); const [fileError, setFileError] = useState(null); const [newFiles, setNewFiles] = useState([]); const [newPreviews, setNewPreviews] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); + const [isOptimizingImages, setIsOptimizingImages] = useState(false); const navigate = useNavigate(); - const handleImageChange = (e: React.ChangeEvent) => { + const compressImage = async (file: File) => { + if (!file.type.startsWith("image/")) return { file, previewUrl: URL.createObjectURL(file) }; + + // If the file is already small, avoid work and keep original. + if (file.size <= 900 * 1024) { + return { file, previewUrl: URL.createObjectURL(file) }; + } + + const bitmap = await createImageBitmap(file); + const scale = Math.min(1, MAX_IMAGE_DIMENSION / Math.max(bitmap.width, bitmap.height)); + const targetW = Math.max(1, Math.round(bitmap.width * scale)); + const targetH = Math.max(1, Math.round(bitmap.height * scale)); + + const canvas = document.createElement("canvas"); + canvas.width = targetW; + canvas.height = targetH; + + const ctx = canvas.getContext("2d"); + if (!ctx) return { file, previewUrl: URL.createObjectURL(file) }; + + ctx.drawImage(bitmap, 0, 0, targetW, targetH); + bitmap.close(); + + const blob: Blob | null = await new Promise((resolve) => + canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY), + ); + + if (!blob) return { file, previewUrl: URL.createObjectURL(file) }; + + const optimized = new File([blob], file.name.replace(/\.\w+$/, "") + ".jpg", { + type: "image/jpeg", + lastModified: file.lastModified, + }); + + // If compression didn't help, keep original. + const finalFile = optimized.size < file.size ? optimized : file; + return { file: finalFile, previewUrl: URL.createObjectURL(finalFile) }; + }; + + const handleImageChange = async (e: React.ChangeEvent) => { if (!e.target.files) return; const files = Array.from(e.target.files); - const validFiles: File[] = []; - const previews: string[] = []; - - files.forEach((file) => { - if (file.size <= MAX_FILE_SIZE) { - validFiles.push(file); - previews.push(URL.createObjectURL(file)); - } - }); + const remainingSlots = Math.max(0, 10 - newFiles.length); + const incoming = files.slice(0, remainingSlots); + + const oversized = incoming.filter((f) => f.size > MAX_FILE_SIZE); + const valid = incoming.filter((f) => f.size <= MAX_FILE_SIZE); - if (validFiles.length < files.length) { + if (oversized.length > 0) { setFileError("Files larger than 5 MB were skipped."); } else { setFileError(null); } - setNewFiles((prev) => [...prev, ...validFiles]); - setNewPreviews((prev) => [...prev, ...previews]); + setIsOptimizingImages(true); + try { + const results = await Promise.all(valid.map(compressImage)); + setNewFiles((prev) => [...prev, ...results.map((r) => r.file)]); + setNewPreviews((prev) => [...prev, ...results.map((r) => r.previewUrl)]); + } finally { + setIsOptimizingImages(false); + } if (productImages.current) productImages.current.value = ""; }; const removePreview = (idx: number) => { + setNewPreviews((p) => { + const url = p[idx]; + if (url) URL.revokeObjectURL(url); + return p.filter((_, i) => i !== idx); + }); setNewFiles((f) => f.filter((_, i) => i !== idx)); - setNewPreviews((p) => p.filter((_, i) => i !== idx)); }; const handleSubmit = async (e: FormEvent) => { - if (isSubmitting) return; + if (isSubmitting || isOptimizingImages) return; setIsSubmitting(true); e.preventDefault(); try { - if (productName.current && productPrice.current && productDescription.current && user) { - let images; - if (productImages.current && productImages.current.files) { - images = productImages.current.files[0]; - } - + if (productName.current && productPrice.current && productDescription.current && productYear.current && productCategory.current && productCondition.current && user) { const body = new FormData(); body.append("name", productName.current.value); body.append("price", productPrice.current.value); body.append("description", productDescription.current.value); + body.append("year", productYear.current.value); + body.append("category", productCategory.current.value); + body.append("condition", productCondition.current.value); if (user.email) body.append("userEmail", user.email); - if (productImages.current && productImages.current.files) { - Array.from(productImages.current.files).forEach((file) => { - body.append("images", file); - }); - } - newFiles.forEach((file) => body.append("images", file)); const res = await post("/api/products", body); @@ -92,119 +150,274 @@ export function AddProduct() { Low-Price Center Marketplace -
-

Add Product

-
-
- {/* Name */} -
- - -
+
+
+ +
+
+

+ List an item +

+

+ Add photos and details so buyers can easily understand what you're selling. +

+
- {/* Price */} -
- - -
+ {/* Photos */} +
+
+
+

+ Photos +

+

+ Add up to 10 photos (max 5MB each) +

+
+
- {/* Description */} -
- -