Skip to content

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