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:
278
src/app/admin/bottles/AdminBottlesList.tsx
Normal file
278
src/app/admin/bottles/AdminBottlesList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/app/admin/bottles/page.tsx
Normal file
161
src/app/admin/bottles/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -123,6 +123,12 @@ export default async function AdminPage() {
|
|||||||
>
|
>
|
||||||
Manage Banners
|
Manage Banners
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/"
|
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"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user