Back to catalog

file-upload-patterns

This repository contains a complete **Cursor (Claude AI)** infrastructure that provides **auto-activating skills**, **specialized agents**, and **powerful commands** to help you build better applications faster.

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • Updated: 2025-11-22

Download the latest version (v1.0.0) from this page.

SKILL.md

# File Upload Patterns

**Skill Version:** 1.0.0  
**Stack:** React, TypeScript, Firebase Storage  
**Purpose:** Production-ready file upload patterns with progress tracking and validation

## Overview

This skill provides reusable patterns for file uploads including progress tracking, validation, multiple file support, and Firebase Storage integration. All patterns are generic and adaptable to any use case.

## When to Use This Skill

Load this skill when working on:
- File upload components
- Progress tracking
- File validation (type, size)
- Firebase Storage integration
- Multiple file uploads
- File management (preview, delete)

## Quick Reference

### Core Features
1. **File Validation** - Type and size checking
2. **Progress Tracking** - Real-time upload progress
3. **Multiple Files** - Batch upload support
4. **Error Handling** - User-friendly error messages
5. **Preview & Delete** - File management UI

## Core Guidelines

### 1. Reusable Upload Component

```typescript
// components/FileUpload.tsx
'use client';

import { useState } from 'react';
import { uploadFile, deleteFile } from '@/lib/firebase/storage';

interface FileAttachment {
  name: string;
  url: string;
  uploadedAt: Date;
  uploadedBy: string;
}

interface FileUploadProps {
  files: FileAttachment[];
  storagePath: string; // e.g., "users/user123/documents"
  onFilesChange: (files: FileAttachment[]) => void;
  maxFiles?: number;
  maxSizeBytes?: number;
  allowedTypes?: string[];
  disabled?: boolean;
  label?: string;
}

export function FileUpload({
  files,
  storagePath,
  onFilesChange,
  maxFiles = 10,
  maxSizeBytes = 10 * 1024 * 1024, // 10MB
  allowedTypes = ['image/*', 'application/pdf', '.doc', '.docx'],
  disabled = false,
  label = 'Upload Files',
}: FileUploadProps) {
  const [uploading, setUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState<number>(0);
  const [error, setError] = useState('');

  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFiles = e.target.files;
    if (!selectedFiles || selectedFiles.length === 0) return;

    // Validate max files
    if (files.length + selectedFiles.length > maxFiles) {
      setError(`Maximum ${maxFiles} files allowed`);
      return;
    }

    setUploading(true);
    setError('');
    const newFiles: FileAttachment[] = [];

    try {
      for (let i = 0; i < selectedFiles.length; i++) {
        const file = selectedFiles[i];

        // Validate file size
        if (file.size > maxSizeBytes) {
          const sizeMB = (maxSizeBytes / (1024 * 1024)).toFixed(0);
          setError(`File "${file.name}" exceeds ${sizeMB}MB limit`);
          continue;
        }

        // Update progress
        setUploadProgress(Math.round(((i + 1) / selectedFiles.length) * 100));

        // Upload file
        const fileName = `${Date.now()}_${file.name}`;
        const filePath = `${storagePath}/${fileName}`;
        const downloadUrl = await uploadFile(file, filePath);

        newFiles.push({
          name: file.name,
          url: downloadUrl,
          uploadedAt: new Date(),
          uploadedBy: 'current-user-id', // Get from auth context
        });
      }

      // Add new files to existing
      onFilesChange([...files, ...newFiles]);
      
      // Reset input
      e.target.value = '';
    } catch (err) {
      console.error('Upload error:', err);
      setError('Failed to upload files. Please try again.');
    } finally {
      setUploading(false);
      setUploadProgress(0);
    }
  };

  const handleDelete = async (fileToDelete: FileAttachment) => {
    try {
      // Extract path from URL or store it with file
      const path = `${storagePath}/${fileToDelete.name}`;
      await deleteFile(path);

      // Remove from list
      onFilesChange(files.filter(f => f.url !== fileToDelete.url));
    } catch (err) {
      console.error('Delete error:', err);
      setError('Failed to delete file');
    }
  };

  return (
    <div className="space-y-4">
      {/* Upload Button */}
      <div>
        <label className="block text-sm font-medium mb-2">{label}</label>
        <input
          type="file"
          multiple
          accept={allowedTypes.join(',')}
          onChange={handleFileSelect}
          disabled={disabled || uploading}
          className="block w-full text-sm text-gray-500
            file:mr-4 file:py-2 file:px-4
            file:rounded-lg file:border-0
            file:text-sm file:font-semibold
            file:bg-blue-50 file:text-blue-700
            hover:file:bg-blue-100
            disabled:opacity-50 disabled:cursor-not-allowed"
        />
        <p className="mt-1 text-sm text-gray-500">
          Max {maxFiles} files, {(maxSizeBytes / (1024 * 1024)).toFixed(0)}MB each
        </p>
      </div>

      {/* Progress Bar */}
      {uploading && (
        <div className="w-full bg-gray-200 rounded-full h-2">
          <div
            className="bg-blue-600 h-2 rounded-full transition-all"
            style={{ width: `${uploadProgress}%` }}
          />
        </div>
      )}

      {/* Error Message */}
      {error && (
        <div className="text-sm text-red-600 bg-red-50 p-3 rounded-lg">
          {error}
        </div>
      )}

      {/* File List */}
      {files.length > 0 && (
        <div className="space-y-2">
          <p className="text-sm font-medium">Uploaded Files ({files.length})</p>
          {files.map((file, index) => (
            <div
              key={index}
              className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
            >
              <div className="flex items-center space-x-3">
                <FileIcon fileName={file.name} />
                <div>
                  <p className="text-sm font-medium">{file.name}</p>
                  <p className="text-xs text-gray-500">
                    {new Date(file.uploadedAt).toLocaleDateString()}
                  </p>
                </div>
              </div>
              <div className="flex space-x-2">
                <a
                  href={file.url}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="text-blue-600 hover:text-blue-800 text-sm"
                >
                  View
                </a>
                <button
                  onClick={() => handleDelete(file)}
                  disabled={disabled}
                  className="text-red-600 hover:text-red-800 text-sm disabled:opacity-50"
                >
                  Delete
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// Helper component for file icons
function FileIcon({ fileName }: { fileName: string }) {
  const ext = fileName.split('.').pop()?.toLowerCase();
  
  const iconClass = "w-10 h-10 flex items-center justify-center rounded-lg text-white text-xs font-bold";
  
  if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) {
    return <div className={`${iconClass} bg-purple-500`}>IMG</div>;
  }
  if (ext === 'pdf') {
    return <div className={`${iconClass} bg-red-500`}>PDF</div>;
  }
  if (['doc', 'docx'].includes(ext || '')) {
    return <div className={`${iconClass} bg-blue-500`}>DOC</div>;
  }
  return <div className={`${iconClass} bg-gray-500`}>FILE</div>;
}
```

