add admin

This commit is contained in:
songtianlun 2025-08-03 10:46:36 +08:00
parent 5a7584c673
commit 2e44c5865d
16 changed files with 829 additions and 2 deletions

View File

@ -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.",

View File

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

@ -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",

View File

@ -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"
}
}
}

View File

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

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

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

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

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

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

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

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

View File

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