AWS S3 Storage System/Phase 5 of 61-2 hours

Frontend Integration

Phase 6: Frontend Integration

Overview

Build React hooks and components for the admin frontend to handle file uploads with progress, preview, and error handling.

Requirements

Functional:

  • •Upload hook with progress tracking
  • •Image preview before upload
  • •Drag & drop support
  • •Multiple file upload for courts
  • •Avatar cropping (optional)

Non-functional:

  • •Progress updates every 100ms
  • •Retry on network failure
  • •Cancel in-progress uploads

Architecture

src/
ā”œā”€ā”€ hooks/
│   └── use-file-upload.ts
ā”œā”€ā”€ components/
│   ā”œā”€ā”€ image-upload/
│   │   ā”œā”€ā”€ image-upload.tsx
│   │   ā”œā”€ā”€ image-upload-dropzone.tsx
│   │   ā”œā”€ā”€ image-upload-preview.tsx
│   │   └── image-upload-progress.tsx
│   └── avatar-upload/
│       └── avatar-upload.tsx
└── lib/
    └── storage-api.ts

Implementation Steps

1. Create Storage API Client

src/lib/storage-api.ts:

import { api } from './api-client';

export interface PresignedUrlResponse {
  uploadUrl: string;
  fileKey: string;
  publicUrl: string;
  expiresAt: string;
}

export interface UploadProgress {
  loaded: number;
  total: number;
  percentage: number;
}

export const storageApi = {
  getAvatarPresignUrl: (data: {
    fileName: string;
    contentType: string;
    contentLength: number;
  }) => api.post<PresignedUrlResponse>('/users/me/avatar/presign', data),

  confirmAvatarUpload: (fileKey: string) =>
    api.post<{ avatarUrl: string }>('/users/me/avatar/confirm', { fileKey }),

  deleteAvatar: () => api.delete('/users/me/avatar'),

  getEntityPresignUrl: (
    entity: string,
    entityId: string,
    data: {
      fileName: string;
      contentType: string;
      contentLength: number;
      slot?: string;
    },
  ) => api.post<PresignedUrlResponse>(`/${entity}/${entityId}/images/presign`, data),

  confirmEntityUpload: (
    entity: string,
    entityId: string,
    data: { fileKey: string; slot?: string },
  ) => api.post(`/${entity}/${entityId}/images/confirm`, data),
};

export async function uploadToS3(
  presignedUrl: string,
  file: File,
  onProgress?: (progress: UploadProgress) => void,
): Promise<void> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable && onProgress) {
        onProgress({
          loaded: e.loaded,
          total: e.total,
          percentage: Math.round((e.loaded / e.total) * 100),
        });
      }
    });

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve();
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => reject(new Error('Network error')));
    xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')));

    xhr.open('PUT', presignedUrl);
    xhr.setRequestHeader('Content-Type', file.type);
    xhr.send(file);
  });
}

2. Create useFileUpload Hook

src/hooks/use-file-upload.ts:

import { useState, useCallback, useRef } from 'react';
import { storageApi, uploadToS3, UploadProgress } from '@/lib/storage-api';

export type UploadStatus = 'idle' | 'presigning' | 'uploading' | 'confirming' | 'success' | 'error';

export interface UseFileUploadOptions {
  entity: string;
  entityId?: string;
  slot?: string;
  maxSize?: number;
  allowedTypes?: string[];
  onSuccess?: (url: string) => void;
  onError?: (error: Error) => void;
}

export function useFileUpload(options: UseFileUploadOptions) {
  const [status, setStatus] = useState<UploadStatus>('idle');
  const [progress, setProgress] = useState<UploadProgress | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [resultUrl, setResultUrl] = useState<string | null>(null);
  const abortRef = useRef<(() => void) | null>(null);

  const upload = useCallback(async (file: File) => {
    setStatus('idle');
    setProgress(null);
    setError(null);
    setResultUrl(null);

    // Validate
    if (options.maxSize && file.size > options.maxSize) {
      const err = new Error(`File too large (max ${options.maxSize / 1024 / 1024}MB)`);
      setError(err);
      setStatus('error');
      options.onError?.(err);
      return;
    }

    if (options.allowedTypes && !options.allowedTypes.includes(file.type)) {
      const err = new Error('File type not allowed');
      setError(err);
      setStatus('error');
      options.onError?.(err);
      return;
    }

    try {
      // Step 1: Get presigned URL
      setStatus('presigning');
      const presignData = options.entity === 'avatar'
        ? await storageApi.getAvatarPresignUrl({
            fileName: file.name,
            contentType: file.type,
            contentLength: file.size,
          })
        : await storageApi.getEntityPresignUrl(options.entity, options.entityId!, {
            fileName: file.name,
            contentType: file.type,
            contentLength: file.size,
            slot: options.slot,
          });

      // Step 2: Upload to S3
      setStatus('uploading');
      await uploadToS3(presignData.uploadUrl, file, setProgress);

      // Step 3: Confirm upload
      setStatus('confirming');
      const result = options.entity === 'avatar'
        ? await storageApi.confirmAvatarUpload(presignData.fileKey)
        : await storageApi.confirmEntityUpload(options.entity, options.entityId!, {
            fileKey: presignData.fileKey,
            slot: options.slot,
          });

      setResultUrl(presignData.publicUrl);
      setStatus('success');
      options.onSuccess?.(presignData.publicUrl);
    } catch (err) {
      const error = err instanceof Error ? err : new Error('Upload failed');
      setError(error);
      setStatus('error');
      options.onError?.(error);
    }
  }, [options]);

  const reset = useCallback(() => {
    setStatus('idle');
    setProgress(null);
    setError(null);
    setResultUrl(null);
  }, []);

  return {
    upload,
    reset,
    status,
    progress,
    error,
    resultUrl,
    isUploading: status === 'presigning' || status === 'uploading' || status === 'confirming',
  };
}

