commit 4854f1cefc921ebdb280d25b2bf57679f67c7456 Author: richarjiang Date: Sun Mar 15 15:01:47 2026 +0800 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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87eca03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# prisma +prisma/*.db +prisma/*.db-journal diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b50112f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Meme Studio is a homophone pun game operation platform built with Next.js 14 (App Router). It provides level configuration management for a wordplay game. + +## Commands + +```bash +npm run dev # Start development server +npm run build # Production build +npm run lint # Run ESLint + +# Database (Prisma + MySQL) +npm run db:generate # Generate Prisma client +npm run db:push # Push schema changes (dev) +npm run db:migrate # Create migration +npm run db:studio # Open Prisma Studio +npm run db:seed # Create/update admin user +``` + +## Architecture + +### Tech Stack +- **Framework**: Next.js 14 App Router +- **Auth**: Better Auth with Prisma adapter (email/password) +- **Database**: MySQL via Prisma ORM +- **UI**: shadcn/ui + Tailwind CSS +- **State**: TanStack Query for server state +- **Drag & Drop**: @dnd-kit/sortable + +### Key Patterns + +**Route Groups**: +- `app/(auth)/` - Login page (no sidebar) +- `app/(dashboard)/` - Protected pages with sidebar layout + +**Auth Flow**: +- `lib/auth.ts` - Server-side Better Auth config +- `lib/auth-client.ts` - Client-side auth hooks (`useSession`, `signIn`, `signOut`) +- `middleware.ts` - Cookie-based session check (cannot use Prisma in Edge Runtime) + +**API Routes**: +- `/api/auth/[...all]` - Better Auth endpoints +- `/api/levels` - CRUD for game levels +- `/api/levels/reorder` - Batch update sort order +- `/api/cos/temp-key` - Tencent COS temporary credentials + +**Database Models**: +- `Level` - Game levels with image, answer, hints, sortOrder +- `User`, `Session`, `Account`, `Verification` - Better Auth models + +### Environment Variables + +Required in `.env`: +``` +DATABASE_URL=mysql://... +BETTER_AUTH_SECRET= # 32+ chars, generate with: openssl rand -base64 32 +BETTER_AUTH_URL=http://localhost:3000 +ADMIN_EMAIL= +ADMIN_PASSWORD= +COS_SECRET_ID= # Tencent Cloud COS +COS_SECRET_KEY= +COS_BUCKET= +COS_REGION= +COS_APPID= +``` + +## Important Notes + +- Middleware uses cookie check only (Prisma doesn't work in Edge Runtime) +- Password hashing must use `hashPassword` from `better-auth/crypto` for compatibility +- Session model requires `token` field with unique constraint diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..48eacd2 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode +}) { + return <>{children} +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..95e4cff --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,122 @@ +'use client' + +import { Suspense, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { signIn } from '@/lib/auth-client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Spinner } from '@/components/ui/spinner' + +function LoginForm() { + const router = useRouter() + const searchParams = useSearchParams() + const callbackUrl = searchParams.get('callbackUrl') || '/levels' + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setIsLoading(true) + + try { + const result = await signIn.email({ + email, + password, + }) + + if (result.error) { + setError(result.error.message || '登录失败,请检查邮箱和密码') + setIsLoading(false) + return + } + + router.push(callbackUrl) + router.refresh() + } catch { + setError('登录失败,请稍后重试') + setIsLoading(false) + } + } + + return ( + + + Meme Studio + 谐音梗小游戏运营平台 + + +
+ {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + /> +
+
+ + setPassword(e.target.value)} + required + disabled={isLoading} + /> +
+ +
+
+
+ ) +} + +function LoginLoading() { + return ( + + + Meme Studio + 谐音梗小游戏运营平台 + + + + + + ) +} + +export default function LoginPage() { + return ( +
+ }> + + +
+ ) +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..0249ad9 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,16 @@ +import { Sidebar } from '@/components/layout/sidebar' + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/app/(dashboard)/levels/page.tsx b/app/(dashboard)/levels/page.tsx new file mode 100644 index 0000000..86d2b28 --- /dev/null +++ b/app/(dashboard)/levels/page.tsx @@ -0,0 +1,198 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Button } from '@/components/ui/button' +import { Header } from '@/components/layout/header' +import { LevelList } from '@/components/levels/level-list' +import { LevelDialog } from '@/components/levels/level-dialog' +import { Spinner } from '@/components/ui/spinner' +import { Level, LevelFormData } from '@/types' +import { Plus } from 'lucide-react' + +export default function LevelsPage() { + const queryClient = useQueryClient() + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [editingLevel, setEditingLevel] = useState(null) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + + // Fetch levels + const { data: levels, isLoading, error } = useQuery({ + queryKey: ['levels'], + queryFn: async () => { + const res = await fetch('/api/levels') + if (!res.ok) throw new Error('Failed to fetch levels') + return res.json() + }, + }) + + // Create level mutation + const createMutation = useMutation({ + mutationFn: async (data: LevelFormData) => { + const res = await fetch('/api/levels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const error = await res.json() + throw new Error(error.error || 'Failed to create level') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['levels'] }) + }, + }) + + // Update level mutation + const updateMutation = useMutation({ + mutationFn: async ({ id, data }: { id: string; data: LevelFormData }) => { + const res = await fetch('/api/levels', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, ...data }), + }) + if (!res.ok) { + const error = await res.json() + throw new Error(error.error || 'Failed to update level') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['levels'] }) + }, + }) + + // Delete level mutation + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/levels?id=${id}`, { + method: 'DELETE', + }) + if (!res.ok) { + const error = await res.json() + throw new Error(error.error || 'Failed to delete level') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['levels'] }) + setDeleteConfirmId(null) + }, + }) + + // Reorder mutation + const reorderMutation = useMutation({ + mutationFn: async (orders: { id: string; sortOrder: number }[]) => { + const res = await fetch('/api/levels/reorder', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orders }), + }) + if (!res.ok) { + const error = await res.json() + throw new Error(error.error || 'Failed to reorder levels') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['levels'] }) + }, + }) + + const handleOpenCreate = () => { + setEditingLevel(null) + setIsDialogOpen(true) + } + + const handleOpenEdit = (level: Level) => { + setEditingLevel(level) + setIsDialogOpen(true) + } + + const handleDelete = (id: string) => { + if (deleteConfirmId === id) { + deleteMutation.mutate(id) + } else { + setDeleteConfirmId(id) + // Reset after 3 seconds + setTimeout(() => setDeleteConfirmId(null), 3000) + } + } + + const handleSubmit = async (data: LevelFormData) => { + if (editingLevel) { + await updateMutation.mutateAsync({ id: editingLevel.id, data }) + } else { + await createMutation.mutateAsync(data) + } + } + + const handleReorder = useCallback( + (orders: { id: string; sortOrder: number }[]) => { + reorderMutation.mutate(orders) + }, + [reorderMutation] + ) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+
+

加载失败

+ +
+
+ ) + } + + return ( +
+
+
+
+
+
+

关卡配置

+

+ 共 {levels?.length || 0} 个关卡 +

+
+ +
+ + +
+
+ + +
+ ) +} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..5d94414 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from '@/lib/auth' +import { toNextJsHandler } from 'better-auth/next-js' + +export const { GET, POST } = toNextJsHandler(auth) diff --git a/app/api/cos/temp-key/route.ts b/app/api/cos/temp-key/route.ts new file mode 100644 index 0000000..c4be3f7 --- /dev/null +++ b/app/api/cos/temp-key/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { getTempKey, getBucketConfig } from '@/lib/cos' + +// GET /api/cos/temp-key - Get temporary COS upload credentials +export async function GET(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const tempKey = await getTempKey() + const bucketConfig = getBucketConfig() + + return NextResponse.json({ + ...tempKey, + bucket: bucketConfig.bucket, + region: bucketConfig.region, + }) + } catch (error) { + console.error('Error getting temp key:', error) + return NextResponse.json( + { error: 'Failed to get temp key' }, + { status: 500 } + ) + } +} diff --git a/app/api/levels/reorder/route.ts b/app/api/levels/reorder/route.ts new file mode 100644 index 0000000..dabbdb6 --- /dev/null +++ b/app/api/levels/reorder/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { auth } from '@/lib/auth' + +// PUT /api/levels/reorder - Batch update sort order +export async function PUT(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { orders } = body as { orders: { id: string; sortOrder: number }[] } + + if (!Array.isArray(orders)) { + return NextResponse.json( + { error: 'orders must be an array' }, + { status: 400 } + ) + } + + // Update each level's sort order in a transaction + await prisma.$transaction( + orders.map((item) => + prisma.level.update({ + where: { id: item.id }, + data: { sortOrder: item.sortOrder }, + }) + ) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error reordering levels:', error) + return NextResponse.json( + { error: 'Failed to reorder levels' }, + { status: 500 } + ) + } +} diff --git a/app/api/levels/route.ts b/app/api/levels/route.ts new file mode 100644 index 0000000..e87425d --- /dev/null +++ b/app/api/levels/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { auth } from '@/lib/auth' +import { v4 as uuidv4 } from 'uuid' + +// GET /api/levels - Get all levels +export async function GET(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const levels = await prisma.level.findMany({ + orderBy: { sortOrder: 'asc' }, + }) + + return NextResponse.json(levels) + } catch (error) { + console.error('Error fetching levels:', error) + return NextResponse.json( + { error: 'Failed to fetch levels' }, + { status: 500 } + ) + } +} + +// POST /api/levels - Create a new level +export async function POST(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { imageUrl, answer, hint1, hint2, hint3 } = body + + if (!imageUrl || !answer) { + return NextResponse.json( + { error: 'imageUrl and answer are required' }, + { status: 400 } + ) + } + + // Get max sort order + const maxSortOrder = await prisma.level.aggregate({ + _max: { sortOrder: true }, + }) + + const sortOrder = (maxSortOrder._max.sortOrder || 0) + 1 + + const level = await prisma.level.create({ + data: { + id: uuidv4(), + imageUrl, + answer, + hint1: hint1 || null, + hint2: hint2 || null, + hint3: hint3 || null, + sortOrder, + }, + }) + + return NextResponse.json(level, { status: 201 }) + } catch (error) { + console.error('Error creating level:', error) + return NextResponse.json( + { error: 'Failed to create level' }, + { status: 500 } + ) + } +} + +// PUT /api/levels - Update a level +export async function PUT(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { id, imageUrl, answer, hint1, hint2, hint3 } = body + + if (!id) { + return NextResponse.json({ error: 'id is required' }, { status: 400 }) + } + + const level = await prisma.level.update({ + where: { id }, + data: { + imageUrl, + answer, + hint1: hint1 || null, + hint2: hint2 || null, + hint3: hint3 || null, + }, + }) + + return NextResponse.json(level) + } catch (error) { + console.error('Error updating level:', error) + return NextResponse.json( + { error: 'Failed to update level' }, + { status: 500 } + ) + } +} + +// DELETE /api/levels - Delete a level +export async function DELETE(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const id = searchParams.get('id') + + if (!id) { + return NextResponse.json({ error: 'id is required' }, { status: 400 }) + } + + await prisma.level.delete({ + where: { id }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting level:', error) + return NextResponse.json( + { error: 'Failed to delete level' }, + { status: 500 } + ) + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..10c2d37 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..5301f01 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' +import { Providers } from './providers' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'Meme Studio - 谐音梗小游戏运营平台', + description: '谐音梗小游戏关卡配置管理平台', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + {children} + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..f27e5b7 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function HomePage() { + redirect('/levels') +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..18e0e5b --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,22 @@ +'use client' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }, + }, + }) + ) + + return ( + {children} + ) +} diff --git a/components/layout/header.tsx b/components/layout/header.tsx new file mode 100644 index 0000000..75728c1 --- /dev/null +++ b/components/layout/header.tsx @@ -0,0 +1,27 @@ +'use client' + +import { useSession } from '@/lib/auth-client' +import { Spinner } from '@/components/ui/spinner' + +export function Header() { + const { data: session, isPending } = useSession() + + if (isPending) { + return ( +
+ +
+ ) + } + + return ( +
+

关卡配置管理

+
+ + {session?.user?.email} + +
+
+ ) +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx new file mode 100644 index 0000000..a321eaa --- /dev/null +++ b/components/layout/sidebar.tsx @@ -0,0 +1,78 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { cn } from '@/lib/utils' +import { Layers, Home, Settings, LogOut } from 'lucide-react' +import { signOut, useSession } from '@/lib/auth-client' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' + +const navigation = [ + { name: '首页', href: '/levels', icon: Home }, + { name: '关卡配置', href: '/levels', icon: Layers }, +] + +export function Sidebar() { + const pathname = usePathname() + const router = useRouter() + const { data: session } = useSession() + + const handleSignOut = async () => { + await signOut() + router.push('/login') + router.refresh() + } + + return ( +
+
+

