feat(frontend): Revamp Leaderboards page with Heady Jams tab
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-21 01:57:43 -08:00
parent 6407c19a29
commit 97a7f50c93

View file

@ -2,53 +2,61 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import Link from "next/link" import Link from "next/link"
import { Star, MapPin, Music } from "lucide-react" import { Star, MapPin, Music, User, Trophy, Calendar, Sparkles } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
interface TopShow { interface TopShow {
show: { show: { id: number; date: string; venue_id: number }
id: number venue: { id: number; name: string; city: string; state: string }
date: string
venue_id: number
}
venue: {
id: number
name: string
city: string
state: string
}
avg_score: number avg_score: number
review_count: number review_count: number
} }
interface TopVenue { interface TopVenue {
venue: { venue: { id: number; name: string; city: string; state: string }
id: number
name: string
city: string
state: string
}
avg_score: number avg_score: number
review_count: number review_count: number
} }
interface TopPerformance {
performance: { id: number; show_id: number; song_id: number; notes: string | null }
song: { id: number; title: string; original_artist: string | null }
show: { id: number; date: string }
venue: { id: number; name: string; city: string; state: string }
avg_score: number
rating_count: number
}
interface TopUser {
profile: { id: number; user_id: number }
review_count: number
}
export default function LeaderboardsPage() { export default function LeaderboardsPage() {
const [topShows, setTopShows] = useState<TopShow[]>([]) const [topShows, setTopShows] = useState<TopShow[]>([])
const [topVenues, setTopVenues] = useState<TopVenue[]>([]) const [topVenues, setTopVenues] = useState<TopVenue[]>([])
const [topPerformances, setTopPerformances] = useState<TopPerformance[]>([])
const [topUsers, setTopUsers] = useState<TopUser[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [showsRes, venuesRes] = await Promise.all([ const [showsRes, venuesRes, perfsRes, usersRes] = await Promise.all([
fetch(`${getApiUrl()}/leaderboards/shows/top`), fetch(`${getApiUrl()}/leaderboards/shows/top`),
fetch(`${getApiUrl()}/leaderboards/venues/top`) fetch(`${getApiUrl()}/leaderboards/venues/top`),
fetch(`${getApiUrl()}/leaderboards/performances/top`),
fetch(`${getApiUrl()}/leaderboards/users/active`)
]) ])
setTopShows(await showsRes.json()) setTopShows(await showsRes.json())
setTopVenues(await venuesRes.json()) setTopVenues(await venuesRes.json())
setTopPerformances(await perfsRes.json())
setTopUsers(await usersRes.json())
} catch (error) { } catch (error) {
console.error("Failed to fetch leaderboards:", error) console.error("Failed to fetch leaderboards:", error)
} finally { } finally {
@ -59,89 +67,175 @@ export default function LeaderboardsPage() {
fetchData() fetchData()
}, []) }, [])
if (loading) return <div className="container py-10">Loading leaderboards...</div> const RankIcon = ({ rank }: { rank: number }) => {
if (rank === 1) return <Trophy className="h-5 w-5 text-yellow-500" />
if (rank === 2) return <Trophy className="h-4 w-4 text-gray-400" />
if (rank === 3) return <Trophy className="h-4 w-4 text-orange-400" />
return <span className="text-muted-foreground font-mono w-5 text-center">{rank}</span>
}
if (loading) {
return (
<div className="container py-10 flex items-center justify-center min-h-[50vh]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="text-muted-foreground animate-pulse">Computing Heady Stats...</p>
</div>
</div>
)
}
return ( return (
<div className="container py-10 space-y-8"> <div className="container py-10 space-y-8 max-w-5xl">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Leaderboards</h1> <h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
<p className="text-muted-foreground"> Leaderboards
Top rated shows and venues. </h1>
<p className="text-xl text-muted-foreground">
Discover the highest rated shows, legendary jams, and top contributors.
</p> </p>
</div> </div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-2"> <Tabs defaultValue="jams" className="w-full">
{/* Top Shows */} <TabsList className="grid w-full grid-cols-4 mb-8 h-12">
<Card> <TabsTrigger value="jams" className="text-base font-medium">Heady Jams</TabsTrigger>
<CardHeader> <TabsTrigger value="shows" className="text-base font-medium">Top Shows</TabsTrigger>
<CardTitle className="flex items-center gap-2"> <TabsTrigger value="venues" className="text-base font-medium">Venues</TabsTrigger>
<Music className="h-5 w-5 text-blue-500" /> <TabsTrigger value="users" className="text-base font-medium">Contributors</TabsTrigger>
Top Rated Shows </TabsList>
</CardTitle>
</CardHeader> {/* HEADY JAMS CONTENT */}
<CardContent> <TabsContent value="jams">
<div className="space-y-4"> <Card className="border-none shadow-none bg-transparent">
{topShows.map((item, i) => ( <div className="grid gap-4">
<div key={item.show.id} className="flex items-center justify-between"> {topPerformances.map((item, i) => (
<div className="flex items-center gap-3"> <div
<span className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${i === 0 ? "bg-yellow-100 text-yellow-700" : key={item.performance.id}
i === 1 ? "bg-gray-100 text-gray-700" : className="group relative flex items-center gap-4 rounded-xl border bg-card p-4 shadow-sm transition-all hover:shadow-md hover:border-primary/50"
i === 2 ? "bg-orange-100 text-orange-700" : "text-muted-foreground" >
}`}> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-secondary/50 font-bold text-xl">
{i + 1} <RankIcon rank={i + 1} />
</span>
<Link href={`/shows/${item.show.id}`} className="font-medium hover:underline block">
{new Date(item.show.date).toLocaleDateString()}
<span className="text-sm font-normal text-muted-foreground ml-2">
{item.venue?.name ? `${item.venue.name} (${item.venue.city}, ${item.venue.state})` : ""}
</span>
</Link>
</div> </div>
<div className="flex items-center gap-1 text-sm"> <div className="flex flex-1 flex-col justify-center gap-1">
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" /> <div className="flex items-center gap-2">
<span className="font-bold">{item.avg_score}</span> <Link href={`/songs/${item.song.id}`} className="font-semibold text-lg hover:underline decoration-primary decoration-2 underline-offset-2">
<span className="text-muted-foreground text-xs">({item.review_count})</span> {item.song.title}
</Link>
{item.performance.notes && (
<Badge variant="outline" className="text-[10px] font-normal hidden sm:inline-flex">
{item.performance.notes}
</Badge>
)}
</div>
<div className="flex items-center text-sm text-muted-foreground gap-2">
<Calendar className="h-3 w-3" />
<Link href={`/shows/${item.show.id}`} className="hover:text-primary transition-colors">
{new Date(item.show.date).toLocaleDateString(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric'
})}
</Link>
<span className="text-muted-foreground/50"></span>
<span className="truncate max-w-[200px]">{item.venue.name}</span>
</div>
</div>
<div className="flex flex-col items-end justify-center min-w-[80px]">
<div className="flex items-center gap-1.5">
<Star className="h-4 w-4 fill-yellow-500 text-yellow-500" />
<span className="text-xl font-bold">{item.avg_score.toFixed(2)}</span>
</div>
<span className="text-xs text-muted-foreground">{item.rating_count} votes</span>
</div> </div>
</div> </div>
))} ))}
{topPerformances.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
No ranked jams yet. Start rating performances!
</div>
)}
</div> </div>
</CardContent> </Card>
</Card> </TabsContent>
{/* Top Venues */} {/* TOP SHOWS CONTENT */}
<Card> <TabsContent value="shows">
<CardHeader> <div className="grid gap-4">
<CardTitle className="flex items-center gap-2"> {topShows.map((item, i) => (
<MapPin className="h-5 w-5 text-green-500" /> <div key={item.show.id} className="flex items-center justify-between p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors">
Top Venues <div className="flex items-center gap-4">
</CardTitle> <div className="font-mono text-muted-foreground w-6 text-center">{i + 1}</div>
</CardHeader> <div>
<CardContent> <Link href={`/shows/${item.show.id}`} className="font-medium text-lg hover:underline block">
<div className="space-y-4"> {new Date(item.show.date).toLocaleDateString(undefined, {
{topVenues.map((item, i) => ( year: 'numeric', month: 'long', day: 'numeric'
<div key={item.venue.id} className="flex items-center justify-between"> })}
<div className="flex items-center gap-3 overflow-hidden"> </Link>
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold ${i === 0 ? "bg-yellow-100 text-yellow-700" : <p className="text-sm text-muted-foreground">
i === 1 ? "bg-gray-100 text-gray-700" : {item.venue.name} {item.venue.city}, {item.venue.state}
i === 2 ? "bg-orange-100 text-orange-700" : "text-muted-foreground" </p>
}`}> </div>
{i + 1} </div>
</span> <div className="text-right">
<Link href={`/venues/${item.venue.id}`} className="font-medium hover:underline truncate"> <div className="font-bold text-lg flex items-center justify-end gap-1">
<Star className="h-4 w-4 fill-primary/20 text-primary" />
{item.avg_score.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">{item.review_count} ratings</div>
</div>
</div>
))}
</div>
</TabsContent>
{/* VENUES CONTENT */}
<TabsContent value="venues">
<div className="grid gap-4 md:grid-cols-2">
{topVenues.map((item, i) => (
<div key={item.venue.id} className="flex items-center justify-between p-4 rounded-lg border bg-card/50">
<div className="flex items-center gap-3 overflow-hidden">
<div className={`
flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold shrink-0
${i < 3 ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}
`}>
{i + 1}
</div>
<div>
<Link href={`/venues/${item.venue.id}`} className="font-medium hover:underline truncate block">
{item.venue.name} {item.venue.name}
</Link> </Link>
</div> <p className="text-xs text-muted-foreground truncate">
<div className="flex items-center gap-1 text-sm shrink-0"> {item.venue.city}, {item.venue.state}
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" /> </p>
<span className="font-bold">{item.avg_score}</span>
<span className="text-muted-foreground text-xs">({item.review_count})</span>
</div> </div>
</div> </div>
))} <Badge variant="secondary" className="gap-1 ml-2 shrink-0">
</div> <Star className="h-3 w-3 fill-current" />
</CardContent> {item.avg_score.toFixed(2)}
</Card> </Badge>
</div> </div>
))}
</div>
</TabsContent>
{/* USERS CONTENT */}
<TabsContent value="users">
<div className="grid gap-4 md:grid-cols-3">
{topUsers.map((item, i) => (
<Card key={item.profile.id} className="flex flex-col items-center justify-center p-6 text-center hover:border-primary/50 transition-colors">
<Avatar className="h-16 w-16 mb-4">
<AvatarImage src={`https://api.dicebear.com/7.x/notionists/svg?seed=${item.profile.user_id}`} />
<AvatarFallback>U</AvatarFallback>
</Avatar>
<div className="font-semibold text-lg">User {item.profile.user_id}</div>
<p className="text-sm text-muted-foreground mb-3">Top Contributor</p>
<Badge variant="outline" className="bg-primary/5">
{item.review_count} Reviews
</Badge>
</Card>
))}
{topUsers.length === 0 && <p className="col-span-3 text-center text-muted-foreground py-10">No active users yet.</p>}
</div>
</TabsContent>
</Tabs>
</div> </div>
) )
} }