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