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
| Risk | Mitigation |
|---|---|
| Large file blocks UI | Web Worker for processing (future) |
| Memory leak on preview | Cleanup blob URLs on unmount |
| Network timeout | Retry logic with exponential backoff |
| CORS errors | Verify CloudFront/S3 CORS config |