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:
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -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
|
||||
75
CLAUDE.md
Normal file
75
CLAUDE.md
Normal file
@@ -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
|
||||
7
app/(auth)/layout.tsx
Normal file
7
app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
122
app/(auth)/login/page.tsx
Normal file
122
app/(auth)/login/page.tsx
Normal file
@@ -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 (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Meme Studio</CardTitle>
|
||||
<CardDescription>谐音梗小游戏运营平台</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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 htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginLoading() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Meme Studio</CardTitle>
|
||||
<CardDescription>谐音梗小游戏运营平台</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<Suspense fallback={<LoginLoading />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
app/(dashboard)/layout.tsx
Normal file
16
app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Sidebar } from '@/components/layout/sidebar'
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
app/(dashboard)/levels/page.tsx
Normal file
198
app/(dashboard)/levels/page.tsx
Normal file
@@ -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<Level | null>(null)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
|
||||
// Fetch levels
|
||||
const { data: levels, isLoading, error } = useQuery<Level[]>({
|
||||
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 (
|
||||
<div className="h-screen flex items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600">加载失败</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['levels'] })}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">关卡配置</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
共 {levels?.length || 0} 个关卡
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加关卡
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<LevelList
|
||||
levels={levels || []}
|
||||
onReorder={handleReorder}
|
||||
onEdit={handleOpenEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LevelDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
level={editingLevel}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from '@/lib/auth'
|
||||
import { toNextJsHandler } from 'better-auth/next-js'
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth)
|
||||
31
app/api/cos/temp-key/route.ts
Normal file
31
app/api/cos/temp-key/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
44
app/api/levels/reorder/route.ts
Normal file
44
app/api/levels/reorder/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
150
app/api/levels/route.ts
Normal file
150
app/api/levels/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
59
app/globals.css
Normal file
59
app/globals.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
25
app/layout.tsx
Normal file
25
app/layout.tsx
Normal file
@@ -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 (
|
||||
<html lang="zh-CN">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function HomePage() {
|
||||
redirect('/levels')
|
||||
}
|
||||
22
app/providers.tsx
Normal file
22
app/providers.tsx
Normal file
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
27
components/layout/header.tsx
Normal file
27
components/layout/header.tsx
Normal file
@@ -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 (
|
||||
<header className="h-16 border-b bg-white flex items-center justify-center px-6">
|
||||
<Spinner size="sm" />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-16 border-b bg-white flex items-center justify-between px-6">
|
||||
<h2 className="text-lg font-semibold">关卡配置管理</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{session?.user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
78
components/layout/sidebar.tsx
Normal file
78
components/layout/sidebar.tsx
Normal file
@@ -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 (
|
||||
<div className="flex h-full w-64 flex-col bg-gray-900 text-white">
|
||||
<div className="flex h-16 items-center justify-center border-b border-gray-800">
|
||||
<h1 className="text-xl font-bold">Meme Studio</h1>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-4">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="border-t border-gray-800 p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gray-700 flex items-center justify-center">
|
||||
{session?.user?.email?.[0]?.toUpperCase() || 'U'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{session?.user?.name || session?.user?.email || '用户'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{session?.user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-gray-400 hover:text-white hover:bg-gray-800"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
56
components/ui/button.tsx
Normal file
56
components/ui/button.tsx
Normal file
@@ -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<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
components/ui/card.tsx
Normal file
79
components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
122
components/ui/dialog.tsx
Normal file
122
components/ui/dialog.tsx
Normal file
@@ -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<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
25
components/ui/input.tsx
Normal file
25
components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@@ -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<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
44
components/ui/spinner.tsx
Normal file
44
components/ui/spinner.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
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 (
|
||||
<div
|
||||
className={cn('animate-spin', sizeClasses[size], className)}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
components/ui/textarea.tsx
Normal file
24
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
7
lib/auth-client.ts
Normal file
7
lib/auth-client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
|
||||
})
|
||||
|
||||
export const { signIn, signOut, useSession } = authClient
|
||||
20
lib/auth.ts
Normal file
20
lib/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: 'mysql',
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
trustedOrigins: [process.env.BETTER_AUTH_URL || 'http://localhost:3000'],
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // 1 day
|
||||
},
|
||||
})
|
||||
|
||||
export type Auth = typeof auth
|
||||
89
lib/cos.ts
Normal file
89
lib/cos.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import STS from 'qcloud-cos-sts'
|
||||
|
||||
export interface TempKeyResult {
|
||||
credentials: {
|
||||
tmpSecretId: string
|
||||
tmpSecretKey: string
|
||||
sessionToken: string
|
||||
}
|
||||
startTime: number
|
||||
expiredTime: number
|
||||
}
|
||||
|
||||
export function getBucketName(): string {
|
||||
const bucket = process.env.COS_BUCKET || ''
|
||||
const appid = process.env.COS_APPID || ''
|
||||
if (bucket.includes('-')) {
|
||||
return bucket
|
||||
}
|
||||
return `${bucket}-${appid}`
|
||||
}
|
||||
|
||||
export async function getTempKey(): Promise<TempKeyResult> {
|
||||
const secretId = process.env.COS_SECRET_ID || ''
|
||||
const secretKey = process.env.COS_SECRET_KEY || ''
|
||||
const bucket = getBucketName()
|
||||
const region = process.env.COS_REGION || 'ap-guangzhou'
|
||||
const appid = process.env.COS_APPID || ''
|
||||
|
||||
// Define the policy for upload permissions
|
||||
const policy = {
|
||||
version: '2.0',
|
||||
statement: [
|
||||
{
|
||||
action: [
|
||||
'name/cos:PutObject',
|
||||
'name/cos:PostObject',
|
||||
],
|
||||
effect: 'allow',
|
||||
principal: { qcs: ['qcs::cam::anyone:anyone'] },
|
||||
resource: [
|
||||
`qcs::cos:${region}:uid/${appid}:${bucket}/*`,
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
STS.getCredential(
|
||||
{
|
||||
secretId,
|
||||
secretKey,
|
||||
proxy: '',
|
||||
durationSeconds: 1800,
|
||||
policy,
|
||||
},
|
||||
(err, data) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
const credentialData = data as {
|
||||
credentials: {
|
||||
tmpSecretId: string
|
||||
tmpSecretKey: string
|
||||
sessionToken: string
|
||||
}
|
||||
startTime: number
|
||||
expiredTime: number
|
||||
}
|
||||
resolve({
|
||||
credentials: {
|
||||
tmpSecretId: credentialData.credentials.tmpSecretId,
|
||||
tmpSecretKey: credentialData.credentials.tmpSecretKey,
|
||||
sessionToken: credentialData.credentials.sessionToken,
|
||||
},
|
||||
startTime: credentialData.startTime,
|
||||
expiredTime: credentialData.expiredTime,
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function getBucketConfig() {
|
||||
return {
|
||||
bucket: getBucketName(),
|
||||
region: process.env.COS_REGION || 'ap-guangzhou',
|
||||
}
|
||||
}
|
||||
9
lib/prisma.ts
Normal file
9
lib/prisma.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
35
middleware.ts
Normal file
35
middleware.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Allow auth API routes and static files
|
||||
if (
|
||||
pathname.startsWith('/api/auth') ||
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname.startsWith('/favicon') ||
|
||||
pathname.includes('.')
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Allow login page
|
||||
if (pathname === '/login') {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Check if session cookie exists (simple check, full validation happens in server)
|
||||
const sessionToken = request.cookies.get('better-auth.session_token')
|
||||
|
||||
if (!sessionToken?.value) {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
loginUrl.searchParams.set('callbackUrl', pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'],
|
||||
}
|
||||
13
next.config.js
Normal file
13
next.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.myqcloud.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
9176
package-lock.json
generated
Normal file
9176
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
package.json
Normal file
56
package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "meme-studio",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.28",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"better-auth": "^1.2.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"zod": "^3.24.2",
|
||||
"cos-nodejs-sdk-v5": "^2.14.0",
|
||||
"uuid": "^11.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"lucide-react": "^0.483.0",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"qcloud-cos-sts": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^22.13.11",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"typescript": "^5.8.2",
|
||||
"prisma": "^6.5.0",
|
||||
"tsx": "^4.19.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.28",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"postcss": "^8.5.3",
|
||||
"autoprefixer": "^10.4.21"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
86
prisma/schema.prisma
Normal file
86
prisma/schema.prisma
Normal file
@@ -0,0 +1,86 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Level {
|
||||
id String @id @default(uuid())
|
||||
imageUrl String @map("image_url")
|
||||
answer String
|
||||
hint1 String?
|
||||
hint2 String?
|
||||
hint3 String?
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("levels")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
name String?
|
||||
image String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime @map("expires_at")
|
||||
token String @unique
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(uuid())
|
||||
accountId String @map("account_id")
|
||||
providerId String @map("provider_id")
|
||||
userId String @map("user_id")
|
||||
accessToken String? @map("access_token")
|
||||
refreshToken String? @map("refresh_token")
|
||||
idToken String? @map("id_token")
|
||||
accessTokenExpires DateTime? @map("access_token_expires")
|
||||
refreshTokenExpires DateTime? @map("refresh_token_expires")
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id @default(uuid())
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("verifications")
|
||||
}
|
||||
72
prisma/seed.ts
Normal file
72
prisma/seed.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const email = process.env.ADMIN_EMAIL || 'admin@example.com'
|
||||
const password = process.env.ADMIN_PASSWORD || 'admin123456'
|
||||
|
||||
// Check if admin already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: { accounts: true },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
// Update password for existing user
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
if (existingUser.accounts.length > 0) {
|
||||
await prisma.account.update({
|
||||
where: { id: existingUser.accounts[0].id },
|
||||
data: { password: hashedPassword },
|
||||
})
|
||||
} else {
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
accountId: email,
|
||||
providerId: 'credential',
|
||||
userId: existingUser.id,
|
||||
password: hashedPassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`Admin user password updated: ${email}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password using Better Auth's method
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
// Create admin user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: 'Admin',
|
||||
emailVerified: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Create account with password
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
accountId: email,
|
||||
providerId: 'credential',
|
||||
userId: user.id,
|
||||
password: hashedPassword,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Admin user created: ${email}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
56
tailwind.config.ts
Normal file
56
tailwind.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
23
types/index.ts
Normal file
23
types/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface Level {
|
||||
id: string
|
||||
imageUrl: string
|
||||
answer: string
|
||||
hint1: string | null
|
||||
hint2: string | null
|
||||
hint3: string | null
|
||||
sortOrder: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface LevelFormData {
|
||||
imageUrl: string
|
||||
answer: string
|
||||
hint1?: string
|
||||
hint2?: string
|
||||
hint3?: string
|
||||
}
|
||||
|
||||
export interface ReorderRequest {
|
||||
orders: { id: string; sortOrder: number }[]
|
||||
}
|
||||
Reference in New Issue
Block a user