Building a Photo Vault with Multipart Upload

Learn how to create a complete photo vault interface that displays existing user images and enables reliable large file uploads using advanced multipart upload techniques.

This comprehensive tutorial teaches both the concepts and implementation details, providing a complete working example that demonstrates best practices for handling file uploads in production applications.

Understanding Multipart Upload

What is Multipart Upload?

Imagine you need to mail a 500-page book to someone, but the postal service only accepts packages up to 50 pages. You could:

  1. Regular approach: Try to force the entire book into one package (fails)
  2. Multipart approach: Split the book into 10 packages of 50 pages each, mail them separately, then have the recipient reassemble them

Multipart upload works the same way with files. Instead of sending one massive HTTP request with your entire file, you:

  1. Split the file into smaller chunks (typically 5-10MB each)
  2. Upload each chunk independently using signed URLs
  3. Combine the chunks server-side into the original file

Why Use Multipart Upload?

๐Ÿš€ Performance Benefits

  • Chunks upload in parallel, utilizing full bandwidth
  • Faster overall upload times for large files
  • Better resource utilization

๐Ÿ”„ Reliability Improvements

  • If one chunk fails, only that chunk needs retry (not the entire file)
  • Network interruptions donโ€™t restart the entire upload
  • Automatic retry logic for transient failures

๐Ÿ“Š Better User Experience

  • Granular progress tracking (chunk-by-chunk)
  • Users can see exactly how much has uploaded
  • Cancel/resume functionality

๐Ÿ—๏ธ Technical Advantages

  • No single request size limits (overcome HTTP timeouts)
  • Reduced server memory usage (chunks processed individually)
  • Improved error isolation and debugging

When Should You Use Multipart Upload?

โœ… Use multipart upload when:

  • Files are larger than 10MB
  • Users are on unreliable connections (mobile networks)
  • You need detailed progress feedback
  • Building production applications with user-generated content
  • Handling media files (images, videos, audio)

โŒ Regular upload is fine when:

  • Files are small (< 5MB)
  • Network is reliable and fast
  • Simple use cases without progress requirements
  • Prototypes or internal tools

How Multipart Upload Works (The 3-Phase Flow)

Understanding this flow is crucial for implementing multipart upload correctly:

Phase 1: Create Upload Session (Setup)

  • Tell the server you want to upload a file
  • Server creates a media record in database
  • Server starts a multipart upload session
  • You receive a mediaUuid and uploadId for tracking

Phase 2: Upload Parts (The Heavy Lifting)

  • Split your file into chunks (weโ€™ll use 10MB chunks)
  • For each chunk:
    • Request a signed URL for that specific part number
    • Upload the chunk directly to cloud storage using the signed URL
    • Save the ETag returned by storage (proof of successful upload)

Phase 3: Complete Upload Session (Assembly)

  • Tell the server all chunks are uploaded
  • Provide the list of ETags to prove which chunks were uploaded
  • Server combines chunks into the final file
  • File enters processing pipeline (thumbnail generation, etc.)

What Youโ€™ll Build

By the end of this tutorial, youโ€™ll have a complete photo vault featuring:

๐Ÿ“ฑ Photo Grid Interface

  • Responsive grid that adapts to screen size
  • Displays existing vault images using optimized thumbnails
  • Infinite scroll pagination for large image collections
  • Loading states and graceful error handling

โฌ†๏ธ Advanced Upload System

  • Multipart upload with 10MB chunking
  • Real-time progress tracking with visual feedback
  • Automatic retry logic for failed chunks
  • Upload queue management (multiple files simultaneously)
  • File validation and error handling

๐Ÿ›ก๏ธ Production-Ready Features

  • OAuth authentication integration
  • Proper error boundaries and user feedback
  • Mobile-responsive design
  • Security best practices
  • Performance optimizations

Learning Objectives

After completing this tutorial, youโ€™ll understand:

  • How multipart upload works under the hood
  • When and why to use multipart vs regular uploads
  • How to implement chunked file upload with progress tracking
  • OAuth authentication patterns for API access
  • React patterns for handling async file operations
  • Error handling strategies for network operations
  • Performance optimization techniques for file uploads

Before You Start

Complete the Quickstart Guide first to set up OAuth authentication, environment variables, and your basic Fanvue app. This tutorial builds on that foundation and focuses specifically on multipart upload implementation.

Prerequisites

Since youโ€™ve completed the Quickstart Guide, you should have:

  • โœ… A working Next.js app with Fanvue OAuth authentication
  • โœ… Environment variables configured (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, etc.)
  • โœ… Basic read:self scope working for user authentication

Additional Scopes Required

Add these scopes to your existing OAUTH_SCOPES in .env.local:

1# Add these to your existing OAuth scopes from the quickstart
2OAUTH_SCOPES=read:self read:media write:media

Make sure to update your app configuration in the Fanvue Developer area to include the read:media and write:media scopes.

Technical Requirements

  • File API Knowledge: Understanding of JavaScript File objects and Blob handling
  • React Patterns: Experience with hooks, async state management, and component patterns

Ready to dive in? Letโ€™s start by understanding how to fetch and display existing vault images, then build up to the complete multipart upload system.

Step 1: Fetch Existing Vault Images

Before we dive into multipart uploads, letโ€™s start with something simpler: displaying existing images in the userโ€™s vault. This will help us understand the API structure and build the foundation for our upload system.

Understanding the Media API

The Fanvue API organizes user content into media items with different variants (sizes/formats). For our vault, weโ€™ll focus on:

  • Media Type: image (filtering out videos and audio)
  • Variant Type: thumbnail_gallery (optimized for grid display)

Building the API Client

Our API client handles OAuth authentication and provides methods for media operations. Notice how we use Bearer token authentication:

1// lib/fanvue-api.ts
2// Enhanced Fanvue API client with OAuth authentication
3
4const API_BASE = "https://api.fanvue.com";
5
6export interface MediaItem {
7 uuid: string;
8 status: "created" | "processing" | "ready" | "error";
9 createdAt?: string;
10 url?: string;
11 caption?: string | null;
12 description?: string | null;
13 name?: string | null;
14 variants?: Array<{
15 uuid: string;
16 variantType: "blurred" | "main" | "thumbnail" | "thumbnail_gallery";
17 displayPosition: number;
18 url?: string;
19 width: number | null;
20 height: number | null;
21 lengthMs: number | null;
22 }>;
23}
24
25export interface PaginatedResponse<T> {
26 data: T[];
27 pagination: {
28 page: number;
29 size: number;
30 hasMore: boolean;
31 };
32}
33
34export class FanvueAPI {
35 private accessToken: string;
36 private baseURL: string;
37
38 // ๐Ÿ”‘ OAuth access token for authenticated requests
39 constructor(accessToken: string) {
40 this.accessToken = accessToken;
41 this.baseURL = API_BASE;
42 }
43
44 // ๐Ÿ› ๏ธ Private helper method for making authenticated requests
45 private async makeRequest(endpoint: string, options: RequestInit = {}): Promise<Response> {
46 const url = `${this.baseURL}${endpoint}`;
47
48 const response = await fetch(url, {
49 ...options,
50 headers: {
51 // ๐Ÿ” OAuth Bearer token authentication
52 Authorization: `Bearer ${this.accessToken}`,
53 "X-Fanvue-API-Version": "2025-06-26",
54 "Content-Type": "application/json",
55 ...options.headers,
56 },
57 cache: "no-store", // Fresh data for user's vault
58 });
59
60 if (!response.ok) {
61 let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
62 try {
63 const errorBody = await response.json();
64 errorMessage = errorBody.message || errorBody.error || errorMessage;
65 } catch {
66 // If we can't parse the error body, use the original message
67 }
68 throw new Error(errorMessage);
69 }
70
71 return response;
72 }
73
74 // ๐Ÿ“ธ Get user's media with flexible filtering options
75 async getUserMedia(
76 options: {
77 mediaType?: "image" | "video" | "audio";
78 variants?: Array<"blurred" | "main" | "thumbnail" | "thumbnail_gallery">;
79 status?: Array<"created" | "processing" | "ready" | "error">;
80 page?: number;
81 size?: number;
82 } = {}
83 ): Promise<PaginatedResponse<MediaItem>> {
84 // Build query parameters - the API uses URL parameters for filtering
85 const params = new URLSearchParams();
86
87 if (options.mediaType) params.set("mediaType", options.mediaType);
88 if (options.variants?.length) params.set("variants", options.variants.join(","));
89 if (options.status?.length) params.set("status", options.status.join(","));
90 if (options.page) params.set("page", options.page.toString());
91 if (options.size) params.set("size", options.size.toString());
92
93 const response = await this.makeRequest(`/media?${params}`);
94 return response.json();
95 }
96}

Whatโ€™s happening here:

  1. OAuth Authentication: We use Authorization: Bearer ${token}
  2. Error Handling: Structured error parsing with fallbacks
  3. Flexibility: Support for filtering by type, status, variants, and pagination
  4. Type Safety: Full TypeScript interfaces for all API responses

Building the Photo Grid Component

Now letโ€™s create a React component that displays user images in a responsive grid. This component demonstrates pagination, loading states, and error handling patterns youโ€™ll use throughout the vault.

1'use client';
2
3// components/PhotoGrid.tsx
4import { useState, useEffect, useCallback } from 'react';
5import { FanvueAPI, MediaItem } from '../lib/fanvue-api';
6
7interface PhotoGridProps {
8 accessToken: string; // ๐Ÿ”‘ OAuth access token
9 onImageClick?: (item: MediaItem) => void;
10 refreshTrigger?: number; // Used to refresh when new images are uploaded
11}
12
13export function PhotoGrid({ accessToken, onImageClick, refreshTrigger }: PhotoGridProps) {
14 const [images, setImages] = useState<MediaItem[]>([]);
15 const [loading, setLoading] = useState(true);
16 const [error, setError] = useState<string | null>(null);
17 const [page, setPage] = useState(1);
18 const [hasMore, setHasMore] = useState(false);
19 const [loadingMore, setLoadingMore] = useState(false);
20
21 // ๐Ÿ—๏ธ Create API client with OAuth token
22 const api = new FanvueAPI(accessToken);
23
24 const loadImages = useCallback(async (pageNum: number = 1, reset: boolean = false) => {
25 try {
26 if (pageNum === 1) {
27 setLoading(true);
28 } else {
29 setLoadingMore(true);
30 }
31
32 // ๐Ÿ“ก Fetch user's images with specific filtering
33 const response = await api.getUserMedia({
34 mediaType: 'image', // Only images, not videos
35 variants: ['thumbnail_gallery'], // Optimized thumbnails for grid display
36 status: ['ready'], // Only fully processed images
37 page: pageNum,
38 size: 20, // 20 images per page
39 });
40
41 // ๐Ÿ” Double-check that images have thumbnails (defensive programming)
42 const vaultImages = response.data.filter((item: MediaItem) => {
43 const hasThumbnail = item.variants?.some(v =>
44 v.variantType === 'thumbnail_gallery' && v.url
45 );
46 return item.status === 'ready' && hasThumbnail;
47 });
48
49 setImages(prev => reset ? vaultImages : [...prev, ...vaultImages]);
50 setHasMore(response.pagination.hasMore);
51 setError(null);
52 } catch (err) {
53 const errorMessage = err instanceof Error ? err.message : 'Failed to load images';
54 setError(errorMessage);
55 console.error('Error loading images:', err);
56 } finally {
57 setLoading(false);
58 setLoadingMore(false);
59 }
60 }, [api]);
61
62 // ๐Ÿš€ Load initial images when component mounts or token changes
63 useEffect(() => {
64 if (accessToken) {
65 loadImages(1, true);
66 setPage(1);
67 }
68 }, [accessToken, loadImages]);
69
70 // ๐Ÿ”„ Reload when refreshTrigger changes (after uploads complete)
71 useEffect(() => {
72 if (refreshTrigger && refreshTrigger > 0) {
73 loadImages(1, true);
74 setPage(1);
75 }
76 }, [refreshTrigger, loadImages]);
77
78 const loadMore = useCallback(() => {
79 if (!loadingMore && hasMore && !loading) {
80 const nextPage = page + 1;
81 setPage(nextPage);
82 loadImages(nextPage, false);
83 }
84 }, [loadingMore, hasMore, loading, page, loadImages]);
85
86 // ๐ŸŽจ Loading state for initial load
87 if (loading && images.length === 0) {
88 return (
89 <div className="flex items-center justify-center p-12">
90 <div className="text-center">
91 <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
92 <p className="text-gray-600">Loading your vault...</p>
93 </div>
94 </div>
95 );
96 }
97
98 // โŒ Error state with retry option
99 if (error && images.length === 0) {
100 return (
101 <div className="text-center p-12">
102 <div className="text-red-600 mb-4">
103 <svg className="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
104 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
105 </svg>
106 <p className="text-lg font-medium">Failed to load images</p>
107 <p className="text-sm mt-2 text-gray-600">{error}</p>
108 </div>
109 <button
110 onClick={() => loadImages(1, true)}
111 className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
112 >
113 Try Again
114 </button>
115 </div>
116 );
117 }
118
119 // ๐Ÿ“ญ Empty state when user has no images
120 if (images.length === 0 && !loading) {
121 return (
122 <div className="text-center p-12">
123 <div className="text-gray-400 mb-4">
124 <svg className="mx-auto h-16 w-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
125 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
126 </svg>
127 <p className="text-lg font-medium text-gray-600">Your vault is empty</p>
128 <p className="text-sm mt-2 text-gray-500">
129 Upload some images to get started!
130 </p>
131 </div>
132 </div>
133 );
134 }
135
136 // ๐Ÿ–ผ๏ธ Main image grid
137 return (
138 <div className="w-full">
139 {/* Responsive grid that adapts to screen size */}
140 <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3 md:gap-4">
141 {images.map((item) => {
142 const thumbnail = item.variants?.find(v => v.variantType === 'thumbnail_gallery');
143
144 return (
145 <div
146 key={item.uuid}
147 className="group relative aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer transition-transform hover:scale-105 hover:shadow-lg"
148 onClick={() => onImageClick?.(item)}
149 >
150 {thumbnail?.url ? (
151 <>
152 <img
153 src={thumbnail.url}
154 alt={item.name || 'Vault image'}
155 className="w-full h-full object-cover"
156 loading="lazy" // โšก Performance: lazy load images
157 />
158 {/* Overlay with image info on hover */}
159 <div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-end">
160 <div className="p-2 text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity">
161 <p className="truncate">{item.name || 'Untitled'}</p>
162 {item.createdAt && (
163 <p className="text-gray-200 text-xs">
164 {new Date(item.createdAt).toLocaleDateString()}
165 </p>
166 )}
167 </div>
168 </div>
169 </>
170 ) : (
171 <div className="w-full h-full flex items-center justify-center text-gray-400">
172 <div className="text-center">
173 <svg className="mx-auto h-8 w-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
174 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
175 </svg>
176 <p className="text-xs">No preview</p>
177 </div>
178 </div>
179 )}
180 </div>
181 );
182 })}
183 </div>
184
185 {/* Loading indicator for "Load More" */}
186 {loadingMore && (
187 <div className="text-center p-8">
188 <div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mb-2"></div>
189 <p className="text-sm text-gray-600">Loading more images...</p>
190 </div>
191 )}
192
193 {/* Load More button */}
194 {hasMore && !loadingMore && !loading && (
195 <div className="text-center p-8">
196 <button
197 onClick={loadMore}
198 className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
199 >
200 Load More
201 </button>
202 </div>
203 )}
204 </div>
205 );
206}

