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}`
|
||||
}
|
||||
78
components/levels/level-card.tsx
Normal file
78
components/levels/level-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
components/levels/level-dialog.tsx
Normal file
186
components/levels/level-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
components/levels/level-list.tsx
Normal file
134
components/levels/level-list.tsx
Normal 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">点击上方“添加关卡”按钮创建第一个关卡</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user