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