Compare commits
2 Commits
3e31b9d08b
...
1b4e86be29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4e86be29 | ||
|
|
807082eebf |
20
backend/migrations/20251230000001-add-geospatial-index.js
Normal file
20
backend/migrations/20251230000001-add-geospatial-index.js
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ const { authenticateToken } = require("../middleware/auth");
|
|||||||
const { uploadPresignLimiter } = require("../middleware/rateLimiter");
|
const { uploadPresignLimiter } = require("../middleware/rateLimiter");
|
||||||
const s3Service = require("../services/s3Service");
|
const s3Service = require("../services/s3Service");
|
||||||
const S3OwnershipService = require("../services/s3OwnershipService");
|
const S3OwnershipService = require("../services/s3OwnershipService");
|
||||||
|
const { v4: uuidv4 } = require("uuid");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const MAX_BATCH_SIZE = 20;
|
const MAX_BATCH_SIZE = 20;
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ router.post(
|
|||||||
/**
|
/**
|
||||||
* POST /api/upload/presign-batch
|
* POST /api/upload/presign-batch
|
||||||
* Get presigned URLs for uploading multiple files to S3
|
* Get presigned URLs for uploading multiple files to S3
|
||||||
|
* All files in a batch share the same UUID base for coordinated variant uploads
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/presign-batch",
|
"/presign-batch",
|
||||||
@@ -96,13 +98,17 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate one shared UUID for all files in this batch
|
||||||
|
const sharedBaseKey = uuidv4();
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
files.map((f) =>
|
files.map((f) =>
|
||||||
s3Service.getPresignedUploadUrl(
|
s3Service.getPresignedUploadUrl(
|
||||||
uploadType,
|
uploadType,
|
||||||
f.contentType,
|
f.contentType,
|
||||||
f.fileName,
|
f.fileName,
|
||||||
f.fileSize
|
f.fileSize,
|
||||||
|
sharedBaseKey
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -111,9 +117,10 @@ router.post(
|
|||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
uploadType,
|
uploadType,
|
||||||
count: results.length,
|
count: results.length,
|
||||||
|
baseKey: sharedBaseKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ uploads: results });
|
res.json({ uploads: results, baseKey: sharedBaseKey });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message.includes("Invalid")) {
|
if (error.message.includes("Invalid")) {
|
||||||
return res.status(400).json({ error: error.message });
|
return res.status(400).json({ error: error.message });
|
||||||
|
|||||||
@@ -6,6 +6,28 @@ const { Op } = require("sequelize");
|
|||||||
* Used to authorize signed URL requests for private content
|
* Used to authorize signed URL requests for private content
|
||||||
*/
|
*/
|
||||||
class S3OwnershipService {
|
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
|
* Extract file type from S3 key
|
||||||
* @param {string} key - S3 key like "messages/uuid.jpg"
|
* @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
|
* 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
|
* @param {string} userId - User ID making the request
|
||||||
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
||||||
*/
|
*/
|
||||||
static async verifyMessageAccess(key, userId) {
|
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({
|
const message = await Message.findOne({
|
||||||
where: {
|
where: {
|
||||||
imageFilename: key,
|
imageFilename: baseKey,
|
||||||
[Op.or]: [{ senderId: userId }, { receiverId: userId }],
|
[Op.or]: [{ senderId: userId }, { receiverId: userId }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -69,14 +93,16 @@ class S3OwnershipService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify condition check image access - user must be rental owner OR renter
|
* 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
|
* @param {string} userId - User ID making the request
|
||||||
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
||||||
*/
|
*/
|
||||||
static async verifyConditionCheckAccess(key, userId) {
|
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({
|
const check = await ConditionCheck.findOne({
|
||||||
where: {
|
where: {
|
||||||
imageFilenames: { [Op.contains]: [key] },
|
imageFilenames: { [Op.contains]: [baseKey] },
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -112,9 +112,10 @@ class S3Service {
|
|||||||
* @param {string} contentType - MIME type of the file
|
* @param {string} contentType - MIME type of the file
|
||||||
* @param {string} fileName - Original filename (used for extension)
|
* @param {string} fileName - Original filename (used for extension)
|
||||||
* @param {number} fileSize - File size in bytes (required for size enforcement)
|
* @param {number} fileSize - File size in bytes (required for size enforcement)
|
||||||
|
* @param {string} [baseKey] - Optional base key (UUID) for coordinated variant uploads
|
||||||
* @returns {Promise<{uploadUrl: string, key: string, publicUrl: string, expiresAt: Date}>}
|
* @returns {Promise<{uploadUrl: string, key: string, publicUrl: string, expiresAt: Date}>}
|
||||||
*/
|
*/
|
||||||
async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize) {
|
async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize, baseKey = null) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
throw new Error("S3 storage is not enabled");
|
throw new Error("S3 storage is not enabled");
|
||||||
}
|
}
|
||||||
@@ -135,8 +136,21 @@ class S3Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract known variant suffix from fileName if present (e.g., "photo_th.jpg" -> "_th")
|
||||||
const ext = path.extname(fileName) || this.getExtFromMime(contentType);
|
const ext = path.extname(fileName) || this.getExtFromMime(contentType);
|
||||||
const key = `${config.folder}/${uuidv4()}${ext}`;
|
const baseName = path.basename(fileName, ext);
|
||||||
|
|
||||||
|
// Only recognize known variant suffixes
|
||||||
|
let suffix = "";
|
||||||
|
if (baseName.endsWith("_th")) {
|
||||||
|
suffix = "_th";
|
||||||
|
} else if (baseName.endsWith("_md")) {
|
||||||
|
suffix = "_md";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use provided baseKey or generate new UUID
|
||||||
|
const uuid = baseKey || uuidv4();
|
||||||
|
const key = `${config.folder}/${uuid}${suffix}${ext}`;
|
||||||
|
|
||||||
const cacheDirective = config.public ? "public" : "private";
|
const cacheDirective = config.public ? "public" : "private";
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
|
|||||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"bootstrap": "^5.3.7",
|
"bootstrap": "^5.3.7",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
@@ -5572,6 +5573,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/browser-process-hrtime": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
|
||||||
@@ -17385,6 +17395,12 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"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": {
|
"node_modules/v8-to-istanbul": {
|
||||||
"version": "8.1.1",
|
"version": "8.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"bootstrap": "^5.3.7",
|
"bootstrap": "^5.3.7",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { getPublicImageUrl } from "../services/uploadService";
|
import { getImageUrl } from "../services/uploadService";
|
||||||
|
|
||||||
interface AvatarUser {
|
interface AvatarUser {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -98,8 +98,8 @@ const Avatar: React.FC<AvatarProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { firstName, lastName, imageFilename, id } = user;
|
const { firstName, lastName, imageFilename, id } = user;
|
||||||
// Use direct imageUrl if provided, otherwise construct from imageFilename
|
// Use direct imageUrl if provided, otherwise construct from imageFilename (use thumbnail for avatars)
|
||||||
const imageUrl = directImageUrl || (imageFilename ? getPublicImageUrl(imageFilename) : null);
|
const imageUrl = directImageUrl || (imageFilename ? getImageUrl(imageFilename, 'thumbnail') : null);
|
||||||
const hasValidImage = imageUrl && !imageError;
|
const hasValidImage = imageUrl && !imageError;
|
||||||
|
|
||||||
if (hasValidImage) {
|
if (hasValidImage) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { messageAPI } from "../services/api";
|
import { messageAPI } from "../services/api";
|
||||||
import { getSignedUrl, uploadFile } from "../services/uploadService";
|
import { getSignedImageUrl, uploadImageWithVariants } from "../services/uploadService";
|
||||||
import { User, Message } from "../types";
|
import { User, Message } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useSocket } from "../contexts/SocketContext";
|
import { useSocket } from "../contexts/SocketContext";
|
||||||
@@ -204,7 +204,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const newUrls = new Map(imageUrls);
|
const newUrls = new Map(imageUrls);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
messagesWithImages.map(async (m) => {
|
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);
|
newUrls.set(m.imageFilename!, url);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -374,11 +375,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload image to S3 first if present
|
// Upload image to S3 first if present (with resizing)
|
||||||
let imageFilename: string | undefined;
|
let imageFilename: string | undefined;
|
||||||
if (imageToSend) {
|
if (imageToSend) {
|
||||||
const { key } = await uploadFile("message", imageToSend);
|
const { baseKey } = await uploadImageWithVariants("message", imageToSend);
|
||||||
imageFilename = key;
|
imageFilename = baseKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await messageAPI.sendMessage({
|
const response = await messageAPI.sendMessage({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { ForumComment } from "../types";
|
import { ForumComment } from "../types";
|
||||||
import CommentForm from "./CommentForm";
|
import CommentForm from "./CommentForm";
|
||||||
import ForumImageUpload from "./ForumImageUpload";
|
import ForumImageUpload from "./ForumImageUpload";
|
||||||
import { getPublicImageUrl } from "../services/uploadService";
|
import { getImageUrl } from "../services/uploadService";
|
||||||
import { IMAGE_LIMITS } from "../config/imageLimits";
|
import { IMAGE_LIMITS } from "../config/imageLimits";
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
@@ -77,7 +77,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
setEditContent(comment.content);
|
setEditContent(comment.content);
|
||||||
const existingKeys = comment.imageFilenames || [];
|
const existingKeys = comment.imageFilenames || [];
|
||||||
setExistingImageKeys(existingKeys);
|
setExistingImageKeys(existingKeys);
|
||||||
setEditImagePreviews(existingKeys.map((key) => getPublicImageUrl(key)));
|
setEditImagePreviews(existingKeys.map((key) => getImageUrl(key, 'thumbnail')));
|
||||||
setEditImageFiles([]);
|
setEditImageFiles([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
{comment.imageFilenames.map((image, index) => (
|
{comment.imageFilenames.map((image, index) => (
|
||||||
<div key={index} className="col-4 col-md-3">
|
<div key={index} className="col-4 col-md-3">
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(image)}
|
src={getImageUrl(image, 'thumbnail')}
|
||||||
alt={`Comment image`}
|
alt={`Comment image`}
|
||||||
className="img-fluid rounded"
|
className="img-fluid rounded"
|
||||||
style={{
|
style={{
|
||||||
@@ -289,8 +289,15 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(image, 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(getPublicImageUrl(image), "_blank")
|
window.open(getImageUrl(image, 'original'), "_blank")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { conditionCheckAPI } from "../services/api";
|
import { conditionCheckAPI } from "../services/api";
|
||||||
import { uploadFiles } from "../services/uploadService";
|
import { uploadImagesWithVariants } from "../services/uploadService";
|
||||||
import { IMAGE_LIMITS } from "../config/imageLimits";
|
import { IMAGE_LIMITS } from "../config/imageLimits";
|
||||||
|
|
||||||
interface ConditionCheckModalProps {
|
interface ConditionCheckModalProps {
|
||||||
@@ -84,9 +84,9 @@ const ConditionCheckModal: React.FC<ConditionCheckModalProps> = ({
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Upload photos to S3 first
|
// Upload photos to S3 first (with resizing)
|
||||||
const uploadResults = await uploadFiles("condition-check", photos);
|
const uploadResults = await uploadImagesWithVariants("condition-check", photos);
|
||||||
const imageFilenames = uploadResults.map((result) => result.key);
|
const imageFilenames = uploadResults.map((result) => result.baseKey);
|
||||||
|
|
||||||
// Submit condition check with S3 keys
|
// Submit condition check with S3 keys
|
||||||
await conditionCheckAPI.submitConditionCheck(rentalId, {
|
await conditionCheckAPI.submitConditionCheck(rentalId, {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { ConditionCheck } from "../types";
|
import { ConditionCheck } from "../types";
|
||||||
import { getSignedUrl } from "../services/uploadService";
|
import { getSignedImageUrl } from "../services/uploadService";
|
||||||
|
|
||||||
interface ConditionCheckViewerModalProps {
|
interface ConditionCheckViewerModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -51,7 +51,7 @@ const ConditionCheckViewerModal: React.FC<ConditionCheckViewerModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
validKeys.map(async (key) => {
|
validKeys.map(async (key) => {
|
||||||
const url = await getSignedUrl(key);
|
const url = await getSignedImageUrl(key, 'medium');
|
||||||
newUrls.set(key, url);
|
newUrls.set(key, url);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Item } from '../types';
|
import { Item } from '../types';
|
||||||
import { getPublicImageUrl } from '../services/uploadService';
|
import { getImageUrl } from '../services/uploadService';
|
||||||
|
|
||||||
interface ItemCardProps {
|
interface ItemCardProps {
|
||||||
item: Item;
|
item: Item;
|
||||||
@@ -50,9 +50,18 @@ const ItemCard: React.FC<ItemCardProps> = ({
|
|||||||
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||||
{item.imageFilenames && item.imageFilenames[0] ? (
|
{item.imageFilenames && item.imageFilenames[0] ? (
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
src={getImageUrl(item.imageFilenames[0], 'thumbnail')}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
// 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={{
|
style={{
|
||||||
height: isCompact ? '150px' : '200px',
|
height: isCompact ? '150px' : '200px',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Item } from '../types';
|
import { Item } from '../types';
|
||||||
import { getPublicImageUrl } from '../services/uploadService';
|
import { getImageUrl } from '../services/uploadService';
|
||||||
|
|
||||||
interface ItemMarkerInfoProps {
|
interface ItemMarkerInfoProps {
|
||||||
item: Item;
|
item: Item;
|
||||||
@@ -26,9 +26,16 @@ const ItemMarkerInfo: React.FC<ItemMarkerInfoProps> = ({ item, onViewDetails })
|
|||||||
<div className="card border-0">
|
<div className="card border-0">
|
||||||
{item.imageFilenames && item.imageFilenames[0] ? (
|
{item.imageFilenames && item.imageFilenames[0] ? (
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
src={getImageUrl(item.imageFilenames[0], 'thumbnail')}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(item.imageFilenames[0], 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
height: '120px',
|
height: '120px',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||||
import { uploadFiles } from "../services/uploadService";
|
import { uploadImagesWithVariants } from "../services/uploadService";
|
||||||
import { Rental } from "../types";
|
import { Rental } from "../types";
|
||||||
|
|
||||||
interface ReturnStatusModalProps {
|
interface ReturnStatusModalProps {
|
||||||
@@ -290,9 +290,9 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
|
|||||||
|
|
||||||
// Submit post-rental condition check if photos are provided
|
// Submit post-rental condition check if photos are provided
|
||||||
if (photos.length > 0) {
|
if (photos.length > 0) {
|
||||||
// Upload photos to S3 first
|
// Upload photos to S3 first (with resizing)
|
||||||
const uploadResults = await uploadFiles("condition-check", photos);
|
const uploadResults = await uploadImagesWithVariants("condition-check", photos);
|
||||||
const imageFilenames = uploadResults.map((result) => result.key);
|
const imageFilenames = uploadResults.map((result) => result.baseKey);
|
||||||
|
|
||||||
await conditionCheckAPI.submitConditionCheck(rental.id, {
|
await conditionCheckAPI.submitConditionCheck(rental.id, {
|
||||||
checkType: "post_rental_owner",
|
checkType: "post_rental_owner",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
|
|||||||
import { useNavigate, Link, useParams } from "react-router-dom";
|
import { useNavigate, Link, useParams } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { forumAPI, addressAPI } from "../services/api";
|
import { forumAPI, addressAPI } from "../services/api";
|
||||||
import { uploadFiles, getPublicImageUrl } from "../services/uploadService";
|
import { uploadImagesWithVariants, getImageUrl } from "../services/uploadService";
|
||||||
import TagInput from "../components/TagInput";
|
import TagInput from "../components/TagInput";
|
||||||
import ForumImageUpload from "../components/ForumImageUpload";
|
import ForumImageUpload from "../components/ForumImageUpload";
|
||||||
import VerificationCodeModal from "../components/VerificationCodeModal";
|
import VerificationCodeModal from "../components/VerificationCodeModal";
|
||||||
@@ -73,7 +73,7 @@ const CreateForumPost: React.FC = () => {
|
|||||||
if (post.imageFilenames && post.imageFilenames.length > 0) {
|
if (post.imageFilenames && post.imageFilenames.length > 0) {
|
||||||
setExistingImageKeys(post.imageFilenames);
|
setExistingImageKeys(post.imageFilenames);
|
||||||
setImagePreviews(
|
setImagePreviews(
|
||||||
post.imageFilenames.map((key: string) => getPublicImageUrl(key))
|
post.imageFilenames.map((key: string) => getImageUrl(key, 'thumbnail'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -199,8 +199,8 @@ const CreateForumPost: React.FC = () => {
|
|||||||
// Upload images to S3 first (if any)
|
// Upload images to S3 first (if any)
|
||||||
let imageFilenames: string[] = [];
|
let imageFilenames: string[] = [];
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
const uploadResults = await uploadFiles("forum", imageFiles);
|
const uploadResults = await uploadImagesWithVariants("forum", imageFiles);
|
||||||
imageFilenames = uploadResults.map((result) => result.key);
|
imageFilenames = uploadResults.map((result) => result.baseKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the post data
|
// Build the post data
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import api, { addressAPI, userAPI, itemAPI } from "../services/api";
|
import api, { addressAPI, userAPI, itemAPI } from "../services/api";
|
||||||
import { uploadFiles } from "../services/uploadService";
|
import { uploadImagesWithVariants } from "../services/uploadService";
|
||||||
import AvailabilitySettings from "../components/AvailabilitySettings";
|
import AvailabilitySettings from "../components/AvailabilitySettings";
|
||||||
import ImageUpload from "../components/ImageUpload";
|
import ImageUpload from "../components/ImageUpload";
|
||||||
import ItemInformation from "../components/ItemInformation";
|
import ItemInformation from "../components/ItemInformation";
|
||||||
@@ -217,11 +217,11 @@ const CreateItem: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload images to S3 first
|
// Upload images to S3 first (with resizing to thumbnail, medium, original)
|
||||||
let imageFilenames: string[] = [];
|
let imageFilenames: string[] = [];
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
const uploadResults = await uploadFiles("item", imageFiles);
|
const uploadResults = await uploadImagesWithVariants("item", imageFiles);
|
||||||
imageFilenames = uploadResults.map((result) => result.key);
|
imageFilenames = uploadResults.map((result) => result.baseKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct location from address components
|
// Construct location from address components
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
|||||||
import { Item, Rental, Address } from "../types";
|
import { Item, Rental, Address } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { itemAPI, rentalAPI, addressAPI, userAPI } from "../services/api";
|
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 AvailabilitySettings from "../components/AvailabilitySettings";
|
||||||
import ImageUpload from "../components/ImageUpload";
|
import ImageUpload from "../components/ImageUpload";
|
||||||
import ItemInformation from "../components/ItemInformation";
|
import ItemInformation from "../components/ItemInformation";
|
||||||
@@ -161,8 +161,8 @@ const EditItem: React.FC = () => {
|
|||||||
// Set existing images - store S3 keys and generate preview URLs
|
// Set existing images - store S3 keys and generate preview URLs
|
||||||
if (item.imageFilenames && item.imageFilenames.length > 0) {
|
if (item.imageFilenames && item.imageFilenames.length > 0) {
|
||||||
setExistingImageKeys(item.imageFilenames);
|
setExistingImageKeys(item.imageFilenames);
|
||||||
// Generate preview URLs from S3 keys
|
// Generate preview URLs from S3 keys (use thumbnail for previews)
|
||||||
setImagePreviews(item.imageFilenames.map((key: string) => getPublicImageUrl(key)));
|
setImagePreviews(item.imageFilenames.map((key: string) => getImageUrl(key, 'thumbnail')));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which pricing unit to select based on existing data
|
// Determine which pricing unit to select based on existing data
|
||||||
@@ -315,11 +315,11 @@ const EditItem: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload new images to S3 and get their keys
|
// Upload new images to S3 and get their keys (with resizing)
|
||||||
let newImageKeys: string[] = [];
|
let newImageKeys: string[] = [];
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
const uploadResults = await uploadFiles("item", imageFiles);
|
const uploadResults = await uploadImagesWithVariants("item", imageFiles);
|
||||||
newImageKeys = uploadResults.map((result) => result.key);
|
newImageKeys = uploadResults.map((result) => result.baseKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine existing S3 keys with newly uploaded keys
|
// Combine existing S3 keys with newly uploaded keys
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { forumAPI } from '../services/api';
|
import { forumAPI } from '../services/api';
|
||||||
import { uploadFiles, getPublicImageUrl } from '../services/uploadService';
|
import { uploadImagesWithVariants, getImageUrl } from '../services/uploadService';
|
||||||
import { ForumPost, ForumComment } from '../types';
|
import { ForumPost, ForumComment } from '../types';
|
||||||
import CategoryBadge from '../components/CategoryBadge';
|
import CategoryBadge from '../components/CategoryBadge';
|
||||||
import PostStatusBadge from '../components/PostStatusBadge';
|
import PostStatusBadge from '../components/PostStatusBadge';
|
||||||
@@ -60,8 +60,8 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
// Upload images to S3 first (if any)
|
// Upload images to S3 first (if any)
|
||||||
let imageFilenames: string[] = [];
|
let imageFilenames: string[] = [];
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
const uploadResults = await uploadFiles("forum", images);
|
const uploadResults = await uploadImagesWithVariants("forum", images);
|
||||||
imageFilenames = uploadResults.map((result) => result.key);
|
imageFilenames = uploadResults.map((result) => result.baseKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
await forumAPI.createComment(id!, {
|
await forumAPI.createComment(id!, {
|
||||||
@@ -92,8 +92,8 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
// Upload images to S3 first (if any)
|
// Upload images to S3 first (if any)
|
||||||
let imageFilenames: string[] = [];
|
let imageFilenames: string[] = [];
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
const uploadResults = await uploadFiles("forum", images);
|
const uploadResults = await uploadImagesWithVariants("forum", images);
|
||||||
imageFilenames = uploadResults.map((result) => result.key);
|
imageFilenames = uploadResults.map((result) => result.baseKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
await forumAPI.createComment(id!, {
|
await forumAPI.createComment(id!, {
|
||||||
@@ -130,8 +130,8 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
// Upload new images to S3
|
// Upload new images to S3
|
||||||
let newImageFilenames: string[] = [];
|
let newImageFilenames: string[] = [];
|
||||||
if (newImageFiles.length > 0) {
|
if (newImageFiles.length > 0) {
|
||||||
const uploadResults = await uploadFiles("forum", newImageFiles);
|
const uploadResults = await uploadImagesWithVariants("forum", newImageFiles);
|
||||||
newImageFilenames = uploadResults.map((result) => result.key);
|
newImageFilenames = uploadResults.map((result) => result.baseKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine existing and new image keys
|
// Combine existing and new image keys
|
||||||
@@ -400,11 +400,18 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
{post.imageFilenames.map((image, index) => (
|
{post.imageFilenames.map((image, index) => (
|
||||||
<div key={index} className="col-6 col-md-4">
|
<div key={index} className="col-6 col-md-4">
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(image)}
|
src={getImageUrl(image, 'medium')}
|
||||||
alt={`Post image`}
|
alt={`Post image`}
|
||||||
className="img-fluid rounded"
|
className="img-fluid rounded"
|
||||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain', cursor: 'pointer' }}
|
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain', cursor: 'pointer' }}
|
||||||
onClick={() => 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')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
|||||||
import { Item, Rental } from "../types";
|
import { Item, Rental } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { itemAPI, rentalAPI } from "../services/api";
|
import { itemAPI, rentalAPI } from "../services/api";
|
||||||
import { getPublicImageUrl } from "../services/uploadService";
|
import { getImageUrl } from "../services/uploadService";
|
||||||
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
||||||
import ItemReviews from "../components/ItemReviews";
|
import ItemReviews from "../components/ItemReviews";
|
||||||
import ConfirmationModal from "../components/ConfirmationModal";
|
import ConfirmationModal from "../components/ConfirmationModal";
|
||||||
@@ -419,9 +419,17 @@ const ItemDetail: React.FC = () => {
|
|||||||
{item.imageFilenames.length > 0 ? (
|
{item.imageFilenames.length > 0 ? (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(item.imageFilenames[selectedImage])}
|
src={getImageUrl(item.imageFilenames[selectedImage], 'medium')}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="img-fluid rounded mb-3"
|
className="img-fluid rounded mb-3"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(item.imageFilenames[selectedImage], 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxHeight: "500px",
|
maxHeight: "500px",
|
||||||
@@ -434,13 +442,21 @@ const ItemDetail: React.FC = () => {
|
|||||||
{item.imageFilenames.map((image, index) => (
|
{item.imageFilenames.map((image, index) => (
|
||||||
<img
|
<img
|
||||||
key={index}
|
key={index}
|
||||||
src={getPublicImageUrl(image)}
|
src={getImageUrl(image, 'thumbnail')}
|
||||||
alt={`${item.name} ${index + 1}`}
|
alt={`${item.name} ${index + 1}`}
|
||||||
className={`rounded cursor-pointer ${
|
className={`rounded cursor-pointer ${
|
||||||
selectedImage === index
|
selectedImage === index
|
||||||
? "border border-primary"
|
? "border border-primary"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(image, 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: "80px",
|
width: "80px",
|
||||||
height: "80px",
|
height: "80px",
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import SearchResultsMap from "../components/SearchResultsMap";
|
|||||||
import FilterPanel from "../components/FilterPanel";
|
import FilterPanel from "../components/FilterPanel";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
const ItemList: React.FC = () => {
|
const ItemList: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
@@ -19,6 +21,9 @@ const ItemList: React.FC = () => {
|
|||||||
const [locationName, setLocationName] = useState(searchParams.get("locationName") || "");
|
const [locationName, setLocationName] = useState(searchParams.get("locationName") || "");
|
||||||
const locationCheckDone = useRef(false);
|
const locationCheckDone = useRef(false);
|
||||||
const filterButtonRef = useRef<HTMLDivElement>(null);
|
const filterButtonRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get("page") || "1"));
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
search: searchParams.get("search") || "",
|
search: searchParams.get("search") || "",
|
||||||
city: searchParams.get("city") || "",
|
city: searchParams.get("city") || "",
|
||||||
@@ -58,7 +63,12 @@ const ItemList: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchItems();
|
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)
|
// Update filters when URL params change (e.g., from navbar search)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,13 +116,15 @@ const ItemList: React.FC = () => {
|
|||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params = {
|
const params: Record<string, string | number> = {
|
||||||
...filters,
|
...filters,
|
||||||
|
page: currentPage,
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
};
|
};
|
||||||
// Remove empty filters
|
// Remove empty filters
|
||||||
Object.keys(params).forEach((key) => {
|
Object.keys(params).forEach((key) => {
|
||||||
if (!params[key as keyof typeof params]) {
|
if (!params[key]) {
|
||||||
delete params[key as keyof typeof params];
|
delete params[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,6 +134,8 @@ const ItemList: React.FC = () => {
|
|||||||
// Filter only available items
|
// Filter only available items
|
||||||
const availableItems = allItems.filter((item: Item) => item.isAvailable);
|
const availableItems = allItems.filter((item: Item) => item.isAvailable);
|
||||||
setItems(availableItems);
|
setItems(availableItems);
|
||||||
|
setTotalPages(response.data.totalPages || 1);
|
||||||
|
setTotalItems(response.data.totalItems || availableItems.length);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Error fetching items:", err);
|
console.error("Error fetching items:", err);
|
||||||
console.error("Error response:", err.response);
|
console.error("Error response:", err.response);
|
||||||
@@ -137,6 +151,20 @@ const ItemList: React.FC = () => {
|
|||||||
navigate(`/items/${item.id}`);
|
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 = () => {
|
const getSearchLocationString = () => {
|
||||||
if (filters.lat && filters.lng) {
|
if (filters.lat && filters.lng) {
|
||||||
// When using coordinates, return them as a string for the map
|
// When using coordinates, return them as a string for the map
|
||||||
@@ -174,7 +202,10 @@ const ItemList: React.FC = () => {
|
|||||||
<div className="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4 gap-3">
|
<div className="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="mb-1">Browse Items</h1>
|
<h1 className="mb-1">Browse Items</h1>
|
||||||
<span className="text-muted">{items.length} items found</span>
|
<span className="text-muted">
|
||||||
|
{totalItems} items found
|
||||||
|
{totalPages > 1 && ` (page ${currentPage} of ${totalPages})`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex align-items-center gap-2">
|
||||||
@@ -240,6 +271,7 @@ const ItemList: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'list' ? (
|
) : viewMode === 'list' ? (
|
||||||
|
<>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.id} className="col-md-6 col-lg-4 col-xl-3 mb-4">
|
<div key={item.id} className="col-md-6 col-lg-4 col-xl-3 mb-4">
|
||||||
@@ -247,6 +279,100 @@ const ItemList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<nav aria-label="Item list pagination" className="mt-4">
|
||||||
|
<ul className="pagination justify-content-center">
|
||||||
|
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<i className="bi bi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* Page numbers */}
|
||||||
|
{(() => {
|
||||||
|
const pages = [];
|
||||||
|
const maxVisiblePages = 5;
|
||||||
|
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
||||||
|
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||||
|
|
||||||
|
// Adjust start if we're near the end
|
||||||
|
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||||
|
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First page + ellipsis
|
||||||
|
if (startPage > 1) {
|
||||||
|
pages.push(
|
||||||
|
<li key={1} className="page-item">
|
||||||
|
<button className="page-link" onClick={() => handlePageChange(1)}>1</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
if (startPage > 2) {
|
||||||
|
pages.push(
|
||||||
|
<li key="start-ellipsis" className="page-item disabled">
|
||||||
|
<span className="page-link">...</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page numbers
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
pages.push(
|
||||||
|
<li key={i} className={`page-item ${currentPage === i ? 'active' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => handlePageChange(i)}
|
||||||
|
aria-current={currentPage === i ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{i}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last page + ellipsis
|
||||||
|
if (endPage < totalPages) {
|
||||||
|
if (endPage < totalPages - 1) {
|
||||||
|
pages.push(
|
||||||
|
<li key="end-ellipsis" className="page-item disabled">
|
||||||
|
<span className="page-link">...</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pages.push(
|
||||||
|
<li key={totalPages} className="page-item">
|
||||||
|
<button className="page-link" onClick={() => handlePageChange(totalPages)}>
|
||||||
|
{totalPages}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<i className="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<SearchResultsMap
|
<SearchResultsMap
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useAuth } from "../contexts/AuthContext";
|
|||||||
import api from "../services/api";
|
import api from "../services/api";
|
||||||
import { Item, Rental, ConditionCheck } from "../types";
|
import { Item, Rental, ConditionCheck } from "../types";
|
||||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||||
import { getPublicImageUrl } from "../services/uploadService";
|
import { getImageUrl } from "../services/uploadService";
|
||||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||||
import DeclineRentalModal from "../components/DeclineRentalModal";
|
import DeclineRentalModal from "../components/DeclineRentalModal";
|
||||||
@@ -364,9 +364,16 @@ const Owning: React.FC = () => {
|
|||||||
{rental.item?.imageFilenames &&
|
{rental.item?.imageFilenames &&
|
||||||
rental.item.imageFilenames[0] && (
|
rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
src={getImageUrl(rental.item.imageFilenames[0], 'thumbnail')}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback && rental.item) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(rental.item.imageFilenames[0], 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
height: "200px",
|
height: "200px",
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
@@ -617,9 +624,16 @@ const Owning: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{item.imageFilenames && item.imageFilenames[0] && (
|
{item.imageFilenames && item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
src={getImageUrl(item.imageFilenames[0], 'thumbnail')}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(item.imageFilenames[0], 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
height: "200px",
|
height: "200px",
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { userAPI, itemAPI, rentalAPI, addressAPI, conditionCheckAPI } from "../services/api";
|
import { userAPI, itemAPI, rentalAPI, addressAPI, conditionCheckAPI } from "../services/api";
|
||||||
import { User, Item, Rental, Address, ConditionCheck } from "../types";
|
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 AvailabilitySettings from "../components/AvailabilitySettings";
|
||||||
import ReviewItemModal from "../components/ReviewModal";
|
import ReviewItemModal from "../components/ReviewModal";
|
||||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||||
@@ -168,7 +168,7 @@ const Profile: React.FC = () => {
|
|||||||
response.data.itemRequestNotificationRadius || 10,
|
response.data.itemRequestNotificationRadius || 10,
|
||||||
});
|
});
|
||||||
if (response.data.imageFilename) {
|
if (response.data.imageFilename) {
|
||||||
setImagePreview(getPublicImageUrl(response.data.imageFilename));
|
setImagePreview(getImageUrl(response.data.imageFilename, 'thumbnail'));
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || "Failed to fetch profile");
|
setError(err.response?.data?.message || "Failed to fetch profile");
|
||||||
@@ -365,21 +365,21 @@ const Profile: React.FC = () => {
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
// Upload image to S3
|
// Upload image to S3 (with resizing)
|
||||||
try {
|
try {
|
||||||
const { key, publicUrl } = await uploadFile("profile", file);
|
const { baseKey, publicUrl } = await uploadImageWithVariants("profile", file);
|
||||||
|
|
||||||
// Update the imageFilename in formData with the S3 key
|
// Update the imageFilename in formData with the S3 key
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
imageFilename: key,
|
imageFilename: baseKey,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Update preview to use the S3 URL
|
// Update preview to use the thumbnail URL
|
||||||
setImagePreview(publicUrl);
|
setImagePreview(getImageUrl(baseKey, 'thumbnail'));
|
||||||
|
|
||||||
// Save imageFilename to database immediately
|
// Save imageFilename to database immediately
|
||||||
const response = await userAPI.updateProfile({ imageFilename: key });
|
const response = await userAPI.updateProfile({ imageFilename: baseKey });
|
||||||
setProfileData(response.data);
|
setProfileData(response.data);
|
||||||
updateUser(response.data);
|
updateUser(response.data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -389,7 +389,7 @@ const Profile: React.FC = () => {
|
|||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
setImagePreview(
|
setImagePreview(
|
||||||
profileData?.imageFilename
|
profileData?.imageFilename
|
||||||
? getPublicImageUrl(profileData.imageFilename)
|
? getImageUrl(profileData.imageFilename, 'thumbnail')
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -450,7 +450,7 @@ const Profile: React.FC = () => {
|
|||||||
profileData.itemRequestNotificationRadius || 10,
|
profileData.itemRequestNotificationRadius || 10,
|
||||||
});
|
});
|
||||||
setImagePreview(
|
setImagePreview(
|
||||||
profileData.imageFilename ? getPublicImageUrl(profileData.imageFilename) : null
|
profileData.imageFilename ? getImageUrl(profileData.imageFilename, 'thumbnail') : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1269,9 +1269,16 @@ const Profile: React.FC = () => {
|
|||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
src={getImageUrl(rental.item.imageFilenames[0], 'thumbnail')}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback && rental.item) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(rental.item.imageFilenames[0], 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
height: "150px",
|
height: "150px",
|
||||||
objectFit: "cover",
|
objectFit: "cover",
|
||||||
@@ -1424,9 +1431,16 @@ const Profile: React.FC = () => {
|
|||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
src={getImageUrl(rental.item.imageFilenames[0], 'thumbnail')}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback && rental.item) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(rental.item.imageFilenames[0], 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
height: "150px",
|
height: "150px",
|
||||||
objectFit: "cover",
|
objectFit: "cover",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { User, Item } from '../types';
|
import { User, Item } from '../types';
|
||||||
import { userAPI, itemAPI } from '../services/api';
|
import { userAPI, itemAPI } from '../services/api';
|
||||||
import { getPublicImageUrl } from '../services/uploadService';
|
import { getImageUrl } from '../services/uploadService';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ChatWindow from '../components/ChatWindow';
|
import ChatWindow from '../components/ChatWindow';
|
||||||
import Avatar from '../components/Avatar';
|
import Avatar from '../components/Avatar';
|
||||||
@@ -101,9 +101,16 @@ const PublicProfile: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{item.imageFilenames.length > 0 ? (
|
{item.imageFilenames.length > 0 ? (
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
src={getImageUrl(item.imageFilenames[0], 'thumbnail')}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
|
onError={(e) => {
|
||||||
|
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' }}
|
style={{ height: '200px', objectFit: 'contain', backgroundColor: '#f8f9fa' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import { Item } from "../types";
|
import { Item } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { itemAPI, rentalAPI } from "../services/api";
|
import { itemAPI, rentalAPI } from "../services/api";
|
||||||
import { getPublicImageUrl } from "../services/uploadService";
|
import { getImageUrl } from "../services/uploadService";
|
||||||
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
||||||
import VerificationCodeModal from "../components/VerificationCodeModal";
|
import VerificationCodeModal from "../components/VerificationCodeModal";
|
||||||
|
|
||||||
@@ -261,9 +261,16 @@ const RentItem: React.FC = () => {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{item.imageFilenames && item.imageFilenames[0] && (
|
{item.imageFilenames && item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
src={getImageUrl(item.imageFilenames[0], 'medium')}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="img-fluid rounded mb-3"
|
className="img-fluid rounded mb-3"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(item.imageFilenames[0], 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "150px",
|
height: "150px",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||||
import { getPublicImageUrl } from "../services/uploadService";
|
import { getImageUrl } from "../services/uploadService";
|
||||||
import { Rental, ConditionCheck } from "../types";
|
import { Rental, ConditionCheck } from "../types";
|
||||||
import ReviewItemModal from "../components/ReviewModal";
|
import ReviewItemModal from "../components/ReviewModal";
|
||||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||||
@@ -243,9 +243,16 @@ const Renting: React.FC = () => {
|
|||||||
{rental.item?.imageFilenames &&
|
{rental.item?.imageFilenames &&
|
||||||
rental.item.imageFilenames[0] && (
|
rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
src={getImageUrl(rental.item.imageFilenames[0], 'thumbnail')}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (!target.dataset.fallback && rental.item) {
|
||||||
|
target.dataset.fallback = 'true';
|
||||||
|
target.src = getImageUrl(rental.item.imageFilenames[0], 'original');
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
height: "200px",
|
height: "200px",
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import api from "./api";
|
import api from "./api";
|
||||||
|
import {
|
||||||
|
resizeImage,
|
||||||
|
getVariantKey,
|
||||||
|
getSizeSuffix,
|
||||||
|
} from "../utils/imageResizer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the public URL for an image (S3 only)
|
* Get the public URL for an image (S3 only)
|
||||||
@@ -54,13 +59,19 @@ export async function getPresignedUrl(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchPresignResponse {
|
||||||
|
uploads: PresignedUrlResponse[];
|
||||||
|
baseKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get presigned URLs for uploading multiple files
|
* Get presigned URLs for uploading multiple files
|
||||||
|
* All files share the same base UUID for coordinated variant uploads
|
||||||
*/
|
*/
|
||||||
export async function getPresignedUrls(
|
export async function getPresignedUrls(
|
||||||
uploadType: UploadType,
|
uploadType: UploadType,
|
||||||
files: File[]
|
files: File[]
|
||||||
): Promise<PresignedUrlResponse[]> {
|
): Promise<BatchPresignResponse> {
|
||||||
const response = await api.post("/upload/presign-batch", {
|
const response = await api.post("/upload/presign-batch", {
|
||||||
uploadType,
|
uploadType,
|
||||||
files: files.map((f) => ({
|
files: files.map((f) => ({
|
||||||
@@ -69,7 +80,7 @@ export async function getPresignedUrls(
|
|||||||
fileSize: f.size,
|
fileSize: f.size,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
return response.data.uploads;
|
return { uploads: response.data.uploads, baseKey: response.data.baseKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,39 +162,6 @@ export async function uploadFile(
|
|||||||
return { key: presigned.key, publicUrl: presigned.publicUrl };
|
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)
|
* Get a signed URL for accessing private content (messages, condition-checks)
|
||||||
*/
|
*/
|
||||||
@@ -193,3 +171,119 @@ export async function getSignedUrl(key: string): Promise<string> {
|
|||||||
);
|
);
|
||||||
return response.data.url;
|
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<string> {
|
||||||
|
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 (all share same base UUID)
|
||||||
|
const files = resizedImages.map((r) => r.file);
|
||||||
|
const { uploads: 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);
|
||||||
|
|
||||||
|
if (onProgress) onProgress(100);
|
||||||
|
|
||||||
|
// Use the baseKey returned by the backend (shared UUID for all variants)
|
||||||
|
// The stored key format is: items/uuid.jpg (original), and variants are items/uuid_th.jpg, items/uuid_md.jpg
|
||||||
|
const originalKey = keys.find((k) => !k.includes("_th") && !k.includes("_md")) || keys[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseKey: originalKey,
|
||||||
|
publicUrl: getPublicImageUrl(originalKey),
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
|||||||
91
frontend/src/utils/imageResizer.ts
Normal file
91
frontend/src/utils/imageResizer.ts
Normal file
@@ -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<ResizedImage[]> {
|
||||||
|
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 || "";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user