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:
richarjiang
2026-03-15 15:01:47 +08:00
commit 4854f1cefc
43 changed files with 11543 additions and 0 deletions

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