Key Features Explained:

  1. OAuth Authentication: Uses accessToken prop for secure authentication
  2. Responsive Grid: Adapts from 2 to 6 columns based on screen size
  3. Lazy Loading: Images load only when theyโ€™re about to be visible
  4. State Management: Separate loading states for initial load vs. pagination
  5. Error Handling: Comprehensive error states with retry functionality
  6. Performance: Optimized with useCallback to prevent unnecessary re-renders

Now that you understand how to display existing images, letโ€™s move on to the exciting part: implementing multipart upload!

Step 2: Implement Multipart Upload

Hereโ€™s where we get to the core of this tutorial. Weโ€™ll build a complete multipart upload system that handles large files efficiently, provides real-time progress feedback, and gracefully handles errors.

Understanding the Upload Flow (Again, but with Code)

Before we dive into implementation, letโ€™s revisit the 3-phase flow with more technical detail:

Phase 1: Initiate Upload

  • Client tells server: โ€œI want to upload a 50MB image called โ€˜vacation.jpgโ€™โ€
  • Server creates a database record for the media
  • Server tells cloud storage: โ€œPrepare for a multipart uploadโ€
  • Server returns: {mediaUuid: "abc-123", uploadId: "xyz-789"}

Phase 2: Upload Parts

  • Client splits file into 10MB chunks (5 chunks for our 50MB file)
  • For each chunk:
    • Client asks: โ€œWhere do I upload chunk 3?โ€
    • Server returns a signed URL (valid for ~15 minutes)
    • Client uploads chunk directly to cloud storage
    • Storage returns an ETag (proof of successful upload)

Phase 3: Finalize Upload

  • Client tells server: โ€œAll chunks uploaded, here are the ETagsโ€
  • Server tells storage: โ€œCombine all chunks into final fileโ€
  • Storage combines chunks and confirms success
  • Server starts processing pipeline (thumbnail generation, etc.)

Enhanced API Client with Upload Methods

Letโ€™s extend our API client to support the multipart upload flow:

1// lib/fanvue-api.ts (continued)
2// Add these methods to the existing FanvueAPI class
3
4export interface UploadPart {
5 ETag?: string;
6 PartNumber: number;
7}
8
9export interface InitiateUploadResponse {
10 mediaUuid: string;
11 uploadId: string;
12}
13
14// Add these methods to your existing FanvueAPI class:
15
16 // ๐Ÿš€ Phase 1: Create upload session
17 async createUploadSession(file: File): Promise<InitiateUploadResponse> {
18 const response = await this.makeRequest('/media/uploads', {
19 method: 'POST',
20 body: JSON.stringify({
21 name: file.name, // Display name for the media
22 filename: file.name, // Original filename
23 mediaType: 'image', // We're focusing on images
24 }),
25 });
26
27 return response.json();
28 }
29
30 // ๐Ÿ“ Phase 2a: Get signed URL for specific chunk
31 async getPartUrl(
32 uploadId: string,
33 partNumber: number
34 ): Promise<string> {
35 const response = await this.makeRequest(`/media/uploads/${uploadId}/parts/${partNumber}/url`, {
36 method: 'GET',
37 });
38
39 // Response is plain text (the signed URL)
40 return response.text();
41 }
42
43 // ๐Ÿ“ฆ Phase 2b: Upload chunk to signed URL
44 async uploadPart(signedUrl: string, chunk: Blob): Promise<string> {
45 // Note: This request goes directly to cloud storage, not our API
46 const response = await fetch(signedUrl, {
47 method: 'PUT',
48 body: chunk,
49 headers: {
50 'Content-Type': 'application/octet-stream',
51 },
52 // Don't add Authorization header - signed URL handles auth
53 });
54
55 if (!response.ok) {
56 throw new Error(`Part upload failed: ${response.status} ${response.statusText}`);
57 }
58
59 // Cloud storage returns ETag header as proof of successful upload
60 const etag = response.headers.get('ETag');
61 if (!etag) {
62 throw new Error('No ETag received from part upload');
63 }
64
65 return etag;
66 }
67
68 // โœ… Phase 3: Complete upload session
69 async completeUploadSession(
70 uploadId: string,
71 parts: UploadPart[]
72 ): Promise<{ status: string }> {
73 const response = await this.makeRequest(`/media/uploads/${uploadId}`, {
74 method: 'PATCH',
75 body: JSON.stringify({
76 parts, // Array of {ETag, PartNumber} objects
77 }),
78 });
79
80 return response.json();
81 }
82}

Whatโ€™s happening in these upload methods:

  1. createUploadSession: Creates media record and starts multipart session
  2. getPartUrl: Gets signed URL for uploading a specific chunk using RESTful URL structure
  3. uploadPart: Uploads chunk directly to cloud storage (bypasses our API)
  4. completeUploadSession: Tells server to combine all chunks into final file using PATCH method

Error Handling for Upload Operations

Network operations can fail in many ways, especially with large file uploads. Letโ€™s add robust error handling:

