- Fork elmeg-demo codebase for multi-band support - Add data importer infrastructure with base class - Create band-specific importers: - phish.py: Phish.net API v5 - grateful_dead.py: Grateful Stats API - setlistfm.py: Dead & Company, Billy Strings (Setlist.fm) - Add spec-kit configuration for Gemini - Update README with supported bands and architecture
149 lines
5.5 KiB
TypeScript
149 lines
5.5 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Trophy, Flame, Medal, Crown } from "lucide-react"
|
|
import { getApiUrl } from "@/lib/api-config"
|
|
import { motion } from "framer-motion"
|
|
|
|
interface LeaderboardEntry {
|
|
rank: number
|
|
username: string
|
|
xp: number
|
|
level: number
|
|
level_name: string
|
|
streak: number
|
|
}
|
|
|
|
const getRankIcon = (rank: number) => {
|
|
switch (rank) {
|
|
case 1:
|
|
return <Crown className="h-5 w-5 text-yellow-500" />
|
|
case 2:
|
|
return <Medal className="h-5 w-5 text-slate-400" />
|
|
case 3:
|
|
return <Medal className="h-5 w-5 text-amber-600" />
|
|
default:
|
|
return <span className="text-muted-foreground font-mono">#{rank}</span>
|
|
}
|
|
}
|
|
|
|
const getRankBg = (rank: number) => {
|
|
switch (rank) {
|
|
case 1:
|
|
return "bg-gradient-to-r from-yellow-500/20 to-amber-500/10 border-yellow-500/30"
|
|
case 2:
|
|
return "bg-gradient-to-r from-slate-400/20 to-slate-500/10 border-slate-400/30"
|
|
case 3:
|
|
return "bg-gradient-to-r from-amber-600/20 to-amber-700/10 border-amber-600/30"
|
|
default:
|
|
return "bg-muted/30"
|
|
}
|
|
}
|
|
|
|
export function XPLeaderboard() {
|
|
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
useEffect(() => {
|
|
fetchLeaderboard()
|
|
}, [])
|
|
|
|
const fetchLeaderboard = async () => {
|
|
try {
|
|
const res = await fetch(`${getApiUrl()}/gamification/leaderboard?limit=10`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setLeaderboard(data)
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch leaderboard", err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <div className="text-muted-foreground">Loading leaderboard...</div>
|
|
}
|
|
|
|
if (leaderboard.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Trophy className="h-5 w-5 text-yellow-500" />
|
|
XP Leaderboard
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-muted-foreground text-center py-4">
|
|
No rankings yet. Be the first!
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="bg-gradient-to-r from-yellow-500/10 via-primary/5 to-transparent">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Trophy className="h-5 w-5 text-yellow-500" />
|
|
XP Leaderboard
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-4">
|
|
<div className="space-y-2">
|
|
{leaderboard.map((entry, index) => (
|
|
<motion.div
|
|
key={entry.username}
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: index * 0.05 }}
|
|
className={`flex items-center gap-3 p-3 rounded-lg border ${getRankBg(entry.rank)}`}
|
|
>
|
|
<div className="w-8 flex justify-center">
|
|
{getRankIcon(entry.rank)}
|
|
</div>
|
|
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarFallback className="bg-primary/10 text-primary">
|
|
{entry.username.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium truncate">{entry.username}</span>
|
|
<Badge variant="outline" className="text-xs">
|
|
Lv.{entry.level}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{entry.level_name}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="text-right">
|
|
<div className="font-bold font-mono text-primary">
|
|
{entry.xp.toLocaleString()}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">XP</div>
|
|
</div>
|
|
|
|
{entry.streak > 0 && (
|
|
<div className="flex items-center gap-1 text-orange-500">
|
|
<Flame className="h-4 w-4" />
|
|
<span className="text-sm font-medium">{entry.streak}</span>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|