feat: Add admin page for tasting sessions
- Create /admin/sessions page showing all sessions from all users - Stats: total sessions, active, hosts, participants, tastings - Filter by host, status (active/ended) - Show session duration, participant count, tasting count - Add 'All Sessions' link to admin dashboard
This commit is contained in:
@@ -141,6 +141,12 @@ export default async function AdminPage() {
|
|||||||
>
|
>
|
||||||
All Tastings
|
All Tastings
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/sessions"
|
||||||
|
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Sessions
|
||||||
|
</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"
|
||||||
|
|||||||
220
src/app/admin/sessions/AdminSessionsList.tsx
Normal file
220
src/app/admin/sessions/AdminSessionsList.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Search, User, Calendar, GlassWater, Users, Check, X, Clock, ExternalLink } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
user_id: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
user: { username: string; display_name: string | null };
|
||||||
|
participantCount: number;
|
||||||
|
tastingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminSessionsListProps {
|
||||||
|
sessions: Session[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSessionsList({ sessions }: AdminSessionsListProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterHost, setFilterHost] = useState<string | null>(null);
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'ended'>('all');
|
||||||
|
|
||||||
|
// Get unique hosts for filter
|
||||||
|
const hosts = useMemo(() => {
|
||||||
|
const hostMap = new Map<string, string>();
|
||||||
|
sessions.forEach(s => {
|
||||||
|
hostMap.set(s.user_id, s.user.display_name || s.user.username);
|
||||||
|
});
|
||||||
|
return Array.from(hostMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
// Filter sessions
|
||||||
|
const filteredSessions = useMemo(() => {
|
||||||
|
let result = sessions;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
result = result.filter(s =>
|
||||||
|
s.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
s.user.username.toLowerCase().includes(searchLower) ||
|
||||||
|
s.user.display_name?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterHost) {
|
||||||
|
result = result.filter(s => s.user_id === filterHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterStatus === 'active') {
|
||||||
|
result = result.filter(s => !s.ended_at);
|
||||||
|
} else if (filterStatus === 'ended') {
|
||||||
|
result = result.filter(s => s.ended_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [sessions, search, filterHost, filterStatus]);
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionDuration = (start: string, end: string | null) => {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const endDate = end ? new Date(end) : new Date();
|
||||||
|
const diffMs = endDate.getTime() - startDate.getTime();
|
||||||
|
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<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 sessions or hosts..."
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={filterHost || ''}
|
||||||
|
onChange={e => setFilterHost(e.target.value || null)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">All Hosts</option>
|
||||||
|
{hosts.map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={e => setFilterStatus(e.target.value as any)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="ended">Ended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
Showing {filteredSessions.length} of {sessions.length} sessions
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredSessions.map(session => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`bg-zinc-900 rounded-2xl border p-4 transition-colors ${!session.ended_at
|
||||||
|
? 'border-orange-600/30 hover:border-orange-600/50'
|
||||||
|
: 'border-zinc-800 hover:border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${!session.ended_at
|
||||||
|
? 'bg-orange-600/20'
|
||||||
|
: 'bg-zinc-800'
|
||||||
|
}`}>
|
||||||
|
<GlassWater size={24} className={
|
||||||
|
!session.ended_at ? 'text-orange-500' : 'text-zinc-500'
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-bold text-white truncate">{session.name}</h3>
|
||||||
|
{!session.ended_at ? (
|
||||||
|
<span className="px-2 py-0.5 bg-orange-600/20 text-orange-500 text-[10px] font-bold uppercase rounded-full flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 bg-zinc-800 text-zinc-500 text-[10px] font-bold uppercase rounded-full flex items-center gap-1">
|
||||||
|
<Check size={10} />
|
||||||
|
Ended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-zinc-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User size={12} />
|
||||||
|
{session.user.display_name || session.user.username}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar size={12} />
|
||||||
|
{formatDate(session.scheduled_at)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={12} />
|
||||||
|
{getSessionDuration(session.scheduled_at, session.ended_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-white">{session.participantCount}</div>
|
||||||
|
<div className="text-[10px] text-zinc-600 uppercase">Buddies</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-orange-500">{session.tastingCount}</div>
|
||||||
|
<div className="text-[10px] text-zinc-600 uppercase">Tastings</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link */}
|
||||||
|
<Link
|
||||||
|
href={`/sessions/${session.id}`}
|
||||||
|
target="_blank"
|
||||||
|
className="p-2 text-zinc-500 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={18} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredSessions.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">
|
||||||
|
<Calendar size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white mb-2">No Sessions Found</p>
|
||||||
|
<p className="text-sm text-zinc-500">No tasting sessions match your filters.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/app/admin/sessions/page.tsx
Normal file
133
src/app/admin/sessions/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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, Calendar, User, Users, GlassWater, Clock, CheckCircle } from 'lucide-react';
|
||||||
|
import AdminSessionsList from './AdminSessionsList';
|
||||||
|
|
||||||
|
export default async function AdminSessionsPage() {
|
||||||
|
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 sessions from all users
|
||||||
|
const { data: sessionsRaw, error } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
user_id,
|
||||||
|
scheduled_at,
|
||||||
|
ended_at,
|
||||||
|
created_at,
|
||||||
|
session_participants (id),
|
||||||
|
tastings (id)
|
||||||
|
`)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
// Get unique user IDs
|
||||||
|
const userIds = Array.from(new Set(sessionsRaw?.map(s => s.user_id) || []));
|
||||||
|
|
||||||
|
// Fetch profiles for users
|
||||||
|
const { data: profiles } = userIds.length > 0
|
||||||
|
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
|
// Combine sessions with user info
|
||||||
|
const sessions = sessionsRaw?.map(session => ({
|
||||||
|
...session,
|
||||||
|
user: profiles?.find(p => p.id === session.user_id) || { username: 'Unknown', display_name: null },
|
||||||
|
participantCount: (session.session_participants as any[])?.length || 0,
|
||||||
|
tastingCount: (session.tastings as any[])?.length || 0,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
totalSessions: sessions.length,
|
||||||
|
activeSessions: sessions.filter(s => !s.ended_at).length,
|
||||||
|
totalHosts: userIds.length,
|
||||||
|
totalParticipants: sessions.reduce((sum, s) => sum + s.participantCount, 0),
|
||||||
|
totalTastings: sessions.reduce((sum, s) => sum + s.tastingCount, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Tasting Sessions</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
View all tasting sessions 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 sessions: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 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">
|
||||||
|
<Calendar size={18} className="text-purple-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Sessions</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalSessions}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Clock size={18} className="text-green-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Active</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.activeSessions}</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">Hosts</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalHosts}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users size={18} className="text-orange-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Participants</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalParticipants}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<GlassWater size={18} className="text-yellow-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Tastings</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalTastings}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions List */}
|
||||||
|
<AdminSessionsList sessions={sessions} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user