117 lines
3.2 KiB
TypeScript
117 lines
3.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { trpc } from '../src/trpc-client';
|
|
|
|
type ContextString = 'review' | 'product_info' | 'notification' | 'store';
|
|
|
|
interface UploadInput {
|
|
blob: Blob;
|
|
mimeType: string;
|
|
}
|
|
|
|
interface UploadBatchInput {
|
|
images: UploadInput[];
|
|
contextString: ContextString;
|
|
}
|
|
|
|
interface UploadResult {
|
|
keys: string[];
|
|
presignedUrls: string[];
|
|
}
|
|
|
|
export function useUploadToObjectStorage() {
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
|
|
|
|
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
|
|
|
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
|
|
setIsUploading(true);
|
|
setError(null);
|
|
setProgress({ completed: 0, total: input.images.length });
|
|
|
|
try {
|
|
const { images, contextString } = input;
|
|
|
|
if (images.length === 0) {
|
|
return { keys: [], presignedUrls: [] };
|
|
}
|
|
|
|
// 1. Get presigned URLs from backend (one call for all images)
|
|
const mimeTypes = images.map(img => img.mimeType);
|
|
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
|
contextString,
|
|
mimeTypes,
|
|
});
|
|
|
|
if (uploadUrls.length !== images.length) {
|
|
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`);
|
|
}
|
|
|
|
// 2. Upload all images in parallel
|
|
const uploadPromises = images.map(async (image, index) => {
|
|
const presignedUrl = uploadUrls[index];
|
|
const { blob, mimeType } = image;
|
|
|
|
const response = await fetch(presignedUrl, {
|
|
method: 'PUT',
|
|
body: blob,
|
|
headers: { 'Content-Type': mimeType },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
|
|
}
|
|
|
|
// Update progress
|
|
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
|
|
|
|
return {
|
|
key: extractKeyFromPresignedUrl(presignedUrl),
|
|
presignedUrl,
|
|
};
|
|
});
|
|
|
|
// Use Promise.all - if any fails, entire batch fails
|
|
const results = await Promise.all(uploadPromises);
|
|
|
|
return {
|
|
keys: results.map(r => r.key),
|
|
presignedUrls: results.map(r => r.presignedUrl),
|
|
};
|
|
} catch (err) {
|
|
const uploadError = err instanceof Error ? err : new Error('Upload failed');
|
|
setError(uploadError);
|
|
throw uploadError;
|
|
} finally {
|
|
setIsUploading(false);
|
|
setProgress(null);
|
|
}
|
|
};
|
|
|
|
const uploadSingle = async (blob: Blob, mimeType: string, contextString: ContextString): Promise<{ key: string; presignedUrl: string }> => {
|
|
const result = await upload({
|
|
images: [{ blob, mimeType }],
|
|
contextString,
|
|
});
|
|
return {
|
|
key: result.keys[0],
|
|
presignedUrl: result.presignedUrls[0],
|
|
};
|
|
};
|
|
|
|
return {
|
|
upload,
|
|
uploadSingle,
|
|
isUploading,
|
|
error,
|
|
progress,
|
|
isPending: generateUploadUrls.isPending
|
|
};
|
|
}
|
|
|
|
function extractKeyFromPresignedUrl(url: string): string {
|
|
const u = new URL(url);
|
|
const rawKey = u.pathname.replace(/^\/+/, '');
|
|
return decodeURIComponent(rawKey);
|
|
}
|