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:
richarjiang
2026-03-15 15:01:47 +08:00
commit 4854f1cefc
43 changed files with 11543 additions and 0 deletions

7
app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

122
app/(auth)/login/page.tsx Normal file
View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,4 @@
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { GET, POST } = toNextJsHandler(auth)

View 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 }
)
}
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function HomePage() {
redirect('/levels')
}

22
app/providers.tsx Normal file
View 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>
)
}