From ef64c89e9b20938c829261b41b092805cc1fa8e0 Mon Sep 17 00:00:00 2001 From: robin Date: Mon, 19 Jan 2026 11:31:29 +0100 Subject: [PATCH] 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 --- src/app/admin/bottles/AdminBottlesList.tsx | 278 +++++++++++++++++++++ src/app/admin/bottles/page.tsx | 161 ++++++++++++ src/app/admin/page.tsx | 6 + 3 files changed, 445 insertions(+) create mode 100644 src/app/admin/bottles/AdminBottlesList.tsx create mode 100644 src/app/admin/bottles/page.tsx diff --git a/src/app/admin/bottles/AdminBottlesList.tsx b/src/app/admin/bottles/AdminBottlesList.tsx new file mode 100644 index 0000000..bcd1323 --- /dev/null +++ b/src/app/admin/bottles/AdminBottlesList.tsx @@ -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(null); + const [filterCategory, setFilterCategory] = useState(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(); + 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(); + 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 ( +
+ {/* Search and Filters */} +
+ {/* Search */} +
+ + 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" + /> +
+ + {/* Filters */} +
+ {/* User Filter */} + + + {/* Category Filter */} + + + {/* Sort */} + + + {/* Clear Filters */} + {hasFilters && ( + + )} +
+
+ + {/* Results Count */} +
+ Showing {filteredBottles.length} of {bottles.length} bottles +
+ + {/* Bottles Grid */} +
+ {filteredBottles.map(bottle => { + const avgRating = bottle.tastings?.length > 0 + ? bottle.tastings.reduce((sum, t) => sum + t.rating, 0) / bottle.tastings.length + : 0; + + return ( +
+ {/* Image */} +
+ {bottle.image_url ? ( + {bottle.name + ) : ( +
+ +
+ )} + + {/* Category Badge */} + {bottle.category && ( + + {bottle.category} + + )} + + {/* Rating Badge */} + {avgRating > 0 && ( + + + {avgRating.toFixed(1)} + + )} +
+ + {/* Info */} +
+

+ {bottle.name || 'Unknown'} +

+

+ {bottle.distillery || 'Unknown Distillery'} + {bottle.age && ` • ${bottle.age}y`} + {bottle.abv && ` • ${bottle.abv}%`} +

+ + {/* User & Date */} +
+ + + {bottle.user.display_name || bottle.user.username} + + + {new Date(bottle.created_at).toLocaleDateString('de-DE')} + +
+
+
+ ); + })} +
+ + {/* Empty State */} + {filteredBottles.length === 0 && ( +
+
+ +
+

No Bottles Found

+

+ {hasFilters ? 'Try adjusting your filters.' : 'No bottles have been scanned yet.'} +

+
+ )} +
+ ); +} diff --git a/src/app/admin/bottles/page.tsx b/src/app/admin/bottles/page.tsx new file mode 100644 index 0000000..9409763 --- /dev/null +++ b/src/app/admin/bottles/page.tsx @@ -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, 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 ( +
+
+ {/* Header */} +
+ + + +
+

All Bottles

+

+ View all scanned bottles from all users +

+
+
+ + {error && ( +
+ Error loading bottles: {error.message} +
+ )} + + {/* Stats Cards */} +
+
+
+ + Total Bottles +
+
{stats.totalBottles}
+
+
+
+ + Total Users +
+
{stats.totalUsers}
+
+
+
+ + Avg Rating +
+
+ {stats.avgRating > 0 ? stats.avgRating.toFixed(1) : 'N/A'} +
+
+
+
+ + Top Distillery +
+
+ {stats.topDistilleries[0]?.[0] || 'N/A'} +
+ {stats.topDistilleries[0] && ( +
{stats.topDistilleries[0][1]} bottles
+ )} +
+
+ + {/* Top Distilleries */} +
+

Top 5 Distilleries

+
+ {stats.topDistilleries.map(([name, count]) => ( + + {name} ({count}) + + ))} +
+
+ + {/* Bottles List - Client Component for search/filter */} + +
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 10eac10..007a15b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -123,6 +123,12 @@ export default async function AdminPage() { > Manage Banners + + All Bottles +