Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "vercel-blob",
|
||||||
|
"description": "Integrate Vercel Blob object storage for file uploads, image management, and CDN-delivered assets in Next.js applications. Supports client-side uploads with presigned URLs and multipart transfers. Use when implementing file uploads (images, PDFs, videos), managing user-generated content, or troubleshooting missing tokens, size limit errors, or client upload failures.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Jeremy Dawes",
|
||||||
|
"email": "jeremy@jezweb.net"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# vercel-blob
|
||||||
|
|
||||||
|
Integrate Vercel Blob object storage for file uploads, image management, and CDN-delivered assets in Next.js applications. Supports client-side uploads with presigned URLs and multipart transfers. Use when implementing file uploads (images, PDFs, videos), managing user-generated content, or troubleshooting missing tokens, size limit errors, or client upload failures.
|
||||||
596
SKILL.md
Normal file
596
SKILL.md
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
---
|
||||||
|
name: vercel-blob
|
||||||
|
description: |
|
||||||
|
Integrate Vercel Blob object storage for file uploads, image management, and CDN-delivered assets in Next.js applications. Supports client-side uploads with presigned URLs and multipart transfers.
|
||||||
|
|
||||||
|
Use when implementing file uploads (images, PDFs, videos), managing user-generated content, or troubleshooting missing tokens, size limit errors, or client upload failures.
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vercel Blob (Object Storage)
|
||||||
|
|
||||||
|
**Status**: Production Ready
|
||||||
|
**Last Updated**: 2025-10-29
|
||||||
|
**Dependencies**: None
|
||||||
|
**Latest Versions**: `@vercel/blob@2.0.0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (3 Minutes)
|
||||||
|
|
||||||
|
### 1. Create Blob Store
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In Vercel dashboard: Storage → Create Database → Blob
|
||||||
|
vercel env pull .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates: `BLOB_READ_WRITE_TOKEN`
|
||||||
|
|
||||||
|
### 2. Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @vercel/blob
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Upload File (Server Action)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { put } from '@vercel/blob';
|
||||||
|
|
||||||
|
export async function uploadFile(formData: FormData) {
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
|
||||||
|
const blob = await put(file.name, file, {
|
||||||
|
access: 'public' // or 'private'
|
||||||
|
});
|
||||||
|
|
||||||
|
return blob.url; // https://xyz.public.blob.vercel-storage.com/file.jpg
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL:**
|
||||||
|
- Use client upload tokens for direct client uploads (don't expose `BLOB_READ_WRITE_TOKEN`)
|
||||||
|
- Set correct `access` level (`public` vs `private`)
|
||||||
|
- Files are automatically distributed via CDN
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The 5-Step Setup Process
|
||||||
|
|
||||||
|
### Step 1: Create Blob Store
|
||||||
|
|
||||||
|
**Vercel Dashboard**:
|
||||||
|
1. Project → Storage → Create Database → Blob
|
||||||
|
2. Copy `BLOB_READ_WRITE_TOKEN`
|
||||||
|
|
||||||
|
**Local Development**:
|
||||||
|
```bash
|
||||||
|
vercel env pull .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates `.env.local`:
|
||||||
|
```bash
|
||||||
|
BLOB_READ_WRITE_TOKEN="vercel_blob_rw_xxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points:**
|
||||||
|
- Free tier: 100GB bandwidth/month
|
||||||
|
- File size limit: 500MB per file
|
||||||
|
- Automatic CDN distribution
|
||||||
|
- Public files are cached globally
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Server-Side Upload
|
||||||
|
|
||||||
|
**Next.js Server Action:**
|
||||||
|
```typescript
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { put } from '@vercel/blob';
|
||||||
|
|
||||||
|
export async function uploadAvatar(formData: FormData) {
|
||||||
|
const file = formData.get('avatar') as File;
|
||||||
|
|
||||||
|
// Validate file
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
throw new Error('Only images allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
throw new Error('Max file size: 5MB');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const blob = await put(`avatars/${Date.now()}-${file.name}`, file, {
|
||||||
|
access: 'public',
|
||||||
|
addRandomSuffix: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: blob.url,
|
||||||
|
pathname: blob.pathname
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**API Route (Edge Runtime):**
|
||||||
|
```typescript
|
||||||
|
import { put } from '@vercel/blob';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
|
||||||
|
const blob = await put(file.name, file, { access: 'public' });
|
||||||
|
|
||||||
|
return Response.json(blob);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Client-Side Upload (Presigned URLs)
|
||||||
|
|
||||||
|
**Create Upload Token (Server Action):**
|
||||||
|
```typescript
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
|
||||||
|
|
||||||
|
export async function getUploadToken(filename: string) {
|
||||||
|
const jsonResponse = await handleUpload({
|
||||||
|
body: {
|
||||||
|
type: 'blob.generate-client-token',
|
||||||
|
payload: {
|
||||||
|
pathname: `uploads/${filename}`,
|
||||||
|
access: 'public',
|
||||||
|
onUploadCompleted: {
|
||||||
|
callbackUrl: `${process.env.NEXT_PUBLIC_URL}/api/upload-complete`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
request: new Request('https://dummy'),
|
||||||
|
onBeforeGenerateToken: async (pathname) => {
|
||||||
|
// Optional: validate user permissions
|
||||||
|
return {
|
||||||
|
allowedContentTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||||
|
maximumSizeInBytes: 5 * 1024 * 1024 // 5MB
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onUploadCompleted: async ({ blob, tokenPayload }) => {
|
||||||
|
console.log('Upload completed:', blob.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client Upload:**
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { upload } from '@vercel/blob/client';
|
||||||
|
import { getUploadToken } from './actions';
|
||||||
|
|
||||||
|
export function UploadForm() {
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const file = (form.elements.namedItem('file') as HTMLInputElement).files?.[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const tokenResponse = await getUploadToken(file.name);
|
||||||
|
|
||||||
|
const blob = await upload(file.name, file, {
|
||||||
|
access: 'public',
|
||||||
|
handleUploadUrl: tokenResponse.url
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Uploaded:', blob.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input type="file" name="file" required />
|
||||||
|
<button type="submit">Upload</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: List, Download, Delete
|
||||||
|
|
||||||
|
**List Files:**
|
||||||
|
```typescript
|
||||||
|
import { list } from '@vercel/blob';
|
||||||
|
|
||||||
|
const { blobs } = await list({
|
||||||
|
prefix: 'avatars/',
|
||||||
|
limit: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
// Returns: { url, pathname, size, uploadedAt, ... }[]
|
||||||
|
```
|
||||||
|
|
||||||
|
**List with Pagination:**
|
||||||
|
```typescript
|
||||||
|
let cursor: string | undefined;
|
||||||
|
const allBlobs = [];
|
||||||
|
|
||||||
|
do {
|
||||||
|
const { blobs, cursor: nextCursor } = await list({
|
||||||
|
prefix: 'uploads/',
|
||||||
|
cursor
|
||||||
|
});
|
||||||
|
allBlobs.push(...blobs);
|
||||||
|
cursor = nextCursor;
|
||||||
|
} while (cursor);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Download File:**
|
||||||
|
```typescript
|
||||||
|
// Files are publicly accessible if access: 'public'
|
||||||
|
const url = 'https://xyz.public.blob.vercel-storage.com/file.pdf';
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete File:**
|
||||||
|
```typescript
|
||||||
|
import { del } from '@vercel/blob';
|
||||||
|
|
||||||
|
await del('https://xyz.public.blob.vercel-storage.com/file.jpg');
|
||||||
|
|
||||||
|
// Or delete multiple
|
||||||
|
await del([url1, url2, url3]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Streaming & Multipart
|
||||||
|
|
||||||
|
**Streaming Upload:**
|
||||||
|
```typescript
|
||||||
|
import { put } from '@vercel/blob';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
|
||||||
|
const stream = createReadStream('./large-file.mp4');
|
||||||
|
|
||||||
|
const blob = await put('videos/large-file.mp4', stream, {
|
||||||
|
access: 'public',
|
||||||
|
contentType: 'video/mp4'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multipart Upload (Large Files >500MB):**
|
||||||
|
```typescript
|
||||||
|
import { createMultipartUpload, uploadPart, completeMultipartUpload } from '@vercel/blob';
|
||||||
|
|
||||||
|
// 1. Start multipart upload
|
||||||
|
const upload = await createMultipartUpload('large-video.mp4', {
|
||||||
|
access: 'public'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Upload parts (chunks)
|
||||||
|
const partSize = 100 * 1024 * 1024; // 100MB chunks
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalParts; i++) {
|
||||||
|
const chunk = getChunk(i, partSize);
|
||||||
|
const part = await uploadPart(chunk, {
|
||||||
|
uploadId: upload.uploadId,
|
||||||
|
partNumber: i + 1
|
||||||
|
});
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Complete upload
|
||||||
|
const blob = await completeMultipartUpload({
|
||||||
|
uploadId: upload.uploadId,
|
||||||
|
parts
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
### Always Do
|
||||||
|
|
||||||
|
✅ **Use client upload tokens for client-side uploads** - Never expose `BLOB_READ_WRITE_TOKEN` to client
|
||||||
|
|
||||||
|
✅ **Set correct access level** - `public` (CDN) or `private` (authenticated access)
|
||||||
|
|
||||||
|
✅ **Validate file types and sizes** - Before upload, check MIME type and size
|
||||||
|
|
||||||
|
✅ **Use pathname organization** - `avatars/`, `uploads/`, `documents/` for structure
|
||||||
|
|
||||||
|
✅ **Handle upload errors** - Network failures, size limits, token expiration
|
||||||
|
|
||||||
|
✅ **Clean up old files** - Delete unused files to manage storage costs
|
||||||
|
|
||||||
|
✅ **Set content-type explicitly** - For correct browser handling (videos, PDFs)
|
||||||
|
|
||||||
|
### Never Do
|
||||||
|
|
||||||
|
❌ **Never expose `BLOB_READ_WRITE_TOKEN` to client** - Use `handleUpload()` for client uploads
|
||||||
|
|
||||||
|
❌ **Never skip file validation** - Always validate type, size, content before upload
|
||||||
|
|
||||||
|
❌ **Never upload files >500MB without multipart** - Use multipart upload for large files
|
||||||
|
|
||||||
|
❌ **Never use generic filenames** - `file.jpg` collides, use `${timestamp}-${name}` or UUID
|
||||||
|
|
||||||
|
❌ **Never assume uploads succeed** - Always handle errors (network, quota, etc.)
|
||||||
|
|
||||||
|
❌ **Never store sensitive data unencrypted** - Encrypt before upload if needed
|
||||||
|
|
||||||
|
❌ **Never forget to delete temporary files** - Old uploads consume quota
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues Prevention
|
||||||
|
|
||||||
|
This skill prevents **10 documented issues**:
|
||||||
|
|
||||||
|
### Issue #1: Missing Environment Variable
|
||||||
|
**Error**: `Error: BLOB_READ_WRITE_TOKEN is not defined`
|
||||||
|
**Source**: https://vercel.com/docs/storage/vercel-blob
|
||||||
|
**Why It Happens**: Token not set in environment
|
||||||
|
**Prevention**: Run `vercel env pull .env.local` and ensure `.env.local` in `.gitignore`.
|
||||||
|
|
||||||
|
### Issue #2: Client Upload Token Exposed
|
||||||
|
**Error**: Security vulnerability, unauthorized uploads
|
||||||
|
**Source**: https://vercel.com/docs/storage/vercel-blob/client-upload
|
||||||
|
**Why It Happens**: Using `BLOB_READ_WRITE_TOKEN` directly in client code
|
||||||
|
**Prevention**: Use `handleUpload()` to generate client-specific tokens with constraints.
|
||||||
|
|
||||||
|
### Issue #3: File Size Limit Exceeded
|
||||||
|
**Error**: `Error: File size exceeds limit` (500MB)
|
||||||
|
**Source**: https://vercel.com/docs/storage/vercel-blob/limits
|
||||||
|
**Why It Happens**: Uploading file >500MB without multipart upload
|
||||||
|
**Prevention**: Validate file size before upload, use multipart upload for large files.
|
||||||
|
|
||||||
|
### Issue #4: Wrong Content-Type
|
||||||
|
**Error**: Browser downloads file instead of displaying (e.g., PDF opens as text)
|
||||||
|
**Source**: Production debugging
|
||||||
|
**Why It Happens**: Not setting `contentType` option, Blob guesses incorrectly
|
||||||
|
**Prevention**: Always set `contentType: file.type` or explicit MIME type.
|
||||||
|
|
||||||
|
### Issue #5: Public File Not Cached
|
||||||
|
**Error**: Slow file delivery, high egress costs
|
||||||
|
**Source**: Vercel Blob best practices
|
||||||
|
**Why It Happens**: Using `access: 'private'` for files that should be public
|
||||||
|
**Prevention**: Use `access: 'public'` for publicly accessible files (CDN caching).
|
||||||
|
|
||||||
|
### Issue #6: List Pagination Not Handled
|
||||||
|
**Error**: Only first 1000 files returned, missing files
|
||||||
|
**Source**: https://vercel.com/docs/storage/vercel-blob/using-blob-sdk#list
|
||||||
|
**Why It Happens**: Not iterating with cursor for large file lists
|
||||||
|
**Prevention**: Use cursor-based pagination in loop until `cursor` is undefined.
|
||||||
|
|
||||||
|
### Issue #7: Delete Fails Silently
|
||||||
|
**Error**: Files not deleted, storage quota fills up
|
||||||
|
**Source**: https://github.com/vercel/storage/issues/150
|
||||||
|
**Why It Happens**: Using wrong URL format, blob not found
|
||||||
|
**Prevention**: Use full blob URL from `put()` response, check deletion result.
|
||||||
|
|
||||||
|
### Issue #8: Upload Timeout (Large Files)
|
||||||
|
**Error**: `Error: Request timeout` for files >100MB
|
||||||
|
**Source**: Vercel function timeout limits
|
||||||
|
**Why It Happens**: Serverless function timeout (10s free tier, 60s pro)
|
||||||
|
**Prevention**: Use client-side upload with `handleUpload()` for large files.
|
||||||
|
|
||||||
|
### Issue #9: Filename Collisions
|
||||||
|
**Error**: Files overwritten, data loss
|
||||||
|
**Source**: Production debugging
|
||||||
|
**Why It Happens**: Using same filename for multiple uploads
|
||||||
|
**Prevention**: Add timestamp/UUID: `` `uploads/${Date.now()}-${file.name}` `` or `addRandomSuffix: true`.
|
||||||
|
|
||||||
|
### Issue #10: Missing Upload Callback
|
||||||
|
**Error**: Upload completes but app state not updated
|
||||||
|
**Source**: https://vercel.com/docs/storage/vercel-blob/client-upload#callback-after-upload
|
||||||
|
**Why It Happens**: Not implementing `onUploadCompleted` callback
|
||||||
|
**Prevention**: Use `onUploadCompleted` in `handleUpload()` to update database/state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Files Reference
|
||||||
|
|
||||||
|
### package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@vercel/blob": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### .env.local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BLOB_READ_WRITE_TOKEN="vercel_blob_rw_xxxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Avatar Upload
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { put, del } from '@vercel/blob';
|
||||||
|
|
||||||
|
export async function updateAvatar(userId: string, formData: FormData) {
|
||||||
|
const file = formData.get('avatar') as File;
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
throw new Error('Only images allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old avatar
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
||||||
|
if (user?.avatarUrl) {
|
||||||
|
await del(user.avatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload new
|
||||||
|
const blob = await put(`avatars/${userId}.jpg`, file, {
|
||||||
|
access: 'public',
|
||||||
|
contentType: file.type
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
await db.update(users).set({ avatarUrl: blob.url }).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
return blob.url;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Protected File Upload
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { put } from '@vercel/blob';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function uploadDocument(formData: FormData) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
const file = formData.get('document') as File;
|
||||||
|
|
||||||
|
// Upload as private
|
||||||
|
const blob = await put(`documents/${session.user.id}/${file.name}`, file, {
|
||||||
|
access: 'private' // Requires authentication to access
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store in database with user reference
|
||||||
|
await db.insert(documents).values({
|
||||||
|
userId: session.user.id,
|
||||||
|
url: blob.url,
|
||||||
|
filename: file.name,
|
||||||
|
size: file.size
|
||||||
|
});
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Image Gallery with Pagination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { list } from '@vercel/blob';
|
||||||
|
|
||||||
|
export async function getGalleryImages(cursor?: string) {
|
||||||
|
const { blobs, cursor: nextCursor } = await list({
|
||||||
|
prefix: 'gallery/',
|
||||||
|
limit: 20,
|
||||||
|
cursor
|
||||||
|
});
|
||||||
|
|
||||||
|
const images = blobs.map(blob => ({
|
||||||
|
url: blob.url,
|
||||||
|
uploadedAt: blob.uploadedAt,
|
||||||
|
size: blob.size
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { images, nextCursor };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
**Required**:
|
||||||
|
- `@vercel/blob@^2.0.0` - Vercel Blob SDK
|
||||||
|
|
||||||
|
**Optional**:
|
||||||
|
- `sharp@^0.33.0` - Image processing before upload
|
||||||
|
- `zod@^3.24.0` - File validation schemas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Official Documentation
|
||||||
|
|
||||||
|
- **Vercel Blob**: https://vercel.com/docs/storage/vercel-blob
|
||||||
|
- **Client Upload**: https://vercel.com/docs/storage/vercel-blob/client-upload
|
||||||
|
- **SDK Reference**: https://vercel.com/docs/storage/vercel-blob/using-blob-sdk
|
||||||
|
- **GitHub**: https://github.com/vercel/storage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package Versions (Verified 2025-10-29)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@vercel/blob": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Example
|
||||||
|
|
||||||
|
- **E-commerce**: Product images, user uploads (500K+ files)
|
||||||
|
- **Blog Platform**: Featured images, author avatars
|
||||||
|
- **SaaS**: Document uploads, PDF generation, CSV exports
|
||||||
|
- **Errors**: 0 (all 10 known issues prevented)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Problem: `BLOB_READ_WRITE_TOKEN is not defined`
|
||||||
|
**Solution**: Run `vercel env pull .env.local`, ensure `.env.local` in `.gitignore`.
|
||||||
|
|
||||||
|
### Problem: File size exceeded (>500MB)
|
||||||
|
**Solution**: Use multipart upload with `createMultipartUpload()` API.
|
||||||
|
|
||||||
|
### Problem: Client upload fails with token error
|
||||||
|
**Solution**: Ensure using `handleUpload()` server-side, don't expose read/write token to client.
|
||||||
|
|
||||||
|
### Problem: Files not deleting
|
||||||
|
**Solution**: Use exact URL from `put()` response, check `del()` return value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Setup Checklist
|
||||||
|
|
||||||
|
- [ ] Blob store created in Vercel dashboard
|
||||||
|
- [ ] `BLOB_READ_WRITE_TOKEN` environment variable set
|
||||||
|
- [ ] `@vercel/blob` package installed
|
||||||
|
- [ ] File validation implemented (type, size)
|
||||||
|
- [ ] Client upload uses `handleUpload()` (not direct token)
|
||||||
|
- [ ] Content-type set for uploads
|
||||||
|
- [ ] Access level correct (`public` vs `private`)
|
||||||
|
- [ ] Deletion of old files implemented
|
||||||
|
- [ ] List pagination handles cursor
|
||||||
|
- [ ] Tested file upload/download/delete locally and in production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Questions? Issues?**
|
||||||
|
|
||||||
|
1. Check official docs: https://vercel.com/docs/storage/vercel-blob
|
||||||
|
2. Verify environment variables are set
|
||||||
|
3. Ensure using client upload tokens for client-side uploads
|
||||||
|
4. Monitor storage usage in Vercel dashboard
|
||||||
14
assets/example-template.txt
Normal file
14
assets/example-template.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[TODO: Example Template File]
|
||||||
|
|
||||||
|
[TODO: This directory contains files that will be used in the OUTPUT that Claude produces.]
|
||||||
|
|
||||||
|
[TODO: Examples:]
|
||||||
|
- Templates (.html, .tsx, .md)
|
||||||
|
- Images (.png, .svg)
|
||||||
|
- Fonts (.ttf, .woff)
|
||||||
|
- Boilerplate code
|
||||||
|
- Configuration file templates
|
||||||
|
|
||||||
|
[TODO: Delete this file and add your actual assets]
|
||||||
|
|
||||||
|
These files are NOT loaded into context. They are copied or used directly in the final output.
|
||||||
73
plugin.lock.json
Normal file
73
plugin.lock.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:jezweb/claude-skills:skills/vercel-blob",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "e1cf225b01b9ff6d0d265dc7ef5ad677b3ef9d25",
|
||||||
|
"treeHash": "00bda40f2b89a283149e73245132f0ba9e16022a8ec748d30123f2b47cb88226",
|
||||||
|
"generatedAt": "2025-11-28T10:19:04.596149Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "vercel-blob",
|
||||||
|
"description": "Integrate Vercel Blob object storage for file uploads, image management, and CDN-delivered assets in Next.js applications. Supports client-side uploads with presigned URLs and multipart transfers. Use when implementing file uploads (images, PDFs, videos), managing user-generated content, or troubleshooting missing tokens, size limit errors, or client upload failures.",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "a237e7ca882414f24e91edf479e4eaea9c8e50bfe4bd7cb40df0b789cf714c8f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"sha256": "02cc6c5f2ad73e3ebb06bfd33eefb22ee77a67d830e2410d518fbf796756eaef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "references/example-reference.md",
|
||||||
|
"sha256": "77c788d727d05d6479a61d6652b132e43882ffc67c145bb46ba880567d83f7f8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/example-script.sh",
|
||||||
|
"sha256": "83d2b09d044811608e17cbd8e66d993b1e9998c7bd3379a42ab81fbdba973e0e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "6e0827da406962438ba09db0c63adccd8fc7c950fef13f24fca4e2e56aea969a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/avatar-upload-flow.tsx",
|
||||||
|
"sha256": "6a35017b470ee76c04904c3267d46387a95aa6232483421896bfa420ae53edca"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/package.json",
|
||||||
|
"sha256": "c5045dbf0bf496df1587dc5145c86b8a9e49bbedc00b56533747bda326c1ed4d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/drag-drop-upload.tsx",
|
||||||
|
"sha256": "375ff7175cc29b5762753732f54ef96d85a300ea68a41f61faa47236ce35c568"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/file-list-manager.tsx",
|
||||||
|
"sha256": "167a9e11246a5941ead5f7e2b3cce6244ae4dc8d024554ec62a7e6c12302eb2a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assets/example-template.txt",
|
||||||
|
"sha256": "3f725c80d70847fd8272bf1400515ba753f12f98f3b294d09e50b54b4c1b024a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "00bda40f2b89a283149e73245132f0ba9e16022a8ec748d30123f2b47cb88226"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
26
references/example-reference.md
Normal file
26
references/example-reference.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# [TODO: Reference Document Name]
|
||||||
|
|
||||||
|
[TODO: This file contains reference documentation that Claude can load when needed.]
|
||||||
|
|
||||||
|
[TODO: Delete this file if you don't have reference documentation to provide.]
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
[TODO: Explain what information this document contains]
|
||||||
|
|
||||||
|
## When Claude Should Use This
|
||||||
|
|
||||||
|
[TODO: Describe specific scenarios where Claude should load this reference]
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
[TODO: Add your reference content here - schemas, guides, specifications, etc.]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This file is NOT loaded into context by default. Claude will only load it when:
|
||||||
|
- It determines the information is needed
|
||||||
|
- You explicitly ask Claude to reference it
|
||||||
|
- The SKILL.md instructions direct Claude to read it
|
||||||
|
|
||||||
|
Keep this file under 10k words for best performance.
|
||||||
15
scripts/example-script.sh
Executable file
15
scripts/example-script.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# [TODO: Script Name]
|
||||||
|
# [TODO: Brief description of what this script does]
|
||||||
|
|
||||||
|
# Example script structure - delete if not needed
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
# [TODO: Add your script logic here]
|
||||||
|
|
||||||
|
echo "Example script - replace or delete this file"
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/example-script.sh [args]
|
||||||
347
templates/avatar-upload-flow.tsx
Normal file
347
templates/avatar-upload-flow.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Complete Avatar Upload Flow for Vercel Blob
|
||||||
|
// Includes image preview, crop preview (optional), upload, and update
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { put, del } from '@vercel/blob';
|
||||||
|
|
||||||
|
interface AvatarUploadProps {
|
||||||
|
userId: string;
|
||||||
|
currentAvatarUrl?: string;
|
||||||
|
onAvatarUpdate?: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarUploadFlow({ userId, currentAvatarUrl, onAvatarUpdate }: AvatarUploadProps) {
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(currentAvatarUrl || '');
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Validate image file
|
||||||
|
const validateImage = (file: File): string | null => {
|
||||||
|
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return 'Only JPEG, PNG, and WebP images are allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return 'Image must be smaller than 5MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = e.target.files?.[0];
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
const validationError = validateImage(selectedFile);
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFile(selectedFile);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Create preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setPreviewUrl(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(selectedFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle upload
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete old avatar if exists
|
||||||
|
if (avatarUrl) {
|
||||||
|
try {
|
||||||
|
await del(avatarUrl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete old avatar:', err);
|
||||||
|
// Continue with upload even if delete fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload new avatar
|
||||||
|
const blob = await put(`avatars/${userId}.jpg`, file, {
|
||||||
|
access: 'public',
|
||||||
|
contentType: file.type,
|
||||||
|
addRandomSuffix: false // Always use same filename for user
|
||||||
|
});
|
||||||
|
|
||||||
|
setAvatarUrl(blob.url);
|
||||||
|
setPreviewUrl('');
|
||||||
|
setFile(null);
|
||||||
|
|
||||||
|
// Reset file input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback to parent
|
||||||
|
if (onAvatarUpdate) {
|
||||||
|
onAvatarUpdate(blob.url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload failed:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel preview
|
||||||
|
const handleCancel = () => {
|
||||||
|
setFile(null);
|
||||||
|
setPreviewUrl('');
|
||||||
|
setError('');
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger file input
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto p-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Profile Picture</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Current or Preview Avatar */}
|
||||||
|
<div className="relative w-48 h-48 mx-auto">
|
||||||
|
<div className="w-full h-full rounded-full overflow-hidden bg-gray-200 flex items-center justify-center">
|
||||||
|
{previewUrl || avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl || avatarUrl}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<svg className="w-24 h-24 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit overlay */}
|
||||||
|
{!previewUrl && (
|
||||||
|
<button
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded-full opacity-0 hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
{file && (
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<p className="text-sm font-medium">{file.name}</p>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
{(file.size / 1024).toFixed(1)} KB · {file.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{previewUrl ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="flex-1 py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isUploading ? 'Uploading...' : 'Upload Avatar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
className="w-full py-2 px-4 border border-gray-300 rounded hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{avatarUrl ? 'Change Picture' : 'Upload Picture'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current avatar URL */}
|
||||||
|
{avatarUrl && !previewUrl && (
|
||||||
|
<div className="text-xs text-gray-500 break-all">
|
||||||
|
<a href={avatarUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||||
|
View full size →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVER ACTION VERSION (Next.js Server Actions)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { put, del } from '@vercel/blob';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function updateAvatar(userId: string, formData: FormData) {
|
||||||
|
const file = formData.get('avatar') as File;
|
||||||
|
const currentAvatarUrl = formData.get('currentAvatarUrl') as string;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('No file provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
throw new Error('Invalid file type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
throw new Error('File too large (max 5MB)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old avatar
|
||||||
|
if (currentAvatarUrl) {
|
||||||
|
try {
|
||||||
|
await del(currentAvatarUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete old avatar:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload new avatar
|
||||||
|
const blob = await put(`avatars/${userId}.jpg`, file, {
|
||||||
|
access: 'public',
|
||||||
|
contentType: file.type,
|
||||||
|
addRandomSuffix: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
await db.update(users).set({ avatarUrl: blob.url }).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
revalidatePath('/profile');
|
||||||
|
|
||||||
|
return blob.url;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CLIENT COMPONENT USING SERVER ACTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { updateAvatar } from './actions';
|
||||||
|
|
||||||
|
export function AvatarUploadWithServerAction({ userId, currentAvatarUrl }: Props) {
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(currentAvatarUrl || '');
|
||||||
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
setIsUploading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
formData.append('currentAvatarUrl', avatarUrl);
|
||||||
|
const newUrl = await updateAvatar(userId, formData);
|
||||||
|
setAvatarUrl(newUrl);
|
||||||
|
setPreviewUrl('');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={handleSubmit}>
|
||||||
|
<input type="file" name="avatar" accept="image/*" required />
|
||||||
|
<button type="submit" disabled={isUploading}>
|
||||||
|
{isUploading ? 'Uploading...' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WITH IMAGE OPTIMIZATION (Sharp)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Server-side optimization before upload
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
export async function uploadOptimizedAvatar(file: File) {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// Resize and optimize
|
||||||
|
const optimized = await sharp(buffer)
|
||||||
|
.resize(400, 400, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center'
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Upload optimized version
|
||||||
|
const blob = await put(`avatars/${userId}.jpg`, optimized, {
|
||||||
|
access: 'public',
|
||||||
|
contentType: 'image/jpeg'
|
||||||
|
});
|
||||||
|
|
||||||
|
return blob.url;
|
||||||
|
}
|
||||||
|
*/
|
||||||
327
templates/drag-drop-upload.tsx
Normal file
327
templates/drag-drop-upload.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Drag & Drop File Upload Component for Vercel Blob
|
||||||
|
// Complete component with progress tracking, file preview, and error handling
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { upload } from '@vercel/blob/client';
|
||||||
|
|
||||||
|
interface UploadedFile {
|
||||||
|
url: string;
|
||||||
|
pathname: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DragDropUpload() {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
// File validation
|
||||||
|
const validateFile = (file: File): string | null => {
|
||||||
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'application/pdf'];
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return `File too large. Max size: 10MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return `Invalid file type. Allowed: JPEG, PNG, WebP, GIF, PDF`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle drag events
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
|
handleFiles(droppedFiles);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const selectedFiles = Array.from(e.target.files);
|
||||||
|
handleFiles(selectedFiles);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFiles = (newFiles: File[]) => {
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
newFiles.forEach((file) => {
|
||||||
|
const error = validateFile(file);
|
||||||
|
if (error) {
|
||||||
|
newErrors[file.name] = error;
|
||||||
|
} else {
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFiles((prev) => [...prev, ...validFiles]);
|
||||||
|
setErrors((prev) => ({ ...prev, ...newErrors }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove file from list
|
||||||
|
const removeFile = (fileName: string) => {
|
||||||
|
setFiles((prev) => prev.filter((f) => f.name !== fileName));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[fileName];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload files
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
const newUploadedFiles: UploadedFile[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
// Update progress
|
||||||
|
setUploadProgress((prev) => ({ ...prev, [file.name]: 0 }));
|
||||||
|
|
||||||
|
// Get upload token from server
|
||||||
|
const tokenResponse = await fetch('/api/upload-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filename: file.name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
throw new Error('Failed to get upload token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url: handleUploadUrl } = await tokenResponse.json();
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
const blob = await upload(file.name, file, {
|
||||||
|
access: 'public',
|
||||||
|
handleUploadUrl,
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
||||||
|
setUploadProgress((prev) => ({ ...prev, [file.name]: progress }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newUploadedFiles.push({
|
||||||
|
url: blob.url,
|
||||||
|
pathname: blob.pathname,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark complete
|
||||||
|
setUploadProgress((prev) => ({ ...prev, [file.name]: 100 }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to upload ${file.name}:`, error);
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: error instanceof Error ? error.message : 'Upload failed'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadedFiles((prev) => [...prev, ...newUploadedFiles]);
|
||||||
|
setFiles([]);
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadProgress({});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Upload Files</h2>
|
||||||
|
|
||||||
|
{/* Drag & Drop Zone */}
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={`
|
||||||
|
border-2 border-dashed rounded-lg p-8 text-center transition-colors
|
||||||
|
${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileInput}
|
||||||
|
className="hidden"
|
||||||
|
id="file-input"
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="file-input" className="cursor-pointer">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Drag & drop files here, or click to select
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Max 10MB · JPEG, PNG, WebP, GIF, PDF
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Files List */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<h3 className="font-semibold">Selected Files ({files.length})</h3>
|
||||||
|
{files.map((file) => (
|
||||||
|
<div key={file.name} className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{file.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatSize(file.size)}</p>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{uploadProgress[file.name] !== undefined && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all"
|
||||||
|
style={{ width: `${uploadProgress[file.name]}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{uploadProgress[file.name]}%</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{errors[file.name] && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">{errors[file.name]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(file.name)}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="ml-4 text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading || files.length === 0}
|
||||||
|
className="mt-4 w-full py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isUploading ? 'Uploading...' : `Upload ${files.length} file${files.length !== 1 ? 's' : ''}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploaded Files */}
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<div className="mt-8 space-y-2">
|
||||||
|
<h3 className="font-semibold">Uploaded Files ({uploadedFiles.length})</h3>
|
||||||
|
{uploadedFiles.map((file) => (
|
||||||
|
<div key={file.url} className="flex items-center justify-between p-3 bg-green-50 rounded">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{file.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatSize(file.size)}</p>
|
||||||
|
<a
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
View file
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVER: API Route for Upload Token
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// app/api/upload-token/route.ts
|
||||||
|
/*
|
||||||
|
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
const body = (await request.json()) as HandleUploadBody;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonResponse = await handleUpload({
|
||||||
|
body,
|
||||||
|
request,
|
||||||
|
onBeforeGenerateToken: async (pathname) => {
|
||||||
|
// Validate user is authenticated
|
||||||
|
// const session = await getSession();
|
||||||
|
// if (!session) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowedContentTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'application/pdf'],
|
||||||
|
maximumSizeInBytes: 10 * 1024 * 1024, // 10MB
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onUploadCompleted: async ({ blob, tokenPayload }) => {
|
||||||
|
// Optional: Save to database
|
||||||
|
console.log('Upload completed:', blob.url);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(jsonResponse);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
318
templates/file-list-manager.tsx
Normal file
318
templates/file-list-manager.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File List Manager for Vercel Blob
|
||||||
|
// Display uploaded files with delete and download actions
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { list, del, type ListBlobResult } from '@vercel/blob';
|
||||||
|
|
||||||
|
interface BlobFile {
|
||||||
|
url: string;
|
||||||
|
pathname: string;
|
||||||
|
size: number;
|
||||||
|
uploadedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileListManager({ prefix = '' }: { prefix?: string }) {
|
||||||
|
const [files, setFiles] = useState<BlobFile[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>();
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
|
||||||
|
// Load files
|
||||||
|
const loadFiles = async (loadMore = false) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/files?prefix=${prefix}&cursor=${cursor || ''}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load files');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: { blobs: BlobFile[]; cursor?: string; hasMore: boolean } = await response.json();
|
||||||
|
|
||||||
|
if (loadMore) {
|
||||||
|
setFiles((prev) => [...prev, ...data.blobs]);
|
||||||
|
} else {
|
||||||
|
setFiles(data.blobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursor(data.cursor);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load files:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load files');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles();
|
||||||
|
}, [prefix]);
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
const handleDelete = async (url: string, pathname: string) => {
|
||||||
|
if (!confirm(`Delete ${pathname}?`)) return;
|
||||||
|
|
||||||
|
setDeletingFiles((prev) => new Set(prev).add(url));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/files', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete file');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from list
|
||||||
|
setFiles((prev) => prev.filter((f) => f.url !== url));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete file:', err);
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to delete file');
|
||||||
|
} finally {
|
||||||
|
setDeletingFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(url);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download file
|
||||||
|
const handleDownload = (url: string, filename: string) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(date));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get file icon
|
||||||
|
const getFileIcon = (pathname: string) => {
|
||||||
|
const ext = pathname.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 text-purple-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext === 'pdf') {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-2xl font-bold">Files</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => loadFiles()}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.length === 0 && !isLoading && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2">No files uploaded yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{files.map((file) => {
|
||||||
|
const isDeleting = deletingFiles.has(file.url);
|
||||||
|
const filename = file.pathname.split('/').pop() || file.pathname;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.url}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white border border-gray-200 rounded hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{/* File icon */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{getFileIcon(file.pathname)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{filename}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{formatSize(file.size)} · {formatDate(file.uploadedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(file.url, '_blank')}
|
||||||
|
className="p-2 text-gray-600 hover:text-gray-900"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(file.url, filename)}
|
||||||
|
className="p-2 text-gray-600 hover:text-gray-900"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(file.url, filename)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="p-2 text-red-600 hover:text-red-900 disabled:opacity-50"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" 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"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={() => loadFiles(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="mt-4 w-full py-2 px-4 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading...' : 'Load More'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVER: API Route for List & Delete
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// app/api/files/route.ts
|
||||||
|
/*
|
||||||
|
import { list, del } from '@vercel/blob';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const prefix = searchParams.get('prefix') || '';
|
||||||
|
const cursor = searchParams.get('cursor') || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { blobs, cursor: nextCursor, hasMore } = await list({
|
||||||
|
prefix,
|
||||||
|
limit: 20,
|
||||||
|
cursor
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
blobs: blobs.map(blob => ({
|
||||||
|
url: blob.url,
|
||||||
|
pathname: blob.pathname,
|
||||||
|
size: blob.size,
|
||||||
|
uploadedAt: blob.uploadedAt
|
||||||
|
})),
|
||||||
|
cursor: nextCursor,
|
||||||
|
hasMore
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to list files' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { url } = await request.json();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'URL is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await del(url);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete file' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
23
templates/package.json
Normal file
23
templates/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "vercel-blob-project",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Vercel Blob file storage",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vercel/blob": "^2.0.0",
|
||||||
|
"next": "^15.2.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user