### 2. Firebase Storage Service

```typescript
// lib/firebase/storage.ts
import {
  ref,
  uploadBytes,
  uploadBytesResumable,
  getDownloadURL,
  deleteObject,
  listAll,
  UploadTaskSnapshot
} from 'firebase/storage';
import { storage } from './config';

/**
 * Upload file with optional progress tracking
 */
export async function uploadFile(
  file: File,
  path: string,
  onProgress?: (progress: number) => void
): Promise<string> {
  try {
    const storageRef = ref(storage, path);
    
    if (onProgress) {
      // Upload with progress
      const uploadTask = uploadBytesResumable(storageRef, file);
      
      return new Promise((resolve, reject) => {
        uploadTask.on(
          'state_changed',
          (snapshot: UploadTaskSnapshot) => {
            const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
            onProgress(progress);
          },
          (error) => reject(error),
          async () => {
            const url = await getDownloadURL(uploadTask.snapshot.ref);
            resolve(url);
          }
        );
      });
    } else {
      // Simple upload
      await uploadBytes(storageRef, file);
      return await getDownloadURL(storageRef);
    }
  } catch (error) {
    console.error('Upload error:', error);
    throw error;
  }
}

/**
 * Delete file from storage
 */
export async function deleteFile(path: string): Promise<void> {
  try {
    const storageRef = ref(storage, path);
    await deleteObject(storageRef);
  } catch (error) {
    console.error('Delete error:', error);
    throw error;
  }
}

/**
 * List files in a directory
 */
export async function listFiles(path: string) {
  try {
    const storageRef = ref(storage, path);
    const result = await listAll(storageRef);
    
    const files = await Promise.all(
      result.items.map(async (item) => ({
        name: item.name,
        fullPath: item.fullPath,
        url: await getDownloadURL(item),
      }))
    );
    
    return files;
  } catch (error) {
    console.error('List files error:', error);
    throw error;
  }
}
```

