s3
This commit is contained in:
@@ -19,8 +19,6 @@ import {
|
||||
feedbackAPI,
|
||||
fetchCSRFToken,
|
||||
resetCSRFToken,
|
||||
getMessageImageUrl,
|
||||
getForumImageUrl,
|
||||
} from '../../services/api';
|
||||
import api from '../../services/api';
|
||||
|
||||
@@ -91,22 +89,6 @@ describe('API Service', () => {
|
||||
expect(typeof resetCSRFToken).toBe('function');
|
||||
});
|
||||
|
||||
it('exports helper functions for image URLs', () => {
|
||||
expect(typeof getMessageImageUrl).toBe('function');
|
||||
expect(typeof getForumImageUrl).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper Functions', () => {
|
||||
it('getMessageImageUrl constructs correct URL', () => {
|
||||
const url = getMessageImageUrl('test-image.jpg');
|
||||
expect(url).toContain('/messages/images/test-image.jpg');
|
||||
});
|
||||
|
||||
it('getForumImageUrl constructs correct URL', () => {
|
||||
const url = getForumImageUrl('forum-image.jpg');
|
||||
expect(url).toContain('/uploads/forum/forum-image.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSRF Token Management', () => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import React, {
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { messageAPI, getMessageImageUrl } from "../services/api";
|
||||
import { messageAPI } from "../services/api";
|
||||
import { getSignedUrl } from "../services/uploadService";
|
||||
import { User, Message } from "../types";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useSocket } from "../contexts/SocketContext";
|
||||
@@ -46,6 +47,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [imageUrls, setImageUrls] = useState<Map<string, string>>(new Map());
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
@@ -189,6 +191,29 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
}
|
||||
}, [messages, isRecipientTyping, isAtBottom, hasScrolledToUnread]);
|
||||
|
||||
// Pre-fetch signed URLs for private message images
|
||||
useEffect(() => {
|
||||
const fetchImageUrls = async () => {
|
||||
const messagesWithImages = messages.filter(
|
||||
(m) => m.imageFilename && !imageUrls.has(m.imageFilename)
|
||||
);
|
||||
|
||||
if (messagesWithImages.length === 0) return;
|
||||
|
||||
const newUrls = new Map(imageUrls);
|
||||
await Promise.all(
|
||||
messagesWithImages.map(async (m) => {
|
||||
const url = await getSignedUrl(m.imageFilename!);
|
||||
newUrls.set(m.imageFilename!, url);
|
||||
})
|
||||
);
|
||||
|
||||
setImageUrls(newUrls);
|
||||
};
|
||||
|
||||
fetchImageUrls();
|
||||
}, [messages]);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
// Fetch all messages between current user and recipient
|
||||
@@ -525,27 +550,28 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{message.imageFilename && (
|
||||
<div className="mb-2">
|
||||
<img
|
||||
src={getMessageImageUrl(message.imageFilename)}
|
||||
alt="Shared image"
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
maxHeight: "300px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
getMessageImageUrl(message.imageFilename!),
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{message.imageFilename &&
|
||||
imageUrls.has(message.imageFilename) && (
|
||||
<div className="mb-2">
|
||||
<img
|
||||
src={imageUrls.get(message.imageFilename)}
|
||||
alt="Shared image"
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
maxHeight: "300px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
imageUrls.get(message.imageFilename!),
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{message.content.trim() && (
|
||||
<p className="mb-1" style={{ fontSize: "0.95rem" }}>
|
||||
{message.content}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { ForumComment } from "../types";
|
||||
import CommentForm from "./CommentForm";
|
||||
import { getForumImageUrl } from "../services/api";
|
||||
import { getPublicImageUrl } from "../services/uploadService";
|
||||
|
||||
interface CommentThreadProps {
|
||||
comment: ForumComment;
|
||||
@@ -217,7 +217,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
{comment.imageFilenames.map((image, index) => (
|
||||
<div key={index} className="col-4 col-md-3">
|
||||
<img
|
||||
src={getForumImageUrl(image)}
|
||||
src={getPublicImageUrl(image)}
|
||||
alt={`Comment image`}
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
@@ -227,7 +227,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() =>
|
||||
window.open(getForumImageUrl(image), "_blank")
|
||||
window.open(getPublicImageUrl(image), "_blank")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Item } from '../types';
|
||||
import { getPublicImageUrl } from '../services/uploadService';
|
||||
|
||||
interface ItemCardProps {
|
||||
item: Item;
|
||||
@@ -49,12 +50,13 @@ const ItemCard: React.FC<ItemCardProps> = ({
|
||||
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||
{item.imageFilenames && item.imageFilenames[0] ? (
|
||||
<img
|
||||
src={item.imageFilenames[0]}
|
||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||
className="card-img-top"
|
||||
alt={item.name}
|
||||
style={{
|
||||
height: isCompact ? '150px' : '200px',
|
||||
objectFit: 'cover'
|
||||
style={{
|
||||
height: isCompact ? '150px' : '200px',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Item } from '../types';
|
||||
import { getPublicImageUrl } from '../services/uploadService';
|
||||
|
||||
interface ItemMarkerInfoProps {
|
||||
item: Item;
|
||||
@@ -31,12 +32,13 @@ const ItemMarkerInfo: React.FC<ItemMarkerInfoProps> = ({ item, onViewDetails })
|
||||
<div className="card border-0">
|
||||
{item.imageFilenames && item.imageFilenames[0] ? (
|
||||
<img
|
||||
src={item.imageFilenames[0]}
|
||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||
className="card-img-top"
|
||||
alt={item.name}
|
||||
style={{
|
||||
height: '120px',
|
||||
objectFit: 'cover',
|
||||
style={{
|
||||
height: '120px',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px 8px 0 0'
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { forumAPI, addressAPI } from "../services/api";
|
||||
import { uploadFiles } from "../services/uploadService";
|
||||
import TagInput from "../components/TagInput";
|
||||
import ForumImageUpload from "../components/ForumImageUpload";
|
||||
import { Address } from "../types";
|
||||
@@ -151,36 +152,53 @@ const CreateForumPost: React.FC = () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Create FormData
|
||||
const submitData = new FormData();
|
||||
submitData.append('title', formData.title);
|
||||
submitData.append('content', formData.content);
|
||||
submitData.append('category', formData.category);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Add tags as JSON string
|
||||
// Build the post data
|
||||
const postData: {
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
zipCode?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
imageFilenames?: string[];
|
||||
} = {
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
category: formData.category,
|
||||
};
|
||||
|
||||
// Add tags if present
|
||||
if (formData.tags.length > 0) {
|
||||
submitData.append('tags', JSON.stringify(formData.tags));
|
||||
postData.tags = formData.tags;
|
||||
}
|
||||
|
||||
// Add location data for item requests
|
||||
if (formData.category === 'item_request' && formData.zipCode) {
|
||||
submitData.append('zipCode', formData.zipCode);
|
||||
postData.zipCode = formData.zipCode;
|
||||
// If we have coordinates from a saved address, send them to avoid re-geocoding
|
||||
if (formData.latitude !== undefined && formData.longitude !== undefined) {
|
||||
submitData.append('latitude', formData.latitude.toString());
|
||||
submitData.append('longitude', formData.longitude.toString());
|
||||
postData.latitude = formData.latitude;
|
||||
postData.longitude = formData.longitude;
|
||||
}
|
||||
}
|
||||
|
||||
// Add images
|
||||
imageFiles.forEach((file) => {
|
||||
submitData.append('images', file);
|
||||
});
|
||||
// Add S3 image keys
|
||||
if (imageFilenames.length > 0) {
|
||||
postData.imageFilenames = imageFilenames;
|
||||
}
|
||||
|
||||
const response = await forumAPI.createPost(submitData);
|
||||
const response = await forumAPI.createPost(postData);
|
||||
navigate(`/forum/${response.data.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || "Failed to create post");
|
||||
setError(err.response?.data?.error || err.message || "Failed to create post");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +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 AvailabilitySettings from "../components/AvailabilitySettings";
|
||||
import ImageUpload from "../components/ImageUpload";
|
||||
import ItemInformation from "../components/ItemInformation";
|
||||
@@ -175,9 +176,12 @@ const CreateItem: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// For now, we'll store image URLs as base64 strings
|
||||
// In production, you'd upload to a service like S3
|
||||
const imageUrls = imagePreviews;
|
||||
// Upload images to S3 first
|
||||
let imageFilenames: string[] = [];
|
||||
if (imageFiles.length > 0) {
|
||||
const uploadResults = await uploadFiles("item", imageFiles);
|
||||
imageFilenames = uploadResults.map((result) => result.key);
|
||||
}
|
||||
|
||||
// Construct location from address components
|
||||
const locationParts = [
|
||||
@@ -216,7 +220,7 @@ const CreateItem: React.FC = () => {
|
||||
specifyTimesPerDay: formData.specifyTimesPerDay,
|
||||
weeklyTimes: formData.weeklyTimes,
|
||||
location,
|
||||
images: imageUrls,
|
||||
imageFilenames,
|
||||
});
|
||||
|
||||
// Auto-save address if user has no addresses and entered manual address
|
||||
@@ -260,7 +264,7 @@ const CreateItem: React.FC = () => {
|
||||
|
||||
navigate(`/items/${response.data.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || "Failed to create listing");
|
||||
setError(err.response?.data?.error || err.message || "Failed to create listing");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -3,6 +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 AvailabilitySettings from "../components/AvailabilitySettings";
|
||||
import ImageUpload from "../components/ImageUpload";
|
||||
import ItemInformation from "../components/ItemInformation";
|
||||
@@ -53,6 +54,7 @@ const EditItem: React.FC = () => {
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]); // S3 keys for existing images
|
||||
const [acceptedRentals, setAcceptedRentals] = useState<Rental[]>([]);
|
||||
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
||||
@@ -161,9 +163,11 @@ const EditItem: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Set existing images as previews
|
||||
// Set existing images - store S3 keys and generate preview URLs
|
||||
if (item.imageFilenames && item.imageFilenames.length > 0) {
|
||||
setImagePreviews(item.imageFilenames);
|
||||
setExistingImageKeys(item.imageFilenames);
|
||||
// Generate preview URLs from S3 keys
|
||||
setImagePreviews(item.imageFilenames.map((key: string) => getPublicImageUrl(key)));
|
||||
}
|
||||
|
||||
// Determine which pricing unit to select based on existing data
|
||||
@@ -270,8 +274,15 @@ const EditItem: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use existing image previews (which includes both old and new images)
|
||||
const imageUrls = imagePreviews;
|
||||
// Upload new images to S3 and get their keys
|
||||
let newImageKeys: string[] = [];
|
||||
if (imageFiles.length > 0) {
|
||||
const uploadResults = await uploadFiles("item", imageFiles);
|
||||
newImageKeys = uploadResults.map((result) => result.key);
|
||||
}
|
||||
|
||||
// Combine existing S3 keys with newly uploaded keys
|
||||
const allImageKeys = [...existingImageKeys, ...newImageKeys];
|
||||
|
||||
const updatePayload = {
|
||||
...formData,
|
||||
@@ -297,7 +308,7 @@ const EditItem: React.FC = () => {
|
||||
availableBefore: formData.generalAvailableBefore,
|
||||
specifyTimesPerDay: formData.specifyTimesPerDay,
|
||||
weeklyTimes: formData.weeklyTimes,
|
||||
images: imageUrls,
|
||||
imageFilenames: allImageKeys,
|
||||
};
|
||||
|
||||
await itemAPI.updateItem(id!, updatePayload);
|
||||
@@ -328,7 +339,7 @@ const EditItem: React.FC = () => {
|
||||
navigate(`/items/${id}`);
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to update item");
|
||||
setError(err.response?.data?.message || err.message || "Failed to update item");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -355,6 +366,16 @@ const EditItem: React.FC = () => {
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
// Check if removing an existing image or a new upload
|
||||
if (index < existingImageKeys.length) {
|
||||
// Removing an existing S3 image
|
||||
setExistingImageKeys((prev) => prev.filter((_, i) => i !== index));
|
||||
} else {
|
||||
// Removing a new upload - adjust index for the imageFiles array
|
||||
const newFileIndex = index - existingImageKeys.length;
|
||||
setImageFiles((prev) => prev.filter((_, i) => i !== newFileIndex));
|
||||
}
|
||||
// Always update previews
|
||||
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { forumAPI, getForumImageUrl } from '../services/api';
|
||||
import { forumAPI } from '../services/api';
|
||||
import { uploadFiles, getPublicImageUrl } from '../services/uploadService';
|
||||
import { ForumPost, ForumComment } from '../types';
|
||||
import CategoryBadge from '../components/CategoryBadge';
|
||||
import PostStatusBadge from '../components/PostStatusBadge';
|
||||
@@ -54,17 +55,20 @@ const ForumPostDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('content', content);
|
||||
// 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);
|
||||
}
|
||||
|
||||
images.forEach((file) => {
|
||||
formData.append('images', file);
|
||||
await forumAPI.createComment(id!, {
|
||||
content,
|
||||
imageFilenames: imageFilenames.length > 0 ? imageFilenames : undefined,
|
||||
});
|
||||
|
||||
await forumAPI.createComment(id!, formData);
|
||||
await fetchPost(); // Refresh to get new comment
|
||||
} catch (err: any) {
|
||||
throw new Error(err.response?.data?.error || 'Failed to post comment');
|
||||
throw new Error(err.response?.data?.error || err.message || 'Failed to post comment');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,14 +79,13 @@ const ForumPostDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('content', content);
|
||||
formData.append('parentCommentId', parentCommentId);
|
||||
|
||||
await forumAPI.createComment(id!, formData);
|
||||
await forumAPI.createComment(id!, {
|
||||
content,
|
||||
parentId: parentCommentId,
|
||||
});
|
||||
await fetchPost(); // Refresh to get new reply
|
||||
} catch (err: any) {
|
||||
throw new Error(err.response?.data?.error || 'Failed to post reply');
|
||||
throw new Error(err.response?.data?.error || err.message || 'Failed to post reply');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -348,11 +351,11 @@ const ForumPostDetail: React.FC = () => {
|
||||
{post.imageFilenames.map((image, index) => (
|
||||
<div key={index} className="col-6 col-md-4">
|
||||
<img
|
||||
src={getForumImageUrl(image)}
|
||||
src={getPublicImageUrl(image)}
|
||||
alt={`Post image`}
|
||||
className="img-fluid rounded"
|
||||
style={{ width: '100%', height: '200px', objectFit: 'cover', cursor: 'pointer' }}
|
||||
onClick={() => window.open(getForumImageUrl(image), '_blank')}
|
||||
onClick={() => window.open(getPublicImageUrl(image), '_blank')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,6 +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 GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
||||
import ItemReviews from "../components/ItemReviews";
|
||||
import ConfirmationModal from "../components/ConfirmationModal";
|
||||
@@ -417,13 +418,14 @@ const ItemDetail: React.FC = () => {
|
||||
{item.imageFilenames.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={item.imageFilenames[selectedImage]}
|
||||
src={getPublicImageUrl(item.imageFilenames[selectedImage])}
|
||||
alt={item.name}
|
||||
className="img-fluid rounded mb-3"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxHeight: "500px",
|
||||
objectFit: "cover",
|
||||
objectFit: "contain",
|
||||
backgroundColor: "#f8f9fa",
|
||||
}}
|
||||
/>
|
||||
{item.imageFilenames.length > 1 && (
|
||||
@@ -431,7 +433,7 @@ const ItemDetail: React.FC = () => {
|
||||
{item.imageFilenames.map((image, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={image}
|
||||
src={getPublicImageUrl(image)}
|
||||
alt={`${item.name} ${index + 1}`}
|
||||
className={`rounded cursor-pointer ${
|
||||
selectedImage === index
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from "../contexts/AuthContext";
|
||||
import api from "../services/api";
|
||||
import { Item, Rental } from "../types";
|
||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||
import { getPublicImageUrl } from "../services/uploadService";
|
||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||
import DeclineRentalModal from "../components/DeclineRentalModal";
|
||||
@@ -308,10 +309,10 @@ const Owning: React.FC = () => {
|
||||
<div className="card h-100">
|
||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||
<img
|
||||
src={rental.item.imageFilenames[0]}
|
||||
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
||||
className="card-img-top"
|
||||
alt={rental.item.name}
|
||||
style={{ height: "200px", objectFit: "cover" }}
|
||||
style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }}
|
||||
/>
|
||||
)}
|
||||
<div className="card-body">
|
||||
@@ -529,10 +530,10 @@ const Owning: React.FC = () => {
|
||||
>
|
||||
{item.imageFilenames && item.imageFilenames[0] && (
|
||||
<img
|
||||
src={item.imageFilenames[0]}
|
||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||
className="card-img-top"
|
||||
alt={item.name}
|
||||
style={{ height: "200px", objectFit: "cover" }}
|
||||
style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }}
|
||||
/>
|
||||
)}
|
||||
<div className="card-body">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { userAPI, itemAPI, rentalAPI, addressAPI } from "../services/api";
|
||||
import { User, Item, Rental, Address } from "../types";
|
||||
import { getImageUrl } from "../utils/imageUrl";
|
||||
import { uploadFile, getPublicImageUrl } from "../services/uploadService";
|
||||
import AvailabilitySettings from "../components/AvailabilitySettings";
|
||||
import ReviewItemModal from "../components/ReviewModal";
|
||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||
@@ -161,7 +161,7 @@ const Profile: React.FC = () => {
|
||||
response.data.itemRequestNotificationRadius || 10,
|
||||
});
|
||||
if (response.data.imageFilename) {
|
||||
setImagePreview(getImageUrl(response.data.imageFilename));
|
||||
setImagePreview(getPublicImageUrl(response.data.imageFilename));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to fetch profile");
|
||||
@@ -301,29 +301,26 @@ const Profile: React.FC = () => {
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload image immediately
|
||||
// Upload image to S3
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("imageFilename", file);
|
||||
const { key, publicUrl } = await uploadFile("profile", file);
|
||||
|
||||
const response = await userAPI.uploadProfileImage(formData);
|
||||
|
||||
// Update the imageFilename in formData with the new filename
|
||||
// Update the imageFilename in formData with the S3 key
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
imageFilename: response.data.filename,
|
||||
imageFilename: key,
|
||||
}));
|
||||
|
||||
// Update preview to use the uploaded image URL
|
||||
setImagePreview(getImageUrl(response.data.imageUrl));
|
||||
// Update preview to use the S3 URL
|
||||
setImagePreview(publicUrl);
|
||||
} catch (err: any) {
|
||||
console.error("Image upload error:", err);
|
||||
setError(err.response?.data?.error || "Failed to upload image");
|
||||
setError(err.message || "Failed to upload image");
|
||||
// Reset on error
|
||||
setImageFile(null);
|
||||
setImagePreview(
|
||||
profileData?.imageFilename
|
||||
? getImageUrl(profileData.imageFilename)
|
||||
? getPublicImageUrl(profileData.imageFilename)
|
||||
: null
|
||||
);
|
||||
}
|
||||
@@ -384,7 +381,7 @@ const Profile: React.FC = () => {
|
||||
profileData.itemRequestNotificationRadius || 10,
|
||||
});
|
||||
setImagePreview(
|
||||
profileData.imageFilename ? getImageUrl(profileData.imageFilename) : null
|
||||
profileData.imageFilename ? getPublicImageUrl(profileData.imageFilename) : null
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1224,7 +1221,7 @@ const Profile: React.FC = () => {
|
||||
<div className="card h-100">
|
||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||
<img
|
||||
src={rental.item.imageFilenames[0]}
|
||||
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
||||
className="card-img-top"
|
||||
alt={rental.item.name}
|
||||
style={{
|
||||
@@ -1361,7 +1358,7 @@ const Profile: React.FC = () => {
|
||||
<div className="card h-100">
|
||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||
<img
|
||||
src={rental.item.imageFilenames[0]}
|
||||
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
||||
className="card-img-top"
|
||||
alt={rental.item.name}
|
||||
style={{
|
||||
|
||||
@@ -2,6 +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 { useAuth } from '../contexts/AuthContext';
|
||||
import ChatWindow from '../components/ChatWindow';
|
||||
|
||||
@@ -113,10 +114,10 @@ const PublicProfile: React.FC = () => {
|
||||
>
|
||||
{item.imageFilenames.length > 0 ? (
|
||||
<img
|
||||
src={item.imageFilenames[0]}
|
||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||
className="card-img-top"
|
||||
alt={item.name}
|
||||
style={{ height: '200px', objectFit: 'cover' }}
|
||||
style={{ height: '200px', objectFit: 'contain', backgroundColor: '#f8f9fa' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-light d-flex align-items-center justify-content-center" style={{ height: '200px' }}>
|
||||
|
||||
@@ -3,6 +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 EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
||||
|
||||
const RentItem: React.FC = () => {
|
||||
@@ -343,13 +344,14 @@ const RentItem: React.FC = () => {
|
||||
<div className="card-body">
|
||||
{item.imageFilenames && item.imageFilenames[0] && (
|
||||
<img
|
||||
src={item.imageFilenames[0]}
|
||||
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||
alt={item.name}
|
||||
className="img-fluid rounded mb-3"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "150px",
|
||||
objectFit: "cover",
|
||||
objectFit: "contain",
|
||||
backgroundColor: "#f8f9fa",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,6 +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 { Rental } from "../types";
|
||||
import ReviewItemModal from "../components/ReviewModal";
|
||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||
@@ -232,10 +233,10 @@ const Renting: React.FC = () => {
|
||||
>
|
||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||
<img
|
||||
src={rental.item.imageFilenames[0]}
|
||||
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
||||
className="card-img-top"
|
||||
alt={rental.item.name}
|
||||
style={{ height: "200px", objectFit: "cover" }}
|
||||
style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }}
|
||||
/>
|
||||
)}
|
||||
<div className="card-body">
|
||||
|
||||
@@ -261,12 +261,16 @@ export const messageAPI = {
|
||||
export const forumAPI = {
|
||||
getPosts: (params?: any) => api.get("/forum/posts", { params }),
|
||||
getPost: (id: string) => api.get(`/forum/posts/${id}`),
|
||||
createPost: (formData: FormData) =>
|
||||
api.post("/forum/posts", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
}),
|
||||
createPost: (data: {
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
zipCode?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
imageFilenames?: string[];
|
||||
}) => api.post("/forum/posts", data),
|
||||
updatePost: (id: string, data: any) => api.put(`/forum/posts/${id}`, data),
|
||||
deletePost: (id: string) => api.delete(`/forum/posts/${id}`),
|
||||
updatePostStatus: (id: string, status: string) =>
|
||||
@@ -275,12 +279,14 @@ export const forumAPI = {
|
||||
api.patch(`/forum/posts/${postId}/accept-answer`, { commentId }),
|
||||
getMyPosts: () => api.get("/forum/my-posts"),
|
||||
getTags: (params?: any) => api.get("/forum/tags", { params }),
|
||||
createComment: (postId: string, formData: FormData) =>
|
||||
api.post(`/forum/posts/${postId}/comments`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
}),
|
||||
createComment: (
|
||||
postId: string,
|
||||
data: {
|
||||
content: string;
|
||||
parentId?: string;
|
||||
imageFilenames?: string[];
|
||||
}
|
||||
) => api.post(`/forum/posts/${postId}/comments`, data),
|
||||
updateComment: (commentId: string, data: any) =>
|
||||
api.put(`/forum/comments/${commentId}`, data),
|
||||
deleteComment: (commentId: string) =>
|
||||
@@ -342,12 +348,4 @@ export const feedbackAPI = {
|
||||
api.post("/feedback", data),
|
||||
};
|
||||
|
||||
// Helper to construct message image URLs
|
||||
export const getMessageImageUrl = (imagePath: string) =>
|
||||
`${API_BASE_URL}/messages/images/${imagePath}`;
|
||||
|
||||
// Helper to construct forum image URLs
|
||||
export const getForumImageUrl = (imagePath: string) =>
|
||||
`${process.env.REACT_APP_BASE_URL}/uploads/forum/${imagePath}`;
|
||||
|
||||
export default api;
|
||||
|
||||
195
frontend/src/services/uploadService.ts
Normal file
195
frontend/src/services/uploadService.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import api from "./api";
|
||||
|
||||
/**
|
||||
* Get the public URL for an image (S3 only)
|
||||
*/
|
||||
export const getPublicImageUrl = (
|
||||
imagePath: string | null | undefined
|
||||
): string => {
|
||||
if (!imagePath) return "";
|
||||
|
||||
// Already a full S3 URL
|
||||
if (imagePath.startsWith("https://") && imagePath.includes("s3.")) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// S3 key (e.g., "profiles/uuid.jpg", "items/uuid.jpg", "forum/uuid.jpg")
|
||||
const s3Bucket = process.env.REACT_APP_S3_BUCKET || "";
|
||||
const s3Region = process.env.REACT_APP_AWS_REGION || "us-east-1";
|
||||
return `https://${s3Bucket}.s3.${s3Region}.amazonaws.com/${imagePath}`;
|
||||
};
|
||||
|
||||
export interface PresignedUrlResponse {
|
||||
uploadUrl: string;
|
||||
key: string;
|
||||
publicUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export type UploadType =
|
||||
| "profile"
|
||||
| "item"
|
||||
| "message"
|
||||
| "forum"
|
||||
| "condition-check";
|
||||
|
||||
interface UploadOptions {
|
||||
onProgress?: (percent: number) => void;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for uploading a single file
|
||||
*/
|
||||
export async function getPresignedUrl(
|
||||
uploadType: UploadType,
|
||||
file: File
|
||||
): Promise<PresignedUrlResponse> {
|
||||
const response = await api.post("/upload/presign", {
|
||||
uploadType,
|
||||
contentType: file.type,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get presigned URLs for uploading multiple files
|
||||
*/
|
||||
export async function getPresignedUrls(
|
||||
uploadType: UploadType,
|
||||
files: File[]
|
||||
): Promise<PresignedUrlResponse[]> {
|
||||
const response = await api.post("/upload/presign-batch", {
|
||||
uploadType,
|
||||
files: files.map((f) => ({
|
||||
contentType: f.type,
|
||||
fileName: f.name,
|
||||
fileSize: f.size,
|
||||
})),
|
||||
});
|
||||
return response.data.uploads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file directly to S3 using a presigned URL
|
||||
*/
|
||||
export async function uploadToS3(
|
||||
file: File,
|
||||
uploadUrl: string,
|
||||
options: UploadOptions = {}
|
||||
): Promise<void> {
|
||||
const { onProgress, maxRetries = 3 } = options;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("PUT", uploadUrl, true);
|
||||
xhr.setRequestHeader("Content-Type", file.type);
|
||||
|
||||
if (onProgress) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`HTTP ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error("Network error"));
|
||||
xhr.send(file);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries - 1) throw error;
|
||||
// Exponential backoff
|
||||
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm that files have been uploaded to S3
|
||||
*/
|
||||
export async function confirmUploads(
|
||||
keys: string[]
|
||||
): Promise<{ confirmed: string[]; total: number }> {
|
||||
const response = await api.post("/upload/confirm", { keys });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single file to S3 (complete flow)
|
||||
*/
|
||||
export async function uploadFile(
|
||||
uploadType: UploadType,
|
||||
file: File,
|
||||
options: UploadOptions = {}
|
||||
): Promise<{ key: string; publicUrl: string }> {
|
||||
// Get presigned URL
|
||||
const presigned = await getPresignedUrl(uploadType, file);
|
||||
|
||||
// Upload to S3
|
||||
await uploadToS3(file, presigned.uploadUrl, options);
|
||||
|
||||
// Confirm upload
|
||||
const { confirmed } = await confirmUploads([presigned.key]);
|
||||
|
||||
if (confirmed.length === 0) {
|
||||
throw new Error("Upload verification failed");
|
||||
}
|
||||
|
||||
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)
|
||||
*/
|
||||
export async function getSignedUrl(key: string): Promise<string> {
|
||||
const response = await api.get(
|
||||
`/upload/signed-url/${encodeURIComponent(key)}`
|
||||
);
|
||||
return response.data.url;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export const getImageUrl = (imagePath: string): string => {
|
||||
// Get the base URL without /api
|
||||
const apiUrl = process.env.REACT_APP_API_URL || '';
|
||||
const baseUrl = apiUrl.replace('/api', '');
|
||||
|
||||
// If imagePath already includes the full path, use it
|
||||
if (imagePath.startsWith('/uploads/')) {
|
||||
return `${baseUrl}${imagePath}`;
|
||||
}
|
||||
|
||||
// Otherwise, construct the full path
|
||||
return `${baseUrl}/uploads/profiles/${imagePath}`;
|
||||
};
|
||||
Reference in New Issue
Block a user