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

View File

@@ -0,0 +1,78 @@
'use client'
import { Level } from '@/types'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { GripVertical, Pencil, Trash2 } from 'lucide-react'
import Image from 'next/image'
interface LevelCardProps {
level: Level
onEdit: (level: Level) => void
onDelete: (id: string) => void
isDragging?: boolean
}
export function LevelCard({ level, onEdit, onDelete, isDragging }: LevelCardProps) {
return (
<Card
className={`cursor-grab transition-shadow ${
isDragging ? 'shadow-lg opacity-90' : 'hover:shadow-md'
}`}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded bg-gray-100 text-gray-500 cursor-grab">
<GripVertical className="h-4 w-4" />
</div>
<div className="relative w-20 h-20 rounded-md overflow-hidden bg-gray-100 flex-shrink-0">
{level.imageUrl ? (
<Image
src={level.imageUrl}
alt="关卡图片"
fill
className="object-cover"
sizes="80px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-lg truncate">{level.answer}</p>
<div className="mt-1 space-y-0.5">
{level.hint1 && (
<p className="text-sm text-gray-500 truncate">1: {level.hint1}</p>
)}
{level.hint2 && (
<p className="text-sm text-gray-500 truncate">2: {level.hint2}</p>
)}
{level.hint3 && (
<p className="text-sm text-gray-500 truncate">3: {level.hint3}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => onEdit(level)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onDelete(level.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)
}

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>
)
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useState, useCallback } from 'react'
import { Level } from '@/types'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { LevelCard } from './level-card'
interface SortableLevelCardProps {
level: Level
onEdit: (level: Level) => void
onDelete: (id: string) => void
}
function SortableLevelCard({ level, onEdit, onDelete }: SortableLevelCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: level.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<LevelCard
level={level}
onEdit={onEdit}
onDelete={onDelete}
isDragging={isDragging}
/>
</div>
)
}
interface LevelListProps {
levels: Level[]
onReorder: (orders: { id: string; sortOrder: number }[]) => void
onEdit: (level: Level) => void
onDelete: (id: string) => void
}
export function LevelList({ levels, onReorder, onEdit, onDelete }: LevelListProps) {
const [items, setItems] = useState<Level[]>(levels)
// Update items when levels prop changes
if (JSON.stringify(items.map(i => i.id)) !== JSON.stringify(levels.map(l => l.id))) {
setItems(levels)
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = items.findIndex((item) => item.id === active.id)
const newIndex = items.findIndex((item) => item.id === over.id)
const newItems = arrayMove(items, oldIndex, newIndex)
setItems(newItems)
// Notify parent of new order
const orders = newItems.map((item, index) => ({
id: item.id,
sortOrder: index,
}))
onReorder(orders)
}
},
[items, onReorder]
)
if (items.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<p></p>
<p className="text-sm mt-2">&ldquo;&rdquo;</p>
</div>
)
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{items.map((level) => (
<SortableLevelCard
key={level.id}
level={level}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</SortableContext>
</DndContext>
)
}