This commit is contained in:
jackiettran
2025-12-11 20:05:18 -05:00
parent 11593606aa
commit b0268a2fb7
28 changed files with 2578 additions and 432 deletions

View File

@@ -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', () => {

View File

@@ -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}

View File

@@ -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>

View File

@@ -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'
}}
/>
) : (

View File

@@ -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'
}}
/>

View File

@@ -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);
}
};

View File

@@ -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);
}

View File

@@ -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));
};

View File

@@ -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>
))}

View File

@@ -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

View File

@@ -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">

View File

@@ -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={{

View File

@@ -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' }}>

View File

@@ -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",
}}
/>
)}

View File

@@ -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">

View File

@@ -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;

View 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;
}

View File

@@ -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}`;
};