elmeg-demo/frontend/components/gamification/xp-leaderboard.tsx
fullsizemalt 5ffb428bb8 feat: Add gamification system
Backend:
- Add XP, level, streak fields to User model
- Add tier, category, xp_reward fields to Badge model
- Create gamification service with XP, levels, streaks, badge checking
- Add gamification router with level progress, leaderboard endpoints
- Define 16 badge types across attendance, ratings, social, milestones

Frontend:
- LevelProgressCard component with XP bar and streak display
- XPLeaderboard component showing top users
- Integrate level progress into profile page

Slug System:
- All entities now support slug-based URLs
- Performances use songslug-YYYY-MM-DD format
2025-12-21 18:58:42 -08:00

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>
)
}