3. Create ImageUpload Component

src/components/image-upload/image-upload.tsx:

'use client';

import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, X, Loader2 } from 'lucide-react';
import { useFileUpload, UseFileUploadOptions } from '@/hooks/use-file-upload';
import { cn } from '@/lib/utils';

interface ImageUploadProps extends UseFileUploadOptions {
  className?: string;
  previewUrl?: string;
  onRemove?: () => void;
}

export function ImageUpload({
  className,
  previewUrl,
  onRemove,
  ...uploadOptions
}: ImageUploadProps) {
  const [preview, setPreview] = useState<string | null>(previewUrl || null);
  const { upload, status, progress, isUploading, resultUrl } = useFileUpload(uploadOptions);

  const onDrop = useCallback(
    (acceptedFiles: File[]) => {
      const file = acceptedFiles[0];
      if (file) {
        setPreview(URL.createObjectURL(file));
        upload(file);
      }
    },
    [upload],
  );

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: { 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] },
    maxFiles: 1,
    disabled: isUploading,
  });

  const displayUrl = resultUrl || preview;

  return (
    <div className={cn('relative', className)}>
      {displayUrl ? (
        <div className="relative group">
          <img
            src={displayUrl}
            alt="Preview"
            className="w-full h-48 object-cover rounded-lg"
          />
          {isUploading && (
            <div className="absolute inset-0 bg-black/50 flex items-center justify-center rounded-lg">
              <div className="text-white text-center">
                <Loader2 className="h-8 w-8 animate-spin mx-auto" />
                <p className="mt-2">{progress?.percentage || 0}%</p>
              </div>
            </div>
          )}
          {!isUploading && onRemove && (
            <button
              onClick={onRemove}
              className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
            >
              <X className="h-4 w-4" />
            </button>
          )}
        </div>
      ) : (
        <div
          {...getRootProps()}
          className={cn(
            'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
            isDragActive ? 'border-primary bg-primary/5' : 'border-muted-foreground/25',
            isUploading && 'pointer-events-none opacity-50',
          )}
        >
          <input {...getInputProps()} />
          <Upload className="h-10 w-10 mx-auto text-muted-foreground" />
          <p className="mt-2 text-sm text-muted-foreground">
            {isDragActive ? 'Drop image here' : 'Drag & drop or click to upload'}
          </p>
        </div>
      )}
    </div>
  );
}

4. Create AvatarUpload Component

src/components/avatar-upload/avatar-upload.tsx:

'use client';

import { useState } from 'react';
import { useFileUpload } from '@/hooks/use-file-upload';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Camera, Loader2 } from 'lucide-react';

interface AvatarUploadProps {
  currentUrl?: string | null;
  fallback?: string;
  onSuccess?: (url: string) => void;
}

export function AvatarUpload({ currentUrl, fallback, onSuccess }: AvatarUploadProps) {
  const [preview, setPreview] = useState<string | null>(null);
  const { upload, status, progress, isUploading, resultUrl } = useFileUpload({
    entity: 'avatar',
    maxSize: 5 * 1024 * 1024,
    allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
    onSuccess,
  });

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      setPreview(URL.createObjectURL(file));
      upload(file);
    }
  };

  const displayUrl = resultUrl || preview || currentUrl;

  return (
    <div className="relative inline-block">
      <Avatar className="h-24 w-24">
        <AvatarImage src={displayUrl || undefined} />
        <AvatarFallback>{fallback}</AvatarFallback>
      </Avatar>
      
      {isUploading ? (
        <div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full">
          <Loader2 className="h-6 w-6 animate-spin text-white" />
        </div>
      ) : (
        <label className="absolute bottom-0 right-0 p-1.5 bg-primary text-primary-foreground rounded-full cursor-pointer hover:bg-primary/90 transition-colors">
          <Camera className="h-4 w-4" />
          <input
            type="file"
            accept="image/jpeg,image/png,image/webp"
            className="sr-only"
            onChange={handleFileChange}
          />
        </label>
      )}
    </div>
  );
}

5. Install Dependencies

cd pickleballdot-admin-fe
pnpm add react-dropzone

6. Usage Example

// In profile settings page
<AvatarUpload
  currentUrl={user.profile?.avatarUrl}
  fallback={user.profile?.displayName?.charAt(0)}
  onSuccess={(url) => {
    toast.success('Avatar updated');
    refetch();
  }}
/>

// In court edit page
<ImageUpload
  entity="courts"
  entityId={courtId}
  slot="primary"
  maxSize={10 * 1024 * 1024}
  onSuccess={(url) => {
    toast.success('Image uploaded');
    refetch();
  }}
  onRemove={() => deleteImage(courtId, imageUrl)}
/>

Related Code Files

  • •Create: src/lib/storage-api.ts
  • •Create: src/hooks/use-file-upload.ts
  • •Create: src/components/image-upload/image-upload.tsx
  • •Create: src/components/avatar-upload/avatar-upload.tsx
  • •Modify: package.json (add react-dropzone)

Success Criteria

  • • useFileUpload hook handles full upload flow
  • • Progress updates shown during upload
  • • Drag & drop working in ImageUpload
  • • AvatarUpload component ready for profile page integration
  • • Error handling shows user-friendly messages
  • • Cancel functionality working

Risk Assessment

RiskMitigation
Large file blocks UIWeb Worker for processing (future)
Memory leak on previewCleanup blob URLs on unmount
Network timeoutRetry logic with exponential backoff
CORS errorsVerify CloudFront/S3 CORS config