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
This commit is contained in:
182
components/levels/image-uploader.tsx
Normal file
182
components/levels/image-uploader.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'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">支持 JPG、PNG,最大 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}`
|
||||
}
|
||||
Reference in New Issue
Block a user