### 3. File Validation Utilities

```typescript
// lib/file-validation.ts

export interface FileValidationOptions {
  maxSizeBytes?: number;
  allowedTypes?: string[];
  allowedExtensions?: string[];
}

export interface ValidationResult {
  valid: boolean;
  error?: string;
}

/**
 * Validate file size
 */
export function validateFileSize(
  file: File,
  maxSizeBytes: number = 10 * 1024 * 1024 // 10MB default
): ValidationResult {
  if (file.size > maxSizeBytes) {
    const sizeMB = (maxSizeBytes / (1024 * 1024)).toFixed(0);
    return {
      valid: false,
      error: `File size must not exceed ${sizeMB}MB`,
    };
  }
  return { valid: true };
}

/**
 * Validate file type (MIME type)
 */
export function validateFileType(
  file: File,
  allowedTypes: string[] = ['image/*', 'application/pdf']
): ValidationResult {
  const fileType = file.type;
  
  const isAllowed = allowedTypes.some(type => {
    if (type.endsWith('/*')) {
      const category = type.split('/')[0];
      return fileType.startsWith(category + '/');
    }
    return fileType === type;
  });
  
  if (!isAllowed) {
    return {
      valid: false,
      error: `File type ${fileType} is not allowed`,
    };
  }
  
  return { valid: true };
}

/**
 * Validate file extension
 */
export function validateFileExtension(
  file: File,
  allowedExtensions: string[] = ['.jpg', '.png', '.pdf']
): ValidationResult {
  const fileName = file.name.toLowerCase();
  const hasAllowedExtension = allowedExtensions.some(ext =>
    fileName.endsWith(ext.toLowerCase())
  );
  
  if (!hasAllowedExtension) {
    return {
      valid: false,
      error: `Only ${allowedExtensions.join(', ')} files are allowed`,
    };
  }
  
  return { valid: true };
}

/**
 * Comprehensive file validation
 */
export function validateFile(
  file: File,
  options: FileValidationOptions = {}
): ValidationResult {
  const {
    maxSizeBytes = 10 * 1024 * 1024,
    allowedTypes = ['image/*', 'application/pdf'],
    allowedExtensions,
  } = options;
  
  // Size validation
  const sizeResult = validateFileSize(file, maxSizeBytes);
  if (!sizeResult.valid) return sizeResult;
  
  // Type validation
  const typeResult = validateFileType(file, allowedTypes);
  if (!typeResult.valid) return typeResult;
  
  // Extension validation (if provided)
  if (allowedExtensions) {
    const extResult = validateFileExtension(file, allowedExtensions);
    if (!extResult.valid) return extResult;
  }
  
  return { valid: true };
}

/**
 * Format file size for display
 */
export function formatFileSize(bytes: number): string {
  if (bytes === 0) return '0 Bytes';
  
  const k = 1024;
  const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  
  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
```

### 4. Drag & Drop Upload

