- Create /admin/bottles page with comprehensive bottle overview - Show stats: total bottles, total users, avg rating, top distilleries - AdminBottlesList with search, filter by user/category, sorting - Display bottle images, ratings, user info, and dates - Add 'All Bottles' link to admin dashboard
313 lines
19 KiB
TypeScript
313 lines
19 KiB
TypeScript
export const dynamic = 'force-dynamic';
|
|
import { createClient } from '@/lib/supabase/server';
|
|
import { redirect } from 'next/navigation';
|
|
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
|
|
import { BarChart3, TrendingUp, Users, Calendar, AlertCircle } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
|
|
export default async function AdminPage() {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
|
|
console.log('[Admin Page] User:', user?.id, user?.email);
|
|
|
|
if (!user) {
|
|
console.log('[Admin Page] No user found, redirecting to home');
|
|
redirect('/');
|
|
}
|
|
|
|
const isAdmin = await checkIsAdmin(user.id);
|
|
console.log('[Admin Page] Is admin check result:', isAdmin);
|
|
|
|
if (!isAdmin) {
|
|
console.log('[Admin Page] User is not admin, redirecting to home');
|
|
redirect('/');
|
|
}
|
|
|
|
console.log('[Admin Page] Access granted, loading dashboard');
|
|
|
|
// Fetch global API stats
|
|
const stats = await getGlobalApiStats();
|
|
|
|
// Fetch recent API usage without join to avoid relationship errors
|
|
console.log('[Admin Page] Fetching recent API usage...');
|
|
const { data: recentUsageRaw, error: recentError } = await supabase
|
|
.from('api_usage')
|
|
.select('*')
|
|
.order('created_at', { ascending: false })
|
|
.limit(50);
|
|
|
|
console.log('[Admin Page] Recent usage raw - count:', recentUsageRaw?.length, 'error:', recentError);
|
|
|
|
// Get unique user IDs from recent usage
|
|
const recentUserIds = Array.from(new Set(recentUsageRaw?.map(u => u.user_id) || []));
|
|
|
|
// Fetch profiles for these users
|
|
const { data: recentProfiles } = recentUserIds.length > 0
|
|
? await supabase.from('profiles').select('id, username').in('id', recentUserIds)
|
|
: { data: [] };
|
|
|
|
// Combine usage with profiles
|
|
const recentUsage = recentUsageRaw?.map(usage => ({
|
|
...usage,
|
|
profiles: recentProfiles?.find(p => p.id === usage.user_id) || { username: 'Unknown' }
|
|
})) || [];
|
|
|
|
// Fetch per-user statistics
|
|
const { data: userStatsRaw } = await supabase
|
|
.from('api_usage')
|
|
.select('user_id, api_type');
|
|
|
|
// Group by user
|
|
const userStatsMap = new Map<string, { googleSearch: number; geminiAi: number; total: number }>();
|
|
userStatsRaw?.forEach(item => {
|
|
const current = userStatsMap.get(item.user_id) || { googleSearch: 0, geminiAi: 0, total: 0 };
|
|
if (item.api_type === 'google_search') current.googleSearch++;
|
|
if (item.api_type === 'gemini_ai') current.geminiAi++;
|
|
current.total++;
|
|
userStatsMap.set(item.user_id, current);
|
|
});
|
|
|
|
// Get user details for top users
|
|
const topUserIds = Array.from(userStatsMap.entries())
|
|
.sort((a, b) => b[1].total - a[1].total)
|
|
.slice(0, 10)
|
|
.map(([userId]) => userId);
|
|
|
|
const { data: topUsers } = topUserIds.length > 0
|
|
? await supabase.from('profiles').select('id, username').in('id', topUserIds)
|
|
: { data: [] };
|
|
|
|
const topUsersWithStats = topUsers?.map(user => ({
|
|
...user,
|
|
stats: userStatsMap.get(user.id)!
|
|
})) || [];
|
|
|
|
return (
|
|
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
|
|
<div className="max-w-7xl mx-auto space-y-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">Admin Dashboard</h1>
|
|
<p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Link
|
|
href="/admin/ocr-logs"
|
|
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-xl font-bold transition-colors"
|
|
>
|
|
OCR Logs
|
|
</Link>
|
|
<Link
|
|
href="/admin/plans"
|
|
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"
|
|
>
|
|
Manage Plans
|
|
</Link>
|
|
<Link
|
|
href="/admin/tags"
|
|
className="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white rounded-xl font-bold transition-colors"
|
|
>
|
|
Manage Tags
|
|
</Link>
|
|
<Link
|
|
href="/admin/users"
|
|
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-bold transition-colors"
|
|
>
|
|
Manage Users
|
|
</Link>
|
|
<Link
|
|
href="/admin/banners"
|
|
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-xl font-bold transition-colors"
|
|
>
|
|
Manage Banners
|
|
</Link>
|
|
<Link
|
|
href="/admin/bottles"
|
|
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-bold transition-colors"
|
|
>
|
|
All Bottles
|
|
</Link>
|
|
<Link
|
|
href="/"
|
|
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
|
>
|
|
Back to App
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Global Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
|
<BarChart3 size={20} className="text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<span className="text-xs font-black uppercase text-zinc-400">Total Calls</span>
|
|
</div>
|
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.totalCalls || 0}</div>
|
|
<div className="text-xs text-zinc-500 mt-1">All time</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
|
<Calendar size={20} className="text-green-600 dark:text-green-400" />
|
|
</div>
|
|
<span className="text-xs font-black uppercase text-zinc-400">Today</span>
|
|
</div>
|
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.todayCalls || 0}</div>
|
|
<div className="text-xs text-zinc-500 mt-1">
|
|
{stats?.todayCalls && stats.todayCalls >= 80 ? (
|
|
<span className="text-red-500 font-bold flex items-center gap-1">
|
|
<AlertCircle size={12} /> Limit reached
|
|
</span>
|
|
) : (
|
|
`${80 - (stats?.todayCalls || 0)} remaining`
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
|
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
|
|
</div>
|
|
<span className="text-xs font-black uppercase text-zinc-400">Google Search</span>
|
|
</div>
|
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.googleSearchCalls || 0}</div>
|
|
<div className="text-xs text-zinc-500 mt-1">Whiskybase searches</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
|
<Users size={20} className="text-purple-600 dark:text-purple-400" />
|
|
</div>
|
|
<span className="text-xs font-black uppercase text-zinc-400">Gemini AI</span>
|
|
</div>
|
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.geminiAiCalls || 0}</div>
|
|
<div className="text-xs text-zinc-500 mt-1">Bottle analyses</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Users */}
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
|
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Top Users by API Usage</h2>
|
|
<div className="space-y-3">
|
|
{topUsersWithStats.map((user, index) => (
|
|
<div key={user.id} className="flex items-center justify-between p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-700 dark:text-amber-500 font-black text-sm">
|
|
{index + 1}
|
|
</div>
|
|
<div>
|
|
<div className="font-bold text-zinc-900 dark:text-white">{user.username}</div>
|
|
<div className="text-xs text-zinc-500">
|
|
{user.stats.googleSearch} searches · {user.stats.geminiAi} analyses
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-2xl font-black text-zinc-900 dark:text-white">{user.stats.total}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent API Calls */}
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
|
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent API Calls</h2>
|
|
<div className="text-sm text-zinc-500 mb-4">
|
|
Total calls logged: {recentUsage?.length || 0}
|
|
</div>
|
|
{!recentUsage || recentUsage.length === 0 ? (
|
|
<div className="text-center py-8 text-zinc-500">
|
|
<AlertCircle className="mx-auto mb-2" size={32} />
|
|
<p>No API calls recorded yet</p>
|
|
{recentError && (
|
|
<p className="text-red-500 text-xs mt-2">Error: {recentError.message}</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-zinc-200 dark:border-zinc-800">
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Time</th>
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">User</th>
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">API/Provider</th>
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Model</th>
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Endpoint</th>
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentUsage.map((call: any) => (
|
|
<tr key={call.id} className="border-b border-zinc-100 dark:border-zinc-800/50 hover:bg-zinc-50 dark:hover:bg-zinc-900/50 transition-colors">
|
|
<td className="py-3 px-4 text-[10px] text-zinc-500 font-mono">
|
|
{new Date(call.created_at).toLocaleString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit', day: '2-digit', month: '2-digit' })}
|
|
</td>
|
|
<td className="py-3 px-4 text-sm font-bold text-zinc-900 dark:text-white">
|
|
{call.profiles?.username || 'Unknown'}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<div className="flex flex-col gap-1">
|
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase w-fit ${call.api_type === 'google_search'
|
|
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
|
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400'
|
|
}`}>
|
|
{call.api_type === 'google_search' ? 'Google' : 'AI'}
|
|
</span>
|
|
{call.provider && (
|
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
|
|
via {call.provider}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className="text-[10px] font-mono text-zinc-600 dark:text-zinc-400 block max-w-[120px] truncate" title={call.model}>
|
|
{call.model || '-'}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<div className="space-y-1">
|
|
<div className="text-[10px] font-bold text-zinc-500 uppercase">{call.endpoint}</div>
|
|
{call.response_text && (
|
|
<details className="text-[10px]">
|
|
<summary className="cursor-pointer text-orange-600 hover:text-orange-700 font-bold uppercase transition-colors">Response</summary>
|
|
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-950 rounded border border-zinc-200 dark:border-zinc-800 overflow-x-auto max-w-sm whitespace-pre-wrap font-mono text-[9px] text-zinc-400">
|
|
{call.response_text}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
{call.success ? (
|
|
<span className="text-green-600 dark:text-green-400 font-black text-xs">OK</span>
|
|
) : (
|
|
<div className="group relative">
|
|
<span className="text-red-600 dark:text-red-400 font-black text-xs cursor-help">ERR</span>
|
|
{call.error_message && (
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2 bg-red-600 text-white text-[9px] rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
|
|
{call.error_message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|