← back to IWasCute
src/app/admin/page.tsx
1372 lines
'use client'
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import {
Shield,
CheckCircle2,
XCircle,
Clock,
AlertTriangle,
Users,
ImageIcon,
DollarSign,
Search,
Eye,
Scale,
FileWarning,
Ban,
BarChart3,
Settings,
LogOut,
Loader2,
Flag,
RefreshCw,
} from 'lucide-react'
import Link from 'next/link'
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type AdminTab = 'overview' | 'queue' | 'dmca' | 'users'
interface AuthUser {
id: string
email: string
display_name: string
role: string
}
interface Stats {
pending: number
approved: number
rejected: number
users: number
revenue: number
dmca_open: number
}
interface QueueItem {
id: string
status: string
created_at: string
copyright_relationship: string
copyright_permission: string
display_name: string
email: string
photo_count: number
photos: { id: string; filename: string; exif_year: number | null }[] | null
}
interface DMCAClaim {
id: string
claimant_name: string
claimant_email: string
reason: string
status: string
filed_at: string
upload_uuid: string | null
upload_user: string | null
}
interface UserRow {
id: string
email: string
display_name: string
role: string
is_verified: boolean
created_at: string
upload_count: number
photo_count: number
total_earnings: number
approved_uploads: number
}
interface OverviewData {
stats: Stats
recent_queue: QueueItem[]
recent_dmca: DMCAClaim[]
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function timeAgo(dateStr: string): string {
const now = new Date()
const date = new Date(dateStr)
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHours = Math.floor(diffMin / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 30) return `${diffDays}d ago`
const diffMonths = Math.floor(diffDays / 30)
return `${diffMonths}mo ago`
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
}
/* ------------------------------------------------------------------ */
/* Alert Banner */
/* ------------------------------------------------------------------ */
interface AlertState {
type: 'success' | 'error'
message: string
}
function AlertBanner({ alert, onDismiss }: { alert: AlertState; onDismiss: () => void }) {
useEffect(() => {
const timer = setTimeout(onDismiss, 4000)
return () => clearTimeout(timer)
}, [onDismiss])
return (
<div
className="fixed top-4 right-4 z-50 px-5 py-3 rounded-xl shadow-lg flex items-center gap-3 text-sm font-semibold"
style={{
background: alert.type === 'success' ? '#8BBCAA' : '#F4846F',
color: 'white',
}}
>
{alert.type === 'success' ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
{alert.message}
<button onClick={onDismiss} className="ml-2 opacity-70 hover:opacity-100">
<XCircle size={14} />
</button>
</div>
)
}
/* ------------------------------------------------------------------ */
/* StatusBadge */
/* ------------------------------------------------------------------ */
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { bg: string; color: string }> = {
pending: { bg: 'rgba(255, 181, 167, 0.2)', color: '#F4846F' },
flagged: { bg: 'rgba(244, 132, 111, 0.25)', color: '#F4846F' },
in_review: { bg: 'rgba(255, 224, 203, 0.4)', color: '#6B705C' },
approved: { bg: 'rgba(139, 188, 170, 0.25)', color: '#8BBCAA' },
rejected: { bg: 'rgba(107, 112, 92, 0.2)', color: '#6B705C' },
open: { bg: 'rgba(244, 132, 111, 0.2)', color: '#F4846F' },
investigating: { bg: 'rgba(255, 224, 203, 0.4)', color: '#6B705C' },
taken_down: { bg: 'rgba(107, 112, 92, 0.2)', color: '#6B705C' },
counter_noticed: { bg: 'rgba(139, 188, 170, 0.25)', color: '#8BBCAA' },
dismissed: { bg: 'rgba(139, 188, 170, 0.25)', color: '#8BBCAA' },
resolved: { bg: 'rgba(139, 188, 170, 0.25)', color: '#8BBCAA' },
active: { bg: 'rgba(139, 188, 170, 0.25)', color: '#8BBCAA' },
suspended: { bg: 'rgba(244, 132, 111, 0.2)', color: '#F4846F' },
dmca_removed: { bg: 'rgba(107, 112, 92, 0.2)', color: '#6B705C' },
}
const c = config[status] ?? config.pending
return (
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider whitespace-nowrap"
style={{ background: c.bg, color: c.color }}
>
{status.replace(/_/g, ' ')}
</span>
)
}
/* ------------------------------------------------------------------ */
/* Loading Spinner */
/* ------------------------------------------------------------------ */
function LoadingPane({ text }: { text?: string }) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<Loader2 size={28} className="animate-spin" style={{ color: '#F4846F' }} />
<p className="text-xs font-semibold" style={{ color: '#6B705C' }}>{text || 'Loading...'}</p>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Empty State */
/* ------------------------------------------------------------------ */
function EmptyState({ icon: Icon, message }: { icon: typeof Shield; message: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Icon size={32} style={{ color: '#B5D5C5' }} />
<p className="text-sm font-semibold" style={{ color: '#6B705C' }}>{message}</p>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Login Form */
/* ------------------------------------------------------------------ */
function LoginForm({ onLogin }: { onLogin: (user: AuthUser) => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'login', email, password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Login failed')
setLoading(false)
return
}
if (data.user?.role !== 'admin') {
setError('Admin access required')
setLoading(false)
return
}
onLogin(data.user)
} catch {
setError('Network error. Please try again.')
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--color-cream)' }}>
<div
className="w-full max-w-sm rounded-3xl p-8 shadow-xl"
style={{ background: 'rgba(255,255,255,0.7)', border: '1px solid rgba(255,224,203,0.5)' }}
>
<div className="flex items-center gap-2 mb-6 justify-center">
<Shield size={22} style={{ color: '#F4846F' }} />
<span className="text-lg font-black tracking-tighter" style={{ color: '#3D405B' }}>
i was cute
</span>
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(255,181,167,0.2)', color: '#FFB5A7' }}>
ADMIN
</span>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-[10px] font-bold uppercase tracking-wider mb-1.5" style={{ color: '#6B705C' }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-xl text-sm border outline-none"
style={{ background: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
placeholder="admin@iwascute.com"
required
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase tracking-wider mb-1.5" style={{ color: '#6B705C' }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 rounded-xl text-sm border outline-none"
style={{ background: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
placeholder="Enter password"
required
/>
</div>
{error && (
<p className="text-xs font-semibold px-3 py-2 rounded-lg" style={{ background: 'rgba(244,132,111,0.15)', color: '#F4846F' }}>
{error}
</p>
)}
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2"
style={{ background: '#F4846F', color: 'white', opacity: loading ? 0.7 : 1 }}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <Shield size={16} />}
{loading ? 'Signing in...' : 'Sign In'}
</motion.button>
</form>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Overview Tab */
/* ------------------------------------------------------------------ */
function OverviewTab({
data,
loading,
onSwitchTab,
}: {
data: OverviewData | null
loading: boolean
onSwitchTab: (tab: AdminTab) => void
}) {
if (loading || !data) return <LoadingPane text="Loading overview..." />
const { stats, recent_queue, recent_dmca } = data
const statCards = [
{ label: 'Pending Review', value: String(stats.pending), icon: Clock, color: '#F4846F' },
{ label: 'Approved', value: String(stats.approved), icon: CheckCircle2, color: '#8BBCAA' },
{ label: 'Rejected', value: String(stats.rejected), icon: XCircle, color: '#6B705C' },
{ label: 'Total Users', value: String(stats.users), icon: Users, color: '#FFB5A7' },
{ label: 'Revenue', value: formatCurrency(stats.revenue), icon: DollarSign, color: '#F4846F' },
{ label: 'DMCA Claims', value: String(stats.dmca_open), icon: AlertTriangle, color: '#FFB5A7' },
]
return (
<div className="flex flex-col gap-6">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{statCards.map((s) => {
const Icon = s.icon
return (
<div
key={s.label}
className="p-4 rounded-2xl"
style={{ background: 'rgba(255,255,255,0.5)', border: '1px solid rgba(255,224,203,0.5)' }}
>
<div className="flex items-center gap-2 mb-2">
<Icon size={14} style={{ color: s.color }} />
<span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: '#6B705C' }}>
{s.label}
</span>
</div>
<p className="text-2xl font-black" style={{ color: '#3D405B' }}>{s.value}</p>
</div>
)
})}
</div>
{/* Quick actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Recent queue */}
<div
className="rounded-2xl p-5"
style={{ background: 'rgba(255,255,255,0.5)', border: '1px solid rgba(255,224,203,0.5)' }}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold" style={{ color: '#3D405B' }}>Review Queue</h3>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onSwitchTab('queue')}
className="text-xs font-semibold px-2 py-1 rounded-full cursor-pointer"
style={{ background: 'rgba(244,132,111,0.15)', color: '#F4846F' }}
>
{stats.pending} pending
</motion.button>
</div>
{recent_queue.length === 0 ? (
<p className="text-xs py-4 text-center" style={{ color: '#6B705C' }}>No pending uploads</p>
) : (
recent_queue.slice(0, 5).map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-3 border-b last:border-b-0"
style={{ borderColor: 'rgba(255,224,203,0.4)' }}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'rgba(255,181,167,0.2)' }}>
<ImageIcon size={14} style={{ color: '#F4846F' }} />
</div>
<div>
<p className="text-xs font-semibold" style={{ color: '#3D405B' }}>{item.display_name || item.email}</p>
<p className="text-[10px]" style={{ color: '#6B705C' }}>
{item.photo_count} photo{Number(item.photo_count) !== 1 ? 's' : ''} · {timeAgo(item.created_at)}
</p>
</div>
</div>
<StatusBadge status={item.status} />
</div>
))
)}
</div>
{/* DMCA alerts */}
<div
className="rounded-2xl p-5"
style={{ background: 'rgba(255,255,255,0.5)', border: '1px solid rgba(255,224,203,0.5)' }}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold" style={{ color: '#3D405B' }}>DMCA Claims</h3>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onSwitchTab('dmca')}
className="text-xs font-semibold px-2 py-1 rounded-full cursor-pointer"
style={{ background: 'rgba(244,132,111,0.15)', color: '#F4846F' }}
>
{stats.dmca_open} open
</motion.button>
</div>
{recent_dmca.length === 0 ? (
<p className="text-xs py-4 text-center" style={{ color: '#6B705C' }}>No DMCA claims</p>
) : (
recent_dmca.slice(0, 5).map((claim) => (
<div
key={claim.id}
className="flex items-center justify-between py-3 border-b last:border-b-0"
style={{ borderColor: 'rgba(255,224,203,0.4)' }}
>
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ background: claim.status === 'open' ? 'rgba(244,132,111,0.2)' : 'rgba(139,188,170,0.2)' }}
>
<FileWarning size={14} style={{ color: claim.status === 'open' ? '#F4846F' : '#8BBCAA' }} />
</div>
<div>
<p className="text-xs font-semibold" style={{ color: '#3D405B' }}>{claim.claimant_name}</p>
<p className="text-[10px]" style={{ color: '#6B705C' }}>{timeAgo(claim.filed_at)}</p>
</div>
</div>
<StatusBadge status={claim.status} />
</div>
))
)}
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Queue Tab */
/* ------------------------------------------------------------------ */
function QueueTab({
onAction,
actionLoading,
}: {
onAction: (action: string, payload: Record<string, string>) => Promise<void>
actionLoading: string | null
}) {
const [queue, setQueue] = useState<QueueItem[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const [rejectId, setRejectId] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
const [flagId, setFlagId] = useState<string | null>(null)
const [flagNotes, setFlagNotes] = useState('')
const fetchQueue = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ view: 'queue' })
if (statusFilter !== 'all') params.set('status', statusFilter)
if (search) params.set('search', search)
const res = await fetch(`/api/admin?${params}`)
if (res.ok) {
const data = await res.json()
setQueue(data.queue || [])
}
} catch {
// silently fail, user can retry
} finally {
setLoading(false)
}
}, [statusFilter, search])
useEffect(() => {
fetchQueue()
}, [fetchQueue])
const handleApprove = async (uploadId: string) => {
await onAction('approve_upload', { upload_id: uploadId })
fetchQueue()
}
const handleReject = async () => {
if (!rejectId) return
await onAction('reject_upload', { upload_id: rejectId, reason: rejectReason || 'Rights verification failed' })
setRejectId(null)
setRejectReason('')
fetchQueue()
}
const handleFlag = async () => {
if (!flagId) return
await onAction('flag_upload', { upload_id: flagId, notes: flagNotes })
setFlagId(null)
setFlagNotes('')
fetchQueue()
}
return (
<div className="flex flex-col gap-4">
{/* Search / Filter */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: '#6B705C' }} />
<input
type="text"
placeholder="Search by user or submission ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 rounded-xl text-sm border outline-none"
style={{ background: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2.5 rounded-xl text-xs font-semibold border"
style={{ background: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
>
<option value="all">All status</option>
<option value="pending">Pending</option>
<option value="flagged">Flagged</option>
<option value="in_review">In Review</option>
</select>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchQueue}
className="p-2.5 rounded-xl border"
style={{ background: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,224,203,0.5)', color: '#6B705C' }}
>
<RefreshCw size={14} />
</motion.button>
</div>
{loading ? (
<LoadingPane text="Loading review queue..." />
) : queue.length === 0 ? (
<EmptyState icon={CheckCircle2} message="No uploads matching this filter" />
) : (
queue.map((item) => {
const photoCount = Number(item.photo_count)
const isActing = actionLoading === item.id
return (
<div
key={item.id}
className="rounded-2xl p-5 flex flex-col md:flex-row md:items-center gap-4"
style={{
background: 'rgba(255,255,255,0.5)',
border: `1px solid ${item.status === 'flagged' ? 'rgba(244,132,111,0.4)' : 'rgba(255,224,203,0.5)'}`,
opacity: isActing ? 0.6 : 1,
}}
>
{/* Thumbnails placeholder */}
<div className="flex gap-1 shrink-0">
{Array.from({ length: Math.min(photoCount, 3) }).map((_, i) => (
<div
key={i}
className="w-12 h-12 rounded-lg"
style={{ background: ['#FFB5A7', '#B5D5C5', '#FFE0CB'][i % 3] }}
/>
))}
{photoCount > 3 && (
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-xs font-bold"
style={{ background: 'rgba(255,224,203,0.5)', color: '#6B705C' }}
>
+{photoCount - 3}
</div>
)}
{photoCount === 0 && (
<div
className="w-12 h-12 rounded-lg flex items-center justify-center"
style={{ background: 'rgba(255,224,203,0.3)' }}
>
<ImageIcon size={14} style={{ color: '#6B705C' }} />
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-bold" style={{ color: '#3D405B' }}>
{item.display_name || 'Anonymous'}
</p>
<StatusBadge status={item.status} />
{item.status === 'flagged' && <AlertTriangle size={12} style={{ color: '#F4846F' }} />}
</div>
<p className="text-xs mt-1" style={{ color: '#6B705C' }}>
{item.id.slice(0, 8)}... · {photoCount} photo{photoCount !== 1 ? 's' : ''} ·
Copyright: {item.copyright_relationship || 'n/a'} ·
Permission: {item.copyright_permission || 'n/a'} · {timeAgo(item.created_at)}
</p>
{item.email && (
<p className="text-[10px] mt-0.5" style={{ color: '#6B705C' }}>{item.email}</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleApprove(item.id)}
disabled={isActing}
className="flex items-center gap-1 px-3 py-2 rounded-xl text-xs font-semibold"
style={{ background: 'rgba(139,188,170,0.25)', color: '#8BBCAA' }}
>
{isActing ? <Loader2 size={12} className="animate-spin" /> : <CheckCircle2 size={12} />} Approve
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setRejectId(item.id)}
disabled={isActing}
className="flex items-center gap-1 px-3 py-2 rounded-xl text-xs font-semibold"
style={{ background: 'rgba(244,132,111,0.15)', color: '#F4846F' }}
>
<XCircle size={12} /> Reject
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setFlagId(item.id)}
disabled={isActing}
className="flex items-center gap-1 px-3 py-2 rounded-xl text-xs font-semibold"
style={{ background: 'rgba(255,255,255,0.6)', color: '#6B705C' }}
>
<Flag size={12} /> Flag
</motion.button>
</div>
</div>
)
})
)}
{/* Reject Modal */}
{rejectId && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setRejectId(null)}>
<div
className="w-full max-w-md rounded-2xl p-6 shadow-xl"
style={{ background: 'var(--color-cream)', border: '1px solid rgba(255,224,203,0.5)' }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-bold mb-3" style={{ color: '#3D405B' }}>Reject Upload</h3>
<p className="text-xs mb-3" style={{ color: '#6B705C' }}>Upload: {rejectId.slice(0, 8)}...</p>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Reason for rejection (optional)..."
rows={3}
className="w-full px-3 py-2 rounded-xl text-sm border outline-none resize-none mb-4"
style={{ background: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
/>
<div className="flex gap-2 justify-end">
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => setRejectId(null)}
className="px-4 py-2 rounded-xl text-xs font-semibold"
style={{ background: 'rgba(255,255,255,0.6)', color: '#6B705C' }}
>
Cancel
</motion.button>
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={handleReject}
className="px-4 py-2 rounded-xl text-xs font-semibold"
style={{ background: '#F4846F', color: 'white' }}
>
Confirm Reject
</motion.button>
</div>
</div>
</div>
)}
{/* Flag Modal */}
{flagId && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setFlagId(null)}>
<div
className="w-full max-w-md rounded-2xl p-6 shadow-xl"
style={{ background: 'var(--color-cream)', border: '1px solid rgba(255,224,203,0.5)' }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-bold mb-3" style={{ color: '#3D405B' }}>Flag Upload for Review</h3>
<p className="text-xs mb-3" style={{ color: '#6B705C' }}>Upload: {flagId.slice(0, 8)}...</p>
<textarea
value={flagNotes}
onChange={(e) => setFlagNotes(e.target.value)}
placeholder="Notes about why this is flagged..."
rows={3}
className="w-full px-3 py-2 rounded-xl text-sm border outline-none resize-none mb-4"
style={{ background: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
/>
<div className="flex gap-2 justify-end">
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => setFlagId(null)}
className="px-4 py-2 rounded-xl text-xs font-semibold"
style={{ background: 'rgba(255,255,255,0.6)', color: '#6B705C' }}
>
Cancel
</motion.button>
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={handleFlag}
className="px-4 py-2 rounded-xl text-xs font-semibold"
style={{ background: '#F4846F', color: 'white' }}
>
Confirm Flag
</motion.button>
</div>
</div>
</div>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* DMCA Tab */
/* ------------------------------------------------------------------ */
function DMCATab({
onAction,
actionLoading,
}: {
onAction: (action: string, payload: Record<string, string>) => Promise<void>
actionLoading: string | null
}) {
const [claims, setClaims] = useState<DMCAClaim[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('all')
const [counterModal, setCounterModal] = useState<string | null>(null)
const [counterNotes, setCounterNotes] = useState('')
const [dismissModal, setDismissModal] = useState<string | null>(null)
const [dismissNotes, setDismissNotes] = useState('')
const fetchClaims = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ view: 'dmca' })
if (statusFilter !== 'all') params.set('status', statusFilter)
const res = await fetch(`/api/admin?${params}`)
if (res.ok) {
const data = await res.json()
setClaims(data.claims || [])
}
} catch {
// silently fail
} finally {
setLoading(false)
}
}, [statusFilter])
useEffect(() => {
fetchClaims()
}, [fetchClaims])
const handleTakedown = async (claimId: string) => {
await onAction('dmca_takedown', { claim_id: claimId })
fetchClaims()
}
const handleCounter = async () => {
if (!counterModal) return
await onAction('dmca_counter', { claim_id: counterModal, notes: counterNotes })
setCounterModal(null)
setCounterNotes('')
fetchClaims()
}
const handleDismiss = async () => {
if (!dismissModal) return
await onAction('dmca_dismiss', { claim_id: dismissModal, notes: dismissNotes })
setDismissModal(null)
setDismissNotes('')
fetchClaims()
}
return (
<div className="flex flex-col gap-4">
{/* Filter */}
<div className="flex items-center gap-3">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2.5 rounded-xl text-xs font-semibold border"
style={{ background: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
>
<option value="all">All status</option>
<option value="open">Open</option>
<option value="investigating">Investigating</option>
<option value="taken_down">Taken Down</option>
<option value="counter_noticed">Counter Noticed</option>
<option value="dismissed">Dismissed</option>
</select>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchClaims}
className="p-2.5 rounded-xl border"
style={{ background: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,224,203,0.5)', color: '#6B705C' }}
>
<RefreshCw size={14} />
</motion.button>
</div>
{loading ? (
<LoadingPane text="Loading DMCA claims..." />
) : claims.length === 0 ? (
<EmptyState icon={FileWarning} message="No DMCA claims matching this filter" />
) : (
claims.map((claim) => {
const isOpen = claim.status === 'open' || claim.status === 'investigating'
const isActing = actionLoading === claim.id
return (
<div
key={claim.id}
className="rounded-2xl p-6"
style={{
background: 'rgba(255,255,255,0.5)',
border: `1px solid ${isOpen ? 'rgba(244,132,111,0.3)' : 'rgba(255,224,203,0.5)'}`,
opacity: isActing ? 0.6 : 1,
}}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center"
style={{ background: isOpen ? 'rgba(244,132,111,0.15)' : 'rgba(139,188,170,0.15)' }}
>
<FileWarning size={18} style={{ color: isOpen ? '#F4846F' : '#8BBCAA' }} />
</div>
<div>
<p className="text-sm font-bold" style={{ color: '#3D405B' }}>DMCA-{claim.id.slice(0, 8)}</p>
<p className="text-xs" style={{ color: '#6B705C' }}>
Filed {timeAgo(claim.filed_at)}
{claim.upload_uuid && <> · Upload: {claim.upload_uuid.slice(0, 8)}...</>}
</p>
</div>
</div>
<StatusBadge status={claim.status} />
</div>
<p className="text-sm mb-2" style={{ color: '#6B705C' }}>
<strong style={{ color: '#3D405B' }}>Claimant:</strong> {claim.claimant_name}
{claim.claimant_email && <span className="ml-2 text-xs">({claim.claimant_email})</span>}
</p>
{claim.upload_user && (
<p className="text-sm mb-2" style={{ color: '#6B705C' }}>
<strong style={{ color: '#3D405B' }}>Upload by:</strong> {claim.upload_user}
</p>
)}
<p className="text-sm mb-4" style={{ color: '#6B705C' }}>
<strong style={{ color: '#3D405B' }}>Reason:</strong> {claim.reason}
</p>
{isOpen && (
<div className="flex gap-2 flex-wrap">
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => handleTakedown(claim.id)}
disabled={isActing}
className="px-4 py-2 rounded-xl text-xs font-semibold flex items-center gap-1"
style={{ background: 'rgba(244,132,111,0.15)', color: '#F4846F' }}
>
{isActing ? <Loader2 size={12} className="animate-spin" /> : <Ban size={12} />} Take Down
</motion.button>
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => setCounterModal(claim.id)}
disabled={isActing}
className="px-4 py-2 rounded-xl text-xs font-semibold flex items-center gap-1"
style={{ background: 'rgba(139,188,170,0.2)', color: '#8BBCAA' }}
>
<Scale size={12} /> Counter-notice
</motion.button>
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => setDismissModal(claim.id)}
disabled={isActing}
className="px-4 py-2 rounded-xl text-xs font-semibold flex items-center gap-1"
style={{ background: 'rgba(255,255,255,0.5)', color: '#6B705C' }}
>
<Eye size={12} /> Dismiss
</motion.button>
</div>
)}
</div>
)
})
)}
{/* Counter-notice Modal */}
{counterModal && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setCounterModal(null)}>
<div
className="w-full max-w-md rounded-2xl p-6 shadow-xl"
style={{ background: 'var(--color-cream)', border: '1px solid rgba(255,224,203,0.5)' }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-bold mb-3" style={{ color: '#3D405B' }}>File Counter-Notice</h3>
<textarea
value={counterNotes}
onChange={(e) => setCounterNotes(e.target.value)}
placeholder="Counter-notice details..."
rows={4}
className="w-full px-3 py-2 rounded-xl text-sm border outline-none resize-none mb-4"
style={{ background: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
/>
<div className="flex gap-2 justify-end">
<motion.button whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} onClick={() => setCounterModal(null)} className="px-4 py-2 rounded-xl text-xs font-semibold" style={{ background: 'rgba(255,255,255,0.6)', color: '#6B705C' }}>
Cancel
</motion.button>
<motion.button whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} onClick={handleCounter} className="px-4 py-2 rounded-xl text-xs font-semibold" style={{ background: '#8BBCAA', color: 'white' }}>
Submit Counter-Notice
</motion.button>
</div>
</div>
</div>
)}
{/* Dismiss Modal */}
{dismissModal && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setDismissModal(null)}>
<div
className="w-full max-w-md rounded-2xl p-6 shadow-xl"
style={{ background: 'var(--color-cream)', border: '1px solid rgba(255,224,203,0.5)' }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-bold mb-3" style={{ color: '#3D405B' }}>Dismiss DMCA Claim</h3>
<textarea
value={dismissNotes}
onChange={(e) => setDismissNotes(e.target.value)}
placeholder="Reason for dismissal..."
rows={3}
className="w-full px-3 py-2 rounded-xl text-sm border outline-none resize-none mb-4"
style={{ background: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
/>
<div className="flex gap-2 justify-end">
<motion.button whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} onClick={() => setDismissModal(null)} className="px-4 py-2 rounded-xl text-xs font-semibold" style={{ background: 'rgba(255,255,255,0.6)', color: '#6B705C' }}>
Cancel
</motion.button>
<motion.button whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} onClick={handleDismiss} className="px-4 py-2 rounded-xl text-xs font-semibold" style={{ background: '#F4846F', color: 'white' }}>
Confirm Dismiss
</motion.button>
</div>
</div>
</div>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Users Tab */
/* ------------------------------------------------------------------ */
function UsersTab() {
const [users, setUsers] = useState<UserRow[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const fetchUsers = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ view: 'users' })
if (search) params.set('search', search)
const res = await fetch(`/api/admin?${params}`)
if (res.ok) {
const data = await res.json()
setUsers(data.users || [])
}
} catch {
// silently fail
} finally {
setLoading(false)
}
}, [search])
useEffect(() => {
fetchUsers()
}, [fetchUsers])
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: '#6B705C' }} />
<input
type="text"
placeholder="Search users by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 rounded-xl text-sm border outline-none"
style={{ background: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,224,203,0.5)', color: '#3D405B' }}
/>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchUsers}
className="p-2.5 rounded-xl border"
style={{ background: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,224,203,0.5)', color: '#6B705C' }}
>
<RefreshCw size={14} />
</motion.button>
</div>
{loading ? (
<LoadingPane text="Loading users..." />
) : users.length === 0 ? (
<EmptyState icon={Users} message="No users found" />
) : (
<div
className="rounded-2xl overflow-hidden"
style={{ border: '1px solid rgba(255,224,203,0.5)' }}
>
{/* Header */}
<div
className="grid grid-cols-7 gap-4 px-5 py-3 text-[10px] font-bold uppercase tracking-wider"
style={{ background: 'rgba(255,224,203,0.3)', color: '#6B705C' }}
>
<span>User</span>
<span>Email</span>
<span className="text-center">Uploads</span>
<span className="text-center">Photos</span>
<span className="text-center">Earnings</span>
<span className="text-center">Joined</span>
<span className="text-center">Status</span>
</div>
{/* Rows */}
{users.map((user) => (
<div
key={user.id}
className="grid grid-cols-7 gap-4 items-center px-5 py-3.5 border-t hover:bg-white/30 transition-colors"
style={{ borderColor: 'rgba(255,224,203,0.3)', background: 'rgba(255,255,255,0.4)' }}
>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0"
style={{ background: '#FFB5A7', color: '#3D405B' }}
>
{(user.display_name || user.email || '?')
.split(' ')
.map((n: string) => n[0])
.join('')
.slice(0, 2)
.toUpperCase()}
</div>
<span className="text-xs font-semibold truncate" style={{ color: '#3D405B' }}>
{user.display_name || 'No name'}
</span>
</div>
<span className="text-xs truncate" style={{ color: '#6B705C' }}>{user.email}</span>
<span className="text-xs text-center font-semibold" style={{ color: '#3D405B' }}>
{user.upload_count}
{Number(user.approved_uploads) > 0 && (
<span className="text-[10px] font-normal ml-0.5" style={{ color: '#8BBCAA' }}>
({user.approved_uploads})
</span>
)}
</span>
<span className="text-xs text-center font-semibold" style={{ color: '#3D405B' }}>{user.photo_count}</span>
<span className="text-xs text-center font-semibold" style={{ color: '#3D405B' }}>
{formatCurrency(Number(user.total_earnings))}
</span>
<span className="text-xs text-center" style={{ color: '#6B705C' }}>{formatDate(user.created_at)}</span>
<div className="flex justify-center">
{user.is_verified ? (
<StatusBadge status="active" />
) : (
<StatusBadge status="pending" />
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Tab Config */
/* ------------------------------------------------------------------ */
const TABS: { id: AdminTab; label: string; icon: typeof Shield }[] = [
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'queue', label: 'Review Queue', icon: Scale },
{ id: 'dmca', label: 'DMCA', icon: FileWarning },
{ id: 'users', label: 'Users', icon: Users },
]
/* ------------------------------------------------------------------ */
/* Main Admin Page */
/* ------------------------------------------------------------------ */
export default function AdminPage() {
// Auth state
const [authChecking, setAuthChecking] = useState(true)
const [user, setUser] = useState<AuthUser | null>(null)
// Dashboard state
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
const [overview, setOverview] = useState<OverviewData | null>(null)
const [overviewLoading, setOverviewLoading] = useState(false)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [alert, setAlert] = useState<AlertState | null>(null)
// Check auth on mount
useEffect(() => {
fetch('/api/auth')
.then((res) => res.json())
.then((data) => {
if (data.user?.role === 'admin') {
setUser(data.user)
}
})
.catch(() => {})
.finally(() => setAuthChecking(false))
}, [])
// Load overview when authenticated and on overview tab
const fetchOverview = useCallback(async () => {
setOverviewLoading(true)
try {
const res = await fetch('/api/admin?view=overview')
if (res.ok) {
const data = await res.json()
setOverview(data)
}
} catch {
// silently fail
} finally {
setOverviewLoading(false)
}
}, [])
useEffect(() => {
if (user && activeTab === 'overview') {
fetchOverview()
}
}, [user, activeTab, fetchOverview])
// Generic action handler for POST actions
const handleAction = useCallback(
async (action: string, payload: Record<string, string>) => {
const targetId = payload.upload_id || payload.claim_id || payload.user_id || ''
setActionLoading(targetId)
try {
const res = await fetch('/api/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...payload }),
})
const data = await res.json()
if (res.ok && data.success) {
setAlert({ type: 'success', message: `Action "${action.replace(/_/g, ' ')}" completed successfully` })
// Refresh overview stats in background
fetchOverview()
} else {
setAlert({ type: 'error', message: data.error || 'Action failed' })
}
} catch {
setAlert({ type: 'error', message: 'Network error. Please try again.' })
} finally {
setActionLoading(null)
}
},
[fetchOverview]
)
// Handle logout
const handleLogout = async () => {
await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'logout' }),
})
setUser(null)
setOverview(null)
}
// Auth checking spinner
if (authChecking) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--color-cream)' }}>
<LoadingPane text="Checking authentication..." />
</div>
)
}
// Not authenticated — show login
if (!user) {
return <LoginForm onLogin={setUser} />
}
// Badge counts from overview data
const pendingCount = overview?.stats.pending ?? 0
const dmcaCount = overview?.stats.dmca_open ?? 0
return (
<div className="min-h-screen flex" style={{ background: 'var(--color-cream)' }}>
{/* Alert */}
{alert && <AlertBanner alert={alert} onDismiss={() => setAlert(null)} />}
{/* Sidebar */}
<aside
className="w-56 shrink-0 flex flex-col justify-between p-4"
style={{ background: '#1a1d2e' }}
>
<div>
<div className="flex items-center gap-2 px-3 py-3 mb-6">
<Shield size={18} style={{ color: '#FFB5A7' }} />
<span className="text-sm font-black tracking-tighter" style={{ color: 'white' }}>
i was cute
</span>
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(255,181,167,0.2)', color: '#FFB5A7' }}>
ADMIN
</span>
</div>
<div className="flex flex-col gap-1">
{TABS.map((tab) => {
const Icon = tab.icon
const active = activeTab === tab.id
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-xs font-semibold transition-all text-left"
style={{
background: active ? 'rgba(255,181,167,0.12)' : 'transparent',
color: active ? '#FFB5A7' : 'rgba(255,255,255,0.45)',
}}
>
<Icon size={15} />
{tab.label}
{tab.id === 'queue' && pendingCount > 0 && (
<span className="ml-auto text-[9px] font-bold px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(244,132,111,0.25)', color: '#F4846F' }}>
{pendingCount}
</span>
)}
{tab.id === 'dmca' && dmcaCount > 0 && (
<span className="ml-auto text-[9px] font-bold px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(244,132,111,0.25)', color: '#F4846F' }}>
{dmcaCount}
</span>
)}
</button>
)
})}
</div>
</div>
<div className="flex flex-col gap-1">
{/* Logged-in user info */}
<div className="px-3 py-2 mb-2">
<p className="text-[10px] font-semibold truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
{user.display_name || user.email}
</p>
<p className="text-[9px] truncate" style={{ color: 'rgba(255,255,255,0.3)' }}>
{user.email}
</p>
</div>
<button className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-xs font-medium text-left" style={{ color: 'rgba(255,255,255,0.35)' }}>
<Settings size={14} /> Settings
</button>
<Link href="/" className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-xs font-medium no-underline" style={{ color: 'rgba(255,255,255,0.35)' }}>
<LogOut size={14} /> Exit Admin
</Link>
<button
onClick={handleLogout}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-xs font-medium text-left"
style={{ color: 'rgba(244,132,111,0.7)' }}
>
<LogOut size={14} /> Log Out
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 px-6 py-6 md:px-10 md:py-8 overflow-y-auto">
<div className="max-w-6xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-black tracking-tighter" style={{ color: '#3D405B' }}>
{TABS.find((t) => t.id === activeTab)?.label}
</h1>
<p className="text-xs mt-1" style={{ color: '#6B705C' }}>
{activeTab === 'overview' && 'Platform health and activity at a glance.'}
{activeTab === 'queue' && 'Review uploads for dual-rights clearance.'}
{activeTab === 'dmca' && 'Manage takedown requests and counter-notices.'}
{activeTab === 'users' && 'Manage licensor accounts and activity.'}
</p>
</div>
{activeTab === 'overview' && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchOverview}
className="flex items-center gap-2 px-3 py-2 rounded-xl text-xs font-semibold"
style={{ background: 'rgba(255,255,255,0.6)', color: '#6B705C', border: '1px solid rgba(255,224,203,0.5)' }}
>
<RefreshCw size={12} /> Refresh
</motion.button>
)}
</div>
{activeTab === 'overview' && (
<OverviewTab
data={overview}
loading={overviewLoading}
onSwitchTab={setActiveTab}
/>
)}
{activeTab === 'queue' && (
<QueueTab
onAction={handleAction}
actionLoading={actionLoading}
/>
)}
{activeTab === 'dmca' && (
<DMCATab
onAction={handleAction}
actionLoading={actionLoading}
/>
)}
{activeTab === 'users' && <UsersTab />}
</div>
</main>
</div>
)
}