add admin
This commit is contained in:
parent
5a7584c673
commit
2e44c5865d
@ -3,6 +3,7 @@
|
||||
"home": "Home",
|
||||
"studio": "Studio",
|
||||
"profile": "Profile",
|
||||
"admin": "Admin",
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"signOut": "Sign Out"
|
||||
@ -215,6 +216,30 @@
|
||||
"perMonth": "per month",
|
||||
"startProTrial": "Start Pro Trial"
|
||||
},
|
||||
"admin": {
|
||||
"dashboard": "Admin Dashboard",
|
||||
"totalUsers": "Total Users",
|
||||
"totalPrompts": "Total Prompts",
|
||||
"sharedPrompts": "Shared Prompts",
|
||||
"publishedPrompts": "Published Prompts",
|
||||
"quickActions": "Quick Actions",
|
||||
"systemStatus": "System Status",
|
||||
"reviewPrompts": "Review Prompts",
|
||||
"reviewPromptsDesc": "Approve or reject shared prompts",
|
||||
"databaseStatus": "Database",
|
||||
"authStatus": "Authentication",
|
||||
"healthy": "Healthy",
|
||||
"pending": "pending",
|
||||
"noPromptsPending": "No prompts pending review",
|
||||
"allPromptsReviewed": "All shared prompts have been reviewed.",
|
||||
"underReview": "Under Review",
|
||||
"published": "Published",
|
||||
"promptContent": "Prompt Content",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"allPrompts": "All Prompts",
|
||||
"allPromptsDesc": "Review all shared prompts"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
"network": "Network error. Please check your connection.",
|
||||
|
@ -3,6 +3,7 @@
|
||||
"home": "首页",
|
||||
"studio": "工作室",
|
||||
"profile": "个人资料",
|
||||
"admin": "管理员后台",
|
||||
"signIn": "登录",
|
||||
"signUp": "注册",
|
||||
"signOut": "退出登录"
|
||||
@ -215,6 +216,30 @@
|
||||
"perMonth": "每月",
|
||||
"startProTrial": "开始专业试用"
|
||||
},
|
||||
"admin": {
|
||||
"dashboard": "管理员后台",
|
||||
"totalUsers": "用户总数",
|
||||
"totalPrompts": "提示词总数",
|
||||
"sharedPrompts": "用户共享提示词",
|
||||
"publishedPrompts": "广场提示词",
|
||||
"quickActions": "快捷操作",
|
||||
"systemStatus": "系统状态",
|
||||
"reviewPrompts": "审核提示词",
|
||||
"reviewPromptsDesc": "审核或拒绝用户共享的提示词",
|
||||
"databaseStatus": "数据库",
|
||||
"authStatus": "身份验证",
|
||||
"healthy": "正常",
|
||||
"pending": "待审核",
|
||||
"noPromptsPending": "没有待审核的提示词",
|
||||
"allPromptsReviewed": "所有共享的提示词都已审核完成。",
|
||||
"underReview": "审核中",
|
||||
"published": "已发布",
|
||||
"promptContent": "提示词内容",
|
||||
"approve": "通过",
|
||||
"reject": "拒绝",
|
||||
"allPrompts": "所有提示词",
|
||||
"allPromptsDesc": "审核所有共享提示词"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "出现错误,请重试。",
|
||||
"network": "网络错误,请检查您的网络连接。",
|
||||
|
11
package-lock.json
generated
11
package-lock.json
generated
@ -15,6 +15,7 @@
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.53.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.532.0",
|
||||
"next": "15.4.4",
|
||||
"next-intl": "^4.3.4",
|
||||
@ -2692,6 +2693,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.53.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.532.0",
|
||||
"next": "15.4.4",
|
||||
"next-intl": "^4.3.4",
|
||||
@ -45,4 +46,4 @@
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ model User {
|
||||
avatar String?
|
||||
bio String?
|
||||
language String @default("en")
|
||||
isAdmin Boolean @default(false) // 管理员标记
|
||||
versionLimit Int @default(3) // 版本数量限制,可在用户配置中设置
|
||||
subscribePlan String @default("free") // 订阅计划: "free", "pro"
|
||||
maxVersionLimit Int @default(3) // 基于订阅的最大版本限制
|
||||
|
51
src/app/admin/layout.tsx
Normal file
51
src/app/admin/layout.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { userData, loading } = useUser()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('admin')
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (!userData || !userData.isAdmin)) {
|
||||
router.push('/')
|
||||
}
|
||||
}, [userData, loading, router])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!userData || !userData.isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="border-b border-border bg-card">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{t('dashboard')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
157
src/app/admin/page.tsx
Normal file
157
src/app/admin/page.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Users, FileText, Share, CheckCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface AdminStats {
|
||||
totalUsers: number
|
||||
totalPrompts: number
|
||||
sharedPrompts: number
|
||||
publishedPrompts: number
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const t = useTranslations('admin')
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/stats')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch admin stats:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
|
||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: t('totalUsers'),
|
||||
value: stats?.totalUsers || 0,
|
||||
icon: Users,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
title: t('totalPrompts'),
|
||||
value: stats?.totalPrompts || 0,
|
||||
icon: FileText,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
title: t('sharedPrompts'),
|
||||
value: stats?.sharedPrompts || 0,
|
||||
icon: Share,
|
||||
color: 'text-yellow-600',
|
||||
},
|
||||
{
|
||||
title: t('publishedPrompts'),
|
||||
value: stats?.publishedPrompts || 0,
|
||||
icon: CheckCircle,
|
||||
color: 'text-purple-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{statCards.map((stat, index) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<Card key={index} className="p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-foreground">
|
||||
{stat.value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Icon className={`h-8 w-8 ${stat.color}`} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
{t('quickActions')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/admin/review"
|
||||
className="block p-3 rounded-lg border border-border hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{t('allPrompts')}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('allPromptsDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
{t('systemStatus')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('databaseStatus')}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-green-600">
|
||||
{t('healthy')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('authStatus')}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-green-600">
|
||||
{t('healthy')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
209
src/app/admin/review/page.tsx
Normal file
209
src/app/admin/review/page.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CheckCircle, XCircle, Eye, Calendar, User as UserIcon } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface ReviewPrompt {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
content: string
|
||||
visibility: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
user: {
|
||||
username: string | null
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminReviewPage() {
|
||||
const t = useTranslations('admin')
|
||||
const [prompts, setPrompts] = useState<ReviewPrompt[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchPendingPrompts = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/prompts/pending')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setPrompts(data.prompts)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pending prompts:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPendingPrompts()
|
||||
}, [])
|
||||
|
||||
const handleApprove = async (promptId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/prompts/${promptId}/approve`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (response.ok) {
|
||||
setPrompts(prompts.filter(p => p.id !== promptId))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to approve prompt:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (promptId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/prompts/${promptId}/reject`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (response.ok) {
|
||||
setPrompts(prompts.filter(p => p.id !== promptId))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reject prompt:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{t('reviewPrompts') || 'Review Prompts'}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i} className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-20 bg-muted rounded"></div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 bg-muted rounded w-20"></div>
|
||||
<div className="h-9 bg-muted rounded w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{t('allPrompts')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{prompts.filter(p => !p.visibility || p.visibility === 'under_review').length} {t('pending')}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{prompts.filter(p => p.visibility === 'published').length} {t('published')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prompts.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t('noPromptsPending')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t('allPromptsReviewed')}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{prompts.map((prompt) => (
|
||||
<Card key={prompt.id} className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{prompt.name}
|
||||
</h3>
|
||||
{prompt.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{prompt.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={prompt.visibility === 'published' ? 'default' : 'outline'}
|
||||
className={prompt.visibility === 'published' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : ''}
|
||||
>
|
||||
{prompt.visibility === 'published' ? t('published') : t('underReview')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<span>{prompt.user.username || prompt.user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Preview */}
|
||||
<div className="border border-border rounded-lg p-4 bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('promptContent')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground max-h-32 overflow-y-auto">
|
||||
{prompt.content.length > 500
|
||||
? `${prompt.content.slice(0, 500)}...`
|
||||
: prompt.content
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
{prompt.visibility !== 'published' && (
|
||||
<Button
|
||||
onClick={() => handleApprove(prompt.id)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
{t('approve')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleReject(prompt.id)}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
{t('reject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
49
src/app/api/admin/prompts/[id]/approve/route.ts
Normal file
49
src/app/api/admin/prompts/[id]/approve/route.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createServerSupabaseClient } from '@/lib/supabase-server'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient()
|
||||
const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !supabaseUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: supabaseUser.id },
|
||||
select: { isAdmin: true }
|
||||
})
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Update prompt visibility to published
|
||||
const updatedPrompt = await prisma.prompt.update({
|
||||
where: { id },
|
||||
data: {
|
||||
visibility: 'published',
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Prompt approved successfully',
|
||||
prompt: updatedPrompt
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error approving prompt:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to approve prompt' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
50
src/app/api/admin/prompts/[id]/reject/route.ts
Normal file
50
src/app/api/admin/prompts/[id]/reject/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createServerSupabaseClient } from '@/lib/supabase-server'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient()
|
||||
const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !supabaseUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: supabaseUser.id },
|
||||
select: { isAdmin: true }
|
||||
})
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Update prompt permissions back to private (rejected)
|
||||
const updatedPrompt = await prisma.prompt.update({
|
||||
where: { id },
|
||||
data: {
|
||||
permissions: 'private',
|
||||
visibility: null,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Prompt rejected successfully',
|
||||
prompt: updatedPrompt
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error rejecting prompt:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reject prompt' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
56
src/app/api/admin/prompts/pending/route.ts
Normal file
56
src/app/api/admin/prompts/pending/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createServerSupabaseClient } from '@/lib/supabase-server'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient()
|
||||
const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !supabaseUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: supabaseUser.id },
|
||||
select: { isAdmin: true }
|
||||
})
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get prompts that are public (including pending and published ones for review)
|
||||
const prompts = await prisma.prompt.findMany({
|
||||
where: {
|
||||
permissions: 'public',
|
||||
OR: [
|
||||
{ visibility: null },
|
||||
{ visibility: 'under_review' },
|
||||
{ visibility: 'published' }
|
||||
]
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ prompts })
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching pending prompts:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch prompts' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
53
src/app/api/admin/stats/route.ts
Normal file
53
src/app/api/admin/stats/route.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createServerSupabaseClient } from '@/lib/supabase-server'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient()
|
||||
const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !supabaseUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: supabaseUser.id },
|
||||
select: { isAdmin: true }
|
||||
})
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
const [totalUsers, totalPrompts, sharedPrompts, publishedPrompts] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.prompt.count(),
|
||||
prisma.prompt.count({
|
||||
where: { permissions: 'public' }
|
||||
}),
|
||||
prisma.prompt.count({
|
||||
where: {
|
||||
permissions: 'public',
|
||||
visibility: 'published'
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
totalUsers,
|
||||
totalPrompts,
|
||||
sharedPrompts,
|
||||
publishedPrompts
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
24
src/components/ui/badge.tsx
Normal file
24
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = 'default', className }: BadgeProps) {
|
||||
const baseStyles = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
|
||||
|
||||
const variants = {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground border border-input bg-background hover:bg-accent hover:text-accent-foreground'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(baseStyles, variants[variant], className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
17
src/components/ui/card.tsx
Normal file
17
src/components/ui/card.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Card({ children, className }: CardProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -5,8 +5,10 @@ import { useTranslations } from 'next-intl'
|
||||
import { User as SupabaseUser } from '@supabase/supabase-js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { ChevronDown, User, LogOut } from 'lucide-react'
|
||||
import { ChevronDown, User, LogOut, Settings } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface UserAvatarDropdownProps {
|
||||
user: SupabaseUser
|
||||
@ -23,6 +25,8 @@ export function MobileUserMenu({
|
||||
className
|
||||
}: UserAvatarDropdownProps) {
|
||||
const t = useTranslations('navigation')
|
||||
const { isAdmin } = useUser()
|
||||
const router = useRouter()
|
||||
|
||||
const userName = user.user_metadata?.full_name ||
|
||||
user.user_metadata?.name ||
|
||||
@ -67,6 +71,20 @@ export function MobileUserMenu({
|
||||
<span>{t('profile')}</span>
|
||||
</button>
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus:bg-accent focus:text-accent-foreground focus:outline-none"
|
||||
)}
|
||||
onClick={() => router.push('/admin')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{t('admin')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors",
|
||||
@ -91,6 +109,8 @@ export function UserAvatarDropdown({
|
||||
className
|
||||
}: UserAvatarDropdownProps) {
|
||||
const t = useTranslations('navigation')
|
||||
const { isAdmin } = useUser()
|
||||
const router = useRouter()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
@ -182,6 +202,24 @@ export function UserAvatarDropdown({
|
||||
<span>{t('profile')}</span>
|
||||
</button>
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-sm transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus:bg-accent focus:text-accent-foreground focus:outline-none"
|
||||
)}
|
||||
onClick={() => {
|
||||
router.push('/admin')
|
||||
setIsOpen(false)
|
||||
}}
|
||||
role="menuitem"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{t('admin')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-sm transition-colors",
|
||||
|
60
src/hooks/useUser.ts
Normal file
60
src/hooks/useUser.ts
Normal file
@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
interface UserData {
|
||||
id: string
|
||||
email: string
|
||||
username: string | null
|
||||
avatar: string | null
|
||||
bio: string | null
|
||||
language: string
|
||||
isAdmin: boolean
|
||||
versionLimit: number
|
||||
subscribePlan: string
|
||||
maxVersionLimit: number
|
||||
promptLimit: number
|
||||
creditBalance: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export function useUser() {
|
||||
const { user: supabaseUser, loading: authLoading } = useAuth()
|
||||
const [userData, setUserData] = useState<UserData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
if (!supabaseUser) {
|
||||
setUserData(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/sync')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setUserData(data.user)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!authLoading) {
|
||||
fetchUserData()
|
||||
}
|
||||
}, [supabaseUser, authLoading])
|
||||
|
||||
return {
|
||||
user: supabaseUser,
|
||||
userData,
|
||||
loading: authLoading || loading,
|
||||
isAdmin: userData?.isAdmin || false
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user