Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:25:45 +08:00
commit eba53798b0
11 changed files with 1754 additions and 0 deletions

View 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;
}
*/

View 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 }
);
}
}
*/

View 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
View 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"
}
}