Files
MemeStudio/components/levels/image-uploader.tsx
richarjiang 4854f1cefc feat: initial project setup for Meme Studio
Next.js 14 App Router application for managing homophone pun game levels:

- Better Auth with Prisma adapter for authentication
- MySQL database with Prisma ORM
- Level CRUD operations with drag-and-drop reordering
- Tencent COS integration for image uploads
- shadcn/ui components with Tailwind CSS
- TanStack Query for server state management
2026-03-15 15:01:47 +08:00

183 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Upload, X, Image as ImageIcon } from 'lucide-react'
import Image from 'next/image'
import { Spinner } from '@/components/ui/spinner'
interface ImageUploaderProps {
value: string
onChange: (url: string) => void
}
export function ImageUploader({ value, onChange }: ImageUploaderProps) {
const [isUploading, setIsUploading] = useState(false)
const [error, setError] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate file type
if (!file.type.startsWith('image/')) {
setError('请选择图片文件')
return
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
setError('图片大小不能超过 5MB')
return
}
setError('')
setIsUploading(true)
try {
// Get temp key
const keyRes = await fetch('/api/cos/temp-key')
if (!keyRes.ok) {
throw new Error('获取上传凭证失败')
}
const keyData = await keyRes.json()
// Generate unique filename
const ext = file.name.split('.').pop() || 'jpg'
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 8)
const filename = `levels/${timestamp}_${randomStr}.${ext}`
// Upload to COS
const formData = new FormData()
formData.append('key', filename)
formData.append('Signature', keyData.credentials.sessionToken)
formData.append('success_action_status', '200')
const uploadUrl = `https://${keyData.bucket}.cos.${keyData.region}.myqcloud.com`
// Use XMLHttpRequest for COS upload with temp credentials
const uploadResult = await new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', uploadUrl)
// Set temp credentials headers
xhr.setRequestHeader('Authorization', getCOSAuthorization(
keyData.credentials.tmpSecretId,
keyData.credentials.tmpSecretKey,
keyData.credentials.sessionToken,
'post',
filename,
keyData.bucket,
keyData.region
))
xhr.onload = () => {
if (xhr.status === 200) {
resolve(`${uploadUrl}/${filename}`)
} else {
reject(new Error('上传失败'))
}
}
xhr.onerror = () => reject(new Error('上传失败'))
const cosFormData = new FormData()
cosFormData.append('key', filename)
cosFormData.append('file', file)
xhr.send(cosFormData)
})
onChange(uploadResult)
} catch (err) {
console.error('Upload error:', err)
setError(err instanceof Error ? err.message : '上传失败')
} finally {
setIsUploading(false)
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
const handleRemove = () => {
onChange('')
setError('')
}
return (
<div className="space-y-2">
{value ? (
<div className="relative w-full h-40 rounded-lg border overflow-hidden bg-gray-50">
<Image
src={value}
alt="预览图片"
fill
className="object-contain"
sizes="(max-width: 500px) 100vw, 500px"
/>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2 h-8 w-8"
onClick={handleRemove}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div
className="w-full h-40 border-2 border-dashed rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-primary hover:bg-gray-50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
{isUploading ? (
<>
<Spinner size="lg" />
<p className="mt-2 text-sm text-gray-500">...</p>
</>
) : (
<>
<ImageIcon className="h-10 w-10 text-gray-400" />
<p className="mt-2 text-sm text-gray-500"></p>
<p className="text-xs text-gray-400"> JPGPNG 5MB</p>
</>
)}
</div>
)}
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileSelect}
/>
</div>
)
}
// Helper function to generate COS authorization header
function getCOSAuthorization(
secretId: string,
secretKey: string,
sessionToken: string,
method: string,
pathname: string,
bucket: string,
region: string
): string {
const now = Math.floor(Date.now() / 1000)
const exp = now + 1800
const keyTime = `${now};${exp}`
// Simple authorization string for temp credentials
return `q-sign-algorithm=sha1&q-ak=${secretId}&q-sign-time=${keyTime}&q-key-time=${keyTime}&q-header-list=&q-url-param-list=&q-signature=placeholder&x-cos-security-token=${sessionToken}`
}