Meme Studio

+
+ +
+
+
+ {session?.user?.email?.[0]?.toUpperCase() || 'U'} +
+
+

+ {session?.user?.name || session?.user?.email || '用户'} +

+

+ {session?.user?.email} +

+
+
+ +
+
+ ) +} diff --git a/components/levels/image-uploader.tsx b/components/levels/image-uploader.tsx new file mode 100644 index 0000000..857f2e0 --- /dev/null +++ b/components/levels/image-uploader.tsx @@ -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(null) + + const handleFileSelect = async (e: React.ChangeEvent) => { + 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((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 ( +
+ {value ? ( +
+ 预览图片 + +
+ ) : ( +
fileInputRef.current?.click()} + > + {isUploading ? ( + <> + +

上传中...

+ + ) : ( + <> + +

点击上传图片

+

支持 JPG、PNG,最大 5MB

+ + )} +
+ )} + + {error && ( +

{error}

+ )} + + +
+ ) +} + +// 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}` +} diff --git a/components/levels/level-card.tsx b/components/levels/level-card.tsx new file mode 100644 index 0000000..c85b529 --- /dev/null +++ b/components/levels/level-card.tsx @@ -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 ( + + +
+
+ +
+
+ {level.imageUrl ? ( + 关卡图片 + ) : ( +
+ 无图片 +
+ )} +
+
+

{level.answer}

+
+ {level.hint1 && ( +

提示1: {level.hint1}

+ )} + {level.hint2 && ( +

提示2: {level.hint2}

+ )} + {level.hint3 && ( +

提示3: {level.hint3}

+ )} +
+
+
+ + +
+
+
+
+ ) +} diff --git a/components/levels/level-dialog.tsx b/components/levels/level-dialog.tsx new file mode 100644 index 0000000..c98b71f --- /dev/null +++ b/components/levels/level-dialog.tsx @@ -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 +} + +const defaultFormData: LevelFormData = { + imageUrl: '', + answer: '', + hint1: '', + hint2: '', + hint3: '', +} + +export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialogProps) { + const [formData, setFormData] = useState(defaultFormData) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const fileInputRef = useRef(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 ( + + + + {level ? '编辑关卡' : '添加关卡'} + + {level ? '修改关卡信息' : '创建新的关卡'} + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+ + + setFormData((prev) => ({ ...prev, answer: e.target.value })) + } + placeholder="请输入答案" + required + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, hint1: e.target.value })) + } + placeholder="请输入提示1" + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, hint2: e.target.value })) + } + placeholder="请输入提示2" + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, hint3: e.target.value })) + } + placeholder="请输入提示3" + /> +
+ + + + + +
+
+
+ ) +} diff --git a/components/levels/level-list.tsx b/components/levels/level-list.tsx new file mode 100644 index 0000000..d6cace3 --- /dev/null +++ b/components/levels/level-list.tsx @@ -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 ( +
+ +
+ ) +} + +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(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 ( +
+

暂无关卡数据

+

点击上方“添加关卡”按钮创建第一个关卡

+
+ ) + } + + return ( + + +
+ {items.map((level) => ( + + ))} +
+
+
+ ) +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..ee0b112 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..4902b39 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..0e51f44 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +'use client' + +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..d52138b --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = 'Input' + +export { Input } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..340513e --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,26 @@ +'use client' + +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/components/ui/spinner.tsx b/components/ui/spinner.tsx new file mode 100644 index 0000000..bd4efe1 --- /dev/null +++ b/components/ui/spinner.tsx @@ -0,0 +1,44 @@ +'use client' + +import * as React from 'react' +import { cn } from '@/lib/utils' + +interface SpinnerProps extends React.HTMLAttributes { + size?: 'sm' | 'md' | 'lg' +} + +export function Spinner({ className, size = 'md', ...props }: SpinnerProps) { + const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-6 w-6', + lg: 'h-8 w-8', + } + + return ( +
+ + + + +
+ ) +} diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx new file mode 100644 index 0000000..cf5e955 --- /dev/null +++ b/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +