Image Upload¶
Client-side image compression with direct upload to Cloudflare R2.
Overview¶
flowchart TB
subgraph Client
A[Select File] --> B{Validate}
B -->|Invalid| X[Show Error]
B -->|Valid| C[Compress]
C --> D{Check Size}
D -->|Too Large| X
D -->|OK| E[Get Upload URL]
end
subgraph API
E --> F[Create StoredFile]
F --> G[Generate Presigned URL]
end
subgraph R2
G --> H[Direct Upload]
end
subgraph Confirm
H --> I[Confirm Upload]
I --> J[Mark Completed]
end Validation Pipeline¶
| Stage | Check | Limit |
|---|---|---|
| Select | MIME type | jpeg, png, webp |
| Select | File size | 50 MB |
| Compress | Output size | 10 MB |
| Backend | File exists | R2 HEAD |
| Backend | Actual size | 10 MB |
useFileCompression Hook¶
export function useFileCompression() {
const compressFile = async (
file: File,
signal?: AbortSignal
): Promise<CompressedFile> => {
// Validate input
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
throw new Error("Invalid file type");
}
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
throw new Error("File too large");
}
// Compress with Web Worker
const compressed = await imageCompression(file, {
maxSizeMB: IMAGE_COMPRESSION.MAX_SIZE_MB,
maxWidthOrHeight: IMAGE_COMPRESSION.MAX_WIDTH_OR_HEIGHT,
initialQuality: IMAGE_COMPRESSION.INITIAL_QUALITY,
useWebWorker: true,
signal,
});
// Validate output
if (compressed.size > API_FILE_LIMIT_MB * 1024 * 1024) {
throw new Error("Compressed file still too large");
}
return {
file: compressed,
dataUrl: await readAsDataUrl(compressed),
};
};
return { compressFile };
}
Compression Settings¶
const IMAGE_COMPRESSION = {
MAX_SIZE_MB: 7, // Target size
MAX_WIDTH_OR_HEIGHT: 2048, // Max dimension
INITIAL_QUALITY: 0.9, // JPEG quality
};
useR2Upload Hook¶
export function useR2Upload() {
const [state, setState] = useState({
isUploading: false,
progress: 0,
error: null,
});
const uploadToR2 = async (uploadUrl: string, file: File) => {
setState({ isUploading: true, progress: 0, error: null });
try {
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
setState((s) => ({ ...s, progress: 100 }));
} catch (error) {
setState((s) => ({ ...s, error }));
throw error;
} finally {
setState((s) => ({ ...s, isUploading: false }));
}
};
return { uploadToR2, ...state };
}
Full Upload Flow¶
async function uploadMarkerImage(markerId: string, file: File) {
// 1. Compress
const { compressFile } = useFileCompression();
const compressed = await compressFile(file);
// 2. Get presigned URL
const { uploadUrl, fileId } = await getMarkerImageUploadUrl({
markerId,
fileName: file.name,
contentType: file.type,
fileSize: compressed.file.size,
});
// 3. Upload to R2
const { uploadToR2 } = useR2Upload();
await uploadToR2(uploadUrl, compressed.file);
// 4. Confirm with backend
await confirmMarkerImageUpload({ markerId, fileId });
return fileId;
}
File Status Polling¶
For processing status (if backend processes files):
export function useFileStatusCallback(options: {
onComplete: (file: FileResponse) => void;
onError: (error: Error) => void;
}) {
const checkStatus = async (fileId: string) => {
const poll = async () => {
const status = await getFileStatus(fileId);
switch (status.processingStatus) {
case "Completed":
options.onComplete(status);
break;
case "Failed":
options.onError(new Error("Processing failed"));
break;
default:
setTimeout(poll, 1000);
}
};
poll();
};
return { checkStatus };
}
Error Handling¶
try {
await uploadMarkerImage(markerId, file);
showSuccessToast(t("imageUploaded"));
} catch (error) {
if (error.message === "File too large") {
showErrorToast(t("errors.fileTooLarge"));
} else if (error.message === "Invalid file type") {
showErrorToast(t("errors.invalidFileType"));
} else {
showErrorToast(t("errors.uploadFailed"));
}
}
Related¶
- File Storage — R2 integration
- Hooks — Custom hooks
- Integrations — Cloudflare R2