1// lib/upload-errors.ts
2// Structured error handling for upload operations
3
4export class UploadError extends Error {
5 constructor(
6 message: string,
7 public code: string,
8 public retryable: boolean = false,
9 public originalError?: unknown
10 ) {
11 super(message);
12 this.name = "UploadError";
13 }
14}
15
16export function handleApiError(error: any): UploadError {
17 // Handle fetch abort (user cancelled)
18 if (error.name === "AbortError") {
19 return new UploadError("Upload cancelled by user", "CANCELLED");
20 }
21
22 const message = error.message || "Unknown error occurred";
23
24 // File too large
25 if (message.includes("413") || message.toLowerCase().includes("too large")) {
26 return new UploadError("File is too large. Maximum size is 1GB.", "FILE_TOO_LARGE");
27 }
28
29 // Rate limiting (temporary issue)
30 if (message.includes("429") || message.toLowerCase().includes("rate limit")) {
31 return new UploadError(
32 "Too many requests. Please wait a moment before retrying.",
33 "RATE_LIMITED",
34 true // This is retryable
35 );
36 }
37
38 // Authentication issues
39 if (message.includes("401") || message.includes("403")) {
40 return new UploadError("Authentication failed. Please log in again.", "AUTHENTICATION_FAILED");
41 }
42
43 // Server errors (likely temporary)
44 if (message.includes("5") || message.toLowerCase().includes("server error")) {
45 return new UploadError(
46 "Server error occurred. Please try again.",
47 "SERVER_ERROR",
48 true // Server errors are usually temporary
49 );
50 }
51
52 // Generic network error (likely temporary)
53 if (message.toLowerCase().includes("network") || message.toLowerCase().includes("fetch")) {
54 return new UploadError(
55 "Network error. Please check your connection and try again.",
56 "NETWORK_ERROR",
57 true
58 );
59 }
60
61 // Unknown error
62 return new UploadError(
63 message,
64 "UNKNOWN_ERROR",
65 true // When in doubt, allow retry
66 );
67}

Building the Upload Progress Component

Before we build the main uploader, letโ€™s create a component that shows upload progress. This provides immediate user feedback and makes the upload feel responsive:

1// components/UploadProgress.tsx
2// Visual progress indicator for individual file uploads
3
4interface UploadProgressProps {
5 fileName: string;
6 progress: number; // 0-100
7 status: 'uploading' | 'processing' | 'completed' | 'error';
8 error?: string;
9 onRetry?: () => void;
10 onCancel?: () => void;
11}
12
13export function UploadProgress({
14 fileName,
15 progress,
16 status,
17 error,
18 onRetry,
19 onCancel
20}: UploadProgressProps) {
21 // ๐Ÿ“ Dynamic text based on current status
22 const getStatusText = () => {
23 switch (status) {
24 case 'uploading':
25 return `Uploading... ${Math.round(progress)}%`;
26 case 'processing':
27 return 'Processing...'; // Server is creating thumbnails, etc.
28 case 'completed':
29 return 'Upload complete!';
30 case 'error':
31 return 'Upload failed';
32 default:
33 return 'Preparing...';
34 }
35 };
36
37 // ๐ŸŽจ Color scheme based on status
38 const getStatusColor = () => {
39 switch (status) {
40 case 'uploading':
41 return 'bg-blue-600';
42 case 'processing':
43 return 'bg-yellow-600 animate-pulse'; // Pulsing animation for processing
44 case 'completed':
45 return 'bg-green-600';
46 case 'error':
47 return 'bg-red-600';
48 default:
49 return 'bg-gray-400';
50 }
51 };
52
53 // ๐Ÿ”„ Icon for current status
54 const getIcon = () => {
55 switch (status) {
56 case 'uploading':
57 return (
58 <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
59 <circle
60 className="opacity-25"
61 cx="12"
62 cy="12"
63 r="10"
64 stroke="currentColor"
65 strokeWidth="4"
66 />
67 <path
68 className="opacity-75"
69 fill="currentColor"
70 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
71 />
72 </svg>
73 );
74 case 'processing':
75 return (
76 <svg className="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
77 <path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
78 </svg>
79 );
80 case 'completed':
81 return (
82 <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
83 <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
84 </svg>
85 );
86 case 'error':
87 return (
88 <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
89 <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
90 </svg>
91 );
92 default:
93 return null;
94 }
95 };
96
97 return (
98 <div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
99 {/* Header with file name and action buttons */}
100 <div className="flex items-center justify-between mb-3">
101 <div className="flex items-center space-x-3 min-w-0 flex-1">
102 <div className={`text-white p-1 rounded ${getStatusColor().split(' ')[0]}`}>
103 {getIcon()}
104 </div>
105 <div className="min-w-0 flex-1">
106 <p className="text-sm font-medium text-gray-900 truncate" title={fileName}>
107 {fileName}
108 </p>
109 <p className="text-xs text-gray-600">
110 {getStatusText()}
111 </p>
112 </div>
113 </div>
114
115 {/* Action buttons */}
116 <div className="flex items-center space-x-2 ml-4">
117 {status === 'uploading' && onCancel && (
118 <button
119 onClick={onCancel}
120 className="text-gray-400 hover:text-red-600 text-sm px-2 py-1 rounded transition-colors"
121 title="Cancel upload"
122 >
123 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
124 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
125 </svg>
126 </button>
127 )}
128 {status === 'error' && onRetry && (
129 <button
130 onClick={onRetry}
131 className="text-xs bg-red-600 text-white px-3 py-1 rounded hover:bg-red-700 transition-colors"
132 >
133 Retry
134 </button>
135 )}
136 </div>
137 </div>
138
139 {/* Progress bar */}
140 <div className="w-full bg-gray-200 rounded-full h-2 mb-2">
141 <div
142 className={`h-2 rounded-full transition-all duration-300 ease-out ${getStatusColor()}`}
143 style={{
144 width: `${status === 'error' ? 100 : Math.max(progress, 2)}%`
145 }}
146 />
147 </div>
148
149 {/* Error message */}
150 {status === 'error' && error && (
151 <div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-800">
152 <div className="flex items-start space-x-2">
153 <svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
154 <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
155 </svg>
156 <p className="flex-1">{error}</p>
157 </div>
158 </div>
159 )}
160
161 {/* Processing explanation */}
162 {status === 'processing' && (
163 <div className="mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-800">
164 <p className="flex items-center space-x-2">
165 <svg className="w-4 h-4 text-yellow-500 animate-pulse" fill="currentColor" viewBox="0 0 20 20">
166 <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
167 </svg>
168 <span>Creating thumbnails and optimizing your image...</span>
169 </p>
170 </div>
171 )}
172 </div>
173 );
174}

This progress component handles all the visual feedback during upload. The key features are:

  1. Status-based styling: Different colors and icons for each state
  2. Progress calculation: Smooth progress bar with minimum 2% width for visibility
  3. Interactive elements: Cancel and retry buttons when appropriate
  4. Educational messaging: Explains what โ€œprocessingโ€ means to users

Building the Main Upload Component

Now comes the centerpiece: a React component that handles the complete multipart upload flow. This component coordinates file chunking, parallel uploads, progress tracking, and error recovery.

