Files
Dramlog-Prod/src/app/admin/bottles/page.tsx
robin ef64c89e9b feat: Add admin page to view all bottles from all users
- 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
2026-01-19 11:31:29 +01:00

162 lines
7.0 KiB
TypeScript

export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import Link from 'next/link';
import { ArrowLeft, Wine, User, Calendar, Star, Search, Filter } from 'lucide-react';
import AdminBottlesList from './AdminBottlesList';
export default async function AdminBottlesPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
// Fetch all bottles from all users with user info
const { data: bottlesRaw, error } = await supabase
.from('bottles')
.select(`
id,
name,
distillery,
image_url,
abv,
age,
category,
status,
created_at,
user_id,
tastings (
id,
rating
)
`)
.order('created_at', { ascending: false })
.limit(500);
// Get unique user IDs
const userIds = Array.from(new Set(bottlesRaw?.map(b => b.user_id) || []));
// Fetch profiles for these users
const { data: profiles } = userIds.length > 0
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
: { data: [] };
// Combine bottles with user info
const bottles = bottlesRaw?.map(bottle => ({
...bottle,
user: profiles?.find(p => p.id === bottle.user_id) || { username: 'Unknown', display_name: null }
})) || [];
// Calculate stats
const stats = {
totalBottles: bottles.length,
totalUsers: userIds.length,
avgRating: bottles.reduce((sum, b) => {
const ratings = b.tastings?.map((t: any) => t.rating).filter((r: number) => r > 0) || [];
const avg = ratings.length > 0 ? ratings.reduce((a: number, b: number) => a + b, 0) / ratings.length : 0;
return sum + avg;
}, 0) / bottles.filter(b => b.tastings && b.tastings.length > 0).length || 0,
topDistilleries: Object.entries(
bottles.reduce((acc: Record<string, number>, b) => {
const d = b.distillery || 'Unknown';
acc[d] = (acc[d] || 0) + 1;
return acc;
}, {})
).sort((a, b) => b[1] - a[1]).slice(0, 5),
};
return (
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/admin"
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-white">All Bottles</h1>
<p className="text-sm text-zinc-500">
View all scanned bottles from all users
</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
Error loading bottles: {error.message}
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Wine size={18} className="text-orange-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Total Bottles</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalBottles}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<User size={18} className="text-blue-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Total Users</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalUsers}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Star size={18} className="text-yellow-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Avg Rating</span>
</div>
<div className="text-2xl font-black text-white">
{stats.avgRating > 0 ? stats.avgRating.toFixed(1) : 'N/A'}
</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Calendar size={18} className="text-green-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Top Distillery</span>
</div>
<div className="text-lg font-black text-white truncate">
{stats.topDistilleries[0]?.[0] || 'N/A'}
</div>
{stats.topDistilleries[0] && (
<div className="text-xs text-zinc-500">{stats.topDistilleries[0][1]} bottles</div>
)}
</div>
</div>
{/* Top Distilleries */}
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800 mb-8">
<h3 className="text-sm font-bold text-zinc-400 uppercase mb-3">Top 5 Distilleries</h3>
<div className="flex flex-wrap gap-2">
{stats.topDistilleries.map(([name, count]) => (
<span
key={name}
className="px-3 py-1.5 bg-zinc-800 rounded-lg text-sm text-zinc-300"
>
{name} <span className="text-orange-500 font-bold">({count})</span>
</span>
))}
</div>
</div>
{/* Bottles List - Client Component for search/filter */}
<AdminBottlesList bottles={bottles} />
</div>
</main>
);
}