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,186 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/spinner'
import { Level, LevelFormData } from '@/types'
import { ImageUploader } from './image-uploader'
import { Upload } from 'lucide-react'
interface LevelDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
level?: Level | null
onSubmit: (data: LevelFormData) => Promise<void>
}
const defaultFormData: LevelFormData = {
imageUrl: '',
answer: '',
hint1: '',
hint2: '',
hint3: '',
}
export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialogProps) {
const [formData, setFormData] = useState<LevelFormData>(defaultFormData)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
// Reset form when dialog opens/closes or level changes
useEffect(() => {
if (open) {
if (level) {
setFormData({
imageUrl: level.imageUrl,
answer: level.answer,
hint1: level.hint1 || '',
hint2: level.hint2 || '',
hint3: level.hint3 || '',
})
} else {
setFormData(defaultFormData)
}
setError('')
}
}, [open, level])
const handleImageUpload = (url: string) => {
setFormData((prev) => ({ ...prev, imageUrl: url }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!formData.imageUrl) {
setError('请上传关卡图片')
return
}
if (!formData.answer.trim()) {
setError('请输入答案')
return
}
setIsLoading(true)
try {
await onSubmit(formData)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败,请稍后重试')
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{level ? '编辑关卡' : '添加关卡'}</DialogTitle>
<DialogDescription>
{level ? '修改关卡信息' : '创建新的关卡'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label> *</Label>
<ImageUploader
value={formData.imageUrl}
onChange={handleImageUpload}
/>
</div>
<div className="space-y-2">
<Label htmlFor="answer"> *</Label>
<Input
id="answer"
value={formData.answer}
onChange={(e) =>
setFormData((prev) => ({ ...prev, answer: e.target.value }))
}
placeholder="请输入答案"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="hint1">1 ()</Label>
<Input
id="hint1"
value={formData.hint1}
onChange={(e) =>
setFormData((prev) => ({ ...prev, hint1: e.target.value }))
}
placeholder="请输入提示1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="hint2">2 ()</Label>
<Input
id="hint2"
value={formData.hint2}
onChange={(e) =>
setFormData((prev) => ({ ...prev, hint2: e.target.value }))
}
placeholder="请输入提示2"
/>
</div>
<div className="space-y-2">
<Label htmlFor="hint3">3 ()</Label>
<Input
id="hint3"
value={formData.hint3}
onChange={(e) =>
setFormData((prev) => ({ ...prev, hint3: e.target.value }))
}
placeholder="请输入提示3"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
...
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}