@astrify/react-s3-upload
Version:
React file upload system built for S3-compatible storage with shadcn/ui components
445 lines (349 loc) • 11.8 kB
Markdown
# @astrify/react-s3-upload
A flexible, composable React file upload system built for S3-compatible storage. Features include drag-and-drop, progress tracking, detailed error handling, duplicate detection, and shadcn/ui component integration.
## Features
- 🎯 **S3-Compatible Storage** - Works with AWS S3, DigitalOcean Spaces, Cloudflare R2, MinIO, and more
- 📦 **Batch Upload Processing** - Request signed URLs for multiple files in a single API call
- 🔒 **Duplicate Detection** - SHA-256 hashing for deduplication
- 📊 **Progress Tracking** - Real-time upload progress for each file
- 🎨 **Composable Components** - Mix and match UI components to build custom upload interfaces
- 🚀 **Concurrent Uploads** - Automatic queue management with configurable concurrency
- ♻️ **Error Recovery** - Built-in retry mechanism for failed uploads
- 🎯 **Type-Safe** - Full TypeScript support with comprehensive type definitions
- 🧩 **shadcn/ui Components** - Pre-built components available via shadcn CLI
## Installation
```bash
npm install @astrify/react-s3-upload
# or
pnpm add @astrify/react-s3-upload
# or
yarn add @astrify/react-s3-upload
```
### Peer Dependencies
This package requires React 17 or higher:
```json
{
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
}
```
## Quick Start
### 1. Install UI components from shadcn registry
```bash
# Install upload components
npx shadcn@latest add https://astrify.github.io/react-s3-upload/r/upload.json
### 2. Compose your own interface with individual components
```tsx
import { FileUploadProvider } from '@astrify/react-s3-upload';
import { Dropzone, List, Errors } from '@/components/astrify/upload';
function UploadSection() {
return (
<FileUploadProvider
config={{
signedUrlEndpoint: '/upload/signed-url',
maxFiles: 10,
maxSize: 50 * 1024 * 1024, // 50MB
accept: 'image/*,application/pdf'
}}
>
<div className="space-y-4">
<Dropzone />
<List />
<Errors />
</div>
</FileUploadProvider>
);
}
```
### 3. Use in a form (example)
```tsx
import { useState } from 'react';
import { FileUploadProvider, useFileUpload } from '@astrify/react-s3-upload';
import { Dropzone, List, Errors } from '@/components/astrify/upload';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
// Main form component with the provider
function UploadForm() {
return (
<FileUploadProvider
config={{
signedUrlEndpoint: '/upload/signed-url',
maxFiles: 5,
maxSize: 10 * 1024 * 1024, // 10MB
accept: 'image/*,application/pdf'
}}
>
<FormContent />
</FileUploadProvider>
);
}
function FormContent() {
const { files, hasComplete, hasPending, hasUploading, hasErrors } = useFileUpload();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Extract only completed files for submission
const completedFiles = files.filter(f => f.status === 'complete');
// Get form data
const formData = new FormData(e.target as HTMLFormElement);
// Submit with completed file data
const submission = {
name: formData.get('name'),
files: completedFiles.map(f => ({
id: f.id,
name: f.name,
url: f.url,
sha256: f.sha256
}))
};
console.log('Form submitted:', submission);
// Send to your API here
};
// Enable submit only when all uploads are complete
const canSubmit = hasComplete && !hasPending && !hasUploading && !hasErrors;
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
type="text"
id="name"
name="name"
placeholder="Enter your name"
required
/>
</div>
<div className="space-y-2">
<Label>Attachments</Label>
<div className="space-y-4">
<Errors />
<Dropzone />
<List />
</div>
</div>
<Button
type="submit"
disabled={!canSubmit}
className="w-full sm:w-auto"
>
Submit with {files.filter(f => f.status === 'complete').length} files
</Button>
</form>
);
}
```
## Server Integration
### Request & Response Payloads
The uploader issues a `POST` to your presign endpoint (default `/upload/signed-url`) with JSON shaped like:
```json
{
"files": [
{
"filename": "invoice.pdf",
"filesize": 58211,
"contentType": "application/pdf",
"sha256": "3f0d2f8c8d0d2b36f9b8c5c2f5deda4d3b1c7a6d1e9f5e8c6a7b8c9d0e1f2a3"
}
]
}
```
Reply with a `200` JSON body describing each generated upload target. At minimum, return the matching `sha256` plus the presigned URL details:
```json
{
"files": [
{
"sha256": "3f0d2f8c8d0d2b36f9b8c5c2f5deda4d3b1c7a6d1e9f5e8c6a7b8c9d0e1f2a3",
"bucket": "my-uploads",
"key": "uploads/9d1fcdcb-0f1f-4c82-bfd5-e8a4c5d9e123.pdf",
"url": "https://my-uploads.s3.amazonaws.com/uploads/9d1fcdc...",
"filename": "invoice.pdf"
}
]
}
```
For validation failures return `422 Unprocessable Entity` with an `errors` object (for example `{"errors": {"files.0.filesize": ["File too large"]}}`); the uploader surfaces those messages to the user.
### Laravel Example
The package expects a server endpoint that returns presigned URLs for S3 uploads:
```php
// routes/api.php
Route::post('/upload/signed-url', function (Request $request) {
$validated = $request->validate([
'files' => 'required|array',
'files.*.filename' => 'required|string',
'files.*.content_type' => 'required|string',
'files.*.filesize' => 'required|integer',
'files.*.sha256' => 'required|string',
]);
$responses = [];
foreach ($validated['files'] as $file) {
// Check for duplicates
if (File::where('sha256', $file['sha256'])->exists()) {
return response()->json([
'error' => 'Duplicate file detected'
], 422);
}
// Generate presigned URL
$key = 'uploads/' . Str::uuid() . '.' . $file['extension'];
$url = Storage::disk('s3')->temporaryUploadUrl(
$key,
now()->addMinutes(30),
['ContentType' => $file['content_type']]
);
$responses[] = [
'sha256' => $file['sha256'],
'bucket' => config('filesystems.disks.s3.bucket'),
'key' => $key,
'url' => $url,
'filename' => $file['filename']
];
}
return response()->json(['files' => $responses]);
});
```
### Node.js/Express Example
```javascript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
app.post('/upload/signed-url', async (req, res) => {
const { files } = req.body;
const responses = await Promise.all(files.map(async (file) => {
const key = `uploads/${uuid()}.${file.extension}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: file.content_type,
});
const url = await getSignedUrl(s3Client, command, {
expiresIn: 1800, // 30 minutes
});
return {
sha256: file.sha256,
bucket: process.env.S3_BUCKET,
key,
url,
filename: file.filename
};
}));
res.json({ files: responses });
});
```
## API Reference
### FileUploadProvider
The main context provider that manages upload state and logic.
```tsx
interface FileUploadConfig {
maxFiles?: number; // Maximum number of files (default: 10)
maxSize?: number; // Maximum file size in bytes (default: 50MB)
accept?: string; // Accepted file types (default: '*')
multiple?: boolean; // Allow multiple file selection (default: true)
signedUrlEndpoint?: string; // Endpoint for signed URL generation (default: '/upload/signed-url')
presignHeaders?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>); // Optional headers for presign requests
onUploadComplete?: (files: FileUpload[]) => void;
onUploadError?: (errors: Array<{ file: File; error: any }>) => void;
onFilesChange?: (files: File[]) => void;
}
```
### useFileUpload Hook
Access the upload context and all functionality.
```tsx
const {
// State
files, // Current file collection
errors, // Error messages
isUploading, // Upload in progress
remainingSlots, // Available upload slots
// Actions
addFiles, // Add files to upload
removeFile, // Remove a specific file
removeAll, // Clear all files
retryUpload, // Retry failed upload
reset, // Reset to initial state
// Utilities
canAcceptMore, // Can accept more files
acceptedFileTypes,
maxFileSize
} = useFileUpload();
```
### Types
```tsx
interface FileUpload {
id: string; // SHA-256 hash
name: string; // File name
size: number; // File size in bytes
type: string; // MIME type
sha256: string; // SHA-256 hash
url: string; // Presigned upload URL
status: UploadStatus; // Upload status
progress: number; // Upload progress (0-100)
error?: string; // Error message if failed
preview?: string; // Preview URL for images
}
type UploadStatus = 'pending' | 'uploading' | 'complete' | 'error';
```
## Contributing
We welcome contributions! Please see our [Contributing Guide](#contributing-guide) below for details on development setup and guidelines.
### Development Setup
```bash
# Clone the repository
git clone https://github.com/astrify/react-s3-upload.git
cd react-s3-upload
# Install dependencies
pnpm install
# Start development mode
pnpm dev
```
### Development Tools
- **tsup** - TypeScript bundler for ESM and CJS outputs
- **Vite** - Powers Storybook development
- **Vitest** - Testing framework
- **Biome** - Code formatting and linting
- **Lefthook** - Git hooks for code quality
- **Commitizen** - Standardized commit messages
### Testing
Run tests with:
```bash
pnpm test
```
Tests are located in the `tests/` directory and use Vitest with React Testing Library.
### Building
Build the package with:
```bash
pnpm build
```
This creates ESM and CJS bundles in the `dist/` directory.
### 🖇️ Linking
Often times you want to link this package to another project when developing locally, circumventing the need to publish to NPM to consume it. In a project where you want to consume your package run:
```bash
pnpm link @astrify/react-s3-upload --global
```
Learn more about package linking [here](https://pnpm.io/cli/link).
### Releasing
To create a new release:
```bash
pnpm release
```
This will:
1. Build the package
2. Create a git tag
3. Generate a GitHub release
4. Publish to npm (if configured)
### Contributing Guide
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes
4. Run tests (`pnpm test`)
5. Run linting (`pnpm lint`)
6. Commit your changes (`pnpm commit`)
7. Push to your branch (`git push origin feature/amazing-feature`)
8. Open a Pull Request
## License
[MIT](LICENSE) © [Your Name]
## Support
- [GitHub Issues](https://github.com/astrify/react-s3-upload/issues)
## Acknowledgments
Built with:
- [React](https://react.dev)
- [TypeScript](https://www.typescriptlang.org)
- [shadcn/ui](https://ui.shadcn.com)
- [Tailwind CSS](https://tailwindcss.com)