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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user