1'use client';
2
3// components/ImageUploader.tsx
4// Complete multipart upload implementation with queue management
5
6import { useState, useCallback, useRef } from 'react';
7import { FanvueAPI, UploadPart, handleApiError, UploadError } from '../lib/fanvue-api';
8import { UploadProgress } from './UploadProgress';
9
10// Configuration constants
11const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks - good balance of reliability and performance
12const MAX_CONCURRENT_UPLOADS = 3; // Limit concurrent uploads to avoid overwhelming the server
13const MAX_FILE_SIZE = 1024 * 1024 * 1024; // 1GB limit
14const MAX_RETRY_ATTEMPTS = 3; // Auto-retry failed chunks up to 3 times
15
16interface UploadTask {
17 id: string;
18 file: File;
19 progress: number;
20 status: 'uploading' | 'processing' | 'completed' | 'error';
21 error?: string;
22 mediaUuid?: string;
23 uploadId?: string;
24 abortController?: AbortController;
25 retryCount: number;
26}
27
28interface ImageUploaderProps {
29 accessToken: string; // ๐Ÿ”‘ OAuth token
30 onUploadComplete?: () => void;
31 maxFiles?: number;
32 acceptedTypes?: string[];
33 disabled?: boolean;
34}
35
36export function ImageUploader({
37 accessToken,
38 onUploadComplete,
39 maxFiles = 10,
40 acceptedTypes = ['image/*'],
41 disabled = false
42}: ImageUploaderProps) {
43 const [uploads, setUploads] = useState<UploadTask[]>([]);
44 const fileInputRef = useRef<HTMLInputElement>(null);
45
46 // Queue management for concurrent uploads
47 const uploadQueueRef = useRef<(() => Promise<void>)[]>([]);
48 const runningUploads = useRef(0);
49
50 const api = new FanvueAPI(accessToken);
51
52 // ๐Ÿ”„ Helper to update upload state
53 const updateUpload = useCallback((id: string, updates: Partial<UploadTask>) => {
54 setUploads(prev => prev.map(upload =>
55 upload.id === id ? { ...upload, ...updates } : upload
56 ));
57 }, []);
58
59 const removeUpload = useCallback((id: string) => {
60 setUploads(prev => prev.filter(upload => upload.id !== id));
61 }, []);
62
63 // ๐ŸŽฏ Queue management - processes uploads with concurrency limit
64 const processUploadQueue = useCallback(async () => {
65 if (runningUploads.current >= MAX_CONCURRENT_UPLOADS || uploadQueueRef.current.length === 0) {
66 return;
67 }
68
69 const uploadTask = uploadQueueRef.current.shift();
70 if (uploadTask) {
71 runningUploads.current++;
72 try {
73 await uploadTask();
74 } finally {
75 runningUploads.current--;
76 // Process next item in queue
77 setTimeout(processUploadQueue, 100);
78 }
79 }
80 }, []);
81
82 // ๐Ÿš€ Main upload function - implements the 3-phase multipart flow
83 const uploadFile = useCallback(async (file: File): Promise<void> => {
84 const uploadId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
85 const abortController = new AbortController();
86
87 const upload: UploadTask = {
88 id: uploadId,
89 file,
90 progress: 0,
91 status: 'uploading',
92 abortController,
93 retryCount: 0,
94 };
95
96 setUploads(prev => [...prev, upload]);
97
98 try {
99 // ๐Ÿ” Phase 1: Create upload session
100 if (abortController.signal.aborted) throw new Error('Upload cancelled');
101
102 const initResponse = await api.createUploadSession(file);
103 updateUpload(uploadId, {
104 mediaUuid: initResponse.mediaUuid,
105 uploadId: initResponse.uploadId,
106 });
107
108 // ๐Ÿ“ฆ Phase 2: Upload parts in parallel
109 const chunks = Math.ceil(file.size / CHUNK_SIZE);
110 const parts: Array<{ ETag: string; PartNumber: number }> = new Array(chunks);
111 const uploadPromises: Promise<void>[] = [];
112
113 // Create upload promises for each chunk
114 for (let partNumber = 1; partNumber <= chunks; partNumber++) {
115 const uploadPartPromise = async () => {
116 if (abortController.signal.aborted) throw new Error('Upload cancelled');
117
118 const start = (partNumber - 1) * CHUNK_SIZE;
119 const end = Math.min(start + CHUNK_SIZE, file.size);
120 const chunk = file.slice(start, end);
121
122 try {
123 // Get signed URL for this specific chunk
124 const partUrl = await api.getPartUrl(
125 initResponse.uploadId,
126 partNumber
127 );
128
129 // Upload chunk directly to cloud storage
130 const etag = await api.uploadPart(partUrl, chunk);
131 parts[partNumber - 1] = { ETag: etag, PartNumber: partNumber };
132
133 // Update progress (thread-safe because we're using the part number as index)
134 const completedChunks = parts.filter(Boolean).length;
135 const progress = (completedChunks / chunks) * 100;
136 updateUpload(uploadId, { progress });
137
138 } catch (error) {
139 throw new Error(`Failed to upload chunk ${partNumber}: ${error}`);
140 }
141 };
142
143 uploadPromises.push(uploadPartPromise());
144
145 // โšก Process chunks in batches to avoid overwhelming the browser
146 if (uploadPromises.length >= 3) {
147 await Promise.all(uploadPromises);
148 uploadPromises.length = 0;
149 }
150 }
151
152 // Wait for any remaining uploads
153 if (uploadPromises.length > 0) {
154 await Promise.all(uploadPromises);
155 }
156
157 // Verify all parts uploaded successfully
158 if (parts.length !== chunks || parts.some(part => !part)) {
159 throw new Error('Some chunks failed to upload');
160 }
161
162 // โœ… Phase 3: Complete upload session
163 updateUpload(uploadId, { status: 'processing', progress: 100 });
164
165 await api.completeUploadSession(
166 initResponse.uploadId,
167 parts
168 );
169
170 updateUpload(uploadId, {
171 status: 'completed',
172 progress: 100
173 });
174
175 // Notify parent component
176 onUploadComplete?.();
177
178 } catch (error) {
179 const uploadError = handleApiError(error);
180 updateUpload(uploadId, {
181 status: 'error',
182 error: uploadError.message,
183 });
184
185 // Auto-retry for retryable errors (rate limits, network issues, etc.)
186 if (uploadError.retryable && upload.retryCount < MAX_RETRY_ATTEMPTS) {
187 setTimeout(() => retryUpload(uploadId), 2000 * (upload.retryCount + 1));
188 }
189 }
190 }, [accessToken, onUploadComplete, api, updateUpload]);
191
192 // ๐Ÿ“‹ Queue upload with concurrency management
193 const queueUpload = useCallback((file: File) => {
194 const uploadTask = () => uploadFile(file);
195 uploadQueueRef.current.push(uploadTask);
196 processUploadQueue();
197 }, [uploadFile, processUploadQueue]);
198
199 // โœ… File validation before upload
200 const validateFile = useCallback((file: File): string | null => {
201 // Check file type
202 const isValidType = acceptedTypes.some(type => {
203 if (type === 'image/*') return file.type.startsWith('image/');
204 return file.type === type;
205 });
206
207 if (!isValidType) {
208 return 'File type not supported. Please select an image file.';
209 }
210
211 // Check file size
212 if (file.size > MAX_FILE_SIZE) {
213 return `File too large. Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)}MB.`;
214 }
215
216 if (file.size === 0) {
217 return 'File is empty.';
218 }
219
220 return null;
221 }, [acceptedTypes]);
222
223 // ๐Ÿ“ Handle file selection from input
224 const handleFileSelect = useCallback((files: FileList | null) => {
225 if (!files || disabled) return;
226
227 const fileArray = Array.from(files);
228 const currentUploads = uploads.length;
229
230 // Check file limits
231 if (currentUploads + fileArray.length > maxFiles) {
232 alert(`Maximum ${maxFiles} files allowed. You can upload ${maxFiles - currentUploads} more files.`);
233 return;
234 }
235
236 // Validate and queue each file
237 fileArray.forEach(file => {
238 const error = validateFile(file);
239 if (error) {
240 alert(`${file.name}: ${error}`);
241 return;
242 }
243
244 queueUpload(file);
245 });
246
247 // Reset input for re-selection of same files
248 if (fileInputRef.current) {
249 fileInputRef.current.value = '';
250 }
251 }, [disabled, uploads.length, maxFiles, validateFile, queueUpload]);
252
253 // ๐Ÿ”„ Retry failed upload
254 const retryUpload = useCallback((uploadId: string) => {
255 const upload = uploads.find(u => u.id === uploadId);
256 if (!upload) return;
257
258 // Cancel any existing upload
259 upload.abortController?.abort();
260
261 // Reset state and increment retry count
262 updateUpload(uploadId, {
263 status: 'uploading',
264 progress: 0,
265 error: undefined,
266 retryCount: upload.retryCount + 1,
267 abortController: new AbortController(),
268 });
269
270 // Queue the retry
271 queueUpload(upload.file);
272 }, [uploads, updateUpload, queueUpload]);
273
274 // โŒ Cancel upload
275 const cancelUpload = useCallback((uploadId: string) => {
276 const upload = uploads.find(u => u.id === uploadId);
277 if (upload) {
278 upload.abortController?.abort();
279 removeUpload(uploadId);
280 }
281 }, [uploads, removeUpload]);
282
283 // ๐Ÿงน Clear completed uploads
284 const clearCompleted = useCallback(() => {
285 setUploads(prev => prev.filter(upload => upload.status !== 'completed'));
286 }, []);
287
288 const completedCount = uploads.filter(u => u.status === 'completed').length;
289 const hasUploads = uploads.length > 0;
290
291 return (
292 <div className="w-full">
293 {/* File Selection Area */}
294 <div
295 className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
296 disabled
297 ? 'border-gray-200 bg-gray-50 cursor-not-allowed'
298 : 'border-gray-300 hover:border-blue-400 hover:bg-blue-50 cursor-pointer'
299 }`}
300 onClick={() => !disabled && fileInputRef.current?.click()}
301 >
302 <input
303 ref={fileInputRef}
304 type="file"
305 multiple
306 accept={acceptedTypes.join(',')}
307 onChange={(e) => handleFileSelect(e.target.files)}
308 className="hidden"
309 disabled={disabled}
310 />
311
312 <div className={`${disabled ? 'text-gray-400' : 'text-gray-600'}`}>
313 <svg
314 className="mx-auto h-12 w-12 mb-4"
315 stroke="currentColor"
316 fill="none"
317 viewBox="0 0 48 48"
318 aria-hidden="true"
319 >
320 <path
321 d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
322 strokeWidth={2}
323 strokeLinecap="round"
324 strokeLinejoin="round"
325 />
326 </svg>
327 <p className="text-lg font-medium">
328 {disabled ? 'Upload disabled' : 'Upload images to your vault'}
329 </p>
330 <p className="text-sm mt-1">
331 {disabled
332 ? 'Please authenticate to upload files'
333 : 'Click to select files'
334 }
335 </p>
336 <p className="text-xs mt-2 text-gray-500">
337 Max {maxFiles} files, up to {MAX_FILE_SIZE / (1024 * 1024)}MB each
338 </p>
339 </div>
340 </div>
341
342 {/* Upload List */}
343 {hasUploads && (
344 <div className="mt-6 space-y-3">
345 <div className="flex items-center justify-between">
346 <h3 className="text-lg font-medium">
347 Uploads ({uploads.length})
348 </h3>
349 {completedCount > 0 && (
350 <button
351 onClick={clearCompleted}
352 className="text-sm text-gray-500 hover:text-gray-700 underline"
353 >
354 Clear completed ({completedCount})
355 </button>
356 )}
357 </div>
358
359 <div className="space-y-3 max-h-96 overflow-y-auto">
360 {uploads.map(upload => (
361 <UploadProgress
362 key={upload.id}
363 fileName={upload.file.name}
364 progress={upload.progress}
365 status={upload.status}
366 error={upload.error}
367 onRetry={() => retryUpload(upload.id)}
368 onCancel={
369 upload.status === 'uploading'
370 ? () => cancelUpload(upload.id)
371 : undefined
372 }
373 />
374 ))}
375 </div>
376 </div>
377 )}
378 </div>
379 );
380}

Key Implementation Details:

  1. Chunking Strategy: Files are split into 10MB chunks for optimal balance of reliability and performance
  2. Concurrency Control: Maximum 3 concurrent uploads to avoid overwhelming the server
  3. Progress Tracking: Real-time progress updates as chunks complete
  4. Error Recovery: Automatic retry for network issues and rate limits
  5. User Control: Users can cancel uploads and retry failed ones
  6. Queue Management: Multiple files are queued and processed efficiently

Understanding the Upload Logic

Letโ€™s break down the complex upload logic step by step:

File Validation

  • Checks file type matches accepted formats
  • Ensures file size is within limits
  • Provides clear error messages for invalid files

Upload Orchestration

  1. Initiate: Create media record and get upload session ID
  2. Chunk Processing: Split file and upload chunks in parallel batches
  3. Progress Tracking: Update UI as each chunk completes
  4. Finalization: Combine chunks server-side and start processing

Error Handling Strategy

  • Network Errors: Auto-retry with exponential backoff
  • Rate Limits: Wait and retry automatically
  • File Errors: Show user-friendly messages
  • Cancellation: Clean up resources properly

This completes our multipart upload implementation! The component handles all the complexity of chunking, uploading, and error recovery while providing a clean interface for users.

Step 3: Bringing It All Together

Now letโ€™s combine our PhotoGrid and ImageUploader components into a complete vault page that demonstrates the full user experience.

Creating the Vault Page

Hereโ€™s how all our components work together in a production-ready vault interface:

1// app/vault/page.tsx
2import { Suspense } from 'react';
3import { VaultContent } from './VaultContent';
4
5export default function VaultPage() {
6 return (
7 <div className="min-h-screen bg-gray-50">
8 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
9 <div className="mb-8">
10 <h1 className="text-3xl font-bold text-gray-900 mb-2">
11 My Vault
12 </h1>
13 <p className="text-gray-600">
14 Upload and manage your images with advanced multipart upload technology
15 </p>
16 </div>
17
18 <Suspense fallback={
19 <div className="flex items-center justify-center p-12">
20 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
21 </div>
22 }>
23 <VaultContent />
24 </Suspense>
25 </div>
26 </div>
27 );
28}

The Main Vault Component

1'use client';
2
3// app/vault/VaultContent.tsx
4// Handles authentication and coordinates the photo grid with uploader
5
6import { useState, useEffect } from 'react';
7import { PhotoGrid } from '../../components/PhotoGrid';
8import { ImageUploader } from '../../components/ImageUploader';
9import { getCurrentUser } from '../../lib/fanvue';
10
11export function VaultContent() {
12 const [user, setUser] = useState<any>(null);
13 const [loading, setLoading] = useState(true);
14 const [error, setError] = useState<string | null>(null);
15 const [refreshKey, setRefreshKey] = useState(0);
16
17 // ๐Ÿ” Get authenticated user with OAuth token
18 useEffect(() => {
19 async function loadUser() {
20 try {
21 const currentUser = await getCurrentUser();
22 if (!currentUser) {
23 setError('Please log in to access your vault');
24 return;
25 }
26 setUser(currentUser);
27 } catch (err) {
28 setError('Authentication failed. Please log in again.');
29 console.error('Failed to get current user:', err);
30 } finally {
31 setLoading(false);
32 }
33 }
34
35 loadUser();
36 }, []);
37
38 // ๐Ÿ”„ Refresh photo grid when uploads complete
39 const handleUploadComplete = () => {
40 setRefreshKey(prev => prev + 1);
41 };
42
43 if (loading) {
44 return (
45 <div className="flex items-center justify-center p-12">
46 <div className="text-center">
47 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
48 <p className="text-gray-600">Loading your vault...</p>
49 </div>
50 </div>
51 );
52 }
53
54 if (error || !user) {
55 return (
56 <div className="text-center p-12">
57 <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 max-w-md mx-auto">
58 <svg className="mx-auto h-12 w-12 text-yellow-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
59 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
60 </svg>
61 <h3 className="text-lg font-medium text-yellow-800 mb-2">
62 Authentication Required
63 </h3>
64 <p className="text-yellow-700 mb-4">
65 {error || 'Please log in to access your vault and upload images.'}
66 </p>
67 <a
68 href="/"
69 className="inline-flex items-center px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
70 >
71 <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
72 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
73 </svg>
74 Back to Home
75 </a>
76 </div>
77 </div>
78 );
79 }
80
81 return (
82 <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
83 {/* Photo Grid - 2/3 width on large screens */}
84 <div className="lg:col-span-2">
85 <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
86 <div className="flex items-center justify-between mb-6">
87 <h2 className="text-xl font-semibold text-gray-900">Your Images</h2>
88 <div className="flex items-center space-x-2 text-sm text-gray-500">
89 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
90 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
91 </svg>
92 <span>Logged in as {user.username || user.email}</span>
93 </div>
94 </div>
95
96 <PhotoGrid
97 accessToken={user.accessToken} // ๐Ÿ”‘ Pass OAuth token
98 refreshTrigger={refreshKey}
99 onImageClick={(item) => {
100 console.log('Clicked image:', item);
101 // Here you could open a modal, navigate to detail page, etc.
102 }}
103 />
104 </div>
105 </div>
106
107 {/* Upload Panel - 1/3 width on large screens */}
108 <div>
109 <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
110 <h2 className="text-xl font-semibold text-gray-900 mb-6">Upload New Images</h2>
111
112 <ImageUploader
113 accessToken={user.accessToken} // ๐Ÿ”‘ Pass OAuth token
114 onUploadComplete={handleUploadComplete}
115 maxFiles={10}
116 acceptedTypes={['image/*']}
117 />
118
119 {/* Educational Tips */}
120 <div className="mt-6 p-4 bg-blue-50 rounded-lg">
121 <h3 className="text-sm font-medium text-blue-900 mb-2">RESTful Multipart Upload</h3>
122 <ul className="text-xs text-blue-800 space-y-1">
123 <li>โ€ข POST /uploads - Create upload session</li>
124 <li>โ€ข GET /uploads/{id}/parts/{n}/url - Get signed URLs</li>
125 <li>โ€ข PATCH /uploads/{id} - Complete upload</li>
126 <li>โ€ข Files split into 10MB chunks for reliability</li>
127 <li>โ€ข Real-time progress tracking per file</li>
128 </ul>
129 </div>
130 </div>
131
132 {/* API Demo Information */}
133 <div className="mt-6 bg-gray-50 rounded-lg border border-gray-200 p-6">
134 <h3 className="text-lg font-medium text-gray-900 mb-4">About This Demo</h3>
135 <p className="text-sm text-gray-600 mb-4">
136 This vault demonstrates the Fanvue API's advanced multipart upload capabilities:
137 </p>
138 <ul className="text-sm text-gray-600 space-y-2">
139 <li className="flex items-start space-x-2">
140 <svg className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
141 <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
142 </svg>
143 <span>OAuth Bearer token authentication</span>
144 </li>
145 <li className="flex items-start space-x-2">
146 <svg className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
147 <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
148 </svg>
149 <span>RESTful 3-phase multipart upload flow</span>
150 </li>
151 <li className="flex items-start space-x-2">
152 <svg className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
153 <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
154 </svg>
155 <span>Automatic thumbnail generation</span>
156 </li>
157 <li className="flex items-start space-x-2">
158 <svg className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
159 <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
160 </svg>
161 <span>Production-ready error handling</span>
162 </li>
163 </ul>
164
165 <div className="mt-4 pt-4 border-t border-gray-200">
166 <a
167 href="https://api.fanvue.com/docs"
168 target="_blank"
169 rel="noopener noreferrer"
170 className="text-sm text-blue-600 hover:text-blue-700 font-medium inline-flex items-center"
171 >
172 View Full API Documentation
173 <svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
174 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
175 </svg>
176 </a>
177 </div>
178 </div>
179 </div>
180 </div>
181 );
182}

Key Architecture Decisions

Layout & UX

  • Responsive Grid: 3-column layout on desktop, single column on mobile
  • Progressive Enhancement: Works without JavaScript for basic functionality
  • Clear Visual Hierarchy: Existing images prominent, upload panel secondary

State Management

  • Centralized Auth: Single source of truth for user authentication
  • Optimistic Updates: UI updates immediately, syncs with server
  • Error Boundaries: Graceful handling of authentication and network failures

Performance Considerations

  • Lazy Loading: Images load as needed
  • Suspense Boundaries: Non-blocking loading states
  • Efficient Re-renders: Minimal re-renders through proper key usage

Testing Your Implementation

Now that you have a complete multipart upload system, hereโ€™s how to test it thoroughly:

1. Basic Functionality Tests

Photo Grid Loading

  • Empty vault state displays correctly
  • Loading spinner appears during API calls
  • Error message shows with retry button for network failures
  • Images display properly with hover effects
  • โ€œLoad Moreโ€ button works for pagination

File Upload Tests

  • File picker opens and accepts image files
  • Multiple file selection works correctly
  • File validation rejects invalid types and sizes
  • Progress tracking updates in real-time
  • Completed uploads refresh the photo grid

2. Multipart Upload Flow Testing

Small Files (< 10MB)

  • Should upload in single chunk
  • Progress should jump quickly to 100%
  • Processing state should appear briefly

Large Files (> 10MB)

  • Should split into multiple chunks
  • Progress should increase gradually
  • Each chunk upload should be visible in network tab

Very Large Files (> 100MB)

  • Should handle many chunks without browser freezing
  • Memory usage should remain stable
  • Upload should complete successfully

3. Error Scenario Testing

Network Interruptions

  • Disconnect internet during upload
  • Should show retry button when reconnected
  • Auto-retry should work for retryable errors

Authentication Failures

  • Use expired or invalid OAuth token
  • Should show authentication error message
  • Should redirect to login when appropriate

Rate Limiting

  • Upload many files simultaneously
  • Should handle 429 responses gracefully
  • Should implement exponential backoff

File Size Limits

  • Try uploading files over 1GB
  • Should show clear error message before upload starts

4. User Experience Testing

Visual Feedback

  • All loading states have appropriate spinners
  • Error states have clear messaging
  • Success states provide confirmation

Responsive Design

  • Grid layout adapts to different screen sizes
  • Upload interface works on mobile devices
  • Touch interactions work properly

Accessibility

  • Keyboard navigation works throughout
  • Screen readers can announce upload progress
  • Color contrast meets accessibility standards

Advanced Features

Once youโ€™ve mastered the basic multipart upload, consider adding these enhancements:

Upload Queue Management

1// Enhanced queue with priority and retry logic
2class AdvancedUploadQueue {
3 private highPriorityQueue: UploadTask[] = [];
4 private normalQueue: UploadTask[] = [];
5
6 addHighPriority(task: UploadTask) {
7 this.highPriorityQueue.push(task);
8 }
9
10 // Process high priority tasks first
11 getNextTask(): UploadTask | null {
12 return this.highPriorityQueue.shift() || this.normalQueue.shift() || null;
13 }
14}

Upload Analytics

1// Track upload performance for optimization
2interface UploadMetrics {
3 fileSize: number;
4 chunkSize: number;
5 totalTime: number;
6 avgChunkTime: number;
7 retryCount: number;
8 networkSpeed: number;
9}
10
11function trackUploadMetrics(upload: UploadTask): UploadMetrics {
12 // Implementation for gathering upload analytics
13 // Use this data to optimize chunk sizes and retry strategies
14}

Drag and Drop Enhancement

1// Add drag-and-drop for better UX
2function useDragAndDrop(onFilesDropped: (files: File[]) => void) {
3 const [isDragging, setIsDragging] = useState(false);
4
5 const handleDrop = (e: DragEvent) => {
6 e.preventDefault();
7 setIsDragging(false);
8 const files = Array.from(e.dataTransfer?.files || []);
9 onFilesDropped(files);
10 };
11
12 // Return drag handlers and state
13 return { isDragging, dragHandlers: { onDrop: handleDrop } };
14}

Performance Optimizations

1. Dynamic Chunk Sizing

1// Adjust chunk size based on network speed
2function calculateOptimalChunkSize(networkSpeed: number): number {
3 if (networkSpeed > 10000) return 20 * 1024 * 1024; // 20MB for fast networks
4 if (networkSpeed > 1000) return 10 * 1024 * 1024; // 10MB for medium
5 return 5 * 1024 * 1024; // 5MB for slow networks
6}

2. Connection Pooling

1// Reuse connections for better performance
2class ConnectionPool {
3 private activeConnections = new Set<string>();
4 private maxConnections = 6; // Browser limit
5
6 canMakeRequest(): boolean {
7 return this.activeConnections.size < this.maxConnections;
8 }
9}

3. Image Preprocessing

1// Compress images client-side before upload
2async function compressImage(file: File, quality: number = 0.8): Promise<Blob> {
3 const canvas = document.createElement("canvas");
4 const ctx = canvas.getContext("2d");
5 const img = new Image();
6
7 return new Promise((resolve) => {
8 img.onload = () => {
9 canvas.width = img.width;
10 canvas.height = img.height;
11 ctx?.drawImage(img, 0, 0);
12 canvas.toBlob((blob) => resolve(blob!), "image/jpeg", quality);
13 };
14 img.src = URL.createObjectURL(file);
15 });
16}

Security Considerations

1. Client-Side Validation is Not Enough

1// Always validate on server-side too
2const validateFile = (file: File) => {
3 // Client-side validation for UX
4 if (!file.type.startsWith("image/")) {
5 throw new Error("Invalid file type");
6 }
7
8 // Note: Server MUST also validate file type, size, and content
9};

2. Secure Token Handling

1// Never log or expose OAuth tokens
2class SecureAPIClient {
3 private token: string;
4
5 constructor(token: string) {
6 this.token = token;
7 // Never console.log(this.token) or expose it in errors
8 }
9
10 makeRequest(url: string) {
11 return fetch(url, {
12 headers: {
13 Authorization: `Bearer ${this.token}`, // Keep this secure
14 },
15 });
16 }
17}

3. Content Security Policy

1// Add CSP headers to prevent XSS
2const cspHeader = {
3 "Content-Security-Policy":
4 "default-src 'self'; " +
5 "img-src 'self' https://cdn.fanvue.com; " +
6 "connect-src 'self' https://api.fanvue.com",
7};

Troubleshooting Common Issues

Upload Fails Immediately

  • Check OAuth token: Ensure read:media and write:media scopes
  • Verify file type: Only images are supported in this implementation
  • Check file size: Must be under 1GB limit
  • Network connectivity: Ensure API endpoint is reachable

Progress Bar Stuck at 0%

  • Check initiate response: Verify mediaUuid and uploadId are received
  • Check part URLs: Ensure signed URLs are being generated
  • Network issues: Temporary connectivity problems

Images Not Appearing After Upload

  • Processing time: Large images may take minutes to process
  • Status checking: Verify images reach โ€˜readyโ€™ status
  • Variant generation: Thumbnails must be created before display
  • Refresh timing: Grid should refresh after successful upload

Memory Issues with Large Files

  • Chunk size: Reduce from 10MB to 5MB for slower devices
  • Concurrency: Reduce MAX_CONCURRENT_UPLOADS from 3 to 2
  • File cleanup: Ensure File objects are garbage collected

Next Steps

This tutorial provides a solid foundation for multipart uploads. Consider extending with:

  • Resume Capability: Store upload state in localStorage
  • Background Uploads: Continue uploads when tab is hidden
  • Bulk Operations: Select and delete multiple images
  • Advanced Metadata: EXIF data extraction and display
  • Image Editing: Basic crop/rotate before upload

Production Checklist

Before deploying your multipart upload vault to production:

  • OAuth tokens stored securely - Never expose tokens client-side
  • Error tracking implemented - Use Sentry, LogRocket, or similar
  • Upload limits configured - File size, count, and rate limits
  • User feedback for all states - Loading, error, and success messaging
  • Mobile-responsive design tested - Upload works on all devices
  • Accessibility features added - Screen reader support, keyboard navigation
  • Rate limiting handled gracefully - Exponential backoff, user messaging
  • Image optimization implemented - Client-side compression where appropriate
  • Content security policies configured - CSP headers to prevent XSS
  • Performance monitoring - Track upload success rates and timing
  • Comprehensive error logging - Log all upload failures for debugging
  • Backup recovery plan - Handle corrupted uploads gracefully

This tutorial demonstrates a complete multipart upload implementation using OAuth Bearer tokens and modern React patterns. For questions about specific API endpoints, see the API Reference.