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
This commit is contained in:
2026-01-19 11:31:29 +01:00
parent c047966b43
commit ef64c89e9b
3 changed files with 445 additions and 0 deletions

View File

@@ -0,0 +1,278 @@
'use client';
import { useState, useMemo } from 'react';
import { Search, Filter, User, Wine, Calendar, Star, X, ChevronDown } from 'lucide-react';
import Image from 'next/image';
interface Bottle {
id: string;
name: string;
distillery: string | null;
image_url: string | null;
abv: number | null;
age: number | null;
category: string | null;
status: string | null;
created_at: string;
user_id: string;
tastings: { id: string; rating: number }[];
user: { username: string; display_name: string | null };
}
interface AdminBottlesListProps {
bottles: Bottle[];
}
export default function AdminBottlesList({ bottles }: AdminBottlesListProps) {
const [search, setSearch] = useState('');
const [filterUser, setFilterUser] = useState<string | null>(null);
const [filterCategory, setFilterCategory] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'created_at' | 'name' | 'distillery' | 'rating'>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Get unique users and categories for filters
const users = useMemo(() => {
const userMap = new Map<string, string>();
bottles.forEach(b => {
userMap.set(b.user_id, b.user.display_name || b.user.username);
});
return Array.from(userMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [bottles]);
const categories = useMemo(() => {
const cats = new Set<string>();
bottles.forEach(b => {
if (b.category) cats.add(b.category);
});
return Array.from(cats).sort();
}, [bottles]);
// Filter and sort bottles
const filteredBottles = useMemo(() => {
let result = bottles;
// Search filter
if (search) {
const searchLower = search.toLowerCase();
result = result.filter(b =>
b.name?.toLowerCase().includes(searchLower) ||
b.distillery?.toLowerCase().includes(searchLower) ||
b.user.username.toLowerCase().includes(searchLower) ||
b.user.display_name?.toLowerCase().includes(searchLower)
);
}
// User filter
if (filterUser) {
result = result.filter(b => b.user_id === filterUser);
}
// Category filter
if (filterCategory) {
result = result.filter(b => b.category === filterCategory);
}
// Sort
result = [...result].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = (a.name || '').localeCompare(b.name || '');
break;
case 'distillery':
comparison = (a.distillery || '').localeCompare(b.distillery || '');
break;
case 'rating':
const avgA = a.tastings?.length > 0
? a.tastings.reduce((sum, t) => sum + t.rating, 0) / a.tastings.length
: 0;
const avgB = b.tastings?.length > 0
? b.tastings.reduce((sum, t) => sum + t.rating, 0) / b.tastings.length
: 0;
comparison = avgA - avgB;
break;
case 'created_at':
default:
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
break;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
return result;
}, [bottles, search, filterUser, filterCategory, sortBy, sortOrder]);
const clearFilters = () => {
setSearch('');
setFilterUser(null);
setFilterCategory(null);
};
const hasFilters = search || filterUser || filterCategory;
return (
<div className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search bottles, distilleries, or users..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600"
/>
</div>
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{/* User Filter */}
<select
value={filterUser || ''}
onChange={e => setFilterUser(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none focus:border-orange-600 appearance-none cursor-pointer"
>
<option value="">All Users</option>
{users.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
{/* Category Filter */}
<select
value={filterCategory || ''}
onChange={e => setFilterCategory(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none focus:border-orange-600 appearance-none cursor-pointer"
>
<option value="">All Categories</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{/* Sort */}
<select
value={`${sortBy}-${sortOrder}`}
onChange={e => {
const [by, order] = e.target.value.split('-') as [typeof sortBy, typeof sortOrder];
setSortBy(by);
setSortOrder(order);
}}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none focus:border-orange-600 appearance-none cursor-pointer"
>
<option value="created_at-desc">Newest First</option>
<option value="created_at-asc">Oldest First</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
<option value="distillery-asc">Distillery A-Z</option>
<option value="rating-desc">Highest Rating</option>
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={clearFilters}
className="px-4 py-3 bg-zinc-800 text-zinc-400 hover:text-white rounded-xl transition-colors flex items-center gap-2"
>
<X size={16} />
Clear
</button>
)}
</div>
</div>
{/* Results Count */}
<div className="text-sm text-zinc-500">
Showing {filteredBottles.length} of {bottles.length} bottles
</div>
{/* Bottles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredBottles.map(bottle => {
const avgRating = bottle.tastings?.length > 0
? bottle.tastings.reduce((sum, t) => sum + t.rating, 0) / bottle.tastings.length
: 0;
return (
<div
key={bottle.id}
className="bg-zinc-900 rounded-2xl border border-zinc-800 overflow-hidden hover:border-zinc-700 transition-colors"
>
{/* Image */}
<div className="aspect-[4/3] relative bg-zinc-800">
{bottle.image_url ? (
<Image
src={bottle.image_url}
alt={bottle.name || 'Bottle'}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Wine size={48} className="text-zinc-700" />
</div>
)}
{/* Category Badge */}
{bottle.category && (
<span className="absolute top-2 left-2 px-2 py-1 bg-black/60 backdrop-blur-sm text-[10px] font-bold text-white rounded-lg uppercase">
{bottle.category}
</span>
)}
{/* Rating Badge */}
{avgRating > 0 && (
<span className="absolute top-2 right-2 px-2 py-1 bg-orange-600/90 backdrop-blur-sm text-xs font-bold text-white rounded-lg flex items-center gap-1">
<Star size={12} fill="currentColor" />
{avgRating.toFixed(1)}
</span>
)}
</div>
{/* Info */}
<div className="p-4">
<h3 className="font-bold text-white truncate mb-1">
{bottle.name || 'Unknown'}
</h3>
<p className="text-sm text-zinc-500 truncate mb-3">
{bottle.distillery || 'Unknown Distillery'}
{bottle.age && `${bottle.age}y`}
{bottle.abv && `${bottle.abv}%`}
</p>
{/* User & Date */}
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1 text-zinc-400">
<User size={12} />
{bottle.user.display_name || bottle.user.username}
</span>
<span className="text-zinc-600">
{new Date(bottle.created_at).toLocaleDateString('de-DE')}
</span>
</div>
</div>
</div>
);
})}
</div>
{/* Empty State */}
{filteredBottles.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
<Wine size={32} className="text-zinc-600" />
</div>
<p className="text-lg font-bold text-white mb-2">No Bottles Found</p>
<p className="text-sm text-zinc-500">
{hasFilters ? 'Try adjusting your filters.' : 'No bottles have been scanned yet.'}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
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>
);
}