united-tattoo/lib/r2-upload.ts
Nicholai 16cee69250 __Admin dashboard scaffolded with D1 database and R2 file uploads__
This commit implements the core admin dashboard functionality including NextAuth authentication, Cloudflare D1 database integration with complete schema, and Cloudflare R2 file upload system for portfolio images. Features include artist management, appointment scheduling, and data migration capabilities.
2025-09-17 16:08:34 -06:00

361 lines
8.3 KiB
TypeScript

import { getR2Bucket } from '@/lib/db'
export interface R2UploadResponse {
success: boolean
url?: string
key?: string
error?: string
}
export interface BulkUploadResult {
successful: FileUploadResult[]
failed: { filename: string; error: string }[]
total: number
}
export interface FileUploadResult {
filename: string
url: string
key: string
size: number
mimeType: string
}
export interface FileUploadProgress {
id: string
filename: string
progress: number
status: 'uploading' | 'processing' | 'complete' | 'error'
url?: string
error?: string
}
/**
* File Upload Manager for Cloudflare R2
*/
export class FileUploadManager {
private bucket: R2Bucket
private baseUrl: string
constructor(env?: any) {
this.bucket = getR2Bucket(env)
// R2 public URL format: https://<account-id>.r2.cloudflarestorage.com/<bucket-name>
this.baseUrl = process.env.R2_PUBLIC_URL || ''
}
/**
* Upload a single file to R2
*/
async uploadFile(
file: File | Buffer,
key: string,
options?: {
contentType?: string
metadata?: Record<string, string>
}
): Promise<R2UploadResponse> {
try {
const fileBuffer = file instanceof File ? await file.arrayBuffer() : file.buffer as ArrayBuffer
const contentType = options?.contentType || (file instanceof File ? file.type : 'application/octet-stream')
// Upload to R2
await this.bucket.put(key, fileBuffer, {
httpMetadata: {
contentType,
},
customMetadata: options?.metadata || {},
})
const url = `${this.baseUrl}/${key}`
return {
success: true,
url,
key,
}
} catch (error) {
console.error('R2 upload error:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Upload failed',
}
}
}
/**
* Upload multiple files to R2
*/
async bulkUpload(
files: File[],
keyPrefix: string = 'uploads'
): Promise<BulkUploadResult> {
const successful: FileUploadResult[] = []
const failed: { filename: string; error: string }[] = []
for (const file of files) {
try {
const key = `${keyPrefix}/${Date.now()}-${file.name}`
const result = await this.uploadFile(file, key, {
contentType: file.type,
metadata: {
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
})
if (result.success && result.url && result.key) {
successful.push({
filename: file.name,
url: result.url,
key: result.key,
size: file.size,
mimeType: file.type,
})
} else {
failed.push({
filename: file.name,
error: result.error || 'Upload failed',
})
}
} catch (error) {
failed.push({
filename: file.name,
error: error instanceof Error ? error.message : 'Upload failed',
})
}
}
return {
successful,
failed,
total: files.length,
}
}
/**
* Delete a file from R2
*/
async deleteFile(key: string): Promise<boolean> {
try {
await this.bucket.delete(key)
return true
} catch (error) {
console.error('R2 delete error:', error)
return false
}
}
/**
* Get file metadata from R2
*/
async getFileMetadata(key: string): Promise<R2Object | null> {
try {
return await this.bucket.get(key)
} catch (error) {
console.error('R2 metadata error:', error)
return null
}
}
/**
* Generate a presigned URL for direct upload
*/
async generatePresignedUrl(
key: string,
expiresIn: number = 3600
): Promise<string | null> {
try {
// Note: R2 presigned URLs require additional setup
// For now, we'll use direct upload through our API
return null
} catch (error) {
console.error('Presigned URL error:', error)
return null
}
}
/**
* Validate file before upload
*/
validateFile(file: File, options?: {
maxSize?: number
allowedTypes?: string[]
}): { valid: boolean; error?: string } {
const maxSize = options?.maxSize || 10 * 1024 * 1024 // 10MB default
const allowedTypes = options?.allowedTypes || [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
]
if (file.size > maxSize) {
return {
valid: false,
error: `File size exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`,
}
}
if (!allowedTypes.includes(file.type)) {
return {
valid: false,
error: `File type ${file.type} not allowed`,
}
}
return { valid: true }
}
/**
* Generate a unique key for file upload
*/
generateFileKey(filename: string, prefix: string = 'uploads'): string {
const timestamp = Date.now()
const randomString = Math.random().toString(36).substring(2, 15)
const extension = filename.split('.').pop()
const baseName = filename.replace(/\.[^/.]+$/, '').replace(/[^a-zA-Z0-9]/g, '-')
return `${prefix}/${timestamp}-${randomString}-${baseName}.${extension}`
}
}
/**
* Convenience functions for common upload operations
*/
export async function uploadToR2(
file: File,
key?: string,
options?: {
contentType?: string
metadata?: Record<string, string>
},
env?: any
): Promise<R2UploadResponse> {
const manager = new FileUploadManager(env)
const uploadKey = key || manager.generateFileKey(file.name)
return await manager.uploadFile(file, uploadKey, options)
}
export async function bulkUploadToR2(
files: File[],
keyPrefix?: string,
env?: any
): Promise<BulkUploadResult> {
const manager = new FileUploadManager(env)
return await manager.bulkUpload(files, keyPrefix)
}
export async function deleteFromR2(key: string, env?: any): Promise<boolean> {
const manager = new FileUploadManager(env)
return await manager.deleteFile(key)
}
export function validateUploadFile(
file: File,
options?: {
maxSize?: number
allowedTypes?: string[]
},
env?: any
): { valid: boolean; error?: string } {
const manager = new FileUploadManager(env)
return manager.validateFile(file, options)
}
/**
* Portfolio image specific upload functions
*/
export async function uploadPortfolioImage(
file: File,
artistId: string,
options?: {
caption?: string
tags?: string[]
},
env?: any
): Promise<R2UploadResponse & { portfolioData?: any }> {
const manager = new FileUploadManager(env)
// Validate image file
const validation = manager.validateFile(file, {
maxSize: 5 * 1024 * 1024, // 5MB for portfolio images
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
if (!validation.valid) {
return {
success: false,
error: validation.error,
}
}
// Generate key for portfolio image
const key = manager.generateFileKey(file.name, `portfolio/${artistId}`)
// Upload to R2
const uploadResult = await manager.uploadFile(file, key, {
contentType: file.type,
metadata: {
artistId,
originalName: file.name,
caption: options?.caption || '',
tags: JSON.stringify(options?.tags || []),
uploadedAt: new Date().toISOString(),
},
})
if (uploadResult.success) {
return {
...uploadResult,
portfolioData: {
artistId,
url: uploadResult.url,
caption: options?.caption,
tags: options?.tags || [],
},
}
}
return uploadResult
}
/**
* Artist profile image upload
*/
export async function uploadArtistProfileImage(
file: File,
artistId: string,
env?: any
): Promise<R2UploadResponse> {
const manager = new FileUploadManager(env)
// Validate image file
const validation = manager.validateFile(file, {
maxSize: 2 * 1024 * 1024, // 2MB for profile images
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
if (!validation.valid) {
return {
success: false,
error: validation.error,
}
}
// Generate key for profile image
const key = `profiles/${artistId}/profile-${Date.now()}.${file.name.split('.').pop()}`
return await manager.uploadFile(file, key, {
contentType: file.type,
metadata: {
artistId,
type: 'profile',
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
})
}