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

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