```typescript
// components/DragDropUpload.tsx
'use client';

import { useState, useRef, DragEvent } from 'react';

interface DragDropUploadProps {
  onFilesSelected: (files: File[]) => void;
  accept?: string;
  maxFiles?: number;
  disabled?: boolean;
}

export function DragDropUpload({
  onFilesSelected,
  accept = '*',
  maxFiles = 10,
  disabled = false,
}: DragDropUploadProps) {
  const [isDragging, setIsDragging] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleDrag = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
  };

  const handleDragIn = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
      setIsDragging(true);
    }
  };

  const handleDragOut = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);
  };

  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);

    if (disabled) return;

    const files = Array.from(e.dataTransfer.files);
    if (files.length > 0) {
      onFilesSelected(files.slice(0, maxFiles));
    }
  };

  const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      const files = Array.from(e.target.files);
      onFilesSelected(files.slice(0, maxFiles));
    }
  };

  return (
    <div
      onDragEnter={handleDragIn}
      onDragLeave={handleDragOut}
      onDragOver={handleDrag}
      onDrop={handleDrop}
      onClick={() => fileInputRef.current?.click()}
      className={`
        border-2 border-dashed rounded-lg p-8
        text-center cursor-pointer transition-colors
        ${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}
        ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-blue-400'}
      `}
    >
      <input
        ref={fileInputRef}
        type="file"
        multiple
        accept={accept}
        onChange={handleFileInput}
        disabled={disabled}
        className="hidden"
      />
      
      <div className="space-y-2">
        <svg
          className="mx-auto h-12 w-12 text-gray-400"
          stroke="currentColor"
          fill="none"
          viewBox="0 0 48 48"
        >
          <path
            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"
            strokeWidth={2}
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
        <div className="text-gray-600">
          <span className="font-medium text-blue-600">Click to upload</span>
          {' '}or drag and drop
        </div>
        <p className="text-xs text-gray-500">
          Up to {maxFiles} files
        </p>
      </div>
    </div>
  );
}
```

### 5. Image Preview Component

```typescript
// components/ImagePreview.tsx
'use client';

import { useState } from 'react';
import Image from 'next/image';

interface ImagePreviewProps {
  files: File[];
  onRemove: (index: number) => void;
}

export function ImagePreview({ files, onRemove }: ImagePreviewProps) {
  const [previews, setPreviews] = useState<string[]>([]);

  // Generate previews
  useState(() => {
    files.forEach((file, index) => {
      if (file.type.startsWith('image/')) {
        const reader = new FileReader();
        reader.onloadend = () => {
          setPreviews(prev => {
            const updated = [...prev];
            updated[index] = reader.result as string;
            return updated;
          });
        };
        reader.readAsDataURL(file);
      }
    });
  }, [files]);

  return (
    <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
      {files.map((file, index) => (
        <div key={index} className="relative group">
          {file.type.startsWith('image/') && previews[index] ? (
            <Image
              src={previews[index]}
              alt={file.name}
              width={200}
              height={200}
              className="object-cover rounded-lg"
            />
          ) : (
            <div className="w-full h-32 bg-gray-100 rounded-lg flex items-center justify-center">
              <p className="text-sm text-gray-500">{file.name}</p>
            </div>
          )}
          
          <button
            onClick={() => onRemove(index)}
            className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1
              opacity-0 group-hover:opacity-100 transition-opacity"
          >
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        </div>
      ))}
    </div>
  );
}
```

## Best Practices

### Do's ✅
- **Validate on client and server** side
- **Show progress** for better UX
- **Handle errors gracefully** with user-friendly messages
- **Limit file count and size** to prevent abuse
- **Generate unique filenames** to avoid conflicts
- **Clean up** failed uploads
- **Provide preview** for images
- **Allow deletion** of uploaded files

### Don'ts ❌
- **Don't trust client validation** alone
- **Don't store files** in Firestore documents
- **Don't upload without progress** for large files
- **Don't forget to cleanup** on component unmount
- **Don't allow unlimited** file sizes
- **Don't expose storage paths** directly

## Security Checklist

- [ ] File type validation
- [ ] File size limits enforced
- [ ] Unique filenames generated
- [ ] Storage rules configured
- [ ] Authentication required
- [ ] Path traversal prevented
- [ ] Virus scanning (if needed)

---

**Last Updated:** 2025-01-10  
**Maintained By:** Development Team