From 807082eebfab6ce14b1c071a83754bee4407d046 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:23:32 -0500 Subject: [PATCH] image optimization. Image resizing client side, index added to db, pagination --- .../20251230000001-add-geospatial-index.js | 20 +++ backend/services/s3OwnershipService.js | 34 +++- frontend/package-lock.json | 16 ++ frontend/package.json | 1 + frontend/src/components/Avatar.tsx | 6 +- frontend/src/components/ChatWindow.tsx | 11 +- frontend/src/components/CommentThread.tsx | 15 +- .../src/components/ConditionCheckModal.tsx | 8 +- .../components/ConditionCheckViewerModal.tsx | 4 +- frontend/src/components/ItemCard.tsx | 13 +- frontend/src/components/ItemMarkerInfo.tsx | 11 +- frontend/src/components/ReturnStatusModal.tsx | 8 +- frontend/src/pages/CreateForumPost.tsx | 8 +- frontend/src/pages/CreateItem.tsx | 8 +- frontend/src/pages/EditItem.tsx | 12 +- frontend/src/pages/ForumPostDetail.tsx | 25 ++- frontend/src/pages/ItemDetail.tsx | 22 ++- frontend/src/pages/ItemList.tsx | 152 +++++++++++++++-- frontend/src/pages/Owning.tsx | 20 ++- frontend/src/pages/Profile.tsx | 38 +++-- frontend/src/pages/PublicProfile.tsx | 11 +- frontend/src/pages/RentItem.tsx | 11 +- frontend/src/pages/Renting.tsx | 11 +- frontend/src/services/uploadService.ts | 154 ++++++++++++++---- frontend/src/utils/imageResizer.ts | 91 +++++++++++ 25 files changed, 587 insertions(+), 123 deletions(-) create mode 100644 backend/migrations/20251230000001-add-geospatial-index.js create mode 100644 frontend/src/utils/imageResizer.ts diff --git a/backend/migrations/20251230000001-add-geospatial-index.js b/backend/migrations/20251230000001-add-geospatial-index.js new file mode 100644 index 0000000..13271f9 --- /dev/null +++ b/backend/migrations/20251230000001-add-geospatial-index.js @@ -0,0 +1,20 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add index on latitude and longitude columns for faster geospatial queries + // This improves performance of the bounding box pre-filter used in radius searches + await queryInterface.addIndex('Items', ['latitude', 'longitude'], { + name: 'idx_items_lat_lng', + where: { + latitude: { [Sequelize.Op.ne]: null }, + longitude: { [Sequelize.Op.ne]: null } + } + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeIndex('Items', 'idx_items_lat_lng'); + } +}; diff --git a/backend/services/s3OwnershipService.js b/backend/services/s3OwnershipService.js index e9e00e8..2bb8474 100644 --- a/backend/services/s3OwnershipService.js +++ b/backend/services/s3OwnershipService.js @@ -6,6 +6,28 @@ const { Op } = require("sequelize"); * Used to authorize signed URL requests for private content */ class S3OwnershipService { + /** + * Image size variant suffixes + */ + static SIZE_SUFFIXES = ["_th", "_md"]; + + /** + * Extract the base key from a variant key (strips _th or _md suffix) + * @param {string} key - S3 key like "messages/uuid_th.jpg" or "messages/uuid.jpg" + * @returns {string} - Base key like "messages/uuid.jpg" + */ + static getBaseKey(key) { + if (!key) return key; + for (const suffix of this.SIZE_SUFFIXES) { + // Match suffix before file extension (e.g., _th.jpg, _md.png) + const regex = new RegExp(`${suffix}(\\.[^.]+)$`); + if (regex.test(key)) { + return key.replace(regex, "$1"); + } + } + return key; + } + /** * Extract file type from S3 key * @param {string} key - S3 key like "messages/uuid.jpg" @@ -50,14 +72,16 @@ class S3OwnershipService { /** * Verify message image access - user must be sender OR receiver - * @param {string} key - S3 key + * @param {string} key - S3 key (may be variant like uuid_th.jpg) * @param {string} userId - User ID making the request * @returns {Promise<{authorized: boolean, reason?: string}>} */ static async verifyMessageAccess(key, userId) { + // Use base key for lookup (DB stores original key, not variants) + const baseKey = this.getBaseKey(key); const message = await Message.findOne({ where: { - imageFilename: key, + imageFilename: baseKey, [Op.or]: [{ senderId: userId }, { receiverId: userId }], }, }); @@ -69,14 +93,16 @@ class S3OwnershipService { /** * Verify condition check image access - user must be rental owner OR renter - * @param {string} key - S3 key + * @param {string} key - S3 key (may be variant like uuid_th.jpg) * @param {string} userId - User ID making the request * @returns {Promise<{authorized: boolean, reason?: string}>} */ static async verifyConditionCheckAccess(key, userId) { + // Use base key for lookup (DB stores original key, not variants) + const baseKey = this.getBaseKey(key); const check = await ConditionCheck.findOne({ where: { - imageFilenames: { [Op.contains]: [key] }, + imageFilenames: { [Op.contains]: [baseKey] }, }, include: [ { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a9e800..c2b4349 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@types/react-router-dom": "^5.3.3", "axios": "^1.10.0", "bootstrap": "^5.3.7", + "browser-image-compression": "^2.0.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1", @@ -5572,6 +5573,15 @@ "node": ">=8" } }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "license": "MIT", + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -17385,6 +17395,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==", + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 89bfaf4..28e5350 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@types/react-router-dom": "^5.3.3", "axios": "^1.10.0", "bootstrap": "^5.3.7", + "browser-image-compression": "^2.0.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1", diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx index 0fdbb95..daeab95 100644 --- a/frontend/src/components/Avatar.tsx +++ b/frontend/src/components/Avatar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { getPublicImageUrl } from "../services/uploadService"; +import { getImageUrl } from "../services/uploadService"; interface AvatarUser { id?: string; @@ -98,8 +98,8 @@ const Avatar: React.FC = ({ } const { firstName, lastName, imageFilename, id } = user; - // Use direct imageUrl if provided, otherwise construct from imageFilename - const imageUrl = directImageUrl || (imageFilename ? getPublicImageUrl(imageFilename) : null); + // Use direct imageUrl if provided, otherwise construct from imageFilename (use thumbnail for avatars) + const imageUrl = directImageUrl || (imageFilename ? getImageUrl(imageFilename, 'thumbnail') : null); const hasValidImage = imageUrl && !imageError; if (hasValidImage) { diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx index 6158676..b172495 100644 --- a/frontend/src/components/ChatWindow.tsx +++ b/frontend/src/components/ChatWindow.tsx @@ -6,7 +6,7 @@ import React, { useCallback, } from "react"; import { messageAPI } from "../services/api"; -import { getSignedUrl, uploadFile } from "../services/uploadService"; +import { getSignedImageUrl, uploadImageWithVariants } from "../services/uploadService"; import { User, Message } from "../types"; import { useAuth } from "../contexts/AuthContext"; import { useSocket } from "../contexts/SocketContext"; @@ -204,7 +204,8 @@ const ChatWindow: React.FC = ({ const newUrls = new Map(imageUrls); await Promise.all( messagesWithImages.map(async (m) => { - const url = await getSignedUrl(m.imageFilename!); + // Use thumbnail size for chat previews + const url = await getSignedImageUrl(m.imageFilename!, 'thumbnail'); newUrls.set(m.imageFilename!, url); }) ); @@ -374,11 +375,11 @@ const ChatWindow: React.FC = ({ } try { - // Upload image to S3 first if present + // Upload image to S3 first if present (with resizing) let imageFilename: string | undefined; if (imageToSend) { - const { key } = await uploadFile("message", imageToSend); - imageFilename = key; + const { baseKey } = await uploadImageWithVariants("message", imageToSend); + imageFilename = baseKey; } const response = await messageAPI.sendMessage({ diff --git a/frontend/src/components/CommentThread.tsx b/frontend/src/components/CommentThread.tsx index babc55c..4f759c3 100644 --- a/frontend/src/components/CommentThread.tsx +++ b/frontend/src/components/CommentThread.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { ForumComment } from "../types"; import CommentForm from "./CommentForm"; import ForumImageUpload from "./ForumImageUpload"; -import { getPublicImageUrl } from "../services/uploadService"; +import { getImageUrl } from "../services/uploadService"; import { IMAGE_LIMITS } from "../config/imageLimits"; interface CommentThreadProps { @@ -77,7 +77,7 @@ const CommentThread: React.FC = ({ setEditContent(comment.content); const existingKeys = comment.imageFilenames || []; setExistingImageKeys(existingKeys); - setEditImagePreviews(existingKeys.map((key) => getPublicImageUrl(key))); + setEditImagePreviews(existingKeys.map((key) => getImageUrl(key, 'thumbnail'))); setEditImageFiles([]); }; @@ -280,7 +280,7 @@ const CommentThread: React.FC = ({ {comment.imageFilenames.map((image, index) => (
{`Comment = ({ objectFit: "contain", cursor: "pointer", }} + onError={(e) => { + const target = e.currentTarget; + if (!target.dataset.fallback) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(image, 'original'); + } + }} onClick={() => - window.open(getPublicImageUrl(image), "_blank") + window.open(getImageUrl(image, 'original'), "_blank") } />
diff --git a/frontend/src/components/ConditionCheckModal.tsx b/frontend/src/components/ConditionCheckModal.tsx index 1c33541..f22e6f8 100644 --- a/frontend/src/components/ConditionCheckModal.tsx +++ b/frontend/src/components/ConditionCheckModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { conditionCheckAPI } from "../services/api"; -import { uploadFiles } from "../services/uploadService"; +import { uploadImagesWithVariants } from "../services/uploadService"; import { IMAGE_LIMITS } from "../config/imageLimits"; interface ConditionCheckModalProps { @@ -84,9 +84,9 @@ const ConditionCheckModal: React.FC = ({ setSubmitting(true); setError(null); - // Upload photos to S3 first - const uploadResults = await uploadFiles("condition-check", photos); - const imageFilenames = uploadResults.map((result) => result.key); + // Upload photos to S3 first (with resizing) + const uploadResults = await uploadImagesWithVariants("condition-check", photos); + const imageFilenames = uploadResults.map((result) => result.baseKey); // Submit condition check with S3 keys await conditionCheckAPI.submitConditionCheck(rentalId, { diff --git a/frontend/src/components/ConditionCheckViewerModal.tsx b/frontend/src/components/ConditionCheckViewerModal.tsx index 7ea56d8..bfb25da 100644 --- a/frontend/src/components/ConditionCheckViewerModal.tsx +++ b/frontend/src/components/ConditionCheckViewerModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { ConditionCheck } from "../types"; -import { getSignedUrl } from "../services/uploadService"; +import { getSignedImageUrl } from "../services/uploadService"; interface ConditionCheckViewerModalProps { show: boolean; @@ -51,7 +51,7 @@ const ConditionCheckViewerModal: React.FC = ({ try { await Promise.all( validKeys.map(async (key) => { - const url = await getSignedUrl(key); + const url = await getSignedImageUrl(key, 'medium'); newUrls.set(key, url); }) ); diff --git a/frontend/src/components/ItemCard.tsx b/frontend/src/components/ItemCard.tsx index 1e6167b..283ba20 100644 --- a/frontend/src/components/ItemCard.tsx +++ b/frontend/src/components/ItemCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Item } from '../types'; -import { getPublicImageUrl } from '../services/uploadService'; +import { getImageUrl } from '../services/uploadService'; interface ItemCardProps { item: Item; @@ -50,9 +50,18 @@ const ItemCard: React.FC = ({
{item.imageFilenames && item.imageFilenames[0] ? ( {item.name} { + // Fallback to original for images uploaded before resizing was added + const target = e.currentTarget; + if (!target.dataset.fallback) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(item.imageFilenames[0], 'original'); + } + }} style={{ height: isCompact ? '150px' : '200px', objectFit: 'contain', diff --git a/frontend/src/components/ItemMarkerInfo.tsx b/frontend/src/components/ItemMarkerInfo.tsx index 8ca0a66..25d7ad9 100644 --- a/frontend/src/components/ItemMarkerInfo.tsx +++ b/frontend/src/components/ItemMarkerInfo.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Item } from '../types'; -import { getPublicImageUrl } from '../services/uploadService'; +import { getImageUrl } from '../services/uploadService'; interface ItemMarkerInfoProps { item: Item; @@ -26,9 +26,16 @@ const ItemMarkerInfo: React.FC = ({ item, onViewDetails })
{item.imageFilenames && item.imageFilenames[0] ? ( {item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(item.imageFilenames[0], 'original'); + } + }} style={{ height: '120px', objectFit: 'contain', diff --git a/frontend/src/components/ReturnStatusModal.tsx b/frontend/src/components/ReturnStatusModal.tsx index 8edad11..941c582 100644 --- a/frontend/src/components/ReturnStatusModal.tsx +++ b/frontend/src/components/ReturnStatusModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { rentalAPI, conditionCheckAPI } from "../services/api"; -import { uploadFiles } from "../services/uploadService"; +import { uploadImagesWithVariants } from "../services/uploadService"; import { Rental } from "../types"; interface ReturnStatusModalProps { @@ -290,9 +290,9 @@ const ReturnStatusModal: React.FC = ({ // Submit post-rental condition check if photos are provided if (photos.length > 0) { - // Upload photos to S3 first - const uploadResults = await uploadFiles("condition-check", photos); - const imageFilenames = uploadResults.map((result) => result.key); + // Upload photos to S3 first (with resizing) + const uploadResults = await uploadImagesWithVariants("condition-check", photos); + const imageFilenames = uploadResults.map((result) => result.baseKey); await conditionCheckAPI.submitConditionCheck(rental.id, { checkType: "post_rental_owner", diff --git a/frontend/src/pages/CreateForumPost.tsx b/frontend/src/pages/CreateForumPost.tsx index b6b88b9..2a740ce 100644 --- a/frontend/src/pages/CreateForumPost.tsx +++ b/frontend/src/pages/CreateForumPost.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { useNavigate, Link, useParams } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { forumAPI, addressAPI } from "../services/api"; -import { uploadFiles, getPublicImageUrl } from "../services/uploadService"; +import { uploadImagesWithVariants, getImageUrl } from "../services/uploadService"; import TagInput from "../components/TagInput"; import ForumImageUpload from "../components/ForumImageUpload"; import VerificationCodeModal from "../components/VerificationCodeModal"; @@ -73,7 +73,7 @@ const CreateForumPost: React.FC = () => { if (post.imageFilenames && post.imageFilenames.length > 0) { setExistingImageKeys(post.imageFilenames); setImagePreviews( - post.imageFilenames.map((key: string) => getPublicImageUrl(key)) + post.imageFilenames.map((key: string) => getImageUrl(key, 'thumbnail')) ); } } catch (err: any) { @@ -199,8 +199,8 @@ const CreateForumPost: React.FC = () => { // Upload images to S3 first (if any) let imageFilenames: string[] = []; if (imageFiles.length > 0) { - const uploadResults = await uploadFiles("forum", imageFiles); - imageFilenames = uploadResults.map((result) => result.key); + const uploadResults = await uploadImagesWithVariants("forum", imageFiles); + imageFilenames = uploadResults.map((result) => result.baseKey); } // Build the post data diff --git a/frontend/src/pages/CreateItem.tsx b/frontend/src/pages/CreateItem.tsx index f3b7429..4b0193d 100644 --- a/frontend/src/pages/CreateItem.tsx +++ b/frontend/src/pages/CreateItem.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import api, { addressAPI, userAPI, itemAPI } from "../services/api"; -import { uploadFiles } from "../services/uploadService"; +import { uploadImagesWithVariants } from "../services/uploadService"; import AvailabilitySettings from "../components/AvailabilitySettings"; import ImageUpload from "../components/ImageUpload"; import ItemInformation from "../components/ItemInformation"; @@ -217,11 +217,11 @@ const CreateItem: React.FC = () => { } try { - // Upload images to S3 first + // Upload images to S3 first (with resizing to thumbnail, medium, original) let imageFilenames: string[] = []; if (imageFiles.length > 0) { - const uploadResults = await uploadFiles("item", imageFiles); - imageFilenames = uploadResults.map((result) => result.key); + const uploadResults = await uploadImagesWithVariants("item", imageFiles); + imageFilenames = uploadResults.map((result) => result.baseKey); } // Construct location from address components diff --git a/frontend/src/pages/EditItem.tsx b/frontend/src/pages/EditItem.tsx index 0e68842..fee9257 100644 --- a/frontend/src/pages/EditItem.tsx +++ b/frontend/src/pages/EditItem.tsx @@ -3,7 +3,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { Item, Rental, Address } from "../types"; import { useAuth } from "../contexts/AuthContext"; import { itemAPI, rentalAPI, addressAPI, userAPI } from "../services/api"; -import { uploadFiles, getPublicImageUrl } from "../services/uploadService"; +import { uploadImagesWithVariants, getImageUrl } from "../services/uploadService"; import AvailabilitySettings from "../components/AvailabilitySettings"; import ImageUpload from "../components/ImageUpload"; import ItemInformation from "../components/ItemInformation"; @@ -161,8 +161,8 @@ const EditItem: React.FC = () => { // Set existing images - store S3 keys and generate preview URLs if (item.imageFilenames && item.imageFilenames.length > 0) { setExistingImageKeys(item.imageFilenames); - // Generate preview URLs from S3 keys - setImagePreviews(item.imageFilenames.map((key: string) => getPublicImageUrl(key))); + // Generate preview URLs from S3 keys (use thumbnail for previews) + setImagePreviews(item.imageFilenames.map((key: string) => getImageUrl(key, 'thumbnail'))); } // Determine which pricing unit to select based on existing data @@ -315,11 +315,11 @@ const EditItem: React.FC = () => { } try { - // Upload new images to S3 and get their keys + // Upload new images to S3 and get their keys (with resizing) let newImageKeys: string[] = []; if (imageFiles.length > 0) { - const uploadResults = await uploadFiles("item", imageFiles); - newImageKeys = uploadResults.map((result) => result.key); + const uploadResults = await uploadImagesWithVariants("item", imageFiles); + newImageKeys = uploadResults.map((result) => result.baseKey); } // Combine existing S3 keys with newly uploaded keys diff --git a/frontend/src/pages/ForumPostDetail.tsx b/frontend/src/pages/ForumPostDetail.tsx index d82ccbb..4113604 100644 --- a/frontend/src/pages/ForumPostDetail.tsx +++ b/frontend/src/pages/ForumPostDetail.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { forumAPI } from '../services/api'; -import { uploadFiles, getPublicImageUrl } from '../services/uploadService'; +import { uploadImagesWithVariants, getImageUrl } from '../services/uploadService'; import { ForumPost, ForumComment } from '../types'; import CategoryBadge from '../components/CategoryBadge'; import PostStatusBadge from '../components/PostStatusBadge'; @@ -60,8 +60,8 @@ const ForumPostDetail: React.FC = () => { // Upload images to S3 first (if any) let imageFilenames: string[] = []; if (images.length > 0) { - const uploadResults = await uploadFiles("forum", images); - imageFilenames = uploadResults.map((result) => result.key); + const uploadResults = await uploadImagesWithVariants("forum", images); + imageFilenames = uploadResults.map((result) => result.baseKey); } await forumAPI.createComment(id!, { @@ -92,8 +92,8 @@ const ForumPostDetail: React.FC = () => { // Upload images to S3 first (if any) let imageFilenames: string[] = []; if (images.length > 0) { - const uploadResults = await uploadFiles("forum", images); - imageFilenames = uploadResults.map((result) => result.key); + const uploadResults = await uploadImagesWithVariants("forum", images); + imageFilenames = uploadResults.map((result) => result.baseKey); } await forumAPI.createComment(id!, { @@ -130,8 +130,8 @@ const ForumPostDetail: React.FC = () => { // Upload new images to S3 let newImageFilenames: string[] = []; if (newImageFiles.length > 0) { - const uploadResults = await uploadFiles("forum", newImageFiles); - newImageFilenames = uploadResults.map((result) => result.key); + const uploadResults = await uploadImagesWithVariants("forum", newImageFiles); + newImageFilenames = uploadResults.map((result) => result.baseKey); } // Combine existing and new image keys @@ -400,11 +400,18 @@ const ForumPostDetail: React.FC = () => { {post.imageFilenames.map((image, index) => (
{`Post window.open(getPublicImageUrl(image), '_blank')} + onError={(e) => { + const target = e.currentTarget; + if (!target.dataset.fallback) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(image, 'original'); + } + }} + onClick={() => window.open(getImageUrl(image, 'original'), '_blank')} />
))} diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx index 23dc073..83b2242 100644 --- a/frontend/src/pages/ItemDetail.tsx +++ b/frontend/src/pages/ItemDetail.tsx @@ -3,7 +3,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { Item, Rental } from "../types"; import { useAuth } from "../contexts/AuthContext"; import { itemAPI, rentalAPI } from "../services/api"; -import { getPublicImageUrl } from "../services/uploadService"; +import { getImageUrl } from "../services/uploadService"; import GoogleMapWithRadius from "../components/GoogleMapWithRadius"; import ItemReviews from "../components/ItemReviews"; import ConfirmationModal from "../components/ConfirmationModal"; @@ -419,9 +419,17 @@ const ItemDetail: React.FC = () => { {item.imageFilenames.length > 0 ? (
{item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(item.imageFilenames[selectedImage], 'original'); + } + }} style={{ width: "100%", maxHeight: "500px", @@ -434,13 +442,21 @@ const ItemDetail: React.FC = () => { {item.imageFilenames.map((image, index) => ( {`${item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(image, 'original'); + } + }} style={{ width: "80px", height: "80px", diff --git a/frontend/src/pages/ItemList.tsx b/frontend/src/pages/ItemList.tsx index c850031..6dd1b47 100644 --- a/frontend/src/pages/ItemList.tsx +++ b/frontend/src/pages/ItemList.tsx @@ -7,8 +7,10 @@ import SearchResultsMap from "../components/SearchResultsMap"; import FilterPanel from "../components/FilterPanel"; import { useAuth } from "../contexts/AuthContext"; +const ITEMS_PER_PAGE = 20; + const ItemList: React.FC = () => { - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const { user } = useAuth(); const [items, setItems] = useState([]); @@ -19,6 +21,9 @@ const ItemList: React.FC = () => { const [locationName, setLocationName] = useState(searchParams.get("locationName") || ""); const locationCheckDone = useRef(false); const filterButtonRef = useRef(null); + const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get("page") || "1")); + const [totalPages, setTotalPages] = useState(1); + const [totalItems, setTotalItems] = useState(0); const [filters, setFilters] = useState({ search: searchParams.get("search") || "", city: searchParams.get("city") || "", @@ -58,7 +63,12 @@ const ItemList: React.FC = () => { useEffect(() => { fetchItems(); - }, [filters]); + }, [filters, currentPage]); + + // Reset to page 1 when filters change + useEffect(() => { + setCurrentPage(1); + }, [filters.search, filters.city, filters.zipCode, filters.lat, filters.lng, filters.radius]); // Update filters when URL params change (e.g., from navbar search) useEffect(() => { @@ -106,13 +116,15 @@ const ItemList: React.FC = () => { const fetchItems = async () => { try { setLoading(true); - const params = { + const params: Record = { ...filters, + page: currentPage, + limit: ITEMS_PER_PAGE, }; // Remove empty filters Object.keys(params).forEach((key) => { - if (!params[key as keyof typeof params]) { - delete params[key as keyof typeof params]; + if (!params[key]) { + delete params[key]; } }); @@ -122,6 +134,8 @@ const ItemList: React.FC = () => { // Filter only available items const availableItems = allItems.filter((item: Item) => item.isAvailable); setItems(availableItems); + setTotalPages(response.data.totalPages || 1); + setTotalItems(response.data.totalItems || availableItems.length); } catch (err: any) { console.error("Error fetching items:", err); console.error("Error response:", err.response); @@ -137,6 +151,20 @@ const ItemList: React.FC = () => { navigate(`/items/${item.id}`); }; + const handlePageChange = (page: number) => { + setCurrentPage(page); + // Update URL with page parameter + const params = new URLSearchParams(searchParams); + if (page === 1) { + params.delete("page"); + } else { + params.set("page", page.toString()); + } + navigate(`/items?${params.toString()}`, { replace: true }); + // Scroll to top + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + const getSearchLocationString = () => { if (filters.lat && filters.lng) { // When using coordinates, return them as a string for the map @@ -174,7 +202,10 @@ const ItemList: React.FC = () => {

Browse Items

- {items.length} items found + + {totalItems} items found + {totalPages > 1 && ` (page ${currentPage} of ${totalPages})`} +
@@ -240,13 +271,108 @@ const ItemList: React.FC = () => {

) : viewMode === 'list' ? ( -
- {items.map((item) => ( -
- -
- ))} -
+ <> +
+ {items.map((item) => ( +
+ +
+ ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( + + )} + ) : (
{ {rental.item?.imageFilenames && rental.item.imageFilenames[0] && ( {rental.item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback && rental.item) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(rental.item.imageFilenames[0], 'original'); + } + }} style={{ height: "200px", objectFit: "contain", @@ -617,9 +624,16 @@ const Owning: React.FC = () => { > {item.imageFilenames && item.imageFilenames[0] && ( {item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(item.imageFilenames[0], 'original'); + } + }} style={{ height: "200px", objectFit: "contain", diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index ab3b5cb..31f5a27 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { userAPI, itemAPI, rentalAPI, addressAPI, conditionCheckAPI } from "../services/api"; import { User, Item, Rental, Address, ConditionCheck } from "../types"; -import { uploadFile, getPublicImageUrl } from "../services/uploadService"; +import { uploadImageWithVariants, getImageUrl } from "../services/uploadService"; import AvailabilitySettings from "../components/AvailabilitySettings"; import ReviewItemModal from "../components/ReviewModal"; import ReviewRenterModal from "../components/ReviewRenterModal"; @@ -168,7 +168,7 @@ const Profile: React.FC = () => { response.data.itemRequestNotificationRadius || 10, }); if (response.data.imageFilename) { - setImagePreview(getPublicImageUrl(response.data.imageFilename)); + setImagePreview(getImageUrl(response.data.imageFilename, 'thumbnail')); } } catch (err: any) { setError(err.response?.data?.message || "Failed to fetch profile"); @@ -365,21 +365,21 @@ const Profile: React.FC = () => { }; reader.readAsDataURL(file); - // Upload image to S3 + // Upload image to S3 (with resizing) try { - const { key, publicUrl } = await uploadFile("profile", file); + const { baseKey, publicUrl } = await uploadImageWithVariants("profile", file); // Update the imageFilename in formData with the S3 key setFormData((prev) => ({ ...prev, - imageFilename: key, + imageFilename: baseKey, })); - // Update preview to use the S3 URL - setImagePreview(publicUrl); + // Update preview to use the thumbnail URL + setImagePreview(getImageUrl(baseKey, 'thumbnail')); // Save imageFilename to database immediately - const response = await userAPI.updateProfile({ imageFilename: key }); + const response = await userAPI.updateProfile({ imageFilename: baseKey }); setProfileData(response.data); updateUser(response.data); } catch (err: any) { @@ -389,7 +389,7 @@ const Profile: React.FC = () => { setImageFile(null); setImagePreview( profileData?.imageFilename - ? getPublicImageUrl(profileData.imageFilename) + ? getImageUrl(profileData.imageFilename, 'thumbnail') : null ); } @@ -450,7 +450,7 @@ const Profile: React.FC = () => { profileData.itemRequestNotificationRadius || 10, }); setImagePreview( - profileData.imageFilename ? getPublicImageUrl(profileData.imageFilename) : null + profileData.imageFilename ? getImageUrl(profileData.imageFilename, 'thumbnail') : null ); } }; @@ -1269,9 +1269,16 @@ const Profile: React.FC = () => {
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && ( {rental.item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback && rental.item) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(rental.item.imageFilenames[0], 'original'); + } + }} style={{ height: "150px", objectFit: "cover", @@ -1424,9 +1431,16 @@ const Profile: React.FC = () => {
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && ( {rental.item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback && rental.item) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(rental.item.imageFilenames[0], 'original'); + } + }} style={{ height: "150px", objectFit: "cover", diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index 7c62efd..64aa400 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { User, Item } from '../types'; import { userAPI, itemAPI } from '../services/api'; -import { getPublicImageUrl } from '../services/uploadService'; +import { getImageUrl } from '../services/uploadService'; import { useAuth } from '../contexts/AuthContext'; import ChatWindow from '../components/ChatWindow'; import Avatar from '../components/Avatar'; @@ -101,9 +101,16 @@ const PublicProfile: React.FC = () => { > {item.imageFilenames.length > 0 ? ( {item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(item.imageFilenames[0], 'original'); + } + }} style={{ height: '200px', objectFit: 'contain', backgroundColor: '#f8f9fa' }} /> ) : ( diff --git a/frontend/src/pages/RentItem.tsx b/frontend/src/pages/RentItem.tsx index e6c3c9f..00ca42a 100644 --- a/frontend/src/pages/RentItem.tsx +++ b/frontend/src/pages/RentItem.tsx @@ -3,7 +3,7 @@ import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import { Item } from "../types"; import { useAuth } from "../contexts/AuthContext"; import { itemAPI, rentalAPI } from "../services/api"; -import { getPublicImageUrl } from "../services/uploadService"; +import { getImageUrl } from "../services/uploadService"; import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout"; import VerificationCodeModal from "../components/VerificationCodeModal"; @@ -261,9 +261,16 @@ const RentItem: React.FC = () => {
{item.imageFilenames && item.imageFilenames[0] && ( {item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(item.imageFilenames[0], 'original'); + } + }} style={{ width: "100%", height: "150px", diff --git a/frontend/src/pages/Renting.tsx b/frontend/src/pages/Renting.tsx index 726f4de..7413caa 100644 --- a/frontend/src/pages/Renting.tsx +++ b/frontend/src/pages/Renting.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { Link, useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { rentalAPI, conditionCheckAPI } from "../services/api"; -import { getPublicImageUrl } from "../services/uploadService"; +import { getImageUrl } from "../services/uploadService"; import { Rental, ConditionCheck } from "../types"; import ReviewItemModal from "../components/ReviewModal"; import RentalCancellationModal from "../components/RentalCancellationModal"; @@ -243,9 +243,16 @@ const Renting: React.FC = () => { {rental.item?.imageFilenames && rental.item.imageFilenames[0] && ( {rental.item.name} { + const target = e.currentTarget; + if (!target.dataset.fallback && rental.item) { + target.dataset.fallback = 'true'; + target.src = getImageUrl(rental.item.imageFilenames[0], 'original'); + } + }} style={{ height: "200px", objectFit: "contain", diff --git a/frontend/src/services/uploadService.ts b/frontend/src/services/uploadService.ts index 89bd8e1..1d13458 100644 --- a/frontend/src/services/uploadService.ts +++ b/frontend/src/services/uploadService.ts @@ -1,4 +1,9 @@ import api from "./api"; +import { + resizeImage, + getVariantKey, + getSizeSuffix, +} from "../utils/imageResizer"; /** * Get the public URL for an image (S3 only) @@ -151,39 +156,6 @@ export async function uploadFile( return { key: presigned.key, publicUrl: presigned.publicUrl }; } -/** - * Upload multiple files to S3 (complete flow) - */ -export async function uploadFiles( - uploadType: UploadType, - files: File[], - options: UploadOptions = {} -): Promise<{ key: string; publicUrl: string }[]> { - if (files.length === 0) return []; - - // Get presigned URLs for all files - const presignedUrls = await getPresignedUrls(uploadType, files); - - // Upload all files in parallel - await Promise.all( - files.map((file, i) => - uploadToS3(file, presignedUrls[i].uploadUrl, options) - ) - ); - - // Confirm all uploads - const keys = presignedUrls.map((p) => p.key); - const { confirmed, total } = await confirmUploads(keys); - - if (confirmed.length < total) { - console.warn(`${total - confirmed.length} uploads failed verification`); - } - - return presignedUrls - .filter((p) => confirmed.includes(p.key)) - .map((p) => ({ key: p.key, publicUrl: p.publicUrl })); -} - /** * Get a signed URL for accessing private content (messages, condition-checks) */ @@ -193,3 +165,119 @@ export async function getSignedUrl(key: string): Promise { ); return response.data.url; } + +/** + * Get a signed URL for a specific image size variant (private content) + * Backend will validate ownership using the base key + */ +export async function getSignedImageUrl( + baseKey: string, + size: "thumbnail" | "medium" | "original" = "original" +): Promise { + const suffix = getSizeSuffix(size); + const variantKey = getVariantKey(baseKey, suffix); + return getSignedUrl(variantKey); +} + +/** + * Get URL for a specific image size variant + * Falls back to original if variant doesn't exist (backward compatibility) + */ +export function getImageUrl( + baseKey: string | null | undefined, + size: "thumbnail" | "medium" | "original" = "original" +): string { + if (!baseKey) return ""; + + const suffix = getSizeSuffix(size); + const variantKey = getVariantKey(baseKey, suffix); + + return getPublicImageUrl(variantKey); +} + +export interface UploadWithResizeOptions extends UploadOptions { + skipResize?: boolean; +} + +/** + * Upload a single image with all size variants (thumbnail, medium, original) + * Returns the base key (original, without suffix) for database storage + */ +export async function uploadImageWithVariants( + uploadType: UploadType, + file: File, + options: UploadWithResizeOptions = {} +): Promise<{ baseKey: string; publicUrl: string; variants: string[] }> { + const { onProgress, skipResize } = options; + + // If skipping resize, use regular upload + if (skipResize) { + const result = await uploadFile(uploadType, file, { onProgress }); + return { baseKey: result.key, publicUrl: result.publicUrl, variants: [result.key] }; + } + + // Generate resized variants + const resizedImages = await resizeImage(file); + + if (resizedImages.length === 0) { + throw new Error("Failed to resize image"); + } + + // Get presigned URLs for all variants + const files = resizedImages.map((r) => r.file); + const presignedUrls = await getPresignedUrls(uploadType, files); + + // Upload all variants in parallel with combined progress + const totalBytes = files.reduce((sum, f) => sum + f.size, 0); + let uploadedBytes = 0; + + await Promise.all( + files.map((variantFile, i) => + uploadToS3(variantFile, presignedUrls[i].uploadUrl, { + onProgress: (percent) => { + if (onProgress) { + const fileContribution = (variantFile.size / totalBytes) * percent; + // Approximate combined progress + onProgress(Math.min(99, Math.round(uploadedBytes / totalBytes * 100 + fileContribution))); + } + }, + }).then(() => { + uploadedBytes += files[i].size; + }) + ) + ); + + // Confirm all uploads + const keys = presignedUrls.map((p) => p.key); + await confirmUploads(keys); + + // Find the original variant key (no suffix) for database storage + const originalIdx = resizedImages.findIndex((r) => r.variant.size === "original"); + const baseKey = presignedUrls[originalIdx]?.key || presignedUrls[0].key; + + if (onProgress) onProgress(100); + + return { + baseKey, + publicUrl: getPublicImageUrl(baseKey), + variants: keys, + }; +} + +/** + * Upload multiple images with all size variants + * Returns array of base keys for database storage + */ +export async function uploadImagesWithVariants( + uploadType: UploadType, + files: File[], + options: UploadWithResizeOptions = {} +): Promise<{ baseKey: string; publicUrl: string }[]> { + if (files.length === 0) return []; + + const results = await Promise.all( + files.map((file) => uploadImageWithVariants(uploadType, file, options)) + ); + + return results.map((r) => ({ baseKey: r.baseKey, publicUrl: r.publicUrl })); +} diff --git a/frontend/src/utils/imageResizer.ts b/frontend/src/utils/imageResizer.ts new file mode 100644 index 0000000..dfa5324 --- /dev/null +++ b/frontend/src/utils/imageResizer.ts @@ -0,0 +1,91 @@ +import imageCompression from "browser-image-compression"; + +interface ImageSizeVariant { + size: "original" | "medium" | "thumbnail"; + maxWidth: number; + quality: number; + suffix: string; +} + +const IMAGE_VARIANTS: ImageSizeVariant[] = [ + { size: "thumbnail", maxWidth: 200, quality: 0.8, suffix: "_th" }, + { size: "medium", maxWidth: 800, quality: 0.8, suffix: "_md" }, + { size: "original", maxWidth: 4096, quality: 0.9, suffix: "" }, +]; + +interface ResizedImage { + variant: ImageSizeVariant; + file: File; +} + +/** + * Resize an image to all size variants (thumbnail, medium, original) + * Returns array of resized File objects + */ +export async function resizeImage(file: File): Promise { + const results: ResizedImage[] = []; + + for (const variant of IMAGE_VARIANTS) { + const options = { + maxWidthOrHeight: variant.maxWidth, + useWebWorker: true, + initialQuality: variant.quality, + fileType: "image/jpeg" as const, + }; + + try { + const compressedFile = await imageCompression(file, options); + + // Create new File with variant-specific name + const variantFile = new File( + [compressedFile], + generateVariantFilename(file.name, variant.suffix), + { type: "image/jpeg" } + ); + + results.push({ + variant, + file: variantFile, + }); + } catch (error) { + console.error(`Failed to resize image for ${variant.size}:`, error); + throw error; + } + } + + return results; +} + +/** + * Generate filename with variant suffix + * e.g., "photo.png" + "_th" => "photo_th.jpg" + */ +function generateVariantFilename( + originalName: string, + suffix: string +): string { + const lastDot = originalName.lastIndexOf("."); + const baseName = lastDot === -1 ? originalName : originalName.substring(0, lastDot); + return `${baseName}${suffix}.jpg`; +} + +/** + * Derive S3 key for a specific variant from the base key + * e.g., "items/uuid.jpg" + "_th" => "items/uuid_th.jpg" + */ +export function getVariantKey(baseKey: string, suffix: string): string { + if (!suffix) return baseKey; + const lastDot = baseKey.lastIndexOf("."); + if (lastDot === -1) return `${baseKey}${suffix}`; + return `${baseKey.substring(0, lastDot)}${suffix}${baseKey.substring(lastDot)}`; +} + +/** + * Get the suffix for a given size + */ +export function getSizeSuffix( + size: "thumbnail" | "medium" | "original" +): string { + const variant = IMAGE_VARIANTS.find((v) => v.size === size); + return variant?.suffix || ""; +}